The 5.4× Breakthrough: Replacing Bincode with Raw Binary Serialization for a 25.7 GiB Circuit Cache
Introduction
In the course of optimizing a Groth16 proof generation pipeline for Filecoin's Proof-of-Replication (PoRep), a team of engineers discovered a surprising bottleneck: the disk serialization format they had chosen was making their carefully constructed cache slower than recomputing the data from scratch. Message [msg 1641] in this coding session captures the precise moment when that realization crystallized into action — a decision to abandon the safe, generic bincode serialization in favor of an audacious raw binary format built on unsafe byte reinterpretation. This message is a masterclass in performance-driven engineering: it demonstrates how empirical measurement can overturn theoretical assumptions, how understanding the memory layout of your types unlocks optimization opportunities, and how a willingness to use unsafe Rust, when justified, can yield a 5.4× speedup on a 25.7 GiB data structure.
The Context: A Cache That Couldn't Beat Recalculation
To understand why message [msg 1641] matters, we must first understand the problem it solved. The team had built a Pre-Compiled Evaluator (PCE) — a mechanism that pre-computes constraint system matrices for a given circuit topology, storing them as Compressed Sparse Row (CSR) structures. The PCE was designed to accelerate the synthesis phase of Groth16 proof generation by avoiding redundant constraint evaluation. When a proof request arrived for a circuit type that had been seen before, the PCE could be loaded from disk instead of re-extracted from the circuit definition.
The numbers told a compelling story about the PCE's value: extraction took 47 seconds, while the PCE itself was a massive 25.7 GiB on disk. But when the team benchmarked the bincode-based serialization in message [msg 1637], they got a shock:
| Operation | Time | Throughput | |-----------|------|------------| | Save (tmpfs) | 16.8s | 1.6 GB/s | | Save (NVMe) | 30.6s | 0.9 GB/s | | Load (tmpfs) | 49.9s | 0.6 GB/s |
The load time of 49.9 seconds was worse than the 47-second extraction. This meant the disk cache was counterproductive — loading the PCE from disk took longer than recomputing it. The bottleneck was not I/O (tmpfs delivers ~6 GB/s sequential reads), but bincode's per-element deserialization of 722 million scalar field elements.
The Message: A Decision Point
Message [msg 1641] is the pivot. The assistant writes:
I'll replace the bincode-based save/load with a raw binary format. The key: useunsafebyte reinterpretation forVec<u32>andVec<Scalar>since both have known, stable memory layouts. ForScalar(blstrs::Scalar = 32 bytes), this is safe because it's#[repr(transparent)]overblst_fr.
This is a remarkably concise statement that packs several layers of reasoning. Let us unpack them.
First, the assistant has already performed the diagnosis. In the preceding messages ([msg 1637], [msg 1638], [msg 1639]), the assistant ran benchmarks, identified bincode as the bottleneck, calculated the theoretical performance of a raw format (estimating 3-5 seconds vs 50 seconds), and verified that blstrs::Scalar has a #[repr(transparent)] layout over blst_fr which is [u64; 4] — a fixed 32-byte representation. The Python calculation in [msg 1638] showed that the raw format could achieve a 10-16× improvement.
Second, the assistant has weighed the tradeoffs. Bincode is safe, generic, and requires no unsafe code. But it deserializes each of the 722 million scalars individually, parsing length prefixes, dispatching on types, and performing per-element validation. For a Vec<Scalar>, bincode writes an 8-byte length prefix followed by each element serialized through its Serialize implementation. The Scalar type's serialization likely involves converting from internal [u64; 4] representation to a byte array — and on deserialization, the reverse. This per-element overhead, multiplied by 722 million, is what produces the 49.9-second load time.
The raw format, by contrast, treats the entire Vec<Scalar> as a contiguous byte buffer. Writing is a single write() call with a pointer cast; reading is a single read_exact() into a pre-allocated buffer, followed by a pointer cast to reconstitute the Vec<Scalar>. The cost is essentially the memory bandwidth of copying 25.7 GiB — which on tmpfs is about 3-4 seconds for the I/O plus allocation overhead.
The Reasoning: Why unsafe Is Justified Here
The assistant's justification for using unsafe is precise and well-reasoned. The key insight is that blstrs::Scalar is #[repr(transparent)] over blst_fr, which is itself a [u64; 4] array. This means:
- The memory layout of
Scalaris exactly 32 contiguous bytes, with no padding, no enum discriminants, and no hidden metadata. - A
Vec<Scalar>is a contiguous heap allocation ofN * 32bytes, with a 3-word header (pointer, length, capacity) on the stack. - Reinterpreting
&[Scalar]as&[u8]is safe because the byte representation is well-defined and stable. - Reinterpreting the other direction — constructing a
Vec<Scalar>from raw bytes — requiresunsafebecause Rust's type system cannot verify alignment, validity, or initialization at compile time. The assistant explicitly acknowledges theunsafenature: "The key: useunsafebyte reinterpretation." This is not reckless — it is a calculated decision based on knowledge of the type's representation guarantees. The#[repr(transparent)]attribute guarantees thatScalarhas the same ABI as its inner type, and the inner type (blst_fr = [u64; 4]) is a plain-old-data array with no invalid bit patterns. The risk is minimal because: - The bytes are written by the same program, on the same architecture, with the same endianness. - The file format includes a 32-byte header with magic bytes and versioning to reject corrupt or mismatched data. - TheVec<u32>andVec<Scalar>types have no invariants beyond being properly aligned and containing valid bit patterns for their types — andu32and[u64; 4]have no invalid bit patterns.
The Assumptions Underlying the Decision
The assistant makes several assumptions in this message, most of which are well-supported:
Assumption 1: The memory layout of Scalar is stable across compiler versions and targets. This is a reasonable assumption because blstrs is a well-established library (the BLS12-381 scalar field implementation used by the Filecoin ecosystem), and #[repr(transparent)] guarantees ABI compatibility with the inner type. However, if a future version of blstrs changed the inner representation (e.g., switching to a different field implementation), the raw binary format would silently produce garbage. The header's version field mitigates this: a version mismatch would trigger re-extraction.
Assumption 2: The I/O subsystem can sustain the bandwidth needed. The assistant's Python calculation assumed ~8 GB/s for DDR5 and ~5 GB/s for NVMe. These are optimistic — real-world sequential read speeds depend on file system fragmentation, kernel page cache state, and memory bus contention. The actual result (9.2 seconds from tmpfs, or ~3.0 GB/s) was lower than the ideal 3.2 seconds but still a dramatic improvement over bincode.
Assumption 3: The allocation overhead of creating 25.7 GiB of Vecs is acceptable. Loading the raw format requires allocating the full 25.7 GiB upfront, then reading into it. This means the system must have at least 25.7 GiB of free memory (plus the working set for the rest of the proving pipeline). On a machine with 256 GiB of RAM (typical for Filecoin storage miners), this is acceptable, but it does mean the load cannot be streamed or lazily evaluated.
Assumption 4: The density bitmap (Vec<u64>) can also be raw-cast. The assistant reads density.rs in this message to confirm that the density bitmap uses Vec<u64>, which is also safe to reinterpret as raw bytes. This is correct: u64 has no invalid bit patterns and a fixed 8-byte representation.
What Knowledge Was Required
To understand and make this decision, the assistant needed deep knowledge spanning several domains:
- Rust's type layout guarantees: Understanding
#[repr(transparent)],Vec's heap allocation model, and the semantics of pointer casting between&[T]and&[u8]. - Serialization internals: Knowing how bincode serializes vectors (8-byte length prefix + per-element serialization) and why that adds overhead for large flat arrays of primitive types.
- The blstrs library: Knowing that
blstrs::Scalaris a wrapper aroundblst_frwhich is[u64; 4], and that this representation is stable and has no invalid bit patterns. - File I/O performance characteristics: Understanding the difference between tmpfs (RAM-backed, ~6 GB/s sequential) and NVMe (PCIe 4.0, ~5 GB/s sequential) and how they interact with allocation bandwidth.
- The PCE data structure: Knowing the composition of
PreCompiledCircuit— three CSR matrices (A, B, C) each withrow_ptrs: Vec<u32>,cols: Vec<u32>,vals: Vec<Scalar>, plus density bitmaps asVec<u64>— and that all these vectors are flat arrays suitable for bulk byte operations.
The Output Knowledge Created
Message [msg 1641] itself does not produce code — it is a planning and information-gathering message. The assistant reads density.rs to confirm the density bitmap structure, then in the following message ([msg 1642]) writes the complete rewrite of disk.rs. But the message creates crucial design knowledge:
- The decision to use raw binary format is explicitly recorded. Anyone reading the conversation can see the reasoning chain: benchmark → identify bincode bottleneck → calculate raw format potential → verify type layouts → commit to
unsafeapproach. - The safety justification is articulated. The message states that
unsafeis acceptable because the types have "known, stable memory layouts" andScalaris#[repr(transparent)]. This provides a rationale that could be cited in a code review or security audit. - The scope of the change is bounded. By reading
density.rs, the assistant confirms that all the vectors in the PCE structure are amenable to raw casting — no complex nested structures or variable-length encodings need special handling.
The Results: Validation of the Decision
The subsequent messages validate the assistant's reasoning. In [msg 1648], the benchmark results come in:
| Operation | Bincode (v1) | Raw (v2) | Speedup | |-----------|-------------|----------|---------| | Save (tmpfs) | 16.8s (1.6 GB/s) | 7.7s (3.6 GB/s) | 2.2× | | Load (tmpfs) | 49.9s (0.6 GB/s) | 9.2s (3.0 GB/s) | 5.4× | | Save (NVMe) | 30.6s (0.9 GB/s) | 22.3s (1.2 GB/s) | 1.4× |
The 5.4× load speedup from tmpfs (49.9s → 9.2s) was slightly below the theoretical 10-16× estimate but still transformative. The load time dropped from "worse than recomputation" to "3× faster than extraction." The PCE cache was now genuinely useful.
The gap between theoretical (3-5s) and actual (9.2s) is explained by allocation overhead: creating 25.7 GiB of Vec storage requires the OS to fault in and zero all those pages, which adds ~4-5 seconds even before any I/O occurs. This is a cost that bincode also pays (it allocates the Vecs too), but bincode's per-element deserialization adds another ~40 seconds on top.
Broader Implications
Message [msg 1641] represents a broader pattern in high-performance systems engineering: the moment when a theoretically sound design meets empirical reality and is found wanting. The bincode format was chosen for good reasons — it is safe, well-tested, and generic. But for a 25.7 GiB data structure with 722 million elements of a fixed-size primitive type, the generality of bincode became a liability. Each scalar was being deserialized through a generic code path designed for arbitrary Rust types, when what was needed was a simple memory copy.
The lesson is that "fast enough" is relative. Bincode at 49.9 seconds might have been acceptable if extraction took 500 seconds. But because extraction was already optimized to 47 seconds, the cache was useless. The optimization only became necessary because the baseline was already so good — a classic case of Amdahl's Law revealing a new bottleneck once the previous one was eliminated.
The decision to use unsafe byte reinterpretation also reflects a mature understanding of Rust's safety guarantees. The assistant does not reach for unsafe casually — it is justified by the specific properties of the types involved, the stability of the representation, and the inclusion of header validation to catch format mismatches. This is the responsible use of unsafe: bounded, justified, and documented.
Conclusion
Message [msg 1641] is a small message with outsized impact. In two sentences, it captures a design decision that would save 40 seconds per proof load — and in a pipeline processing thousands of proofs per day, that adds up to hours of saved time. More importantly, it illustrates the engineering mindset that drives the entire session: measure everything, question assumptions, and be willing to abandon a comfortable approach (bincode) when the numbers say it isn't working. The raw binary format for PCE persistence was not the most elegant solution, but it was the right one — and message [msg 1641] is where that right decision was made.