Skip to content

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

Open
ericproulx wants to merge 1 commit intomasterfrom
perf/request-hot-path-polish
Open

Reduce per-request allocations on the request hot path#2696
ericproulx wants to merge 1 commit intomasterfrom
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 — existing specs pass unchanged. The unifying pattern: stable values (filter buckets, versioner options, 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 — same shape: 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.
  • 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.

Throughput impact

3-run averaged on Ruby 4.0.3, arm64-darwin25, against /api/v1/hello returning a small JSON object:

Configuration No-YJIT (i/s) YJIT (i/s)
master HEAD only 52,088 101,563
master + this PR 57,433 107,982
Delta +10.3% +6.3%

The relative gain compresses under YJIT (10% → 6%) since the JIT was already specializing some of the hot-path indirection; the absolute gain stays at ~+6.4k req/s either way.

Test plan

  • bundle exec rspec — full suite green (2236 examples, 0 failures), no test changes needed.
  • 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 from 4c86825 to 312510b Compare May 1, 2026 12:22
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.

* `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 312510b to 3bdba32 Compare May 1, 2026 13:01
@ericproulx ericproulx requested a review from dblock May 1, 2026 14:12
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.

1 participant