Code size on small CPUs¶
How much code shrink_wrap costs on a CPU with a few kilobytes of memory, where the bytes go, what was changed
because of it and what is left. Measured in October 2026 for a softcore firmware with 8 KB of RAM for code, data and
stack (the QERV RISC-V of fpga_tools' busgen). Its request/reply codec was hand-written because the derived one did
not fit; since fpga_tools 0.9.0 (BUS-12) the firmware runs on head_tail views and variant writers (SW-52, SW-53,
SW-54), and no hand-written codec is left.
Summary:
- The derived codec of that firmware went from 11.3 KB to 9.0 KB on RV32IC (15.1 → 12.5 KB on RV32I, 9.3 → 7.3 KB
on Cortex-M0) with five changes that keep the wire format as it is. With
sized/final_structuretypes and linker relaxation it is 7.3 KB. The same codec written by hand is 5.2 to 5.8 KB. - Half of what was saved came from one thing: plain fields are no longer checked for an error one by one.
- An encoder whose types have no lists, strings or nested evolvable types does not link the code for lengths at all: a struct of eight fields is 964 bytes on RV32IC instead of 1476 (1934 before any of this).
- No cargo feature was needed, and none is proposed: what a firmware does not call is not linked, and a feature that changes the wire format would be a trap (see Feature flags).
- A build setting found on the way is worth more than any of the code changes on RISC-V: linker relaxation was off,
-C target-feature=+relaxsaves 9 to 11 % for any firmware, hand-written ones too.
The bench¶
just size-bench (the size_bench crate in shrink_wrap/size_bench) builds one bare firmware image per test case
from shrink_wrap/size_bench/fw and prints .text + .rodata in bytes. just size-bench -c busgen -t rv32ic -s 30
prints the 30 largest symbols of an image instead.
The build profile is the one of the softcore firmware: nightly, build-std = ["core"], panic = "immediate-abort",
opt-level = "z", fat LTO, one codegen unit, -Zlocation-detail=none -Zfmt-debug=none. Targets:
| name | target | flags |
|---|---|---|
rv32i |
riscv32i-unknown-none-elf |
|
rv32ic |
riscv32i-unknown-none-elf |
-C target-feature=+c (what the softcore runs) |
rv32ic-relax |
riscv32i-unknown-none-elf |
-C target-feature=+c,+relax |
thumbv6m |
thumbv6m-none-eabi |
Cortex-M0, for comparison |
thumbv7em |
thumbv7em-none-eabihf |
Cortex-M4F / M7 (Thumb-2), for comparison with larger MCUs |
Every case is a run(rx, tx) that decodes from one buffer and encodes into another. Inputs and outputs go through
core::hint::black_box, so nothing is computed at compile time and every decoded field counts as used. The images
are measured, not run.
| case | what it holds |
|---|---|
empty |
no shrink_wrap code: 38 bytes on RV32IC, the floor of every other case |
rw_de, rw_ser |
BufReader / BufWriter alone: a u8, a bool, a u16, a u32 (rw_ser ends with finish_no_lens() since step 4) |
struct_de, struct_ser |
a struct of eight integers and flags (Wave) |
enum_de, enum_ser |
an enum of five small variants |
vec_de, vec_ser |
a struct with a u16 and a RefVec<u16>, the list walked after decoding |
bits_de, bits_ser |
a bit-packed struct: U3, bool, U4, U12, I10, U20, U5, u8 (Sample, since SW-38) |
option_de, option_ser |
a struct of ten fields, two of them Option |
busgen_de |
the softcore firmware's receive side: Request (18 variants, lists of structs and of u16, nested structs, an Option<Wave>) decoded and acted on |
busgen_ser |
its transmit side: one of the nine Reply variants encoded |
busgen |
both: a request decoded, acted on, its reply encoded |
busgen_hand |
the same as busgen with a hand-written codec: bytes, little endian, a list is a count and its items, reading past the end gives zeros |
reply |
a method's reply as ww_codegen! server code encodes it: a Wave return value alone, then the ww_client_server::Event around its bytes, and the error event when that fails |
The cases with derived types are built twice: evolvable (the default, every type Unsized) and final (sized
where the fields allow it, else final_structure). The two differ only where a type is nested in another, so the
small cases give the same number for both.
busgen_hand is the yardstick, not empty: the handler that acts on a request, the enum values it builds and the
bench's own black_box calls are in both busgen and busgen_hand. What the two differ by is the codec.
Results¶
.text + .rodata in bytes, before (as of f661ec9c) and after the five changes:
| case | types | rv32i | rv32ic | rv32ic-relax | thumbv6m |
|---|---|---|---|---|---|
rw_de |
796 → 648 | 512 → 444 | 472 → 404 | 430 → 364 | |
rw_ser |
2540 → 688 | 1806 → 462 | 1690 → 428 | 1460 → 384 | |
struct_de |
944 → 840 | 638 → 554 | 568 → 478 | 530 → 440 | |
struct_ser |
2620 → 1348 | 1934 → 964 | 1746 → 872 | 1596 → 830 | |
busgen_de |
evolvable | 10748 → 8468 | 7718 → 6040 | 7104 → 5442 | 6316 → 5044 |
busgen_ser |
evolvable | 6776 → 5284 | 4762 → 3650 | 4346 → 3256 | 4054 → 3224 |
busgen |
evolvable | 15148 → 12532 | 11308 → 9000 | 10338 → 8114 | 9302 → 7320 |
busgen |
final | 13672 → 11148 | 10320 → 8062 | 9496 → 7296 | 8370 → 6512 |
busgen_hand |
7824 | 5762 | 5152 | 4408 |
All cases after the changes:
| case | types | rv32i | rv32ic | rv32ic-relax | thumbv6m |
|---|---|---|---|---|---|
empty |
56 | 38 | 34 | 44 | |
rw_de |
648 | 444 | 404 | 364 | |
rw_ser |
688 | 462 | 428 | 384 | |
struct_de |
840 | 554 | 478 | 440 | |
struct_ser |
1348 | 964 | 872 | 830 | |
enum_de |
1004 | 678 | 586 | 512 | |
enum_ser |
1420 | 1012 | 920 | 864 | |
vec_de |
908 | 592 | 548 | 536 | |
vec_ser |
1634 | 1142 | 1074 | 1030 | |
option_de |
980 | 658 | 554 | 520 | |
option_ser |
1452 | 1048 | 936 | 888 | |
busgen_de |
evolvable | 8468 | 6040 | 5442 | 5044 |
busgen_de |
final | 7588 | 5452 | 4920 | 4496 |
busgen_ser |
evolvable | 5284 | 3650 | 3256 | 3224 |
busgen_ser |
final | 4464 | 3180 | 2820 | 2796 |
busgen |
evolvable | 12532 | 9000 | 8114 | 7320 |
busgen |
final | 11148 | 8062 | 7296 | 6512 |
busgen_hand |
7824 | 5762 | 5152 | 4408 |
vec_de, busgen_de and busgen are 50 to 140 bytes larger since SW-37, which is not in these tables: see
Lists of plain numbers.
Read as: on RV32IC the derived codec is now 1.55 times the hand-written one with evolvable types and 1.4 times with final ones (it was 1.95 and 1.8 times), and Cortex-M0 code is about 20 % smaller than RV32IC for the same source.
The encoders, before and after steps 4 and 5 alone (the length FIFO, SW-36; the decoders did not change):
| case | types | rv32i | rv32ic | rv32ic-relax | thumbv6m |
|---|---|---|---|---|---|
rw_ser |
1456 → 688 | 996 → 462 | 920 → 428 | 794 → 384 | |
struct_ser |
2092 → 1348 | 1476 → 964 | 1336 → 872 | 1240 → 830 | |
enum_ser |
2160 → 1420 | 1536 → 1012 | 1396 → 920 | 1298 → 864 | |
option_ser |
2220 → 1452 | 1572 → 1048 | 1412 → 936 | 1286 → 888 | |
vec_ser |
1958 → 1634 | 1378 → 1142 | 1286 → 1074 | 1180 → 1030 | |
busgen_ser |
evolvable | 5496 → 5284 | 3820 → 3650 | 3424 → 3256 | 3344 → 3224 |
busgen_ser |
final | 4708 → 4464 | 3356 → 3180 | 2972 → 2820 | 2944 → 2796 |
busgen |
evolvable | 12744 → 12532 | 9170 → 9000 | 8260 → 8114 | 7400 → 7320 |
busgen |
final | 11364 → 11148 | 8232 → 8062 | 7446 → 7296 | 6660 → 6512 |
rw_ser, struct_ser, enum_ser and option_ser have no lengths and lose the FIFO code (step 4). vec_ser and
the busgen cases have lists and keep it, smaller (step 5).
Where the bytes went¶
The largest symbols of busgen (evolvable) on RV32IC before the changes, 11100 bytes of .text:
| bytes | symbol | what |
|---|---|---|
| 6958 | _start |
everything inlined into the caller: Request decode, the handler, Reply encode |
| 506 | BufWriter::encode_len_fifo |
the lengths at the end of the buffer, u16 slots turned into nibbles |
| 338 | compiler_builtins::mem::memcpy |
moves of the request and reply values, and every write_u16 / write_u32 |
| 326 | BufReader::read::<Wave> |
a nested evolvable struct of 8 fields |
| 270 | BufReader::read::<UartFormat> |
6 fields |
| 238 | BufWriter::write::<RxFrame> |
2 fields, with the size slot of an Unsized type |
| 156 | BufReader::read_rev_len |
|
| 138 + 132 | BufReader::read::<RefVec<Wave>>, <RefVec<u16>> |
one pass over the list to find its end |
| 138 | BufWriter::write_nib |
|
| 132 + 32 + 8 | u32_div_rem, u32_normalization_shift, __udivsi3 |
one division by 3, see below |
| 124 + 76 | RefVecIter<Wave>::next, RefVecIter<u16>::next |
|
| 122 | BufWriter::write_rev_len |
|
| 118, 118, 114 | BufReader::split, read_bool, read_raw_slice |
|
| 108, 98 | BufWriter::write_bool, write_raw_slice |
|
| 92, 88, 78 | BufReader::read_u32, read_u8, read_u16 |
three copies of the same bounds checks |
| 82, 64 | BufWriter::align_nibble, align_byte |
By kind:
| part | bytes | notes |
|---|---|---|
BufReader primitives |
810 | |
BufWriter primitives |
1260 | 850 of them only for the length FIFO |
| compiler builtins | 560 | memcpy 346, division 180, multiplication 32 |
| generic code per type, not inlined | 1300 | read::<T>, write::<T>, RefVec<T>, RefVecIter<T> |
_start |
6960 | the hand-written codec has 4400 here |
So 2.6 KB did not depend on the types at all, where the hand-written codec has 1.15 KB outside _start (memcpy
included). The code that does depend on the types, the generic instances and _start, was 8.3 KB against 4.4 KB
by hand.
Per field. A derived des_shrink_wrap is a run of let x = rd.read_u32()?;. On RV32IC that was 22 to 26
bytes per field: Result<u32, Error> came back through memory, because Error had variants with a payload, so
every call passed a pointer to a stack slot, loaded the tag, compared it, loaded the value, and had an error exit
of its own (store the error, jump). The hand-written reader returns the value in a register and has no error
exits: 6 to 10 bytes per field. Request has about 80 fields and Reply about 40.
The length FIFO. Lengths of lists, strings and nested Unsized values are collected as u16 slots at the end
of the buffer and turned into nibbles by finish(). encode_len_fifo, write_nib, align_nibble,
write_rev_len and the nibble counting are 850 bytes on the encode side, read_rev_len 156 on the decode side.
finish() was always called, so an encoder paid for this even when its types had no lengths: rw_ser (four
integer writes) was 1806 bytes. Steps 4 and 5 below are about this.
A division. UNib32::len_nibbles was (32 - leading_zeros).div_ceil(3). RV32I has neither a divider nor a
count-leading-zeros instruction, so this one line linked 172 bytes of division and a software clz: 430 bytes in
every image that encodes (rw_ser 1806 → 1378 with a three line loop instead).
Evolvable nesting. Every use of an Unsized type inside another is a size slot, a split() and an encode of
the slots the value used. busgen has ten such uses (three types, used in lists, in an Option and directly):
final is 0.9 to 1.0 KB smaller, and the messages are shorter too. This is the only saving that
changes the wire format, and it is chosen per type, as before.
Lists. RefVec<T> is decoded twice: once when its parent is decoded, to find where the list ends, and again
when it is walked. Each T costs a read::<RefVec<T>> (130 to 140 bytes) and a RefVecIter<T>::next (75 to 125
bytes). Encoding a RefVec<T> also contains the decoder of T, for its Buf variant (a list taken from another
buffer); the optimizer removes it only when it can prove the list is a slice.
Decode only, encode only. Nothing to switch off: the linker keeps what is called. busgen_de is 6.0 KB,
busgen_ser 3.7 KB, and neither contains the other's primitives.
Bounds checks and panics. With panic = "immediate-abort" no panic code or message is left, a failed check is
one unimp. The checks themselves stay: BufReader and BufWriter compare against their own limits and then index
a slice, which checks again. It is 4 to 8 bytes per access and was three times in three functions for
read_u8 / read_u16 / read_u32.
Cortex-M0. The same picture with smaller numbers: _start 4864 of 7292 bytes after the changes, memcpy
316, encode_len_fifo 174. Thumb has no linker relaxation to forget, and a call is 4 bytes.
What was changed¶
All five keep the wire format: every byte-level test of the workspace passes unchanged.
| step | busgen evolvable |
busgen final |
rw_ser |
struct_de |
|---|---|---|---|---|
| before | 11308 | 10320 | 1806 | 638 |
1. no division in UNib32::len_nibbles |
10872 | 9870 | 1378 | 638 |
2. Error without payloads, shared integer read / write |
10184 | 9076 | 984 | 532 |
| 3. latched errors for plain fields | 9170 | 8232 | 996 | 554 |
| 4. no FIFO code for types without lengths | 9170 | 8232 | 466 | 554 |
| 5. smaller length FIFO | 9000 | 8062 | 462 | 554 |
(RV32IC, bytes. The other targets move the same way, see Results.)
UNib32::len_nibblesis a shift loop. No division routine, no softwareclz.Errorhas no payloads and is#[repr(u32)], andread_u8/read_u16/read_u32/read_i16/read_i32/read_f32are one function taking a length (write_*likewise,read_boolthrough one bit reader).Result<u32, Error>is now two registers,Result<(), Error>one withOkas zero, and the bounds checks exist once. Writing an integer no longer callsmemcpy. Breaking:Error::OutOfBoundsWriteUN,OutOfBoundsReadUNandOutOfBoundsSplitlost theirUNib32payload (the bit count or length asked for). AnErrorof one byte was tried first and gained nothing:Result<u32, Error>stays in memory then.- Latched errors.
BufReaderandBufWriterhaveread_*_latch/write_*_latchmethods forbooland the integers up to 32 bits: noResult, an error is kept in the reader or writer and the read gives 0.rd.latched()/wr.latched()return the first error and clear it;BufWriter::finish()returns it too.#[derive_shrink_wrap]uses them for plain fields,Option/Resultflags andu8/u16/u32discriminants, and checks once per struct or enum (and before aTailSizeslot). A field is then a call and a move: 12 to 14 bytes on RV32IC, 6 to 8 with relaxation. Fields with#[default = ..], lists, strings and nested types are read as before, so evolution is untouched. The price:BufReaderis 24 bytes instead of 20 (so is everyRefVec), and the smallest cases grow by 10 to 30 bytes.
The rule for hand-written code that uses the _latch methods: don't use a value before latched() returned
Ok, in particular not as a loop bound.
4. No FIFO code for types without lengths (SW-36). SerializeShrinkWrap has an associated constant
PUSHES_LEN: whether ser_shrink_wrap can leave a length for finish() to encode. to_ww_bytes ends with
wr.finish_for::<Self>(), which is finish() when it is true and finish_no_lens() (latched error, align
to a byte, done) when it is false. The choice is made at compile time, so encode_len_fifo, write_nib and
the rest are not linked: 510 to 530 bytes less on RV32IC for rw_ser, struct_ser, enum_ser and
option_ser, about 750 on RV32I, 400 to 440 on Cortex-M0.
The default is true for everything that is not Sized, so a hand-written impl stays correct without knowing
about it. #[derive_shrink_wrap] computes it from the fields: false only when no field is a string, a list, a
RefBox, a nested Unsized type (its size slot is a length) or a user type that pushes one itself (asked
through its own constant, also inside Option, Result, arrays and tuples). Evolvable (Unsized) root types
made of plain fields qualify, they need no sized. A type that says false and pushes a length anyway panics
in a debug build and gets Error::LenFifoNotEmpty in a release one, never a message without its lengths.
Code that calls ser_shrink_wrap and finish() itself gets the same with wr.finish_for::<T>(), where T is
the type of everything written into that writer.
5. A smaller length FIFO (SW-36), for the types that do have lengths. write_nib aligns and writes in one
step (140 → 130 bytes on RV32IC) and encode_len_fifo no longer needs align_nibble
(82 bytes, not linked any more); the nibbles are counted in one pass over a slice, as a parity, and written by
a loop that does not ask for the length of each number first (276 → 214 bytes; 216 → 174 on Cortex-M0).
vec_ser 1378 → 1142, busgen 9170 → 9000.
Fixed on the way: aligning to a nibble from the last three bits of a byte (align_nibble(), write_nib(), a
length after 5 to 7 bits) left those bits as they were in the buffer instead of zero. A reader skips them, so
nothing on the other end changes, but the bytes depended on what the buffer held before.
Lists of plain numbers: time for bytes¶
Done after the five steps (SW-37), and the one change here that makes the code larger. Decoding a RefVec<T>
reads every element once just to find where the list ends. For u8..u128, i8..i128, f32 and f64 that
is now one bounds check: reading such a value is "align to a byte, take N bytes" and nothing else, so count
reads succeed exactly when one read of count * N bytes does. A list that is too short, or a count whose size
overflows, still goes through the pass, so the error and the reader state are the ones it always gave. The wire
format does not change.
RefVec<u8> of 16 |
of 256 | of 4096 | |
|---|---|---|---|
| decode pass | 43 ns | 631 ns | 10254 ns |
| one bounds check | 5 ns | 5 ns | 6 ns |
(from_ww_bytes on an x86-64 host, for the proportion. On a small CPU the pass is a call of read_le32, about 30
instructions, per element; on a bit-serial softcore each of them takes 32 clock cycles or more.)
It was expected to save code as well, and does not. Since step 2 the pass over a list of integers is a loop of
about 22 bytes around the shared read_le32, nothing like the 130 byte read::<RefVec<u16>> of the first
profile. The check comes on top of it (the pass stays for the error case), and read_raw_slice is no longer
inlined into its only caller:
| case | types | rv32i | rv32ic | rv32ic-relax | thumbv6m |
|---|---|---|---|---|---|
vec_de |
908 → 1004 | 592 → 652 | 548 → 596 | 536 → 582 | |
busgen_de |
evolvable | 8468 → 8604 | 6040 → 6126 | 5442 → 5504 | 5044 → 5112 |
busgen_de |
final | 7588 → 7708 | 5452 → 5524 | 4920 → 4982 | 4496 → 4556 |
busgen |
evolvable | 12532 → 12664 | 9000 → 9086 | 8114 → 8180 | 7320 → 7384 |
busgen |
final | 11148 → 11268 | 8062 → 8134 | 7296 → 7356 | 6512 → 6576 |
Every other case is unchanged, to the byte: the choice is a constant per element type
(DeserializeShrinkWrap::PLAIN_BYTES, hidden, None by default), so a list of anything else compiles to the pass
alone. Two other shapes were measured and were larger: one shared function that computes the end and the reader
state after a failure (vec_de 714, busgen 9082 on RV32IC), and the same on top of read_raw_slice (670, 9114).
Only these twelve types, on purpose. ElementSize::Sized { size_bits } is not used to skip other elements:
- It is the size of the value without alignment padding, so it is not the stride of an element:
struct { a: bool, b: u8 }is 9 bits and takes 2 bytes.bool,u1..uNandNibbleare bit-packed, their elements share bytes with each other and the last one with the lengths read from the back. - The pass is also where an element is validated: an enum's discriminant, a string's UTF-8, a nested length. Without it those errors would move from decode time to the iteration, where code written against today's behaviour does not expect them.
shrink_wrap/tests/ref_vec_skip.rs holds the two against each other, result and whole reader state: every type,
empty to long lists, every truncation, counts up to u32::MAX, lists that start in the middle of a byte or end
where the lengths begin, inside a struct with every byte damaged in turn, and 40 000 random buffers read from
random reader states. just fuzz-shrink-wrap-ref-vec does the same under libFuzzer.
Latched bit fields¶
Done after SW-37 (SW-38). U1..U64 and I2..I64 fields are read like the integers of step 3: no error check
each, one rd.latched()? per type. The derive cannot tell U4 from a nested struct by its name, so the type says
so itself: their DeserializeShrinkWrap::des_shrink_wrap_latch (hidden, read by default) calls
BufReader::read_un32_latch / read_un64_latch, which are read_un32 / read_un64 with the error kept, and the
derive reads every field of a user type through it. The wire format does not change.
| case | types | rv32i | rv32ic | rv32ic-relax | thumbv6m |
|---|---|---|---|---|---|
bits_de |
1364 → 1276 | 886 → 846 | 786 → 746 | 700 → 666 |
Every other case is unchanged, to the byte: none of them has such a field.
As first landed, this was limited to types whose every field is latched: since step 3 a type that mixed latched
fields with lists or nested types could report a later error instead of the first one, and the U/I fields
were not to join that. The mixed types were fixed right after (SW-41, Fixed on the way), and
the limit went with it.
Not latched: Nibble, u64 / i64 / f64, u128 / i128, the varints. Tried, and one field of a kind costs
more than it saves: the read is a function of its own then, where with ? it was inlined into its only caller. A
struct of five U/I fields, a bool, a Nibble, a UNib32 and a u64 (every field latched, before the
rule above):
| latched | rv32i | rv32ic | thumbv6m |
|---|---|---|---|
| nothing (before) | 1628 | 1086 | 900 |
U/I |
1580 | 1060 | 872 |
U/I, Nibble, u64, UNib32 |
1628 | 1090 | 908 |
the same with three more Nibble, UNib32 and u64: nothing |
2232 | 1452 | 1204 |
U/I |
2172 | 1424 | 1180 |
U/I, Nibble, u64, UNib32 |
2036 | 1358 | 1100 |
From about two fields of a kind it would pay.
Discriminants other than u8 / u16 / u32 (Nibble, u1..u31, UNib32) and TailSize slots are read with
? as before: the code decides with them before latched() is checked. (u8 / u16 / u32 discriminants and
Option / Result flags have been latched since step 3: a failed one reads as 0 / false, i.e. variant 0 or
None / Err, and since SW-41 the kept error is what the type returns in every case.)
shrink_wrap/tests/latched_reads.rs holds each of the 126 types against its read with a Result (value, error,
whole reader state, from every bit offset and from 20 000 random reader states, an earlier kept error stays
first) and derived structs and enums against the same fields read one by one with ?; six hand-made mutants
(a wrong value after an error, an overwritten, lost or wrong error, a wrong value, no sign extension) each fail it. just fuzz-shrink-wrap-latched does the same under libFuzzer.
Transparent fields¶
DER-20. A transparent wrapper (exact_units::Fixed<Second, Micro, u32>, a
Count(u32)) as a field of a derived type takes the path of its plain field on both sides. The read side had it
through des_shrink_wrap_latch; the write side gets the same: a hidden SerializeShrinkWrap::ser_shrink_wrap_latch
(ser_shrink_wrap by default, ser_shrink_wrap_owned_latch for owned types), called through
BufWriter::write_field for every user-type field. bool, u8..u32, i8..i32 and f32 override both sides with
their _latch methods, and a transparent struct forwards both to its field. One with validate (the bounds of a Fixed)
checks the value after the latched read and keeps a failure for rd.latched()? (BufReader::keep_error, an
earlier error stays first), so Fixed<.., Unbounded>, whose check is true, is the plain field's code. Before it, such a
field went through the wrapper's own ser_shrink_wrap with an error check per field: TEST-27's ELF comparison found
+2 to 15 instructions and +4 to 42 bytes per struct encode or decode on Cortex-M0, M4F and M33. After it the same
comparison (just units-elf, a struct of Fixed<Second, Micro, u64>, Fixed<Hertz, One, u32> and
Fixed<Ratio, One, u16> against u64, u32, u16) is identical in instructions and bytes on all three at
opt-level = "z" and on M0 at 3; at 3 the M4F / M33 encode has the same 80 instructions and 224 bytes and
differs only in register allocation. Cycles on silicon are not measured yet. The bytes and the
errors are the same; tests/transparent.rs (fields_are_latched_like_the_plain_field, validated_fields_are_latched) checks both against the
plain struct, tests/latched_reads.rs and the shrink_wrap_latched fuzz target hold the plain types' latched
writes against their checked ones.
Fixed on the way¶
Bit reads and the lengths in the last byte (SW-40). The last byte of a message can hold forward data in its
high nibble and the first length nibble in its low one. read_un32 / read_un64 read to the end of that byte
(with 4 bits left, read_un8(6) returned 2 bits of the length), and a length was read from a nibble the forward
side had read past. Both are errors now, for malformed input only. In bytes: the bit loop is shorter, the reverse
read has one check more:
| case | types | rv32i | rv32ic | rv32ic-relax | thumbv6m |
|---|---|---|---|---|---|
vec_de |
1004 → 1040 | 652 → 664 | 596 → 608 | 582 → 622 | |
bits_de |
1276 → 1168 | 846 → 774 | 746 → 686 | 666 → 622 | |
busgen_de |
evolvable | 8604 → 8696 | 6126 → 6182 | 5504 → 5564 | 5112 → 5168 |
busgen_ser |
evolvable | 5284 → 5296 | 3650 → 3676 | 3256 → 3282 | 3224 → 3228 |
busgen |
evolvable | 12664 → 12756 | 9086 → 9142 | 8180 → 8244 | 7384 → 7440 |
busgen |
final | 11268 → 11360 | 8134 → 8190 | 7356 → 7416 | 6576 → 6632 |
(busgen_ser grows because encoding a RefVec<RxFrame> contains its decoder, see Lists.)
The first error in types that mix latched and other fields (SW-41). Since step 3, a type with latched plain
fields and fields read with ? (a list, a string, a nested evolvable type, an Option of one) could return a
later error instead of the first: in a u32 followed by a RefVec<u8>, decoded from 0 to 5 bytes, 11 of 24
cases gave OutOfBoundsRev or OutOfBoundsReadU8 where reading field by field gives OutOfBoundsReadRawSlice. It
was an error in every case, but not the one it was before step 3. Now an error kept in the reader stops it: every
read with a Result returns that error, a _latch read gives 0, and no read moves; an enum without a variant 0
checks latched() before it reports an unknown discriminant (a discriminant read after an error is 0). So every
error exit of a derived decoder reports the first error, and nothing read after it decides anything.
Four forms were measured, RV32IC, bytes over the code before the fix:
| form | struct_de |
vec_de |
busgen evolvable |
busgen final |
|---|---|---|---|---|
map the error that leaves a derived decoder to the kept one (latch.take().unwrap_or(e), per type) |
+26 | +124 | ||
| the check at the start of every read | +16 | +48 | +68 | +116 |
the same, the reads with a Result only, the _latch ones go on |
0 | +34 | +30 | +90 |
| the check at the start of every read, the byte reads sharing one bounds check (chosen) | +48 | +28 | +20 | +20 |
The last one is the cheapest for a real decoder; the small cases pay for a function call the first one inlines. All targets, with the chosen form:
| case | types | rv32i | rv32ic | rv32ic-relax | thumbv6m |
|---|---|---|---|---|---|
rw_de |
648 → 732 | 444 → 492 | 404 → 446 | 364 → 402 | |
struct_de |
840 → 924 | 554 → 602 | 478 → 520 | 440 → 478 | |
enum_de |
1004 → 1088 | 678 → 726 | 586 → 628 | 512 → 550 | |
vec_de |
1040 → 1060 | 664 → 692 | 608 → 628 | 622 → 632 | |
bits_de |
1168 → 1256 | 774 → 822 | 686 → 728 | 622 → 652 | |
option_de |
980 → 1064 | 658 → 706 | 554 → 596 | 520 → 558 | |
busgen_de |
evolvable | 8696 → 8736 | 6182 → 6202 | 5564 → 5596 | 5168 → 5196 |
busgen_ser |
evolvable | 5296 → 5348 | 3676 → 3718 | 3282 → 3324 | 3228 → 3296 |
busgen |
evolvable | 12756 → 12800 | 9142 → 9162 | 8244 → 8276 | 7440 → 7468 |
busgen |
final | 11360 → 11388 | 8190 → 8210 | 7416 → 7438 | 6632 → 6652 |
shrink_wrap/tests/latched_errors.rs holds a request enum with each of these shapes against the same fields read
one by one with ?: the result (error included) and the reader of every truncation, every byte damaged in turn
and 20 000 random buffers; an enum without a variant 0 after a failed field. Removing any one of the checks makes
a test fail.
Fixed-layout fast path¶
A type whose encoding is a fixed byte layout (SW-44, derive)
does not need BufReader or BufWriter at all: each field is a load or store at a constant offset. The cases
header_de / header_ser read and write Header (a [u8; 4], two u8, a u16, four u32 and a u64) through
the reader and writer, fixed_de / fixed_ser the same type with from_fixed / to_fixed. bits_fixed_* is the
bit-packed Sample of bits_* (SW-45: shifts and masks at constant bit offsets instead of the bit loop of the
reader and writer):
| case | rv32i | rv32ic | rv32ic-relax | thumbv6m | thumbv7em |
|---|---|---|---|---|---|
header_de |
884 | 566 | 484 | 484 | 410 |
fixed_de |
580 | 406 | 354 | 228 | 120 |
header_ser |
1408 | 1022 | 918 | 888 | 856 |
fixed_ser |
788 | 612 | 548 | 462 | 452 |
bits_de |
1256 | 822 | 728 | 652 | 650 |
bits_fixed_de |
464 | 308 | 292 | 276 | 268 |
bits_ser |
1740 | 1204 | 1098 | 1034 | 716 |
bits_fixed_ser |
616 | 458 | 430 | 278 | 310 |
A third to a half less, and the gap grows with the number of types: the fast path has no shared runtime, each type
is its own straight-line code. The bytes left in fixed_ser are mostly memcpy (see Build settings).
Since SW-53 every multi-byte number goes through fixed_layout::Le at the bytes of its field: on RISC-V (no
unaligned loads, so a u32 is four byte loads and three shifts and ors, some 28 bytes) ld and st are one
function per type and a field is a call with a pointer, elsewhere they are inlined. That costs a type read or
written once: fixed_de / fixed_ser above grew from 378 / 498 bytes on RV32IC (the two functions per type are
more than the eight fields save). It pays as soon as fields are read and written in many places, as in a
firmware: busgen_ht 6004 → 5658 on RV32IC, busgen's own firmware 8.1 KB → 7.7 KB before its views. On a
Cortex-M0, which has no unaligned loads either, the inlined byte loads of 2-byte instructions take less than the
calls (busgen_view 3894 inlined, 4196 out of line), so it stays inline there. The bit fields' bytes are now
cleared once per head or struct instead of per field (bits_fixed_ser 698 → 458 on RV32IC, 560 → 278 on
Cortex-M0).
On a real format, chunky_mcu_updater's plan reader (header, signed descriptor, steps) takes 892 bytes on Cortex-M0 with
the fast path, 840 with its hand-written parser and 1854 through BufReader (its just size-bench).
Since chunky 0.9.0 its steps are a head_tail enum read through Step::view (a WRITE's data is a List<u8>, the
same wire bytes), and the reader is 734 bytes on Cortex-M0 and 912 on RV32IMC: below format 1's hand-written 840 /
1252 and below the fast path's 892 / 1086 (chunky_mcu_updater FEATURES.md, PROTO-3).
Head and tail¶
busgen_ht is busgen with head_tail types (SW-46, derive):
the plain structs fixed_layout, Utx, I2c, I2cSlave, Request and Reply head_tail, lists as
head_tail::List, the same handler. Every fixed field is a load at a constant offset, the lists, the string and
the Option<Wave> are taken from the tail by lengths already read from the head:
| case | types | rv32i | rv32ic | rv32ic-relax | thumbv6m | thumbv7em |
|---|---|---|---|---|---|---|
busgen |
final | 11388 | 8210 | 7438 | 6652 | 6816 |
busgen_hand |
7824 | 5762 | 5152 | 4408 | 4318 | |
busgen_ht |
7752 | 5658 | 5234 | 4590 | 3992 | |
busgen_view |
6572 | 4988 | 4546 | 3894 | 3496 |
busgen_view is the same types and handler through views and the variant writers (SW-52): Request::view(rx)
checks the lengths and walks the tail, the handler reads each field at its offset when it uses it, and each reply
is written by Reply::write_<variant>(tx, fields..), without making a Request or a Reply first. It is the
smallest of all: 13 % below the hand-written codec on RV32IC, 12 % on Cortex-M0, 19 % on Thumb-2. In busgen's real
firmware (fpga_tools BUS-12), where the handler also drives the engines, the views take 5608 bytes of .text for
the UART image and 5090 for the I2C one on RV32IC, against 4552 / 4716 hand-written: the hand-written firmware reads
a field as one small call and checks no length, the views check every length before the first field is used.
busgen_ht (values: from_head_tail, to_head_tail) is 2 % below the hand-written codec on RV32IC and 4 % above
on Cortex-M0, 31 % below the derived one; on Thumb-2 it is smaller. Unlike the hand-written one it checks every length (a short message is an error, not
zeros) and the UTF-8 of the string. Linker relaxation helps it less than the others: its calls are fewer, the code
is mostly inline loads. Two things were worth taking out of the runtime on the way: a checked multiplication for
the length of a list (on a Cortex-M0 its overflow check links a 200-byte division routine) and chunks_exact_mut
(divides by the chunk length); the list iterator walks a cursor instead of computing i * len. On the wire it is
a little smaller than busgen's own format too, since the bools of the head are bits.
Replies of generated servers¶
Measured after SW-41, not changed. The code ww_codegen! generates for a server ends every message with
finish(): the request's reply, a stream's data, a deferred return, an error. All but one of them write a
ww_client_server::Event, and Event pushes lengths whatever it holds (PUSHES_LEN is true: its ErrorKind is
evolvable and has a size slot, StreamData has a RefVec path), so for them finish_for is finish. The one
that is not is the return value of a method, serialized alone into a scratch buffer before it goes into the event
as TailBytes. reply is that code, and with --fw-features finish_for the same with finish_for::<Wave>() for
the return value:
| return value ends with | rv32i | rv32ic | rv32ic-relax | thumbv6m |
|---|---|---|---|---|
finish() (as generated) |
5120 | 3632 | 3236 | 3144 |
finish_for::<Wave>() |
5144 (+24) | 3646 (+14) | 3250 (+14) | 3160 (+16) |
The same for both flavors (a root Wave pushes no length either way). Event::ser_shrink_wrap is one function
for every variant of the event, so encode_len_fifo, write_rev_len and write_nib are linked in any firmware
with a generated server, even one whose methods all return plain values; finish_for at the return value only adds
finish_no_lens next to finish. The generated code keeps finish(). For the length FIFO to drop out of a
server, Event itself would have to lose its lengths: a final_structure/sized event, which is a wire change of
ww_client_server.
Nesting depth¶
The nesting depth limit (P2401) is a check in split(), one copy, and at every
read::<T>() of a final_structure type in place (Vec, RefVec, Option, a final_structure user type): a
compare with zero, a store before and one after. The counter itself fits in the padding of BufReader, which keeps
its size. Before and after, the cases that changed by more than 4 bytes:
| case | types | rv32i | rv32ic | rv32ic-relax | thumbv6m | thumbv7em |
|---|---|---|---|---|---|---|
header_de |
884 → 900 | 566 → 582 | 484 → 496 | 484 → 488 | 410 → 414 | |
vec_de |
1060 → 1092 | 692 → 724 | 628 → 660 | 632 → 648 | 580 → 620 | |
option_de |
1064 → 1072 | 706 → 712 | 596 → 602 | 558 → 558 | 560 → 560 | |
busgen_de |
evolvable | 8800 → 8924 | 6238 → 6340 | 5628 → 5718 | 5168 → 5176 | 4932 → 5028 |
busgen_de |
final | 7828 → 7996 | 5600 → 5660 | 5058 → 5116 | 4636 → 4756 | 4320 → 4400 |
busgen_ser |
evolvable | 5372 → 5432 | 3734 → 3762 | 3338 → 3370 | 3288 → 3336 | 3394 → 3350 |
busgen |
evolvable | 12816 → 12928 | 9166 → 9246 | 8286 → 8362 | 7420 → 7488 | 7576 → 7668 |
busgen |
final | 11388 → 11572 | 8210 → 8396 | 7438 → 7616 | 6652 → 6804 | 6816 → 6908 |
reply |
7140 → 7140 | 5178 → 5180 | 4534 → 4536 | 4202 → 4230 | 3862 → 3866 |
About 1 to 2.5 % of a whole codec. busgen final is 6804 on thumbv6m and 8396 on RV32IC with the saved depth stored
back after the read; a decrement before and an increment after is 6768 and 8408, the check in one shared function
called at every such read 6816 and 8408. There is no switch to turn the limit off: decoding a recursive type from
untrusted data without it overflows the stack.
Larger MCUs: Cortex-M4F / M7¶
The thumbv7em column is there to see whether Thumb-2 changes the picture. It does not: with the same profile the
images are about as large as on Cortex-M0, and the changes above saved about as much. Before them (925a29b1) and
now:
| case | types | thumbv6m before | thumbv6m now | thumbv7em before | thumbv7em now |
|---|---|---|---|---|---|
| rw_ser | 1460 | 384 | 1224 | 412 | |
| struct_ser | evolvable | 1596 | 830 | 916 | 516 |
| enum_ser | evolvable | 1606 | 864 | 1374 | 572 |
| vec_ser | evolvable | 1472 | 1030 | 844 | 684 |
| option_ser | evolvable | 1710 | 888 | 1414 | 608 |
| busgen_de | evolvable | 6316 | 5196 | 6356 | 4836 |
| busgen_ser | evolvable | 4054 | 3296 | 3902 | 3386 |
| busgen | evolvable | 9302 | 7468 | 9058 | 7612 |
| busgen | final | 8370 | 6652 | 8426 | 6816 |
| busgen_hand | 4408 | 4408 | 4318 | 4318 |
The small serializers are 30 to 40 % smaller on Thumb-2 than on Cortex-M0, the whole codec is not (7.6 against
7.5 KB): 16 % smaller than before on Thumb-2, 20 % on Cortex-M0. In a real firmware the share is what makes the
difference small: examples_mcu/usb_stm32h725ig's all_gpio (embassy, USB, defmt; 66 KB of flash) went from
67412 to 66172 bytes, 1.8 %.
Build settings¶
Not shrink_wrap's to fix, but worth more than each code change:
- Linker relaxation on RISC-V.
riscv32i-unknown-none-elfbuilds without it, so every call that is not inlined isauipc+jalr, 8 bytes. With-C target-feature=+relaxthe linker turns them intojal(4 bytes) orc.jal(2 bytes):busgen9000 → 8114,busgen_hand5762 → 5152. Add it to therustflagsof any RISC-V firmware that is short of space. -
A byte-wise
memcpy, for very small targets only (SW-39): a measurement and advice, nothing inshrink_wrapdepends on it or asks for it. Thememcpyofcompiler_builtinsis 338 bytes on RV32IC (316 on Cortex-M0), word-wise with alignment handling. It is linked as soon as a value of more than a few words is moved, which a request or reply enum is, and an encoder's value is;-Zbuild-std-features=optimize_for_sizedoes not change it. A firmware can define its own, a byte at a time: 22 bytes on RV32IC and Cortex-M0, 36 on RV32I. Its symbol wins over the weak one ofcompiler_builtins. Only worth it where 300 bytes decide whether the firmware fits (the 8 KB softcore above), and only with the downsides in mind:- Speed: one byte per iteration instead of a word, about four times slower for long copies. Fine for moves of a few dozen bytes, not for a firmware that copies buffers.
- It replaces
memcpyfor the whole firmware, every crate in it: HAL, drivers, buffer copies of the transport, not onlyshrink_wrap's moves. - The loop can be turned back into a call of
memcpy: LLVM recognizes a byte copy loop and replaces it withmemcpy, which here is the function itself, an endless recursion.compiler_builtinsis built with#![no_builtins]against this; the code below uses a volatile store. With the nightly of October 2026 LLVM leaves the loop alone without it as well, inmemcpyand in__aeabi_memcpy, but nothing promises that. Check the disassembly when changing it. memmove,memset,memcmpare separate and stay those ofcompiler_builtins(none of them is linked in the bench images).- On Arm, code calls
__aeabi_memcpy, whosecompiler_builtinsversion calls its ownmemcpydirectly, so that symbol has to be defined too (and__aeabi_memcpy4/__aeabi_memcpy8if the code uses them).
mod byte_memcpy {
use core::ffi::c_void;
#[inline(always)]
unsafe fn copy(dest: *mut c_void, src: *const c_void, n: usize) {
let (dest, src) = (dest.cast::<u8>(), src.cast::<u8>());
let mut i = 0;
while i < n {
// volatile: LLVM must not turn the loop back into a call of `memcpy`, i.e. of itself
unsafe { dest.add(i).write_volatile(*src.add(i)) };
i += 1;
}
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn memcpy(dest: *mut c_void, src: *const c_void, n: usize) -> *mut c_void {
unsafe { copy(dest, src, n) };
dest
}
#[cfg(target_arch = "arm")]
#[unsafe(no_mangle)]
pub unsafe extern "C" fn __aeabi_memcpy(dest: *mut c_void, src: *const c_void, n: usize) {
unsafe { copy(dest, src, n) };
}
}
just size-bench --fw-features byte_memcpy builds the bench with it (shrink_wrap/size_bench/fw/src/main.rs,
off by default, the tables above are without it); every image that copies a value is 300 to 410 bytes smaller,
the others do not change:
| case | types | rv32i | rv32ic | rv32ic-relax | thumbv6m |
|---|---|---|---|---|---|
struct_ser |
1348 → 936 | 964 → 640 | 872 → 554 | 830 → 526 | |
vec_ser |
1634 → 1222 | 1142 → 818 | 1074 → 756 | 1030 → 726 | |
busgen_de |
evolvable | 8604 → 8192 | 6126 → 5802 | 5504 → 5182 | 5112 → 4806 |
busgen_ser |
evolvable | 5284 → 4872 | 3650 → 3326 | 3256 → 2938 | 3224 → 2922 |
busgen |
evolvable | 12664 → 12252 | 9086 → 8762 | 8180 → 7848 | 7384 → 7082 |
busgen |
final | 11268 → 10856 | 8134 → 7810 | 7356 → 7030 | 6576 → 6270 |
busgen_hand |
7824 → 7412 | 5762 → 5438 | 5152 → 4834 | 4408 → 4104 | |
rw_de, struct_de, vec_de, bits_de, .. |
unchanged |
The other _ser cases lose the same 412 / 324 / 318 / 304 bytes. With both settings the derived busgen codec
is 7848 bytes on RV32IC (evolvable) and 7030 (final), the hand-written one 4834.
Feature flags¶
The options that were on the table, and why none became a cargo feature:
| idea | verdict |
|---|---|
| decode-only / encode-only | nothing to do: unused code is not linked (6.0 KB and 3.7 KB of the 9.0 KB) |
no u4 / nibble / varint support |
nothing to do for the types (UNib32, UVlq32, Nibble are linked only when used); the nibble code of the length FIFO is linked only for types with lengths (step 4) |
| no evolution support | exists per type as sized / final_structure (0.9 to 1.0 KB here). As a cargo feature it would change the wire format of every type at once, and cargo unifies features across a build: one crate in the tree turning it on would silently change the bytes of all the others |
| fixed-size-only mode, no length FIFO | done without a flag (step 4): decided per type at compile time |
| cheaper errors | done (step 2), for everybody |
#[inline(never)] shared helpers, non-generic paths |
done for the integer reads and writes (steps 2 and 3) |
What is left¶
With views and writers (SW-52, Head and tail) the derived codec fits the 8 KB softcore: busgen's
firmware is 5608 bytes (UART) and 5090 (I2C) on RV32IC, against 4552 / 4716 hand-written, with a ceiling of about
6.3 KB. What is left below is for the value path (BufReader, from_ww_bytes) and evolvable types, which the views
do not use; the sizes quoted in the list are of busgen through that path, still 3.0 to 3.3 KB above the
hand-written codec on RV32IC. In order of what they would bring:
RefVec<T>ofSizedstructs and enums without a decode pass: not planned. It was done for plain numbers only (SW-37, above), where it is provably the same as the pass, and there it costs code instead of saving it. For otherSizedtypes it would also change behaviour: the stride of an element is not itssize_bits(alignment padding:struct { a: bool, b: u8 }is 9 bits, 2 bytes per element), and an invalid element (an enum discriminant) would be reported when the list is walked instead of when it is decoded.- Latched reads for the rest: done for
U1..U64/I2..I64(SW-38, above): 40 bytes less for a bit-packed struct on RV32IC, nothing for the busgen types. Not done:Nibble,u64and the varints pay only from about two fields of a kind. Fixed on the way: mixed types report the first error again (SW-41, 20 bytes onbusgen). - A firmware-side
memcpy: measured and documented as advice for very small targets, not a code change (SW-39, Build settings): 324 bytes less on RV32IC for a firmware that moves a value, at the price of speed and of replacingmemcpyfor the whole firmware. finish_forin generated server and client code: measured, not done (Replies of generated servers): every server message is aww_client_server::Event, which always pushes lengths, so the FIFO stays;finish_forfor a return value costs 14 bytes on RV32IC instead of saving any. The client side runs onstdonly. What would remove it is a size-assumedEvent, a wire change.- The rest of the length FIFO for types with lengths:
encode_len_fifo214,write_nib130,write_rev_len126 bytes on RV32IC. Most of what is left is byte loads and stores, which have no compressed form there, and two checks against the end of the buffer.
For a softcore with 8 KB in all the derived codec is the right choice now, through views: the firmware it was
measured for is 4.55 KB (UART image) with the hand-written codec and 5.6 KB with views. The value path alone (derived
BufReader codec, 7.3 KB at best) would not fit, and nothing in the list above changes that. With 16 KB, or on a
Cortex-M0 with 32 KB of flash, either fits with room to spare.
Where the views' last kilobyte goes. In the real UART image the views are about 1 KB above the hand-written codec (symbol diff of fpga_tools v0.8.0 and v0.9.0, 14 October 2026, RV32IC):
mainis 802 bytes larger: the handler reads each field inline, so the byte loads and stores ofu8fields, tags and headers (lbu33 → 50,sb47 → 58) are in the dispatch itself, where the hand-written one calls a small function. The reply buffers are built with a fewmemcpyandmemsetcalls (4 and 2).- A few
__mulsi3calls: this RISC-V has no multiplier, so every multiplication by a non-constant is a call. They do not come from walking a list (ListIteradvances a cursor and divides nothing). The remaining places areList::get(i), which multiplies the index by the item length, andtake_items, a count times an item length (a constant per list type, so mostly shifts). Which of them produced the four calls was not looked at. ListIter<Wave>andListIter<u16>together are 244 bytes;Wave::from_fixedis 150 against 92 for theBufReaderread of the same struct, since every multi-byte field goes throughLe(SW-53).
Ideas to win some of it back (fewer inline byte accesses, an index without the multiplication, replies written straight into the output buffer) are tracked in the project's task list, estimated at 300 to 500 bytes; the firmware fits without them.
A small UART transport¶
The softcore talks over a UART with its own framing: COBS(seq, message, crc16) 0x00, CRC-16/XMODEM a nibble at a
time from a 16-entry table, streaming on both sides (no second buffer), a reply carries the seq of its request.
That module of busgen_proto, built alone with the same profile:
| rv32i | rv32ic | thumbv6m | |
|---|---|---|---|
| encoder | 472 | 326 | 242 |
| decoder | 376 | 254 | 226 |
| both | 724 | 500 | 418 |
plus 32 bytes of CRC table in .rodata, 8 bytes of decoder state and the receive buffer.
As a crate of its own (ww_uart_lite or similar: a framing layer below ww_link, with the host side as a
Transport in wire_weaver_client) it would cost about that, 0.5 KB on RV32IC. Not built, and not measured: what
ww_link's link setup and ww_client_server's request/response types add on top on such a CPU. The bench has a
place for that case when the transport is built.