pdk-serde-fixint 1.11.0

PDK serde codec, byte-compatible with the bincode 1.3 default format
Documentation

pdk-serde-fixint

A small, self-owned serde codec that is byte-for-byte compatible with the bincode 1.3 default configuration (little-endian, fixed-width integers, u64 length prefixes, u32 enum variant tags, reject-trailing-bytes on decode).

Why this crate exists

bincode 1.3 is end-of-life and must not appear in the runtime dependency graph of any built policy. But the PDK persists state (rate-limit buckets, cache entries, lock values, connection counts, …) with bincode's wire format, so the replacement has to read bytes that were already written by the old code.

serde_fixint reproduces that exact wire format in-tree. The legacy bincode crate is kept only as a dev-dependency to prove compatibility — see tests/interop.rs, which asserts, for every representative PDK type, that:

  1. our encoder produces the exact bytes bincode produces,
  2. bincode decodes our bytes, and
  3. we decode bincode's bytes.

Usage

let bytes = serde_fixint::to_vec(&value)?;
let value: T = serde_fixint::from_slice(&bytes)?;

Performance

benches/codec.rs (criterion, dev-dependency only) compares serde_fixint against bincode 1.3 over PDK-representative wire types. Run with:

cargo bench -p pdk-serde-fixint

Median times below, Apple M4 Max, rustc 1.88.0, --release. fixint is this crate; bincode is the legacy reference. Absolute numbers are machine-specific; the ratio is the point of interest.

Payload Op fixint bincode fixint / bincode
u32 encode 9.55 ns 9.39 ns 1.02×
u32 decode 0.42 ns 0.40 ns 1.05×
SystemTime encode 15.29 ns 15.74 ns 0.97×
SystemTime decode 2.45 ns 2.47 ns 0.99×
Option<SystemTime> encode 15.45 ns 15.64 ns 0.99×
Option<SystemTime> decode 3.44 ns 4.73 ns 0.73×
String (34 B) encode 15.23 ns 15.27 ns 1.00×
String (34 B) decode 17.67 ns 18.74 ns 0.94×
Bucket (1 limit) encode 14.54 ns 18.10 ns 0.80×
Bucket (1 limit) decode 17.39 ns 16.67 ns 1.04×
Bucket (16 limits) encode 55.89 ns 81.23 ns 0.69×
Bucket (16 limits) decode 66.21 ns 106.0 ns 0.62×
Bucket (128 limits) encode 312 ns 552 ns 0.56×
Bucket (128 limits) decode 356 ns 707 ns 0.50×

Reading these numbers

serde_fixint is at parity with bincode 1.3 on scalars and faster on compound values — the Bucket types (the largest structured payloads the PDK persists) encode and decode in roughly half bincode's time. Everything is at nanosecond-to-microsecond scale; in a policy this is dwarfed by the WASM host boundary, shared-data/Redis round-trips, and network I/O.

The two cases that read as slightly behind — u32 decode (~1.05×) and Bucket (1 limit) decode (~1.04×) — are sub-nanosecond differences (tens of picoseconds and ~0.7 ns). At that scale they sit at the timer's noise floor: bincode's own u32 decode median swings ±15% run-to-run, larger than the gap itself. They are not a meaningful algorithmic difference.

Three implementation choices close the gap that a naive serde codec would leave:

  • Encode pre-sizing. to_vec walks the value once with a size-only pass (size.rs, integer adds, no writes), then encodes into a Vec allocated to that exact capacity — so the encode pass never reallocates. This mirrors what bincode 1.3 does internally and is what makes compound encode competitive: without it, a Vec::new() encode pays repeated grow+memcpy. On compound values fixint then beats bincode because its size pass is leaner than bincode's generic one.
  • Copy-free fixed-width reads. The decoder reads each multi-byte integer via split_first_chunk::<N>(), feeding an array reference straight into from_le_bytes with a single bounds check and no intermediate copy. Hot ser/de methods are #[inline].
  • Direct single-byte reads. Single bytes — Option/enum tags and bool, hit on every value — read via split_first() rather than the const-generic chunk path, which the compiler turns into tighter code. This is what makes Option<SystemTime> decode beat bincode rather than trail it.

The output is wire-identical to bincode (the bench asserts equal bytes before timing), so old persisted state stays readable, and the whole thing removes an end-of-life dependency from every policy's runtime graph.