/* ========================================================================
* 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/
* ======================================================================*/
// Server load / profiling harness. Not part of the product; see perf/README.md.
// Modes:
// server --port P [--diag 0|1] [--audit 0|1] [--threads N]
// Starts a ReferenceServer and reads commands from stdin: mark | report | quit
// run --scenario read|write|sub|browse [--sessions N] [--nodes K] [--inflight I]
// [--duration S] [--warmup S] [--security none|sign|encrypt] [--diag 0|1] [--audit 0|1]
// [--subs S] [--pub ms] [--write-interval ms] [--write-nodes K]
// [--trace file] [--trace-profile p] [--out file]
// [--client classic|managed] [--callback fast|item] [--trace-side server|client]
// Spawns the server child, drives the load and prints a report of client latency
// and the CPU / GC / allocation deltas of both processes for the measurement window.
using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Opc.Ua.Client;
using Opc.Ua.Client.TestFramework;
using Opc.Ua.Configuration;
using Opc.Ua.Server.TestFramework;
using Quickstarts.ReferenceServer;
using V2 = Opc.Ua.Client.Subscriptions;
namespace Opc.Ua.Perf.ServerLoadHarness
{
///
/// Entry point of the harness.
///
public static class Program
{
private static readonly Dictionary s_args = new(StringComparer.OrdinalIgnoreCase);
///
/// Runs the server or the load driver.
///
public static async Task Main(string[] args)
{
for (int i = 1; i < args.Length; i++)
{
if (args[i].StartsWith("--", StringComparison.Ordinal))
{
string key = args[i][2..];
// --server-args takes the next argument verbatim, which may start with "--"
string value = i + 1 < args.Length &&
(key == "server-args" || !args[i + 1].StartsWith("--", StringComparison.Ordinal))
? args[++i]
: "1";
s_args[key] = value;
}
}
string mode = args.Length > 0 ? args[0] : "run";
if (mode == "server")
{
return await RunServerAsync().ConfigureAwait(false);
}
return NativeWorkerBenchmark.IsScenario(Arg("scenario", "read"))
? await NativeWorkerBenchmark.RunAsync(args).ConfigureAwait(false)
: await RunDriverAsync(args).ConfigureAwait(false);
}
internal static bool TryGetArg(string name, out string? value)
{
return s_args.TryGetValue(name, out value);
}
internal static string Arg(string name, string defaultValue)
{
return s_args.TryGetValue(name, out string? value) ? value : defaultValue;
}
internal static int ArgInt(string name, int defaultValue)
{
return int.Parse(
Arg(name, defaultValue.ToString(CultureInfo.InvariantCulture)),
CultureInfo.InvariantCulture);
}
internal static List GetNodes(ISession session, int count, Type? onlyType = null)
{
var result = new List();
foreach ((Type type, ExpandedNodeId[] ids) in CommonTestWorkers.NodeIdTestSetStaticMassNumeric)
{
if (onlyType != null && type != onlyType)
{
continue;
}
foreach (ExpandedNodeId id in ids)
{
if (result.Count == count)
{
return result;
}
result.Add(ExpandedNodeId.ToNodeId(id, session.NamespaceUris));
}
}
return result;
}
private static ITelemetryContext CreateTelemetry(LogLevel level)
{
return DefaultTelemetry.Create(b => b.SetMinimumLevel(level).AddSimpleConsole(o => o.SingleLine = true));
}
private static async Task RunServerAsync()
{
ITelemetryContext telemetry = CreateTelemetry(LogLevel.Error);
var application = new ApplicationInstance(telemetry)
{
ApplicationName = "PerfReferenceServer",
ApplicationType = ApplicationType.Server
};
try
{
return await RunServerAsync(application, telemetry).ConfigureAwait(false);
}
finally
{
await application.DisposeAsync().ConfigureAwait(false);
}
}
private static async Task RunServerAsync(ApplicationInstance application, ITelemetryContext telemetry)
{
int port = ArgInt("port", 62555);
bool diagnostics = ArgInt("diag", 0) != 0;
bool auditing = ArgInt("audit", 0) != 0;
// the contract of the o6-automation opcua-benchmarks servers (see NativeWorkerBenchmark)
bool contract = ArgInt("contract", 0) != 0;
string pkiRoot = Path.Combine(Path.GetTempPath(), contract ? "uaperf-pki-contractserver" : "uaperf-pki-server");
string endpointUrl = contract
? FormattableString.Invariant($"opc.tcp://127.0.0.1:{port}")
: FormattableString.Invariant($"opc.tcp://localhost:{port}/PerfReferenceServer");
ArrayOf certificates =
ApplicationConfigurationBuilder.CreateDefaultApplicationCertificates(
"CN=PerfReferenceServer, O=OPC Foundation, DC=localhost",
CertificateStoreType.Directory,
pkiRoot);
ApplicationConfiguration config = await application
.Build(contract ? NativeWorkerBenchmark.ContractServerUri : "urn:localhost:UA:PerfReferenceServer", "uri:opcfoundation.org:PerfReferenceServer")
.SetMaxByteStringLength(4 * 1024 * 1024)
.SetMaxArrayLength(1024 * 1024)
.SetMaxMessageSize(16 * 1024 * 1024)
.AsServer([endpointUrl])
.AddUnsecurePolicyNone()
.AddSignPolicies()
.AddSignAndEncryptPolicies()
.SetMaxChannelCount(2100)
.SetDiagnosticsEnabled(diagnostics)
.SetAuditingEnabled(auditing)
.SetShutdownDelay(0)
.AddSecurityConfiguration(certificates, pkiRoot)
.SetAutoAcceptUntrustedCertificates(true)
.CreateAsync()
.ConfigureAwait(false);
config.ServerConfiguration!.MaxSessionCount = 2000;
config.ServerConfiguration.MaxSubscriptionCount = 20000;
config.ServerConfiguration.MaxFailedAuthenticationAttempts = 0;
config.ServerConfiguration.MaxRequestThreadCount = ArgInt("threads", 200);
config.ServerConfiguration.MinRequestThreadCount = Math.Min(50, config.ServerConfiguration.MaxRequestThreadCount);
// sized for the largest pipeline (sessions x inflight), as the o6-automation benchmarks do
config.ServerConfiguration.MaxQueuedRequestCount = ArgInt("queue", 2000);
int counters = ArgInt("counters", 0);
int sampling = ArgInt("sampling", 10);
if (counters > 0)
{
// the subscription capacity workload of o6-automation/opcua-benchmarks
config.ServerConfiguration.MinPublishingInterval = 1;
config.ServerConfiguration.PublishingResolution = 1;
config.ServerConfiguration.MaxPublishingInterval = 86400_000;
config.ServerConfiguration.MaxNotificationQueueSize = 1;
config.ServerConfiguration.MaxNotificationsPerPublish = int.MaxValue;
config.ServerConfiguration.MaxPublishRequestCount = 4;
config.ServerConfiguration.AvailableSamplingRates = [new SamplingRateGroup(sampling, 1, 0)];
}
if (!await application
.CheckApplicationInstanceCertificatesAsync(true, CertificateFactory.DefaultLifeTime)
.ConfigureAwait(false))
{
Console.WriteLine("ERROR cert");
return 1;
}
using Opc.Ua.Server.StandardServer server = counters > 0
? new CounterServer(
telemetry,
counters,
contract ? NativeWorkerBenchmark.ContractServerUri : CounterNodeManager.Namespace,
ArgInt("counter-lock", 0) != 0,
contract && ArgInt("async-node-manager", 1) != 0)
: contract
? new BenchmarkContractServer(telemetry, ArgInt("async-node-manager", 1) != 0)
: new ReferenceServer(telemetry);
server.TransportBindings = TestTransportBindings.WithAllSchemes();
await application.StartAsync(server).ConfigureAwait(false);
Console.WriteLine(FormattableString.Invariant($"READY {endpointUrl} {Environment.ProcessId}"));
ProcessStats mark = ProcessStats.Capture();
using var events = new EventGenerator(server.CurrentInstance);
string? line;
while ((line = await Console.In.ReadLineAsync().ConfigureAwait(false)) != null)
{
line = line.Trim();
if (line.StartsWith("events ", StringComparison.Ordinal))
{
// events : start the event generator; events stop: stop it
string rate = line[7..];
if (rate == "stop")
{
events.Stop();
}
else
{
events.Start(int.Parse(rate, CultureInfo.InvariantCulture));
}
continue;
}
switch (line)
{
case "mark":
GC.Collect();
GC.WaitForPendingFinalizers();
mark = ProcessStats.Capture();
(server as CounterServer)?.Counters.Mark();
events.Mark();
Console.WriteLine("MARKED");
break;
case "report":
string counterReport = (server as CounterServer)?.Counters.Report(sampling) ?? string.Empty;
Console.WriteLine("REPORT " + ProcessStats.Capture().Delta(mark) + " " + counterReport + " " + events.Report());
break;
case "quit":
events.Stop();
await server.StopAsync().ConfigureAwait(false);
return 0;
}
}
return 0;
}
private static async Task RunDriverAsync(string[] args)
{
string scenario = Arg("scenario", "read");
int sessionCount = ArgInt("sessions", 20);
int durationSeconds = ArgInt("duration", 20);
int warmupSeconds = ArgInt("warmup", 10);
int port = ArgInt("port", 62555 + (Environment.ProcessId % 1000));
string security = Arg("security", "none");
(Process startedServer, BlockingCollection startedLines) = StartServerChild(
FormattableString.Invariant(
$"server --port {port} --diag {Arg("diag", "0")} --audit {Arg("audit", "0")} --threads {Arg("threads", "200")} --counters {(scenario == "counters" ? Arg("nodes", "1") : "0")} --sampling {Arg("sampling", "10")} --queue {Arg("queue", "2000")}"));
using Process server = startedServer;
using BlockingCollection serverLines = startedLines;
string ready = serverLines.Take();
if (!ready.StartsWith("READY", StringComparison.Ordinal))
{
Console.WriteLine(ready);
return 1;
}
string url = ready.Split(' ')[1];
Console.WriteLine(FormattableString.Invariant($"server pid={server.Id} url={url}"));
ITelemetryContext telemetry = CreateTelemetry(LogLevel.Error);
using var client = new ClientFixture(telemetry) { SessionTimeout = 60000, OperationTimeout = 60000 };
await client
.LoadClientConfigurationAsync(Path.Combine(Path.GetTempPath(), "uaperf-pki-client"), "PerfClient")
.ConfigureAwait(false);
// The test fixture asks the server for diagnostics in every response;
// an application does not by default.
client.SessionFactory.ReturnDiagnostics = DiagnosticsMasks.None;
string policy = security == "none" ? SecurityPolicies.None : SecurityPolicies.Basic256Sha256;
MessageSecurityMode mode = security switch
{
"sign" => MessageSecurityMode.Sign,
"encrypt" => MessageSecurityMode.SignAndEncrypt,
_ => MessageSecurityMode.None
};
ArrayOf endpoints = await client.GetEndpointsAsync(new Uri(url)).ConfigureAwait(false);
var selected = new List();
foreach (EndpointDescription endpoint in endpoints)
{
if (endpoint.SecurityPolicyUri == policy && endpoint.SecurityMode == mode)
{
selected.Add(endpoint);
}
}
// classic: Session from the default session factory (classic subscription engine).
// managed: ManagedSession with the default V2 subscription engine.
// shared: classic Sessions whose secure channels come from one ClientChannelManager.
string clientKind = Arg("client", "classic");
ConfiguredEndpoint? endpointForBuilder = clientKind is "managed" or "shared"
? await client.GetEndpointAsync(new Uri(url), policy, selected).ConfigureAwait(false)
: null;
var channelManager = new ClientChannelManager(client.Config, telemetry);
await using ConfiguredAsyncDisposable disposeChannelManager = channelManager.ConfigureAwait(false);
// sub: --writers adds dedicated writer sessions after the subscriber sessions
int writerCount = scenario == "sub" ? ArgInt("writers", 0) : 0;
var sessions = new ISession[sessionCount + writerCount];
var connectTime = Stopwatch.StartNew();
// Shared sessions connect one at a time by default: concurrent creation
// on one ClientChannelManager fails with BadSecureChannelClosed.
int connectParallel = clientKind == "shared" ? ArgInt("connect-parallel", 1) : 32;
await Parallel.ForAsync(
0,
sessions.Length,
new ParallelOptions { MaxDegreeOfParallelism = connectParallel },
async (i, ct) => sessions[i] = clientKind switch
{
"managed" => await new ManagedSessionBuilder(client.Config, telemetry)
.UseEndpoint(endpointForBuilder!)
.WithSessionTimeout(TimeSpan.FromSeconds(60))
.ConnectAsync(ct)
.ConfigureAwait(false),
"shared" => await Session.CreateAsync(
channelManager,
client.Config,
endpointForBuilder!,
updateBeforeConnect: false,
sessionName: "PerfShared",
ct: ct)
.ConfigureAwait(false),
_ => await client
.ConnectAsync(new Uri(url), policy, selected)
.ConfigureAwait(false)
})
.ConfigureAwait(false);
Console.WriteLine(FormattableString.Invariant(
$"connected {sessions.Length} {clientKind} sessions ({security}) in {connectTime.ElapsedMilliseconds} ms"));
if (TryGetArg("publish-requests", out string? publishRequests))
{
// outstanding Publish requests per session (the client default adapts to the subscriptions)
int count = int.Parse(publishRequests!, CultureInfo.InvariantCulture);
foreach (ISession session in sessions)
{
session.MaxPublishRequestCount = Math.Max(session.MaxPublishRequestCount, count);
session.MinPublishRequestCount = count;
}
}
Workload workload = scenario == "counters"
? new CounterWorkload(sessions[0], ArgInt("nodes", 1), ArgInt("sampling", 10), ArgInt("pub", 1000))
: scenario == "events"
? new EventWorkload(sessions, ArgInt("subs", 15), ArgInt("pub", 100), ArgInt("queue-size", 10000), ArgInt("event-rate", 0))
: scenario == "sub"
? new SubscribeWorkload(
sessions[..sessionCount],
writerCount == 0 ? [sessions[0]] : sessions[sessionCount..],
ArgInt("nodes", 100),
ArgInt("subs", 5),
ArgInt("pub", 100),
ArgInt("write-interval", 100),
Arg("callback", "fast") == "item",
ArgInt("write-nodes", 0),
Arg("node-type", "int") == "all",
ArgInt("write-batch", 0))
: new RequestWorkload(scenario, sessions, ArgInt("nodes", 100), ArgInt("inflight", 1));
await workload.SetupAsync().ConfigureAwait(false);
using var stop = new CancellationTokenSource();
Task run = workload.RunAsync(stop.Token);
if (workload is EventWorkload eventWorkload)
{
await server.StandardInput.WriteLineAsync(
FormattableString.Invariant($"events {eventWorkload.Rate}")).ConfigureAwait(false);
}
await Task.Delay(TimeSpan.FromSeconds(warmupSeconds)).ConfigureAwait(false);
bool traceClient = Arg("trace-side", "server") == "client";
using Process? tracer = StartTracer(
traceClient ? Environment.ProcessId : server.Id,
durationSeconds,
out string? trace);
if (tracer != null)
{
await Task.Delay(2000).ConfigureAwait(false);
}
await server.StandardInput.WriteLineAsync("mark").ConfigureAwait(false);
Take(serverLines, "MARKED");
GC.Collect();
GC.WaitForPendingFinalizers();
ProcessStats clientMark = ProcessStats.Capture();
workload.ResetStats();
var window = Stopwatch.StartNew();
TimeSpan clientCpuStart = Process.GetCurrentProcess().TotalProcessorTime;
await Task.Delay(TimeSpan.FromSeconds(durationSeconds)).ConfigureAwait(false);
string clientProcess = ProcessStats.Capture().Delta(clientMark);
await server.StandardInput.WriteLineAsync("report").ConfigureAwait(false);
string report = Take(serverLines, "REPORT")[7..];
// freeze the client counts at the end of the server window
workload.OnServerReport(report);
double operations = workload.Operations;
(double Count, string Name)? secondary = workload.Secondary;
double clientCores = (Process.GetCurrentProcess().TotalProcessorTime - clientCpuStart).TotalSeconds /
window.Elapsed.TotalSeconds;
if (workload is EventWorkload)
{
await server.StandardInput.WriteLineAsync("events stop").ConfigureAwait(false);
}
string clientReport = workload.Report(window.Elapsed.TotalSeconds) +
FormattableString.Invariant($" clientCpuCores={clientCores:F2}");
await stop.CancelAsync().ConfigureAwait(false);
try
{
await run.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// the workload stops by cancellation
}
if (tracer != null)
{
await tracer.WaitForExitAsync().ConfigureAwait(false);
Console.WriteLine("trace: " + trace);
}
var result = new StringBuilder();
result.Append(CultureInfo.InvariantCulture, $"RESULT scenario={scenario} security={security} sessions={sessionCount} ")
.Append(string.Join(' ', args.Skip(1)))
.Append('\n')
.Append(" client: ").Append(clientReport).Append('\n')
.Append(" server: ").Append(report).Append('\n')
.Append(" client-process: ").Append(clientProcess).Append('\n');
(double serverMb, double serverCpuMs) = ParseProcessReport(report);
if (operations > 0)
{
result.Append(
CultureInfo.InvariantCulture,
$" per-op: allocB={serverMb * 1048576 / operations:F0} cpuUs={serverCpuMs * 1000 / operations:F1} ({workload.OperationName})\n");
}
if (secondary is (double secondaryCount, string secondaryName) && secondaryCount > 0)
{
result.Append(
CultureInfo.InvariantCulture,
$" per-op2: allocB={serverMb * 1048576 / secondaryCount:F0} cpuUs={serverCpuMs * 1000 / secondaryCount:F1} ({secondaryName})\n");
}
if (operations > 0)
{
(double allocatedMb, double cpuMs) = ParseProcessReport(clientProcess);
result.Append(
CultureInfo.InvariantCulture,
$" client-per-op: allocB={allocatedMb * 1048576 / operations:F0} cpuUs={cpuMs * 1000 / operations:F1} ({workload.OperationName})\n");
}
Console.Write(result.ToString());
if (s_args.TryGetValue("out", out string? outFile))
{
await File.AppendAllTextAsync(outFile, result.ToString()).ConfigureAwait(false);
}
await Parallel.ForEachAsync(sessions, async (session, ct) =>
{
try
{
await session.CloseAsync(ct).ConfigureAwait(false);
}
catch (ServiceResultException)
{
// the server may already be shutting the session down
}
session.Dispose();
}).ConfigureAwait(false);
await server.StandardInput.WriteLineAsync("quit").ConfigureAwait(false);
if (!server.WaitForExit(10000))
{
server.Kill();
}
return 0;
}
///
/// Starts the harness itself as the server child; SERVER_ variables are passed without the prefix.
///
internal static (Process Server, BlockingCollection Lines) StartServerChild(
string arguments,
IReadOnlyDictionary? defaultEnvironment = null)
{
var startInfo = new ProcessStartInfo(Environment.ProcessPath!, arguments)
{
RedirectStandardInput = true,
RedirectStandardOutput = true,
UseShellExecute = false
};
if (defaultEnvironment != null)
{
foreach (KeyValuePair variable in defaultEnvironment)
{
startInfo.Environment[variable.Key] = variable.Value;
}
}
foreach (DictionaryEntry variable in Environment.GetEnvironmentVariables())
{
string name = (string)variable.Key;
if (name.StartsWith("SERVER_", StringComparison.Ordinal))
{
startInfo.Environment[name[7..]] = (string?)variable.Value;
}
}
Process server = Process.Start(startInfo)!;
var lines = new BlockingCollection();
_ = Task.Run(() => PumpServerOutput(server, lines));
return (server, lines);
}
private static void PumpServerOutput(Process server, BlockingCollection lines)
{
string? line;
while ((line = server.StandardOutput.ReadLine()) != null)
{
if (line.StartsWith("READY", StringComparison.Ordinal) ||
line.StartsWith("REPORT", StringComparison.Ordinal) ||
line.StartsWith("MARKED", StringComparison.Ordinal) ||
line.StartsWith("ERROR", StringComparison.Ordinal))
{
lines.Add(line);
}
else if (s_args.ContainsKey("verbose"))
{
Console.WriteLine("[server] " + line);
}
}
lines.CompleteAdding();
}
internal static Process? StartTracer(int processId, int durationSeconds, out string? trace)
{
if (!s_args.TryGetValue("trace", out trace))
{
return null;
}
string providers = Arg("trace-profile", "cpu") switch
{
// allocation sampling (GCAllocationTick, about every 100 KB) and GC events
"alloc" => "--providers Microsoft-Windows-DotNETRuntime:0x1:5",
"gc" => "--profile gc-verbose",
// the Contention keyword
"contention" => "--providers Microsoft-Windows-DotNETRuntime:0x4000:4",
_ => "--profile dotnet-sampled-thread-time"
};
return Process.Start(new ProcessStartInfo(
"dotnet-trace",
FormattableString.Invariant(
$"collect --process-id {processId} {providers} -o \"{trace}\" --duration 00:00:{durationSeconds:D2}"))
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true
});
}
internal static string Take(BlockingCollection lines, string prefix)
{
while (true)
{
string line = lines.Take();
if (line.StartsWith(prefix, StringComparison.Ordinal))
{
return line;
}
}
}
private static (double AllocatedMb, double CpuMs) ParseProcessReport(string report)
{
Dictionary values = report
.Split(' ')
.Select(p => p.Split('='))
.Where(p => p.Length == 2)
.ToDictionary(p => p[0], p => p[1], StringComparer.Ordinal);
return (
double.Parse(values["allocMB"], CultureInfo.InvariantCulture),
double.Parse(values["cpuMs"], CultureInfo.InvariantCulture));
}
}
///
/// Process-wide counters, captured at the start and end of the measurement window.
///
internal sealed record ProcessStats(
long Allocated,
int Gen0,
int Gen1,
int Gen2,
TimeSpan Pause,
TimeSpan Cpu,
long Contention,
long Timestamp,
int Threads,
long WorkingSet,
long Heap)
{
public static ProcessStats Capture()
{
using var process = Process.GetCurrentProcess();
return new ProcessStats(
GC.GetTotalAllocatedBytes(true),
GC.CollectionCount(0),
GC.CollectionCount(1),
GC.CollectionCount(2),
GC.GetTotalPauseDuration(),
process.TotalProcessorTime,
Monitor.LockContentionCount,
Stopwatch.GetTimestamp(),
ThreadPool.ThreadCount,
process.WorkingSet64,
GC.GetGCMemoryInfo().HeapSizeBytes);
}
public string Delta(ProcessStats mark)
{
double seconds = Stopwatch.GetElapsedTime(mark.Timestamp, Timestamp).TotalSeconds;
TimeSpan cpu = Cpu - mark.Cpu;
return FormattableString.Invariant(
$"sec={seconds:F2} allocMB={(Allocated - mark.Allocated) / 1048576.0:F1} gen0={Gen0 - mark.Gen0} ") +
FormattableString.Invariant(
$"gen1={Gen1 - mark.Gen1} gen2={Gen2 - mark.Gen2} pauseMs={(Pause - mark.Pause).TotalMilliseconds:F0} ") +
FormattableString.Invariant(
$"cpuMs={cpu.TotalMilliseconds:F0} cpuCores={cpu.TotalSeconds / seconds:F2} ") +
FormattableString.Invariant(
$"contention={Contention - mark.Contention} threads={Threads} wsMB={WorkingSet / 1048576} heapMB={Heap / 1048576}");
}
}
///
/// Latency samples (Stopwatch ticks) recorded by many threads. ConcurrentBag keeps a
/// list per thread, so recording does not contend between the load threads.
///
internal sealed class LatencyRecorder
{
public void Record(long ticks)
{
m_samples.Add(ticks);
}
public void Clear()
{
m_samples.Clear();
}
public string Percentiles()
{
var all = new List(m_samples);
if (all.Count == 0)
{
return "n=0";
}
all.Sort();
double Ms(double quantile)
{
return all[Math.Min(all.Count - 1, (int)(quantile * all.Count))] * 1000.0 / Stopwatch.Frequency;
}
return FormattableString.Invariant(
$"n={all.Count} p50={Ms(0.5):F3}ms p90={Ms(0.9):F3}ms p99={Ms(0.99):F3}ms ") +
FormattableString.Invariant($"p999={Ms(0.999):F3}ms max={Ms(1):F3}ms");
}
private readonly ConcurrentBag m_samples = [];
}
///
/// A load pattern driven against the server.
///
internal abstract class Workload
{
public abstract double Operations { get; }
public abstract string OperationName { get; }
///
/// A second operation count to report the server cost against, such as the writes
/// that caused the notifications.
///
public virtual (double Count, string Name)? Secondary => null;
public abstract Task SetupAsync();
///
/// Receives the server report of the window as soon as it arrives, before
/// , and are read;
/// workloads that keep counting after the window freeze their counts here.
///
public virtual void OnServerReport(string report)
{
}
public abstract Task RunAsync(CancellationToken ct);
public abstract void ResetStats();
public abstract string Report(double seconds);
}
///
/// Closed-loop Read, Write or Browse requests from every session.
///
internal sealed class RequestWorkload : Workload
{
public RequestWorkload(string kind, ISession[] sessions, int nodes, int inflight)
{
m_kind = kind;
m_sessions = sessions;
m_nodes = nodes;
m_inflight = inflight;
}
public override double Operations => Interlocked.Read(ref m_requests);
public override string OperationName => "request";
public override Task SetupAsync()
{
List nodes = Program.GetNodes(m_sessions[0], m_nodes, m_kind == "write" ? typeof(int) : null);
m_read = nodes.Select(n => new ReadValueId { NodeId = n, AttributeId = Attributes.Value }).ToArray();
m_write = nodes.Select(n => new WriteValue
{
NodeId = n,
AttributeId = Attributes.Value,
Value = new DataValue(new Variant(42))
}).ToArray();
m_browse = new[]
{
new BrowseDescription
{
NodeId = ObjectIds.Server,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
ResultMask = (uint)BrowseResultMask.All
}
};
Console.WriteLine(FormattableString.Invariant(
$"{m_kind}: {nodes.Count} nodes/request, inflight {m_inflight}/session"));
return Task.CompletedTask;
}
public override async Task RunAsync(CancellationToken ct)
{
var tasks = new List();
foreach (ISession session in m_sessions)
{
for (int i = 0; i < m_inflight; i++)
{
tasks.Add(Task.Run(() => LoopAsync(session, ct), CancellationToken.None));
}
}
await Task.WhenAll(tasks).ConfigureAwait(false);
}
public override void ResetStats()
{
Interlocked.Increment(ref m_epoch);
m_latency.Clear();
Interlocked.Exchange(ref m_requests, 0);
Interlocked.Exchange(ref m_errors, 0);
}
public override string Report(double seconds)
{
long requests = Interlocked.Read(ref m_requests);
int valuesPerRequest = m_kind == "browse" ? 1 : m_nodes;
return FormattableString.Invariant(
$"req/s={requests / seconds:F0} values/s={requests * valuesPerRequest / seconds:F0} ") +
FormattableString.Invariant($"errors={Interlocked.Read(ref m_errors)} latency {m_latency.Percentiles()}");
}
private async Task LoopAsync(ISession session, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
long start = Stopwatch.GetTimestamp();
int epoch = Volatile.Read(ref m_epoch);
try
{
switch (m_kind)
{
case "write":
await session.WriteAsync(null, m_write, ct).ConfigureAwait(false);
break;
case "browse":
await session.BrowseAsync(null, null, 0, m_browse, ct).ConfigureAwait(false);
break;
default:
await session.ReadAsync(null, 0, TimestampsToReturn.Both, m_read, ct).ConfigureAwait(false);
break;
}
}
catch (OperationCanceledException)
{
return;
}
catch (ServiceResultException ex)
{
if (Interlocked.Increment(ref m_errors) < 5)
{
Console.WriteLine("error: " + ex.Message);
}
continue;
}
if (epoch == Volatile.Read(ref m_epoch))
{
m_latency.Record(Stopwatch.GetTimestamp() - start);
Interlocked.Increment(ref m_requests);
}
}
}
private readonly string m_kind;
private readonly ISession[] m_sessions;
private readonly int m_nodes;
private readonly int m_inflight;
private readonly LatencyRecorder m_latency = new();
private int m_epoch;
private long m_requests;
private long m_errors;
private ArrayOf m_read;
private ArrayOf m_write;
private ArrayOf m_browse;
}
///
/// Data-change subscriptions on every session and one writer changing the monitored values.
/// The written value is a counter, so each notification maps back to the time of its write.
///
internal sealed class SubscribeWorkload : Workload
{
public SubscribeWorkload(
ISession[] sessions,
ISession[] writers,
int nodes,
int subscriptions,
int publishingInterval,
int writeInterval,
bool itemCallback,
int writeNodes,
bool allTypes,
int writeBatch)
{
m_sessions = sessions;
m_writers = writers;
m_nodes = nodes;
m_subscriptions = subscriptions;
m_publishingInterval = publishingInterval;
m_writeInterval = writeInterval;
m_itemCallback = itemCallback;
m_writeNodes = writeNodes;
m_allTypes = allTypes;
m_writeBatch = writeBatch;
}
public override double Operations => m_final.Notifications;
public override string OperationName => "notification";
public override (double Count, string Name)? Secondary => (m_final.Values, "written value");
///
/// Values per Write request: all written nodes, or slices of --write-batch.
///
private int BatchSize => m_writeBatch > 0 ? Math.Min(m_writeBatch, WrittenNodes) : WrittenNodes;
///
/// The number of monitored nodes the writer changes: all of them, or the first
/// --write-nodes (a large subscription in which few items change).
///
private int WrittenNodes => m_writeNodes > 0 ? Math.Min(m_writeNodes, m_nodeIds.Count) : m_nodeIds.Count;
public override async Task SetupAsync()
{
if (m_allTypes)
{
// the mixed Int16..UInt64 node set of the Sessions LoadTest
m_nodeTypes = [];
foreach ((Type type, ExpandedNodeId[] ids) in CommonTestWorkers.NodeIdTestSetStaticMassNumeric)
{
foreach (ExpandedNodeId id in ids)
{
if (m_nodeTypes.Count < m_nodes)
{
m_nodeTypes.Add(type);
}
}
}
m_nodeIds = Program.GetNodes(m_sessions[0], m_nodes).ToArray();
}
else
{
m_nodeIds = Program.GetNodes(m_sessions[0], m_nodes, typeof(int)).ToArray();
}
var setupTime = Stopwatch.StartNew();
await Parallel.ForEachAsync(
m_sessions,
new ParallelOptions { MaxDegreeOfParallelism = 16 },
async (session, ct) =>
{
for (int i = 0; i < m_subscriptions; i++)
{
await CreateSubscriptionAsync(session, ct).ConfigureAwait(false);
}
}).ConfigureAwait(false);
Console.WriteLine(FormattableString.Invariant(
$"sub: {m_sessions.Length} sessions x {m_subscriptions} subs x {m_nodeIds.Count} items, ") +
FormattableString.Invariant(
$"pub {m_publishingInterval} ms, {m_writers.Length} writer(s) x {BatchSize} of {WrittenNodes} items ") +
FormattableString.Invariant(
$"{(m_writeInterval > 0 ? $"every {m_writeInterval} ms" : "closed loop")}; setup {setupTime.ElapsedMilliseconds} ms"));
}
public override async Task RunAsync(CancellationToken ct)
{
var tasks = new List();
for (int i = 0; i < m_writers.Length; i++)
{
ISession writer = m_writers[i];
// writers start on different slices when they write batches
int first = i * BatchSize % WrittenNodes;
tasks.Add(Task.Run(() => WriteLoopAsync(writer, first, ct), CancellationToken.None));
}
await Task.WhenAll(tasks).ConfigureAwait(false);
}
private async Task WriteLoopAsync(ISession writer, int first, CancellationToken ct)
{
using PeriodicTimer? timer = m_writeInterval > 0
? new PeriodicTimer(TimeSpan.FromMilliseconds(m_writeInterval))
: null;
int batch = BatchSize;
while (timer == null ? !ct.IsCancellationRequested : await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
{
int counter = Interlocked.Increment(ref m_counter);
var values = new WriteValue[batch];
for (int i = 0; i < values.Length; i++)
{
int index = (first + i) % WrittenNodes;
values[i] = new WriteValue
{
NodeId = m_nodeIds[index],
AttributeId = Attributes.Value,
Value = new DataValue(ToVariant(index, counter))
};
}
first = (first + batch) % WrittenNodes;
long start = Stopwatch.GetTimestamp();
Volatile.Write(ref m_writeTicks[counter & 0xFFFF], start);
WriteResponse response;
try
{
response = await writer.WriteAsync(null, values, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
catch (ServiceResultException ex)
{
if (Interlocked.Increment(ref m_writeErrors) < 5)
{
Console.WriteLine("write error: " + ex.Message);
}
continue;
}
m_writeLatency.Record(Stopwatch.GetTimestamp() - start);
// only values the server accepted change and notify
int written = 0;
StatusCode rejected = StatusCodes.BadUnexpectedError;
for (int i = 0; i < response.Results.Count; i++)
{
if (StatusCode.IsGood(response.Results[i]))
{
written++;
}
else
{
rejected = response.Results[i];
}
}
if (written < batch && Interlocked.Increment(ref m_writeErrors) < 5)
{
Console.WriteLine(FormattableString.Invariant(
$"write error: {batch - written} of {batch} values rejected ({rejected})"));
}
Interlocked.Increment(ref m_writes);
Interlocked.Add(ref m_writtenValues, written);
}
}
///
/// The counter as the node's type; 16-bit types wrap, Int32 nodes carry the full
/// counter for the latency measurement.
///
private Variant ToVariant(int index, int counter)
{
if (m_nodeTypes == null)
{
return new Variant(counter);
}
Type type = m_nodeTypes[index];
return type == typeof(short) ? new Variant((short)(counter & 0x7FFF))
: type == typeof(ushort) ? new Variant((ushort)(counter & 0x7FFF))
: type == typeof(int) ? new Variant(counter)
: type == typeof(uint) ? new Variant((uint)counter)
: type == typeof(long) ? new Variant((long)counter)
: new Variant((ulong)counter);
}
public override void ResetStats()
{
m_latency.Clear();
m_writeLatency.Clear();
Interlocked.Exchange(ref m_notifications, 0);
Interlocked.Exchange(ref m_writes, 0);
Interlocked.Exchange(ref m_writtenValues, 0);
Interlocked.Exchange(ref m_writeErrors, 0);
}
public override void OnServerReport(string report)
{
// closed-loop writers keep going: report the counts at the end of the server window
m_final = (
Interlocked.Read(ref m_notifications),
Interlocked.Read(ref m_writes),
Interlocked.Read(ref m_writtenValues),
Interlocked.Read(ref m_writeErrors));
}
public override string Report(double seconds)
{
(long notifications, long writes, long written, long writeErrors) = m_final;
long expected = written * m_sessions.Length * m_subscriptions;
double ratio = expected == 0 ? 0 : (double)notifications / expected;
return FormattableString.Invariant(
$"notif/s={notifications / seconds:F0} received/expected={ratio:P1} ") +
FormattableString.Invariant(
$"writes/s={writes / seconds:F0} values/s={written / seconds:F0} ") +
FormattableString.Invariant(
$"writeErrors={writeErrors} write latency {m_writeLatency.Percentiles()} ") +
FormattableString.Invariant($"write->notify latency {m_latency.Percentiles()}");
}
private async Task CreateSubscriptionAsync(ISession session, CancellationToken ct)
{
if (session is ManagedSession managed)
{
await CreateManagedSubscriptionAsync(managed, ct).ConfigureAwait(false);
return;
}
#pragma warning disable CA2000 // ownership transfers to the session in AddSubscription; disposed below on failure before that
var subscription = new Subscription(session.DefaultSubscription)
{
PublishingInterval = m_publishingInterval,
KeepAliveCount = 10,
LifetimeCount = 100
};
#pragma warning restore CA2000
if (!m_itemCallback)
{
subscription.FastDataChangeCallback = OnDataChange;
}
try
{
foreach (NodeId nodeId in m_nodeIds)
{
var item = new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = nodeId,
AttributeId = Attributes.Value,
MonitoringMode = MonitoringMode.Reporting,
SamplingInterval = 0,
QueueSize = (uint)Program.ArgInt("queue-size", 1)
};
if (m_itemCallback)
{
item.Notification += OnItemNotification;
}
subscription.AddItem(item);
}
// the session owns and disposes the subscription from here on
session.AddSubscription(subscription);
}
catch
{
subscription.Dispose();
throw;
}
await subscription.CreateAsync(ct).ConfigureAwait(false);
}
private void OnDataChange(Subscription subscription, DataChangeNotification notification, ArrayOf stringTable)
{
long now = Stopwatch.GetTimestamp();
int count = 0;
foreach (MonitoredItemNotification item in notification.MonitoredItems)
{
RecordValue(now, item.Value);
count++;
}
Interlocked.Add(ref m_notifications, count);
}
private void OnItemNotification(MonitoredItem monitoredItem, MonitoredItemNotificationEventArgs e)
{
if (e.NotificationValue is MonitoredItemNotification item)
{
RecordValue(Stopwatch.GetTimestamp(), item.Value);
Interlocked.Increment(ref m_notifications);
}
}
private void RecordValue(long now, DataValue value)
{
if (value.WrappedValue.TryGetValue(out int counter) && counter > 0)
{
long written = Volatile.Read(ref m_writeTicks[counter & 0xFFFF]);
if (written != 0)
{
m_latency.Record(now - written);
}
}
}
private async Task CreateManagedSubscriptionAsync(ManagedSession session, CancellationToken ct)
{
var setup = new SubscriptionSetup();
V2.ISubscription subscription = session.AddSubscription(
new NotificationHandler(this, setup),
new V2.SubscriptionOptions
{
PublishingInterval = TimeSpan.FromMilliseconds(m_publishingInterval),
PublishingEnabled = true,
KeepAliveCount = 10,
LifetimeCount = 100
});
for (int i = 0; i < m_nodeIds.Count; i++)
{
if (!subscription.TryAddMonitoredItem(
i.ToString(CultureInfo.InvariantCulture),
new V2.MonitoredItems.MonitoredItemOptions
{
StartNodeId = m_nodeIds[i],
AttributeId = Attributes.Value,
MonitoringMode = MonitoringMode.Reporting,
SamplingInterval = TimeSpan.Zero,
QueueSize = (uint)Program.ArgInt("queue-size", 1)
},
out _))
{
throw new InvalidOperationException(
$"Could not add monitored item '{m_nodeIds[i]}' during subscription setup.");
}
}
await setup.WaitForReadyAsync(subscription, ct).ConfigureAwait(false);
}
///
/// V2 engine handler; data changes are counted like the classic fast callback.
///
private sealed class NotificationHandler : V2.ISubscriptionNotificationHandler
{
public NotificationHandler(SubscribeWorkload owner, SubscriptionSetup setup)
{
m_owner = owner;
m_setup = setup;
}
public ValueTask OnDataChangeNotificationAsync(
V2.ISubscription subscription,
uint sequenceNumber,
DateTime publishTime,
ReadOnlyMemory notification,
V2.PublishState publishStateMask,
IReadOnlyList stringTable)
{
long now = Stopwatch.GetTimestamp();
foreach (V2.DataValueChange change in notification.Span)
{
m_owner.RecordValue(now, change.Value);
}
Interlocked.Add(ref m_owner.m_notifications, notification.Length);
return ValueTask.CompletedTask;
}
public ValueTask OnEventDataNotificationAsync(
V2.ISubscription subscription,
uint sequenceNumber,
DateTime publishTime,
ReadOnlyMemory notification,
V2.PublishState publishStateMask,
IReadOnlyList stringTable)
{
return ValueTask.CompletedTask;
}
public ValueTask OnKeepAliveNotificationAsync(
V2.ISubscription subscription,
uint sequenceNumber,
DateTime publishTime,
V2.PublishState publishStateMask)
{
return ValueTask.CompletedTask;
}
public ValueTask OnSubscriptionStateChangedAsync(
V2.ISubscription subscription,
V2.SubscriptionState state,
V2.PublishState publishStateMask,
CancellationToken ct = default)
{
m_setup.OnStateChanged(state);
return ValueTask.CompletedTask;
}
private readonly SubscribeWorkload m_owner;
private readonly SubscriptionSetup m_setup;
}
private readonly ISession[] m_sessions;
private readonly int m_nodes;
private readonly int m_subscriptions;
private readonly int m_publishingInterval;
private readonly int m_writeInterval;
private readonly bool m_itemCallback;
private readonly long[] m_writeTicks = new long[1 << 16];
private readonly LatencyRecorder m_latency = new();
private long m_notifications;
private long m_writes;
private int m_counter;
private ArrayOf m_nodeIds;
private List? m_nodeTypes;
private readonly int m_writeNodes;
private readonly ISession[] m_writers;
private readonly bool m_allTypes;
private readonly int m_writeBatch;
private readonly LatencyRecorder m_writeLatency = new();
private long m_writtenValues;
private long m_writeErrors;
private (long Notifications, long Writes, long Values, long WriteErrors) m_final;
}
}