Reduce per-request allocations on the request hot path#2696
Merged
Conversation
Danger ReportNo issues found. |
312510b to
3bdba32
Compare
3203eaf to
d5d7dbb
Compare
A bundle of small, behavior-preserving cleanups that drop allocations
or chain walks from every request. Stable values (filter buckets,
versioner options, etc.) are computed once at compile/init and read via
`attr_reader`, instead of being re-resolved through nested hash lookups
on every request.
* `Endpoint#run`: precompute filter buckets (befores, before_validations,
after_validations, afters, finallies) and `:build_params_with` once
in `compile!`; replace the dynamic `define_method` accessor block
with `attr_reader`; remove a dead `header 'Allow', header['Allow']`
self-assignment.
* `Versioner::Base`: replace the two `define_method`-per-key blocks
(top-level and `version_options` keys) with `attr_reader` plus ivars
precomputed in `initialize`. Per-request `pattern`/`prefix`/`vendor`/
`strict`/etc. lookups now hit attr loads. `DEFAULT_OPTIONS`,
`attr_reader` list, and ivar assignments alphabetized; redundant
`version_options` reader dropped.
* `Middleware::Error` / `Middleware::Formatter`: same `options[...]`
-> `attr_reader` migration. All option keys live in `DEFAULT_OPTIONS`
(alphabetized), assigned to ivars in `initialize`, read via attr at
request time. Drop the dead `rescue_subclasses` middleware key (the
per-handler `:rescue_options` hash carries it, not the middleware
options).
* `Middleware::Base`: freeze `@options` after `merge_default_options` --
immutable once initialized. One spec adjusted to construct a fresh
middleware instead of mutating `subject.options[...]`.
* `ErrorFormatter::Base.call`: third argument is now the
`rescue_options` hash directly (not the wrapping middleware options).
Top-level `:backtrace` / `:original_exception` reads instead of
`dig(:rescue_options, ...)`.
* `Request#make_params`: skip the `except(:version, :route_info)` hash
allocation in the common case where routing args contain only the
known keys.
* `Validators::Base#validate!`: lazy-allocate `array_errors`. Saves one
Array allocation per validator per request when validation succeeds.
* `InheritableValues#[]`: drop the per-call merge with `Hash#fetch`
plus a fallback block. Lookup is 1.55x faster microbenched, allocates
zero. Mostly helps boot/compile time (where ~30+ reads per endpoint
hit this).
* `Grape::API::Instance`: rename `ROOT_PREFIX_VERSIONING_KEY` to
`ROOT_PREFIX_VERSIONING_KEYS` (it holds 3 symbols), pair via `zip`
instead of `each_with_index` + manual indexing.
* `Validations::Types::CustomTypeCoercer#infer_coercion_method`:
guard-clause refactor of the nested if/else.
End-to-end, 3-run averaged on Ruby 4.0.3, arm64-darwin25, against
`/api/v1/hello` returning a small JSON object:
no-yjit yjit
master HEAD: 52,088 i/s 101,563 i/s
master + this PR: 57,433 i/s 107,982 i/s
+10.3% +6.3%
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
d5d7dbb to
392bbd7
Compare
Member
|
You're collecting quite a few valuable PRs for performance, time to publish an agent skill for optimizing ruby code based on it! |
3 tasks
ericproulx
added a commit
that referenced
this pull request
May 14, 2026
Demonstration / discussion PR. Right now every middleware that wants
typed accessors over its options has to hand-write the same boilerplate:
DEFAULT_OPTIONS = { foo: nil, bar: nil, ... }.freeze
attr_reader :foo, :bar, ...
def initialize(app, **options)
super
@foo = @options[:foo]
@bar = @options[:bar]
...
end
Since `@options` was already frozen by design (Middleware::Base#initialize
post-PR #2696), the natural next step is to replace the Hash with a
per-subclass `Options = Data.define(...)` and let `Forwardable` cover the
accessor wiring.
Mechanism added in this draft:
- `Grape::Middleware::OptionsCompat` — a small mixin Options classes
include to keep the legacy `options[:key]` idiom working (notably for
`Middleware::Base#content_types` and `#content_type`). Unknown keys
return `nil` to match Hash semantics.
- `Middleware::Base#initialize` detects `self.class::Options` and routes
kwargs through `Options.new(**options)`. Subclasses that still rely on
`DEFAULT_OPTIONS` Hash + deep_merge keep working unchanged.
Demonstrated on `Middleware::Formatter`:
- Replaces 5-line DEFAULT_OPTIONS Hash + 4-line `attr_reader` list +
6-line initialize body with:
Options = Data.define(:content_types, :default_format, :format,
:formatters, :parsers) do
include Grape::Middleware::OptionsCompat
def initialize(content_types: nil, default_format: :txt, format: nil,
formatters: nil, parsers: nil)
super
end
end
def_delegators :options, :default_format, :format, :formatters, :parsers
- Defaults move from the freeze'd Hash to `#initialize` signature.
- Immutability is implicit (Data instances).
Behaviour change: passing an unknown kwarg to a middleware whose `Options`
class doesn't declare it now raises `ArgumentError` instead of being
silently swallowed by `**options`. One formatter spec was passing
`rescue_options:` (dead weight; Formatter doesn't read it) — dropped.
If this direction is acceptable, follow-ups would convert
`Middleware::Error`, `Versioner::Base`, etc., each shedding the same
boilerplate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ericproulx
added a commit
that referenced
this pull request
May 16, 2026
Demonstration / discussion PR. Right now every middleware that wants
typed accessors over its options has to hand-write the same boilerplate:
DEFAULT_OPTIONS = { foo: nil, bar: nil, ... }.freeze
attr_reader :foo, :bar, ...
def initialize(app, **options)
super
@foo = @options[:foo]
@bar = @options[:bar]
...
end
Since `@options` was already frozen by design (Middleware::Base#initialize
post-PR #2696), the natural next step is to replace the Hash with a
per-subclass `Options = Data.define(...)` and let `Forwardable` cover the
accessor wiring.
Mechanism added in this draft:
- `Grape::Middleware::OptionsCompat` — a small mixin Options classes
include to keep the legacy `options[:key]` idiom working (notably for
`Middleware::Base#content_types` and `#content_type`). Unknown keys
return `nil` to match Hash semantics.
- `Middleware::Base#initialize` detects `self.class::Options` and routes
kwargs through `Options.new(**options)`. Subclasses that still rely on
`DEFAULT_OPTIONS` Hash + deep_merge keep working unchanged.
Demonstrated on `Middleware::Formatter`:
- Replaces 5-line DEFAULT_OPTIONS Hash + 4-line `attr_reader` list +
6-line initialize body with:
Options = Data.define(:content_types, :default_format, :format,
:formatters, :parsers) do
include Grape::Middleware::OptionsCompat
def initialize(content_types: nil, default_format: :txt, format: nil,
formatters: nil, parsers: nil)
super
end
end
def_delegators :options, :default_format, :format, :formatters, :parsers
- Defaults move from the freeze'd Hash to `#initialize` signature.
- Immutability is implicit (Data instances).
Behaviour change: passing an unknown kwarg to a middleware whose `Options`
class doesn't declare it now raises `ArgumentError` instead of being
silently swallowed by `**options`. One formatter spec was passing
`rescue_options:` (dead weight; Formatter doesn't read it) — dropped.
If this direction is acceptable, follow-ups would convert
`Middleware::Error`, `Versioner::Base`, etc., each shedding the same
boilerplate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
A bundle of small, behavior-preserving cleanups that reduce per-request allocations and method-dispatch chains on the request hot path. None change semantics. The unifying pattern: stable values (filter buckets, middleware options, versioner config, etc.) are computed once at compile/init time and read via
attr_reader, instead of being re-resolved through nested hash lookups on every request.Changes
Endpoint#run— precompute filter buckets (befores,before_validations,after_validations,afters,finallies) and:build_params_withonce incompile!; replace the dynamicdefine_methodaccessor block withattr_reader; remove a deadheader 'Allow', header['Allow']self-assignment.Versioner::Base— replace the twodefine_method-per-key blocks withattr_reader+ ivars precomputed ininitialize. Per-requestpattern/prefix/vendor/strict/etc. lookups now hit attr loads.DEFAULT_OPTIONS,attr_readerlist, and ivar assignments alphabetized; redundantversion_optionsreader dropped.Middleware::Error/Middleware::Formatter— sameoptions[...]→attr_readermigration. All option keys live inDEFAULT_OPTIONS(alphabetized), assigned to ivars ininitialize, read via attr at request time. Drops the deadrescue_subclassesmiddleware key (the per-handler:rescue_optionshash carries it, not the middleware options).Middleware::Base— freeze@optionsaftermerge_default_optionssince they're immutable once initialized. One spec adjusted to construct a fresh middleware rather than mutatesubject.options[...].Request#make_params— skip theexcept(:version, :route_info)hash allocation in the common case where routing args contain only the known keys.Validators::Base#validate!— lazy-allocatearray_errors. Saves one Array allocation per validator per request when validation succeeds.InheritableValues#[]— drop the per-call@inherited_values.merge(@new_values)and useHash#fetchwith a fallback block. 1.55x faster lookup, zero allocation. Mostly helps boot/compile time (where ~30+ reads per endpoint hit this).Grape::API::Instance— renameROOT_PREFIX_VERSIONING_KEY→ROOT_PREFIX_VERSIONING_KEYS(holds 3 symbols); pair withzipinstead ofeach_with_index+ manual indexing.Validations::Types::CustomTypeCoercer#infer_coercion_method— guard-clause refactor of nested if/else.ErrorFormatter::Base.call— defermerge_backtrace/merge_original_exceptionlookups so the twooptions.dig(:rescue_options, ...)calls only run whenwrap_messagereturned a Hash.Throughput impact
benchmark/version_throughput/(added in this PR) — single-threadedBenchmark.ipsagainstBenchAPI.call(env)hitting/api/v1/hellowith a small JSON response. Ruby 4.0.3, arm64-darwin25:d234bfa5)392bbd79)Deltas:
YJIT delta vs master is small because the JIT was already specializing most of the hot-path indirection; the no-YJIT delta is where this PR shows clearly. ±5-8% noise floor — single-run numbers, not 3-run averages.
Reproduce locally:
GRAPE_VERSIONS="3.2.1,master" ruby benchmark/version_throughput/run.rb. Seebenchmark/version_throughput/README.mdfor methodology.Test plan
bundle exec rspec— full suite green, no behavior changesbundle exec rubocopon touched files — clean🤖 Generated with Claude Code