Skip to content

perf: read and write a run of adjacent bit fields in one call - #677

Open
Simon-Calbert-Aerospacelab wants to merge 12 commits into
sharksforarms:masterfrom
Simon-Calbert-Aerospacelab:perf/derive-batch-bit-runs
Open

perf: read and write a run of adjacent bit fields in one call#677
Simon-Calbert-Aerospacelab wants to merge 12 commits into
sharksforarms:masterfrom
Simon-Calbert-Aerospacelab:perf/derive-batch-bit-runs

Conversation

@Simon-Calbert-Aerospacelab

@Simon-Calbert-Aerospacelab Simon-Calbert-Aerospacelab commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #673, which is now in master.

What this changes

#673 made a single bit field cheap by keeping the value in a u64 instead of a
BitSlice. But a header is not one bit field, it is a dozen of them in a row,
and each still costs its own read_bits_uint_msb0 call, its own leftover
bookkeeping, and its own bits_read update.

The fields are contiguous on the wire. Nothing between them moves the cursor. So
the derive can read the whole group with one call and cut the individual
fields out with a shift and a mask, both of which are compile-time constants.

What the derive emits

For three adjacent big-endian fields of 2, 10 and 4 bits, the read used to be
three calls. It is now:

let __deku_bit_run_0: u64 = __deku_reader.read_bits_uint_msb0(16)?;
let version = ((__deku_bit_run_0 >> 14) & 0b11) as u8;
let id      = ((__deku_bit_run_0 >> 4)  & 0b11_1111_1111) as u16;
let count   = ((__deku_bit_run_0 >> 0)  & 0b1111) as u8;

The write side composes the same integer in reverse and makes one
write_bits_uint_msb0 call.

Two details that keep this behaviour-preserving rather than merely faster:

  • The per-field overflow check survives. Writing 0b1111 into a 2-bit field
    used to fail with "bit size of input is larger than requested size". Folding
    fields into one integer would have silently let the high bits collide with the
    neighbour, so the derive emits a check_bit_size(value, bits) call per field
    before composing. Same error, same message.
  • A run is capped at 64 bits, the width of one read. The planner closes the
    current run and starts a new one rather than overflowing.

On a real frame

The CCSDS TM primary header is 11 fields packed into 48 bits. It went from 11
reads and 11 writes to 1 and 1
.

When a run is not formed

The planner is deliberately conservative. It requires at least two consecutive
eligible fields, and a field is eligible only if:

  • Endianness is explicitly big, from the field or the container. Absent means
    the target's endianness, which is little on x86, so absent is rejected rather
    than assumed.
  • Bit order is Msb0, which is the default, so absent is fine but lsb is
    not.
  • The type is u8/u16/u32/u64, and bits (if present) is a literal in
    1..=width.
  • Every other attribute is unset. bytes, count, bits_read,
    bytes_read, until, read_all, map, ctx, update, reader, writer,
    skip, all four pad_*, temp, temp_value, cond, assert, assert_eq,
    all four seek_*, and magic. Each of those either moves the cursor, makes
    the read conditional, or depends on a value read earlier, and any one of them
    breaks the "one contiguous read" assumption.

Anything ineligible keeps exactly the code it generates today, and an ineligible
field simply splits the run in two. So this is additive: no existing derive
changes behaviour, it either batches or it does not.

Concretely, per file

  • deku-derive/src/macros/deku_read.rs: run_field (is this field
    eligible), plan_bit_runs (group maximal eligible spans, capped at 64 bits),
    emit_bit_run_read (one read plus constant shifts and masks).
  • deku-derive/src/macros/deku_write.rs: the dual, plus the per-field
    check_bit_size calls.
  • src/reader.rs / src/writer.rs: read_bits_uint_msb0 and
    write_bits_uint_msb0 become pub so the derive can call them, and
    check_bit_size is added. No logic change to either.

Numbers

cargo bench --bench bebits --all-features, baselined against master at
90989ff, so layer 1 is in both arms and this isolates the batching. Divided
out to one header, from benches that push 128 frames through a single
reader/writer:

master this PR
read, TM primary header (11 fields, 48 bits) 23.2 ns 5.71 ns 4.1x
write, same header 194.7 ns 13.9 ns 14.0x
read, 6 adjacent u8 fields 12.0 ns 4.84 ns 2.5x
write, 6 adjacent u8 fields 23.1 ns 10.6 ns 2.2x
read, lone 1-bit field 1.67 ns 1.70 ns flat

