Skip to content

Strip the versioner's prefixes with each instead of reduce - #2882

Open
ericproulx wants to merge 1 commit into
masterfrom
perf/versioner-prefix-each
Open

Strip the versioner's prefixes with each instead of reduce#2882
ericproulx wants to merge 1 commit into
masterfrom
perf/versioner-prefix-each

Conversation

@ericproulx

Copy link
Copy Markdown
Contributor

Versioner::Path#before runs on every request of a versioned API and takes the mount path and prefix off the front of PATH_INFO before looking for a version segment:

path_info = @prefixes.reduce(path_info) do |pi, path|
  pi.start_with?(path) ? pi.delete_prefix(path) : pi
end

@prefixes holds at most two entries — a mount path and a prefix — and for an API with neither it is empty. Array does not override Enumerable#reduce, so that list pays the generic accumulator path on every request, which costs more than the work it is driving. In a stackprof run it was 30% of before.

each over the same list, assigning as it goes, is the same operation without it.

Measurements

Same-process A/B on the operation:

@prefixes reduce each
one prefix 205 ns 89 ns 2.31x
empty 102 ns 25 ns 4.02x

It allocates less too, which the timing alone does not show — 3 objects against 1 for identical work, the extra two being Enumerable machinery rather than anything the stripping needs:

reduce  3.0 objects
each    1.0 objects

End to end, median of 9 runs interleaved with master's, Ruby 4.0.5 + YJIT:

master this branch objects/req
small path-versioned API 166,376 170,079 +2.2% 36.1 → 34.1

An API that declares no version never builds this middleware and is untouched.

Behaviour

Identical: the same prefixes are removed, in the same order, under the same start_with? guard. each returns the receiver rather than the accumulator, so the stripped value is assigned to the local as it goes.

Touches the same file as #2880, but a different method (before vs version_from_first_segment) — they merge cleanly in either order.

🤖 Generated with Claude Code

`Versioner::Path#before` runs on every request of a versioned API and takes the
mount path and prefix off the front of PATH_INFO before looking for a version
segment:

    path_info = @prefixes.reduce(path_info) do |pi, path|
      pi.start_with?(path) ? pi.delete_prefix(path) : pi
    end

`@prefixes` holds at most two entries — a mount path and a prefix — and for an
API with neither it is empty. `Array` does not override `Enumerable#reduce`, so
that list pays the generic accumulator path on every request, which costs more
than the work it is driving.

`each` over the same list, assigning as it goes, is the same operation without
it. Measured in one process:

    one prefix    reduce 205 ns   ->   each  89 ns    2.31x
    no prefix     reduce 102 ns   ->   each  25 ns    4.02x

It allocates less too, which the timing alone does not show — 3 objects against
1 for identical work, the extra two being the Enumerable machinery rather than
anything the stripping needs:

    reduce  3.0 objects
    each    1.0 objects

End to end that is 166,376 -> 170,079 req/s on a small path-versioned API
(+2.2%, median of 9 runs interleaved with master's, Ruby 4.0.5 with YJIT), and
two fewer objects allocated per request. An API that declares no version never
builds this middleware and is untouched.

The result is byte-identical: the same prefixes are removed, in the same order,
under the same `start_with?` guard.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@ericproulx
ericproulx force-pushed the perf/versioner-prefix-each branch from 3db482e to e73118c Compare September 1, 2026 20:53
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

Danger Report

No issues found.

View run

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