Skip to content

decode: bulk path for packed fixed-width payloads#314

Merged
iainmcgin merged 3 commits into
anthropics:mainfrom
taoroalin:bulk-packed-fixed-decode
Jul 17, 2026
Merged

decode: bulk path for packed fixed-width payloads#314
iainmcgin merged 3 commits into
anthropics:mainfrom
taoroalin:bulk-packed-fixed-decode

Conversation

@taoroalin

@taoroalin taoroalin commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

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.

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

Packed fixed32/sfixed32/float/fixed64/sfixed64/double payloads previously
decoded one element at a time: a remaining-bytes check, a generic-Buf
fixed-width read, and a Vec push per element. The element count of a
fixed-width packed payload is fully determined by the byte length, so the
whole payload can be validated, reserved, and converted in one pass.

This adds six bulk extenders (buffa::types::extend_packed_{float,fixed32,
sfixed32,double,fixed64,sfixed64}) that validate payload alignment up
front, reserve the exact element count, and convert via chunks_exact +
from_le_bytes -- endian-correct everywhere and a bulk copy on
little-endian targets. Codegen routes packed fixed-width arms through
them on the view path (always slice-contiguous) and on the owned path
when the payload is contiguous in the current chunk and the field uses
the default Vec; fragmented buffers and custom list representations keep
the per-element loop, as do all varint-family payloads (their element
count is not derivable from the byte length).

Semantics: accept/reject behavior is unchanged (a misaligned payload
still fails the field with DecodeError::UnexpectedEof; differential
tests in buffa/src/types.rs pin bulk-vs-per-element parity across valid,
truncated, and empty payloads). One observable difference on the bulk
paths: a malformed payload now leaves the field unmodified instead of
partially extended before the error; per-element fallback paths are
unchanged.

Motivation: long packed-double arrays (hundreds to thousands of elements
per field, e.g. per-element score vectors) are decode-dominated by the
per-element overhead. On such shapes the bulk path measures at multiples
of the per-element loop in isolated benchmarks; short arrays (the
PackedTile bench shapes) are unaffected within noise because per-field
overhead dominates there.

Conformance: identical results to main on the full suite (std, via-view,
via-lazy; binary/JSON/text), same runner, same known-failure sets.
Workspace test suite green; the two codegen intent tests that pinned the
per-element shape for fixed-width types now pin the bulk-extender shape.
@taoroalin
taoroalin force-pushed the bulk-packed-fixed-decode branch from 2a1e350 to 8f208a5 Compare July 16, 2026 00:15
@taoroalin

Copy link
Copy Markdown
Contributor Author

I have read the CLA Document and I hereby sign the CLA

github-actions Bot added a commit that referenced this pull request Jul 16, 2026
@iainmcgin

Copy link
Copy Markdown
Collaborator

[claude code] Reviewed for correctness and benchmarked on dedicated bare metal. The correctness is sound and the owned-path win is real — I want two test additions and a rebase before it lands, and there's a measurement finding you'll want.

The owned-path gain reproduces

Three-way compare on a spot c7i.metal-24xl, criterion baseline mode, base = this branch's own merge-base so the patch is isolated:

benchmark delta
packed_tile/decode −6.58%
mesh/decode (control) −0.31%
packed_signed/decode (control) −0.43%

Both controls are datasets your patch provably cannot touch — mesh has no packed fields at all, packed_signed is varint-only — and both sit under 0.5%. So the −6.58% is a real effect, close to your reported −11%.

One methodology note that cost me a run, and would have cost you one too. My first attempt showed mesh/decode at +8.7% — an apparent regression on a dataset your change cannot reach. The bench crate builds at cgu=1/lto=true but does not wire in the repo's layout-normalization flags; passing them by hand collapsed that control from +8.7% to −0.31%. If you re-measure, use:

RUSTFLAGS='-Cllvm-args=-align-all-nofallthru-blocks=6 -Cllvm-args=-align-loops=64' cargo bench --bench protobuf --no-run

The view-path numbers are unusable, and the suite can't show your real win

Two separate problems, neither your fault:

  1. Even normalized, the view path is layout-dominated. mesh/decode_view moved −12.76% under this patch — again, a dataset with no packed fields. Any decode_view claim from this suite is noise.

  2. This branch is based pre-codegen: inline view field decoding #312. Exactly one commit since your base touches the view decode path, and it's codegen: inline view field decoding #312 (codegen: inline view field decoding), which restructures that loop and moved log_record/decode_view −19.3% on our box. So every view row here describes a shape that will never ship. Please rebase onto current main — a lot has landed today (codegen: inline view field decoding #312, codegen: enforce closed-enum unknown-field limits #313, descriptor: preserve existing message on decode errors #299, docs: surface MessageField/EnumValue From conveniences and derive guidance #306, codegen: never let a proto comment's code fence become a doctest #309, reflect: adopt message values from another descriptor pool on set #308, descriptor: reject ambiguous field identities #300, reflect: preserve oneof members on decode errors #303, descriptor: validate service and method symbols #302, view: mark the unknown-field pushers cold #317).

  3. The suite's arrays are too short to show what this patch is for. packed_tile.samples is 4-8 floats and .positions is 2-4 doubles; your own data puts the win at 64+ elements (1.35-1.45× owned, 1.69-1.84× views). Your PR body even predicts this ("short arrays sit under per-field overhead"). I'd take you up on the long-array bench you offered — it would make the effect legible in-tree instead of resting on an out-of-tree workload, and it's the difference between "we believe this" and "we can show this".

