Skip to content

perf(lazyproto): inlineable decode loop with offset-based FieldData - #218

Open
scunningham wants to merge 1 commit into
CrowdStrike:mainfrom
scunningham:reslab3
Open

scunningham wants to merge 1 commit into
CrowdStrike:mainfrom
scunningham:reslab3

Conversation

@scunningham

@scunningham scunningham commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

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:

  1. GC write barriers — every fd.data = append(fd.data, val) wrote a []byte (pointer-containing type) to the heap, triggering a write barrier on every field
  2. Function call overhead — DecodeTag (cost 990) and DecodeVarint (cost 990) were too expensive to inline, causing call/return overhead on every iteration
  3. Cache pressure — each FieldData was 240 bytes with a [][]byte slice header, cold data mixed with hot data

Design

Offset-based FieldData (zero write barriers)

Replace [][]byte storage with dataPtr{uint32, uint32} offset pairs into the input buffer:

type FieldData struct {          // 24 bytes total
    wt   csproto.WireType        // 8 bytes
    root dataPtr                 // 8 bytes — first/only entry, inline
    cold *coldData               // 8 bytes — set once at clone time
}

type dataPtr struct {
    offset uint32                // byte offset into input buffer; 0 = absent
    length uint32                // byte length
}
  • root holds the first (and usually only) field entry inline — no slice, no pointer chase, no write barrier
  • cold holds repeated entries, buffer base pointer, and typed reuse slices behind a pointer — infrequently touched during the decode loop
  • All writes in the hot loop are plain integer stores: 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):

Function Cost What it does
DecodeTagFast() 73 Inline varint decode, no tag range validation
SkipBytes() 78 Length-delimited skip, offset+length return
SkipVarint() 54 Varint skip with 10-byte window cap
SkipFixed(wt) 39 Fixed32/64 skip
lut.lookup(tag) 68 Unsigned-compare bitmap + popcount tag lookup
dataPtr.hasData() 4 offset > 0 sentinel check

Key techniques to stay under budget:

  • varintShifts LUT saves 1 cost unit vs uint(n) * 7 multiply
  • Named returns save ~9 cost units vs explicit error returns
  • 9-byte varint window cap avoids 10th-byte overflow logic
  • Subtraction-form bounds checks prevent signed integer overflow without extra branches
  • Unsigned comparison trick in lut.lookup collapses two branches into one (cost 72 → 68)
  • Unsigned range check for tag validation: uint(tag-1) >= uint(MaxTagValue) — single CMP + branch

Safety fixes

  • SkipBytes signed overflow: offset+length wrapped negative for large varint lengths; fixed with subtraction form length <= len(d.p)-offset
  • SkipFixed bounds check: Same subtraction form for consistency
  • close() wt sentinel: Uses wt = -1 instead of 0 (which equals WireTypeVarint)
  • Deprecated Decode() stale pointer: close() nils the heap original's buf when cold.result != r (value-copy case)
  • hasData() canonical check: All call sites use hasData() (offset > 0) instead of mixing with struct-equality checks; invariant documented on dataPtr type
  • dataPtr.bytes() zero-length: Returns non-nil empty slice for present-but-empty fields (preserves old DecodeBytes semantics)
  • NestedResults double-close: Error cleanup now uses a separate closers slice; results are registered with r.closers only on success, preventing double pool return
  • NestedResults nil dereference: Nil guard in error cleanup for zero-length nested messages (decodeWithPool returns nil, nil)
  • uint32 overflow guard: Comment on len(data) > MaxUint32 documents why > (not >=) is critical for dataPtr arithmetic safety
  • BytesValues reuse cache: Added [][]byte cache to fieldDataBuffers, matching the pattern used by all other XxxValues() methods

Benchmarks

Apple M5 Max, arm64, benchtime=5s, count=10, all results p=0.000:

BenchmarkDecode (GC disabled, L1-resident batched decode)

Sub-benchmark main reslab3 Δ latency Δ throughput
fields=8 16.49 µs 11.88 µs -28% +39%
fields=18 34.18 µs 24.43 µs -29% +40%
fields=32 65.45 µs 42.89 µs -34% +53%
fields=64 137.22 µs 84.56 µs -38% +62%

BenchmarkDecodeGCPressure (GC enabled, 1 MB allocation per iteration)

