Skip to content

perf: Reduce allocations in Client.Do response decoding with a buffer pool - #4494

Open
JamBalaya56562 wants to merge 4 commits into
google:masterfrom
JamBalaya56562:perf/4195-pooled-response-decoding
Open

perf: Reduce allocations in Client.Do response decoding with a buffer pool#4494
JamBalaya56562 wants to merge 4 commits into
google:masterfrom
JamBalaya56562:perf/4195-pooled-response-decoding

Conversation

@JamBalaya56562

@JamBalaya56562 JamBalaya56562 commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Supersedes #4195all credit for the idea and the core implementation goes to @merchantmoh-debug. That PR reached a shape the maintainers agreed on (scoped strictly to Client.Do decoding, with the NewRequest pooling stripped per the review discussion), but has been inactive for about three and a half months with only the coverage gap left, so this picks it up and carries it over the line on top of the current master.

What it does

Replaces the per-call json.NewDecoder(resp.Body).Decode(v) in Client.Do with a read into a pooled bytes.Buffer followed by json.Unmarshal. The streaming decoder's internal buffering allocated a geometrically growing buffer per response; the pool amortizes that across calls. Buffers whose capacity grew beyond 1MB while reading an unusually large response are dropped instead of pooled, so they cannot pin memory.

Benchmarks

BenchmarkDo runs through the real Client.Do against a stubbed transport; the baseline is the same benchmark run on master (34cec4ff). benchstat, n=8:

master B/op this PR B/op master allocs/op this PR allocs/op
Do/1KB 4.758Ki ± 0% 2.580Ki ± 0% (−45.8%) 23 20 (−13.0%)
Do/500KB 1527.8Ki ± 0% 530.2Ki ± 3% (−65.3%) 31 20 (−35.5%)

sec/op improved as well (−70.7% / −82.9%, p=0.000), though the baseline runs had high variance on the measurement machine (±56% / ±122%), so the allocation metrics above are the reliable signal.

Semantics

What's new relative to #4195

Verified with go build ./..., gofmt, the full ./github/ test suite, the benchmarks above, and custom-gcl (no findings on the changed code).

cc @merchantmoh-debug — hope you don't mind me carrying this forward; happy to hand it back if you'd like to finish it yourself.

…er pool

Replace the per-call json.NewDecoder streaming decode with a pooled
bytes.Buffer read followed by json.Unmarshal. io.ReadAll-style decoding
allocated a geometrically growing buffer per response (about 1.57MB for a
500KB payload); reading into a pooled buffer cuts that to about 0.62MB
and 13 allocations. Empty and whitespace-only bodies keep returning nil,
matching the previous io.EOF handling.

Original work by @merchantmoh-debug in google#4195, scoped to Client.Do
decoding only as agreed with the maintainers there.
Add tests for the io.Writer path (success and write error), invalid
JSON, whitespace-only bodies and the nil-v no-op, covering the decoding
branches touched by the pooled-buffer change.

Test cases provided by @gmlewis in the google#4195 review, adjusted to satisfy
the current linters (extraneousnew, fmtpercentv, revive).
@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.53%. Comparing base (34cec4f) to head (7e43661).

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #4494   +/-   ##
=======================================
  Coverage   98.53%   98.53%           
=======================================
  Files         195      195           
  Lines       17691    17704   +13     
=======================================
+ Hits        17431    17444   +13     
  Misses        260      260           

☔ 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.

@alexandear alexandear left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My comments are the same as in #4195.

Replace the decode-helper benchmarks with a single BenchmarkDo that runs
through Client.Do against a stubbed transport, so the measured numbers
reflect the production code path instead of a re-implementation. The
old-behavior baseline comes from running the same benchmark on master.

Addresses review feedback from @alexandear in google#4195.
Cap the capacity of buffers returned to requestBufferPool at 1MB so an
occasional very large response cannot pin memory in the pool. Add tests
for the cap, for large-then-small body reuse, and for response body read
errors.

Addresses review feedback from @alexandear in google#4195.
@JamBalaya56562

Copy link
Copy Markdown
Contributor Author

@alexandear Addressed all three in 56f55d4 and 7e43661:

  • Benchmark accuracy / running through Client.Do: the decode-helper benchmarks are gone. BenchmarkDo now runs through the real Client.Do against a stubbed transport, using b.Loop, and the old-behavior baseline is the same benchmark run on master (34cec4f) — so both sides measure the actual production path (json.NewDecoder before, pooled json.Unmarshal after). benchstat:

               │    master     │              this PR                │
               │    sec/op     │    sec/op     vs base               │
    Do/1KB      122.10µ ±  56%   35.76µ ± 12%  -70.71% (p=0.000 n=8)
    Do/500KB    55.285m ± 122%   9.436m ±  7%  -82.93% (p=0.000 n=8)
    
               │     B/op      │
    Do/1KB      4.758Ki  ±  0%   2.580Ki ± 0%  -45.77% (p=0.000 n=8)
    Do/500KB    1527.8Ki ±  0%   530.2Ki ± 3%  -65.30% (p=0.000 n=8)
    
               │   allocs/op   │
    Do/1KB      23.00 ± 0%       20.00 ± 0%    -13.04% (p=0.000 n=8)
    Do/500KB    31.00 ± 0%       20.00 ± 0%    -35.48% (p=0.000 n=8)
    

    The baseline sec/op runs had high variance on my measurement machine, so I would treat B/op and allocs/op as the reliable signal here.

  • Pool-specific edge risks: buffers whose capacity grew beyond 1MB while reading an unusually large response are now dropped instead of returned to the pool (putRequestBuffer / maxPooledBufferCap), so occasional large responses cannot pin memory. New tests cover the cap itself (TestPutRequestBuffer), a 2MB-body-then-small-body sequence through Client.Do (TestDo_largeThenSmallBody), and response body read errors (TestDo_readError) — the last one also closes the codecov/patch gap.

On the broader question of whether this is worth shipping at all: the numbers above are now measured on the production path rather than a re-implementation, which is the most concrete data I can offer; I'll leave that call to the maintainers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

NeedsReview PR is awaiting a review before merging.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants