Skip to content

perf: forward-only cursor in monotonicArena.Alloc - #3

Merged
ysmolski merged 4 commits into
mainfrom
perf/monotonic-arena-alloc-cursor
Jun 11, 2026
Merged

perf: forward-only cursor in monotonicArena.Alloc#3
ysmolski merged 4 commits into
mainfrom
perf/monotonic-arena-alloc-cursor

Conversation

@jensneuse

@jensneuse jensneuse commented May 6, 2026

Copy link
Copy Markdown
Member

Summary

monotonicArena.Alloc walked a.buffers from index 0 on every call,
giving O(numBuffers) cost per call and O(N²) total work over the
arena's lifetime.
On the Cosmo Router workload reported in the issue (~180MB JSON
response, ~600-1200 buffers, ~29M Alloc calls per request),
this manifested as ~40s of router-side merge time.

This PR adds a forward-only cursor.
Subsequent walks start at cursor instead of 0.
Cursor advances on a later-buffer hit and on grow.
Reset and Release rewind it to 0 so a reused arena can re-fill
its early buffers from scratch.

For uniform-size allocations the per-call cost becomes O(1).
For mixed sizes the walk is bounded by buffers ahead of the cursor,
with the trade-off that any remaining free space in skipped buffers is
abandoned for the rest of the request.

Benchmarks

Controlled prefix (isolated walk cost):

Prefix Before After Speedup
10 17.5 ns/op 2.7 ns/op 6.5x
100 149 ns/op 2.6 ns/op 57x
1000 1293 ns/op 2.6 ns/op 497x

Pre-fix: clean O(N) scaling.
Post-fix: flat O(1) regardless of prefix size.

Realistic growth workload (AllocCosmoLike):

Prefix Before After Speedup
10 5125 ns/op 3.4 ns/op 1500x
100 5265 ns/op 4.0 ns/op 1300x
1000 2785 ns/op 4.0 ns/op 700x

The realistic workload speedup is larger because the unpatched arena
grows during the timed loop, so the prefix walk gets longer over time.
This is consistent with the reporter's measurement of ~3x end-to-end
on the full Cosmo Router request, where Alloc was the dominant cost.

What changed

Credit

Original analysis and patch by @thoec in #2.
This PR adapts the patch and adds the test/benchmark coverage.

Test plan

  • go test -race ./... passes
  • All 7 new cursor tests pass
  • Benchmarks confirm O(N)O(1) scaling

Fixes #2
FIxes ENG-9512

jensneuse and others added 4 commits May 6, 2026 22:47
Alloc walked a.buffers from index 0 on every call, giving O(numBuffers)
cost per Alloc and O(N²) total work over an arena's lifetime. On the
Cosmo Router workload reported in #2 (~180MB JSON response, ~600-1200
buffers, ~29M Allocs per request), this dominated request time at ~40s
of router-side merge.

Track the index of the most recent successful Alloc and start subsequent
walks there. Cursor advances on a later-buffer hit and on grow; Reset
and Release rewind it to 0 so a reused arena can re-fill its early
buffers from scratch. For roughly uniform-size allocations the per-call
cost becomes O(1); for mixed sizes the walk is bounded by the number of
buffers ahead of the cursor.

Benchmarks (controlled prefix, isolated walk cost):
  prefix=10:    17.5 ns/op  →  2.7 ns/op  (6.5x)
  prefix=100:    149 ns/op  →  2.6 ns/op  (57x)
  prefix=1000:  1293 ns/op  →  2.6 ns/op  (497x)

Realistic growth workload (AllocCosmoLike):
  prefix=10:    5125 ns/op  →  3.4 ns/op  (1500x)
  prefix=100:   5265 ns/op  →  4.0 ns/op  (1300x)
  prefix=1000:  2785 ns/op  →  4.0 ns/op  (700x)

Closes #2.

Credit: original analysis and patch by @thoec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ysmolski

Copy link
Copy Markdown
Contributor

I have checked it against the engine integrational benchmarks. I don't see any increase in the memory consuption.

ys@work:~/d/cosmo/router-tests/protocol$ benchstat old new
goos: darwin
goarch: arm64
pkg: github.com/wundergraph/cosmo/router-tests/protocol
cpu: Apple M4 Max
                                     │     old      │                  new                  │
                                     │    sec/op    │    sec/op      vs base                │
