Skip to content

Commit cd47834

Browse files
ReubenBondCopilot
andauthored
Add AdaptivePing benchmark with hill-climbing concurrency tuning (#10069)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 816edb9 commit cd47834

4 files changed

Lines changed: 678 additions & 3 deletions

File tree

Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
using System.Diagnostics;
2+
using System.Threading.Channels;
3+
4+
namespace Benchmarks.Ping;
5+
6+
/// <summary>
7+
/// A load generator that runs indefinitely and uses a hill climbing algorithm
8+
/// to continuously tune concurrency for maximum throughput.
9+
/// </summary>
10+
public sealed class AdaptiveConcurrencyLoadGenerator<TState>
11+
{
12+
private static readonly double StopwatchTickPerSecond = Stopwatch.Frequency;
13+
14+
private readonly Func<TState, ValueTask> _issueRequest;
15+
private readonly Func<int, TState> _getStateForWorker;
16+
private readonly int _requestsPerBlock;
17+
private readonly TimeSpan _warmupDuration;
18+
private readonly TimeSpan _measurementInterval;
19+
private readonly int _minConcurrency;
20+
private readonly int _maxConcurrency;
21+
private readonly int _initialConcurrency;
22+
private readonly int _maxStableRounds;
23+
24+
private Channel<WorkBlock> _completedBlocks;
25+
private volatile int _currentConcurrency;
26+
private CancellationTokenSource _cts;
27+
28+
// Hill climbing state
29+
private double _bestThroughput;
30+
private double _lastThroughput;
31+
private int _bestConcurrency;
32+
private int _stepSize;
33+
private int _direction; // 1 = increasing, -1 = decreasing
34+
private int _stableCount;
35+
private int _roundsSinceBestChanged;
36+
private const int StableThreshold = 3; // Number of consecutive non-improvements before changing direction
37+
38+
public int CurrentConcurrency => _currentConcurrency;
39+
public int BestConcurrency => _bestConcurrency;
40+
public double BestThroughput => _bestThroughput;
41+
public bool Converged { get; private set; }
42+
43+
public AdaptiveConcurrencyLoadGenerator(
44+
Func<TState, ValueTask> issueRequest,
45+
Func<int, TState> getStateForWorker,
46+
int requestsPerBlock = 500,
47+
TimeSpan? warmupDuration = null,
48+
TimeSpan? measurementInterval = null,
49+
int minConcurrency = 1,
50+
int maxConcurrency = 2000,
51+
int initialConcurrency = 100,
52+
int maxStableRounds = 0)
53+
{
54+
_issueRequest = issueRequest;
55+
_getStateForWorker = getStateForWorker;
56+
_requestsPerBlock = requestsPerBlock;
57+
_warmupDuration = warmupDuration ?? TimeSpan.FromSeconds(5);
58+
_measurementInterval = measurementInterval ?? TimeSpan.FromSeconds(5);
59+
_minConcurrency = minConcurrency;
60+
_maxConcurrency = maxConcurrency;
61+
_initialConcurrency = initialConcurrency;
62+
_currentConcurrency = initialConcurrency;
63+
_stepSize = Math.Max(1, initialConcurrency / 10);
64+
_direction = 1;
65+
_maxStableRounds = maxStableRounds; // 0 = run forever
66+
}
67+
68+
public async Task RunForeverAsync(CancellationToken cancellationToken = default)
69+
{
70+
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
71+
72+
Console.WriteLine($"Starting adaptive load generator with initial concurrency: {_initialConcurrency}");
73+
Console.WriteLine($"Warmup duration: {_warmupDuration.TotalSeconds}s, Measurement interval: {_measurementInterval.TotalSeconds}s");
74+
Console.WriteLine($"Concurrency range: [{_minConcurrency}, {_maxConcurrency}]");
75+
if (_maxStableRounds > 0)
76+
Console.WriteLine($"Will terminate after {_maxStableRounds} rounds with no improvement to best");
77+
Console.WriteLine();
78+
79+
// Warmup phase
80+
Console.WriteLine("=== WARMUP PHASE ===");
81+
await RunPhaseAsync(_warmupDuration, isWarmup: true);
82+
GC.Collect();
83+
84+
Console.WriteLine();
85+
Console.WriteLine("=== TUNING PHASE ===");
86+
Console.WriteLine($"{"Time",-12} {"Concurrency",12} {"Throughput",14} {"Best",14} {"BestConc",10} {"Action",-20}");
87+
Console.WriteLine(new string('-', 82));
88+
89+
var startTime = DateTime.UtcNow;
90+
91+
while (!_cts.Token.IsCancellationRequested)
92+
{
93+
var throughput = await RunPhaseAsync(_measurementInterval, isWarmup: false);
94+
var elapsed = DateTime.UtcNow - startTime;
95+
96+
var action = ApplyHillClimbing(throughput);
97+
98+
var elapsedStr = elapsed.ToString(@"hh\:mm\:ss\.f");
99+
Console.WriteLine($"{elapsedStr,-12} {_currentConcurrency,12} {throughput,14:N0}/s {_bestThroughput,14:N0}/s {_bestConcurrency,10} {action,-20}");
100+
101+
// Check for convergence
102+
if (_maxStableRounds > 0 && _roundsSinceBestChanged >= _maxStableRounds)
103+
{
104+
Converged = true;
105+
Console.WriteLine();
106+
Console.WriteLine($"Converged after {_roundsSinceBestChanged} rounds with no improvement.");
107+
break;
108+
}
109+
}
110+
}
111+
112+
private async Task<double> RunPhaseAsync(TimeSpan duration, bool isWarmup)
113+
{
114+
_completedBlocks = Channel.CreateUnbounded<WorkBlock>(
115+
new UnboundedChannelOptions
116+
{
117+
SingleReader = true,
118+
SingleWriter = false,
119+
AllowSynchronousContinuations = false
120+
});
121+
122+
var workerTasks = new List<Task>();
123+
// Link to main cancellation token so Ctrl+C stops workers immediately
124+
using var workerCts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token);
125+
var states = new Dictionary<int, TState>();
126+
127+
// Start initial workers
128+
for (int i = 0; i < _currentConcurrency; i++)
129+
{
130+
var state = _getStateForWorker(i);
131+
states[i] = state;
132+
workerTasks.Add(RunWorkerAsync(state, i, workerCts.Token));
133+
}
134+
135+
var aggregator = Task.Run(() => AggregateBlocksAsync(duration, isWarmup, workerCts));
136+
137+
var throughput = await aggregator;
138+
139+
// Signal workers to stop
140+
await workerCts.CancelAsync();
141+
_completedBlocks.Writer.Complete();
142+
143+
// Wait for workers with timeout
144+
try
145+
{
146+
await Task.WhenAll(workerTasks).WaitAsync(TimeSpan.FromSeconds(2));
147+
}
148+
catch (TimeoutException)
149+
{
150+
// Workers didn't stop in time, continue anyway
151+
}
152+
catch (OperationCanceledException)
153+
{
154+
// Expected
155+
}
156+
157+
return throughput;
158+
}
159+
160+
private async Task<double> AggregateBlocksAsync(TimeSpan duration, bool isWarmup, CancellationTokenSource workerCts)
161+
{
162+
var reader = _completedBlocks.Reader;
163+
var startTime = Stopwatch.GetTimestamp();
164+
var endTime = startTime + (long)(duration.TotalSeconds * StopwatchTickPerSecond);
165+
166+
long totalCompleted = 0;
167+
long totalSuccesses = 0;
168+
long totalFailures = 0;
169+
long minStartTime = long.MaxValue;
170+
long maxEndTime = long.MinValue;
171+
172+
while (Stopwatch.GetTimestamp() < endTime && !_cts.Token.IsCancellationRequested)
173+
{
174+
try
175+
{
176+
var readTask = reader.WaitToReadAsync(_cts.Token).AsTask();
177+
var completed = await readTask.WaitAsync(TimeSpan.FromMilliseconds(100));
178+
179+
if (!completed) continue;
180+
181+
while (reader.TryRead(out var block))
182+
{
183+
totalCompleted += block.Completed;
184+
totalSuccesses += block.Successes;
185+
totalFailures += block.Failures;
186+
if (block.StartTimestamp < minStartTime) minStartTime = block.StartTimestamp;
187+
if (block.EndTimestamp > maxEndTime) maxEndTime = block.EndTimestamp;
188+
}
189+
}
190+
catch (TimeoutException)
191+
{
192+
// Continue waiting
193+
}
194+
catch (OperationCanceledException)
195+
{
196+
break;
197+
}
198+
}
199+
200+
// Signal workers to stop
201+
workerCts.Cancel();
202+
203+
// Drain remaining blocks
204+
while (reader.TryRead(out var block))
205+
{
206+
totalCompleted += block.Completed;
207+
totalSuccesses += block.Successes;
208+
totalFailures += block.Failures;
209+
if (block.StartTimestamp < minStartTime) minStartTime = block.StartTimestamp;
210+
if (block.EndTimestamp > maxEndTime) maxEndTime = block.EndTimestamp;
211+
}
212+
213+
if (totalCompleted == 0 || maxEndTime <= minStartTime)
214+
{
215+
return 0;
216+
}
217+
218+
var totalSeconds = (maxEndTime - minStartTime) / StopwatchTickPerSecond;
219+
var throughput = totalCompleted / totalSeconds;
220+
221+
if (isWarmup)
222+
{
223+
var failureInfo = totalFailures > 0 ? $" ({totalFailures} failures)" : "";
224+
Console.WriteLine($" Warmup: {throughput:N0}/s, {totalCompleted:N0} requests in {totalSeconds:F1}s{failureInfo}");
225+
}
226+
227+
return throughput;
228+
}
229+
230+
private string ApplyHillClimbing(double currentThroughput)
231+
{
232+
string action;
233+
bool isNewBest = currentThroughput > _bestThroughput;
234+
235+
// Always track the actual best
236+
if (isNewBest)
237+
{
238+
_bestThroughput = currentThroughput;
239+
_bestConcurrency = _currentConcurrency;
240+
_roundsSinceBestChanged = 0;
241+
}
242+
else
243+
{
244+
_roundsSinceBestChanged++;
245+
}
246+
247+
// Determine if this is a meaningful improvement (resets stable count)
248+
// or just noise within measurement variance
249+
bool meaningfulImprovement = currentThroughput > _lastThroughput * 1.005; // 0.5% threshold
250+
251+
if (meaningfulImprovement)
252+
{
253+
_stableCount = 0;
254+
255+
// Continue in the same direction
256+
var newConcurrency = _currentConcurrency + (_direction * _stepSize);
257+
newConcurrency = Math.Clamp(newConcurrency, _minConcurrency, _maxConcurrency);
258+
259+
if (newConcurrency != _currentConcurrency)
260+
{
261+
_currentConcurrency = newConcurrency;
262+
action = isNewBest ? $"New best! {_direction * _stepSize:+#;-#;0}" : $"Improving {_direction * _stepSize:+#;-#;0}";
263+
}
264+
else
265+
{
266+
// Hit boundary, reverse direction
267+
_direction = -_direction;
268+
action = "Hit boundary";
269+
}
270+
}
271+
else
272+
{
273+
_stableCount++;
274+
275+
if (_stableCount >= StableThreshold)
276+
{
277+
// No improvement for a while
278+
_stableCount = 0;
279+
280+
if (_stepSize > 1)
281+
{
282+
// Reduce step size for finer tuning
283+
_stepSize = Math.Max(1, _stepSize / 2);
284+
_direction = -_direction; // Try the other direction with smaller steps
285+
action = $"Refine step={_stepSize}";
286+
}
287+
else
288+
{
289+
// At minimum step, jump back toward best and try again
290+
_direction = _bestConcurrency > _currentConcurrency ? 1 : -1;
291+
_stepSize = Math.Max(1, Math.Abs(_currentConcurrency - _bestConcurrency) / 2);
292+
if (_stepSize < 1) _stepSize = Math.Max(1, _bestConcurrency / 10);
293+
action = $"Reset toward best";
294+
}
295+
296+
var newConcurrency = _currentConcurrency + (_direction * _stepSize);
297+
newConcurrency = Math.Clamp(newConcurrency, _minConcurrency, _maxConcurrency);
298+
_currentConcurrency = newConcurrency;
299+
}
300+
else
301+
{
302+
// Continue probing in current direction
303+
var newConcurrency = _currentConcurrency + (_direction * _stepSize);
304+
newConcurrency = Math.Clamp(newConcurrency, _minConcurrency, _maxConcurrency);
305+
306+
if (newConcurrency == _currentConcurrency)
307+
{
308+
// Hit boundary, reverse
309+
_direction = -_direction;
310+
newConcurrency = _currentConcurrency + (_direction * _stepSize);
311+
newConcurrency = Math.Clamp(newConcurrency, _minConcurrency, _maxConcurrency);
312+
action = "Boundary, reverse";
313+
}
314+
else
315+
{
316+
action = $"Probing ({_stableCount}/{StableThreshold})";
317+
}
318+
319+
_currentConcurrency = newConcurrency;
320+
}
321+
}
322+
323+
_lastThroughput = currentThroughput;
324+
return action;
325+
}
326+
327+
private async Task RunWorkerAsync(TState state, int workerId, CancellationToken cancellationToken)
328+
{
329+
var writer = _completedBlocks.Writer;
330+
331+
while (!cancellationToken.IsCancellationRequested)
332+
{
333+
var workBlock = new WorkBlock { StartTimestamp = Stopwatch.GetTimestamp() };
334+
335+
while (workBlock.Completed < _requestsPerBlock && !cancellationToken.IsCancellationRequested)
336+
{
337+
try
338+
{
339+
await _issueRequest(state).ConfigureAwait(false);
340+
workBlock.Successes++;
341+
}
342+
catch (OperationCanceledException)
343+
{
344+
break;
345+
}
346+
catch
347+
{
348+
workBlock.Failures++;
349+
}
350+
}
351+
352+
workBlock.EndTimestamp = Stopwatch.GetTimestamp();
353+
354+
if (workBlock.Completed > 0)
355+
{
356+
try
357+
{
358+
await writer.WriteAsync(workBlock, cancellationToken).ConfigureAwait(false);
359+
}
360+
catch (OperationCanceledException)
361+
{
362+
break;
363+
}
364+
catch (ChannelClosedException)
365+
{
366+
break;
367+
}
368+
}
369+
}
370+
}
371+
372+
private struct WorkBlock
373+
{
374+
public long StartTimestamp;
375+
public long EndTimestamp;
376+
public int Successes;
377+
public int Failures;
378+
public readonly int Completed => Successes + Failures;
379+
}
380+
}

0 commit comments

Comments
 (0)