Skip to content

perf(derive): batch runs of adjacent big-endian bit fields - #2

Closed
Simon-Calbert-Aerospacelab wants to merge 13 commits into
perf/bit-fields-as-integersfrom
perf/derive-batch-bit-runs
Closed

perf(derive): batch runs of adjacent big-endian bit fields#2
Simon-Calbert-Aerospacelab wants to merge 13 commits into
perf/bit-fields-as-integersfrom
perf/derive-batch-bit-runs

Conversation

@Simon-Calbert-Aerospacelab

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

Copy link
Copy Markdown
Owner

What this changes

The previous PR #1 made one bit field cheap to read and write. This makes a run
of adjacent bit fields cost one call instead of one call per field.

The derive now detects a maximal run of neighbouring fields that a single read
can serve, emits one read_bits_uint_msb0 for the whole run, and hands each
field its bits with a shift and a mask. Writing is the mirror: each field is
shifted into place, OR'd together, and sent out in one write_bits_uint_msb0.

Stacked on #1. It calls the helpers that PR introduced, so it needs
that one first.

What the derive emits

Take the same header as the previous PR:

#[derive(DekuRead, DekuWrite)]
#[deku(endian = "big")]
struct Header {
    #[deku(bits = 2)]
    version: u8,
    #[deku(bits = 10)]
    id: u16,
}

Today the derive emits one read per field. Two reads, each with its own
leftover bookkeeping.

With this PR the two fields are recognised as a run of 2 + 10 = 12 bits:

let run: u64 = __deku_reader.read_bits_uint_msb0(12)?;
let version = ((run >> 10) & 0b11)         as u8;
let id      = ((run >>  0) & 0b1111111111) as u16;

On the input [0x2A, 0xB5]:

run     = 0b001010101011                  one read of 12 bits
version = (run >> 10) & 0b11         = 0
id      = (run >>  0) & 0b1111111111 = 683

The shift for each field is "how many bits sit below it in the run", and the
mask is its own width. Both are compile-time constants, so each field extraction
is two instructions on a register.

The write side is the same arithmetic backwards:

check_bit_size(version as u64, 2)?;
check_bit_size(id as u64, 10)?;
let run: u64 = ((version as u64 & 0b11) << 10)
             | ((id      as u64 & 0b1111111111) << 0);
__deku_writer.write_bits_uint_msb0(run, 12)?;

On a real frame

The CCSDS TM primary header in the benchmark has 11 fields of
2, 10, 3, 1, 8, 8, 1, 1, 1, 2, 11 bits. That sums to exactly 48, so all eleven
become a single run
: one 48-bit read, then eleven shift-and-mask pairs. The
same header goes out in one write.

Worth stating plainly: the two 8-bit fields in that header carry no bits
attribute at all. A plain big-endian integer field is exactly bits = width, so
plain byte fields join runs too. That is deliberate and it is why the
byte-aligned benchmark below speeds up, even though the previous PR left that
path untouched. If you would rather this were restricted to fields that
explicitly declare bits = N, that is a one-line change to run_field.

When a run is not formed

This is the part worth reviewing closely. A field joins a run only if all of
these hold:

  • bits = N with a literal N, or no bits attribute at all (then N is
    the type's width). An expression is rejected, since the width must be known at
    expansion time.
  • the type is a plain u8, u16, u32 or u64
  • endianness resolves to explicitly big (field attribute or container
    attribute). Absent means the target's endianness, which is little on x86, so
    absent is rejected.
  • bit order resolves to Msb0 (absent is fine, since that is the default)
  • no other deku attribute is set at all: count, until, cond, map,
    ctx, reader, writer, skip, temp, assert, magic, every pad_*,
    every seek_*, read_all, bytes, bits_read, bytes_read, update

The last rule is the safety argument. Each of those attributes either moves the
cursor, makes the read conditional, or depends on a value read earlier, and any
of the three breaks the "one contiguous read" assumption a run depends on. Rather
than reason about which combinations are safe, anything unusual keeps its own
per-field read.

Two more limits:

  • a run is capped at 64 bits, the width read_bits_uint_msb0 returns
  • a run needs at least 2 fields, otherwise there is nothing to batch and the
    field keeps its existing single-field path

Anything a run cannot serve is unchanged, so this is purely additive.

Concretely, per file

  • deku-derive/src/macros/deku_read.rs: run_field (does this field
    qualify), plan_bit_runs (group adjacent qualifying fields into maximal runs),
    emit_bit_run_read (emit the single read plus the shift-and-mask extraction).
  • deku-derive/src/macros/deku_write.rs: emit_bit_run_write, the dual.
    It emits a check_bit_size per field so the "value too wide for bits = N"
    error is still raised, and skips that check where a field fills its container
    and therefore cannot overflow it.
  • src/reader.rs / src/writer.rs: the helpers from the previous PR made
    pub so generated code can call them, plus check_bit_size.

Numbers

Measured against this PR's base, the previous PR, so this is the incremental
gain. Per-item cost from benches that push 128 frames through a single reader or
writer:

before after
CCSDS TM primary header, 11 fields, write 182 ns 17.9 ns 10x
CCSDS TM primary header, 11 fields, read 23.8 ns 5.74 ns 4.1x
6 plain byte fields, write 19.1 ns 10.7 ns 1.8x
6 plain byte fields, read 12.8 ns 4.78 ns 2.7x
1-bit field alone in a u64, read 1.67 ns 1.68 ns no change

The last row is the control. A single field cannot form a run, so it takes the
unchanged path, and criterion agrees: "No change in performance detected"
(p = 0.08).

Cumulative over both PRs, against master at 088018f for the same header:
read 723 ns to 5.74 ns (126x), write 534 ns to 17.9 ns (30x). A 1-bit field
alone in a u64 goes from 69.1 ns to 1.68 ns (41x), all of that from the
previous PR.

Reproduce with cargo bench --bench bebits --all-features.

Testing

cargo test across the full CI feature matrix (default, --all-features,
--no-default-features and each individual feature) plus all examples. No test
changes were needed: a batched run is required to produce bit-identical results
to the per-field reads it replaces, so the existing suite is the check.

Updates the requirements on [criterion](https://github.com/criterion-rs/criterion.rs) to permit the latest version.
- [Release notes](https://github.com/criterion-rs/criterion.rs/releases)
- [Changelog](https://github.com/criterion-rs/criterion.rs/blob/master/CHANGELOG.md)
- [Commits](criterion-rs/criterion.rs@criterion-plot-v0.7.0...criterion-v0.8.2)

---
updated-dependencies:
- dependency-name: criterion
  dependency-version: 0.8.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: wcampbell <wcampbell1995@gmail.com>
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.

1 participant