/* ======================================================================== * Copyright (c) 2005-2026 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.Linq; using System.Threading; using System.Threading.Tasks; using Opc.Ua; namespace ConsoleDataChannelStreaming { /// /// Measures what a data channel sustains while the Session carries a /// competing Publish load. /// /// /// /// The question this answers is whether Service traffic and a saturated /// data channel starve each other. Over inline framing they share one /// SecureChannel and one SequenceNumber space, so the credit window is /// what keeps a media stream from starving Publish; over /// opc.quic the channel owns its own stream and QUIC applies the /// flow control, so the two should barely interact. Running this over /// both transports is how that claim gets a number rather than an /// assertion. /// /// /// The Client and the Server are in one process talking over loopback, /// so these figures are bound by CPU and cryptography, not by a network /// interface. They are useful for comparing the four cases against each /// other. They are not a statement about what the stack does over a /// real link, and the output says so. /// /// internal static class Benchmark { public static async Task RunAsync(SampleOptions options, CancellationToken ct) { int[] publishingIntervals = [0, 10, 100, 1000]; int repeat = Math.Max(options.Repeat, 1); Console.WriteLine( $"benchmark: {options.Transport} transport, {options.FrameCount} x " + $"{options.FrameSize} byte frames, {repeat} measured runs per case " + "after one discarded warm-up"); Console.WriteLine(); var results = new List(); // One Server, one Session and one data channel for the whole // matrix, with only the subscription changing between cases. // That keeps the comparison honest - every case runs on the same // channel with the same negotiated credit - and avoids standing // up several Servers in one process, which the data channel // Services do not expect. StreamingHarness harness = await StreamingHarness .CreateAsync(options with { RunMode = SampleRunMode.Server }, ct) .ConfigureAwait(false); try { var accumulators = new Dictionary(); foreach (int publishingInterval in publishingIntervals) { accumulators[publishingInterval] = new CaseAccumulator(); } // Cases are round-robined inside each repetition rather than // run to completion one after another. Run sequentially, a // process that keeps warming up over the matrix hands every // later case an advantage the earlier ones never had, and the // result is a clean monotonic trend that is entirely an // artefact of the order. Round-robin spreads that drift // across all four cases instead of loading it onto the last. for (int pass = 0; pass <= repeat; pass++) { bool isWarmup = pass == 0; foreach (int publishingInterval in publishingIntervals) { if (!isWarmup) { Console.WriteLine( publishingInterval == 0 ? $" pass {pass}: no subscription ..." : $" pass {pass}: {options.MonitoredItems} items at " + $"{publishingInterval} ms ..."); } await harness .StartPublishLoadAsync(publishingInterval, options.MonitoredItems, ct) .ConfigureAwait(false); CaseAccumulator accumulator = accumulators[publishingInterval]; // The idle Publish rate is a property of the // subscription, not of the pass, so it is measured // once on the warm-up pass. Without this control // there is no way to tell a Publish path starved by // the data channel from one that was never going to // run faster, and the two lead to opposite // conclusions. if (isWarmup && publishingInterval != 0) { accumulator.IdlePublishRate = await MeasureIdlePublishRateAsync(harness, ct).ConfigureAwait(false); } DataChannelDiagnosticsDataType before = harness.Source.GetDiagnostics(); long notificationsBefore = harness.PublishNotifications; Program.StreamingRun run = await Program .StreamAsync(harness, options, verbose: false, closeChannel: false, ct) .ConfigureAwait(false); if (isWarmup) { continue; } DataChannelDiagnosticsDataType after = harness.Source.GetDiagnostics(); double seconds = run.Elapsed.TotalSeconds > 0 ? run.Elapsed.TotalSeconds : double.Epsilon; accumulator.Throughputs.Add( (after.BytesSent - before.BytesSent) * 8 / 1_000_000.0 / seconds); accumulator.CreditStalls += (long)(after.CreditStalls - before.CreditStalls); accumulator.Discarded += (long)(after.FramesDiscarded - before.FramesDiscarded); accumulator.FramesReceived += run.FramesReceived; accumulator.Notifications += harness.PublishNotifications - notificationsBefore; accumulator.LoadSeconds += seconds; accumulator.RevisedInterval = harness.RevisedPublishingInterval; } } foreach (int publishingInterval in publishingIntervals) { CaseAccumulator accumulator = accumulators[publishingInterval]; results.Add(new CaseResult( publishingInterval, accumulator.RevisedInterval, publishingInterval == 0 ? 0 : options.MonitoredItems, Median(accumulator.Throughputs), accumulator.Throughputs.Count > 0 ? accumulator.Throughputs.Min() : 0, accumulator.Throughputs.Count > 0 ? accumulator.Throughputs.Max() : 0, accumulator.FramesReceived / repeat, accumulator.CreditStalls, accumulator.Discarded, accumulator.Notifications, accumulator.LoadSeconds, accumulator.IdlePublishRate)); } await harness.StopPublishLoadAsync(ct).ConfigureAwait(false); await harness.CloseDataChannelAsync(ct).ConfigureAwait(false); } finally { await harness.DisposeAsync().ConfigureAwait(false); } Console.WriteLine(); Report(options, results); } private sealed class CaseAccumulator { public List Throughputs { get; } = []; public long CreditStalls { get; set; } public long Discarded { get; set; } public int FramesReceived { get; set; } public long Notifications { get; set; } public double LoadSeconds { get; set; } public double IdlePublishRate { get; set; } public double RevisedInterval { get; set; } } /// /// Counts notifications over a fixed window with the data channel /// idle, giving the Publish rate the subscription reaches when it is /// not competing with anything. /// private static async Task MeasureIdlePublishRateAsync( StreamingHarness harness, CancellationToken ct) { var window = TimeSpan.FromSeconds(3); long before = harness.PublishNotifications; long started = Stopwatch.GetTimestamp(); await Task.Delay(window, ct).ConfigureAwait(false); double seconds = Stopwatch.GetElapsedTime(started).TotalSeconds; return seconds > 0 ? (harness.PublishNotifications - before) / seconds : 0; } private static void Report(SampleOptions options, List results) { Console.WriteLine("data channel throughput vs competing Publish load"); Console.WriteLine("================================================="); Console.WriteLine(); Console.WriteLine( " publish revised items notif/s idle notif/s loaded " + "Mbit/s (median) spread stalls"); Console.WriteLine( " -------- -------- ------ ------------- --------------- " + "---------------- -------------- -------"); double baseline = results.Count > 0 ? results[0].MedianMbits : 0; foreach (CaseResult result in results) { string publish = result.PublishingInterval == 0 ? "none" : result.PublishingInterval.ToString(CultureInfo.InvariantCulture) + " ms"; string revised = result.PublishingInterval == 0 ? "-" : result.RevisedPublishingInterval.ToString("F0", CultureInfo.InvariantCulture) + " ms"; double notificationsPerSecond = result.LoadSeconds > 0 ? result.Notifications / result.LoadSeconds : 0; string idle = result.PublishingInterval == 0 ? "-" : result.IdlePublishRate.ToString("F0", CultureInfo.InvariantCulture); string loaded = result.PublishingInterval == 0 ? "-" : notificationsPerSecond.ToString("F0", CultureInfo.InvariantCulture); string relative = baseline > 0 && result.PublishingInterval != 0 ? $" ({result.MedianMbits / baseline * 100:F0}% of baseline)" : string.Empty; Console.WriteLine( $" {publish,-8} {revised,-8} {result.MonitoredItems,6} " + $"{idle,13} {loaded,15} {result.MedianMbits,16:F1} " + $"{result.MinMbits,6:F1}-{result.MaxMbits,-7:F1} " + $"{result.CreditStalls,7}{relative}"); } Console.WriteLine(); WriteResolution(results); WriteStarvation(results); Console.WriteLine(); WriteValidity(options, results); Console.WriteLine( " Client and Server run in one process over loopback, so these are"); Console.WriteLine( " CPU and cryptography bound figures for comparing the four cases"); Console.WriteLine( " against each other, not a statement about throughput over a real"); Console.WriteLine( " link. Write enqueues rather than blocking, so what is measured is"); Console.WriteLine( " the rate the pipeline drains at: first write to last frame received."); Console.WriteLine(); if (results.Any(r => r.CreditStalls > 0)) { Console.WriteLine( " Credit stalls are the mechanism, not a fault: over inline framing"); Console.WriteLine( " a stalled data channel is what leaves the SecureChannel free for"); Console.WriteLine( " Publish, which is the property the credit window exists to give."); Console.WriteLine(); } } /// /// States whether the differences between the cases are large enough /// to mean anything. /// /// /// A median is a number whatever the data does, and four medians /// printed in a column invite a reader to rank them. When the run to /// run spread within one case covers the distance between all of /// them, that ranking is noise wearing the costume of a result. This /// is the last and most important guard in the benchmark: it is the /// difference between reporting a measurement and reporting a /// coincidence. /// private static void WriteResolution(List results) { if (results.Count < 2) { return; } CaseResult baseline = results[0]; var overlapping = new List(); foreach (CaseResult result in results.Skip(1)) { bool disjoint = result.MaxMbits < baseline.MinMbits || result.MinMbits > baseline.MaxMbits; if (!disjoint) { overlapping.Add(result); } } if (overlapping.Count == 0) { Console.WriteLine( " Every loaded case is separated from the baseline by more than the"); Console.WriteLine( " run to run spread, so the differences above are resolvable."); Console.WriteLine(); return; } Console.WriteLine( " NOT RESOLVABLE: the run to run spread of " + string.Join( ", ", overlapping.Select(r => r.PublishingInterval + " ms")) + " overlaps the"); Console.WriteLine( $" baseline's own spread ({baseline.MinMbits:F0}-{baseline.MaxMbits:F0} Mbit/s). The medians can be"); Console.WriteLine( " ranked but the ranking is not supported by the data: on this machine and"); Console.WriteLine( " at this load the difference between those cases is smaller than the noise."); Console.WriteLine( " Treat them as equal, or reduce the variance before drawing a conclusion."); Console.WriteLine(); } /// /// The data channel throughput column answers only half the question. /// The other half is what the stream costs the Service path it shares /// the SecureChannel with, and that is the direction the asymmetry /// usually runs: a saturated stream barely notices Publish, while /// Publish notices the stream a great deal. Reporting only the /// throughput column would leave a reader concluding the two do not /// interact. /// private static void WriteStarvation(List results) { var starved = new List<(CaseResult Result, double Retained)>(); foreach (CaseResult result in results) { if (result.PublishingInterval == 0 || result.IdlePublishRate <= 0 || result.LoadSeconds <= 0) { continue; } double loaded = result.Notifications / result.LoadSeconds; double retained = loaded / result.IdlePublishRate; if (retained < 0.8) { starved.Add((result, retained)); } } if (starved.Count == 0) { Console.WriteLine( " The Publish path kept its idle rate under load in every case, so the"); Console.WriteLine( " data channel did not starve Service traffic on this run."); Console.WriteLine(); return; } Console.WriteLine(" Publish throughput retained while the data channel was saturated:"); foreach ((CaseResult result, double retained) in starved) { Console.WriteLine( $" {result.PublishingInterval,5} ms : {retained * 100,5:F0}% of its idle rate " + $"({result.IdlePublishRate:F0} -> {result.Notifications / result.LoadSeconds:F0} notif/s)"); } Console.WriteLine(); } /// /// Checks that the competing load was actually applied. /// /// /// This is the check that decides whether the table above means /// anything. If the monitored items never reported, every subscribed /// case is really a second copy of the baseline, the numbers agree /// with each other beautifully, and the conclusion drawn from them is /// entirely false. Saying so loudly is cheaper than believing it. /// private static void WriteValidity(SampleOptions options, List results) { var starved = new List(); var tooShort = new List(); var revised = new List(); int repeat = Math.Max(options.Repeat, 1); foreach (CaseResult result in results) { if (result.PublishingInterval == 0 || result.LoadSeconds <= 0) { continue; } double interval = result.RevisedPublishingInterval > 0 ? result.RevisedPublishingInterval : result.PublishingInterval; if (Math.Abs(interval - result.PublishingInterval) > 0.5) { revised.Add(result); } // The load is judged against the rate this subscription // actually reaches with the channel idle, not against // items/interval. The two are far apart, and the reason is // worth knowing: a Client keeps only a couple of Publish // requests outstanding per subscription, so the notification // rate is bounded by how often it asks rather than by how // often the Server has something to say. Judging against the // theoretical rate would condemn every row as invalid when // the load is in fact exactly as large as it can be. if (result.IdlePublishRate <= 1) { starved.Add(result); } // A run that finishes inside a couple of publishing cycles // cannot show contention no matter what the number says: // most of the window simply had no Publish traffic in it. if (result.LoadSeconds / repeat * 1000.0 < interval * 5) { tooShort.Add(result); } } if (revised.Count > 0) { Console.WriteLine( " NOTE: the Server revised the publishing interval for " + string.Join( ", ", revised.Select(r => $"{r.PublishingInterval} ms -> {r.RevisedPublishingInterval:F0} ms")) + "."); Console.WriteLine( " Rows that were revised to the same value are the same experiment run"); Console.WriteLine( " twice, however different the requested column looks. Lower the"); Console.WriteLine( " Server's MinPublishingInterval to separate them."); Console.WriteLine(); } if (starved.Count > 0) { Console.WriteLine( " WARNING: no Publish traffic reached the Client at all for " + $"{string.Join(", ", starved.Select(r => r.PublishingInterval + " ms"))}, even"); Console.WriteLine( " with the data channel idle. Those rows are a second baseline rather"); Console.WriteLine( $" than a loaded case: the {options.MonitoredItems} monitored items are not reporting,"); Console.WriteLine( " and nothing below them should be read as a measurement of contention."); Console.WriteLine(); } if (tooShort.Count > 0) { Console.WriteLine( " WARNING: the measured run is shorter than five publishing cycles for " + $"{string.Join(", ", tooShort.Select(r => r.PublishingInterval + " ms"))}."); Console.WriteLine( " Most of the window had no Publish traffic in it, so those rows"); Console.WriteLine( " understate the load rather than measuring it. Raise --frames until"); Console.WriteLine( " a run lasts several seconds."); Console.WriteLine(); } } private static double Median(List values) { if (values.Count == 0) { return 0; } double[] ordered = [.. values.Order()]; int middle = ordered.Length / 2; return ordered.Length % 2 == 1 ? ordered[middle] : (ordered[middle - 1] + ordered[middle]) / 2; } private sealed record CaseResult( int PublishingInterval, double RevisedPublishingInterval, int MonitoredItems, double MedianMbits, double MinMbits, double MaxMbits, int FramesReceived, long CreditStalls, long Discarded, long Notifications, double LoadSeconds, double IdlePublishRate); } }