From 1e74359300e7262a75d9c8dfbc9c0e947a984319 Mon Sep 17 00:00:00 2001
From: Pedro Sakuma Travi <39205549+pedrosakuma@users.noreply.github.com>
Date: Sat, 25 Apr 2026 19:14:33 +0000
Subject: [PATCH 1/3] bench: add foreach vs callback group decode benchmark
(#156)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds GroupForeachVsCallbackBenchmarks comparing the v1.5.0 foreach-style
group enumerator against the original ReadGroups callback API on
MarketDataData (two simple top-level groups), parameterized over
GroupSize ∈ {10, 50, 100}.
Results on AMD EPYC 7763 / .NET 9 (GroupSize=100):
Callback 999 ns 152 B 1.00x
Foreach 184 ns 0 B 0.18x
Foreach + break 3 ns 0 B 0.003x
Foreach is ~5x faster on full iteration and eliminates the 152 B
per-call closure allocation. Early break is essentially free because
each group property does an O(1) skip rather than running every entry.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
benchmarks/README.md | 16 ++++
.../GroupForeachVsCallbackBenchmarks.cs | 92 +++++++++++++++++++
2 files changed, 108 insertions(+)
create mode 100644 benchmarks/SbeCodeGenerator.Benchmarks/GroupForeachVsCallbackBenchmarks.cs
diff --git a/benchmarks/README.md b/benchmarks/README.md
index 2bc8b16..0a850df 100644
--- a/benchmarks/README.md
+++ b/benchmarks/README.md
@@ -56,6 +56,22 @@ Tests performance of repeating group encoding/decoding with varying group sizes
- `EncodeWithGroups` - Encoding messages with repeating groups
- `DecodeWithGroups` - Decoding messages with repeating groups
+### GroupForeachVsCallbackBenchmarks
+Compares the v1.5.0 foreach-style group enumerator (#156) against the original `ReadGroups` callback API.
+- `Decode_Callback` - Baseline: `ReadGroups` with capturing lambdas (closure allocation per call)
+- `Decode_Foreach` - Zero-alloc ref struct enumerator
+- `Decode_Foreach_EarlyBreak` - Demonstrates break-out savings vs callbacks (which always run to completion)
+
+Reference results (AMD EPYC 7763, .NET 9, GroupSize=100):
+
+| Method | Mean | Allocated |
+|-------------------|----------:|----------:|
+| Callback | 999 ns | 152 B |
+| Foreach | 184 ns | 0 B |
+| Foreach + break | 3.3 ns | 0 B |
+
+Foreach is ~5× faster on full iteration and eliminates the 152 B closure allocation. Early break is essentially free because each group property is an O(1) skip.
+
### ComplexMessageBenchmarks
Tests performance of complex messages with multiple groups (bids, asks, trades).
- `EncodeComplexMessage` - Encoding complex multi-group messages
diff --git a/benchmarks/SbeCodeGenerator.Benchmarks/GroupForeachVsCallbackBenchmarks.cs b/benchmarks/SbeCodeGenerator.Benchmarks/GroupForeachVsCallbackBenchmarks.cs
new file mode 100644
index 0000000..d2014b1
--- /dev/null
+++ b/benchmarks/SbeCodeGenerator.Benchmarks/GroupForeachVsCallbackBenchmarks.cs
@@ -0,0 +1,92 @@
+using BenchmarkDotNet.Attributes;
+using Benchmark.Messages.V0;
+
+namespace SbeCodeGenerator.Benchmarks;
+
+///
+/// Issue #156 follow-up: compare the foreach-style group enumerator (v1.5.0+) against
+/// the existing ReadGroups callback API for decoding messages with simple top-level groups.
+///
+/// Workload (held identical across variants): iterate every entry of every group and accumulate
+/// Price + Quantity into a checksum, returning the result so the JIT can't elide the work.
+///
+/// Variants:
+/// - Callback : current API; lambdas capture a local sum -> closure allocation per call.
+/// - Foreach : v1.5.0 zero-alloc enumerator; pure stack state via ref struct.
+/// - Foreach_EarlyBreak : foreach + break after first entry per group; demonstrates skipping cost.
+///
+[MemoryDiagnoser]
+[SimpleJob(warmupCount: 3, iterationCount: 10)]
+public class GroupForeachVsCallbackBenchmarks
+{
+ private byte[] _encoded = null!;
+
+ [Params(10, 50, 100)]
+ public int GroupSize { get; set; }
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ var bids = new MarketDataData.BidsData[GroupSize];
+ var asks = new MarketDataData.AsksData[GroupSize];
+ for (int i = 0; i < GroupSize; i++)
+ {
+ bids[i] = new MarketDataData.BidsData { Price = 1_000_000 - i * 100, Quantity = 100 + i };
+ asks[i] = new MarketDataData.AsksData { Price = 1_010_000 + i * 100, Quantity = 50 + i };
+ }
+
+ var buffer = new byte[64 * 1024];
+ var header = new MarketDataData { InstrumentId = 42, Timestamp = 1234567890UL };
+ MarketDataData.TryEncode(header, buffer, bids, asks, out int written);
+ _encoded = new byte[written];
+ Array.Copy(buffer, _encoded, written);
+ }
+
+ [Benchmark(Baseline = true, Description = "Decode via ReadGroups (callback)")]
+ public long Decode_Callback()
+ {
+ long sum = 0;
+ if (MarketDataData.TryParse(_encoded, out var reader))
+ {
+ reader.ReadGroups(
+ (in MarketDataData.BidsData b) => sum += b.Price.Value + b.Quantity.Value,
+ (in MarketDataData.AsksData a) => sum += a.Price.Value + a.Quantity.Value
+ );
+ }
+ return sum;
+ }
+
+ [Benchmark(Description = "Decode via foreach enumerator")]
+ public long Decode_Foreach()
+ {
+ long sum = 0;
+ if (MarketDataData.TryParse(_encoded, out var reader))
+ {
+ foreach (ref readonly var b in reader.Bids)
+ sum += b.Price.Value + b.Quantity.Value;
+ foreach (ref readonly var a in reader.Asks)
+ sum += a.Price.Value + a.Quantity.Value;
+ }
+ return sum;
+ }
+
+ [Benchmark(Description = "Decode via foreach + early break (first entry only)")]
+ public long Decode_Foreach_EarlyBreak()
+ {
+ long sum = 0;
+ if (MarketDataData.TryParse(_encoded, out var reader))
+ {
+ foreach (ref readonly var b in reader.Bids)
+ {
+ sum += b.Price.Value + b.Quantity.Value;
+ break;
+ }
+ foreach (ref readonly var a in reader.Asks)
+ {
+ sum += a.Price.Value + a.Quantity.Value;
+ break;
+ }
+ }
+ return sum;
+ }
+}
From f80d3e71de8daa446d14eb3bab787d17e3cbb1cc Mon Sep 17 00:00:00 2001
From: Pedro Sakuma Travi <39205549+pedrosakuma@users.noreply.github.com>
Date: Wed, 29 Apr 2026 13:43:12 +0000
Subject: [PATCH 2/3] feat: direct varData properties on DataReader (#162)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Expose top-level fields as zero-allocation direct properties on
the {Msg}DataReader ref struct, alongside the existing ReadGroups
callback API. Each property recomputes its offset by chaining the
existing Skip{Group} helpers (O(1) per simple group) plus per-data
SkipData{Prev} helpers — fully stateless, safe regardless of access
order, no closure capture.
Gated on the same condition as the foreach group enumerators (#156):
all top-level groups must be simple (no nested groups, no group-level
varData). Also valid when the message has no top-level groups.
Property emission is suppressed when a varData name collides with a
reserved member of the reader struct (e.g. would
clash with the Data block accessor); ReadGroups remains usable for
those fields. SkipData helpers are still emitted for skipped names so
that subsequent varData properties resolve correctly.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
CHANGELOG.md | 16 ++++
README.md | 5 +-
.../Generators/Types/MessageDefinition.cs | 88 ++++++++++++++++++-
.../GroupForeachWithVarDataTests.cs | 43 +++++++++
4 files changed, 148 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5dddcfa..208b7fd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+### Added
+
+- **Direct properties for top-level `` (varData) on `{Msg}DataReader`** (#162): Each top-level varData field now also exposes a property returning the variable-length composite (e.g. `VarStringEncoding`) computed directly from the buffer. Zero allocation, no callback, no closure capture:
+ ```csharp
+ if (NewOrderData.TryParse(buffer, out var reader))
+ {
+ foreach (ref readonly var leg in reader.Legs) { /* ... */ }
+ var coid = reader.ClientOrderId.VarData; // ReadOnlySpan, zero-alloc
+ }
+ ```
+ Each access is **stateless** — the start offset is recomputed from the buffer by chaining the existing `Skip{Group}` helpers (O(1) per group) plus `SkipData{Prev}` for any prior varData. Safe regardless of access order, before/after iterating groups, and across multiple reads. For repeated reads, cache the returned value locally.
+
+ **Gating**: emitted only when *all* top-level groups are simple (no nested groups, no group-level varData) — same gate as the foreach enumerators added in #156. Also valid for messages with only varData and no groups. When a varData name collides with a reserved member of the reader struct (e.g. `` clashing with the `Data` block accessor), the direct property is silently skipped for that field; it remains reachable via `ReadGroups`. `ReadGroups` continues to work unchanged for all cases.
+
## [1.5.0] - 2026-04-25
### Added — DevEx P0 features
diff --git a/README.md b/README.md
index 6759e37..080a713 100644
--- a/README.md
+++ b/README.md
@@ -59,7 +59,7 @@ See the [v1.2.0 entry in CHANGELOG.md](./CHANGELOG.md) for the full list.
## What's New in v1.0.0
-**Zero-copy `MessageDataReader`** — `TryParse` returns a lightweight ref struct that holds a reference directly into the buffer. Access fields via `reader.Data` (zero-copy `ref readonly`), iterate top-level groups via `foreach` (since v1.5.0, no closure alloc) or `reader.ReadGroups(...)`, and recover the raw wire bytes via `reader.Buffer` / `reader.Block` (since v1.3.0) for replay/forwarding scenarios.
+**Zero-copy `MessageDataReader`** — `TryParse` returns a lightweight ref struct that holds a reference directly into the buffer. Access fields via `reader.Data` (zero-copy `ref readonly`), iterate top-level groups via `foreach` (since v1.5.0, no closure alloc), read top-level varData via direct properties (since v1.6.0, zero alloc) or `reader.ReadGroups(...)`, and recover the raw wire bytes via `reader.Buffer` / `reader.Block` (since v1.3.0) for replay/forwarding scenarios.
```csharp
if (CarData.TryParse(buffer, out var car))
@@ -69,6 +69,9 @@ if (CarData.TryParse(buffer, out var car))
// v1.5.0: foreach-style enumerators on simple top-level groups (zero alloc, no closures).
foreach (ref readonly var fuel in car.FuelFigures) { /* ... */ }
+ // v1.6.0: direct properties for top-level varData (zero alloc, no callback).
+ var manufacturer = car.Manufacturer.VarData; // ReadOnlySpan
+
// For groups with nested groups or group-level varData, ReadGroups remains:
car.ReadGroups(
(in FuelFiguresData fuel) => { /* ... */ },
diff --git a/src/SbeCodeGenerator/Generators/Types/MessageDefinition.cs b/src/SbeCodeGenerator/Generators/Types/MessageDefinition.cs
index b1a8ed9..80c6435 100644
--- a/src/SbeCodeGenerator/Generators/Types/MessageDefinition.cs
+++ b/src/SbeCodeGenerator/Generators/Types/MessageDefinition.cs
@@ -671,14 +671,22 @@ private void AppendReaderStruct(StringBuilder sb, int tabs)
AppendGroupEnumerators(sb, tabs);
}
+ // Issue #162: direct properties for top-level varData (zero-alloc, no callback).
+ // Same gate as enumerators: requires that every top-level group be simple so the
+ // pre-varData offset is the cheap O(1) chain of Skip{Group} helpers.
+ // Also valid when there are no top-level groups (varData starts right at _blockLength).
+ if (CanExposeVarDataDirect)
+ {
+ AppendVarDataProperties(sb, tabs);
+ }
+
sb.AppendLine("}", --tabs);
}
- private bool HasSimpleTopLevelGroupsForEnumerator
+ private bool AreAllTopLevelGroupsSimple
{
get
{
- if (TypedGroups.Count == 0) return false;
foreach (var g in TypedGroups)
{
if (g.HasGroupData || g.HasNestedGroups) return false;
@@ -687,6 +695,12 @@ private bool HasSimpleTopLevelGroupsForEnumerator
}
}
+ private bool HasSimpleTopLevelGroupsForEnumerator
+ => TypedGroups.Count > 0 && AreAllTopLevelGroupsSimple;
+
+ private bool CanExposeVarDataDirect
+ => TypedDatas.Count > 0 && AreAllTopLevelGroupsSimple;
+
private void AppendGroupEnumerators(StringBuilder sb, int tabs)
{
// Per-group: emit a static skip helper + property + nested ref struct enumerator.
@@ -805,8 +819,76 @@ private void AppendGroupEnumerators(StringBuilder sb, int tabs)
}
}
- private void AppendReadGroups(StringBuilder sb, int tabs)
+ private void AppendVarDataProperties(StringBuilder sb, int tabs)
{
+ // Names already taken on the reader struct (excluding group enumerator props,
+ // which we add below). Colliding varData names skip the direct property and
+ // remain accessible through ReadGroups.
+ var reserved = new HashSet(System.StringComparer.Ordinal)
+ {
+ "Data", "Buffer", "Block", "BlockLength", "BytesConsumed", "ReadGroups",
+ "GetEnumerator", "Equals", "GetHashCode", "ToString", "GetType",
+ };
+ foreach (var g in TypedGroups) reserved.Add(g.Name);
+
+ // Pre-build the offset expression that points just past all top-level groups.
+ // When there are no groups, the chain reduces to "_blockLength".
+ void AppendAfterGroupsOffset(StringBuilder dest)
+ {
+ for (int j = 0; j < TypedGroups.Count; j++)
+ dest.Append("Skip").Append(TypedGroups[j].Name).Append("(_buffer, ");
+ dest.Append("_blockLength");
+ for (int j = 0; j < TypedGroups.Count; j++)
+ dest.Append(")");
+ }
+
+ // Emit SkipData{Name} helpers for every varData except the last one.
+ // Always emitted (even when the property of that data is suppressed by a
+ // name collision) because subsequent varData offsets depend on it.
+ for (int i = 0; i < TypedDatas.Count - 1; i++)
+ {
+ var data = TypedDatas[i];
+ sb.AppendLine("", tabs);
+ sb.AppendLine("[MethodImpl(MethodImplOptions.AggressiveInlining)]", tabs);
+ sb.AppendTabs(tabs).Append("private static int SkipData").Append(data.Name)
+ .AppendLine("(ReadOnlySpan buffer, int offset)");
+ sb.AppendLine("{", tabs++);
+ sb.AppendTabs(tabs).Append("if ((uint)offset > (uint)(buffer.Length - sizeof(")
+ .Append(data.LengthPrefixType).AppendLine("))) return buffer.Length;");
+ sb.AppendTabs(tabs).Append("var data = ").Append(data.Type)
+ .AppendLine(".Create(buffer.Slice(offset));");
+ sb.AppendLine("return offset + data.TotalLength;", tabs);
+ sb.AppendLine("}", --tabs);
+ }
+
+ // Emit a direct property for each varData that doesn't clash with a reserved name.
+ // Each property recomputes the start offset from the buffer (stateless, safe regardless
+ // of access order). Properties are skipped when colliding with existing reader members
+ // (e.g. ) — those varData remain reachable via ReadGroups.
+ for (int i = 0; i < TypedDatas.Count; i++)
+ {
+ var data = TypedDatas[i];
+ if (reserved.Contains(data.Name)) continue;
+
+ sb.AppendLine("", tabs);
+ sb.AppendLine("/// ", tabs);
+ sb.AppendTabs(tabs).Append("/// Zero-allocation direct access to the ").Append(data.Name)
+ .AppendLine(" variable-length data segment.");
+ sb.AppendLine("/// Each access recomputes the segment offset from the buffer (stateless and safe", tabs);
+ sb.AppendLine("/// regardless of access order). For repeated reads, cache the returned value locally.", tabs);
+ sb.AppendLine("/// ", tabs);
+ sb.AppendTabs(tabs).Append("public ").Append(data.Type).Append(" ").Append(data.Name).Append(" => ")
+ .Append(data.Type).Append(".Create(_buffer.Slice(");
+ for (int j = 0; j < i; j++)
+ sb.Append("SkipData").Append(TypedDatas[j].Name).Append("(_buffer, ");
+ AppendAfterGroupsOffset(sb);
+ for (int j = 0; j < i; j++)
+ sb.Append(")");
+ sb.AppendLine("));");
+ }
+ }
+
+ private void AppendReadGroups(StringBuilder sb, int tabs) {
var callbackParams = BuildCallbackParams($"{Name}Data.");
sb.AppendLine("", tabs);
sb.AppendLine("/// ", tabs);
diff --git a/tests/SbeCodeGenerator.IntegrationTests/GroupForeachWithVarDataTests.cs b/tests/SbeCodeGenerator.IntegrationTests/GroupForeachWithVarDataTests.cs
index c5f0140..cf16a3c 100644
--- a/tests/SbeCodeGenerator.IntegrationTests/GroupForeachWithVarDataTests.cs
+++ b/tests/SbeCodeGenerator.IntegrationTests/GroupForeachWithVarDataTests.cs
@@ -91,5 +91,48 @@ public void Foreach_EmptyGroup_WithVarDataStillReadable()
cid => coid = Encoding.UTF8.GetString(cid.VarData));
Assert.Equal("EMPTY-LEGS-OK", coid);
}
+
+ [Fact]
+ public void DirectVarDataProperty_AfterSimpleGroups_ReturnsSameBytesAsCallback()
+ {
+ // Issue #162: zero-alloc direct access to top-level varData via property.
+ // Must yield identical bytes as the callback path, regardless of access order.
+ var order = new NO { OrderId = 99, Quantity = 50 };
+ var legs = new[]
+ {
+ new NO.LegsData { LegSymbol = 10, LegRatio = 1 },
+ new NO.LegsData { LegSymbol = 20, LegRatio = 2 },
+ };
+ var buffer = Encode(order, legs, "DIRECT-PROP-COID");
+
+ Assert.True(NO.TryParse(buffer, out var reader));
+
+ // Direct property — no closure, no callback, computed from buffer offsets.
+ var coidBytes = reader.ClientOrderId.VarData.ToArray();
+ Assert.Equal("DIRECT-PROP-COID", Encoding.UTF8.GetString(coidBytes));
+
+ // Property is stateless: accessing again (and even before iterating groups) is safe.
+ var coidBytes2 = reader.ClientOrderId.VarData.ToArray();
+ Assert.Equal(coidBytes, coidBytes2);
+
+ // Iterate groups afterwards — direct property must still resolve correctly.
+ int count = 0;
+ foreach (ref readonly var _ in reader.Legs) count++;
+ Assert.Equal(2, count);
+
+ var coidBytes3 = reader.ClientOrderId.VarData.ToArray();
+ Assert.Equal(coidBytes, coidBytes3);
+ }
+
+ [Fact]
+ public void DirectVarDataProperty_WithEmptyGroup_ResolvesCorrectly()
+ {
+ var order = new NO { OrderId = 1 };
+ var buffer = Encode(order, Array.Empty(), "EMPTY-DIRECT");
+
+ Assert.True(NO.TryParse(buffer, out var reader));
+
+ Assert.Equal("EMPTY-DIRECT", Encoding.UTF8.GetString(reader.ClientOrderId.VarData));
+ }
}
}
From 19d0e43da76a7e467433c5765c14f1a2105a3ae3 Mon Sep 17 00:00:00 2001
From: Pedro Sakuma Travi <39205549+pedrosakuma@users.noreply.github.com>
Date: Wed, 29 Apr 2026 13:48:36 +0000
Subject: [PATCH 3/3] chore: release v1.6.0
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
CHANGELOG.md | 2 +-
src/SbeCodeGenerator/SbeSourceGenerator.csproj | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 208b7fd..b31032e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [Unreleased]
+## [1.6.0] - 2026-04-29
### Added
diff --git a/src/SbeCodeGenerator/SbeSourceGenerator.csproj b/src/SbeCodeGenerator/SbeSourceGenerator.csproj
index 9dc5c40..7018d66 100644
--- a/src/SbeCodeGenerator/SbeSourceGenerator.csproj
+++ b/src/SbeCodeGenerator/SbeSourceGenerator.csproj
@@ -11,7 +11,7 @@
false
SbeSourceGenerator
SBE Source Generator
- 1.5.0
+ 1.6.0
Pedro Sakuma
Pedro Sakuma
SBE Source Generator