Skip to content
emrahsunguPublic

About

Zero-copy .NET-to-C++ serialization with generated C++20 views, schema evolution, and no IDL.

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Tessera

Tessera

Binary buffers from plain .NET objects, read in place by C++20 and .NET.
No IDL, no parsing, and no allocations on the read side.

NuGet

Documentation · Installation · Attributes · Diagnostics · Benchmarks

1. Example

Your C# classes are the schema:

namespace Game;

public class Monster
{
    public string? Name;
    public short Hp = 100;            // equal to its default: not stored
    public int? Mana;                 // nullable: C++ can tell whether it was set
    public Vec3 Position;             // plain struct: stored inline
    public List<Weapon>? Weapons;
}

public class Weapon { public string? Name; public int Damage; }

public record struct Vec3(float X, float Y, float Z);

Serialize one. That call is all the source generator needs: it writes a serializer for Monster and for every type Monster uses.

using Game;
using Tessera;

var orc = new Monster
{
    Name = "Orc",
    Mana = 25,
    Position = new Vec3(1, 2, 3),
    Weapons = [new() { Name = "Axe", Damage = 9 }, new() { Name = "Bow", Damage = 4 }],
};

byte[] buffer = TesseraSerializer.Serialize(orc);
File.WriteAllBytes("monster.bin", buffer);
Console.WriteLine($"{buffer.Length} bytes");
328 bytes

The same build writes a C++ header for each class. From game/Monster.tessera.hpp (shortened):

class Monster final : public tessera::Table<Monster> {
public:
    std::string_view            name() const noexcept;      // string? Name
    std::int16_t                hp() const noexcept;        // short Hp = 100
    std::optional<std::int32_t> mana() const noexcept;      // int? Mana
    const Vec3&                 position() const noexcept;  // Vec3 Position
    tessera::Vector<Weapon>     weapons() const noexcept;   // List<Weapon>? Weapons
    // has_name(), has_hp() ...: whether each field was saved
};

C++ verifies the buffer once, then reads it where it lies:

#include "game/Monster.tessera.hpp"

// data, size: the 328 bytes of monster.bin, in 8-byte aligned memory
tessera::Reader<game::Monster> reader(data, size);  // verifies the buffer once
if (!reader) return 1;

game::Monster orc = reader.root();  // a view: reads in place, nothing is parsed or copied
const game::Vec3& p = orc.position();
std::cout << orc.name() << ": hp " << orc.hp() << ", mana " << orc.mana().value_or(0)
          << ", position " << p.x << ' ' << p.y << ' ' << p.z << '\n';
for (game::Weapon w : orc.weapons()) std::cout << "  " << w.name() << ": damage " << w.damage() << '\n';
Orc: hp 100, mana 25, position 1 2 3
  Axe: damage 9
  Bow: damage 4

hp wasn't saved, because it equaled its default value, so C++ returned the default compiled into the header. orc is a pointer into the buffer, wrapped in a class: keep the buffer (and the Reader) alive while you use it. The whole example, including loading the file, is in samples/Quickstart.

mana() returns std::optional because int? is a nullable value type. Strings, lists and objects are never wrapped in std::optional: if a monster's Weapons was null, weapons() is simply empty, and converts to false (Missing values).

.NET reads the buffer in place too, through a read type generated for each class (MonsterView for Monster), or creates the objects again:

MonsterView view = MonsterView.Read(buffer);   // checks the buffer once; copies nothing
Vec3 p = view.Position;
Console.WriteLine($"{view.Name}: hp {view.Hp}, mana {view.Mana}, position {p.X} {p.Y} {p.Z}");
foreach (WeaponView w in view.Weapons) Console.WriteLine($"  {w.Name}: damage {w.Damage}");

Monster copy = TesseraSerializer.Deserialize<Monster>(buffer);
Console.WriteLine($"{copy.Name} again, with {copy.Weapons!.Count} weapons");
Orc: hp 100, mana 25, position 1 2 3
  Axe: damage 9
  Bow: damage 4
Orc again, with 2 weapons

A view reads each field from the buffer when you ask for it, so keep the buffer unchanged while you use it; Deserialize creates ordinary objects that no longer need the buffer (Reading in .NET).

What the 328 bytes hold