Sub-benchmark main reslab3 Δ latency Δ throughput Δ allocs
fields=8 25.68 µs 18.98 µs -26% +35% 2.5 → 2.0
fields=64 104.36 µs 55.42 µs -47% +88% 11.5 → 3.0 (-74%)

BenchmarkDecodeRealistic (default GC, pool reuse)

Sub-benchmark main reslab3 Δ latency Δ throughput
fields=8 17.50 µs 12.16 µs -31% +44%
fields=18 35.21 µs 24.74 µs -30% +42%
fields=32 65.27 µs 42.80 µs -34% +52%
fields=64 138.31 µs 84.80 µs -39% +63%

Summary

Metric Improvement
Geomean latency -34%
Geomean throughput +51%
GC pressure allocs (64 fields) -74%
Zero-alloc decode (no GC) 0 B/op, 0 allocs/op
Realistic/fields=64 0 B/op (was 1 B/op on main)

Test Coverage

  • decode() function: 96.7% (only untested path: 4 GiB buffer size guard)
  • DecodeTagFast, SkipBytes, SkipVarint, SkipFixed: 100%
  • New test suites:
    • 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 exploitable
    • TestDecodeMalformedInput — 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

  • SkipBytes error conflation: Returns ErrInvalidVarintData for both truncated and overflow length varints. Distinguishing the two would exceed the inlining budget (+6-8 cost units). Documented in the function comment.
  • Mixed packed/unpacked rejection: The decode loop rejects a repeated field appearing with two different wire types (e.g. varint then length-delimited packed). The protobuf spec allows this but no conformant encoder produces it. Documented with comment.
  • BytesValues wire type validation: Unlike other 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 dataPtr offset scheme is internal to FieldData and not exposed.

Files Changed

File What changed
decoder.go New DecodeTagFast, SkipBytes, SkipVarint, SkipFixed; varintShifts LUT; ErrUnexpectedWireType doc
decoder_test.go +733 lines: tests for all new decoder functions including overflow regression
lazyproto/decode.go Deprecated Decode() cold slab setup
lazyproto/decode_result.go Rewritten decode() hot loop, clone() slab allocation, close() cleanup, NestedResults double-close fix
lazyproto/decode_test.go +549 lines: malformed input, repeated field cold path, pool reuse
lazyproto/fielddata.go FieldData 24-byte struct, dataPtr, coldData, BytesValues cache, processEntry buf caching
lazyproto/lut.go Unsigned comparison trick in lookup() (cost 72 → 68)
lazyproto/lut_test.go Updated for lookup API changes

@scunningham
scunningham marked this pull request as ready for review November 3, 2026 19:36
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
tlyons-cs
tlyons-cs previously approved these changes Nov 3, 2026

@wmorgan6796 wmorgan6796 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.

Thanks for the perf work here. A few issues worth addressing before merge, roughly in priority order:

Blocking

  • 32-bit build of lazyproto is broken (GOOS=linux GOARCH=386 go build ./lazyproto/ fails on this branch, passes on main).
  • SkipBytes / DecodeTagFast can produce negative/truncated lengths on 32-bit.
  • Truncated-input error contract changes from io.ErrUnexpectedEOF to ErrInvalidVarintData.
  • ErrLenOverflow (2GB field limit) is no longer enforced on the lazyproto path.

Should fix

  • Latent nil deref in close() after trunc().
  • Extra allocation per NestedResults call.

Design / nits

  • cold.result back-pointer special case in close(), dual source of truth for unsafe mode, duplicated varint loops, guard clause in lut.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 {

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Will do.

Comment thread decoder.go
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).

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.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Believe I can fix this and still remain under budget.

}
}

if r.closers != nil {

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.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Will chase this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Looks like that bug was pre-existing. Glad you caught it.

Comment thread decoder.go
// 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

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread decoder.go
// 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) {

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.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 {

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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)

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.

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]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah, can do this; previous implementation had a double close bug was trying to clearly resolve.

Comment thread lazyproto/fielddata.go
func (fd *FieldData) StringValue() (string, error) {
return scalarValue(fd, csproto.WireTypeLengthDelimited, func(data []byte) (string, error) {
if fd.unsafe {
if fd.cold.buffers != nil {

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I'll clean this up.

Comment thread decoder.go
// 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) {

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread lazyproto/lut.go

if word&mask == 0 {
return 0, false
if word&mask != 0 {

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This improved performance slightly and decreased inline budget.

@wmorgan6796

Copy link
Copy Markdown
Contributor

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)

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.

3 participants