ml-loadtest 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ml_loadtest/__init__.py +1 -0
- ml_loadtest/analyze.py +518 -0
- ml_loadtest/cli.py +29 -0
- ml_loadtest/examples/__init__.py +17 -0
- ml_loadtest/examples/demo_tasks.py +178 -0
- ml_loadtest/examples/distribution_weights.py +32 -0
- ml_loadtest/locustfile.py +1006 -0
- ml_loadtest/notion_sync.py +545 -0
- ml_loadtest-1.0.0.dist-info/METADATA +377 -0
- ml_loadtest-1.0.0.dist-info/RECORD +12 -0
- ml_loadtest-1.0.0.dist-info/WHEEL +4 -0
- ml_loadtest-1.0.0.dist-info/entry_points.txt +3 -0
ml_loadtest/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
ml_loadtest/analyze.py
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict, dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from dacite import Config as DaciteConfig
|
|
10
|
+
from dacite import from_dict
|
|
11
|
+
|
|
12
|
+
from ml_loadtest import __version__ as package_version
|
|
13
|
+
from ml_loadtest.locustfile import EndpointStats, InstanceInfo, Mode, Report
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class PerformanceMetrics:
|
|
18
|
+
"""Baseline performance metrics."""
|
|
19
|
+
|
|
20
|
+
version: str
|
|
21
|
+
timestamp: str
|
|
22
|
+
p99_ms: float
|
|
23
|
+
rps: float
|
|
24
|
+
distribution_weights: dict[str, float]
|
|
25
|
+
endpoint_stats: dict[str, EndpointStats]
|
|
26
|
+
individual_capacities: dict[str, EndpointStats]
|
|
27
|
+
instance_info: InstanceInfo | None = None
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_reports(cls, reports: dict[str, Report]) -> "PerformanceMetrics":
|
|
31
|
+
"""Create PerformanceMetrics from a dictionary of reports.
|
|
32
|
+
|
|
33
|
+
:param reports: Dictionary mapping report names to Report objects.
|
|
34
|
+
:returns: A new PerformanceMetrics instance with extracted metrics.
|
|
35
|
+
"""
|
|
36
|
+
p99_ms, rps = 0.0, 0.0
|
|
37
|
+
distribution_weights = {}
|
|
38
|
+
endpoint_stats = {}
|
|
39
|
+
|
|
40
|
+
# Parse production report
|
|
41
|
+
for r in reports.values():
|
|
42
|
+
if r.mode == Mode.PRODUCTION:
|
|
43
|
+
p99_ms = r.p99_ms
|
|
44
|
+
rps = r.rps
|
|
45
|
+
distribution_weights = r.weights
|
|
46
|
+
for ep, stats in r.endpoint_stats.items():
|
|
47
|
+
endpoint_stats[ep] = EndpointStats(
|
|
48
|
+
p99_ms=stats.p99_ms,
|
|
49
|
+
rps=stats.rps,
|
|
50
|
+
)
|
|
51
|
+
break
|
|
52
|
+
|
|
53
|
+
# Parse individual reports
|
|
54
|
+
individual = {}
|
|
55
|
+
for ep, r in reports.items():
|
|
56
|
+
if r.mode == Mode.INDIVIDUAL:
|
|
57
|
+
individual[ep] = EndpointStats(
|
|
58
|
+
p99_ms=r.p99_ms,
|
|
59
|
+
rps=r.rps,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Create baseline structure
|
|
63
|
+
return PerformanceMetrics(
|
|
64
|
+
version=package_version,
|
|
65
|
+
timestamp=datetime.now().isoformat(),
|
|
66
|
+
p99_ms=p99_ms,
|
|
67
|
+
rps=rps,
|
|
68
|
+
distribution_weights=distribution_weights,
|
|
69
|
+
endpoint_stats=endpoint_stats,
|
|
70
|
+
individual_capacities=individual,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class MetricComparison:
|
|
76
|
+
"""Comparison between current and baseline."""
|
|
77
|
+
|
|
78
|
+
metric: str
|
|
79
|
+
baseline_value: float
|
|
80
|
+
current_value: float
|
|
81
|
+
change_pct: float
|
|
82
|
+
regression: bool
|
|
83
|
+
|
|
84
|
+
def __str__(self) -> str:
|
|
85
|
+
"""Return a string representation of the comparison.
|
|
86
|
+
|
|
87
|
+
:returns: Formatted string showing the comparison with symbols and arrows.
|
|
88
|
+
"""
|
|
89
|
+
symbol = "❌" if self.regression else "✅"
|
|
90
|
+
direction = "↑" if self.change_pct > 0 else "↓" if self.change_pct < 0 else "→"
|
|
91
|
+
return f"{symbol} {self.metric}: {self.baseline_value:.1f} → {self.current_value:.1f} ({direction} {abs(self.change_pct * 100):.1f}%)" # noqa: E501
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class InstanceComparison:
|
|
96
|
+
"""Comparison of instance information."""
|
|
97
|
+
|
|
98
|
+
field: str
|
|
99
|
+
baseline_value: str | int | None
|
|
100
|
+
current_value: str | int | None
|
|
101
|
+
|
|
102
|
+
def __str__(self) -> str:
|
|
103
|
+
"""Return a string representation of the instance comparison.
|
|
104
|
+
|
|
105
|
+
:returns: Formatted string showing the instance comparison.
|
|
106
|
+
"""
|
|
107
|
+
return f"⚠️ Different {self.field}: {self.baseline_value} → {self.current_value}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def compute_rate_limits(
|
|
111
|
+
individual_capacities: dict[str, EndpointStats],
|
|
112
|
+
endpoint_stats: dict[str, EndpointStats],
|
|
113
|
+
safety_factor: float,
|
|
114
|
+
) -> tuple[dict[str, dict[str, int | None]], int | None, int | None]:
|
|
115
|
+
"""Compute rate limits from capacity data.
|
|
116
|
+
|
|
117
|
+
:param individual_capacities: Per-endpoint stats at 100% traffic.
|
|
118
|
+
:param endpoint_stats: Per-endpoint stats at production weights.
|
|
119
|
+
:param safety_factor: Safety factor multiplier (0-1).
|
|
120
|
+
:returns: Tuple of (per_endpoint_limits, total_standard, total_burst).
|
|
121
|
+
per_endpoint_limits maps endpoint to {"per_second": int|None, "burst": int|None}.
|
|
122
|
+
total_standard/total_burst are None if any endpoint has missing stats.
|
|
123
|
+
"""
|
|
124
|
+
rate_limits: dict[str, dict[str, int | None]] = {}
|
|
125
|
+
has_missed_stats = False
|
|
126
|
+
for ep, stats in individual_capacities.items():
|
|
127
|
+
per_second: int | None = None
|
|
128
|
+
if ep in endpoint_stats:
|
|
129
|
+
per_second = max(1, int(endpoint_stats[ep].rps * safety_factor))
|
|
130
|
+
else:
|
|
131
|
+
has_missed_stats = True
|
|
132
|
+
burst = max(1, int(stats.rps * safety_factor))
|
|
133
|
+
rate_limits[ep] = {"per_second": per_second, "burst": burst}
|
|
134
|
+
|
|
135
|
+
total_standard: int | None = None
|
|
136
|
+
total_burst: int | None = None
|
|
137
|
+
if not has_missed_stats:
|
|
138
|
+
total_standard = sum(v["per_second"] for v in rate_limits.values() if v["per_second"] is not None)
|
|
139
|
+
total_burst = sum(v["burst"] for v in rate_limits.values() if v["burst"] is not None)
|
|
140
|
+
|
|
141
|
+
return rate_limits, total_standard, total_burst
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class LoadTestAnalyzer:
|
|
145
|
+
"""Analyze load test results and manage baselines."""
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
loadtest_report_file: str,
|
|
150
|
+
baseline_file: str,
|
|
151
|
+
safety_factor: float = 0.8,
|
|
152
|
+
regression_threshold: float = 0.1,
|
|
153
|
+
) -> None:
|
|
154
|
+
"""Initialize the LoadTestAnalyzer.
|
|
155
|
+
|
|
156
|
+
:param loadtest_report_file: Path to the load test report JSON file.
|
|
157
|
+
:param baseline_file: Path to the baseline JSON file.
|
|
158
|
+
:param safety_factor: Safety factor for rate limit calculations (0-1).
|
|
159
|
+
:param regression_threshold: Threshold for detecting performance regression (0-1).
|
|
160
|
+
"""
|
|
161
|
+
self.loadtest_report_file = Path(loadtest_report_file)
|
|
162
|
+
self.baseline_file = Path(baseline_file)
|
|
163
|
+
self.safety_factor = safety_factor
|
|
164
|
+
self.regression_threshold = regression_threshold
|
|
165
|
+
|
|
166
|
+
def load_from_report(self) -> PerformanceMetrics:
|
|
167
|
+
"""Load and parse report results.
|
|
168
|
+
|
|
169
|
+
:returns: PerformanceMetrics object containing the parsed results.
|
|
170
|
+
:raises FileNotFoundError: If the results file is not found.
|
|
171
|
+
"""
|
|
172
|
+
if not self.loadtest_report_file.exists():
|
|
173
|
+
raise FileNotFoundError(f"Results file not found: {self.loadtest_report_file}")
|
|
174
|
+
|
|
175
|
+
with self.loadtest_report_file.open() as f:
|
|
176
|
+
data = json.load(f)
|
|
177
|
+
|
|
178
|
+
reports = {}
|
|
179
|
+
for name, metrics in data["metrics"].items():
|
|
180
|
+
reports[name] = from_dict(
|
|
181
|
+
data_class=Report,
|
|
182
|
+
data=metrics,
|
|
183
|
+
config=DaciteConfig(cast=[Enum]),
|
|
184
|
+
)
|
|
185
|
+
performance_metrics = PerformanceMetrics.from_reports(reports)
|
|
186
|
+
|
|
187
|
+
instance_info = from_dict(
|
|
188
|
+
data_class=InstanceInfo,
|
|
189
|
+
data=data.get("instance_info", {}),
|
|
190
|
+
config=DaciteConfig(cast=[Enum]),
|
|
191
|
+
)
|
|
192
|
+
performance_metrics.instance_info = instance_info
|
|
193
|
+
|
|
194
|
+
return performance_metrics
|
|
195
|
+
|
|
196
|
+
def save_baseline(self, metrics: PerformanceMetrics) -> None:
|
|
197
|
+
"""Save baseline to file.
|
|
198
|
+
|
|
199
|
+
:param metrics: PerformanceMetrics object to save as baseline.
|
|
200
|
+
"""
|
|
201
|
+
with self.baseline_file.open("w") as f:
|
|
202
|
+
json.dump(asdict(metrics), f, indent=4)
|
|
203
|
+
|
|
204
|
+
def load_baseline(self) -> PerformanceMetrics | None:
|
|
205
|
+
"""Load baseline from file.
|
|
206
|
+
|
|
207
|
+
:returns: PerformanceMetrics object if baseline exists, None otherwise.
|
|
208
|
+
"""
|
|
209
|
+
if not self.baseline_file.exists():
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
with self.baseline_file.open() as f:
|
|
213
|
+
data = json.load(f)
|
|
214
|
+
|
|
215
|
+
return from_dict(data_class=PerformanceMetrics, data=data)
|
|
216
|
+
|
|
217
|
+
def compare_metrics(self, current: PerformanceMetrics, baseline: PerformanceMetrics) -> list[MetricComparison]:
|
|
218
|
+
"""Compare current results with baseline.
|
|
219
|
+
|
|
220
|
+
:param current: Current performance metrics to compare.
|
|
221
|
+
:param baseline: Baseline performance metrics to compare against.
|
|
222
|
+
:returns: List of MetricComparison objects detailing the differences.
|
|
223
|
+
"""
|
|
224
|
+
comparisons = []
|
|
225
|
+
|
|
226
|
+
# Compare global RPS
|
|
227
|
+
current_rps = current.rps
|
|
228
|
+
baseline_rps = baseline.rps
|
|
229
|
+
rps_change = ((current_rps - baseline_rps) / baseline_rps) if baseline_rps > 0 else 0
|
|
230
|
+
rps_regression = rps_change < -self.regression_threshold
|
|
231
|
+
comparisons.append(
|
|
232
|
+
MetricComparison(
|
|
233
|
+
metric="Global RPS",
|
|
234
|
+
baseline_value=baseline_rps,
|
|
235
|
+
current_value=current_rps,
|
|
236
|
+
change_pct=rps_change,
|
|
237
|
+
regression=rps_regression,
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# Compare per-endpoint RPS
|
|
242
|
+
for ep, current_stats in current.endpoint_stats.items():
|
|
243
|
+
if ep == "global":
|
|
244
|
+
continue
|
|
245
|
+
if ep in baseline.endpoint_stats:
|
|
246
|
+
current_ep_rps = current_stats.rps
|
|
247
|
+
baseline_ep_rps = baseline.endpoint_stats[ep].rps
|
|
248
|
+
ep_rps_change = ((current_ep_rps - baseline_ep_rps) / baseline_ep_rps) if baseline_ep_rps > 0 else 0
|
|
249
|
+
ep_rps_regression = ep_rps_change < -self.regression_threshold
|
|
250
|
+
comparisons.append(
|
|
251
|
+
MetricComparison(
|
|
252
|
+
metric=f"{ep} RPS",
|
|
253
|
+
baseline_value=baseline_ep_rps,
|
|
254
|
+
current_value=current_ep_rps,
|
|
255
|
+
change_pct=ep_rps_change,
|
|
256
|
+
regression=ep_rps_regression,
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Compare per-endpoint max RPS
|
|
261
|
+
for ep, current_stats in current.individual_capacities.items():
|
|
262
|
+
if ep in baseline.individual_capacities:
|
|
263
|
+
baseline_ep_rps = baseline.individual_capacities[ep].rps
|
|
264
|
+
current_ep_rps = current_stats.rps
|
|
265
|
+
ep_rps_change = ((current_ep_rps - baseline_ep_rps) / baseline_ep_rps) if baseline_ep_rps > 0 else 0
|
|
266
|
+
ep_rps_regression = ep_rps_change < -self.regression_threshold
|
|
267
|
+
comparisons.append(
|
|
268
|
+
MetricComparison(
|
|
269
|
+
metric=f"{ep} Max RPS",
|
|
270
|
+
baseline_value=baseline_ep_rps,
|
|
271
|
+
current_value=current_ep_rps,
|
|
272
|
+
change_pct=ep_rps_change,
|
|
273
|
+
regression=ep_rps_regression,
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
return comparisons
|
|
278
|
+
|
|
279
|
+
@staticmethod
|
|
280
|
+
def compare_instance_info(current: PerformanceMetrics, baseline: PerformanceMetrics) -> list[InstanceComparison]:
|
|
281
|
+
"""Compare instance information between current and baseline.
|
|
282
|
+
|
|
283
|
+
:param current: Current performance metrics containing instance info.
|
|
284
|
+
:param baseline: Baseline performance metrics containing instance info.
|
|
285
|
+
:returns: List of InstanceComparison objects detailing the differences.
|
|
286
|
+
"""
|
|
287
|
+
comparisons = []
|
|
288
|
+
|
|
289
|
+
if current.instance_info and baseline.instance_info:
|
|
290
|
+
if current.instance_info.instance_type != baseline.instance_info.instance_type:
|
|
291
|
+
comparisons.append(
|
|
292
|
+
InstanceComparison(
|
|
293
|
+
field="instance type",
|
|
294
|
+
baseline_value=baseline.instance_info.instance_type,
|
|
295
|
+
current_value=current.instance_info.instance_type,
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
if current.instance_info.cpu_count_physical_cores != baseline.instance_info.cpu_count_physical_cores:
|
|
299
|
+
comparisons.append(
|
|
300
|
+
InstanceComparison(
|
|
301
|
+
field="CPU physical cores",
|
|
302
|
+
baseline_value=baseline.instance_info.cpu_count_physical_cores,
|
|
303
|
+
current_value=current.instance_info.cpu_count_physical_cores,
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
if current.instance_info.cpu_count_logical_cores != baseline.instance_info.cpu_count_logical_cores:
|
|
307
|
+
comparisons.append(
|
|
308
|
+
InstanceComparison(
|
|
309
|
+
field="CPU logical cores",
|
|
310
|
+
baseline_value=baseline.instance_info.cpu_count_logical_cores,
|
|
311
|
+
current_value=current.instance_info.cpu_count_logical_cores,
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
if current.instance_info.cpu_model != baseline.instance_info.cpu_model:
|
|
315
|
+
comparisons.append(
|
|
316
|
+
InstanceComparison(
|
|
317
|
+
field="CPU model",
|
|
318
|
+
baseline_value=baseline.instance_info.cpu_model,
|
|
319
|
+
current_value=current.instance_info.cpu_model,
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
return comparisons
|
|
324
|
+
|
|
325
|
+
def print_and_save_final_report(
|
|
326
|
+
self,
|
|
327
|
+
current: PerformanceMetrics,
|
|
328
|
+
metric_comparisons: list[MetricComparison] | None = None,
|
|
329
|
+
instance_comparisons: list[InstanceComparison] | None = None,
|
|
330
|
+
output_file: str | None = None,
|
|
331
|
+
) -> None:
|
|
332
|
+
"""Print comprehensive analysis report.
|
|
333
|
+
|
|
334
|
+
:param current: Current performance metrics to report.
|
|
335
|
+
:param metric_comparisons: Optional list of metric comparisons with baseline.
|
|
336
|
+
:param instance_comparisons: Optional list of instance comparisons with baseline.
|
|
337
|
+
:param output_file: Optional path to save the report (without extension).
|
|
338
|
+
"""
|
|
339
|
+
report_buffer = io.StringIO()
|
|
340
|
+
|
|
341
|
+
def write_line(line: str) -> None:
|
|
342
|
+
"""Write a line to both console and report buffer.
|
|
343
|
+
|
|
344
|
+
:param line: The line to write.
|
|
345
|
+
"""
|
|
346
|
+
print(line)
|
|
347
|
+
report_buffer.write(line + "\n")
|
|
348
|
+
|
|
349
|
+
write_line("=" * 80)
|
|
350
|
+
write_line("LOAD TEST ANALYSIS REPORT")
|
|
351
|
+
write_line("=" * 80)
|
|
352
|
+
|
|
353
|
+
# Instance information
|
|
354
|
+
if current.instance_info:
|
|
355
|
+
write_line("\n🖥️ Instance Information:")
|
|
356
|
+
write_line(f" OS: {current.instance_info.os_name} {current.instance_info.os_version}")
|
|
357
|
+
write_line(f" Instance Type: {current.instance_info.instance_type}")
|
|
358
|
+
write_line(f" CPU Physical Cores: {current.instance_info.cpu_count_physical_cores}")
|
|
359
|
+
write_line(f" CPU Logical Cores: {current.instance_info.cpu_count_logical_cores}")
|
|
360
|
+
write_line(f" CPU Model: {current.instance_info.cpu_model}")
|
|
361
|
+
write_line(f" Memory (GB): {current.instance_info.memory_gb}")
|
|
362
|
+
|
|
363
|
+
# Current test summary
|
|
364
|
+
write_line("\n📊 Production Configuration Results:")
|
|
365
|
+
write_line(f" P99 Latency: {current.p99_ms:.1f}ms")
|
|
366
|
+
write_line(f" Total RPS: {current.rps:.2f}")
|
|
367
|
+
|
|
368
|
+
write_line("\n📈 Per-Endpoint Performance:")
|
|
369
|
+
for endpoint, stats in current.endpoint_stats.items():
|
|
370
|
+
write_line(f" {endpoint}:")
|
|
371
|
+
write_line(f" P99: {stats.p99_ms:.1f}ms")
|
|
372
|
+
write_line(f" RPS: {stats.rps:.2f}")
|
|
373
|
+
|
|
374
|
+
# Individual endpoint capacities
|
|
375
|
+
write_line("\n💪 Individual Endpoint Capacities (100% traffic):")
|
|
376
|
+
for ep, stats in current.individual_capacities.items():
|
|
377
|
+
write_line(f" {ep}:")
|
|
378
|
+
write_line(f" P99: {stats.p99_ms:.1f}ms")
|
|
379
|
+
write_line(f" Max RPS: {stats.rps:.2f}")
|
|
380
|
+
|
|
381
|
+
# Comparison with baseline
|
|
382
|
+
if metric_comparisons:
|
|
383
|
+
write_line("\n" + "=" * 80)
|
|
384
|
+
write_line("COMPARISON WITH BASELINE")
|
|
385
|
+
write_line("=" * 80)
|
|
386
|
+
|
|
387
|
+
has_regression = any(c.regression for c in metric_comparisons)
|
|
388
|
+
|
|
389
|
+
for comp in metric_comparisons:
|
|
390
|
+
write_line(f" {comp}")
|
|
391
|
+
|
|
392
|
+
if has_regression:
|
|
393
|
+
write_line("\n⚠️ PERFORMANCE REGRESSION DETECTED")
|
|
394
|
+
else:
|
|
395
|
+
write_line("\n✅ PERFORMANCE ACCEPTABLE")
|
|
396
|
+
|
|
397
|
+
# Warnings if there are instance differences
|
|
398
|
+
if instance_comparisons:
|
|
399
|
+
write_line("\n⚠️ Instance Differences Detected:")
|
|
400
|
+
for warning in instance_comparisons:
|
|
401
|
+
write_line(f" {warning}")
|
|
402
|
+
write_line("\n Note: Performance comparisons may not be valid across different instance types")
|
|
403
|
+
|
|
404
|
+
# Rate limit recommendations
|
|
405
|
+
write_line("\n" + "=" * 80)
|
|
406
|
+
write_line("RECOMMENDED RATE LIMITS")
|
|
407
|
+
write_line("=" * 80)
|
|
408
|
+
|
|
409
|
+
write_line(f"\n🔒 Rate Limits (Safety Factor: {self.safety_factor * 100:.0f}%):")
|
|
410
|
+
|
|
411
|
+
rate_limits, total_standard, total_burst = compute_rate_limits(
|
|
412
|
+
current.individual_capacities, current.endpoint_stats, self.safety_factor
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
for ep, limits in rate_limits.items():
|
|
416
|
+
if limits["per_second"] is None:
|
|
417
|
+
write_line(f"⚠️Warning: No valid stats for endpoint {ep}, rate limits may be incomplete")
|
|
418
|
+
per_second_display: int | str = limits["per_second"] if limits["per_second"] is not None else "N/A"
|
|
419
|
+
burst_display: int | str = limits["burst"] if limits["burst"] is not None else "N/A"
|
|
420
|
+
write_line(f" {ep}:")
|
|
421
|
+
if per_second_display != "N/A":
|
|
422
|
+
write_line(f" Standard Limit: {per_second_display} req/s")
|
|
423
|
+
if burst_display != "N/A":
|
|
424
|
+
write_line(f" Burst Limit: {burst_display} req/s")
|
|
425
|
+
|
|
426
|
+
if total_standard is not None and total_burst is not None:
|
|
427
|
+
write_line("\n GLOBAL TOTALS:")
|
|
428
|
+
write_line(f" Standard Limit: {total_standard} req/s")
|
|
429
|
+
write_line(f" Burst Limit: {total_burst} req/s")
|
|
430
|
+
|
|
431
|
+
write_line("\n" + "=" * 80)
|
|
432
|
+
|
|
433
|
+
# Save test report to file if specified
|
|
434
|
+
if output_file:
|
|
435
|
+
with open(f"{output_file}.txt", "w") as f:
|
|
436
|
+
f.write(report_buffer.getvalue())
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def main() -> None:
|
|
440
|
+
"""Main entry point for the load test analyzer."""
|
|
441
|
+
parser = argparse.ArgumentParser(description="Analyze load test results and manage performance baselines")
|
|
442
|
+
parser.add_argument(
|
|
443
|
+
"--input-file",
|
|
444
|
+
default="report_loadtest_results.json",
|
|
445
|
+
help="Path to experiment results JSON file",
|
|
446
|
+
)
|
|
447
|
+
parser.add_argument(
|
|
448
|
+
"--output-file",
|
|
449
|
+
default="report_analysis_results",
|
|
450
|
+
help="Path to save analysis results without extension (default: report_analysis_results)",
|
|
451
|
+
)
|
|
452
|
+
parser.add_argument(
|
|
453
|
+
"--baseline",
|
|
454
|
+
default="baseline.json",
|
|
455
|
+
help="Path to baseline JSON file",
|
|
456
|
+
)
|
|
457
|
+
parser.add_argument(
|
|
458
|
+
"--update-baseline",
|
|
459
|
+
action="store_true",
|
|
460
|
+
help="Update baseline with current results",
|
|
461
|
+
)
|
|
462
|
+
parser.add_argument(
|
|
463
|
+
"--safety-factor",
|
|
464
|
+
type=float,
|
|
465
|
+
default=0.7,
|
|
466
|
+
help="Safety factor for rate limits (default: 0.7 = 70%)",
|
|
467
|
+
)
|
|
468
|
+
parser.add_argument(
|
|
469
|
+
"--regression-threshold",
|
|
470
|
+
type=float,
|
|
471
|
+
default=0.1,
|
|
472
|
+
help="Percentage threshold for regression detection (default: 0.1 = 10%)",
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
args = parser.parse_args()
|
|
476
|
+
|
|
477
|
+
analyzer = LoadTestAnalyzer(
|
|
478
|
+
loadtest_report_file=args.input_file,
|
|
479
|
+
baseline_file=args.baseline,
|
|
480
|
+
safety_factor=args.safety_factor,
|
|
481
|
+
regression_threshold=args.regression_threshold,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
current_metrics = analyzer.load_from_report()
|
|
485
|
+
baseline_metrics = analyzer.load_baseline()
|
|
486
|
+
metric_comparisons, instance_comparisons = None, None
|
|
487
|
+
|
|
488
|
+
if baseline_metrics is None:
|
|
489
|
+
# No baseline exists, create one
|
|
490
|
+
print("📝 No baseline found, creating from current results...")
|
|
491
|
+
analyzer.save_baseline(current_metrics)
|
|
492
|
+
print("✅ Baseline created successfully")
|
|
493
|
+
|
|
494
|
+
elif args.update_baseline:
|
|
495
|
+
# Force update baseline
|
|
496
|
+
print("🔄 Updating baseline with current results...")
|
|
497
|
+
analyzer.save_baseline(current_metrics)
|
|
498
|
+
print("✅ Baseline updated")
|
|
499
|
+
|
|
500
|
+
else:
|
|
501
|
+
# Compare with existing baseline
|
|
502
|
+
print("📊 Comparing with baseline...")
|
|
503
|
+
metric_comparisons = analyzer.compare_metrics(current_metrics, baseline_metrics)
|
|
504
|
+
instance_comparisons = analyzer.compare_instance_info(current_metrics, baseline_metrics)
|
|
505
|
+
|
|
506
|
+
# Print comprehensive report
|
|
507
|
+
analyzer.print_and_save_final_report(
|
|
508
|
+
current=current_metrics,
|
|
509
|
+
metric_comparisons=metric_comparisons,
|
|
510
|
+
instance_comparisons=instance_comparisons,
|
|
511
|
+
output_file=args.output_file,
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
print("\n✅ Analysis complete")
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
if __name__ == "__main__":
|
|
518
|
+
main()
|
ml_loadtest/cli.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""CLI helper to provide path to installed locustfile for direct locust usage."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_locustfile_path() -> str:
|
|
8
|
+
"""Return absolute path to installed locustfile.py.
|
|
9
|
+
|
|
10
|
+
Returns:
|
|
11
|
+
Absolute path string to the locustfile.py in the installed package.
|
|
12
|
+
"""
|
|
13
|
+
package_dir = Path(__file__).parent
|
|
14
|
+
locustfile = package_dir / "locustfile.py"
|
|
15
|
+
return str(locustfile.absolute())
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main() -> None:
|
|
19
|
+
"""CLI entry point that prints locustfile path.
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
locust -f $(ml-loadtest-file) --host http://api:8000 [any locust params]
|
|
23
|
+
"""
|
|
24
|
+
print(get_locustfile_path())
|
|
25
|
+
sys.exit(0)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
main()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Example TaskSet implementations for ml-loadtest demonstration and testing."""
|
|
2
|
+
|
|
3
|
+
from ml_loadtest.examples.demo_tasks import (
|
|
4
|
+
EchoTaskSet,
|
|
5
|
+
HealthCheckTaskSet,
|
|
6
|
+
ImageProcessingTaskSet,
|
|
7
|
+
PredictionTaskSet,
|
|
8
|
+
StatusTaskSet,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"EchoTaskSet",
|
|
13
|
+
"HealthCheckTaskSet",
|
|
14
|
+
"ImageProcessingTaskSet",
|
|
15
|
+
"PredictionTaskSet",
|
|
16
|
+
"StatusTaskSet",
|
|
17
|
+
]
|