mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-20 00:24:03 +08:00
889572472f
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).
127 lines
5.5 KiB
Markdown
127 lines
5.5 KiB
Markdown
# 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
|
||
|
||
```pwsh
|
||
# 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.
|