Files
UA-.NETStandard/Docs/perf/PooledNotificationBenchmarks.md
Marc Schier 889572472f Convert DataValue from class to readonly struct for further GC relief (#3796)
Convert `DataValue` from a mutable reference type (`class`) to a `readonly struct` to eliminate per-allocation GC pressure on every attribute read, subscription publish, and history sample.

### Key API changes (user-visible)

| Before | After | Pattern |
|---|---|---|
| `class DataValue` | `readonly struct DataValue : INullable` | Foundation |
| `Equals(DataValue?)` / `operator ==(DataValue?, DataValue?)` | `Equals(DataValue)` / `operator ==(DataValue, DataValue)` | Value semantics |
| `IEncoder.WriteDataValue(string?, DataValue?)` | `IEncoder.WriteDataValue(string?, DataValue)` | Non-nullable wire API |
| `IDecoder.ReadDataValue(...) -> DataValue?` | `IDecoder.ReadDataValue(...) -> DataValue` | Same |
| `IDataChangeMonitoredItemQueue.PeekLast/Oldest()` | `TryPeekLastValue/Oldest(out DataValue)` | TryGet pattern |
| `IAggregateCalculator.GetProcessedValue(bool)` | `TryGetProcessedValue(bool, out DataValue)` | Same |
| `IUaPubSubDataStore.ReadPublishedDataItem(...)` | `TryReadPublishedDataItem(NodeId, uint, out DataValue)` | Same |
| `Dictionary<uint, DataValue?>` (NodeCacheContext) | `Dictionary<uint, DataValue>` | Default + IsNull |
| `IHistoryDataSource.FirstRaw/NextRaw` | `TryFirstRaw/TryNextRaw(out DataValue)` | TryGet pattern |
| `NodeState.ReadAttribute(... DataValue)` | `NodeState.ReadAttribute(... ref DataValue)` | Sync mutation via ref |
| `NodeState.ReadAttributeAsync(... DataValue)` | `NodeState.ReadAttributeAsync(...) -> ValueTask<(ServiceResult, DataValue)>` | Async return tuple |
| `obj as DataValue` (heterogeneous Dictionary<string, object>) | `obj is DataValue dv ? dv : default(DataValue)` | Safe struct cast |
| `Assert.That(dv, Is.Not.Null)` (tests) | `Assert.That(dv.IsNull, Is.False)` | Struct test pattern |
| `[Obsolete] Value` setter, all property setters | Removed; use ctor or `With*()` | Immutability |
| `[DataContract]` + `[DataMember]` | Removed; surrogate via `SerializableDataValue` + `DataValueSurrogateProvider` | Serialization |

### Wire format

Wire format is **byte-identical** to the previous class-based implementation across binary / JSON / XML encoders. Default DataValue (`.IsNull == true`) is treated as the equivalent of the old null reference (writes JSON `null`, binary `byte 0`, XML omitted element).

### Why TryGet pattern (instead of leaving `DataValue?`)

For methods that previously returned `DataValue?` to signal "no value available" (queue peek, aggregate result, data store lookup, history navigation), the conversion to `bool TryGet(out DataValue)` matches BCL conventions (`Dictionary.TryGetValue`, `Queue.TryPeek`, `Int32.TryParse`) and gives the caller an explicit "may be absent" affordance without `Nullable<DataValue>` overhead.

### Breaking changes summary

- `DataValue` is now a `readonly struct` - boxing/unboxing semantics change for callers using `object`-typed APIs.
- `IEncoder.WriteDataValue` / `IDecoder.ReadDataValue` / `ReadDataValueArray` interface signatures lose the `Nullable` wrapper.
- `IDataChangeMonitoredItemQueue.PeekLastValue` / `PeekOldestValue` -> `TryPeekLastValue` / `TryPeekOldestValue` (out parameter).
- `IAggregateCalculator.GetProcessedValue` -> `TryGetProcessedValue` (out parameter).
- `IUaPubSubDataStore.ReadPublishedDataItem` -> `TryReadPublishedDataItem` (out parameter).
- `NodeState.ReadAttribute` takes `ref DataValue`; `ReadAttributeAsync` returns `ValueTask<(ServiceResult, DataValue)>`.
- `DataValue` property setters removed (use constructor + `With*` mutators).
2026-05-23 11:38:49 +02:00

5.5 KiB
Raw Permalink Blame History

V2 Subscription Notification Pooling — Benchmark Results

Microbenchmark results for the IPooledEncodeable + PooledEncodeableType<T> activator pooling feature added in support of ManagedSessionBuilder.WithPoolNotifications().

Source

Tests/Opc.Ua.Client.Tests/Subscription/PooledNotificationBenchmarks.cs

Methodology

  • BenchmarkDotNet v0.15.8, [MemoryDiagnoser], InProcessEmitToolchain (avoids the cold-rebuild timeout that the default toolchain hits on the test assembly's transitive reference graph).
  • ShortRun job: 3 warmup iterations, 3 measured iterations, single launch.
  • Inner loop is Iterations = 1000 allocate/use cycles per benchmark invocation — measured values are aggregate over the inner loop.
  • Pre-warm in [GlobalSetup] populates all four type pools to steady-state before the first measured iteration so the pool-hit fast path is exercised rather than the cold-pool new T() fallback.

Environment

Item Value
OS Windows 11 (10.0.26200.8390 / 25H2)
CPU Intel Xeon W-2235 @ 3.80 GHz, 6 physical / 12 logical cores
SDK .NET SDK 10.0.300
Host runtime .NET 10.0.8 (RyuJIT, x86-64-v4)
Config Release, -c Release -f net10.0

Results

Results measured after the DataValue readonly-struct change. DataValue is now a readonly struct that lives inline in MonitoredItemNotification.Value, eliminating one heap allocation per notification item compared to the previous class-based DataValue.

Method Mean Allocated/op Allocation ratio Gen0/1000 ops
new MonitoredItemNotification() (baseline) 34.874 µs 104,408 B 1.000 24.17
MonitoredItemNotificationActivator + Reuse 23.323 µs 408 B 0.004 0.092
new DataChangeNotification() 66.759 µs 216,344 B 2.072 50.05
DataChangeNotificationActivator + Reuse 54.812 µs 32,344 B 0.310 7.45
new EventFieldList() 8.967 µs 48,408 B 0.464 11.22
EventFieldListActivator + Reuse 22.280 µs 408 B 0.004 0.092

Comparison: DataValue as class vs readonly struct

Metric (MonitoredItemNotification, 1000 ops) DataValue = class DataValue = readonly struct Change
Baseline allocated/op 128,408 B 104,408 B −24 KB (−19%) — DataValue heap object eliminated
Pooled allocated/op 408 B 408 B unchanged — pool already recycled the whole notification
Baseline Gen0/1000 ops 29.75 24.17 −19% — fewer heap objects to collect
Pooled Gen0/1000 ops 0.092 0.092 unchanged
Pooled allocation reduction 315× 256× ratio lower because baseline is now cheaper

Interpretation

For the dominant publish-payload allocator (MonitoredItemNotification, which arrives in arrays of arbitrary length on every data-change publish):

  • Mean time per item drops from ~35 µs/1000 to ~23 µs/1000 — a 33% throughput improvement on the synthetic benchmark.
  • Allocations per 1000 ops drop from 104 KB to 408 B — ~256× reduction.
  • Gen-0 collections per 1000 ops drop from 24.17 to 0.09 — ~263× reduction.

For DataChangeNotification (container + inner items + backing array):

  • Pooling both the container and the inner MonitoredItemNotification items brings allocation down to 31% of baseline. The residual 32 KB is the MonitoredItemNotification[] backing array itself (array pooling is out of scope for this phase).

For EventFieldList:

  • The baseline new path is already cheap (~9 µs) because the empty EventFields ArrayOf<Variant> is a zero-allocation default. The pooled path adds Interlocked.CompareExchange + pool round-trip overhead (~22 µs). Under realistic dispatch where each EventFieldList carries a non-empty EventFields array, the allocation savings dominate and the pooled path wins.

How the two optimizations stack

The DataValue readonly-struct change and activator pooling are complementary:

  • Struct DataValue helps all paths — Read, Browse, Call, and unpooled publish. Every DataValue that was previously a separate heap allocation is now inline in its parent (−24 B per notification item, −19% baseline allocation).
  • Activator pooling helps the publish path specifically — it recycles the notification wrapper objects (MonitoredItemNotification, DataChangeNotification, EventFieldList, EventNotificationList) that the struct change does not address.
  • Combined: the pooled publish path allocates 256× less than a baseline that is itself 19% cheaper than before.

Reproducing

# Build release
dotnet build Tests/Opc.Ua.Client.Tests -c Release -f net10.0

# Run BDN harness
cd Tests/Opc.Ua.Client.Tests/bin/Release/net10.0
dotnet Opc.Ua.Client.Tests.dll --filter "*PooledNotificationBenchmarks*"

Artifacts (markdown, csv, html) are written to Tests/Opc.Ua.Client.Tests/bin/Release/net10.0/BenchmarkDotNet.Artifacts/results/.

Out of scope

The following payloads are not pooled in this phase and remain attributable to the residual allocation in the pooled DataChangeNotification numbers above:

  • Variant value payload (arbitrary user data inside DataValue.Value)
  • Dispatch backing arrays (DataValueChange[], EventNotification[], MonitoredItemNotification[])

DataValue is now a readonly struct and no longer allocates on the heap. It is not an IEncodeable and does not participate in the activator pool system.