Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

## [1.6.0] - 2026-04-29

### Added

- **Direct properties for top-level `<data>` (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<byte>, 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. `<data name="data">` 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
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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<byte>

// For groups with nested groups or group-level varData, ReadGroups remains:
car.ReadGroups(
(in FuelFiguresData fuel) => { /* ... */ },
Expand Down
16 changes: 16 additions & 0 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using BenchmarkDotNet.Attributes;
using Benchmark.Messages.V0;

namespace SbeCodeGenerator.Benchmarks;

/// <summary>
/// Issue #156 follow-up: compare the foreach-style group enumerator (v1.5.0+) against
/// the existing <c>ReadGroups</c> callback API for decoding messages with simple top-level groups.
///
/// Workload (held identical across variants): iterate every entry of every group and accumulate
/// <c>Price + Quantity</c> into a checksum, returning the result so the JIT can't elide the work.
///
/// Variants:
/// - Callback : current API; lambdas capture a local <c>sum</c> -> 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.
/// </summary>
[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;
}
}
88 changes: 85 additions & 3 deletions src/SbeCodeGenerator/Generators/Types/MessageDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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<string>(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<byte> 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. <data name="data">) — 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("/// <summary>", tabs);
sb.AppendTabs(tabs).Append("/// Zero-allocation direct access to the <c>").Append(data.Name)
.AppendLine("</c> 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("/// </summary>", 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("/// <summary>", tabs);
Expand Down
2 changes: 1 addition & 1 deletion src/SbeCodeGenerator/SbeSourceGenerator.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<IncludeBuildOutput>false</IncludeBuildOutput>
<PackageId>SbeSourceGenerator</PackageId>
<Title>SBE Source Generator</Title>
<Version>1.5.0</Version>
<Version>1.6.0</Version>
<Authors>Pedro Sakuma</Authors>
<Company>Pedro Sakuma</Company>
<Product>SBE Source Generator</Product>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<NO.LegsData>(), "EMPTY-DIRECT");

Assert.True(NO.TryParse(buffer, out var reader));

Assert.Equal("EMPTY-DIRECT", Encoding.UTF8.GetString(reader.ClientOrderId.VarData));
}
}
}
Loading