perf: restore zero-alloc encode — the duplicated appender was load-bearing

v0.3.0 merged appendRequestHeaders and appendResponseHeaders into one generic
over an interface constraint, in the name of not repeating twelve lines. The
comment it deleted had explained exactly why they were separate: escape analysis
must be able to prove the VisitAll closure does not leave the stack.

A generic there compiles to a shared GC-shape instantiation —
appendHeaders[go.shape.*uint8] in the -gcflags=-m output — so the call goes
through a dictionary, the closure escapes, and every encode picked up a heap
allocation on a path this package documents and benchmarks as allocation-free.

    before  82.39 ns/op  32 B/op  1 allocs/op
    after   25.83 ns/op   0 B/op  0 allocs/op

Restoring the two concrete functions is 3.2x faster and allocation-free again.
Twelve duplicated lines were buying that; deleting them cost it.

Also fixes the benchmarks, which were measuring the wrong thing. benchRequest
sets only Host, which isFrameOwnedHeaderBytes strips, so its headers slot is
NULL and forEachHeader returns on its first line — the header codec these
numbers advertise was not being exercised at all. Added Real fixtures carrying
a realistic header set (auth, trace, ratelimit, and a Location with an "&" in
it, which under the old JSON codec forced the whole message onto the allocating
fallback):

    AppendRequestReal      113.5 ns/op  0 B/op  0 allocs/op
    AppendResponseReal      89.28 ns/op 0 B/op  0 allocs/op
    UnmarshalRequestReal   406.7 ns/op  0 B/op  0 allocs/op
    UnmarshalResponseReal  301.8 ns/op  0 B/op  0 allocs/op

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-27 15:25:34 -07:00
co-authored by Hanzo Dev
parent 53e435550e
commit 05e4490213
4 changed files with 118 additions and 11 deletions
+25 -10
View File
@@ -116,7 +116,7 @@ func AppendRequest(dst []byte, req *fasthttp.Request) ([]byte, error) {
hStart := len(dst)
skip := newTrailerSkip(&req.Header)
dst = appendHeaders(dst, &req.Header, skip)
dst = appendRequestHeaders(dst, &req.Header, skip)
dst = patchVarSlot(dst, obj+reqHeaders, hStart)
dst = putVarBytes(dst, obj+reqBody, req.Body())
@@ -160,7 +160,7 @@ func AppendResponse(dst []byte, resp *fasthttp.Response) ([]byte, error) {
hStart := len(dst)
skip := newTrailerSkip(&resp.Header)
dst = appendHeaders(dst, &resp.Header, skip)
dst = appendResponseHeaders(dst, &resp.Header, skip)
dst = patchVarSlot(dst, obj+respHeaders, hStart)
dst = putVarBytes(dst, obj+respBody, resp.Body())
@@ -293,16 +293,31 @@ func (s *trailerSkip) has(key []byte) bool {
// nothing has to model a list. Order is preserved as sent, and there is no
// escaping, no sort, and no grammar for two implementations to disagree about.
// headerVisitor is the shared shape of *fasthttp.RequestHeader and
// *fasthttp.ResponseHeader. Generic rather than an interface parameter so each
// instantiation keeps its VisitAll closure off the heap.
type headerVisitor interface {
VisitAll(f func(key, value []byte))
// appendRequestHeaders and appendResponseHeaders collect a message's headers
// and emit them as length-prefixed pairs.
//
// Two concrete functions, not one generic over an interface, and that is NOT an
// oversight to be tidied away. A generic here compiles to a shared GC-shape
// instantiation (appendHeaders[go.shape.*uint8]), so the VisitAll call goes
// through a dictionary, escape analysis can no longer prove the closure stays
// on the stack, and every encode picks up a heap allocation — measured at
// exactly 1 allocs/op, 32 B/op, on a path documented and benchmarked as
// allocation-free. Duplicating twelve lines buys back zero-alloc encode.
func appendRequestHeaders(dst []byte, h *fasthttp.RequestHeader, skip *trailerSkip) []byte {
c := hcollectorPool.Get().(*hcollector)
c.pairs = c.pairs[:0]
h.VisitAll(func(key, value []byte) {
if isFrameOwnedHeaderBytes(key) || skip.has(key) {
return
}
c.pairs = append(c.pairs, hpair{key, value})
})
dst = emitHeaders(dst, c.pairs)
hcollectorPool.Put(c)
return dst
}
// appendHeaders writes h's headers to dst, skipping the frame-owned ones and
// any name declared as a trailer.
func appendHeaders[H headerVisitor](dst []byte, h H, skip *trailerSkip) []byte {
func appendResponseHeaders(dst []byte, h *fasthttp.ResponseHeader, skip *trailerSkip) []byte {
c := hcollectorPool.Get().(*hcollector)
c.pairs = c.pairs[:0]
h.VisitAll(func(key, value []byte) {
+88
View File
@@ -203,3 +203,91 @@ func BenchmarkUnmarshalResponse(b *testing.B) {
}
}
}
// The benchmarks above deliberately use a minimal message, and that is a trap
// worth naming: benchRequest sets only Host, which isFrameOwnedHeaderBytes
// strips, so its headers slot is NULL and forEachHeader returns on its first
// line. Those numbers therefore say nothing about the header codec — they
// measure the frame envelope. The fixtures below carry a realistic header set
// so the codec being changed is the codec being measured.
func realRequest() *fasthttp.Request {
req := fasthttp.AcquireRequest()
req.Header.SetMethod("POST")
req.SetRequestURI("/v1/chat/completions")
req.Header.SetHost("api.hanzo.ai")
req.Header.SetContentType("application/json")
req.Header.Set("Authorization", "Bearer sk-proj-0123456789abcdef0123456789abcdef")
req.Header.Set("Accept", "application/json")
req.Header.Set("Accept-Encoding", "gzip, deflate, br")
req.Header.Set("User-Agent", "hanzo-sdk-go/1.4.2 (linux; arm64)")
req.Header.Set("X-Request-Id", "01J8Z9QK4E7M2N5P8R1T3V6W9Y")
req.Header.Set("X-Org-Id", "hanzo")
req.Header.Set("X-User-Id", "z")
req.Header.Set("Traceparent", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
req.SetBodyString(`{"model":"zen-1","messages":[{"role":"user","content":"hi"}]}`)
return req
}
func realResponse() *fasthttp.Response {
resp := fasthttp.AcquireResponse()
resp.SetStatusCode(200)
resp.Header.SetContentType("application/json")
resp.Header.Set("X-Request-Id", "01J8Z9QK4E7M2N5P8R1T3V6W9Y")
resp.Header.Set("X-Ratelimit-Limit", "10000")
resp.Header.Set("X-Ratelimit-Remaining", "9987")
resp.Header.Set("Cache-Control", "no-store")
resp.Header.Set("Vary", "Accept-Encoding, Authorization")
// A perfectly ordinary redirect target. Under the old JSON codec the '&'
// alone forced the WHOLE message onto the allocating fallback, because
// encoding/json escapes it and the fast path bailed on any escape.
resp.Header.Set("Location", "https://ex.test/cb?code=abc&state=xyz")
resp.SetBodyString(`{"id":"chatcmpl-9","object":"chat.completion","choices":[]}`)
return resp
}
func BenchmarkAppendRequestReal(b *testing.B) {
req := realRequest()
defer fasthttp.ReleaseRequest(req)
buf := make([]byte, 0, 1024)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
buf, _ = AppendRequest(buf[:0], req)
}
}
func BenchmarkAppendResponseReal(b *testing.B) {
resp := realResponse()
defer fasthttp.ReleaseResponse(resp)
buf := make([]byte, 0, 1024)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
buf, _ = AppendResponse(buf[:0], resp)
}
}
func BenchmarkUnmarshalRequestReal(b *testing.B) {
req := realRequest()
defer fasthttp.ReleaseRequest(req)
frame, _ := MarshalRequest(req)
var dst fasthttp.Request
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = UnmarshalRequest(frame, &dst)
}
}
func BenchmarkUnmarshalResponseReal(b *testing.B) {
resp := realResponse()
defer fasthttp.ReleaseResponse(resp)
frame, _ := MarshalResponse(resp)
var dst fasthttp.Response
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = UnmarshalResponse(frame, &dst)
}
}
+4
View File
@@ -10,3 +10,7 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/zap-proto/go v1.1.0 h1:DDGhuTBNqkmNax7QV/VvkInJR7hiOcybwUjN3YBt8fg=
github.com/zap-proto/go v1.1.0/go.mod h1:914SNGTH6Rv3Yu1MweWJBPEN8FZlo5C39QyhaB0C7Q0=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
+1 -1
View File
@@ -234,7 +234,7 @@ func MarshalResponseHead(resp *fasthttp.Response) ([]byte, error) {
dst = putVarBytes(dst, obj+respProto, proto)
hStart := len(dst)
dst = appendHeaders(dst, &resp.Header, newTrailerSkip(&resp.Header))
dst = appendResponseHeaders(dst, &resp.Header, newTrailerSkip(&resp.Header))
dst = patchVarSlot(dst, obj+respHeaders, hStart)
// body and trailer slots stay null.