Deep dive
How it works
In: values, each with a width or range fixed by the schema · Out: a byte buffer plus an exact bit count · State: writer: u64 scratch, bits held, buffer; reader: slice, byte position, bits consumed · Cost: O(1) per value, one word store per 32 bits
- Widths come from the schema, not the value. bool 1 bit; enum
bits_for(variants-1); bounded int storesv-mininbits_for(max-min)bits, withbits_for(span) = 32 - span.leading_zeros()(0 bits when min == max); a quantized float is an integer over its range; an optional field is a presence bit then the payload; an array is a bounded count then elements. - Write:
scratch |= value << held; held += n. Atheld >= 32, store the low 32 bits as a little-endian word and shiftscratchright by 32. Withheld < 32andn <= 32a u64 cannot overflow. - Bit order is LSB-first: the first bit written is bit 0 of byte 0. A reader with the other order decodes wrong values without any error. DEFLATE packs fields LSB-first but Huffman codes MSB-first (RFC 1951 §3.1.1): conventions can differ per element type.
- Align before a byte blob: zero bits up to the byte boundary, then whole bytes. The reader rejects nonzero padding.
- Finish: flush the partial scratch as
ceil(held/8)bytes. Length is in bytes, so up to 7 trailing bits are padding; the schema says where data ends. - Read: before every read check
consumed + n <= 8 * len, else error. Refillscratch4 bytes at a time, masknbits. - Range-check: a span that is not a power of two (
0..=10takes 4 bits, which hold 0..=15) can decode abovemax: error. - Any error drops the whole packet.
Little-endian word flushes make the stream host-independent; with big-endian words a packet cut at its last used byte would lose that byte (Fiedler).
Code
#[derive(Debug, PartialEq)]
pub enum Error { Overrun, OutOfRange, BadPadding }
/// Bits needed for values 0..=span: ceil(log2(span + 1)); 0 bits when span == 0.
pub fn bits_for(span: u32) -> u32 { 32 - span.leading_zeros() }
#[derive(Default)]
pub struct BitWriter { buf: Vec<u8>, scratch: u64, held: u32 } // LSB-first: first bit written = bit 0
impl BitWriter {
pub fn write_bits(&mut self, value: u32, n: u32) {
assert!(n <= 32 && (n == 32 || value >> n == 0), "value does not fit in n bits");
self.scratch |= (value as u64) << self.held; // held < 32, n <= 32: no overflow
self.held += n;
if self.held >= 32 { // flush one little-endian word, keep the overflow
self.buf.extend_from_slice(&(self.scratch as u32).to_le_bytes());
self.scratch >>= 32;
self.held -= 32;
}
}
pub fn write_bool(&mut self, b: bool) { self.write_bits(b as u32, 1) }
pub fn write_bounded(&mut self, v: u32, min: u32, max: u32) {
assert!(min <= v && v <= max);
self.write_bits(v - min, bits_for(max - min));
}
pub fn bits(&self) -> usize { self.buf.len() * 8 + self.held as usize }
pub fn align(&mut self) { let pad = (8 - self.bits() % 8) % 8; self.write_bits(0, pad as u32) }
pub fn write_bytes(&mut self, b: &[u8]) { self.align(); for &x in b { self.write_bits(x as u32, 8) } }
pub fn finish(mut self) -> Vec<u8> {
for i in 0..(self.held + 7) / 8 { self.buf.push((self.scratch >> (8 * i)) as u8) }
self.buf
}
}
pub struct BitReader<'a> { buf: &'a [u8], pos: usize, scratch: u64, held: u32, read: usize }
impl<'a> BitReader<'a> {
pub fn new(buf: &'a [u8]) -> Self { BitReader { buf, pos: 0, scratch: 0, held: 0, read: 0 } }
pub fn read_bits(&mut self, n: u32) -> Result<u32, Error> {
assert!(n <= 32);
if self.read + n as usize > self.buf.len() * 8 { return Err(Error::Overrun) } // check first
if self.held < n { // refill: 4 bytes at once when possible
if self.held <= 32 && self.pos + 4 <= self.buf.len() {
let w = u32::from_le_bytes(self.buf[self.pos..self.pos + 4].try_into().unwrap());
self.scratch |= (w as u64) << self.held;
self.pos += 4; self.held += 32;
} else {
while self.held < n { self.scratch |= (self.buf[self.pos] as u64) << self.held; self.pos += 1; self.held += 8 }
}
}
let v = (self.scratch & ((1u64 << n) - 1)) as u32;
self.scratch >>= n; self.held -= n; self.read += n as usize;
Ok(v)
}
pub fn read_bool(&mut self) -> Result<bool, Error> { Ok(self.read_bits(1)? == 1) }
pub fn read_bounded(&mut self, min: u32, max: u32) -> Result<u32, Error> {
let v = self.read_bits(bits_for(max - min))? as u64 + min as u64;
if v > max as u64 { Err(Error::OutOfRange) } else { Ok(v as u32) } // 0..=span is not a power of two
}
pub fn align(&mut self) -> Result<(), Error> {
let pad = (8 - self.read % 8) % 8;
if self.read_bits(pad as u32)? != 0 { Err(Error::BadPadding) } else { Ok(()) }
}
pub fn read_bytes(&mut self, n: usize) -> Result<Vec<u8>, Error> {
self.align()?; // n is bounded by the caller; the overrun check stops the loop early
(0..n).map(|_| self.read_bits(8).map(|b| b as u8)).collect()
}
}
fn main() {
// Mixed packet: bounded int, bool, optional field (presence bit), bounded-count array, aligned blob.
let mut w = BitWriter::default();
w.write_bounded(1000, 0, 1023); // 10 bits
w.write_bool(true); // 1
w.write_bool(true); w.write_bounded(7, 5, 12); // presence bit + 3 bits
w.write_bounded(3, 0, 8); // array count 0..=8: 4 bits
for v in [1, 200, 99] { w.write_bits(v, 8) }
assert_eq!(w.bits(), 10 + 1 + 1 + 3 + 4 + 24);
w.write_bytes(b"hi"); // pads 43 -> 48 bits, then 16
assert_eq!(w.bits(), 64);
let bytes = w.finish();
assert_eq!(bytes.len(), 8);
let mut r = BitReader::new(&bytes);
assert_eq!(r.read_bounded(0, 1023), Ok(1000));
assert_eq!(r.read_bool(), Ok(true));
assert_eq!((r.read_bool(), r.read_bounded(5, 12)), (Ok(true), Ok(7)));
let n = r.read_bounded(0, 8).unwrap();
let arr: Vec<u32> = (0..n).map(|_| r.read_bits(8).unwrap()).collect();
assert_eq!(arr, [1, 200, 99]);
assert_eq!(r.read_bytes(2).unwrap(), b"hi");
assert_eq!(r.read_bits(1), Err(Error::Overrun)); // past the end
// Value that fits the bit width but not the range: 4 bits hold 0..=15, range is 0..=10.
let mut w = BitWriter::default(); w.write_bits(13, 4);
assert_eq!(BitReader::new(&w.finish()).read_bounded(0, 10), Err(Error::OutOfRange));
// Random round trip (xorshift64).
let mut x = 0x9E3779B97F4A7C15u64;
let mut next = || { x ^= x << 13; x ^= x >> 7; x ^= x << 17; x };
let items: Vec<(u32, u32)> = (0..100_000).map(|_| { let n = (next() % 33) as u32; ((next() as u32) & (((1u64 << n) - 1) as u32), n) }).collect();
let mut w = BitWriter::default();
for &(v, n) in &items { w.write_bits(v, n) }
let total: u32 = items.iter().map(|i| i.1).sum();
assert_eq!(w.bits(), total as usize);
let bytes = w.finish();
let mut r = BitReader::new(&bytes);
for &(v, n) in &items { assert_eq!(r.read_bits(n), Ok(v)) }
println!("ok");
}
Complexity and constants
- O(1) per value;
ceil(bits/8)bytes, at most 7 padding bits. - Measured by me, Apple M4 Pro, one thread,
--release: 64 entities per packet, 66 bits each (ids 10, two fields of 18, one of 12, bounded 0..=100 in 7, one bool), 528 bytes. Write 405 M values/s (with a freshVecper packet), read 455 M values/s (cache-hot packet). - As aligned fields (u16, three f32, u8, u8) the entity is 128 bits; 66 is 52 % of that (arithmetic).
- Cost drivers: value count rather than bit count, the flush branch,
Vecgrowth.
Efficiency
- Cost: about 2.5 ns per value written and 2.2 ns read (measured above, M4 Pro),
ceil(bits/8)bytes per packet. The bound is instruction throughput and the flush branch: 5 Gbit/s is far below memory bandwidth. - Levers:
- range-bounded ints — 0..=1000 takes 10 bits instead of 16 (Fiedler) — fields with a known maximum
- bool as 1 bit — 8x less than a byte (Fiedler) — flag-heavy state
- quantize floats first — position 96 → 56 bits in the State quantization example — bounded physical values
- presence bit plus delta against an acknowledged baseline — unchanged field costs 1 bit (Snapshot delta compression) — slowly changing state
- Naive → standard → state of the art:
- text or JSON with field names · the description is larger than the data (Fiedler)
- byte-aligned structs · 128 bits for the example entity
- bounded, quantized bit-packing · 66 bits, about 2.5 ns per value
- the same plus delta and an entropy coder for skewed fields · fewest bits, more CPU and state
Implementation notes
- Derive each width from its range constant (
bits_for(MAX)); never type both. - One
serializefunction generic over the stream keeps read and write in sync (Fiedler, Serialization Strategies). - Bound every array count and string length in the schema; allocate only after the bound passes.
- Start each packet with a protocol id or schema hash; add a checksum or authenticated encryption, since in-range bit flips go unnoticed.
- Log
bits()per field and packet kind: the biggest total is the next target.
Pitfalls
- Widening a constant (
MaxElements32 → 200) without its bit count truncates silently (Fiedler's example). 1u32 << 32panics or is undefined: mask in u64.- Length is rounded up to bytes, so "read until the end" sees phantom zero values.
- Mismatched bit order, enum order or range gives plausible garbage, not an error: test with golden byte vectors.
- Clamp NaN and infinity before quantizing; offset signed ints by
min.
Variants and alternatives
In production
- Fiedler's
serialize.hand yojimbo: 1-bit bool, ranged ints, alignment, one function for read and write. - DEFLATE (RFC 1951) and video codecs: bit-granular headers.
- LoRaWAN: maximum payload is region and data-rate specific (The Things Network).
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| bitstream-io | Rust | tuffy/bitstream-io | cargo add bitstream-io | — | — | — | — | Unaligned reads and writes in big- and little-endian order |
| serialize | C++ | mas-bandwidth/serialize | brew install serialize | — | — | — | — | Single header: 1-bit bool, ranged ints, alignment |
| yojimbo | C++ | mas-bandwidth/yojimbo | git clone https://github.com/mas-bandwidth/yojimbo | — | — | — | — | Game network library with bitpacker; build from source |
| deku | Rust | sharksforarms/deku | cargo add deku | — | — | — | — | Derive macro for bit-level structs |
| bitvec | Rust | bitvecto-rs/bitvec | cargo add bitvec | — | — | — | — | Bit-addressed collections; no packet reader |
| bitbuffer | Rust | icewind/bitbuffer | cargo add bitbuffer | — | — | — | — | Reads bit sequences from a byte slice |
Sources
- G. Fiedler, Reading and Writing Packets, Gaffer On Games, 2016. article
- G. Fiedler, Serialization Strategies, Gaffer On Games, 2016. article · code
- P. Deutsch, DEFLATE Compressed Data Format Specification 1.3, RFC 1951, 1996, §3.1.1. rfc
- Nasdaq, TotalView-ITCH 5.0, data types. spec
- The Things Network, LoRaWAN message types. docs
- ITU-T, Recommendation H.264. page