Skip to content

Commit c4493a7

Browse files
add a simple throughput benchmark calculation (#356)
1 parent 659e5bc commit c4493a7

File tree

8 files changed

+488
-47
lines changed

8 files changed

+488
-47
lines changed

DEVELOPMENT.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,17 @@ A basic set of performance indicators can be obtained by building the project, a
166166

167167
Also see `.github/workflow/bencher.yml`, and the [dashboard](https://bencher.dev/console/projects/scitt-ccf-ledger/plots). This is useful to understand the potential performance impact of changes.
168168

169+
### Load tests
170+
171+
To run load tests, you can use the `load_test.py` script located in the `tests` directory. This script allows you to simulate a high load on the scitt-ccf-ledger application and measure its performance under stress.
172+
173+
```bash
174+
PLATFORM=virtual ./docker/build.sh
175+
PLATFORM=virtual DOCKER=1 ./run_functional_tests.sh -m perf -k test_load --enable-perf
176+
```
177+
178+
The output will be stored in the `tests/load_test/locust_stats.json` file, and the chart images generated in `tests/load_test/charts`.
179+
169180
### Address sanitization
170181

171182
To enable ASan it is necessary to build CCF from source:

mypy.ini

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,7 @@ ignore_missing_imports = True
99
ignore_missing_imports = True
1010
[mypy-setuptools.*]
1111
ignore_missing_imports = True
12+
[mypy-pandas]
13+
ignore_missing_imports = True
14+
[mypy-gevent.*]
15+
ignore_missing_imports = True

test/infra/bencher.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def __init__(self):
7676
with open(BENCHER_FILE, "w+") as bf:
7777
json.dump({}, bf)
7878

79-
def set(self, key: str, metric: Union[Latency, Throughput, Memory]):
79+
def set(self, key: str, metric: Union[Latency, Throughput, Memory, Rate]):
8080
with open(BENCHER_FILE, "r") as bf:
8181
data = json.load(bf)
8282
metric_val = dataclasses.asdict(metric)

test/load_test/generate_charts.py

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""
5+
Generate load test charts from Locust JSON output.
6+
7+
Usage:
8+
python -m test.load_test.generate_charts <stats_json_file> <output_dir>
9+
"""
10+
11+
import argparse
12+
import json
13+
from pathlib import Path
14+
15+
import matplotlib
16+
17+
matplotlib.use("Agg")
18+
import matplotlib.pyplot as plt
19+
import numpy as np
20+
import pandas as pd
21+
22+
23+
def build_rps_df(endpoint_stats, label):
24+
"""Build a time-series DataFrame from an endpoint's num_reqs_per_sec."""
25+
start_time = endpoint_stats["start_time"]
26+
rows = []
27+
for ts_str, count in endpoint_stats["num_reqs_per_sec"].items():
28+
ts = int(ts_str)
29+
rows.append(
30+
{
31+
"unix_ts": ts,
32+
"elapsed_seconds": ts - int(start_time),
33+
"requests_per_sec": count,
34+
}
35+
)
36+
df = pd.DataFrame(rows).sort_values("unix_ts").reset_index(drop=True)
37+
df["cumulative_requests"] = df["requests_per_sec"].cumsum()
38+
df["endpoint"] = label
39+
return df
40+
41+
42+
def expand_response_times(response_times_dict):
43+
"""Expand locust's {bucket: count} into a list of response times."""
44+
times = []
45+
for bucket_str, count in response_times_dict.items():
46+
bucket_ms = float(bucket_str)
47+
times.extend([bucket_ms] * count)
48+
return np.array(times)
49+
50+
51+
def generate_charts(stats, output_dir, peak_users, spawn_rate):
52+
"""Generate and save all load test charts to output_dir."""
53+
output_dir = Path(output_dir)
54+
output_dir.mkdir(parents=True, exist_ok=True)
55+
56+
post_stats = next(s for s in stats if s["name"] == "POST /entries")
57+
get_stats = next(s for s in stats if s["name"] == "GET /operations/[id]")
58+
59+
ramp_up_seconds = peak_users / spawn_rate
60+
61+
df_post = build_rps_df(post_stats, "POST /entries")
62+
df_get = build_rps_df(get_stats, "GET /operations/[id]")
63+
64+
post_rt = expand_response_times(post_stats["response_times"])
65+
get_rt = expand_response_times(get_stats["response_times"])
66+
67+
# Steady-state metrics (after ramp-up)
68+
post_steady = df_post[df_post["elapsed_seconds"] >= ramp_up_seconds]
69+
post_mean = post_steady["requests_per_sec"].mean()
70+
get_steady = df_get[df_get["elapsed_seconds"] >= ramp_up_seconds]
71+
get_mean = get_steady["requests_per_sec"].mean()
72+
73+
# --- Chart 1: Requests Per Second Over Time ---
74+
fig, (ax_top, ax_bot) = plt.subplots(2, 1, figsize=(14, 9), sharex=True)
75+
76+
ax_top.plot(
77+
df_post["elapsed_seconds"],
78+
df_post["requests_per_sec"],
79+
color="#2196F3",
80+
linewidth=1,
81+
alpha=0.7,
82+
label="POST /entries RPS",
83+
)
84+
ax_top.axhline(
85+
y=post_mean,
86+
color="#4CAF50",
87+
linestyle="--",
88+
linewidth=1.5,
89+
label=f"Steady-state Mean ({post_mean:.0f} req/s)",
90+
)
91+
ax_top.axvspan(
92+
0,
93+
ramp_up_seconds,
94+
alpha=0.08,
95+
color="orange",
96+
label=f"Ramp-up ({ramp_up_seconds:.0f}s)",
97+
)
98+
ax_top.set_ylabel("Requests Per Second")
99+
ax_top.set_title("POST /entries — Submission Throughput")
100+
ax_top.legend(loc="upper left")
101+
ax_top.grid(True, alpha=0.3)
102+
103+
ax_bot.plot(
104+
df_get["elapsed_seconds"],
105+
df_get["requests_per_sec"],
106+
color="#FF9800",
107+
linewidth=1,
108+
alpha=0.7,
109+
label="GET /operations RPS",
110+
)
111+
ax_bot.axhline(
112+
y=get_mean,
113+
color="#4CAF50",
114+
linestyle="--",
115+
linewidth=1.5,
116+
label=f"Steady-state Mean ({get_mean:.0f} req/s)",
117+
)
118+
ax_bot.axvspan(0, ramp_up_seconds, alpha=0.08, color="orange")
119+
ax_bot.set_xlabel("Elapsed Time (seconds)")
120+
ax_bot.set_ylabel("Requests Per Second")
121+
ax_bot.set_title("GET /operations/[id] — Operation Polling Throughput")
122+
ax_bot.legend(loc="upper left")
123+
ax_bot.grid(True, alpha=0.3)
124+
125+
plt.tight_layout()
126+
fig.savefig(output_dir / "rps_over_time.png", dpi=150)
127+
plt.close(fig)
128+
129+
# --- Chart 2: Response Time Distribution ---
130+
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
131+
132+
ax1.hist(
133+
post_rt, bins=60, color="#2196F3", alpha=0.7, edgecolor="white", linewidth=0.5
134+
)
135+
ax1.axvline(
136+
np.median(post_rt),
137+
color="#F44336",
138+
linestyle="--",
139+
linewidth=1.5,
140+
label=f"Median: {np.median(post_rt):.0f}ms",
141+
)
142+
ax1.axvline(
143+
np.percentile(post_rt, 95),
144+
color="#FF9800",
145+
linestyle=":",
146+
linewidth=1.5,
147+
label=f"P95: {np.percentile(post_rt, 95):.0f}ms",
148+
)
149+
ax1.axvline(
150+
np.percentile(post_rt, 99),
151+
color="#9C27B0",
152+
linestyle=":",
153+
linewidth=1.5,
154+
label=f"P99: {np.percentile(post_rt, 99):.0f}ms",
155+
)
156+
ax1.set_xlabel("Response Time (ms)")
157+
ax1.set_ylabel("Count")
158+
ax1.set_title("POST /entries — Response Time Distribution")
159+
ax1.legend()
160+
ax1.grid(True, alpha=0.3)
161+
162+
ax2.hist(
163+
get_rt, bins=60, color="#FF9800", alpha=0.7, edgecolor="white", linewidth=0.5
164+
)
165+
ax2.axvline(
166+
np.median(get_rt),
167+
color="#F44336",
168+
linestyle="--",
169+
linewidth=1.5,
170+
label=f"Median: {np.median(get_rt):.0f}ms",
171+
)
172+
ax2.axvline(
173+
np.percentile(get_rt, 95),
174+
color="#2196F3",
175+
linestyle=":",
176+
linewidth=1.5,
177+
label=f"P95: {np.percentile(get_rt, 95):.0f}ms",
178+
)
179+
ax2.axvline(
180+
np.percentile(get_rt, 99),
181+
color="#9C27B0",
182+
linestyle=":",
183+
linewidth=1.5,
184+
label=f"P99: {np.percentile(get_rt, 99):.0f}ms",
185+
)
186+
ax2.set_xlabel("Response Time (ms)")
187+
ax2.set_ylabel("Count")
188+
ax2.set_title("GET /operations/[id] — Response Time Distribution")
189+
ax2.legend()
190+
ax2.grid(True, alpha=0.3)
191+
192+
plt.tight_layout()
193+
fig.savefig(output_dir / "response_time_distribution.png", dpi=150)
194+
plt.close(fig)
195+
196+
# --- Chart 3: User Ramp-Up vs Throughput ---
197+
fig, ax1 = plt.subplots(figsize=(14, 5))
198+
199+
df_post["estimated_users"] = df_post["elapsed_seconds"].apply(
200+
lambda t: min(t * spawn_rate, peak_users)
201+
)
202+
203+
ax1.fill_between(
204+
df_post["elapsed_seconds"],
205+
df_post["requests_per_sec"],
206+
alpha=0.3,
207+
color="#2196F3",
208+
label="POST /entries",
209+
)
210+
ax1.fill_between(
211+
df_get["elapsed_seconds"],
212+
df_get["requests_per_sec"],
213+
alpha=0.3,
214+
color="#FF9800",
215+
label="GET /operations",
216+
)
217+
ax1.set_xlabel("Elapsed Time (seconds)")
218+
ax1.set_ylabel("Requests Per Second")
219+
220+
ax2 = ax1.twinx()
221+
ax2.plot(
222+
df_post["elapsed_seconds"],
223+
df_post["estimated_users"],
224+
color="#F44336",
225+
linewidth=2.5,
226+
linestyle="--",
227+
label="Concurrent Users",
228+
)
229+
ax2.set_ylabel("Concurrent Users", color="#F44336")
230+
ax2.tick_params(axis="y", labelcolor="#F44336")
231+
ax2.set_ylim(0, peak_users * 1.2)
232+
233+
lines1, labels1 = ax1.get_legend_handles_labels()
234+
lines2, labels2 = ax2.get_legend_handles_labels()
235+
ax1.legend(lines1 + lines2, labels1 + labels2, loc="center right")
236+
237+
ax1.set_title("SCITT Load Test — User Ramp-Up vs Throughput (Both Endpoints)")
238+
ax1.grid(True, alpha=0.3)
239+
plt.tight_layout()
240+
fig.savefig(output_dir / "rampup_vs_throughput.png", dpi=150)
241+
plt.close(fig)
242+
243+
# --- Summary text file ---
244+
test_duration = post_stats["last_request_timestamp"] - post_stats["start_time"]
245+
post_avg_rt = post_stats["total_response_time"] / post_stats["num_requests"]
246+
get_avg_rt = get_stats["total_response_time"] / get_stats["num_requests"]
247+
248+
summary_lines = [
249+
"SCITT Load Test Summary",
250+
"=" * 40,
251+
"",
252+
"Test Configuration",
253+
"-" * 40,
254+
f" Test Duration: {test_duration:.1f}s",
255+
f" Peak Users: {peak_users:,}",
256+
f" Spawn Rate: {spawn_rate} users/sec",
257+
f" Ramp-up Duration: {ramp_up_seconds:.0f}s",
258+
"",
259+
"POST /entries (Submissions)",
260+
"-" * 40,
261+
f" Total Submissions: {post_stats['num_requests']:,}",
262+
f" Failures: {post_stats['num_failures']:,}",
263+
f" Steady-state Mean RPS: {post_mean:.0f}",
264+
f" Avg Response Time: {post_avg_rt:.1f}ms",
265+
f" Median Response Time: {np.median(post_rt):.0f}ms",
266+
f" P95 Response Time: {np.percentile(post_rt, 95):.0f}ms",
267+
f" P99 Response Time: {np.percentile(post_rt, 99):.0f}ms",
268+
f" Max Response Time: {post_stats['max_response_time']:.1f}ms",
269+
"",
270+
"GET /operations/[id] (Polling)",
271+
"-" * 40,
272+
f" Total Polls: {get_stats['num_requests']:,}",
273+
f" Failures: {get_stats['num_failures']:,}",
274+
f" Steady-state Mean RPS: {get_mean:.0f}",
275+
f" Avg Response Time: {get_avg_rt:.1f}ms",
276+
f" Median Response Time: {np.median(get_rt):.0f}ms",
277+
f" P95 Response Time: {np.percentile(get_rt, 95):.0f}ms",
278+
f" P99 Response Time: {np.percentile(get_rt, 99):.0f}ms",
279+
f" Max Response Time: {get_stats['max_response_time']:.1f}ms",
280+
f" Polls per Submission: {get_stats['num_requests']/post_stats['num_requests']:.1f}x",
281+
]
282+
283+
summary_text = "\n".join(summary_lines)
284+
(output_dir / "summary.txt").write_text(summary_text)
285+
print(summary_text)
286+
287+
print(f"\nCharts saved to {output_dir}/")
288+
print(" - rps_over_time.png")
289+
print(" - response_time_distribution.png")
290+
print(" - rampup_vs_throughput.png")
291+
print(" - summary.txt")
292+
293+
294+
def main():
295+
parser = argparse.ArgumentParser(
296+
description="Generate load test charts from Locust JSON output."
297+
)
298+
parser.add_argument(
299+
"stats_file", type=Path, help="Path to the Locust JSON stats file"
300+
)
301+
parser.add_argument(
302+
"output_dir", type=Path, help="Directory to save charts into (will be created)"
303+
)
304+
parser.add_argument(
305+
"--peak-users",
306+
type=int,
307+
default=800,
308+
help="Peak concurrent users (default: 800)",
309+
)
310+
parser.add_argument(
311+
"--spawn-rate",
312+
type=int,
313+
default=20,
314+
help="User spawn rate per second (default: 20)",
315+
)
316+
args = parser.parse_args()
317+
318+
stats = json.loads(args.stats_file.read_text())
319+
generate_charts(stats, args.output_dir, args.peak_users, args.spawn_rate)
320+
321+
322+
if __name__ == "__main__":
323+
main()

0 commit comments

Comments
 (0)