Skip to content

Commit 93bb296

Browse files
authored
Merge pull request #159 from kpouget/kpis2
[llm-d] Finalize the KPI regression testing
2 parents 92ed5dd + 174c77d commit 93bb296

22 files changed

Lines changed: 967 additions & 178 deletions

File tree

docs/caliper/plugin_kpis.md

Lines changed: 391 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,391 @@
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

Comments
 (0)