Part Bytes Contents
Header 16 where the top-level object starts, a format marker and version, and a hash of the classes' layout
Data 88 the orc (28), the weapon list (12), two weapons (12 each) and three strings (8 each)
Schema 224 a description of Monster, Weapon, Vec3 and the list: each field's name (as a hash) and type

The schema lets programs built from older or newer versions of these classes read the buffer: add, remove or reorder fields whenever you like (Changing your classes). Its size depends on the classes, not on the data, so it only matters for small buffers. When the writer and the reader are always built from the same classes, leave it out (IncludeSchema):

byte[] small = TesseraSerializer.Serialize(orc, new TesseraOptions { IncludeSchema = false });  // 104 bytes

2. Getting started

Add the package from nuget.org (--prerelease, because every version so far is a preview):

dotnet add package Tessera.Serialization --prerelease

Then choose where the C++ headers go, in your project file:

<PropertyGroup>
  <!-- After each build, the generated headers and the C++ runtime headers are written here. -->
  <TesseraCppOutputDir>cpp/generated</TesseraCppOutputDir>
</PropertyGroup>

In C++, add that folder to the include path. game/Monster.tessera.hpp includes what Monster needs, and the header named after the assembly (<AssemblyName>.tessera.hpp) includes every model type. The runtime is header-only C++20. Buffers must start at an 8-byte aligned address, as memory from new, malloc or a std::vector<std::uint64_t> does.

To build the package from source, or to use this repository's projects in your solution instead of the package, see Installation; the documentation covers every attribute, option and diagnostic.

3. Why Tessera

Tessera was built for game tooling: UI prefabs, records, scene graphs and time series authored in .NET and loaded by a C++ engine. Both sides are generated from the same C# source, so they cannot drift apart.

  • Your classes are the schema. No separate schema file and no required attributes: classes, records, structs, enums, nullable values, lists, dictionaries, and fields declared as an abstract class or interface, which can hold any derived class. Types that can't be saved are reported at compile time, with a suggested replacement.
  • No parsing. C++ and .NET validate a buffer once; after that, every field read is a plain memory load: nothing is parsed, copied or allocated. .NET can also create your objects again from it.
  • Readable C++. One header per class, std::optional for nullable fields, std::string_view for strings, enum class with your names, and const S& straight into the buffer for plain structs.
  • Fast. Saving is 9.4× faster than FlatBuffers, 3.3× faster than MessagePack-CSharp and 6.3× faster than Google.Protobuf, with no memory allocated besides the result (none at all with a reused writer). Validating a buffer is 2.7–4.0× faster than FlatBuffers' verifier; validating and then reading every field is 1.6–1.9× faster than with FlatBuffers, and 4–18× faster than with MessagePack, Protocol Buffers or JSON, which must parse first. Geometric means over the benchmark workloads (per compiler for C++). In .NET, creating the objects again, with the buffer checked, is 4.6× faster than MessagePack-CSharp, 4.2× faster than Google.Protobuf's parse, 22× faster than System.Text.Json and 1.14× faster than MemoryPack, and reading every field in place is 5.9× faster than with FlatBuffers. MemoryPack, a .NET-only format that can only be read from the start, saves about as fast: Tessera is faster on four of the nine workloads, MemoryPack on five, by up to 1.5× where many strings are written; see Performance.
  • Compact. Fields that are null or equal to their default take no space, and equal strings are stored once: buffers are up to 70% smaller than FlatBuffers' (1.3× on the geometric mean). MessagePack and Protocol Buffers, which store small integers in fewer bytes, are about 10% smaller.
  • Versioning by name. Add, remove, reorder and rename fields anywhere, with no field numbers to manage: older and newer programs read each other's data (Changing your classes).
  • Hardened. About 450,000 fuzzed buffers per test run under GCC's AddressSanitizer and UBSan, MSVC and Clang, and every change to the format or the reader is benchmarked against the commit before it.
  • Apache-2.0 licensed.

4. Performance

Environment: Intel Core i7-6700K, Windows 10, .NET 10.0.12. C++ with clang-cl 19.1, MSVC 19.42 and GCC 15.2 (in WSL), each compiler with the same flags for every library (optimized, AVX2). Tessera is compared with:

