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.
@@ -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
+ ]