Correctness: no Critical or High

Verified rather than assumed: the accept/reject set is identical to the per-element path for every input; double-occurrence still concatenates (extend appends, reserve is additive); zero-length is a no-op; valid payloads round-trip byte-identically including NaN bit patterns. No UB — from_le_bytes over chunks_exact + try_into is byte-based, correct on big-endian, no transmute or unaligned read. The "alignment check" is a byte-count-multiple check, correctly named for what it does.

The reserve is not a #287-class amplification surface — I checked specifically, since that's the bug we just fixed twice this week. len is bounded before any reserve by the pre-existing buf.remaining() < len guard (owned) and borrow_bytes's length check (view), so a 10-byte payload reserves 2-3 elements. And the custom-repr path that #287 neutered is untouched, because bulk only fires for the default Vec.

Two asks before merge

  • M1 (Medium) — the fragmented-Buf fallback is never exercised. impl_message.rs emits if buf.chunk().len() >= len { bulk } else { packed_loop }. The owned decoder is generic over bytes::Buf, so a multi-chunk buffer takes the else — but every test decodes from a contiguous &[u8]/Bytes. The whole owned-path claim is that the two branches agree, and only one is covered at integration level. One test decoding a packed double from a two-chunk impl Buf, asserting equality with the contiguous decode, closes it.
  • L1 (Low) — double-occurrence is unproven on the bulk fixed-width path at integration level. repeated_mixed_packed_and_unpacked_on_wire covers accumulate-across-occurrences only for varint ints; the fixed-width bulk path relies on the same per-occurrence extend, proven only in helper isolation. A packed fixed64-twice case is cheap.

Optional hardening, your call: a const _: () = assert!($width == core::mem::size_of::<$ty>()); in the macro would turn a future $ty/$width mismatch into a compile error rather than the current runtime .expect(); and the differential harness compares from_le_bytes against decode_double, which is also LE, so it can't catch an endianness regression — a fixed expected-to_bits assertion would. Also worth a one-line comment at the routing site: for identical malformed bytes, a contiguous buffer leaves the field unmodified while a fragmented one partially extends, so "no partial extension" isn't an invariant a reader should rely on.

Nice work — the differential tests are load-bearing (I mutation-tested them: disabling the width check fails four), and tightening the existing fixed-width test to compare error variants was a real improvement.

@iainmcgin

Copy link
Copy Markdown
Collaborator

[claude code] Follow-up: I took you up on the long-array benchmark rather than asking you to write it — #318 adds ColumnBatch, a columnar record batch with 1024-element packed columns. It vindicates your numbers, and our suite was badly under-reporting this patch.