Format .NET writer .NET reader C++ reader
FlatBuffers 25.12.19 FlatBuffers' builder, from the same objects its generated accessors, without its verifier (which cannot check buffers over 32 KB) FlatBuffers
MessagePack MessagePack-CSharp 3.1.10 the same, into the same classes msgpack-cxx 9.0.0
Protocol Buffers Google.Protobuf 3.36.2, whose generated classes the objects are converted to on every save the same, into its generated classes libprotobuf 36.2
JSON System.Text.Json (source-generated) and Newtonsoft.Json 13.0.4 the same, into the same classes simdjson 5.0.3
MemoryPack MemoryPack 1.21.4 the same, into the same classes none: MemoryPack is .NET only

There are nine workloads: a UI prefab (400 objects with polymorphic components), a game world (1,000 monsters), 2,000 sparse records (40 optional fields each), a 20,000-sample time series, three scene graphs of 1,024 nodes (dense, sparse, and 16 distinct nodes repeated), two dictionaries of 5,000 entries, and canada.json, the contour of Canada from the common JSON benchmarks: one GeoJSON polygon of 480 rings and 55,563 points. Every library saves the same C# objects, and every C++ reader computes a checksum over every field, which must match the C# objects before anything is timed.

Saving and reading in .NET are timed with BenchmarkDotNet, every case in three processes of its own; C++ reads are medians of interleaved rounds. Tessera and FlatBuffers read a buffer where it lies. MessagePack, Protocol Buffers and JSON must parse it first, so for them, reading includes parsing; each parser runs its fastest way, measured, reusing its memory from one parse to the next. docs/BENCHMARKS.md has every table and the details.

