Avoid per-entry array allocation in Request#build_headers#2770
Merged
Conversation
61d32f8 to
ad631db
Compare
Danger ReportNo issues found. |
`build_headers` walked the env with `each_header.with_object(...)`. Because `Enumerator#with_object` hands the block a single value plus the memo, the two values `each_header` yields (`k`, `v`) get boxed into a throwaway `[k, v]` Array on every iteration — which the `|(k, v), headers|` destructure then immediately unpacks. That is one array allocated, packed, destructured, and discarded per request header. Drop `with_object` for a plain `each_header do |k, v|` block writing into a pre-built `Grape::Util::Header`. `k` and `v` arrive as separate block args (normal multi-value yield), so no array is boxed, and the accumulator is just a closed-over local. Output is byte-identical (`each_header` is `Rack::Request::Env#each_header`, i.e. full env iteration; the `HTTP_` filter is unchanged). `build_headers` runs lazily, only when an endpoint reads `headers`. Measured on a request with ~30 headers: ~43% fewer objects (37 -> 21 per call) and ~1.36x faster, with the array packing — not the Enumerator object itself — accounting for the entire gap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ad631db to
73eb9a8
Compare
dblock
approved these changes
Jun 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Grape::Request#build_headersbuilds the requestheadershash by walking the Rack env. It did this witheach_header.with_object(...), which allocates a throwaway[k, v]Array for every header on the request. This PR dropswith_objectfor a plain block, eliminating that per-entry allocation.This came out of a
memory_profilerrun against a grape-on-rack app, wherebuild_headerswas the single highest object-count allocation site (~24k objects in the sampled run).The problem
Enumerator#with_objectyields the block a fixed shape — one value plus the memo:{ |value, memo| }. Buteach_headerproduces two values per iteration (k,v). To fit two values into that singlevalueslot, Ruby boxes them into a fresh[k, v]Array each iteration — which is exactly what|(k, v), headers|then destructures back apart. So every header does: allocate an array → pass it → destructure it → discard it → GC it. Pure overhead, once per request header.The Enumerator object itself is not the cost — isolating it (
enum.each { |k, v| }, no packing) is within ~5% of a direct block. The array packing accounts for essentially the entire gap.The fix
each_header { |k, v| }passeskandvas separate block arguments (normal multi-value yield) — no array boxing — and the accumulator becomes a plain closed-over local, sowith_object's memo-threading is no longer needed.Correctness
Output is byte-identical.
each_headerisRack::Request::Env#each_header(full env iteration), and theHTTP_-prefix filter plusKNOWN_HEADERS/fallback transform are unchanged.build_headersis still lazy — it only runs when an endpoint readsheaders(@headers ||= build_headers).Measurements
On a request with ~30 headers (
benchmark/ips+GC.statobject counts, Ruby 4.0.5):each_header.with_object(before)each_header { |k, v| }(after)~43% fewer objects and ~1.36x faster, with no behavior change.