Re-ran the same three-way metal compare with the new bench included (base = this branch's merge-base, layout-normalized, spot c7i.metal-24xl):

benchmark this PR + #315
column_batch/decode (1024-elem columns) −24.66% −36.23%
column_batch/decode_view −15.57% −20.48%
packed_tile/decode (4-8 elem) −8.59% −16.33%
packed_signed/decode (varint-only) −0.06% −7.62%
mesh/decode (control) −2.13% +1.73%
mesh/decode_view (control) −3.00% −3.61%

−24.66% against −8.59% on packed_tile — a ~3× larger signal from the identical patch, purely because the arrays are long enough to show what it does. That is the gap your PR body predicted ("short arrays sit under per-field overhead") and it's now measurable in-tree instead of resting on an out-of-tree workload.

Two details worth calling out:

  • packed_signed/decode at −0.06% is a perfect negative control for this PR. It's varint-only and this patch is fixed-width-only, so the right answer is exactly zero — and it is. Then decode: slice-specialized loop for packed plain-varint payloads #315, the varint patch, moves the same row to −7.62%. Each patch hits precisely its own target and nothing else, which is about as clean a separation as this harness produces.
  • Every control is quiet this run (−2.13% to +1.73%), unlike my earlier attempts where mesh/decode_view swung −12.76%. That means the view numbers here are trustworthy for once, and they show a real −15.57%.

The message is a columnar batch — one field per column, every row's value contiguous — because that's what analytics and telemetry pipelines actually send, so it earns its place in the suite beyond settling this PR. Columns are chosen so each lands in a distinct decode path, verified against the wire bytes: dictionary strings, 1.00 B/elem indices (the 1-2 byte varint case), 9.00 B/elem timestamps (the wide case), 8.00 B/elem doubles and 4.00 B/elem floats.

Nothing changes about the asks — the rebase onto current main (eleven PRs landed today), the fragmented-Buf test (M1), and the bulk double-occurrence test (L1). But the evidence for landing this is now much stronger than when I wrote that review, and it's our benchmark saying so rather than yours.

…ences

The owned decoder picks between the bulk copy and the per-element loop on
whether the payload sits in the current chunk, and it is generic over Buf, so
which branch runs is the caller's buffer layout rather than anything on the
wire. Every existing test decodes from a contiguous slice, so the fallback the
correctness argument rests on had no coverage at all. Decode the same message
from a two-chunk buffer split at every byte, which puts the boundary inside the
packed payloads and mid-element, and require it to match the contiguous decode.

Forcing the bulk path unconditionally makes that test panic reading past the
first chunk, so it does reach the branch rather than passing by luck.

Also pin that a packed fixed-width field appearing twice concatenates. The
existing occurrence test covers varints only, and the bulk path reserves per
occurrence, so nothing showed the reserve was additive rather than truncating.

@iainmcgin iainmcgin left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[claude code] Approving. I've merged current main and added the two tests I asked for rather than sending you round again — 15eca83 (merge) and 021b876 (tests).

M1 — the fragmented-Buf fallback. The owned decoder chooses between the bulk copy and the per-element loop on buf.chunk().len() >= len, and it's generic over Buf, so which branch runs is the caller's buffer layout rather than anything on the wire. Every existing test decodes from a contiguous slice, so the branch your correctness argument depends on had no coverage. The new test decodes the same message from a two-chunk buffer split at every byte, which puts the boundary inside the packed payloads and mid-element, and requires it to match the contiguous decode.

I checked the test isn't decorative: forcing the routing to if true (always bulk) makes it panic with range end index 12 out of range for slice of length 1, so it genuinely reaches the fallback rather than passing by luck.

L1 — double-occurrence on the bulk fixed-width path. The existing repeated_mixed_packed_and_unpacked_on_wire covers varints only, and your bulk path reserves per occurrence, so nothing pinned that the reserve is additive rather than truncating. A packed double field appearing twice now has to concatenate.

Gates on the merged branch: cargo fmt --check, clippy --workspace --all-targets -D warnings, cargo test --workspace (48 suites) all clean.

Left the optional hardening from my review alone — the const width assert, the endianness-pinning assertion, and the comment about chunking affecting malformed-input side effects are all reasonable follow-ups but none of them blocks this.

For the record, the numbers this lands on: −24.66% on column_batch/decode (the new 1024-element columnar bench in #318), against −8.59% on packed_tile's short arrays, with mesh controls quiet at −2.13%/−3.00% and packed_signed at −0.06% — exactly the zero a fixed-width patch should show on a varint-only message. Your original −11% was, if anything, modest about it.

Thanks for this, and for the unusually careful PR body — the routing rules and the documented semantic difference were both accurate, which made reviewing it a matter of verification rather than discovery.

@iainmcgin
iainmcgin enabled auto-merge July 16, 2026 22:36
@iainmcgin
iainmcgin merged commit d1db9a7 into anthropics:main Jul 17, 2026
15 of 18 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 17, 2026
@iainmcgin

Copy link
Copy Markdown
Collaborator

[claude code] Merged with an admin override past a red CLAAssistant. Recording why, since bypassing a required check deserves a trail.

The CLA requirement is satisfied; the check could not observe it. The action failed with:

Could not update the JSON file: Error occured when creating or editing the comments of the
pull request: Error occured when getting all the comments of the pull request: <!DOCTYPE html>

It is getting an HTML error page where it expects JSON — GitHub's API was intermittently serving those tonight (it broke a couple of my own gh api calls in the same window). It failed identically on a rerun, so it is not reading the signature state at all rather than reading it and finding it wanting.

Verified both commit authors independently of the action:

Every other check is green on the rerun, including conformance (3m28s) and lint-and-test. That one had failed on the first attempt with type_registry::tests::text::set_type_registry_installs_text_halves, which is a known-class flake rather than anything from this PR: the text registry is a global AtomicPtr mutated by seven test call sites while cargo runs tests in parallel. CI's exact command passes 3/3 locally on this branch (769 tests). It passed on rerun, unchanged. I'll file that separately — it has a sibling in extension_registry with the same cause, and a shared test mutex would kill the class rather than leaving people to rerun.

Thanks again @taoroalin — the numbers on the new columnar bench (#318) put this at −24.66% on 1024-element columns, three times what our short-array benchmarks were showing.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants