Files
UA-.NETStandard/perf/ServerLoadHarness/NativeWorkerBenchmark.cs
romanettandClaude Opus 5.5 8b56937fdf perf: drive the server with external native benchmark workers; TraceAnalyzer GC modes (#4815)
## What this adds

Harness-only changes under `perf/` (not part of `UA.slnx`, `net10.0`).
No product code changes.

### External native worker scenarios (`ServerLoadHarness`)

Two new scenarios run the native open62541 workers of the

[o6-automation/opcua-benchmarks](https://github.com/o6-automation/opcua-benchmarks)
project
against a server, so the .NET server and other OPC UA stacks are
measured with the same client
as that project's published comparison:

| Scenario | What it measures |
|---|---|
| `native-read` | Fixed-load scalar Read throughput
(`throughput/open62541/client.c --worker async`): `--clients` worker
processes, each with `--inflight` single-value Reads of
ns=1;i=1001..1100 in flight, starting together. Reports aggregate req/s,
and server CPU and allocated bytes per request. |
| `native-counters` | Subscription capacity
(`subscription/native/client.c`): one subscription with `--nodes` UInt64
counters at `--sampling` 10 ms / `--pub` 1000 ms, queue size 1. Passes
when every item gets 99 % of the samples its sampling interval calls
for. |

The result block has the same lines as the other scenarios, and the CPU
time of the native
workers goes in `client-per-op:`.

The server is either the harness child in **contract mode** (`server
--contract 1`) or any
external executable (`--server-exe`, `--server-args` with `{port}`,
`{items}`, `{sampling}` and
`{pki}` placeholders), for example the benchmark's open62541 server or
one of its .NET workers.

By default the harness child is configured like the benchmark's .NET
worker for
UA-.NETStandard 2.0.1:

| Setting | Default | Override |
|---|---|---|
| Node managers | `AsyncCustomNodeManager` | `--async-node-manager 0`
(`CustomNodeManager2`) |
| Counter increment | under a lock owned by the counters |
`--counter-lock 0` (interlocked) |
| Garbage collector | Server GC | `SERVER_DOTNET_gcServer=0` |
| `MaxQueuedRequestCount` | clients × in flight + 3 (minimum 200) |
`--queue` |

Other options: `--native-client`, `--clients`, `--inflight`,
`--iterations`, `--warmup-calls`,
`--server-affinity` (processor mask; combine it with
`SERVER_DOTNET_PROCESSOR_COUNT`, because the
mask is applied after the runtime has sized itself),
`--client-affinity`, `--server-priority`,
`--thread-states 1` (samples Running/Ready server threads and appends a
`sched:` line), `--security encrypt`
(Basic256Sha256 SignAndEncrypt with generated DER identities) and
`--verbose`. Also, the
`MinRequestThreadCount` of the server child is now the smaller of 50 and
`--threads`, so
`--threads` below 50 takes effect.

**Worker protocol.** The workers define a few names that are kept as
string literals in named
constants: the environment variables `O6_BENCHMARK_SECURITY_POLICY`,
`O6_BENCHMARK_CERTIFICATE`
and `O6_BENCHMARK_PRIVATE_KEY`, the `O6_BENCHMARK_READY` stdout token,
the identity
application URI / subject prefixes, and the server URI
`urn:o6:benchmark:server`. The workers
need that server URI because the contract puts its nodes in the
namespace of the server URI and
the workers address them as ns=1. Identifiers, file names, scenario and
option names are neutral.

### TraceAnalyzer

- `cpu` with a focus frame also reports the first `Opc.Ua` frame above
it, past framework
  wrappers such as `Monitor.Enter` or `SemaphoreSlim`.
- **GC-poll attribution:** the thread-time sampler suspends running
threads at their next GC
poll, so many on-CPU samples have `Thread.PollGCWorker` as their leaf.
`cpu` used to drop
them as waits (in one trace only 3.5k of 20k on-CPU samples were kept);
it now attributes them
  to the caller.
- `gc` mode (for `--trace-profile gc`): collections by generation, type
and reason; the
suspension time and the whole pause, each with count, total and
percentiles.
- `samples` mode: all samples by type (managed/external) and by leaf and
first `Opc.Ua` frame,
  including the ones `cpu` leaves out.
- `suspend <trace> [thresholdMs]` mode: every SuspendEE longer than the
threshold, with the
events recorded inside it by thread (an `AllocationTick` there means a
thread that had not
reached a safe point yet); event counts including the heap count DATAS
chose and GC handle
  churn by kind.

## Building the workers

The benchmark repository is AGPL-3.0, so nothing of it is vendored here.
Clone it and build the
workers separately. On Windows they build with `zig cc` and CMake
against the open62541 commit
the benchmark pins (OpenSSL backend, `UA_ENABLE_DEBUG_SANITIZER=OFF`,
Release). The
subscription workers also need `poll()` on stdin replaced by
`PeekNamedPipe`, plus unbuffered
control output. `perf/README.md` describes all of this.

## Commits

1. `perf: drive the server with external native benchmark workers`
2. `perf: configure the contract server like the benchmark's 2.0.1 .NET
worker`
3. `perf: TraceAnalyzer gc and samples modes, samples taken at GC polls`
4. `perf: scheduling and GC-suspension diagnostics for the native worker
runs`

Each commit builds with 0 warnings
(`CustomTestTarget=net10.0 dotnet build perf/ServerLoadHarness -c
Release`,
`dotnet build perf/TraceAnalyzer -c Release`), and `dotnet format
whitespace` makes no changes.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:35:16 +00:00

887 lines
42 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.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Opc.Ua.Server;
namespace Opc.Ua.Perf.ServerLoadHarness
{
/// <summary>
/// Drives a server with the native open62541 workers of the o6-automation opcua-benchmarks
/// (https://github.com/o6-automation/opcua-benchmarks), so that the .NET server and other
/// stacks are measured with the same client as in that benchmark.
/// </summary>
/// <remarks>
/// <c>native-read</c>: the fixed-load scalar Read test of <c>throughput/open62541/client.c</c>:
/// <c>--clients</c> worker processes keep <c>--inflight</c> single-value Reads of
/// ns=1;i=1001..1100 in flight, start together and report the aggregate rate.
/// <c>native-counters</c>: the subscription capacity test of <c>subscription/native/client.c</c>:
/// one subscription with a monitored item per counter (queue size one); every item must
/// reach 99% of the samples its sampling interval calls for.
/// The server is the harness child in contract mode (<c>server --contract 1</c>), configured as the
/// benchmark's .NET worker for UA-.NETStandard 2.0.1 is: <see cref="AsyncCustomNodeManager"/> node managers
/// (<c>--async-node-manager 0</c> for <see cref="CustomNodeManager2"/>), counters incremented under a lock
/// (<c>--counter-lock 0</c> for an interlocked increment), Server GC, and a request queue of
/// clients x in flight + 3. Or the server is any
/// executable given by <c>--server-exe</c>/<c>--server-args</c>, for example the open62541
/// server of that benchmark (<c>{port}</c>, <c>{items}</c>, <c>{sampling}</c> and <c>{pki}</c>, the
/// directory with the DER identities <c>server-cert.der</c>/<c>server-key.der</c>/<c>client-cert.der</c>,
/// are replaced).
/// </remarks>
internal static class NativeWorkerBenchmark
{
/// <summary>
/// The server and namespace URI of the benchmark contract (namespace index 1). The value is
/// fixed by the external workers: the contract puts its nodes in the namespace of this server
/// URI, and the workers address them as ns=1.
/// </summary>
public const string ContractServerUri = "urn:o6:benchmark:server";
// The protocol of the external workers, which define these names; not ours to rename.
/// <summary>
/// Environment variable that selects the security policy of a worker (SignAndEncrypt when set).
/// </summary>
public const string WorkerSecurityPolicyVariable = "O6_BENCHMARK_SECURITY_POLICY";
/// <summary>
/// Environment variable with the path of the DER certificate of a worker.
/// </summary>
public const string WorkerCertificateVariable = "O6_BENCHMARK_CERTIFICATE";
/// <summary>
/// Environment variable with the path of the DER private key of a worker.
/// </summary>
public const string WorkerPrivateKeyVariable = "O6_BENCHMARK_PRIVATE_KEY";
/// <summary>
/// The line a Read worker writes to stdout once it is connected and warmed up.
/// </summary>
public const string WorkerReadyToken = "O6_BENCHMARK_READY";
/// <summary>
/// Application URI prefix of the worker identities, as the certificate script of the
/// benchmark writes them; the workers announce these application URIs.
/// </summary>
public const string WorkerApplicationUriPrefix = "urn:o6:benchmark:";
/// <summary>
/// Subject prefix of the worker identities, as the certificate script of the benchmark writes them.
/// </summary>
public const string WorkerSubjectPrefix = "CN=o6 benchmark ";
public const uint FirstNodeId = 1001;
public const int NodeCount = 100;
public static bool IsScenario(string scenario)
{
return scenario is "native-read" or "native-counters";
}
public static async Task<int> RunAsync(string[] args)
{
string scenario = Program.Arg("scenario", "native-read");
int port = Program.ArgInt("port", 48400 + (Environment.ProcessId % 1000));
bool encrypt = Program.Arg("security", "none") == "encrypt";
if (encrypt && scenario == "native-counters")
{
// the subscription worker gets no identity and connects without security, so the
// run would be labelled encrypt but measure an unsecured channel
Console.Error.WriteLine("--security encrypt is not supported for native-counters");
return 2;
}
string pki = Directory.CreateTempSubdirectory("uaperf-native-").FullName;
var environment = new Dictionary<string, string>(StringComparer.Ordinal);
try
{
if (encrypt)
{
environment[WorkerSecurityPolicyVariable] = "Basic256Sha256";
WriteIdentity(pki, "client");
WriteIdentity(pki, "server");
}
using ServerHandle server = await ServerHandle.StartAsync(scenario, port, encrypt, pki).ConfigureAwait(false);
var result = new StringBuilder();
result.Append(CultureInfo.InvariantCulture, $"RESULT scenario={scenario} security={(encrypt ? "encrypt" : "none")} ")
.Append(string.Join(' ', args.Skip(1)))
.Append('\n');
bool ok = scenario == "native-counters"
? await RunCountersAsync(server, port, environment, result).ConfigureAwait(false)
: await RunReadAsync(server, port, environment, pki, result).ConfigureAwait(false);
Console.Write(result.ToString());
if (Program.TryGetArg("out", out string? outFile))
{
await File.AppendAllTextAsync(outFile!, result.ToString()).ConfigureAwait(false);
}
await server.StopAsync().ConfigureAwait(false);
return ok ? 0 : 1;
}
finally
{
try
{
Directory.Delete(pki, true);
}
catch (IOException)
{
// a worker may still hold a file for a moment
}
}
}
private static async Task<bool> RunReadAsync(
ServerHandle server,
int port,
Dictionary<string, string> environment,
string pki,
StringBuilder result)
{
string exe = Program.Arg("native-client", "client.exe");
int clients = Program.ArgInt("clients", 10);
int inflight = Program.ArgInt("inflight", 100);
int iterations = Program.ArgInt("iterations", 100000);
int warmup = Program.ArgInt("warmup-calls", 10000);
var workers = new List<Worker>();
try
{
for (int i = 0; i < clients; i++)
{
workers.Add(Worker.StartClient(
exe,
FormattableString.Invariant(
$"--endpoint opc.tcp://127.0.0.1:{port} --iterations {iterations} --warmup {warmup} --samples 1 ") +
FormattableString.Invariant(
$"--worker async --operation read --max-outstanding {inflight} --seed {i + 1}"),
WithIdentity(environment, pki, "client")));
}
foreach (Worker worker in workers)
{
worker.WaitFor(l => l == WorkerReadyToken, TimeSpan.FromSeconds(120));
}
using Process? tracer = server.StartTracer();
await server.MarkAsync().ConfigureAwait(false);
TimeSpan clientCpu = workers.Aggregate(TimeSpan.Zero, (sum, w) => sum + w.Cpu);
foreach (Worker worker in workers)
{
worker.Send(string.Empty);
}
var reports = new List<JsonElement>();
foreach (Worker worker in workers)
{
string line = worker.WaitFor(l => l.StartsWith('{'), TimeSpan.FromSeconds(600));
reports.Add(JsonDocument.Parse(line).RootElement);
}
clientCpu = workers.Aggregate(TimeSpan.Zero, (sum, w) => sum + w.Cpu) - clientCpu;
string serverReport = await server.ReportAsync().ConfigureAwait(false);
if (tracer != null)
{
await tracer.WaitForExitAsync().ConfigureAwait(false);
}
long start = reports.Min(r => r.GetProperty("start_ns").GetInt64());
long end = reports.Max(r => r.GetProperty("end_ns").GetInt64());
long operations = reports.Sum(r => r.GetProperty("operations").GetInt64());
double seconds = (end - start) / 1e9;
result.Append(CultureInfo.InvariantCulture,
$" client: req/s={operations / seconds:F0} n={operations} sec={seconds:F2} clients={clients} inflight={inflight} ")
.Append(CultureInfo.InvariantCulture,
$"clientCpuCores={clientCpu.TotalSeconds / seconds:F2}\n")
.Append(" server: ").Append(serverReport).Append('\n');
AppendPerOp(result, serverReport, operations, clientCpu, "request");
return true;
}
finally
{
foreach (Worker worker in workers)
{
worker.Dispose();
}
}
}
private static async Task<bool> RunCountersAsync(
ServerHandle server,
int port,
Dictionary<string, string> environment,
StringBuilder result)
{
string exe = Program.Arg("native-client", "sub_client.exe");
int items = Program.ArgInt("nodes", 1);
int sampling = Program.ArgInt("sampling", 10);
int publishing = Program.ArgInt("pub", 1000);
using Worker client = Worker.StartClient(
exe,
FormattableString.Invariant($"{port} {items} {sampling} client {publishing}"),
environment);
string ready = client.WaitFor(l => l.Contains("\"ready\"", StringComparison.Ordinal), TimeSpan.FromSeconds(300));
Console.WriteLine("client " + ready);
// measurement_window(): at least 1000 samples and 5 publishes, plus 10% warm-up
long intervals = Math.Max(5, ((1000L * sampling) + publishing - 1) / publishing);
long warmupIntervals = (intervals + 9) / 10;
const int SettleMs = 2000;
const int ArmLeadMs = 500;
// attach the tracer before the window is armed, so that marking is not delayed; by
// default it records the settle time, the lead-in and the window plus 2 s
long traceSeconds = (SettleMs + ArmLeadMs + ((warmupIntervals + intervals) * publishing) + 999) / 1000 + 2;
using Process? tracer = server.StartTracer(Program.ArgInt("duration", (int)traceSeconds));
// the benchmark coordinator settles the subscription for 2 s before arming
await Task.Delay(SettleMs).ConfigureAwait(false);
long start = NowNs() + (ArmLeadMs * 1_000_000L);
long measuredStart = start + (warmupIntervals * publishing * 1_000_000L);
long measuredEnd = start + ((warmupIntervals + intervals) * publishing * 1_000_000L);
string arm = FormattableString.Invariant(
$"arm {start} {measuredEnd} {measuredStart} {measuredEnd} {publishing * 1_000_000L}");
client.Send(arm);
client.WaitFor(l => l.Contains("\"armed\"", StringComparison.Ordinal), TimeSpan.FromSeconds(2));
server.Arm(arm);
await Task.Delay(TimeSpan.FromMilliseconds(Math.Max(0, (measuredStart - NowNs()) / 1e6))).ConfigureAwait(false);
await server.MarkAsync().ConfigureAwait(false);
TimeSpan clientCpu = client.Cpu;
await Task.Delay(TimeSpan.FromMilliseconds(Math.Max(0, (measuredEnd - NowNs()) / 1e6))).ConfigureAwait(false);
clientCpu = client.Cpu - clientCpu;
string serverReport = await server.ReportAsync().ConfigureAwait(false);
string line = client.WaitFor(
l => l.Contains("\"result\"", StringComparison.Ordinal),
TimeSpan.FromMilliseconds(((measuredEnd - start) / 1e6) + 15000));
if (tracer != null)
{
await tracer.WaitForExitAsync().ConfigureAwait(false);
}
JsonElement root = JsonDocument.Parse(line).RootElement;
// counts: how far each counter advanced, i.e. the samples the server took
ulong[] counts = root.GetProperty("counts").EnumerateArray().Select(e => e.GetUInt64()).ToArray();
double expected = (measuredEnd - measuredStart) / 1e6 / sampling;
double min = counts.Min() / expected;
double mean = counts.Average(c => (double)c) / expected;
int below = counts.Count(c => c / expected < 0.99);
JsonElement delivery = root.GetProperty("delivery");
double samples = counts.Sum(c => (double)c);
double seconds = (measuredEnd - measuredStart) / 1e9;
result.Append(CultureInfo.InvariantCulture,
$" client: samples/s={samples / seconds:F0} ");
if (delivery.TryGetProperty("notifications", out JsonElement notifications) &&
notifications.ValueKind == JsonValueKind.Number)
{
// with queue size 1 only the last sample per publishing interval is delivered
result.Append(CultureInfo.InvariantCulture, $"notif/s={notifications.GetDouble() / seconds:F0} ");
}
result.Append(CultureInfo.InvariantCulture,
$"items={items} sampling={sampling}ms pub={publishing}ms ")
.Append(CultureInfo.InvariantCulture,
$"progressMin={min:P2} progressMean={mean:P2} itemsBelow99={below} ")
.Append(CultureInfo.InvariantCulture,
$"maxGapMs={delivery.GetProperty("max_gap_ms").GetDouble():F1} {(below == 0 ? "PASS" : "FAIL")}\n")
.Append(" server: ").Append(serverReport).Append('\n');
AppendPerOp(result, serverReport, samples, clientCpu, "sample");
client.Send("quit");
return below == 0;
}
private static void AppendPerOp(StringBuilder result, string serverReport, double operations, TimeSpan clientCpu, string unit)
{
if (operations <= 0)
{
return;
}
Dictionary<string, string> values = serverReport
.Split(' ')
.Select(p => p.Split('='))
.Where(p => p.Length == 2)
.ToDictionary(p => p[0], p => p[1], StringComparer.Ordinal);
double allocatedMb = values.TryGetValue("allocMB", out string? a) ? double.Parse(a, CultureInfo.InvariantCulture) : 0;
double cpuMs = double.Parse(values["cpuMs"], CultureInfo.InvariantCulture);
result.Append(CultureInfo.InvariantCulture,
$" per-op: allocB={allocatedMb * 1048576 / operations:F0} cpuUs={cpuMs * 1000 / operations:F2} ({unit})\n")
.Append(CultureInfo.InvariantCulture,
$" client-per-op: allocB=0 cpuUs={clientCpu.TotalMilliseconds * 1000 / operations:F2} ({unit}, native workers)\n");
}
internal static long NowNs()
{
// QueryPerformanceCounter, the CLOCK_MONOTONIC of the mingw-w64 workers
return (long)((Int128)Stopwatch.GetTimestamp() * 1_000_000_000 / Stopwatch.Frequency);
}
private static Dictionary<string, string> WithIdentity(Dictionary<string, string> environment, string pki, string role)
{
var result = new Dictionary<string, string>(environment, StringComparer.Ordinal);
if (environment.ContainsKey(WorkerSecurityPolicyVariable))
{
result[WorkerCertificateVariable] = Path.Combine(pki, role + "-cert.der");
result[WorkerPrivateKeyVariable] = Path.Combine(pki, role + "-key.der");
}
return result;
}
/// <summary>
/// A DER RSA-2048 key and self-signed SHA-256 certificate, as common/certificates.py writes them.
/// </summary>
private static void WriteIdentity(string directory, string role)
{
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
WorkerSubjectPrefix + role,
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
var san = new SubjectAlternativeNameBuilder();
san.AddUri(new Uri(WorkerApplicationUriPrefix + role));
san.AddDnsName("localhost");
san.AddIpAddress(IPAddress.Loopback);
request.CertificateExtensions.Add(san.Build());
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
request.CertificateExtensions.Add(new X509KeyUsageExtension(
X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment |
X509KeyUsageFlags.DataEncipherment | X509KeyUsageFlags.NonRepudiation,
true));
request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(
[new Oid("1.3.6.1.5.5.7.3.1"), new Oid("1.3.6.1.5.5.7.3.2")],
false));
using X509Certificate2 certificate = request.CreateSelfSigned(
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow.AddDays(30));
File.WriteAllBytes(Path.Combine(directory, role + "-cert.der"), certificate.RawData);
File.WriteAllBytes(Path.Combine(directory, role + "-key.der"), rsa.ExportRSAPrivateKey());
}
/// <summary>
/// The server under test: the harness child or an external executable.
/// </summary>
private sealed class ServerHandle : IDisposable
{
public static async Task<ServerHandle> StartAsync(string scenario, int port, bool encrypt, string pki)
{
string? exe = Program.TryGetArg("server-exe", out string? value) ? value : null;
var handle = new ServerHandle();
if (exe == null)
{
bool counters = scenario == "native-counters";
// As the benchmark runner sizes it for UA-.NETStandard 2.0: the whole client pipeline
// plus the three reserved bootstrap, reconnect and control slots.
int queue = Math.Max(200, (Program.ArgInt("clients", 10) * Program.ArgInt("inflight", 100)) + 3);
(handle.m_process, handle.m_lines) = Program.StartServerChild(
FormattableString.Invariant(
$"server --contract 1 --port {port} --threads {Program.Arg("threads", "200")} ") +
FormattableString.Invariant(
$"--queue {Program.Arg("queue", queue.ToString(CultureInfo.InvariantCulture))} ") +
FormattableString.Invariant(
$"--counters {(counters ? Program.Arg("nodes", "1") : "0")} --sampling {Program.Arg("sampling", "10")} ") +
FormattableString.Invariant(
$"--counter-lock {Program.Arg("counter-lock", "1")} --async-node-manager {Program.Arg("async-node-manager", "1")}"),
// the benchmark's .NET workers run with Server GC; SERVER_DOTNET_gcServer=0 overrides it
new Dictionary<string, string>(StringComparer.Ordinal) { ["DOTNET_gcServer"] = "1" });
string ready = handle.m_lines.Take();
if (!ready.StartsWith("READY", StringComparison.Ordinal))
{
throw new InvalidOperationException(ready);
}
Console.WriteLine(FormattableString.Invariant($"server pid={handle.m_process.Id} (harness, contract)"));
}
else
{
string arguments = Program.Arg("server-args", "--port {port}")
.Replace("{port}", port.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal)
.Replace("{items}", Program.Arg("nodes", "1"), StringComparison.Ordinal)
.Replace("{sampling}", Program.Arg("sampling", "10"), StringComparison.Ordinal)
.Replace("{pki}", pki, StringComparison.Ordinal);
var environment = new Dictionary<string, string>(StringComparer.Ordinal);
if (encrypt)
{
environment[WorkerSecurityPolicyVariable] = "Basic256Sha256";
environment[WorkerCertificateVariable] = Path.Combine(pki, "server-cert.der");
environment[WorkerPrivateKeyVariable] = Path.Combine(pki, "server-key.der");
}
handle.m_external = Worker.Start(exe, arguments, environment);
handle.m_process = handle.m_external.Process;
handle.m_external.WaitFor(l => l.Contains("ready", StringComparison.OrdinalIgnoreCase), TimeSpan.FromSeconds(120));
Console.WriteLine(FormattableString.Invariant($"server pid={handle.m_process.Id} ({exe} {arguments})"));
}
if ((OperatingSystem.IsWindows() || OperatingSystem.IsLinux()) &&
Program.TryGetArg("server-affinity", out string? affinity))
{
// e.g. 0x1: one core, to compare the throughput per core with a single-threaded server
handle.m_process.ProcessorAffinity = (nint)Convert.ToInt64(affinity, 16);
}
if (Program.TryGetArg("server-priority", out string? priority))
{
handle.m_process.PriorityClass = Enum.Parse<ProcessPriorityClass>(priority, true);
}
// let the listener settle before the workers connect
await Task.Delay(500).ConfigureAwait(false);
return handle;
}
/// <summary>
/// Attaches dotnet-trace for <paramref name="durationSeconds"/> (default <c>--duration</c>)
/// if <c>--trace</c> is given, and waits 2 s for the session to start.
/// </summary>
public Process? StartTracer(int? durationSeconds = null)
{
Process? tracer = Program.StartTracer(m_process!.Id, durationSeconds ?? Program.ArgInt("duration", 20), out _);
if (tracer != null)
{
Thread.Sleep(2000);
}
return tracer;
}
public void Arm(string arm)
{
// the open62541 counter server advances its counters only while armed
if (m_external != null && Program.Arg("scenario", string.Empty) == "native-counters")
{
m_external.Send(arm);
}
}
public async Task MarkAsync()
{
if (Program.ArgInt("thread-states", 0) != 0)
{
StartThreadSampler();
}
m_markTimestamp = Stopwatch.GetTimestamp();
if (m_external == null)
{
await m_process!.StandardInput.WriteLineAsync("mark").ConfigureAwait(false);
Program.Take(m_lines!, "MARKED");
m_markTimestamp = Stopwatch.GetTimestamp();
}
else
{
m_process!.Refresh();
m_markCpu = m_process.TotalProcessorTime;
}
}
public async Task<string> ReportAsync()
{
string sched = StopThreadSampler();
if (m_external == null)
{
await m_process!.StandardInput.WriteLineAsync("report").ConfigureAwait(false);
return Program.Take(m_lines!, "REPORT")[7..] + sched;
}
m_process!.Refresh();
double seconds = Stopwatch.GetElapsedTime(m_markTimestamp).TotalSeconds;
TimeSpan cpu = m_process.TotalProcessorTime - m_markCpu;
return FormattableString.Invariant(
$"sec={seconds:F2} cpuMs={cpu.TotalMilliseconds:F0} cpuCores={cpu.TotalSeconds / seconds:F2} ") +
FormattableString.Invariant(
$"threads={m_process.Threads.Count} wsMB={m_process.WorkingSet64 / 1048576} (external)") +
sched;
}
/// <summary>
/// Samples the scheduling state of the server threads every ~3 ms until the report:
/// Running, and Ready or Standby (waiting for a processor, so the server's processors
/// are oversubscribed). Runs in this process, not in the server.
/// </summary>
private void StartThreadSampler()
{
m_sampling = true;
int pid = m_process!.Id;
m_samplerThread = new Thread(() =>
{
while (Volatile.Read(ref m_sampling))
{
int running = 0;
int ready = 0;
try
{
using var process = Process.GetProcessById(pid);
foreach (ProcessThread thread in process.Threads)
{
try
{
System.Diagnostics.ThreadState state = thread.ThreadState;
if (state == System.Diagnostics.ThreadState.Running)
{
running++;
}
else if (state is System.Diagnostics.ThreadState.Ready or
System.Diagnostics.ThreadState.Standby)
{
ready++;
}
}
catch (InvalidOperationException)
{
// the thread exited
}
finally
{
thread.Dispose();
}
}
}
catch (ArgumentException)
{
// the server exited
break;
}
m_samples++;
m_running += running;
m_ready += ready;
m_readyMax = Math.Max(m_readyMax, ready);
Thread.Sleep(3);
}
})
{
IsBackground = true,
Priority = ThreadPriority.AboveNormal
};
m_samplerThread.Start();
}
private string StopThreadSampler()
{
if (m_samplerThread == null)
{
return string.Empty;
}
Volatile.Write(ref m_sampling, false);
m_samplerThread.Join();
return m_samples == 0 ? string.Empty : FormattableString.Invariant(
$" sched: samples={m_samples} running={(double)m_running / m_samples:F2} ") +
FormattableString.Invariant(
$"ready={(double)m_ready / m_samples:F2} readyMax={m_readyMax}");
}
public async Task StopAsync()
{
if (m_external == null)
{
await m_process!.StandardInput.WriteLineAsync("quit").ConfigureAwait(false);
if (!m_process.WaitForExit(10000))
{
m_process.Kill();
}
}
}
public void Dispose()
{
if (m_external != null)
{
m_external.Dispose();
}
else
{
if (!m_process!.HasExited)
{
m_process.Kill();
}
m_process.Dispose();
m_lines!.Dispose();
}
}
private Process? m_process;
private BlockingCollection<string>? m_lines;
private Worker? m_external;
private long m_markTimestamp;
private TimeSpan m_markCpu;
private bool m_sampling;
private Thread? m_samplerThread;
private long m_samples;
private long m_running;
private long m_ready;
private int m_readyMax;
}
/// <summary>
/// A native worker process with line-oriented stdin/stdout control.
/// </summary>
private sealed class Worker : IDisposable
{
private Worker(Process process)
{
Process = process;
}
public Process Process { get; }
/// <summary>
/// Processor time; still readable after the worker exited, as the handle is kept open.
/// </summary>
public TimeSpan Cpu
{
get
{
Process.Refresh();
return Process.TotalProcessorTime;
}
}
/// <summary>
/// Starts a client worker, on the processors of <c>--client-affinity</c> if given.
/// </summary>
public static Worker StartClient(string exe, string arguments, Dictionary<string, string> environment)
{
Worker worker = Start(exe, arguments, environment);
if ((OperatingSystem.IsWindows() || OperatingSystem.IsLinux()) &&
Program.TryGetArg("client-affinity", out string? affinity))
{
// e.g. the processors of --server-affinity, to co-locate clients and server
worker.Process.ProcessorAffinity = (nint)Convert.ToInt64(affinity, 16);
}
return worker;
}
public static Worker Start(string exe, string arguments, Dictionary<string, string> environment)
{
// resolve a relative path before the working directory changes to the worker's directory
if (!string.IsNullOrEmpty(Path.GetDirectoryName(exe)))
{
exe = Path.GetFullPath(exe);
}
var startInfo = new ProcessStartInfo(exe, arguments)
{
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
WorkingDirectory = Path.GetDirectoryName(Path.GetFullPath(exe))
};
foreach (KeyValuePair<string, string> variable in environment)
{
startInfo.Environment[variable.Key] = variable.Value;
}
var worker = new Worker(Process.Start(startInfo)!);
worker.Process.OutputDataReceived += (_, e) =>
{
if (e.Data == null)
{
worker.m_lines.CompleteAdding();
}
else
{
worker.m_lines.Add(e.Data);
}
};
// the workers log to stderr; drain it so that they never block
worker.Process.ErrorDataReceived += (_, e) =>
{
if (e.Data != null && Program.Arg("verbose", "0") != "0")
{
Console.WriteLine("[worker] " + e.Data);
}
};
worker.Process.BeginOutputReadLine();
worker.Process.BeginErrorReadLine();
return worker;
}
public string WaitFor(Func<string, bool> predicate, TimeSpan timeout)
{
var deadline = Stopwatch.StartNew();
while (true)
{
TimeSpan left = timeout - deadline.Elapsed;
if (left <= TimeSpan.Zero || !m_lines.TryTake(out string? line, left))
{
throw new TimeoutException(FormattableString.Invariant(
$"{Path.GetFileName(Process.StartInfo.FileName)} (pid {Process.Id}) did not report in {timeout.TotalSeconds:F0} s"));
}
if (predicate(line.Trim()))
{
return line.Trim();
}
if (Program.Arg("verbose", "0") != "0")
{
Console.WriteLine("[worker] " + line);
}
}
}
public void Send(string line)
{
Process.StandardInput.WriteLine(line);
Process.StandardInput.Flush();
}
public void Dispose()
{
try
{
if (!Process.HasExited)
{
Process.StandardInput.Close();
if (!Process.WaitForExit(5000))
{
Process.Kill();
}
}
}
catch (InvalidOperationException)
{
// already gone
}
Process.Dispose();
m_lines.Dispose();
}
private readonly BlockingCollection<string> m_lines = [];
}
}
/// <summary>
/// The address space of the benchmark servers: 100 writable Int32 variables
/// ns=1;i=1001..1100 in the namespace of the server URI.
/// </summary>
internal sealed class BenchmarkContractNodeManager : CustomNodeManager2
{
public BenchmarkContractNodeManager(IServerInternal server, ApplicationConfiguration configuration)
: base(server, configuration, NativeWorkerBenchmark.ContractServerUri)
{
}
public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
{
if (!externalReferences.TryGetValue(ObjectIds.ObjectsFolder, out IList<IReference>? references))
{
externalReferences[ObjectIds.ObjectsFolder] = references = new List<IReference>();
}
for (int i = 0; i < NativeWorkerBenchmark.NodeCount; i++)
{
uint id = NativeWorkerBenchmark.FirstNodeId + (uint)i;
string name = "Value" + id.ToString(CultureInfo.InvariantCulture);
var node = new BaseDataVariableState(null)
{
NodeId = new NodeId(id, NamespaceIndex),
BrowseName = new QualifiedName(name, NamespaceIndex),
DisplayName = new LocalizedText(name),
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
ReferenceTypeId = ReferenceTypeIds.Organizes,
DataType = DataTypeIds.Int32,
ValueRank = ValueRanks.Scalar,
AccessLevel = AccessLevels.CurrentReadOrWrite,
UserAccessLevel = AccessLevels.CurrentReadOrWrite,
Value = new Variant(i),
StatusCode = StatusCodes.Good,
Timestamp = DateTimeUtc.Now
};
node.AddReference(ReferenceTypeIds.Organizes, true, ObjectIds.ObjectsFolder);
references.Add(new NodeStateReference(ReferenceTypeIds.Organizes, false, node.NodeId));
AddPredefinedNode(SystemContext, node);
}
}
}
/// <summary>
/// The benchmark contract on <see cref="AsyncCustomNodeManager"/>, the node manager base
/// without the node manager lock on the read path.
/// </summary>
internal sealed class AsyncBenchmarkContractNodeManager : AsyncCustomNodeManager
{
public AsyncBenchmarkContractNodeManager(IServerInternal server, ApplicationConfiguration configuration)
: base(server, configuration, NativeWorkerBenchmark.ContractServerUri)
{
}
public override async ValueTask CreateAddressSpaceAsync(
IDictionary<NodeId, IList<IReference>> externalReferences,
CancellationToken cancellationToken = default)
{
await base.CreateAddressSpaceAsync(externalReferences, cancellationToken).ConfigureAwait(false);
if (!externalReferences.TryGetValue(ObjectIds.ObjectsFolder, out IList<IReference>? references))
{
externalReferences[ObjectIds.ObjectsFolder] = references = new List<IReference>();
}
for (int i = 0; i < NativeWorkerBenchmark.NodeCount; i++)
{
uint id = NativeWorkerBenchmark.FirstNodeId + (uint)i;
string name = "Value" + id.ToString(CultureInfo.InvariantCulture);
var node = new BaseDataVariableState(null)
{
NodeId = new NodeId(id, NamespaceIndex),
BrowseName = new QualifiedName(name, NamespaceIndex),
DisplayName = new LocalizedText(name),
TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
ReferenceTypeId = ReferenceTypeIds.Organizes,
DataType = DataTypeIds.Int32,
ValueRank = ValueRanks.Scalar,
AccessLevel = AccessLevels.CurrentReadOrWrite,
UserAccessLevel = AccessLevels.CurrentReadOrWrite,
Value = new Variant(i),
StatusCode = StatusCodes.Good,
Timestamp = DateTimeUtc.Now
};
node.AddReference(ReferenceTypeIds.Organizes, true, ObjectIds.ObjectsFolder);
references.Add(new NodeStateReference(ReferenceTypeIds.Organizes, false, node.NodeId));
await AddPredefinedNodeAsync(SystemContext, node, cancellationToken).ConfigureAwait(false);
}
}
}
/// <summary>
/// A server exposing the benchmark contract in addition to the standard nodes.
/// </summary>
internal sealed class BenchmarkContractServer : StandardServer
{
public BenchmarkContractServer(ITelemetryContext telemetry, bool asyncNodeManager)
: base(telemetry)
{
m_asyncNodeManager = asyncNodeManager;
}
protected override IMasterNodeManager CreateMasterNodeManager(
IServerInternal server,
ApplicationConfiguration configuration)
{
return m_asyncNodeManager
? new MasterNodeManager(server, configuration, null, new AsyncBenchmarkContractNodeManager(server, configuration))
: new MasterNodeManager(server, configuration, null, new BenchmarkContractNodeManager(server, configuration));
}
private readonly bool m_asyncNodeManager;
}
}