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 9532a8f..7178082 100644 --- a/src/Clockwork/Runtime/Scheduling/SimulationScheduler.cs +++ b/src/Clockwork/Runtime/Scheduling/SimulationScheduler.cs @@ -57,10 +57,16 @@ 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 SortedDictionary _activeOperations = new(); + private readonly List _operations = []; + private readonly List _activeOperations = []; private readonly SortedDictionary _resources = new(); private readonly HashSet _suspendedNodes = new(StringComparer.Ordinal); private readonly SemaphoreSlim _handback = new(0, 1); @@ -486,8 +492,8 @@ private SimulationOperation RegisterCore( body, capturedContext, priority); - _operations.Add(id, operation); - _activeOperations.Add(id, operation); + _operations.Add(operation); + _activeOperations.Add(operation); _raceTracker.RegisterOperation(operation, parent); } @@ -1201,7 +1207,7 @@ internal bool HasPendingWork(SimulationNodeIdentity node) ArgumentNullException.ThrowIfNull(node); lock (_gate) { - foreach (var operation in _activeOperations.Values) + foreach (var operation in _activeOperations) { if (operation.Node == node) { @@ -1234,7 +1240,7 @@ internal void RemovePendingWork(SimulationNodeIdentity node) _readinessWaits.RemoveAt(index); } - operations = _activeOperations.Values + operations = _activeOperations .Where(operation => operation.Node == node) .ToArray(); } @@ -1305,7 +1311,7 @@ private int ElapseTimers(IReadOnlyList due) private bool HasRunnableUnderLock() { - foreach (var operation in _activeOperations.Values) + foreach (var operation in _activeOperations) { if (operation.State == SimulationOperationState.Runnable && IsEligibleUnderLock(operation)) { @@ -1382,7 +1388,7 @@ public void Cancel(SimulationOperation operation) operation.RequestTermination(); registration = DetachWaiterUnderLock(operation); operation.ApplyTransition(SimulationOperationState.Canceled); - _activeOperations.Remove(operation.Id); + _activeOperations.Remove(operation); needsUnwind = operation.Thread is not null; } @@ -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, @@ -2001,7 +2007,7 @@ public SimulationDeadlockReport DetectDeadlock() var runnable = 0; var blocked = 0; var nonTerminal = 0; - foreach (var operation in _activeOperations.Values) + foreach (var operation in _activeOperations) { nonTerminal++; @@ -2054,7 +2060,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)); } @@ -2090,8 +2096,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, @@ -2219,7 +2225,7 @@ public void Dispose() _disposed = true; victims = new List(); registrations = new List(); - foreach (var operation in _activeOperations.Values) + foreach (var operation in _activeOperations) { operation.RequestTermination(); @@ -2267,12 +2273,16 @@ public void Dispose() private SimulationOperation? SelectRunnable() { - // Collect the runnable operations in ascending id order (_activeOperations 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 _activeOperations.Values) + for (var index = 0; index < _activeOperations.Count; index++) { + SimulationOperation operation = _activeOperations[index]; if (operation.State != SimulationOperationState.Runnable || !IsEligibleUnderLock(operation)) { continue; @@ -2303,6 +2313,33 @@ public void Dispose() return chosen; } + private SimulationOperation? SelectRoundRobinRunnable() + { + SimulationOperation? wrapTarget = null; + for (var index = 0; index < _activeOperations.Count; index++) + { + SimulationOperation operation = _activeOperations[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); @@ -2567,7 +2604,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 { @@ -2639,7 +2676,7 @@ private void HandOffTerminal(SimulationOperation operation, SimulationOperationS } operation.ApplyTransition(terminal, terminalException: terminalException); - _activeOperations.Remove(operation.Id); + _activeOperations.Remove(operation); _pendingTerminalNotification = operation; } } @@ -2725,17 +2762,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) @@ -2778,12 +2807,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; } } @@ -2793,7 +2833,7 @@ private int CountActiveOperations(Func predicate) lock (_gate) { var count = 0; - foreach (var operation in _activeOperations.Values) + foreach (var operation in _activeOperations) { if (predicate(operation)) { @@ -2810,11 +2850,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() {