Back to Blog

Table of Contents

Highlights

The Design Behind wincode, a Faster Rust Serializer

Written By

Zach Brown

TL;DR

wincode is a fast, bincode-wire-compatible serializer and deserializer for Rust, built by Anza originally for the Agave validator. It is not a fork of bincode. It is a new implementation with its own traits and derive macros that produces the same bytes as bincode's default configuration. 

RustSec considers bincode unmaintained (RUSTSEC-2025-0141) and recommends wincode as a replacement. Wincode works anywhere fast serialization is needed in Rust.

Docs: docs.rs/wincode

Crate: crates.io/crates/wincode

Benchmarks 

Speedups vary by hardware, element type, and payload size. A few sample benchmarks against bincode (same wire format, no hand‑rolled parsing) running locally on AMD 9950X3D:

Results

1,000 element deserialization

wincode 1,000 element deserialization benchmarks

1,000 element serialization

wincode 1,000 element serialization benchmarks

Types referenced in benchmarks:

#[repr(C)]
struct PodStruct {
    a: [u8; 32],
    b: [u8; 16],
    c: [u8; 8],
}
struct SimpleStruct {
    id: u64,
    value: u64,
    flag: bool,
}
enum SameSizedEnum {
    Transfer { amount: u64, fee: u64 },
    Stake { lamports: u64, rent: u64 },
    Withdraw { amount: u64, timestamp: u64 },
    Close { refund: u64, slot: u64 },
}

Performance comes from a unifying design philosophy: tailor the read and write paths to the shape of each type so they do the minimum work that type actually requires. The result is hand‑written‑deserializer performance from a normal, type‑driven Rust API.

Why We Built wincode

Serialization sits on the hot path of many sub-systems of the validator we care about. Over time, we kept seeing the same thing in profiles: work that was technically correct, but structurally more expensive than it needed to be.

That was the motivation for wincode. We wanted the ergonomics of type-driven, declarative serialization, compatibility with the existing bincode wire format, and performance characteristics akin to or better than what we were getting from hand-rolled deserializers.

Wire compatibility was a hard requirement, not a convenience. The validator has years of serialized state and network messages in bincode format. wincode had to read and write those bytes identically while changing everything about how the work gets done underneath.

This post explains the technical problem we were trying to solve, and the design choices that made the solution practical.

Core Problem 1: Construct, Then Move

Most Rust code naturally follows a construct-then-move pattern. You build a value, then move it into its final destination.

That is usually the right tradeoff between safety, ergonomics, and performance. But where performance is critical, operation ordering matters. If values are first materialized and only later moved into the destination allocation, extra copies become hard to avoid.

In other words, if we want to remove those copies, data needs to write directly into final memory as bytes are consumed. APIs that are shaped around "construct first, place later" make that difficult to express.

This is where the performance cliff appears: high-level code is easy to write, but the fastest path often requires lower-level control over placement and memory initialization.

Destination-First API Design

wincode addresses this by making deserialization destination-first. Instead of asking the decode path to construct a full value and return it, the API decodes into caller-provided destination memory (&mut MaybeUninit<...>).

Comparison of deserializing into a Rust Box<T>: Serde creates a temporary value and copies it to the heap, while wincode deserializes directly into the final heap allocation.Comparison of deserializing a Rust Vec<T>: Serde creates and copies a temporary for every element, while wincode initializes each element directly in the vector’s spare capacity.

That changes the execution order in an important way. The deserializer can allocate container storage once, then initialize values directly where they will live, rather than constructing temporary values and moving them afterward. For large or deeply nested structures, that removes a meaningful amount of redundant copying and intermediate movement.

It also makes the high-performance path explicit in the API shape. You do not have to drop down into application-level pointer choreography just to express "decode directly into final storage." The library encapsulates that low-level machinery, while application code keeps a declarative, type-driven interface.

Core Problem 2: Sequence Deserialization of Opaque Types

Generic sequence decoding has to work for all element types: variable-sized values, nested structures, custom validation, and arbitrary control flow. Because of that, the default deserialization model is element-by-element.

That model is flexible, but leaves a lot of performance on the table for fixed-size payloads. The most obvious cases are byte-like sequences, including wrappers like Vec<Signature> (where Signature is a fixed-size byte array/newtype) and Vec<u8> in paths that do not hit a specialized bytes fast path. But the same structural issue also appears with non-byte fixed-size types such as Vec<u64>.

The generic sequence path typically looks like this:

1. Decode the sequence length.

2. Allocate capacity.

3. Repeatedly deserialize each element through a generic element path.

For fixed-size types, that can mean repeated control-flow, repeated bounds checks, and repeated per-element bookkeeping over data where total byte requirements are known up front. The semantic model is generic, but the memory layout is often simple enough to plan ahead.

Diagram of a typical generic deserialization loop. Eight elements in a row, each with its own bounds check above it, showing one check per element.

The path we actually want is:

1. Decode the sequence length.

2. Validate one contiguous byte window.

3. Allocate capacity.

4. Copy into final storage in one bulk operation.

For byte-like, memcpy-safe element types, step 4 can be a direct bulk copy (example below). 