ConfigHotReload-14                     8.989m ± 61%    8.583m ± 58%        ~ (p=0.529 n=10)
Sequential-14                          154.9µ ± 11%    149.6µ ±  5%        ~ (p=0.218 n=10)
Parallel-14                            17.67µ ±  2%    17.63µ ±  1%        ~ (p=0.353 n=10)
ParallelWithMinify-14                  94.25µ ± 12%   101.89µ ± 14%        ~ (p=0.579 n=10)
ParallelBig-14                         59.34µ ±  4%    62.30µ ±  6%        ~ (p=0.089 n=10)
SequentialBig-14                       726.7µ ± 11%    656.2µ ±  8%        ~ (p=0.105 n=10)
SequentialBigCostControl-14            733.0µ ±  9%    685.6µ ±  9%        ~ (p=0.393 n=10)
SequentialBigNotCached-14              1.301m ±  7%    1.263m ± 13%        ~ (p=0.436 n=10)
SequentialBigNotCachedCostControl-14   1.513m ±  2%    1.275m ±  4%  -15.72% (p=0.000 n=10)
ParallelCostControl-14                 63.73µ ±  3%    58.84µ ±  4%   -7.68% (p=0.000 n=10)
geomean                                313.6µ          301.1µ         -3.98%

                                     │     old      │                 new                 │
                                     │     B/op     │     B/op      vs base               │
ConfigHotReload-14                     39.62Mi ± 0%   39.63Mi ± 0%       ~ (p=0.315 n=10)
Sequential-14                          66.14Ki ± 0%   66.12Ki ± 0%       ~ (p=0.424 n=10)
Parallel-14                            32.77Ki ± 0%   32.85Ki ± 0%       ~ (p=0.052 n=10)
ParallelWithMinify-14                  204.8Ki ± 0%   204.8Ki ± 0%       ~ (p=0.393 n=10)
ParallelBig-14                         104.0Ki ± 1%   106.9Ki ± 2%  +2.73% (p=0.000 n=10)
SequentialBig-14                       460.5Ki ± 0%   460.4Ki ± 0%       ~ (p=0.353 n=10)
SequentialBigCostControl-14            462.0Ki ± 0%   461.9Ki ± 0%       ~ (p=0.699 n=10)
SequentialBigNotCached-14              949.5Ki ± 0%   950.8Ki ± 0%  +0.13% (p=0.001 n=10)
SequentialBigNotCachedCostControl-14   958.7Ki ± 0%   959.5Ki ± 0%  +0.08% (p=0.004 n=10)
ParallelCostControl-14                 104.8Ki ± 1%   106.7Ki ± 2%  +1.80% (p=0.000 n=10)
geomean                                361.4Ki        363.2Ki       +0.49%

                                     │     old     │                 new                  │
                                     │  allocs/op  │  allocs/op   vs base                 │
ConfigHotReload-14                     47.31k ± 0%   47.31k ± 0%       ~ (p=0.894 n=10)
Sequential-14                           908.0 ± 0%    908.0 ± 0%       ~ (p=1.000 n=10) ¹
Parallel-14                             436.0 ± 0%    436.0 ± 0%       ~ (p=0.700 n=10)
ParallelWithMinify-14                  1.976k ± 0%   1.972k ± 0%       ~ (p=0.986 n=10)
ParallelBig-14                         1.383k ± 2%   1.423k ± 2%  +2.86% (p=0.000 n=10)
SequentialBig-14                       6.603k ± 0%   6.603k ± 0%       ~ (p=1.000 n=10)
SequentialBigCostControl-14            6.620k ± 0%   6.620k ± 0%       ~ (p=0.650 n=10)
SequentialBigNotCached-14              11.04k ± 0%   11.06k ± 0%  +0.16% (p=0.000 n=10)
SequentialBigNotCachedCostControl-14   11.17k ± 0%   11.18k ± 0%  +0.16% (p=0.000 n=10)
ParallelCostControl-14                 1.390k ± 1%   1.421k ± 2%  +2.27% (p=0.000 n=10)
geomean                                3.617k        3.636k       +0.52%
¹ all samples are equal

                                     │      old      │                  new                  │
                                     │      B/s      │      B/s       vs base                │
