Table of Contents

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 it can place: scalars, bitfields, enums, a pointer's stored address, a nested struct (as a nested view), an array of numbers (Values(int index), ValuesBytes, and CopyValuesTo(Span<uint>) to decode the array's elements in one pass into the start of a destination at least that long), text (NameBytes, and Name when a string is wanted - that one allocates the string), and a custom codec's value (ColourBytes, and Colour, which the codec boxes).

An array of fixed-size structs (or unions) gives one nested view per element: view.Entries(i) is the view of element i at a stride of the element's size (the padded size in an aligned layout), and view.EntriesBytes holds the elements one after the other. An element struct with bitfields works the same way, so chunk entries[count] with struct chunk { uint32 offset:31; uint32 compressed:1; } reads view.Entries(i).Compressed without building any object; a runtime-sized one also has CountOfEntries(), and an element past the source fails with the reader's short-read message naming the array.

An array whose length comes from an earlier member (uint32 entries[count]) is exposed the same way, with CountOfEntries() for its length. The view evaluates the count from the members before it with the reader's own limits and failures, so a hostile count fails exactly as it does in Parse. Members placed after such an array are exposed too: the view works out where they start from the counts before them, rounds up to the member's alignment in an aligned layout, and fails with the reader's short-read message when the source ends first.

struct table { uint32 count; uint64 origin; uint32 entries[count]; uint32 checksum; };

view.Checksum finds checksum after the count entries; view.CopyEntriesTo(destination) copies the entries without the array a Table object would allocate for them.

When a count uses a name the layout does not define (uint8 data[N]), or a #define the caller may override, the layout's views take the values as a third constructor argument: new TableView(bytes, options, new Dictionary<string, int> { ["N"] = 3 }). The view evaluates the count with them exactly as Parse does (a name without a value fails with the reader's message), passes them to the views nested in it and to its enumerator, and ToObject() reads with them unless it is given others. Only a layout with such a count has the extra argument.

Every call that needs the length evaluates the count again, because a view keeps no state. That is cheap for one read (Checksum, CopyEntriesTo), but Entries(i) in a loop pays it for every element. To visit all elements, read EntriesBytes once, or call CopyEntriesTo into a span you own, and loop over that.

Members it does not expose are the ones whose position the view cannot compute: a terminated string, a conditional member, a value that is not read yet, or a member of a nested struct whose view has no property for it (a char, an int48, an int128 or a pointer), a pointer's target, a struct with a variable size, a bitfield whose storage the compiler placed after the data, and everything placed after one of those. For those, ToObject() runs the full reader, or Parse does from the start. A member after a runtime-sized array that is named like one of the view's own members (bytes, to_object) stays hidden rather than breaking the build, and a derived member (DataBytes for data, PtrAddress for a pointer ptr) that would take the name of one of the layout's own members gets a trailing underscore instead (DataBytes_).

A struct with @expect or @supports conditions gets Validate() on its view: it checks the conditions of the members the view exposes and throws the reader's failure. Validate(violations) collects every broken condition into a list and goes on (see collect every failure), and TryValidate(out failure) returns the failure and throws nothing. For a layout generated as value types (Shape = Struct), ToObject() returns the generated struct itself, with no allocation.

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 members you want sit after a terminated string, a conditional member, or a variable part the view cannot measure.
  • The value must outlive the method or travel through an async boundary.

Check yourself

  1. Does slicing a span copy bytes?
  2. Why can a ref struct not be stored in a List<T>?
  3. A header has uint8 n; uint8 data[n]; uint32 crc;. Can a view read Crc, and where does it look for it?
Answers
  1. No; it creates a new window onto the same memory.
  2. A list lives on the heap and could outlive the memory the struct's span points to; the compiler forbids it.
  3. Yes. The view reads n, evaluates the array length with the reader's limits, and reads crc at offset 1 + n.

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.