From a416bda5f3a17f2b4a0a6eaee05cdc7cb95075bf Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Sun, 10 May 2026 02:45:14 +0100 Subject: [PATCH] perf(client): pool *bytes.Buffer for response body reads Replace io.ReadAll(resp.Body) on the typed Call/callMultipart paths with a sync.Pool-backed bytes.Buffer + ReadFrom. Saves the 512B initial allocation that ReadAll grows from on every successful call. The pool only covers paths whose decoder copies strings out of the input (decodeResult delegates to goccy/go-json, which copies). CallRaw and callMultipartRaw return slices that alias the buffer storage, so they keep the io.ReadAll path; pooling there would need a defensive copy that defeats the saving. putRespBuf caps Cap() at 64 KiB before returning to the pool so a single oversized response (e.g. large getFile metadata) doesn't bloat the pool for the rest of the process. Bench delta on Call_BoolResponse: 14 allocs -> 13 allocs, 1842B -> 1331B, 526ns -> 479ns. Same shape on Call_StructResponse (16 -> 15, 1973B -> 1462B). --- .benchstats/after_step4.txt | 26 ++++++++++++++++++++++++++ client/call.go | 30 +++++++++++++++++++++++++++--- client/multipart.go | 9 ++++++--- 3 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 .benchstats/after_step4.txt diff --git a/.benchstats/after_step4.txt b/.benchstats/after_step4.txt new file mode 100644 index 0000000..1ecc3b1 --- /dev/null +++ b/.benchstats/after_step4.txt @@ -0,0 +1,26 @@ +After: static-headers + bool-fast-path + lazy-Values + typed-fields + resp-buffer-pool +goos: darwin +goarch: arm64 +cpu: Apple M4 Max + +pkg: github.com/lukaszraczylo/go-telegram/client +BenchmarkCall_BoolResponse-16 4811347 478.7 ns/op 1331 B/op 13 allocs/op +BenchmarkCall_StructResponse-16 4038770 591.6 ns/op 1462 B/op 15 allocs/op +BenchmarkEncodeJSONBody-16 47025052 51.30 ns/op 96 B/op 2 allocs/op +BenchmarkDecodeResult_Bool-16 853161562 2.824 ns/op 0 B/op 0 allocs/op +BenchmarkDecodeResult_Struct-16 26811634 88.80 ns/op 144 B/op 2 allocs/op + +pkg: github.com/lukaszraczylo/go-telegram/dispatch +BenchmarkRouter_DispatchCommand-16 34631486 69.19 ns/op 96 B/op 1 allocs/op +BenchmarkRouter_DispatchTextRegex-16 23260198 106.6 ns/op 112 B/op 2 allocs/op +BenchmarkRouter_DispatchFilter-16 126697654 19.03 ns/op 96 B/op 1 allocs/op +BenchmarkRouter_NewContext-16 1000000000 1.600 ns/op 0 B/op 0 allocs/op +BenchmarkExtractCommand-16 27345622 87.25 ns/op 0 B/op 0 allocs/op + +Cumulative deltas vs baseline: + Call_BoolResponse: 634ns / 18 allocs / 1957B -> 479ns / 13 allocs / 1331B (-24% / -5 / -626B) + Call_StructResponse: 665ns / 18 allocs / 2005B -> 592ns / 15 allocs / 1462B (-11% / -3 / -543B) + DecodeResult_Bool: 50ns / 2 allocs / 80B -> 2.8ns / 0 allocs / 0B + DispatchCommand: 153ns / 5 allocs / 416B -> 69ns / 1 alloc / 96B (-55% / -4 / -320B) + DispatchTextRegex: 181ns / 5 allocs / 428B -> 107ns / 2 allocs / 112B (-41% / -3 / -316B) + DispatchFilter: 32ns / 2 allocs / 96B -> 19ns / 1 alloc / 96B (-41% / -1) diff --git a/client/call.go b/client/call.go index 76a173a..1b6f3ca 100644 --- a/client/call.go +++ b/client/call.go @@ -8,14 +8,36 @@ import ( "io" "net/http" "reflect" + "sync" ) var ( headerJSONValue = []string{"application/json"} rawOKTrueBody = []byte(`{"ok":true,"result":true}`) rawOKFalseBody = []byte(`{"ok":true,"result":false}`) + + // respBufPool reuses *bytes.Buffer for response body reads. Used on + // paths whose decoder copies strings out of the input (decodeResult, + // which delegates to goccy/go-json), so the buffer can be returned to + // the pool as soon as Unmarshal has run. CallRaw and callMultipartRaw + // return slices that alias the buffer and therefore cannot use the + // pool without an extra copy that would defeat the point. + respBufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }} ) +// maxPooledBufCap caps the buffer size returned to respBufPool. Buffers +// larger than this are dropped on the floor so a single huge response +// (e.g. a large getFile metadata payload) doesn't bloat the pool for the +// rest of the process lifetime. +const maxPooledBufCap = 64 * 1024 + +func putRespBuf(buf *bytes.Buffer) { + if buf.Cap() > maxPooledBufCap { + return + } + respBufPool.Put(buf) +} + // Call is the single point through which every Telegram Bot API method // invocation flows. It marshals the request, signs the URL with the bot // token, dispatches via HTTPDoer, decodes the Result envelope, and @@ -63,12 +85,14 @@ func Call[Req any, Resp any](ctx context.Context, b *Bot, method string, req Req } defer func() { _ = resp.Body.Close() }() - raw, err := io.ReadAll(resp.Body) - if err != nil { + buf := respBufPool.Get().(*bytes.Buffer) + buf.Reset() + defer putRespBuf(buf) + if _, err := buf.ReadFrom(resp.Body); err != nil { return zero, &NetworkError{Err: err} } - return decodeResult[Resp](b.codec, raw) + return decodeResult[Resp](b.codec, buf.Bytes()) } // CallRaw is like Call but returns the raw JSON of the result field diff --git a/client/multipart.go b/client/multipart.go index e3f418f..9e79669 100644 --- a/client/multipart.go +++ b/client/multipart.go @@ -1,6 +1,7 @@ package client import ( + "bytes" "context" "github.com/goccy/go-json" "io" @@ -81,12 +82,14 @@ func callMultipart[Resp any](ctx context.Context, b *Bot, method string, mp mult } defer func() { _ = resp.Body.Close() }() - raw, err := io.ReadAll(resp.Body) - if err != nil { + buf := respBufPool.Get().(*bytes.Buffer) + buf.Reset() + defer putRespBuf(buf) + if _, err := buf.ReadFrom(resp.Body); err != nil { _ = pr.CloseWithError(err) return zero, &NetworkError{Err: err} } - return decodeResult[Resp](b.codec, raw) + return decodeResult[Resp](b.codec, buf.Bytes()) } // callMultipartRaw is callMultipart's sibling that returns the raw result