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).
5.5 KiB
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). ShortRunjob: 3 warmup iterations, 3 measured iterations, single launch.- Inner loop is
Iterations = 1000allocate/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-poolnew 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
MonitoredItemNotificationitems brings allocation down to 31% of baseline. The residual 32 KB is theMonitoredItemNotification[]backing array itself (array pooling is out of scope for this phase).
For EventFieldList:
- The baseline
newpath is already cheap (~9 µs) because the emptyEventFieldsArrayOf<Variant>is a zero-allocation default. The pooled path addsInterlocked.CompareExchange+ pool round-trip overhead (~22 µs). Under realistic dispatch where eachEventFieldListcarries a non-emptyEventFieldsarray, 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
DataValuehelps all paths — Read, Browse, Call, and unpooled publish. EveryDataValuethat 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:
Variantvalue payload (arbitrary user data insideDataValue.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.