mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-24 19:13:02 +08:00
Extends the out-of-process load harness (`perf/`, not part of the product build) so it can measure client-side GC, run the V2 client by default, and emulate a container-sized client. No product code changes. ## Client GC measurement - New `client-gc:` result line from an in-process GC event listener (`GcListener.cs`, runtime GC keyword at informational level): GC mode, collections by condemned generation and trigger, pause and suspension p50/p99/max, bytes surviving gen0/gen1, pinned objects, gen2/LOH size, peak heap after a collection, committed bytes, heap limit, DATAS heap count and gen0 budget. `--gc-events 0` turns it off. - The latency recorder is now a fixed log-linear histogram (~1.6 % resolution). The old `ConcurrentBag<long>` kept ~25 MB of samples alive in the measured client's gen2 (client heap 29–31 MB vs 4 MB now). - **Fix:** GC, thread-pool and processor-count settings set for the harness (`DOTNET_GC*`, `COMPlus_GC*`, `DOTNET_ThreadPool_*`, `DOTNET_PROCESSOR_COUNT`) no longer leak into the server child. Before, `DOTNET_gcServer=1` switched the server too, although the README said it switched the client. `SERVER_`-prefixed variables still configure the server. - TraceAnalyzer `gc` decodes the condemn reasons and breaks each collection into phases (marking by root type with the bytes marked, plan/relocate/compact). ## V2 client by default - `--client` defaults to `managed` (`ManagedSession` + V2 subscription engine). The `events` and `counters` workloads gained V2 paths; `--pool-notifications 1` enables `WithPoolNotifications`. `client-scenarios.txt` lists V2 scenarios. `--client classic` and `shared` still work. ## Container-like client - `--client-cores N`: the harness runs itself again with `DOTNET_PROCESSOR_COUNT=N` on the last N processors and pins the server to the others. - `--client-memory MB`: the client's GC heap hard limit becomes 75 % of MB, the limit .NET derives from a container memory limit (native memory is not capped). ## What it found The report is published separately. Highlights: - Request path: a gen0 pause costs ~1 ms of fixed work (stack and card scanning, compaction), so pause time follows collection count. Read-1 allocates ~2 KB/request on the client, and its p99 is the GC pause (a 64 MB gen0 halves it). - V2 subscriptions and events never reach gen2 (classic `Subscription` with its message cache: 22 % of wall time paused for events). - Containers: each V2 monitored item costs 2.8–3.8 KB, mostly a per-item `ConcurrentDictionary` in `OptionsMonitor<T>` (one lock per processor) and a per-item `ConcurrentQueue`. 100k items do not fit in a 256 MB container. A 1-processor client stalls ~1 s unless the thread-pool minimum is raised. ## Testing - Harness builds in Release (`CustomTestTarget=net10.0`). I ran every changed scenario: read/write/browse/sub/events/counters on managed sessions, constrained runs with 1, 2 and 4 cores and 256 MB to 1 GB, and `TraceAnalyzer gc/alloc` on recorded traces. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Marc Schier <marcschier@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com>
381 lines
15 KiB
C#
381 lines
15 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Opc.Ua.Client;
|
|
using V2 = Opc.Ua.Client.Subscriptions;
|
|
using IServerInternal = Opc.Ua.Server.IServerInternal;
|
|
|
|
namespace Opc.Ua.Perf.ServerLoadHarness
|
|
{
|
|
/// <summary>
|
|
/// Server side of the <c>events</c> scenario: reports BaseEvents on the Server object,
|
|
/// as the Sessions <c>ServerEventSubscribeLoadTest</c> does. Started and stopped through
|
|
/// the <c>events</c> stdin command of the server child.
|
|
/// </summary>
|
|
internal sealed class EventGenerator : IDisposable
|
|
{
|
|
public EventGenerator(IServerInternal server)
|
|
{
|
|
m_server = server;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Starts reporting <paramref name="rate"/> events per second, or as fast as one
|
|
/// thread can when the rate is 0.
|
|
/// </summary>
|
|
public void Start(int rate)
|
|
{
|
|
Stop();
|
|
m_stop = new CancellationTokenSource();
|
|
CancellationToken ct = m_stop.Token;
|
|
m_thread = new Thread(() => Run(rate, ct)) { IsBackground = true, Name = "EventGenerator" };
|
|
m_thread.Start();
|
|
}
|
|
|
|
public void Stop()
|
|
{
|
|
m_stop?.Cancel();
|
|
m_thread?.Join();
|
|
m_stop?.Dispose();
|
|
m_stop = null;
|
|
m_thread = null;
|
|
}
|
|
|
|
public void Mark()
|
|
{
|
|
Interlocked.Exchange(ref m_generated, 0);
|
|
}
|
|
|
|
public string Report()
|
|
{
|
|
return FormattableString.Invariant($"events={Interlocked.Read(ref m_generated)}");
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Stop();
|
|
}
|
|
|
|
private void Run(int rate, CancellationToken ct)
|
|
{
|
|
ISystemContext context = m_server.DefaultSystemContext;
|
|
var clock = Stopwatch.StartNew();
|
|
long sent = 0;
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
if (rate > 0 && sent >= (long)(clock.Elapsed.TotalSeconds * rate))
|
|
{
|
|
// paced: report what the elapsed time allows, then sleep
|
|
Thread.Sleep(1);
|
|
continue;
|
|
}
|
|
var e = new BaseEventState(null);
|
|
e.Initialize(
|
|
context,
|
|
m_server.ServerObject,
|
|
EventSeverity.Medium,
|
|
new LocalizedText(string.Create(CultureInfo.InvariantCulture, $"Load event {sent}")));
|
|
m_server.ReportEvent(context, e);
|
|
sent++;
|
|
Interlocked.Increment(ref m_generated);
|
|
}
|
|
}
|
|
|
|
private readonly IServerInternal m_server;
|
|
private CancellationTokenSource? m_stop;
|
|
private Thread? m_thread;
|
|
private long m_generated;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Client side of the <c>events</c> scenario: event subscriptions on the Server object
|
|
/// with the select clauses of the Sessions <c>ServerEventSubscribeLoadTest</c>. Counts the
|
|
/// delivered events and the delay from the event Time to the client callback.
|
|
/// </summary>
|
|
internal sealed class EventWorkload : Workload
|
|
{
|
|
public EventWorkload(ISession[] sessions, int subscriptions, int publishingInterval, int queueSize, int rate)
|
|
{
|
|
m_sessions = sessions;
|
|
m_subscriptions = subscriptions;
|
|
m_publishingInterval = publishingInterval;
|
|
m_queueSize = queueSize;
|
|
Rate = rate;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Server-side events per second (0 = unpaced).
|
|
/// </summary>
|
|
public int Rate { get; }
|
|
|
|
public override double Operations => m_received;
|
|
|
|
public override string OperationName => "event notification";
|
|
|
|
public override (double Count, string Name)? Secondary =>
|
|
m_generated > 0 ? (m_generated, "generated event") : null;
|
|
|
|
public override async Task SetupAsync()
|
|
{
|
|
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(
|
|
$"events: {m_sessions.Length} sessions x {m_subscriptions} event subs, pub {m_publishingInterval} ms, ") +
|
|
FormattableString.Invariant(
|
|
$"queue {m_queueSize}, rate {(Rate == 0 ? "unpaced" : Rate.ToString(CultureInfo.InvariantCulture))}; setup {setupTime.ElapsedMilliseconds} ms"));
|
|
}
|
|
|
|
public override Task RunAsync(CancellationToken ct)
|
|
{
|
|
// the server child generates the events
|
|
return Task.Delay(Timeout.Infinite, ct);
|
|
}
|
|
|
|
public override void ResetStats()
|
|
{
|
|
m_latency.Clear();
|
|
Interlocked.Exchange(ref m_events, 0);
|
|
}
|
|
|
|
public override void OnServerReport(string report)
|
|
{
|
|
// notifications keep arriving after the window: report the count at its end
|
|
m_received = Interlocked.Read(ref m_events);
|
|
foreach (string part in report.Split(' '))
|
|
{
|
|
if (part.StartsWith("events=", StringComparison.Ordinal))
|
|
{
|
|
m_generated = long.Parse(part[7..], CultureInfo.InvariantCulture);
|
|
}
|
|
}
|
|
}
|
|
|
|
public override string Report(double seconds)
|
|
{
|
|
long events = m_received;
|
|
long expected = m_generated * m_sessions.Length * m_subscriptions;
|
|
double ratio = expected == 0 ? 0 : (double)events / expected;
|
|
return FormattableString.Invariant(
|
|
$"generated/s={m_generated / seconds:F0} events/s={events / seconds:F0} received/expected={ratio:P1} ") +
|
|
FormattableString.Invariant($"event->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,
|
|
FastEventCallback = OnEvents
|
|
};
|
|
#pragma warning restore CA2000
|
|
try
|
|
{
|
|
subscription.AddItem(new MonitoredItem(subscription.DefaultItem)
|
|
{
|
|
StartNodeId = ObjectIds.Server,
|
|
AttributeId = Attributes.EventNotifier,
|
|
MonitoringMode = MonitoringMode.Reporting,
|
|
Filter = CreateFilter(),
|
|
QueueSize = (uint)m_queueSize
|
|
});
|
|
session.AddSubscription(subscription);
|
|
}
|
|
catch
|
|
{
|
|
subscription.Dispose();
|
|
throw;
|
|
}
|
|
await subscription.CreateAsync(ct).ConfigureAwait(false);
|
|
}
|
|
|
|
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
|
|
});
|
|
if (!subscription.TryAddMonitoredItem(
|
|
"events",
|
|
new V2.MonitoredItems.MonitoredItemOptions
|
|
{
|
|
StartNodeId = ObjectIds.Server,
|
|
AttributeId = Attributes.EventNotifier,
|
|
MonitoringMode = MonitoringMode.Reporting,
|
|
Filter = CreateFilter(),
|
|
QueueSize = (uint)m_queueSize
|
|
},
|
|
out _))
|
|
{
|
|
throw new InvalidOperationException("Could not add the event monitored item during subscription setup.");
|
|
}
|
|
await setup.WaitForReadyAsync(subscription, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The select clauses of the Sessions <c>ServerEventSubscribeLoadTest</c>; Time is field 4.
|
|
/// </summary>
|
|
private static EventFilter CreateFilter()
|
|
{
|
|
var filter = new EventFilter();
|
|
filter.AddSelectClause(ObjectTypeIds.BaseEventType, QualifiedName.From(BrowseNames.EventId));
|
|
filter.AddSelectClause(ObjectTypeIds.BaseEventType, QualifiedName.From(BrowseNames.EventType));
|
|
filter.AddSelectClause(ObjectTypeIds.BaseEventType, QualifiedName.From(BrowseNames.SourceNode));
|
|
filter.AddSelectClause(ObjectTypeIds.BaseEventType, QualifiedName.From(BrowseNames.SourceName));
|
|
filter.AddSelectClause(ObjectTypeIds.BaseEventType, QualifiedName.From(BrowseNames.Time));
|
|
filter.AddSelectClause(ObjectTypeIds.BaseEventType, QualifiedName.From(BrowseNames.Message));
|
|
filter.AddSelectClause(ObjectTypeIds.BaseEventType, QualifiedName.From(BrowseNames.Severity));
|
|
return filter;
|
|
}
|
|
|
|
private void OnEvents(Subscription subscription, EventNotificationList notification, ArrayOf<string> stringTable)
|
|
{
|
|
DateTime now = DateTime.UtcNow;
|
|
int count = 0;
|
|
foreach (EventFieldList fields in notification.Events)
|
|
{
|
|
count++;
|
|
RecordLatency(now, count, fields.EventFields);
|
|
}
|
|
Interlocked.Add(ref m_events, count);
|
|
}
|
|
|
|
private void RecordLatency(DateTime now, int count, ArrayOf<Variant> fields)
|
|
{
|
|
// sample every 16th event so the recorder stays small at high rates
|
|
if ((count & 15) == 1 && fields.Count > 4 && fields[4].TryGetValue(out DateTimeUtc time))
|
|
{
|
|
m_latency.Record((long)((now - ((DateTime)time).ToUniversalTime()).TotalSeconds * Stopwatch.Frequency));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// V2 engine handler; events are counted like the classic fast event callback.
|
|
/// </summary>
|
|
private sealed class NotificationHandler : V2.ISubscriptionNotificationHandler
|
|
{
|
|
public NotificationHandler(EventWorkload owner, SubscriptionSetup setup)
|
|
{
|
|
m_owner = owner;
|
|
m_setup = setup;
|
|
}
|
|
|
|
public ValueTask OnDataChangeNotificationAsync(
|
|
V2.ISubscription subscription,
|
|
uint sequenceNumber,
|
|
DateTime publishTime,
|
|
ReadOnlyMemory<V2.DataValueChange> notification,
|
|
V2.PublishState publishStateMask,
|
|
IReadOnlyList<string> stringTable)
|
|
{
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
public ValueTask OnEventDataNotificationAsync(
|
|
V2.ISubscription subscription,
|
|
uint sequenceNumber,
|
|
DateTime publishTime,
|
|
ReadOnlyMemory<V2.EventNotification> notification,
|
|
V2.PublishState publishStateMask,
|
|
IReadOnlyList<string> stringTable)
|
|
{
|
|
DateTime now = DateTime.UtcNow;
|
|
int count = 0;
|
|
foreach (V2.EventNotification e in notification.Span)
|
|
{
|
|
count++;
|
|
m_owner.RecordLatency(now, count, e.Fields);
|
|
}
|
|
Interlocked.Add(ref m_owner.m_events, count);
|
|
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 EventWorkload m_owner;
|
|
private readonly SubscriptionSetup m_setup;
|
|
}
|
|
|
|
private readonly ISession[] m_sessions;
|
|
private readonly int m_subscriptions;
|
|
private readonly int m_publishingInterval;
|
|
private readonly int m_queueSize;
|
|
private readonly LatencyRecorder m_latency = new();
|
|
private long m_events;
|
|
private long m_generated;
|
|
private long m_received;
|
|
}
|
|
}
|