Skip to content

perf(internal): add small buffer tiers and route put by capacity - #221

Closed
darakanoit wants to merge 3 commits into
roadrunner-server:masterfrom
darakanoit:perf/bpool-small-tiers
Closed

darakanoit wants to merge 3 commits into
roadrunner-server:masterfrom
darakanoit:perf/bpool-small-tiers

Conversation

@darakanoit

@darakanoit darakanoit commented Sep 22, 2026

Copy link
Copy Markdown

Reason for This PR

Follow-up to roadrunner-server/roadrunner#2395 and #220. Problem statement: roadrunner-server/roadrunner#2401. Two problems in the tiered buffer pool in internal/bpool.go, which since #220 serves both the send and the receive path.

The smallest tier is 1 MB. Every payload up to 1 MB, and every options read of 4 to 40 bytes, takes a 1 MB buffer while in flight. Resident memory grows with the number of workers, not with the size of the data. Measured with N goroutines each holding a buffer for a 200-byte payload, which is what N workers do while their responses are in flight:

Workers in receive master this PR
8 8.4 MB 0.03 MB
64 67.2 MB 0.3 MB
256 268.7 MB 1.3 MB

The memory survives one GC in the victim cache, so under steady load it is permanent. A pool miss also zeroes the whole tier: 37 µs for 1 MB on an M3.

put routes by the requested size, not by the buffer's capacity. A buffer above 10 MB is allocated with make in get, but put stores it in the 10 MB tier. From then on it is handed out for every 5 to 10 MB request and never released while the pool is warm. The default max_request_size in the http plugin is 1 GB, so a single large upload or response reaches this path. Reproduction on master: get(50 << 20), put(50 << 20, buf), then get(6 << 20) returns a buffer with cap == 50 MB.

Description of Changes

  • Add 4 KB, 16 KB, 64 KB and 256 KB tiers below the existing 1 MB, 5 MB and 10 MB. A request is served from the smallest tier that fits it.
  • Hold the tiers in an array indexed in parallel with a sorted tierSizes array instead of a sync.Map keyed by size. get is a scan over seven uint32 comparisons.
  • put(data) routes by cap(*data) and returns a buffer only to the tier whose size equals its capacity. Anything else, including an oversized make result, is dropped. This is the same rule as putDataBufferChunk in x/net/http2 and BinaryTieredBufferPool.Put in grpc-go, which both key on the buffer rather than on the request.
  • Tests moved from receive_test.go into bpool_test.go: every tier boundary, an oversized buffer never entering the 10 MB tier, a foreign slice never entering a tier, a 4 KB buffer returning to the 4 KB tier. The last three pin GOMAXPROCS(1) so the sync.Pool private slot makes the outcome deterministic. Ported onto master they fail on every run without the race detector.
  • Wire format, public API and the Preallocate contract are unchanged. Only internal is touched.

Benchmarks, BenchmarkReceivePath and BenchmarkSendPath, master and this branch interleaved over 5 rounds, benchstat over 10 samples each, M3:

Path master this PR Δ
ReceivePath/1KB 95.3 ns 71.5 ns −25% (p=0.000)
ReceivePath/64KB 2.02 µs 1.73 µs −14% (p=0.023)
ReceivePath/1MB 37.8 µs 37.5 µs ~
SendPath/1KB 115 ns 91.6 ns −20% (p=0.000)
SendPath/64KB 4.32 µs 4.29 µs ~
SendPath/1MB 56.3 µs 55.3 µs ~

Allocations per op are identical in every case. The gain on small frames is the sync.Map lookup and the two type assertions that are gone from get and put.

closes: roadrunner-server/roadrunner#2401

License Acceptance

By submitting this pull request, I confirm that my contribution is made under
the terms of the MIT license.

PR Checklist

  • All commits in this PR are signed (git commit -s) or (git commit -S).
  • The reason for this PR is clearly provided (issue no. or explanation).
  • The description of changes is clear and encompassing.
  • Any required documentation changes (code and docs) are included in this PR.
  • Any user-facing changes are mentioned in CHANGELOG.md.
  • All added/changed functionality is tested.

The buffer pool served every request up to 1 MB from the 1 MB tier, so a
200-byte payload held a 1 MB buffer while in flight. With 64 workers
receiving at once that is 64 MB of resident memory for a few kilobytes
of data, and every pool miss zeroes a full megabyte.

put routed a buffer by the requested size, not by its capacity. A buffer
above 10 MB is allocated with make and was stored in the 10 MB tier,
where it was handed out for every later request in that tier and never
released while the pool stayed warm.

Add 4 KB, 16 KB, 64 KB and 256 KB tiers below the existing 1 MB, 5 MB
and 10 MB ones, hold them in an array instead of a sync.Map, and route
put by the buffer's capacity: a buffer goes back only to the tier whose
size it has, anything else is dropped.

Signed-off-by: darakanoit <dara.kamaliev@gmail.com>
… over the tiers

Review note on roadrunner-server#222, which carries this file. The loop cost grew with
the tier: get plus put took 7.7 ns on the 4 KB tier, 10.0 ns on 64 KB
and 11.7 ns on 5 MB. With one pool per tier and a switch in both
directions the cost is flat at about 7.7 ns.

Signed-off-by: darakanoit <dara.kamaliev@gmail.com>
@codecov

codecov Bot commented Sep 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 78.37%. Comparing base (d1d60f5) to head (18b18f1).

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #221      +/-   ##
==========================================
- Coverage   78.56%   78.37%   -0.19%     
==========================================
  Files           9        9              
  Lines         695      689       -6     
==========================================
- Hits          546      540       -6     
  Misses        149      149              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@rustatian

Copy link
Copy Markdown
Member

Hey @darakanoit 👋🏻
I'd suggest you change the base branch and merge this PR into your PR #222, since they both update the same files.

@darakanoit

Copy link
Copy Markdown
Author

Makes sense, thanks. Closing this one; #222 already carries this change and its description now covers both.

@darakanoit darakanoit closed this Sep 23, 2026
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.

[🧹 CHORE]: goridge: buffer pool serves every payload from a 1 MB tier and pools oversized buffers

2 participants