Files
romanett 6cfb8cf196 Server: remove per-request context copies and lock hot spots from the request path (−16 % allocation per Read) (#4767)
## Description

Five small changes to the per-request server path, found in the CPU and
allocation profiles of the o6 read benchmark (`AsyncCustomNodeManager`,
Server GC, 10 clients × 100 Reads in flight) on master 3a31473cf. Each
change is its own commit and can be reviewed and reverted on its own.
The channel gate (about 40 % of waits) is not touched; that is A1.

### Results

Paired A/B runs against master (0e40e1229), alternating builds per
round, 5 rounds, medians (min..max). The machine is shared with other
sessions: allocated bytes per request are deterministic; CPU per request
is best read from the runs pinned to 4 cores.

| Scenario | master | this PR | Change |
|---|---|---|---|
| o6read 10 × 100, server pinned to 4 cores: CPU / request | 39.1 µs
(35.9..39.8) | 36.9 µs (35.5..37.6) | **−5.6 %** |
| same: allocated / request | 6,198 B | 5,224 B | **−15.7 %** |
| same: req/s | 98.7k (95.4..108.5k) | 104.6k (97.6..105.9k) | +5.9 % |
| o6read 10 × 100, 24 cores: req/s | 183.7k (173.3..198.0k) | 189.8k
(178.9..197.5k) | +3.4 % |
| same: allocated / request | 6,500 B | 5,578 B | −14.2 % |
| o6read 10 × 100 Basic256Sha256, 24 cores: req/s | 159.8k
(149.4..178.7k) | 164.2k (156.7..165.3k) | +2.7 % |
| same: allocated / request | 6,590 B | 5,694 B | −13.6 % |
| `read --nodes 1`, 20 sessions: req/s | 155.3k (141.6..157.6k) | 163.8k
(163.4..166.5k) | +5.5 % |
| same: allocated / request | 5,548 B | 4,689 B | −15.5 % |
| `read --nodes 100`: allocated / request | 74.4 KB | 70.1 KB | −5.8 % |
| `browse`: allocated / request | 39.8 KB | 35.0 KB | −12.2 % |
| `browse`: lock contentions / 1000 requests | 474 | 367 | −23 % |

At 24 cores CPU per request stays within noise, because the time these
changes free now waits at the channel gate (see below).

Per item, pinned to 4 cores (5 rounds; CPU deltas of one item are within
run-to-run noise, allocation deltas are not):

| Item | CPU / request | Allocated / request |
|---|---|---|
| Q1 system context | +1.8 % | −1.7 % |
| Q2 request table | −4.3 % | −1.4 % |
| Q3 request timeouts | −2.8 % | −2.2 % |
| Q4 namespace metadata | −3.5 % | ±0 |
| Q5 read-path allocations | −2.4 % | −10.9 % |

Sampled thread time at 24 cores (cpu trace, first `Opc.Ua` frame above
the wait or copy):

| Frame | master | this PR |
|---|---|---|
| `ServerSystemContext.Copy` (incl. `MemberwiseClone`) | 12.4 % | 0.1 %
|
| `RequestManager` (`ConcurrentDictionary` inside
`EnterValidationScope`, scope `Dispose`, `RequestReceived`,
`CompleteRequest`) | 7.1 % | 0 |
| `RequestLifetime` (CTS registration spin,
ctor/`RegisterExternal`/`DisposeResources`) | 4.8 % | 0.2 % |
| `NamespaceMetadataRegistry.GetAsync` lock | 4.0 % | 0 |
| `RuntimeHelpers.InitializeArray` (`ReadValidationAttributes`) | 2.2 %
| 0 |
| Channel gate (`ChannelGate+AwaitEntryAsync`, inclusive) | 31 % | 46 %
|

## Changes

**Q1: one system context copy per request and node manager.**
`AsyncCustomNodeManager`'s service methods (Read, Write, Browse,
TranslateBrowsePaths, Call, HistoryRead/Update, monitored item and event
services) use the per-request copy from
`OperationContext.GetSystemContext` instead of copying the node
manager's context again. The per-request cache keeps the first node
manager's copy without an extra object and up to eight more, so a
request that touches several node managers no longer evicts the other
one's copy. `ServerSystemContext` copies itself with a copy constructor
instead of `MemberwiseClone` (which allocates through a runtime call)
unless it is a derived type; `SessionSystemContext` gains a protected
copy constructor. Copies per Read went from about three to one.

