Use CStructSharp efficiently
Start with the API that makes ownership and failure handling clear. Measure the real workload before replacing it with a lower-allocation overload.
The highest-value choices are usually:
- Construct a
CStructonce and reuse it for records with the same format. - Read one path with
ReadValuewhen later fields are irrelevant. - Use span or memory input when bytes are already in memory.
- Use the
byte[]serialization overload unless an allocation measurement justifies caller-provided output. - Request debug ranges only in diagnostic paths.
- Map to a class only when typed application code needs it.
ReadValue<T>parses the value (through the static read plan when the layout is fully fixed) and hands it to the class's ownReadFrom; the mapping itself is the code the generator or you wrote, with no reflection to pay for. - Resolve paths you read repeatedly once, with
GetAccessor<T>, and read members without parsing throughCreateView(read the same members many times). In the repository's serializer comparison (a 79-byte record, every member read),Parsefollowed by path-string reads took 329 ns,Parsefollowed by accessor reads took 127 ns, andCreateViewwith accessors took 28.9 ns without allocating. - Generate the layout when it is part of the program: a
[CStructLayout]class parses straight into typed properties, and its view reads members without allocating (the "Generated" table below and runtime or generated?).
Selected reads can avoid decoding unrelated later siblings, but they still perform the work needed to locate the target. Runtime arrays, alignment, terminated strings, and pointers before the selected field must still be read to find where that field starts.
Span output avoids creating the final result array but requires enough capacity. IBufferWriter<byte> can append
through pooled windows. Both have partial-output behavior on late failure, so allocation is not the only tradeoff.
Use benchmarks/CStructSharp.Benchmarks and BenchmarkDotNet for changes to a hot path. Compare time and allocation with the same
layout, payload, target framework, build configuration, and operation. A small benchmark that removes validation or
changes who owns the output is measuring a different workload, so its numbers are not a fair comparison.
Do not add an application cache of mutable streams or results around a reusable layout. Reuse the immutable
CStruct; keep per-operation data owned by the caller.
See Spans, memory, and buffer writers for ownership details and the project testing guide for repository benchmark expectations.
Typical costs
The medians below come from the repository's BenchmarkDotNet cases (benchmarks/CStructSharp.Benchmarks,
Short job, Release build), recorded in benchmarks/CStructSharp.Benchmarks/typical-costs.json; they show
the order of magnitude of each operation, not a guarantee. Most managed operations allocate their result plus
some per-call state (options snapshot, variable slots, budget stream); the Allocated column shows the total. A
whole fixed-layout struct read from or written to memory skips that state and allocates only its result.
| Operation | Median | Allocated |
|---|---|---|
Compile a two-field struct (struct root { uint8 kind; uint32 value; };) |
3.30 µs | 15,848 B |
| Compile the PNG header fixture (an enum and two structs) | 19.1 µs | 47,776 B |
GetOrCompile hit for the same source (cache lookup) |
254 ns | 0 B |
Parse a five-byte record with a count-sized array from memory |
115 ns | 392 B |
ReadValue<T> of the same record into a mapped class |
274 ns | 560 B |
ReadValue<ushort> of one selected field |
153 ns | 208 B |
Parse a 1 KiB uint8[1024] (one PrimitiveArray) |
116 ns | 1,264 B |
ResolveAddress of items[127] in a fixed nested array |
131 ns | 144 B |
ParseWithDebug of the PNG fixture (byte ranges for every value) |
895 ns | 5,200 B |
Truncated input: Parse throws and the caller catches |
5.38 µs | 1,976 B |
Serialize a mapped class into a caller-provided span |
122 ns | 304 B |
Update one value behind a pointer in place |
313 ns | 424 B |
Parse a 16 MiB record from a MemoryStream |
1.06 ms | 16.0 MiB |
Measured 2026-09-30 on AMD Ryzen 9 9950X, .NET 10.0.12 (10.0.12, 10.0.1226.42308), Windows 11 (10.0.26200.9550/25H2/2025Update/HudsonValley2).
A layout on a [CStructLayout] class is read by generated code instead (generated code).
The same bytes four ways - the runtime Parse, the generated Parse, a generated view, and hand-written
BinaryPrimitives code - then the runtime/generated pairs for a write, an update, and a debug read
(GeneratedBenchmarks):
| Operation | Median | Allocated |
|---|---|---|
Runtime Parse of the 28-byte primitives record (StructValue) |
36.4 ns | 256 B |
Generated Parse of the same record (the typed class) |
7.14 ns | 48 B |
| Generated view of the same record (every member read, nothing allocated) | 0.67 ns | 0 B |
Hand-written BinaryPrimitives reader of the same record |
1.41 ns | 0 B |
Runtime Parse of 256 nested records (6,400 bytes) |
29.9 µs | 212 KiB |
Generated Parse of the 256 nested records |
3.81 µs | 57,392 B |
| Generated view over the 256 nested records (one view per element by offset) | 199 ns | 0 B |
| Hand-written reader of the 256 nested records | 209 ns | 0 B |
Runtime Serialize of the record from a StructValue |
45.2 ns | 56 B |
Generated Serialize of the record from the typed class |
5.50 ns | 56 B |
Runtime Update of one field by path |
234 ns | 480 B |
Generated typed setter for the same field (Update.C) |
3.18 ns | 0 B |
Runtime ParseWithDebug of the record |
336 ns | 1,552 B |
Generated ParseWithDebug (the generated value plus the runtime's ranges) |
336 ns | 1,600 B |
The generated Parse allocates the typed class and nothing else; the view allocates nothing and sits next to
the hand-written reader because it is the same code with the offsets filled in. ParseWithDebug costs a
runtime read on top of the generated one (the ranges come from the runtime). Use the generated path when the
layout is in the program's source and the read is hot; runtime or generated?
has the full decision table.
The awaitable forms read the stream with ReadAsync into a pooled buffer and run the same reader over it, so
their cost is the synchronous read plus the buffering and the state machine; a record sequence parses one
record per step (AsyncBenchmarks, SequenceBenchmarks; async and pipelines,
sequences and TryParse):
| Operation | Median | Allocated |
|---|---|---|
Runtime Parse(Stream) of the 28-byte record from a MemoryStream |
94.1 ns | 360 B |
Runtime ParseAsync of the same stream (read in place, the task already complete) |
116 ns | 416 B |
Runtime ParseAsync of a stream that hides its buffer (one pooled copy) |
113 ns | 416 B |
Runtime ParseAsync of a FileStream opened for asynchronous I/O |
12.6 µs | 974 B |
Runtime Write(Stream) of the record |
85.0 ns | 64 B |
Runtime WriteAsync of the record (serialized first, one WriteAsync) |
56.9 ns | 56 B |
Runtime Update(Stream) of one field |
222 ns | 424 B |
Runtime UpdateAsync of the same field (the region buffered, the changed run written back) |
353 ns | 568 B |
Generated Parse(Stream) of the record |
19.2 ns | 48 B |
Generated ParseAsync of the same stream |
33.6 ns | 48 B |
Runtime Parse in a loop over 256 consecutive records (7,168 bytes) |
9.05 µs | 65,536 B |
Runtime ParseMany over the same 256 records |
19.4 µs | 78 KiB |
Generated Parse in a loop over the 256 records |
2.02 µs | 12,288 B |
Generated Records over the same 256 records |
2.42 µs | 12,464 B |
| Hand-written offset loop over 256 views (two members read each) | 110 ns | 0 B |
Generated view enumerator (RootView.Enumerate) over the same 256 records |
116 ns | 0 B |
A MemoryStream that exposes its buffer is read in place and the ValueTask is already complete when it is
returned; a file pays the real asynchronous I/O. ParseMany and the generated Records run the loop a caller
would otherwise write; compare each with the plain loop above it to see what the convenience costs. The view
enumerator allocates nothing, like the hand-written offset loop.
The JavaScript package pays a WebAssembly crossing per call unless the layout is fully fixed and no option
is set, in which case parse reads it in JavaScript (see many records in one call):
| Operation | Median |
|---|---|
parse of a fixed 28-byte record (JavaScript fast path, no WebAssembly call) |
2.29 µs |
parse of the PNG header fixture, 33 bytes (fast path) |
1.95 µs |
parse of 256 nested records, 6,400 bytes (fast path) |
88.2 µs |
parse of a record with four terminated strings, 6,592 bytes (one WebAssembly call) |
132 µs |
parseWithDebug of the 28-byte record (WebAssembly) |
46.0 µs |
serialize of the 28-byte record (WebAssembly) |
39.5 µs |
update of one scalar in the 28-byte record (WebAssembly) |
33.4 µs |
Measured 2026-09-30 in Node 26.8.1 with benchmarks/js (npm run bench:node).
The browser runtime (the WASM publication the npm package and the standalone bundle ship) is 4.2 MiB across 27 files, 1.6 MiB gzip-compressed; it is downloaded once and cached by the browser.
How a layout is run: the compiled engine
A layout describes a record, but the description itself says nothing about speed. The slow way to read a record is to walk the declarations for every call: look at the next member, decide whether it is a number, an array or a nested struct, work out where it starts, find its name among the variables, read it, and repeat. Almost none of those decisions depend on the bytes being read, so making them again on every call is wasted work.
CStructSharp makes them once. The first time a root is read, written or updated, each struct it reaches is
compiled into a program: a flat list of small steps, each of which does exactly one thing - read a
little-endian uint32 into member id, store a value in a named variable, evaluate an array count, check the
element limit, skip to the next aligned offset. Placement that does not depend on the data is worked out while the
program is built, so a member at a fixed offset needs no placement step at all. Names that expressions use (such as
count in values[count]) are numbered when the layout is compiled, so the running program finds a variable by
its number instead of looking up a string. The programs are cached with the layout, and every later call runs them.
Take this aligned layout:
struct sample { uint32 id; uint16 count; int16 values[count]; uint8 flags; };
Its program has seven steps:
| Step | Member | What it does |
|---|---|---|
Read a little-endian uint32 |
id |
4 bytes at offset 0 |
Read a little-endian uint16 |
count |
2 bytes at offset 4 |
| Capture an integer | count |
stores the value in the variable count for the next step |
| Evaluate a count | values |
computes count, rejects a negative value, checks MaxArrayElements |
| Read a numeric array | values |
reads all the elements as one block (a PrimitiveArray<short>) |
Read a uint8 |
flags |
1 byte, right after the array |
| Finish the struct | - | pads the end to a multiple of 4, the struct's alignment |
Reading the 12 bytes 07 00 00 00 02 00 E8 03 FE FF 01 00 runs the steps in order and produces
{ id = 7, count = 2, values = [1000, -2], flags = 1 }. Nothing was decided about id or count during the read:
the program already knew that both come first and at which offsets. Only values depends on the data, so only its
count is evaluated while reading.
The same program runs over every source. Memory (a span, an array, ReadOnlyMemory<byte>, a sequence, a
MemoryStream that exposes its buffer) is read in place; any other seekable stream is read through a bounded stream
reader with the same byte budget and failure positions. Writing works the same way with write programs, and an update
reads with one program and writes with another.
Two faster paths stay in front of the engine, and the engine only runs when neither applies:
- A whole fixed-layout struct read from memory or written to memory, with no variables and no debug records, is decoded or encoded straight between the span and the result, without the per-call state the engine sets up.
- A fixed struct met inside a larger read or write (a record, an array element, a nested member) runs a static plan: all its members at their compile-time offsets in one pass over one block of bytes.
The engine runs its programs one step at a time; it does not generate machine code at run time, which keeps the
library usable with Native AOT, trimming and WebAssembly. Code the source generator writes for a [CStructLayout] class is still faster,
because every step is written out as ordinary C# that the compiler optimizes
(runtime or generated?).
Reuse layouts safely
Construct a CStruct once and share it: it is immutable, safe for concurrent operations, and saves parsing and
preparing the layout for every record, while each operation still needs exclusive use of its own stream, output, and
mutable values (concurrency and ownership).
Many records of one shape
Most operations set up their own bounded context (budget stream, variables, result container) before they decode a
byte. That fixed cost is small in absolute terms - a few hundred nanoseconds and well under a kilobyte - but it
dominates when the record itself is tiny. A whole fixed-layout struct read from memory without variables is the
exception: Parse(bytes, "record") skips the context and decodes the record straight from the span. For other
records - read from a stream, given variables, or holding a runtime-sized array or a string - let the layout express
the repetition instead of calling Parse per record:
struct record { uint16 kind; uint32 length; uint8 flags; };
struct file { record records[EOF]; };
layout.Parse(bytes, "file") reads every whole record to the end of the input in one operation, and a fixed
record shape takes the span-based array path. Measured on the repository benchmark machine for 1,000 of the
seven-byte records above: 21 ns and 161 bytes per record through records[EOF], and 23 ns and 152 bytes per record
for one Parse(bytes.AsSpan(offset, 7), "record") call each, because that record is fixed and in memory. Use
records[count] when a header supplies the count, and ReadValue(bytes, "file.records[7]") when only one record is
needed.
When the records must be handled one at a time - a file too large to hold as one value, a loop that stops early,
a stream that is still arriving - ParseMany and the generated Records parse one record per step and cost what
the per-record Parse loop costs (each record is its own operation, with its own context). The generated view
enumerator (RootView.Enumerate(bytes)) is the exception: it allocates nothing and costs what a hand-written
offset loop costs, so a scan that reads a field or two from each of a million records should use it. The async
and sequences table above has the measured rows; the async guide and the
sequences lesson explain the forms.
CPU features and vector instructions
A vector (SIMD, single instruction, multiple data) instruction processes several numbers at once: one AVX2 instruction can reverse the byte order of eight 32-bit integers. CStructSharp has no processor-specific code of its own. It relies on .NET routines that the JIT compiler (which turns .NET code into machine code while the program runs) specializes for the processor it is running on: SSE and AVX2 or AVX-512 on x64, and AdvSimd on Arm64.
- An array of 2-, 4- or 8-byte numbers in the machine's own byte order is copied as one block of memory. In the other
byte order, it is copied and then reversed with the vectorized
BinaryPrimitives.ReverseEndianness, both when reading and when writing. - A single big-endian number on a little-endian machine compiles to one byte-swap instruction (
bswapormovbeon x64,revon Arm64). - A record of individual fields gains little from vectors: most of its cost is creating the result objects. The direct paths for whole fixed-layout structs and the generated fixed readers are what make those cheap.
Native AOT compiles the program before it runs, so it cannot see the target processor. By default it targets a
conservative instruction set that runs everywhere. If every machine the program will run on supports a newer set, set
<IlcInstructionSet> in the project file (for example x86-x64-v3 for AVX2-class x64 machines) so that the same
routines can use wider vectors; see Optimize AOT deployments.
Managed layout caching
When you already retain a CStruct, keep using it. When a call site repeatedly receives the same layout text,
CStruct.GetOrCompile(definition, pointerSize: 8, aligned: false, isLittleEndian: true) reuses a prepared
instance instead of constructing one every time.
The current cache holds at most 64 entries and a total source-text budget of 8,388,608 characters. Its key includes the exact source, pointer width, alignment, byte order, and compilation limits. This is a retention budget, not an exact managed-memory cap. Failed compilations are not cached. Entries can be evicted, so a cache hit is an optimization rather than an identity guarantee.
CStruct.ClearCompiledCache() releases the cache's references. Existing returned instances remain usable.
The cache does not retain input bytes, stream positions, or parsed results. Layout reuse is safe across calls;
mutable streams and result objects still need separate ownership. The browser bridge uses the same kind of
bounded cache within each runtime.
JavaScript compiled reuse
The record idiom above applies to JavaScript with larger stakes, because a WebAssembly crossing costs tens of
microseconds: 1,000 seven-byte records parse in one records[1000] call at 0.06 µs per record on the JavaScript
fast path, or 2.6 µs per record as records[EOF], against 2.4-37 µs per record when each record is its own call
(see many records in one call).
For repeated reads of the same schema, use compile from the npm package or standalone browser bundle:
const layout = await compile(definition, { root: "root" });
try {
for (const bytes of records) {
const result = await layout.parse(bytes);
if (!result.success) throw new Error(result.error.message);
consume(result.data);
}
} finally {
await layout.dispose();
}
Compilation owns a dedicated worker and runtime. Reads reuse the compiled layout; parseWithDebug adds byte mappings.
One handle queues its worker reads and keeps operation state separate. Small byte reads without a signal can use
the shared calling-thread runtime. Layout options are fixed, while each read
can choose limits and an abort signal. Cancellation stops an active worker; a later read recreates it and compiles the layout again.
Dispose unused handles promptly: each retains a runtime. Ordinary source parsing also reuses a worker for 30 seconds
of idle time. Managed bridge calls use a bounded compiled-layout cache, so repeated ordinary calls can also reuse
preparation while their entries remain cached. A retained handle explicitly owns a layout for its worker reads;
it is not the only way to benefit from caching.
Compare cold startup separately from warmed reads. Worker messaging, source staging, result materialization and JSON projection remain real costs even with a retained layout. Conditional groups are selected once on entry; their cost also depends on the number of fields, nested scopes and active payload, so compare equivalent data and layouts.