/* ========================================================================
* 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] [--trace file] [--trace-profile p] [--out file]
// Spawns the server child, drives the load and prints a report of client latency
// and server-side CPU / GC / allocation deltas 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.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;
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..];
string value = i + 1 < args.Length && !args[i + 1].StartsWith("--", StringComparison.Ordinal)
? args[++i]
: "1";
s_args[key] = value;
}
}
string mode = args.Length > 0 ? args[0] : "run";
return mode == "server"
? await RunServerAsync().ConfigureAwait(false)
: await RunDriverAsync(args).ConfigureAwait(false);
}
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;
string pkiRoot = Path.Combine(Path.GetTempPath(), "uaperf-pki-server");
string endpointUrl = 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("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 = 50;
// sized for the largest pipeline (sessions x inflight), as the o6 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)
: new ReferenceServer(telemetry);
server.TransportBindings = TestTransportBindings.WithAllSchemes();
await application.StartAsync(server).ConfigureAwait(false);
Console.WriteLine(FormattableString.Invariant($"READY {endpointUrl} {Environment.ProcessId}"));
ServerStats mark = ServerStats.Capture();
string? line;
while ((line = await Console.In.ReadLineAsync().ConfigureAwait(false)) != null)
{
switch (line.Trim())
{
case "mark":
GC.Collect();
GC.WaitForPendingFinalizers();
mark = ServerStats.Capture();
(server as CounterServer)?.Counters?.Mark();
Console.WriteLine("MARKED");
break;
case "report":
string counterReport = (server as CounterServer)?.Counters?.Report(sampling) ?? string.Empty;
Console.WriteLine("REPORT " + ServerStats.Capture().Delta(mark) + " " + counterReport);
break;
case "quit":
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");
var startInfo = new ProcessStartInfo(
Environment.ProcessPath!,
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")}"))
{
RedirectStandardInput = true,
RedirectStandardOutput = true,
UseShellExecute = false
};
foreach (DictionaryEntry variable in Environment.GetEnvironmentVariables())
{
string name = (string)variable.Key;
if (name.StartsWith("SERVER_", StringComparison.Ordinal))
{
startInfo.Environment[name[7..]] = (string?)variable.Value;
}
}
using Process server = Process.Start(startInfo)!;
using var serverLines = new BlockingCollection();
_ = Task.Run(() => PumpServerOutput(server, serverLines));
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);
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);
}
}
var sessions = new ISession[sessionCount];
var connectTime = Stopwatch.StartNew();
await Parallel.ForAsync(
0,
sessionCount,
new ParallelOptions { MaxDegreeOfParallelism = 32 },
async (i, ct) => sessions[i] = await client
.ConnectAsync(new Uri(url), policy, selected)
.ConfigureAwait(false))
.ConfigureAwait(false);
Console.WriteLine(FormattableString.Invariant(
$"connected {sessionCount} sessions ({security}) in {connectTime.ElapsedMilliseconds} ms"));
Workload workload = scenario == "counters"
? new CounterWorkload(sessions[0], ArgInt("nodes", 1), ArgInt("sampling", 10), ArgInt("pub", 1000))
: scenario == "sub"
? new SubscribeWorkload(
sessions,
ArgInt("nodes", 100),
ArgInt("subs", 5),
ArgInt("pub", 100),
ArgInt("write-interval", 100))
: 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);
await Task.Delay(TimeSpan.FromSeconds(warmupSeconds)).ConfigureAwait(false);
using Process? tracer = StartTracer(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");
workload.ResetStats();
var window = Stopwatch.StartNew();
TimeSpan clientCpuStart = Process.GetCurrentProcess().TotalProcessorTime;
await Task.Delay(TimeSpan.FromSeconds(durationSeconds)).ConfigureAwait(false);
await server.StandardInput.WriteLineAsync("report").ConfigureAwait(false);
string report = Take(serverLines, "REPORT")[7..];
double clientCores = (Process.GetCurrentProcess().TotalProcessorTime - clientCpuStart).TotalSeconds /
window.Elapsed.TotalSeconds;
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');
double operations = workload.Operations;
if (operations > 0)
{
(double allocatedMb, double cpuMs) = ParseServerReport(report);
result.Append(
CultureInfo.InvariantCulture,
$" 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;
}
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();
}
private 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
});
}
private 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) ParseServerReport(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 of the server process, captured at the start and end of the window.
///
internal sealed record ServerStats(
long Allocated,
int Gen0,
int Gen1,
int Gen2,
TimeSpan Pause,
TimeSpan Cpu,
long Contention,
long Timestamp,
int Threads,
long WorkingSet,
long Heap)
{
public static ServerStats Capture()
{
using var process = Process.GetCurrentProcess();
return new ServerStats(
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(ServerStats 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; }
public abstract Task SetupAsync();
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, int nodes, int subscriptions, int publishingInterval, int writeInterval)
{
m_sessions = sessions;
m_nodes = nodes;
m_subscriptions = subscriptions;
m_publishingInterval = publishingInterval;
m_writeInterval = writeInterval;
}
public override double Operations => Interlocked.Read(ref m_notifications);
public override string OperationName => "notification";
public override async Task SetupAsync()
{
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, write every {m_writeInterval} ms; setup {setupTime.ElapsedMilliseconds} ms"));
}
public override async Task RunAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(m_writeInterval));
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
{
int counter = Interlocked.Increment(ref m_counter);
var values = new WriteValue[m_nodeIds.Count];
for (int i = 0; i < values.Length; i++)
{
values[i] = new WriteValue
{
NodeId = m_nodeIds[i],
AttributeId = Attributes.Value,
Value = new DataValue(new Variant(counter))
};
}
Volatile.Write(ref m_writeTicks[counter & 0xFFFF], Stopwatch.GetTimestamp());
await m_sessions[0].WriteAsync(null, values, ct).ConfigureAwait(false);
Interlocked.Increment(ref m_writes);
}
}
public override void ResetStats()
{
m_latency.Clear();
Interlocked.Exchange(ref m_notifications, 0);
Interlocked.Exchange(ref m_writes, 0);
}
public override string Report(double seconds)
{
long notifications = Interlocked.Read(ref m_notifications);
long expected = Interlocked.Read(ref m_writes) * m_sessions.Length * m_subscriptions * m_nodeIds.Count;
double ratio = expected == 0 ? 0 : (double)notifications / expected;
return FormattableString.Invariant(
$"notif/s={notifications / seconds:F0} received/expected={ratio:P1} ") +
FormattableString.Invariant($"write->notify latency {m_latency.Percentiles()}");
}
private async Task CreateSubscriptionAsync(ISession session, CancellationToken ct)
{
#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,
FastDataChangeCallback = OnDataChange
};
#pragma warning restore CA2000
try
{
foreach (NodeId nodeId in m_nodeIds)
{
subscription.AddItem(new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = nodeId,
AttributeId = Attributes.Value,
MonitoringMode = MonitoringMode.Reporting,
SamplingInterval = 0,
QueueSize = 1
});
}
// 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)
{
if (item.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);
}
}
count++;
}
Interlocked.Add(ref m_notifications, count);
}
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 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;
}
}