decode: slice-specialized loop for packed plain-varint payloads#315
Conversation
|
All contributors have signed the CLA ✍️ ✅ |
1e8a018 to
f85bd66
Compare
|
I have read the CLA Document and I hereby sign the CLA |
|
[claude code] Reviewed for correctness and benchmarked on dedicated bare metal. No Critical, High, or Medium findings — this is careful work, and the riskiest part of it holds up under attack. The claim I tried hardest to break"1-2 byte varints inline with no per-element chunk or remaining checks" is the sort of sentence that usually hides an over-read on the last element. It doesn't here. The enum check also passes, which was the one I most wanted verified: Your malformed-payload contract checks out exactly as written: dispatch on The measurementThree-way compare on a spot
The −8.16% on packed_signed is your patch hitting exactly what it aims at. Read it with the control in mind though: View-path rows are unusable ( If you re-measure, the bench crate does not wire in the repo's layout-normalization flags despite building at One advisory
Since this stacks on #314, it lands behind it — I've asked there for a fragmented- |
|
[claude code] Follow-up: #318 adds the long-array benchmark you offered to write — Same three-way metal compare, now with the new bench (base = this stack's merge-base, layout-normalized):
The separation between the two patches is unusually clean. Two of the columns exist for your patch specifically, and were verified against the wire bytes rather than assumed: Also worth noting: every control is quiet this run (−2.13% to +1.73%), so unlike my earlier attempts — where Asks are unchanged: the rebase (eleven PRs landed today), and #314's two test additions ahead of this one. The correctness review stands — no Critical, High, or Medium, and I could not construct an over-read against your fast-path bound. |
**What**: packed fixed32/sfixed32/float/fixed64/sfixed64/double payloads decode in one bulk call (six new `buffa::types::extend_packed_*` helpers: upfront alignment check, exact reserve, `chunks_exact` + `from_le_bytes` conversion) instead of a per-element remaining-check/read/push loop. Codegen routes the view path through them always (views are slice-contiguous), and the owned path when the payload is contiguous in the current chunk and the field uses the default `Vec`; fragmented buffers, custom list representations, and varint-family payloads are untouched by this patch. **Why**: for long packed arrays the per-element bounds check and generic-Buf read dominate; `chunks_exact`+`from_le_bytes` compiles to a bulk copy on little-endian targets. Short arrays (PackedTile's 4-8 elements) sit under per-field overhead, which is consistent with the existing comment that the plain decoders are optimal in packed loops — the regime the existing benchmarks cover. The workload that motivated this has packed-double arrays of hundreds to thousands of elements per field. **Measured** (shared-container box, so ratios not absolutes; noise floor ±10% per cell on our box, worse on the smallest benches; per-row minima across repeated runs): - The native suite in this repo: packed_tile decode 940 -> 836 µs (-11%), merge -8%; google_message1 decode -7%; mesh/packed_signed within noise (no fixed-width content on the hot rows). - Our calibrated internal workload (map<string, packed double> shapes, 4-64+ elements per entry): owned decode ~1.35-1.45x faster at 64-element arrays, views 1.69-1.84x (independently re-measured); ~1.0x at 4-element arrays (as expected). - An isolated microbenchmark of the conversion: ~0.2-0.4 ns/element bulk vs ~1.7-2.0 ns/element per-element at 512+ elements. **Semantics**: accept/reject identical (misaligned payload still fails with `DecodeError::UnexpectedEof`); one documented difference — on the bulk paths a malformed payload leaves the field unmodified rather than partially extended (changelog fragment included). Differential tests pin bulk-vs-per-element value AND error-variant parity. **Validation**: full conformance suite byte-identical to main (std, via-view, via-lazy; same runner, same known-failure lists); workspace tests green; clippy/fmt clean; the two codegen intent tests updated to pin the new shape. Before opening this PR I re-verified on this exact branch: 2477 workspace tests pass, `cargo fmt --check` and `cargo clippy --all-targets` clean. The conformance run above was done during development; the machine this PR was pushed from has protoc 29.6 (< v30 needed for the conformance build), so CI is the authoritative conformance signal here. **Authorship/process note**: developed and validated by a Claude agent session at Anthropic, which can't push to this repo; I'm opening the PR on its behalf. Happy to iterate on review feedback. Follow-up #315 stacks on this one, applying the same pattern to packed plain-varint payloads. --------- Co-authored-by: Iain McGinniss <309153+iainmcgin@users.noreply.github.com>
Packed plain-varint payloads (int32/int64/uint32/uint64/sint32/sint64/
bool) previously decoded through decode_varint_packed once per element:
each element pays the chunk()/len dispatch, the slice-decoder
precondition re-check, and the advance() bookkeeping of the generic Buf
cursor. For a packed payload those are loop invariants: a payload whose
final byte has a clear continuation bit (every well-formed payload)
satisfies the slice-decoder precondition at every element position, so
the dispatch can be hoisted out of the loop entirely.
This adds seven bulk extenders (buffa::types::extend_packed_{int32,
int64,uint32,uint64,sint32,sint64,bool}) built on a shared driver
(encoding::for_each_packed_varint). The reserve hint is the caller's,
so each decode path keeps the allocation policy it had with the loop:
generated view code passes the exact count_varints total, generated
owned code passes the payload byte length (an upper bound). The driver
then decodes over a position index --
1-2-byte varints inline, longer elements through the existing unrolled
slice decoder. Codegen routes plain-varint packed arms through them on
the view path and on the owned path when the payload is contiguous in
the current chunk and the field uses the default Vec. Enums keep the
per-element loop (their decode interleaves unknown-value routing);
fragmented buffers and custom list representations also keep it.
Semantics are unchanged, including for malformed payloads: an
unterminated trailing element routes the whole payload to the
per-element decoder, preserving the exact error and partial-extension
behavior; overlong-but-terminated encodings and 10-byte/VarintTooLong
cases decode identically (differential tests in buffa/src/types.rs pin
bulk-vs-per-element parity across valid, truncated, overlong, and
unterminated payloads).
Conformance: identical results to main on the full suite (std,
via-view, via-lazy), same runner, same known-failure sets. Workspace
test suite green; the two codegen intent tests now pin the extender
shape for plain-varint packed arms.
Stacks on: decode: bulk path for packed fixed-width payloads.
f85bd66 to
f7524b6
Compare
iainmcgin
left a comment
There was a problem hiding this comment.
[claude code] Approving. #314 landed as d1db9a7, so I rebased this onto current main — it now shows a single commit (f7524b6, your varint work) instead of carrying its own copy of #314's, which the squash had turned into a duplicate. Verified #314's bulk fixed-width code and tests are present from main rather than replayed, and that its split-buffer test still passes on top of this change.
The correctness review stands unchanged: no Critical, High, or Medium. The two findings I most wanted to break both held —
- The fast-path bound is airtight.
payload[i+1]is reached only whenpayload[i] >= 0x80, and the caller's precondition (final byte< 0x80) guarantees byteiisn't the last. Proven from the precondition and confirmed by brute force over every byte string up to length 5 from{0x00,0x01,0x7f,0x80,0x81,0xff}— no panic, zero divergence from the per-element decoder. Nounsafe, no unchecked indexing. - Enums correctly keep the per-element loop, so the closed-enum unknown-value routing that #313 and #304 landed earlier today is not bypassed. Had that gone the other way it would have silently undone a guard fix from the same release.
Also confirmed: the malformed-payload contract behaves exactly as your PR body describes, the reserve policy is unchanged from main on both paths (so it is not a new amplification surface), and zigzag/bool conversions mirror the scalar decoders.
Final numbers on the new columnar bench (#318, 1024-element columns):
| #314 alone | with this | |
|---|---|---|
column_batch/decode |
−24.66% | −36.23% |
column_batch/decode_view |
−15.57% | −20.48% |
packed_signed/decode (your target) |
−0.06% | −7.62% |
mesh (controls) |
−2.13% / −3.00% | +1.73% / −3.61% |
packed_signed reading −0.06% under #314 and −7.62% under yours is about as clean a separation as this harness produces: each patch moves only the regime it targets, and nothing else.
I left the one advisory alone — check_bulk_u32 asserts value parity on Ok but not the partial vector on error. Your claim holds by construction and the partial is discarded on failure, so it's a documentation-vs-test gap, not a defect. Worth a follow-up if you're inclined, not a blocker.
Thanks for both of these — careful work, accurate PR bodies, and you were right that our benchmarks were underselling them.
Stacks on #314 (shares the extender pattern and the codegen routing) — its commit is included here as the first commit; only the second commit is new to this PR.
What: packed int32/int64/uint32/uint64/sint32/sint64/bool payloads decode through seven new bulk extenders built on one driver (
encoding::for_each_packed_varint): one reserve up front (views keep their exactcount_varintshint; owned decode keeps its byte-length upper bound — each path's pre-existing policy), then decode over a position index — 1-2-byte varints (ids, tokens, enum-sized values) inline with no per-element chunk or remaining checks, longer elements through the existing unrolled slice decoder. Same routing rules as the fixed-width PR (view always; owned when contiguous + default Vec; enums keep the loop for unknown-value routing).Why:
decode_varint_packedalready removed the out-of-line call per element; what remains per element is thechunk()/len dispatch, the slice-decoder precondition re-check, andadvance()bookkeeping — all loop invariants for a payload whose final byte has a clear continuation bit (every well-formed payload). Hoisting them is the last step the existing design points at.Measured (same box/method caveats as the fixed-width PR): on varint-dense shapes our calibrated internal workload shows +6-12% on top of the fixed-width patch for frames carrying packed uint32 arrays, rising to +26% on the owned path for a frame dominated by a 4,000-element packed-uint32 array. On the native suite in this repo, packed_tile decode improves a further -25% from this patch (777 -> 579 µs on our box, interleaved A/B at ~3% resolution; -31.6% total across the two patches vs base): PackedTile's cells each carry a packed-uint32 geometry field (12-20 elements x 64 cells) plus a packed-uint64 checksums field, which this patch's extenders decode directly. packed_signed/mesh rows moved within our box's noise floor — a long-array variant of those benches would make the per-element effect visible (happy to contribute one).
Semantics: unchanged, including malformed payloads — an unterminated trailing element routes the whole payload to the per-element decoder, preserving exact error and partial-extension behavior; overlong-but-terminated and 10-byte/VarintTooLong cases decode identically. Differential tests pin value and error-variant parity across valid, truncated, overlong, and unterminated payloads.
Validation: conformance byte-identical to main across std/via-view/via-lazy (this patch activates the owned-path routing for descriptor.proto's packed int32 fields, so the checked-in buffa-descriptor generated code is regenerated here; the regen is mechanical); workspace tests green; clippy/fmt clean. Before opening this PR I re-verified on this exact branch: 2479 workspace tests pass,
cargo fmt --checkandcargo clippy --all-targetsclean. The conformance run was done during development; the machine this PR was pushed from has protoc 29.6 (< v30 needed for the conformance build), so CI is the authoritative conformance signal here.Authorship/process note: developed and validated by a Claude agent session at Anthropic, which can't push to this repo; I'm opening the PR on its behalf. Happy to iterate on review feedback.