Views and zero allocation
Wire.Parse(bytes) creates a Wire.Header object: one allocation on the managed heap, a few dozen bytes, freed
later by the garbage collector. For one header that is nothing. For a million headers in a tight loop it is a
million objects the collector has to track and reclaim, and that shows up as time. A view reads the same bytes
without creating anything.
Spans
A ReadOnlySpan<byte> is a pointer and a length: a window onto memory that already exists - an array, a stack
buffer, a slice of a larger buffer. Slicing a span (bytes.Slice(2, 4)) creates a new window, not a copy. The
generated readers take spans because that is what the bytes are; a byte[] converts to one implicitly.
What an allocation is and why it costs
new Header() asks the runtime for heap memory, zeroes it, writes an object header, and returns a reference. The
memory stays in use until no reference remains and a garbage collection runs. The cost is not the new itself,
which is fast, but the collections: the more objects a program creates, the more often the collector pauses it and
the more memory it walks. A parser that allocates nothing per record never pays that.
BenchmarkDotNet reports the allocated bytes of each case; the performance page
lists the generated view at 0 B next to Parse.
A view
private static void GeneratedViews()
{
byte[] bytes = [0x02, 0x00, 0x06, 0x00, 0x00, 0x00];
// A view decodes each member straight from the span when it is read; nothing is allocated for the header.
var view = new Wire.HeaderView(bytes);
Equal((ushort)2, view.Kind);
Equal(6u, view.Length);
Equal(6, view.Bytes.Length);
// A view can become an object when one is wanted after all.
Wire.Header header = view.ToObject();
Equal(6u, header.Length);
// Too few bytes fail the way the runtime fails: the same message, offset 0, path 'header'.
try
{
_ = new Wire.HeaderView(bytes.AsSpan(0, 4));
True(false, "a short source must fail");
}
catch (CStructReadException error)
{
Equal("Not enough bytes: needed 6, available 4 (path 'header', offset 0).", error.Message);
}
}
Wire.HeaderView is a readonly ref struct holding the span and the read options. view.Kind decodes two bytes
at offset 0 when it is read, and again if it is read again; nothing is stored. view.Bytes is the value's own
bytes. ToObject() is the escape hatch back to a Header object.
The constructor checks that the source holds the value's fixed size and otherwise fails with the runtime's short-read message, so a view can be trusted from its first accessor on.
What a ref struct is
A ref struct lives on the stack and may hold spans. The C# compiler enforces that it never leaves the stack: you
cannot store a view in a field of a class, put it in a List<T>, capture it in a lambda, box it, or return it from
a method that created it over a local buffer. Those rules are what make it safe for a view to point straight into
memory that may be gone a moment later. Write a method that takes the bytes, creates the view, reads what it needs,
and returns plain values.
What a view exposes
A view has an accessor for every member whose position the compiler fixed at build time: scalars, bitfields,
enums, a pointer's stored address, a nested struct (as a nested view), a fixed array of numbers (Values(int index)
and ValuesBytes), and fixed text (NameBytes, and Name when a string is wanted - that one allocates the string).
Members it does not expose are the ones whose position depends on the data: a count-sized array, a terminated
string, a conditional member, and everything placed after one of those. For those, ToObject() runs the full
reader, or Parse does from the start.
When a view is the wrong tool
- You need most of the members anyway and keep them around: parse once into an object.
- You need a string member: the accessor allocates the string, so the view saves nothing there.
- The struct is runtime-sized and the members you want sit after the variable part.
- The value must outlive the method or travel through an
asyncboundary.
Check yourself
- Does slicing a span copy bytes?
- Why can a
ref structnot be stored in aList<T>? - A header has
uint8 n; uint8 data[n]; uint32 crc;. Can a view readCrc?
Answers
- No; it creates a new window onto the same memory.
- A list lives on the heap and could outlive the memory the struct's span points to; the compiler forbids it.
- No.
crcis placed after a runtime-sized array, so its offset is not fixed at build time;ToObject()reads it.
Exercise
Write a method static long SumLengths(ReadOnlySpan<byte> input) that walks a buffer of consecutive six-byte
headers with Wire.HeaderView and returns the sum of all Length values without allocating.
Solution
static long SumLengths(ReadOnlySpan<byte> input)
{
long sum = 0;
for (int offset = 0; offset + Wire.Sizes.Header <= input.Length; offset += Wire.Sizes.Header)
{
sum += new Wire.HeaderView(input.Slice(offset)).Length;
}
return sum;
}
Wire.Sizes.Header is the build-time size; the view over each slice reads one uint.