How many times faster (or smaller) Tessera is, as the geometric mean over the nine workloads, with the range per workload in parentheses. A safe read validates the buffer, then reads every field. In .NET, "deserialize" creates the objects again (checked: Tessera's Deserialize, and the other libraries' deserializers; Protocol Buffers' parse creates its own generated classes), and "read in place" reads every field without creating objects, unchecked for both libraries.

vs FlatBuffers vs MessagePack vs Protocol Buffers vs JSON vs MemoryPack
.NET write 9.41× (1.65–100) 3.32× (1.30–10.8) 6.32× (3.36–27.8) 20.6× (7.57–206) 0.99× (0.65–1.60)
.NET deserialize 4.55× (1.88–13.9) 4.20× (2.51–18.6) 22.1× (7.48–173) 1.14× (0.99–1.44)
.NET read in place 5.94× (4.70–10.2)
Size 1.27× (1.00–3.37) 0.90× (0.59–2.01) 0.92× (0.65–1.42) 2.15× (1.30–3.04) 1.28× (0.87–8.48)
Safe read, Clang 1.68× (1.05–2.51) 5.38× (3.84–13.9) 4.08× (2.13–9.08) 11.3× (5.39–27.7)
Safe read, GCC 1.85× (1.04–2.78) 4.77× (3.75–10.8) 4.11× (2.30–9.25) 10.7× (5.44–25.7)
Safe read, MSVC 1.62× (1.03–3.33) 6.83× (4.59–17.6) 5.95× (2.33–12.5) 18.4× (6.05–56.2)

Below 1, Tessera is slower or larger: see Where Tessera is slower.

Tessera against FlatBuffers, MessagePack, Protocol Buffers, JSON and MemoryPack, geometric means per metric and compiler

These numbers describe these workloads on this machine; they are not guarantees. Measure with your own data.

.NET write

.NET write time per workload for Tessera, FlatBuffers, MessagePack, MemoryPack, Protocol Buffers and System.Text.Json

.NET read

.NET deserialize time per workload for Tessera, MemoryPack, MessagePack, Protocol Buffers and System.Text.Json

Deserialize checks the buffer as it creates the objects; without the check (TesseraReadOptions.Trusted, for data a program wrote itself) it is up to 19% faster. Reading every field in place with the read types, checked too, creates nothing: 44 µs instead of 59 for the prefab, 71 instead of 253 for the dictionaries. It takes longer than deserializing only on the records and canada, because the benchmark mixes every value read into a checksum (80,000 fields and 111,126 coordinates), where deserializing copies them.

Buffer size

Buffer size per workload for Tessera, FlatBuffers, MessagePack, MemoryPack, Protocol Buffers and JSON

MessagePack and Protocol Buffers store small integers in one or two bytes, so they are smaller on six of the nine workloads, and MemoryPack, which stores no offsets, on four. Tessera stores values at their full width, so that C++ can read them in place. It is the smallest on the prefab and the sparse records, and smaller than MessagePack and Protocol Buffers on canada, whose coordinates are doubles.

C++: validate, then read every field

Validate then read every field, Tessera against FlatBuffers per workload and compiler

Against MessagePack, Protocol Buffers and JSON, which must parse the whole buffer first, a safe read is 2.1–56 times faster (the table above).

C++: 1,000 random reads

Random reads, Tessera against FlatBuffers per workload and compiler

Dictionary lookups

Dictionary lookups by string and by int, Tessera against FlatBuffers per compiler

Where Tessera is slower

  • Saving in .NET, against MemoryPack, on five of the nine workloads: the dense scenes (1.53× with unique names, 1.21× with repeated ones), the monsters (1.11×), and within 2% on the dictionaries and canada. Where it falls behind, it is mostly the cost of storing equal strings once (the default, Sharing.Strings): every string written is looked up first, and MemoryPack writes each one as it comes. Without string sharing (Sharing.None), Tessera is within 8% of MemoryPack on all five, and faster on the dictionaries. MemoryPack writes the fields one after another, with nothing that would let a reader find a field without reading everything before it, and only .NET can read it; Tessera also writes the offsets and bits that let C++ read any field directly.
  • Reading in .NET, against MemoryPack, on canada by 0.7% (94.2 µs against 93.5), within the measurements' spread: both create the same objects (920,376 bytes allocated) and copy the same 55,563 points. On the other eight workloads, Tessera's checked Deserialize is 1.01–1.44× faster.
  • Size, against MessagePack and Protocol Buffers on six of the nine workloads (1.07–1.70×), and MemoryPack on four (1.00–1.15×): see Buffer size above.
  • C++ reads: by no more than 1.1%. Reading every field ties with FlatBuffers on a few workloads (within 1.1%): there, every library runs at the speed of the benchmark's checksum, which mixes every value into one running hash. MessagePack's and Protocol Buffers' reads of an already parsed tree are not compared, because they leave the parse out.

With MSVC, the C++ readers' small helper functions are force-inlined, for every library: MSVC stopped inlining them into one large function when it read Tessera, which made reading every field of the prefab 24% slower. The other libraries' code does not change (benchmarks/native/readers.hpp).

The full list, computed from the results, is in docs/BENCHMARKS.md.

Reproduce everything (it fetches the other libraries and canada.json, SHA-256 pinned, into .deps/, and builds libprotobuf from source once per compiler):

powershell scripts/bench.ps1

It writes every table to docs/BENCHMARKS.md, the raw samples to docs/benchmarks/*.json and these charts to docs/images/benchmarks. Every change to the format or the reader is also measured against the commit before it with scripts/ab.ps1 (see How to build).

5. How it works

During the build, a source generator (code that runs inside the C# compiler) reads your classes and writes two things for each: a C# serializer, and a C++ header with an accessor class. At run time, the serializer turns objects into a buffer, and C++ validates the buffer once, then reads fields straight from it.

your C# classes and structs
        │
        │  source generator, during the build
        ▼
C# serializer ───► buffer: for each object, which fields are stored, their values, offsets to
                   strings and lists, and (optionally) a description of the classes
                    │
                    ▼
          C++ tessera::Reader<T>: validates the buffer once
                    │
                    ▼
          generated accessor classes: every field read is a memory load,
          nothing is parsed, copied or allocated

Each object records which fields it has

An object starts with one bit per field, saying whether the field is stored, followed by the values of the stored fields ("cells"). Fields that are null or equal to their default take no space, and bool fields live in the bits themselves (two bits: stored, value).

   0          4W              4W+F
   ┌──────────┬───────────────┬───────┬───────┬─────────┬────────┬───
   │ bits:    │ fields at     │ value │ value │ offset  │ offset │ …     only stored fields take space,
   │ stored?  │ fixed places  │ 8 B   │ 4 B   │ string  │ list   │       sorted by alignment, then size
   └──────────┴───────────────┴───────┴───────┴─────────┴────────┴───

   position of a field = 4W + F + Σ size × popcount(bits & mask)
                         one popcount per run of equal-size values, or one load
                         per byte of bits from a table of value sizes

The generator knows every class's layout, so the C++ header contains the bit masks and sizes as constants. Reading a field is a bit test, one or a few popcount instructions (to count the stored fields before it) and a memory load: no per-object table of positions (FlatBuffers stores one, its "vtable") and no hashing. Where the fields before a field have different sizes, one byte of the bits indexes a 256-entry table of their total size instead: one load replaces several popcounts, and more independent reads overlap inside the CPU. Values of 8 bytes or less, and plain structs, are stored in the object; strings, lists, nested objects and polymorphic values are stored elsewhere and referenced by an offset.

Fields that are always set: fixed positions

Fields marked [TesseraKeepDefault] (numbers, enums and plain structs) are always stored, so they need no bit: they come first, at fixed positions, and reading one is a single load. On the time series workload, that makes random reads 20–33% faster and validation up to 7% faster, at the same size.

Written back to front

The serializer builds a buffer from its end, so every offset points forward and cycles are impossible. Repeated values can be stored once (Sharing): by default equal strings; with Sharing.All also equal lists, objects and shared structs, which turns the buffer into a graph without cycles (a DAG). The serializers are generated code, so no reflection runs, and with a reused TesseraWriter nothing is allocated (except the enumerator of a dictionary that isn't a Dictionary<TKey, TValue>, such as a SortedDictionary: about 230 bytes).

Validate once, then plain loads

tessera::Reader<T> checks the whole buffer before the first read: the header, every object's bits and values, every offset (in bounds, pointing forward, aligned, and present where required), every string's and list's length, every type ID of a polymorphic field, the nesting depth, and, if you ask, that strings are valid UTF-8. The validator is generated for each class. In a buffer with shared values, a value reached by many paths would be checked once per path, which can grow exponentially with nesting; when that adds up to more work than the buffer's size, the validator switches to checking each shared value once (the only case in which it allocates memory, to remember what it checked). After validation, reading a field needs no bounds check.

Same classes: direct reads; another version: converted once

buffer's layout hash == the header's  ──►  fields at positions known at compile time, no lookups
                     !=               ──►  read the buffer's description of its classes once, match fields
                                           by the hash of their names, convert the buffer into the reader's
                                           layout, then read as above

Every buffer records a hash of its classes' layout. A reader built from the same classes reads the buffer directly. A reader built from another version matches the fields by name once, when the Reader opens the buffer, and converts the buffer into its own layout, so every later read is the same plain load; fields it doesn't find read as missing. The conversion costs one copy of the buffer when opening (values stored once stay stored once), and the copy lives as long as the Reader. The matching compares the actual layouts, and never trusts the buffer's hashes for safety. docs/FORMAT.md describes every byte.

Generated code

Generated Where Contents
C# serializers compiled into your assembly one per class, no reflection
C# read types and checks compiled into your assembly one read type per class (MonsterView), list and dictionary, and a check of buffers per type
C# deserializers compiled into your assembly one per class, compiled three ways: checking the buffer as it reads; reading a buffer checked before; and creating each stored item once, for buffers that share items heavily
C++ headers TesseraCppOutputDir, after each build one per class (in a folder per C++ namespace), and one per assembly that includes them all
C++ library copied next to the headers header-only: accessor classes, Reader, validation, conversion between versions, JSON output

6. Documentation

The documentation explains everything, with an example and the use cases for each part:

Requirements

  • Using the package: .NET 8 or later, and any C# compiler from the .NET 8 SDK on.
  • C++: a C++20 compiler and CMake 3.20 or later. Tested with MSVC 19.42, clang-cl 19, GCC 15 and Clang 21.
  • Building this repository: the .NET 10 SDK, CMake, and Visual Studio 2022 or later with "Desktop development with C++" (add "C++ Clang tools for Windows" for clang-cl; Visual Studio 2026 needs CMake 4.2 or later); WSL with GCC and Clang for the Linux tests, and with GCC, CMake, Ninja and Python 3 for the GCC benchmark.

How to build

1. Build

dotnet build Tessera.slnx -c Release

To use Tessera from this repository's source in a project of your own, import src/Tessera.ProjectReference.targets at the end of its project file, as samples/Quickstart does, instead of referencing src/Tessera. The source generator runs only as an analyzer, which a project reference does not pass on, so that project would get the runtime alone and Serialize would throw; inside the repository, the build warns about it (TESSERA015).

2. Run tests

powershell scripts/test.ps1             # .NET tests, then C++ interop, fuzz and UTF-8 tests with MSVC
powershell scripts/test.ps1 -Asan       # also an MSVC AddressSanitizer build
powershell scripts/test.ps1 -Linux      # also GCC (AddressSanitizer + UBSan) and Clang in WSL
powershell scripts/test.ps1 -Package    # also packs Tessera and builds samples/Quickstart from the package

3. Run benchmarks

powershell scripts/bench.ps1            # writes docs/BENCHMARKS.md and docs/images/benchmarks
powershell scripts/bench.ps1 -NoGcc     # without WSL; -NoClang without clang-cl; -Quick for a short smoke run

4. Compare two commits

powershell scripts/ab.ps1 -Baseline <commit> -Candidate <commit>
powershell scripts/ab.ps1 -Candidate HEAD -Jcc   # with the JCC-erratum mitigation, to rule out code placement

Both commits are built in their own worktrees and run in alternating order; the other libraries run on both sides as the noise control.

Repository layout

Folder Contents
src/Tessera runtime (writer, options, attributes) and the package project
src/Tessera.Generator source generator: model analysis, wire layout, C# writers, C++ headers
src/Tessera.Cpp build step that writes the generated headers to disk
cpp/include/tessera C++20 header-only runtime: views, verifier, schema translation, JSON dump
tests .NET tests, generator tests, C++ interop, fuzz and UTF-8 tests
benchmarks the benchmark against FlatBuffers, MessagePack, Protocol Buffers, JSON and MemoryPack (C# writers, C++ readers)
samples/Quickstart the example at the top of this page, end to end
docs the documentation: guide, attributes, diagnostics, wire format; benchmark results and their raw data, the README's charts

Third-party components

The Tessera.Serialization package bundles no third-party code. The following projects are used to build, test and benchmark it; each is distributed under its own license.

Project Used for License
FlatBuffers 25.12.19 benchmarks: flatc, C++ headers, the C# runtime (built from source) Apache-2.0
msgpack-c (C++) 9.0.0 benchmarks: the C++ MessagePack reader BSL-1.0
MessagePack-CSharp 3.1.10 benchmarks: the .NET MessagePack writer MIT
Protocol Buffers 36.2 benchmarks: protoc, the C++ reader (libprotobuf, built from source) and the .NET writer (Google.Protobuf 3.36.2) BSD-3-Clause
Abseil 20250512.1 benchmarks: required by libprotobuf (built from source) Apache-2.0
simdjson 5.0.3 benchmarks: the C++ JSON reader Apache-2.0 or MIT
Newtonsoft.Json 13.0.4 benchmarks: a .NET JSON writer (System.Text.Json, part of .NET, is the other) MIT
MemoryPack 1.21.4 benchmarks: a .NET writer (it has no C++ reader) MIT
BenchmarkDotNet 0.15.8 benchmarks: timing the .NET writes MIT
nativejson-benchmark benchmarks: canada.json, the canada workload's data (fetched, not included) MIT
xUnit.net v3 tests Apache-2.0
Roslyn (Microsoft.CodeAnalysis.CSharp 4.8) building the source generator (not shipped) MIT
xxHash the hash algorithm of member names, union tags and fingerprints, implemented in src/Shared/XxHash.cs BSD-2-Clause

Contributing

Bug reports and pull requests are welcome; see CONTRIBUTING.md. Please report security problems privately, as described in SECURITY.md.

License

Tessera is released under the Apache License 2.0.

Status

Tessera is a preview: Tessera.Serialization on nuget.org, wire format version 2. The API and the wire format may change before 1.0; readers reject buffers of other format versions.

About

Zero-copy .NET-to-C++ serialization with generated C++20 views, schema evolution, and no IDL.

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages