Skip to content

Reduce per-request allocations on the request hot path#2696

Merged
dblock merged 1 commit into
masterfrom
perf/request-hot-path-polish
May 2, 2026
Merged

Reduce per-request allocations on the request hot path#2696
dblock merged 1 commit into
masterfrom
perf/request-hot-path-polish

Conversation

@ericproulx
Copy link
Copy Markdown
Contributor

@ericproulx ericproulx commented May 1, 2026

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_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 with attr_reader + 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. Drops 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 since they're immutable once initialized. One spec adjusted to construct a fresh middleware rather than mutate subject.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 @inherited_values.merge(@new_values) and use Hash#fetch with a fallback block. 1.55x faster lookup, zero allocation. Mostly helps boot/compile time (where ~30+ reads per endpoint hit this).
  • Grape::API::Instance — rename ROOT_PREFIX_VERSIONING_KEYROOT_PREFIX_VERSIONING_KEYS (holds 3 symbols); pair with zip instead of each_with_index + manual indexing.
  • Validations::Types::CustomTypeCoercer#infer_coercion_method — guard-clause refactor of nested if/else.
  • ErrorFormatter::Base.call — defer merge_backtrace / merge_original_exception lookups so the two options.dig(:rescue_options, ...) calls only run when wrap_message returned a Hash.

Throughput impact

benchmark/version_throughput/ (added in this PR) — single-threaded Benchmark.ips against BenchAPI.call(env) hitting /api/v1/hello with a small JSON response. Ruby 4.0.3, arm64-darwin25:

Variant No-YJIT (i/s) μs/req YJIT (i/s) μs/req
Grape 3.2.1 42,106 23.75 79,452 12.59
master (pre-PR, d234bfa5) 52,164 19.17 102,619 9.74
this PR (392bbd79) 57,677 17.34 105,588 9.47

Deltas:

Comparison No-YJIT YJIT
this PR vs master (pre-PR) +10.6% +2.9%
this PR vs 3.2.1 +37.0% +32.9%

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. See benchmark/version_throughput/README.md for methodology.

Test plan

  • bundle exec rspec — full suite green, no behavior changes
  • bundle exec rubocop on touched files — clean
  • CI green

🤖 Generated with Claude Code

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 1, 2026

Danger Report

No issues found.

View run

@ericproulx ericproulx force-pushed the perf/request-hot-path-polish branch 2 times, most recently from 312510b to 3bdba32 Compare May 1, 2026 13:01
@ericproulx ericproulx requested a review from dblock May 1, 2026 14:12
@ericproulx ericproulx marked this pull request as draft May 1, 2026 16:58
@ericproulx ericproulx marked this pull request as draft May 1, 2026 16:58
@ericproulx ericproulx marked this pull request as draft May 1, 2026 16:58
@ericproulx ericproulx force-pushed the perf/request-hot-path-polish branch 6 times, most recently from 3203eaf to d5d7dbb Compare May 1, 2026 17:32
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>
@ericproulx ericproulx force-pushed the perf/request-hot-path-polish branch from d5d7dbb to 392bbd7 Compare May 1, 2026 17:40
@ericproulx ericproulx marked this pull request as ready for review May 1, 2026 17:43
@dblock
Copy link
Copy Markdown
Member

dblock commented May 2, 2026

You're collecting quite a few valuable PRs for performance, time to publish an agent skill for optimizing ruby code based on it!

@dblock dblock merged commit 3e67562 into master May 2, 2026
80 checks passed
@dblock dblock deleted the perf/request-hot-path-polish branch May 2, 2026 15:07
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants