Skip to content

Commit 1081f0c

Browse files
authored
Provide basic modifiable throughput calculator (#3057)
1 parent 49d6326 commit 1081f0c

4 files changed

Lines changed: 180 additions & 0 deletions

File tree

StackExchange.Redis.sln

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RESPite", "src\RESPite\RESP
132132
EndProject
133133
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RESPite.Tests", "tests\RESPite.Tests\RESPite.Tests.csproj", "{CA67D8CA-6CC9-40E2-8CAC-F0B1401BEF7B}"
134134
EndProject
135+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpBench", "toys\OpBench\OpBench.csproj", "{43D3CD21-1E7E-4D28-B1BA-B6DAB3E19183}"
136+
EndProject
135137
Global
136138
GlobalSection(SolutionConfigurationPlatforms) = preSolution
137139
Debug|Any CPU = Debug|Any CPU
@@ -202,6 +204,10 @@ Global
202204
{CA67D8CA-6CC9-40E2-8CAC-F0B1401BEF7B}.Debug|Any CPU.Build.0 = Debug|Any CPU
203205
{CA67D8CA-6CC9-40E2-8CAC-F0B1401BEF7B}.Release|Any CPU.ActiveCfg = Release|Any CPU
204206
{CA67D8CA-6CC9-40E2-8CAC-F0B1401BEF7B}.Release|Any CPU.Build.0 = Release|Any CPU
207+
{43D3CD21-1E7E-4D28-B1BA-B6DAB3E19183}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
208+
{43D3CD21-1E7E-4D28-B1BA-B6DAB3E19183}.Debug|Any CPU.Build.0 = Debug|Any CPU
209+
{43D3CD21-1E7E-4D28-B1BA-B6DAB3E19183}.Release|Any CPU.ActiveCfg = Release|Any CPU
210+
{43D3CD21-1E7E-4D28-B1BA-B6DAB3E19183}.Release|Any CPU.Build.0 = Release|Any CPU
205211
EndGlobalSection
206212
GlobalSection(SolutionProperties) = preSolution
207213
HideSolutionNode = FALSE
@@ -227,6 +233,7 @@ Global
227233
{190742E1-FA50-4E36-A8C4-88AE87654340} = {5FA0958E-6EBD-45F4-808E-3447A293F96F}
228234
{05761CF5-CC46-43A6-814B-6BD2ECC1F0ED} = {00CA0876-DA9F-44E8-B0DC-A88716BF347A}
229235
{CA67D8CA-6CC9-40E2-8CAC-F0B1401BEF7B} = {73A5C363-CA1F-44C4-9A9B-EF791A76BA6A}
236+
{43D3CD21-1E7E-4D28-B1BA-B6DAB3E19183} = {E25031D3-5C64-430D-B86F-697B66816FD8}
230237
EndGlobalSection
231238
GlobalSection(ExtensibilityGlobals) = postSolution
232239
SolutionGuid = {193AA352-6748-47C1-A5FC-C9AA6B5F000B}

toys/OpBench/OpBench.csproj

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net10.0</TargetFramework>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<ProjectReference Include="..\..\src\StackExchange.Redis\StackExchange.Redis.csproj" />
12+
</ItemGroup>
13+
14+
</Project>

toys/OpBench/Program.cs

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
using System.Diagnostics;
2+
using StackExchange.Redis;
3+
4+
#if !RELEASE
5+
Console.WriteLine("Warning: not running in release mode; results may be sub-optimal.");
6+
#endif
7+
using Benchmarker bench = new()
8+
{
9+
// This is the operation (or operations) that we're benchmarking; you can change this to whatever you want to benchmark;
10+
// it should be representative of your real workload, in a database that is in a representative state (e.g. has the right indexes,
11+
// data volume, etc).
12+
Work = db => db.PingAsync(),
13+
// optional: set Connections, PipelineDepth, etc
14+
Clients = 100,
15+
PipelineDepth = 20, // not suitable for all workloads, note! for pure query workloads this might need to be 1
16+
};
17+
var count = bench.RepeatCount;
18+
19+
Console.WriteLine($"Running {count} times, {bench.Clients} clients, {bench.IterationsPerClient} iterations each, pipeline depth {bench.PipelineDepth}");
20+
for (int i = 0; i < count; i++)
21+
{
22+
var watch = Stopwatch.StartNew();
23+
await bench.RunAsync();
24+
watch.Stop();
25+
var opsPerSecond = (bench.IterationsPerClient * bench.Clients) / watch.Elapsed.TotalSeconds;
26+
Console.WriteLine($"Run {i + 1} of {count} completed in {watch.ElapsedMilliseconds}ms, {opsPerSecond:N0} ops/s");
27+
}
28+
29+
internal sealed class Benchmarker : IDisposable
30+
{
31+
public int Connections { get; set; } = 1;
32+
public int IterationsPerClient { get; set; } = 10_000;
33+
public int PipelineDepth { get; set; } = 1;
34+
public int RepeatCount { get; set; } = 10;
35+
private int? _clients;
36+
public int Clients
37+
{
38+
get => _clients ?? Connections;
39+
set => _clients = value;
40+
}
41+
42+
public required Func<IDatabase, Task> Work { get; set; }
43+
44+
// optional run-once setup
45+
public Func<IDatabase, Task>? Init { get; set; }
46+
47+
private IDatabase[] _conns = [];
48+
49+
void IDisposable.Dispose()
50+
{
51+
foreach (var db in _conns)
52+
{
53+
db?.Multiplexer?.Dispose();
54+
}
55+
}
56+
57+
private async Task ConnectAllAsync()
58+
{
59+
Console.WriteLine($"Connecting...");
60+
var arr = new IDatabase[Connections];
61+
for (int i = 0; i < arr.Length; i++)
62+
{
63+
var db = arr[i] = await ConnectAsync();
64+
var id = await db.ExecuteAsync("client", "id");
65+
Console.WriteLine($"Client {i} connected, id: {id}");
66+
}
67+
_conns = arr;
68+
if (Init is not null)
69+
{
70+
Console.WriteLine("Initializing...");
71+
await Init(_conns[0]);
72+
Console.WriteLine("Initialized");
73+
}
74+
}
75+
public async Task RunAsync()
76+
{
77+
// spin up the connections, if not already done
78+
if (_conns.Length is 0) await ConnectAllAsync();
79+
80+
Task[] clients = new Task[Clients];
81+
for (int i = 0; i < clients.Length; i++)
82+
{
83+
// round-robin the connections to clients
84+
var db = _conns[i % _conns.Length]; // explicit local to avoid capture-context problems
85+
clients[i] = Task.Run(() => RunClientAsync(db)); // intentionally not awaited - concurrency
86+
}
87+
await Task.WhenAll(clients);
88+
}
89+
90+
private async Task RunClientAsync(IDatabase db)
91+
{
92+
// run DoWorkAsync in a loop using a pipeline of depth PipelineDepth - just track the
93+
// last outstanding operation so we can await it when the pipe is full, or at the end
94+
int count = IterationsPerClient, maxDepth = PipelineDepth, depth = 0;
95+
var work = Work;
96+
Task? last = null;
97+
for (int i = 0; i < count; i++)
98+
{
99+
var pending = work(db); // intentionally not awaited - pipeline
100+
if (last is not null)
101+
{
102+
_ = last.ContinueWith(static t => GC.KeepAlive(t.Exception), TaskContinuationOptions.OnlyOnFaulted);
103+
}
104+
105+
if (++depth >= maxDepth)
106+
{
107+
// pipeline is full; pause until the last one completes
108+
await pending;
109+
last = null;
110+
}
111+
else
112+
{
113+
// leave it outstanding
114+
last = pending;
115+
}
116+
}
117+
118+
if (last is not null)
119+
{
120+
await last;
121+
}
122+
}
123+
124+
private async Task<IDatabase> ConnectAsync()
125+
{
126+
var options = new ConfigurationOptions
127+
{
128+
EndPoints = { { "127.0.0.1", 6379 } },
129+
TieBreaker = "",
130+
AllowAdmin = true,
131+
Protocol = RedisProtocol.Resp3,
132+
// turn off pub-sub
133+
CommandMap = CommandMap.Create(new HashSet<string>() { "SUBSCRIBE" }, available: false),
134+
ConfigurationChannel = "",
135+
};
136+
var muxer = await ConnectionMultiplexer.ConnectAsync(options);
137+
return muxer.GetDatabase();
138+
}
139+
}

toys/OpBench/readme.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# OpBench
2+
3+
This is a basic client to show achieved throughput. It is designed to be modifiable, so that a consumer can substitute
4+
a command that is more representative of their workload. It is not designed to be a fully featured benchmarking tool.
5+
6+
The work to perform is specified via `Work`; optionally, `Init` can be supplied as a once-only setup step.
7+
8+
Variables:
9+
10+
- `Connections`: the number of actual Redis connections to use. Defaults to 1. Most SE.Redis scenarios do not benefit from multiple connections, due to the internal multiplexing.
11+
- `Clients`: the number of effective parallel clients to simulate. Defaults to 1 per connection.
12+
- each client represents a concurrent call path in your application code; high concurrency is very normal in .NET applications (especially server scenarios)
13+
- `PipelineDepth`: the number of commands to pipeline (batch) per client. Defaults to 1.
14+
- Pipelining is a very effective way of improving throughput, but it is not always applicable; in particular, it is not applicable if you need the result
15+
of one command before issuing the next. Pipelining is especially suitable for write-heavy workloads.
16+
- Note that SE.Redis internally pipelines commands from parallel callers (clients) on the same connection.
17+
- `IterationsPerClient`: the number of operations to perform per client. Defaults to 1000.
18+
- `RepeatCount`: the number of times to repeat the test
19+
20+

0 commit comments

Comments
 (0)