Skip to content

[C#] FlatSpanBuffers: A Span-Centric FlatBuffers Runtime#8939

Draft
bigjt-dev wants to merge 1 commit intogoogle:masterfrom
bigjt-dev:flatspanbuffers
Draft

[C#] FlatSpanBuffers: A Span-Centric FlatBuffers Runtime#8939
bigjt-dev wants to merge 1 commit intogoogle:masterfrom
bigjt-dev:flatspanbuffers

Conversation

@bigjt-dev
Copy link
Contributor

I have been working with FlatBuffers in .NET to quickly process data off the wire. While the net/FlatBuffers package is good, I wanted to reduce object allocations and use Spans to read and write buffers with stackalloc'd memory. I reworked the library and generated code, but kept the IFlatBufferObject API mostly the same.

These changes worked well for me. With a bit more work I could see this rework being adopted here too as an alternative to --csharp. Opening up the discussion with this draft PR.

I've highlighted the key parts of the runtime and IDL changes below. Most of the committed additions are from generated code.

Highlights

  • Targeting .NET 10 for use of allows ref struct constraint' (details below)
  • IDL and FlatSpanBuffers are standalone from the current implementation, flatc --csharp-spanbufs
  • Spans are used for buffer operations handling encoding/decoding with endianess support.
  • No arrays, only spans.
  • More structs, fewer classes to avoid extra object allocations.
  • No AllowUnsafeBlocks required.
  • No preprocessor defines. No ENABLE_SPAN_T / UNSAFE_BYTEBUFFER.
  • More use of Generics for Add/Put/Get operations.
  • Newtonsoft out, System.Text.Json in.
  • Unit tests based on the the existing FlatBuffers.Test project, plus extras to exercise reworked features.
  • Benchmarks!

ref struct and allows ref struct

Span<T> can only be a member of ref struct types. ByteSpanBuffer and FlatSpanBufferBuilder must be ref struct to support accepting a Span buffer and store it as a member.

Using ref struct types along with the array backed ByteBuffer created some code reuse challenges and required an elevation to .NET 9 to use allows ref struct for generic type constraints. This lets a single generic function accept both a regular struct and a ref struct without boxing, virtual dispatch, or core logic duplication.

Where allows ref struct could not be applied cleanly, I intentionally duplicated some structs for simplicity and reduced overhead, but extracted common functions as much as possible.


Folder Structure

FlatSpanBuffers lives alongside the existing net/FlatBuffers code and does not modify or reference it. The new code is self-contained:

net/
  FlatBuffers/                    # Original 
  FlatSpanBuffers/                # New library 
  FlatSpanBuffers.Benchmarks/     # Side-by-side benchmarks
  StackFlatBuffers.Generated/     # Generated code put here
  StackFlatBuffers.Tests/         # Test project
src/
  idl_gen_csharp_spanbufs.h/cpp   # New IDL gen

File Notes

BufferOperations.cs

Serialization functions that operate on Span<byte> buffers. Handles the FlatBuffer type validation checks with minimal overhead. Addressed endian swaps for scalar vectors.

TableOperations.cs

Common functions for table field access shared across Table and TableSpan. Uses generic allows ref struct parameters so the same logic serves both ByteBuffer and ByteSpanBuffer. Contains the binary search (__lookup_by_key) that was previously provided only in the generated code.

ByteBuffer.cs

Now a struct. Wraps a byte[] with span-based access. Removed allocator reference to make struct lighter weight, especially when only used to read/decode.

ByteSpanBuffer.cs

The ref struct counterpart to ByteBuffer. Wraps a Span<byte> directly, enabling use of stackalloc'd memory.

BufferBuilder.cs

Common BufferBuilder operations for FlatBuffer construction using FlatBufferBuilder and FlatSpanBufferBuilder. Manages the allocator and handles buffer growth/reallocation.

FlatBufferBuilder.cs

Uses the array-backed ByteBuffer, familiar api, serves as pass through to BufferBuilder functions. Reduced number of functions using generics.

FlatSpanBufferBuilder.cs

The ref struct builder that supports building from a ByteSpanBuffer buffer. Requires more care in setup, but gives more control of allocations to the caller.

Vectors

Vector wrapper structs to to avoid checking for buffer presence per element access.

FlatBufferVerify.cs

The Verifier is now a ref struct, operating directly on span-backed data.

RefStructNullable.cs

Since Nullable<T> cannot wrap a ref struct, RefStructNullable<T> provides .HasValue / .Value semantics for optional fields.


IDL Gen Notes

ByteBuffer and SpanBuffer Objects

Each table/struct haw two namespaces:

  • MyGame.Example: works with ByteBuffer and FlatBufferBuilder
  • MyGame.Example.StackBuffer: works with ByteSpanBuffer and FlatSpanBufferBuilder

Object API Optimizations

Pack and UnPack have been reworked to reduce allocations:

  • List/vector fields pre-size their collections based on known lengths
  • Reuse objects from existing instances
  • UnPack works from both ByteBuffer and ByteSpanBuffer

Consistent Use of Nullable

Optional fields consistently use nullable semantics in both variants. ref struct-typed fields return RefStructNullable<T>.

System.Text.Json over Newtonsoft

JSON serialization attributes and converters have been migrated from Newtonsoft.Json to System.Text.Json.Serialization. Union types use JsonConverter implementations.


Benchmark Results

All benchmarks compare three implementations: the original Google.FlatBuffers, FlatSpanBuffers, and FlatStackBuf. Benchmarks project defined ENABLE_SPAN_T;UNSAFE_BYTEBUFFER.

For encoding tests, builders are preallocated to exclude allocations from the performance differences between the types to focus on internal improvements.

BenchmarkDotNet v0.15.0, Linux Pop!_OS 22.04 LTS
AMD Ryzen 7 7800X3D 5.05GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 10.0.103
  [Host] : .NET 10.0.3 (10.0.326.7603), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI

SimpleMonsterBenchmarks

Method Mean Error StdDev Ratio Allocated Alloc Ratio
Original_FlatBuffers_BuildSimpleMonster 83.85 ns 0.451 ns 0.422 ns 1.00 - NA
FlatSpanBuffers_BuildSimpleMonster 54.04 ns 0.322 ns 0.285 ns 0.64 - NA
FlatStackBuf_BuildSimpleMonster 52.78 ns 0.373 ns 0.349 ns 0.63 - NA

DecodeBenchmarks

Method Mean Error StdDev Ratio Gen0 Allocated Alloc Ratio
Original_FlatBuffers_Decode 117.68 ns 1.017 ns 0.901 ns 1.00 0.0007 40 B 1.00
FlatSpanBuffers_Decode 33.53 ns 0.172 ns 0.152 ns 0.28 - - 0.00
FlatStackBuf_Decode 24.46 ns 0.097 ns 0.086 ns 0.21 - - 0.00

ObjectApiDecodeBenchmarks

Method Mean Error StdDev Ratio RatioSD Gen0 Allocated Alloc Ratio
Original_FlatBuffers_ObjectApi_Decode 346.2 ns 4.34 ns 4.06 ns 1.00 0.02 0.0205 1040 B 1.00
FlatSpanBuffers_ObjectApi_Decode 156.8 ns 1.32 ns 1.17 ns 0.45 0.01 0.0129 648 B 0.62
FlatStackBuf_ObjectApi_Decode 137.0 ns 1.51 ns 1.34 ns 0.40 0.01 0.0129 648 B 0.62

EncodeBenchmarks

Method Mean Error StdDev Ratio Allocated Alloc Ratio
Original_FlatBuffers_Encode 294.2 ns 2.54 ns 2.38 ns 1.00 - NA
FlatSpanBuffers_Encode 236.2 ns 2.31 ns 2.05 ns 0.80 - NA
FlatStackBuf_Encode 188.4 ns 1.57 ns 1.47 ns 0.64 - NA

ObjectApiEncodeBenchmarks

Method Mean Error StdDev Ratio Gen0 Allocated Alloc Ratio
Original_FlatBuffers_ObjectApi_Encode 308.4 ns 2.34 ns 2.08 ns 1.00 0.0005 40 B 1.00
FlatSpanBuffers_ObjectApi_Encode 243.9 ns 1.58 ns 1.40 ns 0.79 - - 0.00
FlatStackBuf_ObjectApi_Encode 228.9 ns 1.84 ns 1.72 ns 0.74 - - 0.00

VerifyBenchmarks

Method Mean Error StdDev Ratio Gen0 Allocated Alloc Ratio
Original_FlatBuffers_Verify 178.19 ns 1.914 ns 1.791 ns 1.00 0.0029 144 B 1.00
FlatSpanBuffers_Verify 61.34 ns 0.301 ns 0.267 ns 0.34 - - 0.00
FlatStackBuf_Verify 61.29 ns 0.334 ns 0.279 ns 0.34 - - 0.00

Improvements

  • Decode: 4.8x
  • Encode: 1.6x
  • Decode ObjectAPI: 2.5x
  • Encode ObjectAPI: 1.35x
  • Verify: 2.9x

API Examples

Building with FlatBufferBuilder

using Google.FlatSpanBuffers;
using MyGame.Example;

// business as usual here
var builder = new FlatBufferBuilder(1024);

var weaponOneName = builder.CreateString("Sword");
var weaponTwoName = builder.CreateString("Axe");

var sword = Weapon.CreateWeapon(builder, weaponOneName, 3);
var axe = Weapon.CreateWeapon(builder, weaponTwoName, 5);

// Use Span of offsets over an array
Span<Offset<Weapon>> weaponOffsets = stackalloc Offset<Weapon>[2];
weaponOffsets[0] = sword;
weaponOffsets[1] = axe;
var weapons = Monster.CreateWeaponsVectorBlock(builder, weaponOffsets);

var name = builder.CreateString("Orc");

Monster.StartMonster(builder);
Monster.AddName(builder, name);
Monster.AddHp(builder, 300);
Monster.AddWeapons(builder, weapons);
var orc = Monster.EndMonster(builder);
Monster.FinishMonsterBuffer(builder, orc);

Building with FlatSpanBufferBuilder

using Google.FlatSpanBuffers;
using MyGame.Example.StackBuffer;

// The Span Builder Setup is a little ugly, but the trade is
// more control over the allocations that includes the stack.
Span<byte> buffer = stackalloc byte[1024];
Span<int> vtables = stackalloc int[64];
Span<int> vtableOffsets = stackalloc int[64];

var buf = new ByteSpanBuffer(buffer);
var builder = new FlatSpanBufferBuilder(buf, vtables, vtableOffsets);

var weaponOneName = builder.CreateString("Sword");
var weaponTwoName = builder.CreateString("Axe");

// StackBuffer takes a builder by ref
var sword = Weapon.CreateWeapon(ref builder, weaponOneName, 3);
var axe = Weapon.CreateWeapon(ref builder, weaponTwoName, 5);

Span<Offset<Weapon>> weaponOffsets = stackalloc Offset<Weapon>[2];
weaponOffsets[0] = sword;
weaponOffsets[1] = axe;
var weapons = Monster.CreateWeaponsVector(ref builder, weaponOffsets);

var name = builder.CreateString("Orc");

Monster.StartMonster(ref builder);
Monster.AddName(ref builder, name);
Monster.AddHp(ref builder, 300);
Monster.AddWeapons(ref builder, weapons);
var orc = Monster.EndMonster(ref builder);
builder.Finish(orc.Value);

Decoding Vectors

using MyGame.Example;

var bb = new ByteBuffer(receivedBytes);
var monster = Monster.GetRootAsMonster(bb);

// Scalar vector
var inventory = monster.Inventory;
if (inventory.HasValue)
{
    ReadOnlySpan<byte> items = inventory.Value;
    for (int i = 0; i < items.Length; i++)
        Console.WriteLine($"  item[{i}] = {items[i]}");
}

// Table vector
var weapons = monster.Weapons;
if (weapons.HasValue)
{
    var weaponsVec = weapons.Value;
    for (int i = 0; i < weaponsVec.Length; i++)
    {
        var w = weaponsVec[i];
        Console.WriteLine($"  {w.Name}: {w.Damage}");
    }
}

Mutable

using MyGame.Example;

var bb = new ByteBuffer(receivedBytes);
var monster = Monster.GetRootAsMonster(bb);

// Scalar field mutation, works as usual. 
bool mutated = monster.MutateHp(500);

// Scalar vector mutation via Span
var mutableInventory = monster.MutableInventory;
if (mutableInventory.HasValue)
{
    var inventory = mutableInventory.Value;
    mutableInventory.Value[0] = 99;
    mutableInventory.Value[1] = 42;

    // BufferOperations needed to support big endian mutate.
    // Considering another wrapper to make this cleaner or put back
    // the mutate by index method.
    BufferOperations.Write<byte>(inventory, 2, 67); 
}

// Struct vector element mutation
var path = monster.Path;
if (path.HasValue)
{
    var firstPoint = path.Value[0];
    firstPoint.MutateX(10.0f);
    firstPoint.MutateY(20.0f);
    firstPoint.MutateZ(30.0f);
}

Additional Thoughts

While FlatSpanBuffers targets .NET 10, some of the changes here could be applied to net/FlatBuffers.

For example:

  • Struct-based ByteBuffer
  • Span-based BufferOperations
  • Object API allocation reductions
  • System.Text.Json migration

This library could also be reworked to support netstandard2.1, but the 'StackBuffers and allows ref struct' approach would likely need to be discarded.

Based on some of the issues and pull requests I've seen, I presume some general interest in this rework. Let me know your thoughts!

FlatSpanBuffers is a span-centric rework of the C# FlatBuffers implementation that is separate from the net/FlatBuffers implementation. Reduces object allocation and gives the caller more control over memory managment. The isolated addition in net FlatSpanBuffers has its own flatc flag --csharp-spanbufs. The majority of IFlatbufferObject access patterns are unchanged.

Highlights:
+ Targeting .NET 10 for use of 'allows ref struct' constraint'
+ Spans is used for buffer operations handling encoding/decoding with endianess support.
+ No arrays, only spans.
+ More structs, fewer classes to avoid extra object allocations.
+ No AllowUnsafeBlocks required.
+ No preprocessor defines. No ENABLE_SPAN_T / UNSAFE_BYTEBUFFER.
+ Unit tests based on the the existing FlatBuffers.Test project.
@github-actions github-actions bot added python c# c++ codegen Involving generating code from schema documentation Documentation json CI Continuous Integration labels Feb 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

c# c++ CI Continuous Integration codegen Involving generating code from schema documentation Documentation json python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant