|
| 1 | +# Plugin KPI Development Guide |
| 2 | + |
| 3 | +This document explains how to build and expose KPIs (Key Performance Indicators) in Caliper plugins. |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +Caliper plugins define KPIs as decorated Python functions that extract performance metrics from test results. The KPI system supports both scalar metrics and 2D performance curves, with rich metadata for visualization and analysis. |
| 8 | + |
| 9 | +## KPI Function Structure |
| 10 | + |
| 11 | +### Basic KPI Function |
| 12 | + |
| 13 | +```python |
| 14 | +from projects.caliper.engine.kpi import KPIMetadata, HigherBetter |
| 15 | + |
| 16 | + |
| 17 | +@HigherBetter() |
| 18 | +@KPIMetadata(help="Request throughput in requests per second", unit="req/s") |
| 19 | +def request_rate(unified_record) -> float: |
| 20 | + """Request Rate KPI.""" |
| 21 | + value = unified_record.metrics.get("request_rate") |
| 22 | + if value is None: |
| 23 | + raise ValueError("request_rate metric not found") |
| 24 | + return float(value) |
| 25 | +``` |
| 26 | + |
| 27 | +### Function Requirements |
| 28 | + |
| 29 | +1. **Function name**: Becomes the KPI ID in output |
| 30 | +2. **Single parameter**: `unified_record` - the parsed test result |
| 31 | +3. **Return type**: `float` for scalar KPIs, `list[tuple[float, float]]` for 2D KPIs |
| 32 | +4. **Docstring**: First line becomes the display name (without " KPI.") |
| 33 | +5. **Decorators**: Required for metadata and behavior |
| 34 | + |
| 35 | +## KPI Decorators |
| 36 | + |
| 37 | +### Required Decorators |
| 38 | + |
| 39 | +**`@KPIMetadata(help, unit)`** |
| 40 | +- `help`: Human-readable description of the metric |
| 41 | +- `unit`: Unit of measurement (e.g., "req/s", "ms", "tokens") |
| 42 | + |
| 43 | +**Comparison Direction** |
| 44 | +- `@HigherBetter()`: Higher values indicate better performance |
| 45 | +- `@LowerBetter()`: Lower values indicate better performance |
| 46 | + |
| 47 | +### Optional Decorators |
| 48 | + |
| 49 | +**`@Format(format_str)`** |
| 50 | +- Specify number formatting (e.g., `"{:.1f}"`, `"{:.2%}"`) |
| 51 | + |
| 52 | +**`@TwoDimensional(x_unit, x_help, y_unit=None, y_help=None)`** |
| 53 | +- Marks KPI as returning 2D data (performance curves) |
| 54 | +- `x_unit`/`x_help`: X-axis unit and description |
| 55 | +- `y_unit`/`y_help`: Y-axis unit and description (defaults to main unit/help) |
| 56 | + |
| 57 | +## 2D KPIs (Performance Curves) |
| 58 | + |
| 59 | +2D KPIs return lists of (x, y) coordinate pairs representing performance curves: |
| 60 | + |
| 61 | +```python |
| 62 | +@HigherBetter() |
| 63 | +@TwoDimensional( |
| 64 | + x_unit="req/s", x_help="Request rate", y_unit="tokens/s", y_help="Achieved throughput" |
| 65 | +) |
| 66 | +@KPIMetadata(help="Throughput achieved at different request rates", unit="tokens/s") |
| 67 | +def throughput_curve(unified_record) -> list[tuple[float, float]]: |
| 68 | + """Throughput vs Request Rate Curve KPI.""" |
| 69 | + request_rates = unified_record.metrics.get("request_rate", []) |
| 70 | + throughputs = unified_record.metrics.get("throughput", []) |
| 71 | + |
| 72 | + return [(float(x), float(y)) for x, y in zip(request_rates, throughputs)] |
| 73 | +``` |
| 74 | + |
| 75 | +### 2D KPI Output Format |
| 76 | + |
| 77 | +2D KPIs generate structured JSON output: |
| 78 | + |
| 79 | +```json |
| 80 | +{ |
| 81 | + "id": "throughput_curve", |
| 82 | + "value": { |
| 83 | + "data_points": [ |
| 84 | + {"x": 1.0, "y": 150.2}, |
| 85 | + {"x": 2.0, "y": 298.5} |
| 86 | + ], |
| 87 | + "count": 2 |
| 88 | + }, |
| 89 | + "higher_is_better": true, |
| 90 | + "is_2d": true, |
| 91 | + "name": "Throughput vs Request Rate Curve", |
| 92 | + "help": "Throughput achieved at different request rates", |
| 93 | + "x_unit": "req/s", |
| 94 | + "x_help": "Request rate", |
| 95 | + "y_unit": "tokens/s", |
| 96 | + "y_help": "Achieved throughput" |
| 97 | +} |
| 98 | +``` |
| 99 | + |
| 100 | +## Plugin Integration |
| 101 | + |
| 102 | +### KPI Function Discovery |
| 103 | + |
| 104 | +KPI functions are discovered through two mechanisms: |
| 105 | + |
| 106 | +1. **During KPI Computation**: The KPI handler uses `get_kpi_functions(inspect.getmodule(KpiHandler))` to find functions in the handler's module |
| 107 | +2. **During Format Transformation**: The hierarchical format transformer attempts to import the plugin module for metadata |
| 108 | + |
| 109 | +```python |
| 110 | +# In KPI handler module (e.g., projects/myplugin/postprocess/myplugin/parsing/kpis.py) |
| 111 | +from projects.caliper.engine.kpi import KPIMetadata, HigherBetter |
| 112 | + |
| 113 | + |
| 114 | +@HigherBetter() |
| 115 | +@KPIMetadata(help="My metric", unit="units") |
| 116 | +def my_kpi_function(unified_record) -> float: |
| 117 | + """My KPI.""" |
| 118 | + return 42.0 |
| 119 | +``` |
| 120 | + |
| 121 | +### Plugin Module Structure |
| 122 | + |
| 123 | +**For KPI Computation**: Functions are discovered in the KPI handler's module using `inspect.getmodule()`. |
| 124 | + |
| 125 | +**For Metadata Extraction**: The hierarchical format transformer attempts to import the main plugin module to extract decorator metadata. If this fails (e.g., due to heavy dependencies), the transformer preserves metadata from the original v1 KPI records, ensuring 2D KPI information (`x_unit`, `y_unit`, `x_help`, `y_help`) is not lost during format conversion. |
| 126 | + |
| 127 | +### Integration with PostProcessingPlugin |
| 128 | + |
| 129 | +Your plugin class should implement the `compute_kpis` method to generate KPI records: |
| 130 | + |
| 131 | +```python |
| 132 | +class MyPlugin(PostProcessingPlugin): |
| 133 | + def compute_kpis(self, model: UnifiedRunModel) -> list[dict[str, Any]]: |
| 134 | + """Compute KPI values from the unified model.""" |
| 135 | + return self.kpi_handler.compute_kpis(model) |
| 136 | +``` |
| 137 | + |
| 138 | +## KPI Handler Pattern |
| 139 | + |
| 140 | +### Standard KPI Handler Implementation |
| 141 | + |
| 142 | +```python |
| 143 | +import inspect |
| 144 | +from projects.caliper.engine.kpi.decorators import get_kpi_functions |
| 145 | + |
| 146 | + |
| 147 | +class MyKpiHandler: |
| 148 | + def compute_kpis(self, model: UnifiedRunModel) -> list[dict[str, Any]]: |
| 149 | + """Generate KPI records from unified model.""" |
| 150 | + kpi_functions = get_kpi_functions(inspect.getmodule(MyKpiHandler)) |
| 151 | + records = [] |
| 152 | + |
| 153 | + for record in model.unified_result_records: |
| 154 | + for kpi_name, kpi_func in kpi_functions.items(): |
| 155 | + try: |
| 156 | + value = kpi_func(record) |
| 157 | + |
| 158 | + # Base KPI record |
| 159 | + kpi_record = { |
| 160 | + "schema_version": "1", |
| 161 | + "kpi_id": kpi_name, |
| 162 | + "value": value, |
| 163 | + "unit": kpi_func._kpi_unit, |
| 164 | + "run_id": record.test_base_path, |
| 165 | + "timestamp": "2024-01-01T00:00:00Z", |
| 166 | + "labels": { |
| 167 | + "higher_is_better": kpi_func._kpi_higher_is_better, |
| 168 | + # Add other labels from record |
| 169 | + }, |
| 170 | + "source": {"test_base_path": record.test_base_path}, |
| 171 | + } |
| 172 | + |
| 173 | + # Add 2D-specific metadata if this is a 2D KPI |
| 174 | + if getattr(kpi_func, "_kpi_is_2d", False): |
| 175 | + kpi_record.update( |
| 176 | + { |
| 177 | + "is_2d": True, |
| 178 | + "x_unit": kpi_func._kpi_x_unit, |
| 179 | + "x_help": kpi_func._kpi_x_help, |
| 180 | + "y_unit": getattr(kpi_func, "_kpi_y_unit", None) |
| 181 | + or kpi_func._kpi_unit, |
| 182 | + "y_help": getattr(kpi_func, "_kpi_y_help", None) |
| 183 | + or kpi_func._kpi_help, |
| 184 | + } |
| 185 | + ) |
| 186 | + |
| 187 | + records.append(kpi_record) |
| 188 | + except Exception as e: |
| 189 | + # Handle missing metrics gracefully |
| 190 | + continue |
| 191 | + |
| 192 | + return records |
| 193 | +``` |
| 194 | + |
| 195 | +## Best Practices |
| 196 | + |
| 197 | +### 1. Error Handling |
| 198 | + |
| 199 | +```python |
| 200 | +@HigherBetter() |
| 201 | +@KPIMetadata(help="Robust metric", unit="units") |
| 202 | +def robust_kpi(unified_record) -> float: |
| 203 | + """Robust KPI.""" |
| 204 | + value = unified_record.metrics.get("my_metric") |
| 205 | + if value is None: |
| 206 | + raise ValueError("my_metric not found in record") |
| 207 | + |
| 208 | + # Validate data type |
| 209 | + if not isinstance(value, (int, float)): |
| 210 | + raise ValueError(f"Expected numeric value, got {type(value)}") |
| 211 | + |
| 212 | + return float(value) |
| 213 | +``` |
| 214 | + |
| 215 | +### 2. Label Extraction |
| 216 | + |
| 217 | +```python |
| 218 | +def extract_test_labels(record) -> dict[str, Any]: |
| 219 | + """Extract labels for KPI records.""" |
| 220 | + return { |
| 221 | + "platform": record.distinguishing_labels.get("platform", "unknown"), |
| 222 | + "model": record.metrics.get("model_name", "unknown"), |
| 223 | + "version": record.metrics.get("product_version", "unknown"), |
| 224 | + } |
| 225 | +``` |
| 226 | + |
| 227 | +### 3. 2D Data Validation |
| 228 | + |
| 229 | +```python |
| 230 | +@TwoDimensional(x_unit="x", x_help="X values", y_unit="y", y_help="Y values") |
| 231 | +@KPIMetadata(help="Performance curve", unit="y") |
| 232 | +def performance_curve(unified_record) -> list[tuple[float, float]]: |
| 233 | + """Performance Curve KPI.""" |
| 234 | + x_values = unified_record.metrics.get("x_data", []) |
| 235 | + y_values = unified_record.metrics.get("y_data", []) |
| 236 | + |
| 237 | + if len(x_values) != len(y_values): |
| 238 | + raise ValueError("X and Y data arrays must have same length") |
| 239 | + |
| 240 | + if not x_values: |
| 241 | + return [] # Return empty list for missing data |
| 242 | + |
| 243 | + return [(float(x), float(y)) for x, y in zip(x_values, y_values)] |
| 244 | +``` |
| 245 | + |
| 246 | +### 4. Conditional KPIs |
| 247 | + |
| 248 | +```python |
| 249 | +@HigherBetter() |
| 250 | +@KPIMetadata(help="Optional metric", unit="units") |
| 251 | +def optional_kpi(unified_record) -> float: |
| 252 | + """Optional KPI.""" |
| 253 | + # Only compute for certain test types |
| 254 | + if not unified_record.metrics.get("enable_optional_metrics", False): |
| 255 | + raise ValueError("Optional metrics disabled for this test") |
| 256 | + |
| 257 | + return unified_record.metrics.get("optional_value", 0.0) |
| 258 | +``` |
| 259 | + |
| 260 | +### 5. 2D Metadata Preservation |
| 261 | + |
| 262 | +Always include 2D metadata in your KPI handler implementation to ensure proper display: |
| 263 | + |
| 264 | +```python |
| 265 | +# In your KPI handler's compute_kpis method |
| 266 | +if getattr(kpi_func, "_kpi_is_2d", False): |
| 267 | + kpi_record.update( |
| 268 | + { |
| 269 | + "is_2d": True, |
| 270 | + "x_unit": kpi_func._kpi_x_unit, |
| 271 | + "x_help": kpi_func._kpi_x_help, |
| 272 | + "y_unit": getattr(kpi_func, "_kpi_y_unit", None) or kpi_func._kpi_unit, |
| 273 | + "y_help": getattr(kpi_func, "_kpi_y_help", None) or kpi_func._kpi_help, |
| 274 | + } |
| 275 | + ) |
| 276 | +``` |
| 277 | + |
| 278 | +This metadata is preserved during v1→v2 format transformation even if the plugin module cannot be imported due to dependencies. |
| 279 | + |
| 280 | +## Output Formats |
| 281 | + |
| 282 | +### Hierarchical JSON (Schema v2) |
| 283 | + |
| 284 | +The default output format groups KPIs by test with metadata: |
| 285 | + |
| 286 | +```json |
| 287 | +{ |
| 288 | + "schema_version": "2", |
| 289 | + "tests": [ |
| 290 | + { |
| 291 | + "run_id": "test_001", |
| 292 | + "labels": {"platform": "gpu", "model": "llama"}, |
| 293 | + "metadata": {"timestamp": "2024-01-01T00:00:00Z"}, |
| 294 | + "kpis": [ |
| 295 | + { |
| 296 | + "id": "throughput", |
| 297 | + "value": 150.5, |
| 298 | + "higher_is_better": true, |
| 299 | + "is_2d": false, |
| 300 | + "unit": "tokens/s", |
| 301 | + "name": "Throughput", |
| 302 | + "help": "Token generation rate" |
| 303 | + } |
| 304 | + ] |
| 305 | + } |
| 306 | + ] |
| 307 | +} |
| 308 | +``` |
| 309 | + |
| 310 | +### JSONL Format (Schema v1) |
| 311 | + |
| 312 | +Legacy flat format with one KPI per line: |
| 313 | + |
| 314 | +```json |
| 315 | +{"kpi_id": "throughput", "value": 150.5, "unit": "tokens/s", "run_id": "test_001", ...} |
| 316 | +``` |
| 317 | + |
| 318 | +## Testing KPIs |
| 319 | + |
| 320 | +### Unit Testing KPI Functions |
| 321 | + |
| 322 | +```python |
| 323 | +import pytest |
| 324 | +from unittest.mock import Mock |
| 325 | + |
| 326 | + |
| 327 | +def test_throughput_kpi(): |
| 328 | + # Create mock record |
| 329 | + mock_record = Mock() |
| 330 | + mock_record.metrics = {"throughput": 150.5} |
| 331 | + |
| 332 | + # Test KPI function |
| 333 | + result = throughput_kpi(mock_record) |
| 334 | + |
| 335 | + assert result == 150.5 |
| 336 | + assert isinstance(result, float) |
| 337 | + |
| 338 | + |
| 339 | +def test_throughput_kpi_missing_data(): |
| 340 | + mock_record = Mock() |
| 341 | + mock_record.metrics = {} |
| 342 | + |
| 343 | + with pytest.raises(ValueError, match="throughput metric not found"): |
| 344 | + throughput_kpi(mock_record) |
| 345 | +``` |
| 346 | + |
| 347 | +### Integration Testing |
| 348 | + |
| 349 | +```python |
| 350 | +def test_kpi_generation(sample_unified_model): |
| 351 | + handler = MyKpiHandler() |
| 352 | + kpis = handler.compute_kpis(sample_unified_model) |
| 353 | + |
| 354 | + assert len(kpis) > 0 |
| 355 | + assert all("kpi_id" in kpi for kpi in kpis) |
| 356 | + assert all("value" in kpi for kpi in kpis) |
| 357 | +``` |
| 358 | + |
| 359 | +## Common Patterns |
| 360 | + |
| 361 | +### Aggregation KPIs |
| 362 | + |
| 363 | +```python |
| 364 | +@HigherBetter() |
| 365 | +@KPIMetadata(help="Average performance across tests", unit="req/s") |
| 366 | +def average_performance(unified_record) -> float: |
| 367 | + """Average Performance KPI.""" |
| 368 | + values = unified_record.metrics.get("performance_samples", []) |
| 369 | + if not values: |
| 370 | + raise ValueError("No performance samples found") |
| 371 | + |
| 372 | + return sum(values) / len(values) |
| 373 | +``` |
| 374 | + |
| 375 | +### Derived Metrics |
| 376 | + |
| 377 | +```python |
| 378 | +@LowerBetter() |
| 379 | +@KPIMetadata(help="Efficiency ratio", unit="ratio") |
| 380 | +def efficiency_ratio(unified_record) -> float: |
| 381 | + """Efficiency Ratio KPI.""" |
| 382 | + throughput = unified_record.metrics.get("throughput") |
| 383 | + latency = unified_record.metrics.get("latency") |
| 384 | + |
| 385 | + if not throughput or not latency: |
| 386 | + raise ValueError("Both throughput and latency required") |
| 387 | + |
| 388 | + return throughput / latency |
| 389 | +``` |
| 390 | + |
| 391 | +This guide provides the foundation for implementing robust, well-documented KPIs in Caliper plugins. |
0 commit comments