**Q2: lock-free request registration in `RequestManager`.** #4742 took
the server-wide lock off the request path, but the two
`ConcurrentDictionary`s for executing requests and open validation
scopes stayed the main waiting point after the channel gate (four writes
per request from every worker). `RequestTable<T>` places an entry with a
compare-and-swap into a free slot near a scrambled hash of its key, and
the entry remembers its slot so completion is one more compare-and-swap;
only when every probed slot is taken does an entry go to an overflow set
under a lock. Drains, Cancel, Session close, the timeout sweep and
`Dispose` read the slots one after the other, which keeps the existing
ordering arguments (registration fence vs. Session/Cancel marks, drain
count published before the snapshot). Sharding the dictionaries over
per-processor locks was tried first and only moved about 5 % of waits
into the shard locks.

**Q3: no timer or shared-token registrations per request.**
`RequestLifetime`'s timeout constructor, used by the endpoint for every
request, no longer creates a `Timer` and no longer registers with the
server's request-queue token and the transport token, which every worker
registered with and so contended on. The deadline and the two tokens are
watched by `RequestDeadlineMonitor`: one sweep every 20 ms over
intrusive lists sharded by processor, running only while requests are
monitored. The `params` constructor keeps its synchronous registrations.

**Q4: namespace metadata lookups without a lock.**
`NamespaceMetadataRegistry` reads its caches (found and missing, by URI
and now also by index) without its lock; only filling and invalidating
take it. The generation moves before the caches are cleared, so a lookup
racing an invalidation never reports a stale miss.

**Q5: fewer per-call allocations on the read path.**
`ReadValidationAttributes` fills its attribute-id array element by
element instead of with an array initializer
(`RuntimeHelpers.InitializeArray` per call); single-node Read and Write
requests skip the per-request attribute cache and validate with
`permissionsOnly` (the same three attributes, read once); `ReadAsync`
allocates the list of nodes to validate only when needed. (Review: a
shared static attribute array, a per-thread `Variant[]` scratch array
and a per-thread Read node cache were dropped again because they crossed
virtual calls; the measurements below were taken with them, so Q5's
allocation saving is a little smaller than shown.)

## Behaviour notes

- **Request timeouts (Q3):** a `TimeoutHint` now fires up to 20 ms late
(the sweep period), and cancelling the server's or the transport's token
reaches the request's `CancellationToken` up to 20 ms later instead of
synchronously. A token that is already cancelled cancels the request at
once, and `TryCancel` checks the tokens first, so a token cancelled
before an explicit Cancel still owns the terminal state. The
exactly-once terminal state of #4744 is unchanged: the sweep claims it
through the same compare-and-swap.
- **System contexts (Q1):** a custom node manager that changed fields of
the context it got in a service call now changes the copy shared by that
request's other calls into the same node manager; it never reaches other
requests or the node manager's own `SystemContext`.
- **Request ids (Q2):** `RequestManager.RequestReceived` no longer
throws for a different `OperationContext` with a request id that is
still registered. Requests are tracked by their context; ids come from a
server-wide counter.

## Testing

- New: `ServerSystemContextCopyTests` (every field copied, derived types
kept), `AsyncCustomNodeManagerTests` (one copy per node manager and
request, two managers in one request, not shared across requests;
single- vs multi-node Read verdicts identical incl. a node read twice),
`RequestTableTests` (idempotent add, exactly-once remove, overflow,
concurrent churn while enumerating; drain racing completion on many
threads; Session close and Cancel by handle exactly once while others
register), `RequestDeadlineMonitorTests` (timeout precision with a fake
clock, token propagation, ownership vs. explicit Cancel, no leaked
entries, sweep/cancel/complete race), `NamespaceMetadataRegistryTests`
(cached miss by index invalidated, lookups racing `Invalidate`,
permission changes visible through the cached node).
- Run locally (net10.0): Opc.Ua.Types.Tests, Opc.Ua.Core.Tests (Stack),
Opc.Ua.Server.Tests, Opc.Ua.Sessions.Tests, Opc.Ua.Subscriptions.Tests,
Opc.Ua.Redundancy.Server.Tests, Opc.Ua.Stress.Tests Contract +
Integration. net48 builds.
- Measured with `perf/ServerLoadHarness` scenarios `o6read` (o6 harness,
not part of this PR), `read`, `browse`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 06:25:31 +02:00