Note the byte-aligned rows. In #673 those were a control, because that PR only
touched the sub-byte path. Here they are a genuine win: six adjacent big-endian
u8 fields are 48 contiguous bits, so they now batch like any other run.

The lone 1-bit field is the control for this PR. One field cannot form a run, so
its codegen is byte-for-byte identical; the +0.9% is noise.

Testing

No test file changes, which is the point: the entire existing suite passes
unmodified
across all eight CI feature configurations. That is a meaningful
gate here, because this changes codegen for every derived type in every test.

@Simon-Calbert-Aerospacelab

Copy link
Copy Markdown
Contributor Author

One limitation worth flagging, because I think it decides how much this PR is
actually worth in practice.

The eligibility rule requires the field type to be u8/u16/u32/u64. That
sounds mild, but in real wire formats the bit fields are the flags, and users
model flags as enums, not integers:

#[deku(bits = 1)]
ocf: OcfFlag,          // not eligible, splits the run
#[deku(bits = 3)]
version: u8,           // eligible

Every enum-typed field cuts the run in two. On the CCSDS TM primary header I use
as the benchmark, 5 of the 11 fields are enums, so this PR batches it into 8
calls rather than 1
: a run of three, then a lone enum, then a run of two, then
four lone enums, then a trailing field with no eligible neighbour. The headline
4.1x is real, but it is measured on a header that is friendlier than most.

The reason the derive cannot just accept enums is that when it expands a struct it
sees only the token OcfFlag. It cannot ask how wide that type is, cannot query
whether it implements a trait, and stable Rust has no specialization to fall back
on. So the width has to come from the type itself.

I have that working on my fork, and it is a smaller change than I expected:
Simon-Calbert-Aerospacelab#3

The shape is a DekuBitField trait carrying const BITS, derived automatically
for any enum whose variants are all unit variants with integer-literal ids, so
existing flag enums get it with no annotation. Reads reuse the match arms the
derive already generates, so a batched read accepts and rejects exactly the ids an
unbatched one does; writes go through the existing DekuEnumExt::deku_id, so the
write side needed no new codegen at all.

The one thing I could not make automatic is a single opt-in on the container,
#[deku(batch_bits)], because "batch if the type implements the trait" would fail
to compile for anyone whose field does not. With it, the same 11-field header
becomes 1 read and 1 write, and on top of this PR that is another 2.1x on
reads and 9.5x on writes for that header.

I would rather get your read on the design before I open it here, since the opt-in
attribute is the debatable part and it is a public API addition rather than a pure
optimisation. Happy to drop it, change the attribute name, or move the opt-in
somewhere else if you would prefer a different seam.

Comment thread deku-derive/src/macros/deku_write.rs Outdated
Comment thread deku-derive/src/macros/deku_write.rs Outdated
Comment thread src/writer.rs Outdated
Comment thread deku-derive/src/macros/deku_read.rs
Comment thread deku-derive/src/macros/deku_read.rs Outdated
@Simon-Calbert-Aerospacelab

Simon-Calbert-Aerospacelab commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Two follow-ups while addressing the review.

  1. bool and update now batch. bits = 1 bools are how flags are actually modelled, and excluding them split a run at every flag.

  2. A wording inconsistency you may want to know about. The same overflow error is worded two ways: DekuWriter<(Endian, BitSize)> says larger than bit requested size, DekuWriter<(Endian, BitSize, Order)> says larger than requested size. The Big+Msb0 arms are the same operation, and writing bit_order = "msb" explicitly is enough to switch which you get. 6 sites each in impls/primitive.rs, with 6 tests pinning one and 9 the other. A batched write has to reproduce whichever the field would have used, so check_bit_size takes a const ORDERED: bool.
    If we decide to unify the two, that parameter will disappear. For now, I left alone here since it changes user-visible strings and several of your tests.

@wcampbell0x2a

Copy link
Copy Markdown
Collaborator

@Simon-Calbert-Aerospacelab looks like one of the tests is failing.

@Simon-Calbert-Aerospacelab

Copy link
Copy Markdown
Contributor Author

@Simon-Calbert-Aerospacelab looks like one of the tests is failing.

Turned out to be a Writer bug rather than a batching one, and it predates this PR: an Lsb0 write that ends on a byte boundary leaves the order flag set even though no bits are pending, so the next Msb0 write takes the Lsb0 path and reorders whole bytes. Now cleared in write_bits_order whenever the leftover ends empty. CHANGELOG updated.

Also spotted that a batched run wasn't emitting the per-field Reading:/Writing: trace lines, so that's restored with a test.

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.

2 participants