Diagram of wincode's path for deserializing a vector of fixed-size elements. A single bracket spans all elements, showing one memory copy of the full byte range directly into the destination instead of a per-element loop.

For fixed-size non-memcpy-able types (for example u64 under a non-native endianness configuration), we still want steps 1-3: compute total bytes as len * element_size, validate that window once, and then decode elements within that contiguous window without intermediate validation and bounds checks.

Diagram and assembly excerpt of a typical generic deserialization loop. Eight elements each have their own bounds check. The assembly below shows the loop calling a bounds check and then a decode on every iteration.Diagram and assembly excerpt of wincode's fixed-size deserialization path. One box outlines all eight elements, showing a single bounds check for the whole window. The assembly below computes the total size, checks it once before the loop, then runs only the decode on every iteration.

Compile-time Type Metadata

The central design idea that enables this is wincode's TypeMeta: compile-time metadata attached to types.

At a high level, TypeMeta tells wincode two things that matter for read-path optimization:

- whether a type has a statically known serialized size,

- and whether that type is raw memcpy-able and zero-copy eligible.

Because this metadata is known at compile time, wincode can select specialized execution paths without runtime type introspection.

Compile-time type metadata enables large bounds-check-elided windows

When element size is known at compile time, wincode can compute total bytes up front (for example, len * size_of::<T>() for sequences), validate a larger byte window once at the container boundary, then deserialize within that window without re-checking bounds at every intermediate step.

For nested and sequence-heavy data, this removes a large amount of repeated branching from the hot path. You still get correctness checks, but they move to fewer, more meaningful boundaries.

Compile-time type metadata enables contiguous bulk copy

For types that are fixed-width and zero-copy / memcpy eligible, wincode can use the contiguous representation directly. In container paths like Vec<T>, this means:

- one preallocation check,

- one contiguous read window check,

- one bulk copy into Vec storage.

This is the same shape we used to implement manually for fixed-byte payloads, but now exposed through wincode’s traits and derive macros.

Viewed another way, TypeMeta gives wincode three practical read modes:

1. Statically sized and memcpy-able: validate the contiguous region and bulk-copy directly into destination storage.

2. Statically sized, non-memcpy-able: validate once, then run element decode logic inside a trusted window without intermediate bounds checks (for example, structs with fixed sized members without a guaranteed layout).

3. Dynamic: fall back to the fully general path when static sizing is not available.

That split is a key reason wincode can stay general-purpose at the API level while still producing highly specialized behavior.

Compile-time type metadata enables seamless zero-copy

Because wincode carries complete metadata for the types it reads, it can determine when serialized bytes and in-memory layout line up exactly such that it can form references directly from the payload.

The derive macros generate this metadata automatically – they can deterministically infer when zero-copy deserialization is valid and when it is not, based on the same representation constraints the library enforces for safety.

That is what makes zero-copy both usable and predictable in practice. You do not hand-write unsafe reference-casting logic at each call site. The type metadata drives the decision, and the library only allows forming references when the type/configuration combination satisfies zero-copy requirements.

What This Changes for Application Code

The main outcome is that most application code can stay in safe Rust and still hit or exceed performance targets that previously pushed us toward hand-rolled deserializers.

The unsafe machinery does not vanish, but it becomes centralized in one place: the library implementation and its contracts. That makes it auditable, testable, and fuzzable as a shared piece of infrastructure, rather than a pattern every team has to reimplement.

Hand-rolled Deserializers Are Not a Good Steady State

Before wincode, when we needed this performance profile, we often wrote manual deserialization implementations: direct byte parsing, explicit placement writes, carefully ordered initialization, and cleanup logic for partial failures.

That can absolutely be fast, but it is difficult to maintain.

As types and APIs evolve, manual parsing code becomes expensive to review and easy to regress. Safety requirements are spread across call sites. Subtle bugs hide in edge cases around initialization and error unwinding. The engineering cost grows with every new type and every version transition.

We wanted to keep or exceed the performance characteristics of hand-rolled deserializers, but move that complexity out of application code.

Why This Matters

wincode is not about replacing existing tooling for its own sake. It is about closing a real gap between ergonomic serialization code and the performance profile required in high-performance systems.

The goal is straightforward: deliver hand-rolled-deserializer class performance, without inheriting hand-rolled-deserializer maintenance burden everywhere else.

FAQ

Is wincode a fork of bincode?
No. wincode is a separate implementation that shares no code with bincode. It has its own configuration system for options like endianness and fixed vs. varint integer encoding. wincode's default configuration matches bincode 1.x's default configuration byte for byte, and equivalent custom configurations on each side produce the same wire format.

Is wincode a drop-in replacement for bincode?
Yes. As long as your type layout and configuration match, wincode reads and writes the same bytes bincode does. Swap the derive macros to SchemaWrite and SchemaRead and you are done.

Does wincode depend on serde?
No. wincode has its own traits and derive macros. serde-based types and wincode-based types can coexist in the same codebase and exchange bytes.

Is wincode only for Solana?
No. Nothing in wincode is Solana-specific. It is Apache-2.0, supports no_std, and integrates with bytes, smallvec, indexmap, uuid, and bumpalo.

Try wincode

Add wincode to your Cargo.toml and try it yourself: github.com/anza-xyz/wincode