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. - 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 may need traversal.
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) and are regenerated by tools/quality/render-performance-table.mjs; they show
the order of magnitude of each operation, not a guarantee. Every managed operation allocates its result plus
roughly one kilobyte of per-call state (options snapshot, operation context, budget stream), so a loop over
millions of records is dominated by that constant unless the records are read as one array.
| Operation | Median | Allocated |
|---|---|---|
Compile a two-field struct (struct root { uint8 kind; uint32 value; };) |
7.11 µs | 13,992 B |
| Compile the PNG header fixture (an enum and two structs) | 27.6 µs | 46,120 B |
GetOrCompile hit for the same source (cache lookup) |
411 ns | 0 B |
Parse a five-byte record with a count-sized array from memory |
506 ns | 1,272 B |
ReadValue<T> of the same record into a mapped class |
1.06 µs | 1,656 B |
ReadValue<ushort> of one selected field |
298 ns | 1,144 B |
Parse a 1 KiB uint8[1024] (one PrimitiveArray) |
298 ns | 1,584 B |
ResolveAddress of items[127] in a fixed nested array |
435 ns | 1,840 B |
ParseWithDebug of the PNG fixture (byte ranges for every value) |
2.95 µs | 6,384 B |
Truncated input: Parse throws and the caller catches |
9.64 µs | 2,760 B |
Serialize a mapped class into a caller-provided span |
594 ns | 1,352 B |
Update one value behind a pointer in place |
695 ns | 2,160 B |
Parse a 16 MiB record from a MemoryStream |
2.15 ms | 16.0 MiB |
Measured 2026-09-21 on AMD EPYC 9645, .NET 10.0.10 (10.0.10, 10.0.1026.32716), Linux Ubuntu 26.04.1 LTS (Resolute Raccoon).
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) |
265 ns | 784 B |
Generated Parse of the same record (the typed class) |
40.9 ns | 48 B |
| Generated view of the same record (every member read, nothing allocated) | 1.44 ns | 0 B |
Hand-written BinaryPrimitives reader of the same record |
1.62 ns | 0 B |
Runtime Parse of 256 nested records (6,400 bytes) |
87.9 µs | 243 KiB |
Generated Parse of the 256 nested records |
41.8 µs | 124 KiB |
| Generated view over the 256 nested records (one view per element by offset) | 732 ns | 0 B |
| Hand-written reader of the 256 nested records | 353 ns | 0 B |
Runtime Serialize of the record from a StructValue |
196 ns | 784 B |
Generated Serialize of the record from the typed class |
79.6 ns | 184 B |
Runtime Update of one field by path |
753 ns | 2,240 B |
Generated typed setter for the same field (Update.C) |
16.8 ns | 128 B |
Runtime ParseWithDebug of the record |
822 ns | 2,160 B |
Generated ParseWithDebug (the generated value plus the runtime's ranges) |
907 ns | 2,208 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 |
263 ns | 728 B |
Runtime ParseAsync of the same stream (read in place, the task already complete) |
354 ns | 904 B |
Runtime ParseAsync of a stream that hides its buffer (one pooled copy) |
343 ns | 904 B |
Runtime ParseAsync of a FileStream opened for asynchronous I/O |
3.04 µs | 1,464 B |
Runtime Write(Stream) of the record |
185 ns | 384 B |
Runtime WriteAsync of the record (serialized first, one WriteAsync) |
231 ns | 784 B |
Runtime Update(Stream) of one field |
811 ns | 2,184 B |
Runtime UpdateAsync of the same field (the region buffered, the changed run written back) |
1.09 µs | 2,328 B |
Generated Parse(Stream) of the record |
39.9 ns | 48 B |
Generated ParseAsync of the same stream |
78.6 ns | 48 B |
Runtime Parse in a loop over 256 consecutive records (7,168 bytes) |
73.5 µs | 196 KiB |
Runtime ParseMany over the same 256 records |
88.7 µs | 196 KiB |
Generated Parse in a loop over the 256 records |
12.7 µs | 12,288 B |
Generated Records over the same 256 records |
6.63 µs | 12,464 B |
| Hand-written offset loop over 256 views (two members read each) | 255 ns | 0 B |
Generated view enumerator (RootView.Enumerate) over the same 256 records |
222 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 cost what the loop a
caller would write costs, and the view enumerator allocates nothing and sits next to 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) |
4.07 µs |
parse of the PNG header fixture, 33 bytes (fast path) |
3.94 µs |
parse of 256 nested records, 6,400 bytes (fast path) |
199 µs |
parse of a record with four terminated strings, 6,592 bytes (one WebAssembly call) |
327 µs |
parseWithDebug of the 28-byte record (WebAssembly) |
151 µs |
serialize of the 28-byte record (WebAssembly) |
92.2 µs |
update of one scalar in the 28-byte record (WebAssembly) |
60.6 µs |
Measured 2026-09-20 in Node 26.5.0 with benchmarks/js (npm run bench:node).
The browser runtime (the WASM publication the npm package and the standalone bundle ship) is 4.1 MiB across 26 files, 1.6 MiB gzip-compressed; it is downloaded once and cached by the browser.
Reuse layouts safely
A successfully constructed CStruct is immutable and can be used by concurrent operations. Reusing it also avoids
parsing and preparing the same layout for every record.
That thread-safety applies to the layout object, not to mutable objects supplied by your application. Each operation must have exclusive use of its:
- stream;
- writable span or
IBufferWriter<byte>; - mutable dictionary while CStructSharp is copying it;
- dynamic object, mapped-class instance, collection, or enumerable being written; and
- returned mutable dynamic or debug result.
Two tasks may share one CStruct and separate streams. They must not seek or read the same stream at the same time
unless the application holds a lock for the complete CStructSharp call. Locking only an individual stream read is not
enough because one operation may seek, read, and revisit several ranges.
Initialized option objects are safe to share. Variable dictionaries are copied at operation entry, but the caller must not modify a dictionary while that copy is taking place.
A common mistake is placing both the layout and one MemoryStream in a singleton service. Keep the reusable layout
in the service; create or obtain an independent stream for each request.
Many records of one shape
Every operation sets up its own bounded context (budget stream, variables, result container) before it decodes 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. 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 such
records: 60 ns and 184 bytes per record through records[EOF], against 203 ns and 680 bytes per record for one
Parse call each - the single operation is 3.4× faster and allocates 3.7× less. 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.
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.