Sequential-14                          493.2Ki ±  9%   512.7Ki ±  5%        ~ (p=0.230 n=10)
Parallel-14                            4.206Mi ±  2%   4.220Mi ±  1%        ~ (p=0.305 n=10)
ParallelWithMinify-14                  40.68Mi ± 11%   37.63Mi ± 16%        ~ (p=0.579 n=10)
ParallelBig-14                         54.40Mi ±  5%   51.85Mi ±  7%        ~ (p=0.089 n=10)
SequentialBig-14                       4.439Mi ± 13%   4.916Mi ±  7%        ~ (p=0.101 n=10)
SequentialBigCostControl-14            4.411Mi ±  9%   4.706Mi ±  8%        ~ (p=0.382 n=10)
SequentialBigNotCached-14              2.484Mi ±  7%   2.556Mi ± 12%        ~ (p=0.424 n=10)
SequentialBigNotCachedCostControl-14   2.131Mi ±  2%   2.532Mi ±  5%  +18.79% (p=0.000 n=10)
ParallelCostControl-14                 50.64Mi ±  3%   54.85Mi ±  3%   +8.32% (p=0.000 n=10)
geomean                                6.593Mi         6.865Mi         +4.12%

@ysmolski

Copy link
Copy Markdown
Contributor

I have created a big-response benchmark in cosmo to measure how it performs with big responses.
Old:

    iteration_duration.............: avg=7.87s min=7.34s med=7.97s max=8.22s p(90)=8.19s p(95)=8.2s
    iterations.....................: 4      0.126957/s
    data_received..................: 2.4 GB 76 MB/s
    data_sent......................: 5.2 kB 165 B/s

New:

    iteration_duration.............: avg=4.88s min=4.27s med=4.64s max=5.52s p(90)=5.49s p(95)=5.51s
    iterations.....................: 7      0.204882/s
    data_received..................: 4.2 GB 123 MB/s
    data_sent......................: 9.1 kB 267 B/s

Peak Heap Allocated: grew from 8.1 GB to 10.5 GB. But again, when I run prolonged requests of this kind it stabilized both on old and new version of go-arena.

@ysmolski

ysmolski commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

bench.js. It did not change picture for small-ish requests.
old:

    EXECUTION
    iteration_duration.............: avg=2.1ms  min=227.2µs med=1.87ms max=13.53ms p(90)=3.54ms p(95)=4.09ms
    iterations.....................: 1023136 20462.102045/s
    vus............................: 99      min=2            max=99
    vus_max........................: 100     min=100          max=100
    NETWORK
    data_received..................: 7.3 GB  147 MB/s
    data_sent......................: 2.8 GB  57 MB/s

new:

    EXECUTION
    iteration_duration.............: avg=2.11ms min=236.04µs med=1.89ms max=11.37ms p(90)=3.56ms p(95)=4.1ms
    iterations.....................: 1016307 20325.487958/s
    vus............................: 99      min=2            max=99
    vus_max........................: 100     min=100          max=100
    NETWORK
    data_received..................: 7.3 GB  146 MB/s
    data_sent......................: 2.8 GB  57 MB/s

Peak Heap Size grew from 134 MB to 138 MB. Means that heap stayed the same

@ysmolski
ysmolski merged commit 0c5b910 into main Jun 11, 2026
@ysmolski
ysmolski deleted the perf/monotonic-arena-alloc-cursor branch June 11, 2026 10:39
ysmolski added a commit to wundergraph/graphql-go-tools that referenced this pull request Jun 11, 2026
That version includes following fixes:

* forward-only cursor in monotonicArena.Alloc by @jensneuse
* harden arena hot paths and buffer semantics

Cursor PR has extensive benchmarks for The Cosmo Router:

wundergraph/go-arena#3
ysmolski added a commit to wundergraph/graphql-go-tools that referenced this pull request Jun 11, 2026
That version includes following fixes:

* forward-only cursor in monotonicArena.Alloc by @jensneuse
* harden arena hot paths and buffer semantics

Cursor PR has extensive benchmarks for The Cosmo Router:

wundergraph/go-arena#3
ysmolski added a commit to wundergraph/cosmo that referenced this pull request Jun 11, 2026
This will result in faster handling of big responses. 
Bumped go-arena includes following fixes:

    * forward-only cursor in monotonicArena.Alloc by @jensneuse
    * harden arena hot paths and buffer semantics

Cursor's PR has extensive benchmarks for The Cosmo Router:
wundergraph/go-arena#3
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.

monotonicArena.Alloc scales poorly on large subgraph responses in Cosmo Router

2 participants