From 279184daeec64c275c5d56f000432ad021d4a00c Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Wed, 29 Jul 2026 16:00:39 -0700 Subject: [PATCH 1/7] Expand scheduler performance benchmarks Add fixed-dispatch scaling, readiness fan-out, allocation-slope, and trace scenarios for the next scheduler optimization pass. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 58f51472-51b1-4cff-9ed4-e26460b5163c --- .../DeterministicSchedulerBenchmarks.cs | 66 +++-------- benchmarks/Clockwork.Benchmarks/Program.cs | 21 ++++ .../SchedulerAllocationBenchmarks.cs | 19 ++++ .../SchedulerBenchmarkWorkload.cs | 106 ++++++++++++++++++ .../SchedulerReadinessBenchmarks.cs | 33 ++++++ .../SchedulerScalingBenchmarks.cs | 37 ++++++ src/Clockwork/AssemblyInfo.cs | 1 + 7 files changed, 230 insertions(+), 53 deletions(-) create mode 100644 benchmarks/Clockwork.Benchmarks/SchedulerAllocationBenchmarks.cs create mode 100644 benchmarks/Clockwork.Benchmarks/SchedulerBenchmarkWorkload.cs create mode 100644 benchmarks/Clockwork.Benchmarks/SchedulerReadinessBenchmarks.cs create mode 100644 benchmarks/Clockwork.Benchmarks/SchedulerScalingBenchmarks.cs diff --git a/benchmarks/Clockwork.Benchmarks/DeterministicSchedulerBenchmarks.cs b/benchmarks/Clockwork.Benchmarks/DeterministicSchedulerBenchmarks.cs index 219f2af..e949bd7 100644 --- a/benchmarks/Clockwork.Benchmarks/DeterministicSchedulerBenchmarks.cs +++ b/benchmarks/Clockwork.Benchmarks/DeterministicSchedulerBenchmarks.cs @@ -1,7 +1,4 @@ using BenchmarkDotNet.Attributes; -using Clockwork.Runtime.Execution; -using Clockwork.Runtime.Scheduling; - namespace Clockwork.Benchmarks; [MemoryDiagnoser] @@ -14,66 +11,29 @@ public class DeterministicSchedulerBenchmarks [Benchmark(Baseline = true, OperationsPerInvoke = SchedulingPointCount)] public int Direct() - { - var workload = new Workload(scheduler: null, _initialCompletedSteps); - for (var operation = 0; operation < OperationCount; operation++) - { - workload.Run(); - } - - return workload.CompletedSteps; - } + => SchedulerBenchmarkWorkload.RunDirect( + OperationCount, + StepsPerOperation, + _initialCompletedSteps); [Benchmark(OperationsPerInvoke = SchedulingPointCount)] - public int DeterministicScheduler() => RunScheduler(_initialCompletedSteps); + public int DeterministicScheduler() => + SchedulerBenchmarkWorkload.RunScheduler( + OperationCount, + StepsPerOperation, + _initialCompletedSteps); public static int RunTrace(int iterationCount) { var completed = 0; for (var iteration = 0; iteration < iterationCount; iteration++) { - completed += RunScheduler(initialCompletedSteps: 0); + completed += SchedulerBenchmarkWorkload.RunScheduler( + OperationCount, + StepsPerOperation, + initialCompletedSteps: 0); } return completed; } - - private static int RunScheduler(int initialCompletedSteps) - { - using var scheduler = new SimulationScheduler( - new SimulationRuntimeIdentity(Guid.Empty, Seed: 1, Description: "benchmark")); - var workload = new Workload(scheduler, initialCompletedSteps); - Action body = workload.Run; - - for (var operation = 0; operation < OperationCount; operation++) - { - scheduler.Schedule("benchmark", body); - } - - int dispatched = scheduler.Drain(CancellationToken.None); - if (dispatched != SchedulingPointCount) - { - throw new InvalidOperationException( - $"Expected {SchedulingPointCount} dispatches but observed {dispatched}."); - } - - return workload.CompletedSteps; - } - - private sealed class Workload(SimulationScheduler? scheduler, int initialCompletedSteps) - { - public int CompletedSteps { get; private set; } = initialCompletedSteps; - - public void Run() - { - for (var step = 0; step < StepsPerOperation; step++) - { - CompletedSteps++; - if (step + 1 < StepsPerOperation) - { - scheduler?.Yield(); - } - } - } - } } diff --git a/benchmarks/Clockwork.Benchmarks/Program.cs b/benchmarks/Clockwork.Benchmarks/Program.cs index 8119f99..6a49184 100644 --- a/benchmarks/Clockwork.Benchmarks/Program.cs +++ b/benchmarks/Clockwork.Benchmarks/Program.cs @@ -9,4 +9,25 @@ return; } +if (args is ["--trace-scaling", var scalingIterationCount, var operationCount] + && int.TryParse(scalingIterationCount, out var scalingIterations) + && scalingIterations > 0 + && int.TryParse(operationCount, out var operations) + && operations > 0 + && 4096 % operations == 0) +{ + Console.WriteLine(SchedulerScalingBenchmarks.RunTrace(scalingIterations, operations)); + return; +} + +if (args is ["--trace-readiness", var readinessIterationCount, var pendingWaitCount] + && int.TryParse(readinessIterationCount, out var readinessIterations) + && readinessIterations > 0 + && int.TryParse(pendingWaitCount, out var pendingWaits) + && pendingWaits >= 0) +{ + Console.WriteLine(SchedulerReadinessBenchmarks.RunTrace(readinessIterations, pendingWaits)); + return; +} + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); diff --git a/benchmarks/Clockwork.Benchmarks/SchedulerAllocationBenchmarks.cs b/benchmarks/Clockwork.Benchmarks/SchedulerAllocationBenchmarks.cs new file mode 100644 index 0000000..146ccd0 --- /dev/null +++ b/benchmarks/Clockwork.Benchmarks/SchedulerAllocationBenchmarks.cs @@ -0,0 +1,19 @@ +using BenchmarkDotNet.Attributes; + +namespace Clockwork.Benchmarks; + +[MemoryDiagnoser] +public class SchedulerAllocationBenchmarks +{ + private const int OperationCount = 4; + + [Params(2, 32, 512, 4096)] + public int StepsPerOperation { get; set; } + + [Benchmark] + public int DeterministicScheduler() => + SchedulerBenchmarkWorkload.RunScheduler( + OperationCount, + StepsPerOperation, + initialCompletedSteps: 1); +} diff --git a/benchmarks/Clockwork.Benchmarks/SchedulerBenchmarkWorkload.cs b/benchmarks/Clockwork.Benchmarks/SchedulerBenchmarkWorkload.cs new file mode 100644 index 0000000..7c6e93c --- /dev/null +++ b/benchmarks/Clockwork.Benchmarks/SchedulerBenchmarkWorkload.cs @@ -0,0 +1,106 @@ +using Clockwork.Runtime.Execution; +using Clockwork.Runtime.Scheduling; + +namespace Clockwork.Benchmarks; + +internal static class SchedulerBenchmarkWorkload +{ + public static int RunDirect( + int operationCount, + int stepsPerOperation, + int initialCompletedSteps) + { + var workload = new YieldingWorkload( + scheduler: null, + stepsPerOperation, + initialCompletedSteps); + for (var operation = 0; operation < operationCount; operation++) + { + workload.Run(); + } + + return workload.CompletedSteps; + } + + public static int RunScheduler( + int operationCount, + int stepsPerOperation, + int initialCompletedSteps) + { + using var scheduler = CreateScheduler(); + var workload = new YieldingWorkload( + scheduler, + stepsPerOperation, + initialCompletedSteps); + Action body = workload.Run; + + for (var operation = 0; operation < operationCount; operation++) + { + scheduler.Schedule("benchmark", body); + } + + var expectedDispatches = operationCount * stepsPerOperation; + var dispatched = scheduler.Drain(CancellationToken.None); + if (dispatched != expectedDispatches) + { + throw new InvalidOperationException( + $"Expected {expectedDispatches} dispatches but observed {dispatched}."); + } + + return workload.CompletedSteps; + } + + public static int RunWithPendingReadiness( + int pendingWaitCount, + int dispatchCount, + int initialCompletedSteps) + { + using var scheduler = CreateScheduler(); + var workload = new YieldingWorkload( + scheduler, + dispatchCount, + initialCompletedSteps); + + // Register the runnable operation first so round-robin selection remains O(1). This isolates + // the cost of polling the pending readiness set on every dispatch. + scheduler.Schedule("benchmark", workload.Run); + for (var wait = 0; wait < pendingWaitCount; wait++) + { + scheduler.ScheduleWhenReady( + static () => false, + static () => throw new InvalidOperationException("A pending readiness callback ran unexpectedly.")); + } + + var dispatched = scheduler.Drain(CancellationToken.None); + if (dispatched != dispatchCount) + { + throw new InvalidOperationException( + $"Expected {dispatchCount} dispatches but observed {dispatched}."); + } + + return workload.CompletedSteps; + } + + private static SimulationScheduler CreateScheduler() => + new(new SimulationRuntimeIdentity(Guid.Empty, Seed: 1, Description: "benchmark")); + + private sealed class YieldingWorkload( + SimulationScheduler? scheduler, + int stepsPerOperation, + int initialCompletedSteps) + { + public int CompletedSteps { get; private set; } = initialCompletedSteps; + + public void Run() + { + for (var step = 0; step < stepsPerOperation; step++) + { + CompletedSteps++; + if (step + 1 < stepsPerOperation) + { + scheduler?.Yield(); + } + } + } + } +} diff --git a/benchmarks/Clockwork.Benchmarks/SchedulerReadinessBenchmarks.cs b/benchmarks/Clockwork.Benchmarks/SchedulerReadinessBenchmarks.cs new file mode 100644 index 0000000..9205fb6 --- /dev/null +++ b/benchmarks/Clockwork.Benchmarks/SchedulerReadinessBenchmarks.cs @@ -0,0 +1,33 @@ +using BenchmarkDotNet.Attributes; + +namespace Clockwork.Benchmarks; + +[MemoryDiagnoser] +public class SchedulerReadinessBenchmarks +{ + private const int DispatchCount = 256; + + [Params(0, 1, 16, 128)] + public int PendingWaitCount { get; set; } + + [Benchmark(OperationsPerInvoke = DispatchCount)] + public int DeterministicScheduler() => + SchedulerBenchmarkWorkload.RunWithPendingReadiness( + PendingWaitCount, + DispatchCount, + initialCompletedSteps: 1); + + public static int RunTrace(int iterationCount, int pendingWaitCount) + { + var completed = 0; + for (var iteration = 0; iteration < iterationCount; iteration++) + { + completed += SchedulerBenchmarkWorkload.RunWithPendingReadiness( + pendingWaitCount, + DispatchCount, + initialCompletedSteps: 0); + } + + return completed; + } +} diff --git a/benchmarks/Clockwork.Benchmarks/SchedulerScalingBenchmarks.cs b/benchmarks/Clockwork.Benchmarks/SchedulerScalingBenchmarks.cs new file mode 100644 index 0000000..cb54978 --- /dev/null +++ b/benchmarks/Clockwork.Benchmarks/SchedulerScalingBenchmarks.cs @@ -0,0 +1,37 @@ +using BenchmarkDotNet.Attributes; + +namespace Clockwork.Benchmarks; + +[MemoryDiagnoser] +public class SchedulerScalingBenchmarks +{ + private const int DispatchCount = 4096; + + [Params(1, 4, 16, 64, 256)] + public int OperationCount { get; set; } + + [Benchmark(OperationsPerInvoke = DispatchCount)] + public int DeterministicScheduler() + { + var stepsPerOperation = DispatchCount / OperationCount; + return SchedulerBenchmarkWorkload.RunScheduler( + OperationCount, + stepsPerOperation, + initialCompletedSteps: 1); + } + + public static int RunTrace(int iterationCount, int operationCount) + { + var completed = 0; + var stepsPerOperation = DispatchCount / operationCount; + for (var iteration = 0; iteration < iterationCount; iteration++) + { + completed += SchedulerBenchmarkWorkload.RunScheduler( + operationCount, + stepsPerOperation, + initialCompletedSteps: 0); + } + + return completed; + } +} diff --git a/src/Clockwork/AssemblyInfo.cs b/src/Clockwork/AssemblyInfo.cs index 49590da..3e2faee 100644 --- a/src/Clockwork/AssemblyInfo.cs +++ b/src/Clockwork/AssemblyInfo.cs @@ -2,3 +2,4 @@ [assembly: InternalsVisibleTo("Clockwork.Tests")] [assembly: InternalsVisibleTo("Clockwork.Runtime.Tests")] +[assembly: InternalsVisibleTo("Clockwork.Benchmarks")] From f5587bc1a7f26cfc9949e23750672c7d6675ac6c Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Wed, 29 Jul 2026 16:08:48 -0700 Subject: [PATCH 2/7] Pool scheduler readiness snapshots Reuse arrays while polling pending readiness predicates, eliminating the per-dispatch snapshot allocation without evaluating callbacks under the scheduler lock. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 58f51472-51b1-4cff-9ed4-e26460b5163c --- .../Runtime/Scheduling/SimulationScheduler.cs | 50 +++++++++++++------ 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/src/Clockwork/Runtime/Scheduling/SimulationScheduler.cs b/src/Clockwork/Runtime/Scheduling/SimulationScheduler.cs index 9fde2c1..495f7af 100644 --- a/src/Clockwork/Runtime/Scheduling/SimulationScheduler.cs +++ b/src/Clockwork/Runtime/Scheduling/SimulationScheduler.cs @@ -1,3 +1,4 @@ +using System.Buffers; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; @@ -986,36 +987,53 @@ internal void ReportUnhandledCallbackException(Exception exception) private void PromoteReadyWaits() { - ReadinessWait[] waits; + ReadinessWait[]? waits = null; + int count; lock (_gate) { - waits = [.. _readinessWaits]; + count = _readinessWaits.Count; + if (count == 0) + { + return; + } + + waits = ArrayPool.Shared.Rent(count); + _readinessWaits.CopyTo(waits, 0); } - foreach (var wait in waits) + try { - if (wait.IsCanceled || !wait.IsReady()) + for (var index = 0; index < count; index++) { - continue; - } + var wait = waits[index]; + if (wait.IsCanceled || !wait.IsReady()) + { + continue; + } - SimulationOperation? operation = null; - using (EnterTransitionPublicationScope()) - { - lock (_gate) + SimulationOperation? operation = null; + using (EnterTransitionPublicationScope()) { - if (!_readinessWaits.Remove(wait) || wait.IsCanceled) + lock (_gate) { - continue; + if (!_readinessWaits.Remove(wait) || wait.IsCanceled) + { + continue; + } + + operation = wait.Operation; + operation.ApplyTransition(SimulationOperationState.Runnable); } - operation = wait.Operation; - operation.ApplyTransition(SimulationOperationState.Runnable); + Notify(operation, SimulationOperationState.Runnable); } - - Notify(operation, SimulationOperationState.Runnable); } } + finally + { + Array.Clear(waits, 0, count); + ArrayPool.Shared.Return(waits); + } } private SimulationOperation? ResumeDeadlockedSynchronousWaitUnderLock() From f6c8a714b37c5bb8f9e32fedc11d5330ac7a2ac4 Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Wed, 29 Jul 2026 16:13:06 -0700 Subject: [PATCH 3/7] Reduce replay bookkeeping allocations Benchmark record and replay scaling, then use the existing failure listener instead of materializing all operation statuses after every dispatch. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 58f51472-51b1-4cff-9ed4-e26460b5163c --- benchmarks/Clockwork.Benchmarks/Program.cs | 16 +++ .../ReplayRunnerBenchmarks.cs | 107 ++++++++++++++++++ src/Clockwork/Runtime/Replay/ReplayRunner.cs | 19 +++- 3 files changed, 137 insertions(+), 5 deletions(-) create mode 100644 benchmarks/Clockwork.Benchmarks/ReplayRunnerBenchmarks.cs diff --git a/benchmarks/Clockwork.Benchmarks/Program.cs b/benchmarks/Clockwork.Benchmarks/Program.cs index 6a49184..f1cdc8f 100644 --- a/benchmarks/Clockwork.Benchmarks/Program.cs +++ b/benchmarks/Clockwork.Benchmarks/Program.cs @@ -30,4 +30,20 @@ return; } +if (args is ["--trace-replay", var replayIterationCount, var replayOperationCount, var replayMode] + && int.TryParse(replayIterationCount, out var replayIterations) + && replayIterations > 0 + && int.TryParse(replayOperationCount, out var replayOperations) + && replayOperations > 0 + && 4096 % replayOperations == 0 + && replayMode is "record" or "replay") +{ + Console.WriteLine( + ReplayRunnerBenchmarks.RunTrace( + replayIterations, + replayOperations, + replay: replayMode == "replay")); + return; +} + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); diff --git a/benchmarks/Clockwork.Benchmarks/ReplayRunnerBenchmarks.cs b/benchmarks/Clockwork.Benchmarks/ReplayRunnerBenchmarks.cs new file mode 100644 index 0000000..f7cca88 --- /dev/null +++ b/benchmarks/Clockwork.Benchmarks/ReplayRunnerBenchmarks.cs @@ -0,0 +1,107 @@ +using BenchmarkDotNet.Attributes; +using Clockwork.Runtime.Replay; +using Clockwork.Runtime.Scheduling; + +namespace Clockwork.Benchmarks; + +[MemoryDiagnoser] +public class ReplayRunnerBenchmarks +{ + private const int DispatchCount = 4096; + private static readonly ReplayRecordingOptions s_recordingOptions = new() + { + RootSeed = 1, + SchedulingPolicy = ReplaySchedulingPolicy.RoundRobin, + MaxSteps = DispatchCount + 1, + }; + + private static readonly ReplayCompatibilityRequirements s_compatibility = + ReplayCompatibilityRequirements.Current(); + + private ReplayArtifact _artifact = null!; + + [Params(4, 64, 256)] + public int OperationCount { get; set; } + + [GlobalSetup] + public void Setup() => _artifact = Record(OperationCount).Artifact; + + [Benchmark] + public int RecordScenario() => Record(OperationCount).Steps; + + [Benchmark] + public int ReplayScenario() => Replay(_artifact, OperationCount).Steps; + + public static int RunTrace(int iterationCount, int operationCount, bool replay) + { + ReplayArtifact? artifact = replay ? Record(operationCount).Artifact : null; + var completedSteps = 0; + for (var iteration = 0; iteration < iterationCount; iteration++) + { + completedSteps += replay + ? Replay(artifact!, operationCount).Steps + : Record(operationCount).Steps; + } + + return completedSteps; + } + + private static ReplayExecutionResult Record(int operationCount) + { + var scenario = new YieldingReplayScenario(operationCount, DispatchCount / operationCount); + ReplayExecutionResult result = ReplayRunner.Record( + s_recordingOptions, + scenario.Schedule, + CancellationToken.None); + ValidateSteps(result); + return result; + } + + private static ReplayExecutionResult Replay(ReplayArtifact artifact, int operationCount) + { + var scenario = new YieldingReplayScenario(operationCount, DispatchCount / operationCount); + ReplayExecutionResult result = ReplayRunner.Replay( + artifact, + s_compatibility, + scenario.Schedule, + maxSteps: DispatchCount + 1, + cancellationToken: CancellationToken.None); + ValidateSteps(result); + return result; + } + + private static void ValidateSteps(ReplayExecutionResult result) + { + if (result.Steps != DispatchCount) + { + throw new InvalidOperationException( + $"Expected {DispatchCount} replay dispatches but observed {result.Steps}."); + } + } + + private sealed class YieldingReplayScenario(int operationCount, int stepsPerOperation) + { + private SimulationScheduler _scheduler = null!; + + public void Schedule(SimulationScheduler scheduler) + { + _scheduler = scheduler; + Action body = Run; + for (var operation = 0; operation < operationCount; operation++) + { + scheduler.Schedule("benchmark", body); + } + } + + private void Run() + { + for (var step = 0; step < stepsPerOperation; step++) + { + if (step + 1 < stepsPerOperation) + { + _scheduler.Yield(); + } + } + } + } +} diff --git a/src/Clockwork/Runtime/Replay/ReplayRunner.cs b/src/Clockwork/Runtime/Replay/ReplayRunner.cs index 809fba2..17af2ee 100644 --- a/src/Clockwork/Runtime/Replay/ReplayRunner.cs +++ b/src/Clockwork/Runtime/Replay/ReplayRunner.cs @@ -120,7 +120,12 @@ public static ReplayExecutionResult Record( scheduler.SchedulingStrategy = CreateStrategy(configuration.SchedulingPolicy, scheduleSeed); scheduler.DecisionLog = decisionLog; - DriveResult drive = Drive(scheduler, scenario, configuration.MaxSteps, cancellationToken); + DriveResult drive = Drive( + scheduler, + listener, + scenario, + configuration.MaxSteps, + cancellationToken); cancellationToken.ThrowIfCancellationRequested(); ReplayArtifact artifact = CreateArtifact( configuration, @@ -160,7 +165,12 @@ public static ReplayExecutionResult Replay( scheduler.ReplayValidator = new SimulationDecisionReplayValidator( new SimulationInMemoryDecisionReplayReader(records)); - DriveResult drive = Drive(scheduler, scenario, maxSteps, cancellationToken); + DriveResult drive = Drive( + scheduler, + listener, + scenario, + maxSteps, + cancellationToken); cancellationToken.ThrowIfCancellationRequested(); if (!drive.IsAborted) { @@ -215,6 +225,7 @@ private static ISimulationSchedulingStrategy CreateStrategy(ReplaySchedulingPoli private static DriveResult Drive( SimulationScheduler scheduler, + ReplayOperationListener listener, Action scenario, int maxSteps, CancellationToken cancellationToken) @@ -229,9 +240,7 @@ private static DriveResult Drive( if (scheduler.RunStepForPump(cancellationToken)) { steps++; - if (scheduler.FirstRace is not null || - scheduler.CaptureStatus().Any(static status => - status.State == SimulationOperationState.Faulted)) + if (scheduler.FirstRace is not null || listener.FirstFailure is not null) { break; } From a4e2e846b69b1b3965dc3461028979fc404a7b77 Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Wed, 29 Jul 2026 16:19:31 -0700 Subject: [PATCH 4/7] Optimize virtual timer advancement Benchmark timer batches and replace LINQ plus hash-set materialization with stable in-place partitioning and direct due-list sorting. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 58f51472-51b1-4cff-9ed4-e26460b5163c --- benchmarks/Clockwork.Benchmarks/Program.cs | 15 ++++ .../SimulationTimerQueueBenchmarks.cs | 77 +++++++++++++++++++ .../Resources/SimulationTimerQueue.cs | 61 ++++++++++----- .../Resources/SimulationTimerQueueTests.cs | 37 +++++++++ 4 files changed, 171 insertions(+), 19 deletions(-) create mode 100644 benchmarks/Clockwork.Benchmarks/SimulationTimerQueueBenchmarks.cs create mode 100644 tests/Clockwork.Runtime.Tests/Scheduling/Resources/SimulationTimerQueueTests.cs diff --git a/benchmarks/Clockwork.Benchmarks/Program.cs b/benchmarks/Clockwork.Benchmarks/Program.cs index f1cdc8f..15cb07f 100644 --- a/benchmarks/Clockwork.Benchmarks/Program.cs +++ b/benchmarks/Clockwork.Benchmarks/Program.cs @@ -46,4 +46,19 @@ return; } +if (args is ["--trace-timers", var timerIterationCount, var timerCount, var timerMode] + && int.TryParse(timerIterationCount, out var timerIterations) + && timerIterations > 0 + && int.TryParse(timerCount, out var timers) + && timers > 0 + && timerMode is "all" or "individual") +{ + Console.WriteLine( + SimulationTimerQueueBenchmarks.RunTrace( + timerIterations, + timers, + advanceIndividually: timerMode == "individual")); + return; +} + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); diff --git a/benchmarks/Clockwork.Benchmarks/SimulationTimerQueueBenchmarks.cs b/benchmarks/Clockwork.Benchmarks/SimulationTimerQueueBenchmarks.cs new file mode 100644 index 0000000..cf9ed2f --- /dev/null +++ b/benchmarks/Clockwork.Benchmarks/SimulationTimerQueueBenchmarks.cs @@ -0,0 +1,77 @@ +using BenchmarkDotNet.Attributes; +using Clockwork.Runtime.Scheduling.Resources; + +namespace Clockwork.Benchmarks; + +[MemoryDiagnoser] +public class SimulationTimerQueueBenchmarks +{ + private int _initialAdvanced = 1; + + [Benchmark] + [Arguments(32)] + [Arguments(1024)] + [Arguments(16384)] + public int AdvanceAllAtOnce(int timerCount) => + _initialAdvanced + RunAdvanceAllAtOnce(timerCount); + + [Benchmark] + [Arguments(32)] + [Arguments(256)] + [Arguments(1024)] + public int AdvanceIndividually(int timerCount) => + _initialAdvanced + RunAdvanceIndividually(timerCount); + + public static int RunTrace(int iterationCount, int timerCount, bool advanceIndividually) + { + var advanced = 0; + for (var iteration = 0; iteration < iterationCount; iteration++) + { + advanced += advanceIndividually + ? RunAdvanceIndividually(timerCount) + : RunAdvanceAllAtOnce(timerCount); + } + + return advanced; + } + + private static int RunAdvanceAllAtOnce(int timerCount) + { + var queue = new SimulationTimerQueue(); + for (var timer = timerCount; timer > 0; timer--) + { + queue.Schedule(TimeSpan.FromTicks(timer), onElapsed: null); + } + + IReadOnlyList due = queue.AdvanceTo(TimeSpan.MaxValue); + ValidateCount(timerCount, due.Count); + return due.Count; + } + + private static int RunAdvanceIndividually(int timerCount) + { + var queue = new SimulationTimerQueue(); + for (var timer = 1; timer <= timerCount; timer++) + { + queue.Schedule(TimeSpan.FromTicks(timer), onElapsed: null); + } + + var advanced = 0; + while (queue.HasPending) + { + advanced += queue.AdvanceToNextDue().Count; + } + + ValidateCount(timerCount, advanced); + return advanced; + } + + private static void ValidateCount(int expected, int actual) + { + if (actual != expected) + { + throw new InvalidOperationException( + $"Expected {expected} timers to advance but observed {actual}."); + } + } +} diff --git a/src/Clockwork/Runtime/Scheduling/Resources/SimulationTimerQueue.cs b/src/Clockwork/Runtime/Scheduling/Resources/SimulationTimerQueue.cs index 0dc1472..5938cdc 100644 --- a/src/Clockwork/Runtime/Scheduling/Resources/SimulationTimerQueue.cs +++ b/src/Clockwork/Runtime/Scheduling/Resources/SimulationTimerQueue.cs @@ -133,22 +133,23 @@ public SimulationTimerRegistration Schedule( /// public IReadOnlyList AdvanceToNextDue() { - _pending.RemoveAll(static r => r.IsCanceled); - if (_pending.Count == 0) - { - return []; - } - - var earliest = _pending[0].DueTime; + TimeSpan? earliest = null; foreach (var registration in _pending) { - if (registration.DueTime < earliest) + if (!registration.IsCanceled && + (earliest is null || registration.DueTime < earliest.Value)) { earliest = registration.DueTime; } } - return AdvanceTo(earliest); + if (earliest is null) + { + _pending.Clear(); + return []; + } + + return AdvanceTo(earliest.Value); } public IReadOnlyList AdvanceTo(TimeSpan target) @@ -159,19 +160,41 @@ public IReadOnlyList AdvanceTo(TimeSpan target) } _now = target; - _pending.RemoveAll(static r => r.IsCanceled); - var due = _pending - .Where(registration => registration.DueTime <= target) - .OrderBy(static registration => registration.DueTime) - .ThenBy(static registration => registration.Sequence) - .ToArray(); - if (due.Length > 0) + List? due = null; + var retainedCount = 0; + var originalCount = _pending.Count; + for (var index = 0; index < originalCount; index++) + { + ISimulationTimerEntry registration = _pending[index]; + if (registration.IsCanceled) + { + continue; + } + + if (registration.DueTime <= target) + { + (due ??= []).Add(registration); + continue; + } + + _pending[retainedCount++] = registration; + } + + if (retainedCount < originalCount) + { + _pending.RemoveRange(retainedCount, originalCount - retainedCount); + } + + if (due is { Count: > 1 }) { - var dueSet = due.ToHashSet(ReferenceEqualityComparer.Instance); - _pending.RemoveAll(dueSet.Contains); + due.Sort(static (left, right) => + { + var byTime = left.DueTime.CompareTo(right.DueTime); + return byTime != 0 ? byTime : left.Sequence.CompareTo(right.Sequence); + }); } - return due; + return due ?? []; } /// diff --git a/tests/Clockwork.Runtime.Tests/Scheduling/Resources/SimulationTimerQueueTests.cs b/tests/Clockwork.Runtime.Tests/Scheduling/Resources/SimulationTimerQueueTests.cs new file mode 100644 index 0000000..4812440 --- /dev/null +++ b/tests/Clockwork.Runtime.Tests/Scheduling/Resources/SimulationTimerQueueTests.cs @@ -0,0 +1,37 @@ +using Clockwork.Runtime.Scheduling.Resources; + +namespace Clockwork.Runtime.Tests.Scheduling.Resources; + +public sealed class SimulationTimerQueueTests +{ + [Fact] + public void AdvanceToOrdersDueTimersAndRetainsFutureTimers() + { + var queue = new SimulationTimerQueue(); + SimulationTimerRegistration late = queue.Schedule(TimeSpan.FromTicks(3), onElapsed: null); + SimulationTimerRegistration first = queue.Schedule(TimeSpan.FromTicks(1), onElapsed: null); + SimulationTimerRegistration second = queue.Schedule(TimeSpan.FromTicks(1), onElapsed: null); + SimulationTimerRegistration future = queue.Schedule(TimeSpan.FromTicks(5), onElapsed: null); + + IReadOnlyList due = queue.AdvanceTo(TimeSpan.FromTicks(3)); + + Assert.Equal([first, second, late], due); + Assert.Equal(1, queue.PendingCount); + Assert.Equal(future.DueTime, queue.NextDueTime()); + } + + [Fact] + public void AdvanceToNextDuePurgesCanceledTimers() + { + var queue = new SimulationTimerQueue(); + SimulationTimerRegistration canceled = queue.Schedule(TimeSpan.FromTicks(1), onElapsed: null); + SimulationTimerRegistration live = queue.Schedule(TimeSpan.FromTicks(2), onElapsed: null); + canceled.Cancel(); + + IReadOnlyList due = queue.AdvanceToNextDue(); + + Assert.Equal([live], due); + Assert.Equal(TimeSpan.FromTicks(2), queue.Now); + Assert.False(queue.HasPending); + } +} From d32cb683cc29065a07694cc39c09f02bbc59a5a0 Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Wed, 29 Jul 2026 16:23:31 -0700 Subject: [PATCH 5/7] Defer race access diagnostics Benchmark race-tracker access overhead and construct public reports plus synchronization labels only when a conflict is detected. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 58f51472-51b1-4cff-9ed4-e26460b5163c --- .../RaceTrackerBenchmarks.cs | 54 +++++++++++++++++++ src/Clockwork/Runtime/Racing/RaceTracker.cs | 47 +++++++++++----- 2 files changed, 89 insertions(+), 12 deletions(-) create mode 100644 benchmarks/Clockwork.Benchmarks/RaceTrackerBenchmarks.cs diff --git a/benchmarks/Clockwork.Benchmarks/RaceTrackerBenchmarks.cs b/benchmarks/Clockwork.Benchmarks/RaceTrackerBenchmarks.cs new file mode 100644 index 0000000..053f8e7 --- /dev/null +++ b/benchmarks/Clockwork.Benchmarks/RaceTrackerBenchmarks.cs @@ -0,0 +1,54 @@ +using BenchmarkDotNet.Attributes; +using System.Diagnostics.CodeAnalysis; +using Clockwork.Runtime.Execution; +using Clockwork.Runtime.Racing; +using Clockwork.Runtime.Scheduling; + +namespace Clockwork.Benchmarks; + +[MemoryDiagnoser] +[SuppressMessage( + "Design", + "CA1001:Types that own disposable fields should be disposable", + Justification = "BenchmarkDotNet invokes GlobalCleanup after each benchmark case.")] +public class RaceTrackerBenchmarks +{ + private static readonly RaceMemoryLocation s_location = + new(RaceMemoryLocationKind.StaticField, 0, "Benchmark::Value"); + + private static readonly RaceSourceLocation s_source = + new("Benchmark.Write", 0, SourceFile: null, SourceLine: -1); + + private SimulationScheduler _scheduler = null!; + private SimulationOperation _operation = null!; + private RaceTracker _tracker = null!; + + [Params(0, 4, 16)] + public int HeldSynchronizationCount { get; set; } + + [GlobalSetup] + public void Setup() + { + _scheduler = new SimulationScheduler( + new SimulationRuntimeIdentity(Guid.Empty, Seed: 1, Description: "benchmark")); + _operation = _scheduler.Register("benchmark", static () => { }); + _tracker = new RaceTracker(); + _tracker.RegisterOperation(_operation, parent: null); + for (var index = 0; index < HeldSynchronizationCount; index++) + { + _tracker.EnterSynchronization(_operation, new object()); + } + } + + [GlobalCleanup] + public void Cleanup() => _scheduler.Dispose(); + + [Benchmark] + public void RecordWrite() => + _tracker.RecordAccess( + _operation, + RaceAccessKind.Write, + s_location, + s_source, + Array.Empty()); +} diff --git a/src/Clockwork/Runtime/Racing/RaceTracker.cs b/src/Clockwork/Runtime/Racing/RaceTracker.cs index 95c85bf..299192c 100644 --- a/src/Clockwork/Runtime/Racing/RaceTracker.cs +++ b/src/Clockwork/Runtime/Racing/RaceTracker.cs @@ -1,4 +1,3 @@ -using System.Collections.Immutable; using System.Runtime.CompilerServices; using Clockwork.Runtime.Scheduling; @@ -27,9 +26,12 @@ private sealed class AccessState } private sealed record TrackedAccess( - RaceAccessRecord Public, + SimulationOperationId OperationId, + RaceAccessKind Kind, + RaceMemoryLocation Location, + RaceSourceLocation Source, Dictionary Clock, - ImmutableArray HeldSynchronization); + long[] HeldSynchronization); private readonly ConditionalWeakTable _objectIdentities = new(); private readonly ConditionalWeakTable _lockIdentities = new(); @@ -102,14 +104,14 @@ public void RecordAccess( OperationState operationState = StateOf(operation); Tick(operation.Id.Value, operationState.Clock); - var held = operationState.HeldSynchronization.Keys.Order().ToImmutableArray(); - var publicAccess = new RaceAccessRecord( + var held = CaptureHeldSynchronization(operationState); + var access = new TrackedAccess( operation.Id, kind, location, source, - [.. held.Select(static id => $"sync#{id}")]); - var access = new TrackedAccess(publicAccess, new Dictionary(operationState.Clock), held); + new Dictionary(operationState.Clock), + held); if (!_locations.TryGetValue(location, out AccessState? locationState)) { locationState = new AccessState(); @@ -224,7 +226,7 @@ private void DetectConflict( { if (FirstRace is not null || previous is null || - previous.Public.OperationId == current.Public.OperationId || + previous.OperationId == current.OperationId || HappensBefore(previous.Clock, current.Clock) || HasCommonSynchronization(previous.HeldSynchronization, current.HeldSynchronization)) { @@ -233,12 +235,33 @@ previous is null || FirstRace = new RaceReport { - FirstAccess = previous.Public, - SecondAccess = current.Public, + FirstAccess = CreatePublicAccess(previous), + SecondAccess = CreatePublicAccess(current), ScheduleTrace = [.. trace], }; } + private static long[] CaptureHeldSynchronization(OperationState state) + { + if (state.HeldSynchronization.Count == 0) + { + return []; + } + + var held = new long[state.HeldSynchronization.Count]; + state.HeldSynchronization.Keys.CopyTo(held, 0); + Array.Sort(held); + return held; + } + + private static RaceAccessRecord CreatePublicAccess(TrackedAccess access) => + new( + access.OperationId, + access.Kind, + access.Location, + access.Source, + [.. access.HeldSynchronization.Select(static id => $"sync#{id}")]); + private OperationState StateOf(SimulationOperation operation) => _operations.TryGetValue(operation.Id.Value, out OperationState? state) ? state @@ -267,8 +290,8 @@ private static bool HappensBefore( } private static bool HasCommonSynchronization( - ImmutableArray first, - ImmutableArray second) + long[] first, + long[] second) { int left = 0; int right = 0; From 854d147421fd321590d67daa69dc2f17a1e6ac56 Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Wed, 29 Jul 2026 16:25:41 -0700 Subject: [PATCH 6/7] Reduce scheduling decision formatting Write candidate operation IDs directly into one exact-sized metadata string, eliminating per-candidate arrays and strings during decision logging. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 58f51472-51b1-4cff-9ed4-e26460b5163c --- .../Runtime/Scheduling/SimulationScheduler.cs | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/src/Clockwork/Runtime/Scheduling/SimulationScheduler.cs b/src/Clockwork/Runtime/Scheduling/SimulationScheduler.cs index 495f7af..ebc219d 100644 --- a/src/Clockwork/Runtime/Scheduling/SimulationScheduler.cs +++ b/src/Clockwork/Runtime/Scheduling/SimulationScheduler.cs @@ -2626,13 +2626,39 @@ private void RecordWaitResolution(SimulationResourceWaiter waiter, SimulationWai private static string FormatCandidateIds(List runnable) { - var ids = new string[runnable.Count]; - for (var i = 0; i < runnable.Count; i++) + var length = runnable.Count - 1; + Span buffer = stackalloc char[20]; + for (var index = 0; index < runnable.Count; index++) { - ids[i] = FormatOperationId(runnable[i].Id); + bool formatted = runnable[index].Id.Value.TryFormat( + buffer, + out var written, + provider: CultureInfo.InvariantCulture); + Debug.Assert(formatted); + length += written; } - return string.Join(",", ids); + return string.Create( + length, + runnable, + static (destination, operations) => + { + var offset = 0; + for (var index = 0; index < operations.Count; index++) + { + if (index > 0) + { + destination[offset++] = ','; + } + + bool formatted = operations[index].Id.Value.TryFormat( + destination[offset..], + out var written, + provider: CultureInfo.InvariantCulture); + Debug.Assert(formatted); + offset += written; + } + }); } private static string FormatOperationId(SimulationOperationId id) => From 5737801e8c72c6a7873e890441e84f53acfb2ddd Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Wed, 29 Jul 2026 17:02:08 -0700 Subject: [PATCH 7/7] Optimize scheduling without decision logging Keep decision capture opt-in, benchmark logged and unlogged dispatch side by side, and select default round-robin work in one cancellation-safe scan. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 58f51472-51b1-4cff-9ed4-e26460b5163c --- benchmarks/Clockwork.Benchmarks/Program.cs | 12 +++++ .../SchedulerBenchmarkWorkload.cs | 46 +++++++++++++--- .../SchedulerDecisionLogBenchmarks.cs | 54 +++++++++++++++++++ .../SchedulerReadinessBenchmarks.cs | 9 +++- .../Runtime/Scheduling/SimulationScheduler.cs | 42 +++++++++------ .../Scheduling/SimulationSchedulerTests.cs | 12 +++++ 6 files changed, 151 insertions(+), 24 deletions(-) create mode 100644 benchmarks/Clockwork.Benchmarks/SchedulerDecisionLogBenchmarks.cs diff --git a/benchmarks/Clockwork.Benchmarks/Program.cs b/benchmarks/Clockwork.Benchmarks/Program.cs index 15cb07f..57c2cfb 100644 --- a/benchmarks/Clockwork.Benchmarks/Program.cs +++ b/benchmarks/Clockwork.Benchmarks/Program.cs @@ -61,4 +61,16 @@ return; } +if (args is ["--trace-decisions", var decisionIterationCount, var decisionMode] + && int.TryParse(decisionIterationCount, out var decisionIterations) + && decisionIterations > 0 + && decisionMode is "none" or "log") +{ + Console.WriteLine( + SchedulerDecisionLogBenchmarks.RunTrace( + decisionIterations, + captureDecisions: decisionMode == "log")); + return; +} + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); diff --git a/benchmarks/Clockwork.Benchmarks/SchedulerBenchmarkWorkload.cs b/benchmarks/Clockwork.Benchmarks/SchedulerBenchmarkWorkload.cs index 7c6e93c..a43775f 100644 --- a/benchmarks/Clockwork.Benchmarks/SchedulerBenchmarkWorkload.cs +++ b/benchmarks/Clockwork.Benchmarks/SchedulerBenchmarkWorkload.cs @@ -1,4 +1,5 @@ using Clockwork.Runtime.Execution; +using Clockwork.Runtime.Decisions; using Clockwork.Runtime.Scheduling; namespace Clockwork.Benchmarks; @@ -25,9 +26,11 @@ public static int RunDirect( public static int RunScheduler( int operationCount, int stepsPerOperation, - int initialCompletedSteps) + int initialCompletedSteps, + ISimulationDecisionLog? decisionLog = null) { using var scheduler = CreateScheduler(); + scheduler.DecisionLog = decisionLog; var workload = new YieldingWorkload( scheduler, stepsPerOperation, @@ -53,7 +56,28 @@ public static int RunScheduler( public static int RunWithPendingReadiness( int pendingWaitCount, int dispatchCount, - int initialCompletedSteps) + int initialCompletedSteps) => + RunWithPendingOperations( + pendingWaitCount, + dispatchCount, + initialCompletedSteps, + useReadinessWaits: true); + + public static int RunWithCreatedOperations( + int pendingOperationCount, + int dispatchCount, + int initialCompletedSteps) => + RunWithPendingOperations( + pendingOperationCount, + dispatchCount, + initialCompletedSteps, + useReadinessWaits: false); + + private static int RunWithPendingOperations( + int pendingOperationCount, + int dispatchCount, + int initialCompletedSteps, + bool useReadinessWaits) { using var scheduler = CreateScheduler(); var workload = new YieldingWorkload( @@ -61,14 +85,20 @@ public static int RunWithPendingReadiness( dispatchCount, initialCompletedSteps); - // Register the runnable operation first so round-robin selection remains O(1). This isolates - // the cost of polling the pending readiness set on every dispatch. + // Register the runnable operation first so both benchmark variants have identical selection order. scheduler.Schedule("benchmark", workload.Run); - for (var wait = 0; wait < pendingWaitCount; wait++) + for (var operation = 0; operation < pendingOperationCount; operation++) { - scheduler.ScheduleWhenReady( - static () => false, - static () => throw new InvalidOperationException("A pending readiness callback ran unexpectedly.")); + if (useReadinessWaits) + { + scheduler.ScheduleWhenReady( + static () => false, + static () => throw new InvalidOperationException("A pending readiness callback ran unexpectedly.")); + } + else + { + scheduler.Register("pending", static () => { }); + } } var dispatched = scheduler.Drain(CancellationToken.None); diff --git a/benchmarks/Clockwork.Benchmarks/SchedulerDecisionLogBenchmarks.cs b/benchmarks/Clockwork.Benchmarks/SchedulerDecisionLogBenchmarks.cs new file mode 100644 index 0000000..9ff757c --- /dev/null +++ b/benchmarks/Clockwork.Benchmarks/SchedulerDecisionLogBenchmarks.cs @@ -0,0 +1,54 @@ +using BenchmarkDotNet.Attributes; +using Clockwork.Runtime.Decisions; + +namespace Clockwork.Benchmarks; + +[MemoryDiagnoser] +public class SchedulerDecisionLogBenchmarks +{ + private const int OperationCount = 4; + private const int DispatchCount = 4096; + private const int StepsPerOperation = DispatchCount / OperationCount; + private int _initialCompletedSteps = 1; + + [Benchmark(Baseline = true, OperationsPerInvoke = DispatchCount)] + public int WithoutDecisionLog() => + SchedulerBenchmarkWorkload.RunScheduler( + OperationCount, + StepsPerOperation, + _initialCompletedSteps); + + [Benchmark(OperationsPerInvoke = DispatchCount)] + public int WithDecisionLog() + { + var log = new SimulationDecisionLog(); + int completed = SchedulerBenchmarkWorkload.RunScheduler( + OperationCount, + StepsPerOperation, + _initialCompletedSteps, + log); + if (log.Records.Count != DispatchCount - 1) + { + throw new InvalidOperationException( + $"Expected {DispatchCount - 1} scheduling decisions but observed {log.Records.Count}."); + } + + return completed; + } + + public static int RunTrace(int iterationCount, bool captureDecisions) + { + var completed = 0; + for (var iteration = 0; iteration < iterationCount; iteration++) + { + var log = captureDecisions ? new SimulationDecisionLog() : null; + completed += SchedulerBenchmarkWorkload.RunScheduler( + OperationCount, + StepsPerOperation, + initialCompletedSteps: 0, + log); + } + + return completed; + } +} diff --git a/benchmarks/Clockwork.Benchmarks/SchedulerReadinessBenchmarks.cs b/benchmarks/Clockwork.Benchmarks/SchedulerReadinessBenchmarks.cs index 9205fb6..eeb92ef 100644 --- a/benchmarks/Clockwork.Benchmarks/SchedulerReadinessBenchmarks.cs +++ b/benchmarks/Clockwork.Benchmarks/SchedulerReadinessBenchmarks.cs @@ -10,8 +10,15 @@ public class SchedulerReadinessBenchmarks [Params(0, 1, 16, 128)] public int PendingWaitCount { get; set; } + [Benchmark(Baseline = true, OperationsPerInvoke = DispatchCount)] + public int CreatedOperationsOnly() => + SchedulerBenchmarkWorkload.RunWithCreatedOperations( + PendingWaitCount, + DispatchCount, + initialCompletedSteps: 1); + [Benchmark(OperationsPerInvoke = DispatchCount)] - public int DeterministicScheduler() => + public int PendingReadiness() => SchedulerBenchmarkWorkload.RunWithPendingReadiness( PendingWaitCount, DispatchCount, diff --git a/src/Clockwork/Runtime/Scheduling/SimulationScheduler.cs b/src/Clockwork/Runtime/Scheduling/SimulationScheduler.cs index ebc219d..c0b9edf 100644 --- a/src/Clockwork/Runtime/Scheduling/SimulationScheduler.cs +++ b/src/Clockwork/Runtime/Scheduling/SimulationScheduler.cs @@ -916,12 +916,23 @@ private bool RunStepCore( "The scheduler is already being driven by another thread. RunStep/Drain must be driven by a single controlling thread at a time."); } - var hasRunnable = HasRunnableUnderLock(); + var useRoundRobinFastPath = CanSelectRoundRobinWithoutSnapshot(); + SimulationOperation? next = useRoundRobinFastPath + ? FindRoundRobinRunnableUnderLock() + : null; + var hasRunnable = useRoundRobinFastPath + ? next is not null + : HasRunnableUnderLock(); if (!hasRunnable && !_clock.HasPending && HasResumableDeadlockedSynchronousWaitUnderLock()) { cancellationToken.ThrowIfCancellationRequested(); resumedDeadlock = ResumeDeadlockedSynchronousWaitUnderLock(); - hasRunnable = HasRunnableUnderLock(); + next = useRoundRobinFastPath + ? FindRoundRobinRunnableUnderLock() + : null; + hasRunnable = useRoundRobinFastPath + ? next is not null + : HasRunnableUnderLock(); } if (!hasRunnable) @@ -930,7 +941,16 @@ private bool RunStepCore( } cancellationToken.ThrowIfCancellationRequested(); - operation = SelectRunnable() ?? throw new UnreachableException(); + if (useRoundRobinFastPath) + { + operation = next ?? throw new UnreachableException(); + _lastSelected = operation.Id; + } + else + { + operation = SelectRunnable() ?? throw new UnreachableException(); + } + operation.ApplyTransition(SimulationOperationState.Running); _dispatchSequence++; _current = operation; @@ -2347,11 +2367,6 @@ public void Dispose() private SimulationOperation? SelectRunnable() { - if (_strategy is RoundRobinSchedulingStrategy && _decisionLog is null && _replayValidator is null) - { - return SelectRoundRobinRunnable(); - } - // Custom and instrumented strategies receive a stable snapshot which they may retain. List? runnable = null; for (var index = 0; index < _activeOperations.Count; index++) @@ -2387,7 +2402,10 @@ public void Dispose() return chosen; } - private SimulationOperation? SelectRoundRobinRunnable() + private bool CanSelectRoundRobinWithoutSnapshot() => + _strategy is RoundRobinSchedulingStrategy && _decisionLog is null && _replayValidator is null; + + private SimulationOperation? FindRoundRobinRunnableUnderLock() { SimulationOperation? wrapTarget = null; for (var index = 0; index < _activeOperations.Count; index++) @@ -2401,16 +2419,10 @@ public void Dispose() wrapTarget ??= operation; if (operation.Id > _lastSelected) { - _lastSelected = operation.Id; return operation; } } - if (wrapTarget is not null) - { - _lastSelected = wrapTarget.Id; - } - return wrapTarget; } diff --git a/tests/Clockwork.Runtime.Tests/Scheduling/SimulationSchedulerTests.cs b/tests/Clockwork.Runtime.Tests/Scheduling/SimulationSchedulerTests.cs index 34432b4..a344ceb 100644 --- a/tests/Clockwork.Runtime.Tests/Scheduling/SimulationSchedulerTests.cs +++ b/tests/Clockwork.Runtime.Tests/Scheduling/SimulationSchedulerTests.cs @@ -49,6 +49,18 @@ public void DrainRunsAdmittedOperationToCompletion() Assert.Null(op.TerminalException); } + [Fact] + public void DecisionLoggingIsOptIn() + { + using var scheduler = SchedulerTestHarness.NewScheduler(); + scheduler.Schedule("first", static () => { }); + scheduler.Schedule("second", static () => { }); + + scheduler.Drain(TestContext.Current.CancellationToken); + + Assert.Null(scheduler.DecisionLog); + } + [Fact] public void DrainCanCancelAnOperationWhichContinuesYielding() {