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