From 2c3e19e5941535d02ce657e5fa7825d3d8e1d095 Mon Sep 17 00:00:00 2001 From: Reuben Bond Date: Wed, 29 Jul 2026 13:59:53 -0700 Subject: [PATCH] Optimize deterministic scheduler Add a BenchmarkDotNet workload and eliminate per-step scheduler bookkeeping allocations while preserving instrumented strategy behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 58f51472-51b1-4cff-9ed4-e26460b5163c --- Clockwork.slnx | 3 + Directory.Packages.props | 3 +- .../Clockwork.Benchmarks.csproj | 18 +++ .../DeterministicSchedulerBenchmarks.cs | 79 +++++++++++++ benchmarks/Clockwork.Benchmarks/Program.cs | 12 ++ .../Runtime/Scheduling/SimulationScheduler.cs | 105 ++++++++++++------ .../Strategies/PrioritySchedulingStrategy.cs | 27 +++-- .../Strategies/ReplaySchedulingStrategy.cs | 3 +- .../RoundRobinSchedulingStrategy.cs | 9 +- .../Scheduling/SimulationSchedulerTests.cs | 16 +++ .../SimulationSchedulingStrategyTests.cs | 48 ++++++++ 11 files changed, 267 insertions(+), 56 deletions(-) create mode 100644 benchmarks/Clockwork.Benchmarks/Clockwork.Benchmarks.csproj create mode 100644 benchmarks/Clockwork.Benchmarks/DeterministicSchedulerBenchmarks.cs create mode 100644 benchmarks/Clockwork.Benchmarks/Program.cs diff --git a/Clockwork.slnx b/Clockwork.slnx index d0177d0..e450d08 100644 --- a/Clockwork.slnx +++ b/Clockwork.slnx @@ -1,4 +1,7 @@ + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 8351c24..3e2cb6b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,6 +3,7 @@ true + @@ -10,4 +11,4 @@ - + \ No newline at end of file diff --git a/benchmarks/Clockwork.Benchmarks/Clockwork.Benchmarks.csproj b/benchmarks/Clockwork.Benchmarks/Clockwork.Benchmarks.csproj new file mode 100644 index 0000000..ea58c1d --- /dev/null +++ b/benchmarks/Clockwork.Benchmarks/Clockwork.Benchmarks.csproj @@ -0,0 +1,18 @@ + + + + + + + + + + + + Exe + net10.0 + enable + enable + + + diff --git a/benchmarks/Clockwork.Benchmarks/DeterministicSchedulerBenchmarks.cs b/benchmarks/Clockwork.Benchmarks/DeterministicSchedulerBenchmarks.cs new file mode 100644 index 0000000..e80a24a --- /dev/null +++ b/benchmarks/Clockwork.Benchmarks/DeterministicSchedulerBenchmarks.cs @@ -0,0 +1,79 @@ +using BenchmarkDotNet.Attributes; +using Clockwork.Runtime.Execution; +using Clockwork.Runtime.Scheduling; + +namespace Clockwork.Benchmarks; + +[MemoryDiagnoser] +public class DeterministicSchedulerBenchmarks +{ + private const int OperationCount = 4; + private const int StepsPerOperation = 32; + private const int SchedulingPointCount = OperationCount * StepsPerOperation; + private int _initialCompletedSteps = 1; + + [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; + } + + [Benchmark(OperationsPerInvoke = SchedulingPointCount)] + public int DeterministicScheduler() => RunScheduler(_initialCompletedSteps); + + public static int RunTrace(int iterationCount) + { + var completed = 0; + for (var iteration = 0; iteration < iterationCount; iteration++) + { + completed += RunScheduler(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(); + 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 new file mode 100644 index 0000000..8119f99 --- /dev/null +++ b/benchmarks/Clockwork.Benchmarks/Program.cs @@ -0,0 +1,12 @@ +using BenchmarkDotNet.Running; +using Clockwork.Benchmarks; + +if (args is ["--trace", var iterationCount] + && int.TryParse(iterationCount, out var iterations) + && iterations > 0) +{ + Console.WriteLine(DeterministicSchedulerBenchmarks.RunTrace(iterations)); + return; +} + +BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); diff --git a/src/Clockwork/Runtime/Scheduling/SimulationScheduler.cs b/src/Clockwork/Runtime/Scheduling/SimulationScheduler.cs index 26acfbc..198496f 100644 --- a/src/Clockwork/Runtime/Scheduling/SimulationScheduler.cs +++ b/src/Clockwork/Runtime/Scheduling/SimulationScheduler.cs @@ -57,9 +57,15 @@ public sealed class SimulationScheduler : IDisposable [ThreadStatic] private static SimulationOperation? t_currentOperation; + private static readonly ContextCallback s_runBodyInCapturedContext = static state => + { + var operation = (SimulationOperation)state!; + operation.Scheduler.RunBodyScoped(operation); + }; + private readonly object _gate = new(); private readonly object _transitionPublicationGate = new(); - private readonly SortedDictionary _operations = new(); + private readonly List _operations = []; private readonly SortedDictionary _resources = new(); private readonly HashSet _suspendedNodes = new(StringComparer.Ordinal); private readonly SemaphoreSlim _handback = new(0, 1); @@ -485,7 +491,7 @@ private SimulationOperation RegisterCore( body, capturedContext, priority); - _operations.Add(id, operation); + _operations.Add(operation); _raceTracker.RegisterOperation(operation, parent); } @@ -1085,7 +1091,7 @@ public void ValidateReplayComplete() { lock (_gate) { - foreach (var operation in _operations.Values) + foreach (var operation in _operations) { if (!operation.IsTerminal) { @@ -1202,7 +1208,7 @@ internal bool HasPendingWork(SimulationNodeIdentity node) ArgumentNullException.ThrowIfNull(node); lock (_gate) { - foreach (var operation in _operations.Values) + foreach (var operation in _operations) { if (operation.Node == node && !operation.IsTerminal) { @@ -1235,7 +1241,7 @@ internal void RemovePendingWork(SimulationNodeIdentity node) _readinessWaits.RemoveAt(index); } - operations = _operations.Values + operations = _operations .Where(operation => operation.Node == node && !operation.IsTerminal) .ToArray(); } @@ -1306,7 +1312,7 @@ private int ElapseTimers(IReadOnlyList due) private bool HasRunnableUnderLock() { - foreach (var operation in _operations.Values) + foreach (var operation in _operations) { if (operation.State == SimulationOperationState.Runnable && IsEligibleUnderLock(operation)) { @@ -1904,7 +1910,7 @@ public IReadOnlyList CaptureStatus() lock (_gate) { var result = new List(_operations.Count); - foreach (var operation in _operations.Values) + foreach (var operation in _operations) { result.Add(new SimulationOperationStatus( operation.Id, @@ -1930,7 +1936,7 @@ public int PendingOperationCount lock (_gate) { var count = 0; - foreach (var operation in _operations.Values) + foreach (var operation in _operations) { if (!operation.IsTerminal) { @@ -1999,7 +2005,7 @@ public SimulationDeadlockReport DetectDeadlock() var runnable = 0; var blocked = 0; var nonTerminal = 0; - foreach (var operation in _operations.Values) + foreach (var operation in _operations) { if (!operation.IsTerminal) { @@ -2055,7 +2061,7 @@ public string DescribeLiveness() report = DetectDeadlock(); now = _clock.Now; operations = new List<(SimulationOperationId, SimulationOperationState, string, SimulationPauseReason?)>(_operations.Count); - foreach (var operation in _operations.Values) + foreach (var operation in _operations) { operations.Add((operation.Id, operation.State, operation.WorkDescription, operation.PauseReason)); } @@ -2091,8 +2097,8 @@ private List FindWaitForCyclesUnderLock(Dictionary cycleIds, Dic var waiter = edge.Waiter; entries.Add(new SimulationWaitCycleEntry( id, - _operations[id].WorkDescription, + GetOperation(id).WorkDescription, waiter.Resource.Id, waiter.Resource.Name, edge.OwnerId, @@ -2220,7 +2226,7 @@ public void Dispose() _disposed = true; victims = new List(); registrations = new List(); - foreach (var operation in _operations.Values) + foreach (var operation in _operations) { if (operation.IsTerminal) { @@ -2271,12 +2277,16 @@ public void Dispose() private SimulationOperation? SelectRunnable() { - // Collect the runnable operations in ascending id order (_operations is a SortedDictionary), - // then delegate the choice to the pluggable strategy. The default round-robin strategy - // reproduces the controlled-operation scheduler behavior exactly. + 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; - foreach (var operation in _operations.Values) + for (var index = 0; index < _operations.Count; index++) { + SimulationOperation operation = _operations[index]; if (operation.State != SimulationOperationState.Runnable || !IsEligibleUnderLock(operation)) { continue; @@ -2307,6 +2317,33 @@ public void Dispose() return chosen; } + private SimulationOperation? SelectRoundRobinRunnable() + { + SimulationOperation? wrapTarget = null; + for (var index = 0; index < _operations.Count; index++) + { + SimulationOperation operation = _operations[index]; + if (operation.State != SimulationOperationState.Runnable || !IsEligibleUnderLock(operation)) + { + continue; + } + + wrapTarget ??= operation; + if (operation.Id > _lastSelected) + { + _lastSelected = operation.Id; + return operation; + } + } + + if (wrapTarget is not null) + { + _lastSelected = wrapTarget.Id; + } + + return wrapTarget; + } + private bool IsEligibleUnderLock(SimulationOperation operation) => operation.Node is not { } node || !_suspendedNodes.Contains(node.Address); @@ -2571,7 +2608,7 @@ private void RunBody(SimulationOperation operation) var context = operation.CapturedContext; if (context is not null) { - ExecutionContext.Run(context, static s => ((BodyClosure)s!).Run(), new BodyClosure(this, operation)); + ExecutionContext.Run(context, s_runBodyInCapturedContext, operation); } else { @@ -2728,17 +2765,9 @@ private void WaitForPendingTerminalNotificationUnderLock() } } - private sealed class TransitionPublicationScope(SimulationScheduler scheduler) : IDisposable + private readonly ref struct TransitionPublicationScope(SimulationScheduler scheduler) { - private int _disposed; - - public void Dispose() - { - if (Interlocked.Exchange(ref _disposed, 1) == 0) - { - scheduler.ExitTransitionPublicationScope(); - } - } + public void Dispose() => scheduler.ExitTransitionPublicationScope(); } private static void UnwindParkedThread(SimulationOperation operation) @@ -2781,12 +2810,23 @@ private void ValidateOwnership(SimulationOperation operation) } } + private SimulationOperation GetOperation(SimulationOperationId id) + { + var index = checked((int)(id.Value - 1)); + if ((uint)index >= (uint)_operations.Count || _operations[index].Id != id) + { + throw new SimulationSchedulerException($"Unknown controlled operation id '{id}'."); + } + + return _operations[index]; + } + private SimulationOperation[] SnapshotOperations() { lock (_gate) { var array = new SimulationOperation[_operations.Count]; - _operations.Values.CopyTo(array, 0); + _operations.CopyTo(array, 0); return array; } } @@ -2796,11 +2836,6 @@ private void Notify(SimulationOperation operation, SimulationOperationState stat private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); - private sealed class BodyClosure(SimulationScheduler scheduler, SimulationOperation operation) - { - public void Run() => scheduler.RunBodyScoped(operation); - } - private sealed class ReadinessWait : ISimulationWorkRegistration { private readonly SimulationScheduler _scheduler; diff --git a/src/Clockwork/Runtime/Scheduling/Strategies/PrioritySchedulingStrategy.cs b/src/Clockwork/Runtime/Scheduling/Strategies/PrioritySchedulingStrategy.cs index 25ddb2f..2df8416 100644 --- a/src/Clockwork/Runtime/Scheduling/Strategies/PrioritySchedulingStrategy.cs +++ b/src/Clockwork/Runtime/Scheduling/Strategies/PrioritySchedulingStrategy.cs @@ -9,8 +9,6 @@ namespace Clockwork.Runtime.Scheduling.Strategies; /// internal sealed class PrioritySchedulingStrategy : ISimulationSchedulingStrategy { - private static readonly RoundRobinSchedulingStrategy TieBreak = new(); - /// public string Name => "priority"; @@ -22,26 +20,27 @@ public SimulationOperation ChooseNext(SimulationSchedulingContext context) { ArgumentNullException.ThrowIfNull(context); - var highest = int.MinValue; - foreach (var operation in context.Runnable) + SimulationOperation wrapTarget = context.Runnable[0]; + var highest = wrapTarget.Priority; + SimulationOperation? firstAfterLast = + wrapTarget.Id > context.LastSelected ? wrapTarget : null; + for (var index = 1; index < context.Runnable.Count; index++) { + SimulationOperation operation = context.Runnable[index]; if (operation.Priority > highest) { highest = operation.Priority; + wrapTarget = operation; + firstAfterLast = operation.Id > context.LastSelected ? operation : null; } - } - - // Restrict to the highest-priority band (preserving ascending-id order), then apply - // round-robin within it so the choice is fair and stable. - var topBand = new List(context.Runnable.Count); - foreach (var operation in context.Runnable) - { - if (operation.Priority == highest) + else if (operation.Priority == highest + && firstAfterLast is null + && operation.Id > context.LastSelected) { - topBand.Add(operation); + firstAfterLast = operation; } } - return TieBreak.ChooseNext(new SimulationSchedulingContext(topBand, context.LastSelected)); + return firstAfterLast ?? wrapTarget; } } diff --git a/src/Clockwork/Runtime/Scheduling/Strategies/ReplaySchedulingStrategy.cs b/src/Clockwork/Runtime/Scheduling/Strategies/ReplaySchedulingStrategy.cs index 2c768c6..1c2188a 100644 --- a/src/Clockwork/Runtime/Scheduling/Strategies/ReplaySchedulingStrategy.cs +++ b/src/Clockwork/Runtime/Scheduling/Strategies/ReplaySchedulingStrategy.cs @@ -86,8 +86,9 @@ public SimulationOperation ChooseNext(SimulationSchedulingContext context) var expected = _scheduling[_schedulingIndex++]; LastDecisionSourceId = expected.SourceId; - foreach (var operation in context.Runnable) + for (var index = 0; index < context.Runnable.Count; index++) { + SimulationOperation operation = context.Runnable[index]; if (string.Equals(FormatId(operation.Id), expected.SelectedResult, StringComparison.Ordinal)) { return operation; diff --git a/src/Clockwork/Runtime/Scheduling/Strategies/RoundRobinSchedulingStrategy.cs b/src/Clockwork/Runtime/Scheduling/Strategies/RoundRobinSchedulingStrategy.cs index 695d348..4020edf 100644 --- a/src/Clockwork/Runtime/Scheduling/Strategies/RoundRobinSchedulingStrategy.cs +++ b/src/Clockwork/Runtime/Scheduling/Strategies/RoundRobinSchedulingStrategy.cs @@ -20,17 +20,16 @@ public SimulationOperation ChooseNext(SimulationSchedulingContext context) { ArgumentNullException.ThrowIfNull(context); - SimulationOperation? firstAfterLast = null; - foreach (var operation in context.Runnable) + for (var index = 0; index < context.Runnable.Count; index++) { + SimulationOperation operation = context.Runnable[index]; if (operation.Id > context.LastSelected) { - firstAfterLast = operation; - break; + return operation; } } // Runnable is ascending by id and non-empty, so index 0 is the wrap target. - return firstAfterLast ?? context.Runnable[0]; + return context.Runnable[0]; } } diff --git a/tests/Clockwork.Runtime.Tests/Scheduling/SimulationSchedulerTests.cs b/tests/Clockwork.Runtime.Tests/Scheduling/SimulationSchedulerTests.cs index 1d2189e..fae55a2 100644 --- a/tests/Clockwork.Runtime.Tests/Scheduling/SimulationSchedulerTests.cs +++ b/tests/Clockwork.Runtime.Tests/Scheduling/SimulationSchedulerTests.cs @@ -604,6 +604,22 @@ public void CaptureStatusReturnsOperationsInStableIdOrder() Assert.Equal(SimulationOperationState.Runnable, status[1].State); } + [Fact] + public void CaptureStatusPreservesIdOrderAcrossStorageGrowthAndCompletion() + { + using var scheduler = SchedulerTestHarness.NewScheduler(); + var operations = new SimulationOperation[9]; + for (var index = 0; index < operations.Length; index++) + { + operations[index] = scheduler.Schedule($"operation-{index}", () => { }); + } + + scheduler.Cancel(operations[4]); + scheduler.Drain(); + + Assert.Equal(operations.Select(operation => operation.Id), scheduler.CaptureStatus().Select(status => status.Id)); + } + private static bool SpinUntil(Func condition) => SpinWait.SpinUntil(condition, TimeSpan.FromSeconds(5)); private sealed class BlockingPauseListener(CancellationToken cancellationToken) : ISimulationOperationListener diff --git a/tests/Clockwork.Runtime.Tests/Scheduling/Strategies/SimulationSchedulingStrategyTests.cs b/tests/Clockwork.Runtime.Tests/Scheduling/Strategies/SimulationSchedulingStrategyTests.cs index 68aac45..c95f39a 100644 --- a/tests/Clockwork.Runtime.Tests/Scheduling/Strategies/SimulationSchedulingStrategyTests.cs +++ b/tests/Clockwork.Runtime.Tests/Scheduling/Strategies/SimulationSchedulingStrategyTests.cs @@ -26,6 +26,22 @@ public void RoundRobinIsTheDefaultAndRotatesAcrossRunnableOperations() Assert.Equal([1L, 2L, 3L, 1L, 2L, 3L, 1L, 2L, 3L], order); } + [Fact] + public void RoundRobinFastPathMatchesInstrumentedSelection() + { + using var fast = SchedulerTestHarness.NewScheduler(); + using var instrumented = SchedulerTestHarness.NewScheduler(); + var log = new SimulationDecisionLog(); + instrumented.DecisionLog = log; + + var fastOrder = DriveThreeYieldingOperations(fast); + var instrumentedOrder = DriveThreeYieldingOperations(instrumented); + + Assert.Equal(fastOrder, instrumentedOrder); + Assert.NotEmpty(log.Records); + Assert.All(log.Records, record => Assert.Equal("round-robin", record.SourceId)); + } + [Fact] public void FifoAlwaysRunsTheSmallestRunnableId() { @@ -72,6 +88,38 @@ public void PriorityRotatesFairlyWithinAnEqualPriorityBand() Assert.Equal([1L, 2L, 3L, 1L, 2L, 3L, 1L, 2L, 3L], order); } + [Fact] + public void PriorityRotatesAcrossANoncontiguousHighestPriorityBand() + { + using var scheduler = SchedulerTestHarness.NewScheduler(); + scheduler.SchedulingStrategy = SimulationSchedulingStrategies.Priority(); + + var order = new List(); + ScheduleYielding(scheduler, order, priority: 10); + ScheduleYielding(scheduler, order, priority: 0); + ScheduleYielding(scheduler, order, priority: 10); + + scheduler.Drain(); + + Assert.Equal([1L, 3L, 1L, 3L, 1L, 3L, 2L, 2L, 2L], order); + } + + [Fact] + public void PriorityRotatesWhenAllPrioritiesAreMinimumValue() + { + using var scheduler = SchedulerTestHarness.NewScheduler(); + scheduler.SchedulingStrategy = SimulationSchedulingStrategies.Priority(); + + var order = new List(); + ScheduleYielding(scheduler, order, priority: int.MinValue); + ScheduleYielding(scheduler, order, priority: int.MinValue); + ScheduleYielding(scheduler, order, priority: int.MinValue); + + scheduler.Drain(); + + Assert.Equal([1L, 2L, 3L, 1L, 2L, 3L, 1L, 2L, 3L], order); + } + [Fact] public void SameSeedProducesTheSameSchedule() {