376 lines
16 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.Tests;
// The obsolete RequestCompleted is the direct way to complete a request in these tests.
#pragma warning disable CS0618
namespace Opc.Ua.Server.Tests
{
/// <summary>
/// Verifies the lock-free request table of the <see cref="RequestManager"/> and that
/// drains, Cancel and Session close still see every request started before them while
/// registrations and completions run concurrently.
/// </summary>
[TestFixture]
[Category("RequestManager")]
public class RequestTableTests
{
[TestCase(1, 16)]
[TestCase(16, 16)]
[TestCase(17, 32)]
[TestCase(4000, 4096)]
public void CapacityIsAPowerOfTwoOfAtLeastTheProbeLimit(int capacity, int expected)
{
Assert.That(new RequestTable<TestEntry>(capacity).Capacity, Is.EqualTo(expected));
}
[Test]
public void AddIsIdempotentAndRemoveSucceedsOnce()
{
var table = new RequestTable<TestEntry>(64);
var entry = new TestEntry(7);
var sameKey = new TestEntry(7);
table.Add(entry);
table.Add(entry);
table.Add(sameKey);
Assert.That(table.GetEntries(), Is.EquivalentTo(new[] { entry, sameKey }), "entries are tracked by reference");
Assert.That(table.Contains(entry), Is.True);
Assert.That(table.Remove(entry), Is.True);
Assert.That(table.Remove(entry), Is.False);
Assert.That(table.Contains(entry), Is.False);
Assert.That(table.Contains(sameKey), Is.True);
Assert.That(table.Remove(new TestEntry(7)), Is.False, "an entry never added is not removed");
// an entry can be added again after it was removed.
table.Add(entry);
Assert.That(table.Contains(entry), Is.True);
}
/// <summary>
/// Concurrent adds of the same entry place it once, so one remove takes it out and no
/// ghost entry is left behind.
/// </summary>
[Test]
public async Task ConcurrentAddsOfTheSameEntryPlaceItOnceAsync()
{
var table = new RequestTable<TestEntry>(64);
for (int attempt = 0; attempt < 2000; attempt++)
{
var entry = new TestEntry((uint)attempt);
using var start = new Barrier(4);
var adders = new Task[4];
for (int ii = 0; ii < adders.Length; ii++)
{
adders[ii] = Task.Run(() =>
{
start.SignalAndWait();
table.Add(entry);
});
}
await Task.WhenAll(adders).WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false);
Assert.That(table.GetEntries(), Has.Count.EqualTo(1), $"attempt {attempt}");
Assert.That(table.Remove(entry), Is.True);
Assert.That(table.GetEntries(), Is.Empty, $"attempt {attempt}: ghost entry left");
}
}
[Test]
public void EntriesBeyondTheProbedSlotsOverflowAndAreStillTracked()
{
var table = new RequestTable<TestEntry>(16);
var entries = new List<TestEntry>();
for (int ii = 0; ii < 40; ii++)
{
var entry = new TestEntry(5);
entries.Add(entry);
table.Add(entry);
}
Assert.That(table.OverflowCount, Is.EqualTo(40 - 16));
Assert.That(entries.TrueForAll(table.Contains), Is.True);
Assert.That(table.GetEntries(), Is.EquivalentTo(entries));
Assert.That(entries.TrueForAll(table.Remove), Is.True);
Assert.That(table.GetEntries(), Is.Empty);
Assert.That(table.OverflowCount, Is.Zero);
foreach (TestEntry entry in entries)
{
table.Add(entry);
}
Assert.That(table.Clear(), Is.EquivalentTo(entries));
Assert.That(table.GetEntries(), Is.Empty);
Assert.That(entries.TrueForAll(entry => !table.Contains(entry)), Is.True);
}
/// <summary>
/// Writers add and remove their own entries while another thread enumerates: every
/// entry is removed exactly once and the table ends empty.
/// </summary>
[Test]
public async Task ConcurrentAddRemoveAndEnumerationLeaveTheTableEmptyAsync()
{
var table = new RequestTable<TestEntry>(256);
using var stop = new CancellationTokenSource();
int failedRemovals = 0;
int seenByScanner = 0;
Task scanner = Task.Run(() =>
{
while (!stop.IsCancellationRequested)
{
Interlocked.Add(ref seenByScanner, table.GetEntries().Count);
}
});
var writers = new Task[8];
for (int writer = 0; writer < writers.Length; writer++)
{
uint seed = (uint)writer * 1_000_000;
writers[writer] = Task.Run(() =>
{
var mine = new TestEntry[64];
for (int round = 0; round < 2000; round++)
{
for (int ii = 0; ii < mine.Length; ii++)
{
mine[ii] = new TestEntry(seed++);
table.Add(mine[ii]);
}
foreach (TestEntry entry in mine)
{
if (!table.Remove(entry))
{
Interlocked.Increment(ref failedRemovals);
}
}
}
});
}
await Task.WhenAll(writers).WaitAsync(TimeSpan.FromMinutes(2)).ConfigureAwait(false);
stop.Cancel();
await scanner.ConfigureAwait(false);
Assert.That(failedRemovals, Is.Zero);
Assert.That(table.GetEntries(), Is.Empty);
Assert.That(table.OverflowCount, Is.Zero);
TestContext.Out.WriteLine($"entries seen by the scanner: {seenByScanner}");
}
private sealed class TestEntry : IRequestTableEntry
{
public TestEntry(uint key)
{
TableKey = key;
}
public uint TableKey { get; }
public ref int TableSlot => ref m_tableSlot;
private int m_tableSlot = RequestTable<TestEntry>.kNoSlot;
}
/// <summary>
/// A drain started while many requests are executing waits for each of
/// them, however the completions interleave across threads, and completes exactly
/// once after the last one.
/// </summary>
[Test]
[Category("NodeManagerLifecycle")]
public async Task DrainWaitsForEveryRequestStartedBeforeItWhileThreadsCompleteThemAsync()
{
using RequestManager manager = CreateManager();
const int count = 2000;
var lifetimes = new RequestLifetime[count];
var contexts = new OperationContext[count];
for (int ii = 0; ii < count; ii++)
{
lifetimes[ii] = new RequestLifetime();
contexts[ii] = CreateContext((uint)ii, lifetimes[ii]);
manager.RequestReceived(contexts[ii]);
}
Task drain = manager.WaitForCurrentRequestsAsync().AsTask();
Assert.That(drain.IsCompleted, Is.False);
// Complete all but the last request from several threads, interleaved with
// requests that start after the drain and must not extend it.
int next = -1;
var workers = new Task[8];
for (int worker = 0; worker < workers.Length; worker++)
{
workers[worker] = Task.Run(() =>
{
int index;
while ((index = Interlocked.Increment(ref next)) < count - 1)
{
using var lateLifetime = new RequestLifetime();
OperationContext late = CreateContext(uint.MaxValue, lateLifetime);
manager.RequestReceived(late);
manager.RequestCompleted(contexts[index]);
manager.RequestCompleted(late);
}
});
}
await Task.WhenAll(workers).ConfigureAwait(false);
Assert.That(drain.IsCompleted, Is.False, "the drain completed before the last request it waits for");
manager.RequestCompleted(contexts[count - 1]);
await drain.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
Assert.That(drain.Status, Is.EqualTo(TaskStatus.RanToCompletion));
Assert.That(lifetimes.All(lifetime => lifetime.StatusCode == StatusCodes.Good), Is.True,
"completion marks each lifetime completed without cancelling it");
foreach (RequestLifetime lifetime in lifetimes)
{
lifetime.Dispose();
}
}
/// <summary>
/// A Session close racing registrations cancels every request of the Session that
/// was registered before it, exactly once, and none of another Session.
/// </summary>
[Test]
public async Task SessionCloseCancelsEveryEarlierRequestExactlyOnceWhileOthersRegisterAsync()
{
using RequestManager manager = CreateManager();
var closing = new Mock<ISession>();
closing.Setup(s => s.Id).Returns(new NodeId("Closing", 0));
var other = new Mock<ISession>();
other.Setup(s => s.Id).Returns(new NodeId("Other", 0));
const int count = 1000;
var before = new List<(OperationContext Context, RequestLifetime Lifetime)>();
for (int ii = 0; ii < count; ii++)
{
var lifetime = new RequestLifetime();
var context = new OperationContext(
new RequestHeader { RequestHandle = (uint)ii }, null!, RequestType.Read, lifetime, closing.Object);
manager.RequestReceived(context);
before.Add((context, lifetime));
}
using var stop = new CancellationTokenSource();
int otherCancelled = 0;
Task churn = Task.Run(() =>
{
while (!stop.IsCancellationRequested)
{
using var lifetime = new RequestLifetime();
var context = new OperationContext(
new RequestHeader { RequestHandle = 1 }, null!, RequestType.Read, lifetime, other.Object);
manager.RequestReceived(context);
if (lifetime.CancellationToken.IsCancellationRequested)
{
Interlocked.Increment(ref otherCancelled);
}
manager.RequestCompleted(context);
}
});
uint aborted = manager.CancelSessionRequests(closing.Object.Id, 0, StatusCodes.BadSessionClosed);
uint again = manager.CancelSessionRequests(closing.Object.Id, 0, StatusCodes.BadSessionClosed);
stop.Cancel();
await churn.ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(aborted, Is.EqualTo(count));
Assert.That(again, Is.Zero, "a request is cancelled once");
Assert.That(before.All(entry => entry.Context.OperationStatus.Code == StatusCodes.BadSessionClosed), Is.True);
Assert.That(otherCancelled, Is.Zero);
});
foreach ((OperationContext context, RequestLifetime lifetime) in before)
{
manager.RequestCompleted(context);
lifetime.Dispose();
}
}
/// <summary>
/// A Cancel by request handle finds every matching request wherever it is in the table.
/// </summary>
[Test]
public void CancelByHandleFindsEveryMatchingRequest()
{
using RequestManager manager = CreateManager();
var session = new Mock<ISession>();
session.Setup(s => s.Id).Returns(new NodeId("CancelByHandle", 0));
var lifetimes = new List<RequestLifetime>();
var matching = new List<OperationContext>();
for (int ii = 0; ii < 256; ii++)
{
var lifetime = new RequestLifetime();
lifetimes.Add(lifetime);
uint handle = ii % 2 == 0 ? 42u : (uint)ii;
var context = new OperationContext(
new RequestHeader { RequestHandle = handle }, null!, RequestType.Read, lifetime, session.Object);
manager.RequestReceived(context);
if (handle == 42)
{
matching.Add(context);
}
}
manager.CancelRequests(session.Object.Id, 42, out uint cancelCount);
Assert.That(cancelCount, Is.EqualTo((uint)matching.Count));
Assert.That(matching.All(context => context.OperationStatus.Code == StatusCodes.BadRequestCancelledByClient), Is.True);
Assert.That(lifetimes.Count(lifetime => lifetime.CancellationToken.IsCancellationRequested), Is.EqualTo(matching.Count));
lifetimes.ForEach(lifetime => lifetime.Dispose());
}
private static RequestManager CreateManager()
{
var server = new Mock<IServerInternal>();
server.Setup(s => s.Telemetry).Returns(NUnitTelemetryContext.Create());
return new RequestManager(server.Object);
}
private static OperationContext CreateContext(uint requestHandle, RequestLifetime lifetime)
{
return new OperationContext(
new RequestHeader { RequestHandle = requestHandle },
null,
RequestType.Read,
lifetime);
}
}
}