Repository navigation
perf(lazyproto): inlineable decode loop with offset-based FieldData - #218
scunningham wants to merge 1 commit into
Conversation
Redesign the lazy decode hot loop for maximum throughput:
Decoder (csproto package):
- Add DecodeTagFast (cost 73), SkipBytes (cost 78), SkipVarint (cost 54),
and SkipFixed (cost 39) — all inlineable, zero function-call overhead
- Use varintShifts LUT to save 1 inlining cost unit per varint decode
- Cap varint windows at 9 bytes to avoid 10th-byte overflow logic
- Fix SkipBytes signed integer overflow: use subtraction-form bounds
check (length <= len(d.p)-offset) to prevent offset+length wrap
- Fix SkipFixed bounds check consistency (same subtraction form)
FieldData (lazyproto package):
- Replace [][]byte storage with dataPtr{uint32, uint32} offset pairs —
writes to root are plain integers, triggering zero GC write barriers
- Inline first/only entry in 24-byte FieldData (wt + root + cold ptr)
- Move repeated entries, buffer base, and typed reuse slices to coldData
behind a pointer — infrequently touched during the decode loop
- Add [][]byte reuse cache to fieldDataBuffers for BytesValues()
- Cache fd.cold.buf() once in sliceValue processEntry closure
- Use hasData() (offset > 0 sentinel) as single canonical no-data check
- Fix dataPtr.bytes() to return non-nil empty slice for zero-length fields
- Fix close() to use wt=-1 sentinel instead of 0 (WireTypeVarint)
- Fix close() to nil stale cold.result.buf for deprecated Decode() path
Decode loop (decode_result.go):
- Route wire types explicitly: SkipBytes / SkipVarint / SkipFixed with
descriptive error for unknown wire types (3, 4, 6, 7)
- Validate tag range after DecodeTagFast (inline budget precludes in-func)
- Write offset last as publish gate; document write-ordering invariant
- Document packed/unpacked mixed wire type rejection as intentional
Testing:
- Add TestSkipVarint (8 cases), TestSkipFixed (10 cases), TestSkipBytes
overflow regression test proving old addition-form check was exploitable
- Add TestDecodeMalformedInput (8 cases): tag=0, group wire types,
truncated varints, truncated payloads, wire type mismatch, tag overflow
- decode() coverage: 86.7% → 96.7%
Benchmarks (protoslab4 vs main, Apple M5 Max, p=0.000 n=10):
Decode/fields=8 16.4µs → 11.3µs -31% (+45% MB/s)
Decode/fields=64 141.7µs → 84.4µs -40% (+68% MB/s)
GCPressure/64 90.8µs → 61.8µs -32% (allocs: 11 → 3)
Geomean: 34% faster, 52% more throughput
wmorgan6796
left a comment
There was a problem hiding this comment.
Thanks for the perf work here. A few issues worth addressing before merge, roughly in priority order:
Blocking
- 32-bit build of
lazyprotois broken (GOOS=linux GOARCH=386 go build ./lazyproto/fails on this branch, passes onmain). SkipBytes/DecodeTagFastcan produce negative/truncated lengths on 32-bit.- Truncated-input error contract changes from
io.ErrUnexpectedEOFtoErrInvalidVarintData. ErrLenOverflow(2GB field limit) is no longer enforced on the lazyproto path.
Should fix
- Latent nil deref in
close()aftertrunc(). - Extra allocation per
NestedResultscall.
Design / nits
cold.resultback-pointer special case inclose(), dual source of truth for unsafe mode, duplicated varint loops, guard clause inlut.go.
go test ./ ./lazyproto/ passes on 64-bit. Details inline.
| // also satisfies uint32(offset)+uint32(length) <= MaxUint32 — no | ||
| // wraparound in dataPtr.bytes(). Do NOT weaken this to >= without | ||
| // also changing dataPtr to use wider fields. | ||
| if len(data) > math.MaxUint32 { |
There was a problem hiding this comment.
Breaks 32-bit builds. len(data) > math.MaxUint32 forces the untyped constant to int, which overflows on 386/arm:
$ GOOS=linux GOARCH=386 go build ./lazyproto/
math.MaxUint32 (untyped int constant 4294967295) overflows int
main builds fine. Suggest:
if uint64(len(data)) > math.MaxUint32 {
return ..., fmt.Errorf("... %d", uint64(math.MaxUint32))
}(the fmt.Errorf arg on the next line has the same problem). Might be worth adding a GOARCH=386 go build ./... step to CI.
| for n, b := range d.p[d.offset:min(d.offset+allowedVarint, len(d.p))] { | ||
| v |= (uint64(b) & 0x7f) << varintShifts[n] | ||
| if b < 0x80 { | ||
| length = int(v) // This cannot overflow an int64 because we are at max using 63 bits (see above). |
There was a problem hiding this comment.
Negative length on 32-bit. The root package still builds on 386, and there length = int(v) can wrap negative. E.g. a length varint ff ff ff ff 0f decodes to v = 2^32-1 → int(v) == -1, so length <= len(d.p)-offset passes and d.offset moves backwards. A caller slicing data[offset:offset+length] then panics or loops.
Since SkipBytes is a new exported API, a doc warning isn't sufficient — suggest bounds-checking v as uint64 before converting (if v > uint64(len(d.p)-offset) { return ErrUnexpectedEOF }). Same truncation concern for DecodeTagFast (tag = int(v>>3)).
There was a problem hiding this comment.
Believe I can fix this and still remain under budget.
| } | ||
| } | ||
|
|
||
| if r.closers != nil { |
There was a problem hiding this comment.
Latent nil dereference (pre-existing, but this PR touches close()). trunc() rebuilds closers with make([]*DecodeResult, n) — length n, all nil. Subsequent decodes append after those nils, and the loop here calls r.closers[i].close() on a nil receiver; the new first statement r.buf = nil then panics.
Repro sketch: NewDecoder(def, WithMaxBufferSize(1)), decode + NestedResults on ≥2 nested messages (so cap(closers) > 1), Close, then decode again with a nested result and Close.
Fix is one line in trunc: make([]*DecodeResult, 0, n). (Found by inspection; I haven't run a repro.)
There was a problem hiding this comment.
Will chase this.
There was a problem hiding this comment.
Looks like that bug was pre-existing. Glad you caught it.
| // Covers both short buffer (no terminator found) and varint overflow | ||
| // (all allowedVarint continuation bits set). Distinguishing the two | ||
| // requires an if/else branch (~8 cost units) that exceeds the inline budget. | ||
| err = ErrInvalidVarintData |
There was a problem hiding this comment.
Error contract change for truncated input. When the buffer ends right after a length-delimited tag (e.g. []byte{0x0a}) or mid-length-varint (0x0a 0x80), the old DecodeBytes path returned io.ErrUnexpectedEOF; this returns ErrInvalidVarintData. Callers using errors.Is(err, io.ErrUnexpectedEOF) to detect "need more data" will now classify it as corrupt input. Suggest preserving io.ErrUnexpectedEOF for the out-of-bytes case.
There was a problem hiding this comment.
Can't fit the check and stay within inline budget. It is documented in this new API that the error handles both cases due to inline budget.
| // Warning: This function is not 32-bit int compatible. | ||
| // | ||
| // Requires 0 <= d.offset <= len(d.p) on entry. | ||
| func (d *Decoder) SkipBytes() (offset int, length int, err error) { |
There was a problem hiding this comment.
Dropped ErrLenOverflow check. DecodeBytes/Skip enforce l > maxFieldLen, but SkipBytes doesn't, and decode() now accepts buffers up to MaxUint32. So a 3GB buffer with a single 2.5GB length-delimited field used to fail with ErrLenOverflow and is now accepted. If the 2GB protobuf limit is intentionally relaxed, worth calling out; otherwise suggest keeping the check (it's a single compare on an already-computed value).
There was a problem hiding this comment.
The extra check breaks the inline budget. I'll document the change in that new API call.
| // the heap original's buf so cold.buf() can't return stale data | ||
| // and the GC can collect the input buffer. | ||
| // For the pooled path, cold.result == r so this is a no-op. | ||
| if fd.cold.result != r { |
There was a problem hiding this comment.
Design concern. Special-casing fd.cold.result != r in shared cleanup code to accommodate the deprecated by-value Decode() suggests the coldData.result back-pointer lives at the wrong layer. Any other by-value copy (v := *res) has the same aliasing: reads go through cold.result to the original's buf, and closing one copy nils the other's buffer.
Alternatives: pass the base buffer from the owning result into FieldData accessors explicitly, or have deprecated Decode() keep the heap pointer alive instead of returning a copy.
There was a problem hiding this comment.
I'll look at this. Would prefer to just pull out the deprecated api call if we are going to bump major anyhow.
| // closers tracks only non-nil results for cleanup: on error they are | ||
| // closed directly; on success they are moved to r.closers. Kept | ||
| // separate from results to avoid double-close and nil dereferences. | ||
| closers := make([]*DecodeResult, 0, n) |
There was a problem hiding this comment.
Extra allocation per call. This allocates a temporary closers slice on every NestedResults call only to support the error path, then copies into r.closers — on a hot path in a PR aimed at cutting allocations. Cheaper:
start := len(r.closers)
// append directly to r.closers ...
// on error:
for _, c := range r.closers[start:] { c.close() }
r.closers = r.closers[:start]There was a problem hiding this comment.
Yeah, can do this; previous implementation had a double close bug was trying to clearly resolve.
| func (fd *FieldData) StringValue() (string, error) { | ||
| return scalarValue(fd, csproto.WireTypeLengthDelimited, func(data []byte) (string, error) { | ||
| if fd.unsafe { | ||
| if fd.cold.buffers != nil { |
There was a problem hiding this comment.
Two sources of truth for unsafe mode. Fast/unsafe mode is inferred here from fd.cold.buffers != nil, while DecodeResult.unsafe still exists. If those ever diverge (e.g. a future clone change reuses buffers in safe mode), StringValue/BytesValue silently switch between aliased and copied results — aliasing the caller's buffer in safe mode. Suggest keying off a single explicit flag.
There was a problem hiding this comment.
Yeah, I'll clean this up.
| // Warning: This function is not 32-bit int compatible. | ||
| // | ||
| // Requires 0 <= d.offset <= len(d.p) on entry. | ||
| func (d *Decoder) DecodeTagFast() (tag int, wireType WireType, err error) { |
There was a problem hiding this comment.
Duplicated varint decoding. DecodeTagFast, SkipBytes, and (near-copy) SkipVarint each re-implement the varint loop alongside DecodeVarint, with slightly different error contracts (SkipBytes collapses EOF into ErrInvalidVarintData; the others distinguish them). Fixes like the 10th-byte overflow in #212 would need to be applied in up to four places. Consider one small inlineable helper returning (v uint64, n int, ok bool) shared by all of them.
There was a problem hiding this comment.
DecodeVarint has the unroll support; is actually faster in many cases, but not on the hot loop. So could fold it back but would lose that optimization. Getting the inline to work is a dance of compromise.
|
|
||
| if word&mask == 0 { | ||
| return 0, false | ||
| if word&mask != 0 { |
There was a problem hiding this comment.
Nit: this replaces the early-return guard (if word&mask == 0 { return 0, false }) with a nested positive branch. The guard-clause form kept the not-found path explicit and the found path flat — suggest restoring it unless the nesting measurably helps inlining/codegen.
There was a problem hiding this comment.
This improved performance slightly and decreased inline budget.
|
Overall great change, but some minor things block the PR, namely we break a few contracts here that imply we should do a major version release or make this work on 32 bit systems (and return the same errors) |
perf(lazyproto): Inlineable decode loop with offset-based FieldData
Summary
Redesign the lazy protobuf decode hot loop for maximum throughput by eliminating GC write barriers, enabling full inlining of all hot-path functions, and reducing the per-field cache footprint from 240 bytes to 24 bytes.
Geomean across all benchmarks: 34% faster, 51% more throughput, 74% fewer GC-pressure allocations.
Motivation
The lazy decoder's hot loop was bottlenecked by:
fd.data = append(fd.data, val)wrote a[]byte(pointer-containing type) to the heap, triggering a write barrier on every fieldDecodeTag(cost 990) andDecodeVarint(cost 990) were too expensive to inline, causing call/return overhead on every iterationFieldDatawas 240 bytes with a[][]byteslice header, cold data mixed with hot dataDesign
Offset-based FieldData (zero write barriers)
Replace
[][]bytestorage withdataPtr{uint32, uint32}offset pairs into the input buffer:fd.wt = wt,fd.root.offset = uint32(off),fd.root.length = uint32(sz)Inlineable fast-path decoder functions
All functions called in the decode loop are inlineable (Go budget ≤ 80):
DecodeTagFast()SkipBytes()SkipVarint()SkipFixed(wt)lut.lookup(tag)dataPtr.hasData()offset > 0sentinel checkKey techniques to stay under budget:
varintShiftsLUT saves 1 cost unit vsuint(n) * 7multiplylut.lookupcollapses two branches into one (cost 72 → 68)uint(tag-1) >= uint(MaxTagValue)— single CMP + branchSafety fixes
offset+lengthwrapped negative for large varint lengths; fixed with subtraction formlength <= len(d.p)-offsetwt = -1instead of0(which equalsWireTypeVarint)close()nils the heap original'sbufwhencold.result != r(value-copy case)hasData()(offset > 0) instead of mixing with struct-equality checks; invariant documented ondataPtrtypeDecodeBytessemantics)closersslice; results are registered withr.closersonly on success, preventing double pool returndecodeWithPoolreturnsnil, nil)len(data) > MaxUint32documents why>(not>=) is critical fordataPtrarithmetic safety[][]bytecache tofieldDataBuffers, matching the pattern used by all otherXxxValues()methodsBenchmarks
Apple M5 Max, arm64,
benchtime=5s,count=10, all results p=0.000:BenchmarkDecode (GC disabled, L1-resident batched decode)
BenchmarkDecodeGCPressure (GC enabled, 1 MB allocation per iteration)
BenchmarkDecodeRealistic (default GC, pool reuse)
Summary
Test Coverage
decode()function: 96.7% (only untested path: 4 GiB buffer size guard)DecodeTagFast,SkipBytes,SkipVarint,SkipFixed: 100%TestSkipVarint— 8 cases (single/multi byte, max 10-byte, offset, sequential, empty, no terminator, overflow)TestSkipFixed— 10 cases (fixed32/64, truncated, empty, extra data, all rejected wire types)TestSkipBytes— includes signed overflow regression test proving old addition-form check was exploitableTestDecodeMalformedInput— 8 cases (tag=0, group wire types, truncated varints/payloads, wire type mismatch, tag overflow)TestRepeatedFieldColdData— 9 cases (last-write-wins scalar, ordered slice, entryCount, pool reuse clearing, empty bytes)Known Limitations
ErrInvalidVarintDatafor both truncated and overflow length varints. Distinguishing the two would exceed the inlining budget (+6-8 cost units). Documented in the function comment.XxxValues()methods,BytesValues()does not validate wire type. Preserved from old behavior for backward compatibility. Documented with NOTE comment.Breaking Changes
None. All public APIs preserved. The
dataPtroffset scheme is internal toFieldDataand not exposed.Files Changed
decoder.goDecodeTagFast,SkipBytes,SkipVarint,SkipFixed;varintShiftsLUT;ErrUnexpectedWireTypedocdecoder_test.golazyproto/decode.goDecode()cold slab setuplazyproto/decode_result.godecode()hot loop,clone()slab allocation,close()cleanup,NestedResultsdouble-close fixlazyproto/decode_test.golazyproto/fielddata.goFieldData24-byte struct,dataPtr,coldData,BytesValuescache,processEntrybuf cachinglazyproto/lut.golookup()(cost 72 → 68)lazyproto/lut_test.go