Skip to content

Commit 7b6a47d

Browse files
committed
refactor: modularize agentic aggregation
1 parent f339bef commit 7b6a47d

23 files changed

Lines changed: 2331 additions & 1365 deletions

.github/workflows/benchmark-multinode-tmpl.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ jobs:
228228
echo "Run failed: expected $expected_count agentic results, found ${#agentic_results[@]}." >&2
229229
exit 1
230230
fi
231-
# Existence is not enough: utils/agentic/process_agentic_result.py writes the
231+
# Existence is not enough: the agentic aggregation step writes the
232232
# aggregate even when aiperf recorded zero valid requests. Require
233233
# successful requests in every concurrency result.
234234
for result_file in "${agentic_results[@]}"; do

.github/workflows/benchmark-tmpl.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ jobs:
204204
fi
205205
206206
if [ "${{ inputs.scenario-type }}" = "agentic-coding" ]; then
207-
python3 utils/agentic/validate_agentic_result.py \
207+
python3 -m utils.agentic.validation.validate_agentic_result \
208208
results/aiperf_artifacts \
209209
--failed-request-threshold "$AIPERF_FAILED_REQUEST_THRESHOLD"
210210
fi

benchmarks/benchmark_lib.sh

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1527,8 +1527,11 @@ write_agentic_result_json() {
15271527
# this file exists; run_agentic_replay_and_write_outputs separately rejects
15281528
# aggregates whose request error rate exceeds the configured limit.
15291529
local result_dir="$1"
1530-
RESULT_DIR="$result_dir" AGENTIC_OUTPUT_DIR="${AGENTIC_OUTPUT_DIR:-$INFMAX_CONTAINER_WORKSPACE}" \
1531-
"$AIPERF_PYTHON" "$INFMAX_CONTAINER_WORKSPACE/utils/agentic/process_agentic_result.py"
1530+
(
1531+
cd "$INFMAX_CONTAINER_WORKSPACE"
1532+
RESULT_DIR="$result_dir" AGENTIC_OUTPUT_DIR="${AGENTIC_OUTPUT_DIR:-$INFMAX_CONTAINER_WORKSPACE}" \
1533+
"$AIPERF_PYTHON" -m utils.agentic.aggregation.process_agentic_result
1534+
)
15321535

15331536
# Generate metrics_plots.png from the same aiperf artifacts. Best-effort:
15341537
# don't fail the launcher if plot generation has trouble (e.g. matplotlib
@@ -1556,9 +1559,12 @@ run_agentic_replay_and_write_outputs() {
15561559
"$result_dir/aiperf_artifacts" -o "$result_dir" 2>&1 || true
15571560

15581561
set +e
1559-
"$AIPERF_PYTHON" "$INFMAX_CONTAINER_WORKSPACE/utils/agentic/validate_agentic_result.py" \
1560-
"$result_dir/aiperf_artifacts" \
1561-
--failed-request-threshold "$AIPERF_FAILED_REQUEST_THRESHOLD"
1562+
(
1563+
cd "$INFMAX_CONTAINER_WORKSPACE"
1564+
"$AIPERF_PYTHON" -m utils.agentic.validation.validate_agentic_result \
1565+
"$result_dir/aiperf_artifacts" \
1566+
--failed-request-threshold "$AIPERF_FAILED_REQUEST_THRESHOLD"
1567+
)
15621568
validation_rc=$?
15631569
set -e
15641570

utils/agentic/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
"""Agentic benchmark utilities."""
2+
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
"""Agentic result aggregation utilities."""
2+
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"""Shared helpers for agentic aggregate generation."""
2+
3+
from __future__ import annotations
4+
5+
import math
6+
import statistics
7+
from collections.abc import Callable
8+
from typing import Any
9+
10+
11+
def percentile(data: list[float], p: float) -> float:
12+
if not data:
13+
return 0.0
14+
sorted_data = sorted(data)
15+
k = (len(sorted_data) - 1) * (p / 100)
16+
f = int(k)
17+
c = f + 1
18+
if c >= len(sorted_data):
19+
return sorted_data[f]
20+
return sorted_data[f] + (k - f) * (sorted_data[c] - sorted_data[f])
21+
22+
23+
def stats_for(prefix: str, values: list[float]) -> dict[str, float]:
24+
if not values:
25+
return {}
26+
return {
27+
f"mean_{prefix}": statistics.mean(values),
28+
f"p50_{prefix}": percentile(values, 50),
29+
f"p75_{prefix}": percentile(values, 75),
30+
f"p90_{prefix}": percentile(values, 90),
31+
f"p95_{prefix}": percentile(values, 95),
32+
f"std_{prefix}": statistics.pstdev(values) if len(values) > 1 else 0.0,
33+
}
34+
35+
36+
def to_float(value: Any) -> float | None:
37+
if value is None:
38+
return None
39+
try:
40+
return float(value)
41+
except (TypeError, ValueError):
42+
return None
43+
44+
45+
def to_int(value: Any) -> int | None:
46+
if value is None:
47+
return None
48+
try:
49+
return int(value)
50+
except (TypeError, ValueError):
51+
return None
52+
53+
54+
def rate(numerator: float | int | None, denominator: float | int | None) -> float | None:
55+
if numerator is None or denominator is None:
56+
return None
57+
if denominator <= 0:
58+
return None
59+
return float(numerator) / float(denominator)
60+
61+
62+
def normalize_fraction(value: float | None) -> float | None:
63+
"""Normalize gauges that may be exported as 0..1 or 0..100."""
64+
if value is None:
65+
return None
66+
if value > 1.5:
67+
return value / 100.0
68+
return value
69+
70+
71+
def round_floats(obj: Any, decimal_places: int = 5) -> Any:
72+
"""Round every finite float in a nested JSON-like object."""
73+
if isinstance(obj, float):
74+
if not math.isfinite(obj):
75+
return obj
76+
rounded = round(obj, decimal_places)
77+
if abs(rounded) >= 1 and rounded.is_integer():
78+
return int(rounded)
79+
return rounded
80+
if isinstance(obj, dict):
81+
return {key: round_floats(value, decimal_places) for key, value in obj.items()}
82+
if isinstance(obj, list):
83+
return [round_floats(value, decimal_places) for value in obj]
84+
return obj
85+
86+
87+
def index_server_metrics(server_metrics: dict[str, Any]) -> dict[str, dict[str, Any]]:
88+
"""Return the metrics dict from aiperf's server_metrics_export.json."""
89+
if not isinstance(server_metrics, dict):
90+
return {}
91+
metrics = server_metrics.get("metrics")
92+
if isinstance(metrics, dict):
93+
return metrics
94+
return {}
95+
96+
97+
def metric_series(
98+
metrics: dict[str, dict[str, Any]],
99+
metric_names: str | list[str],
100+
) -> list[dict[str, Any]]:
101+
names = [metric_names] if isinstance(metric_names, str) else metric_names
102+
out: list[dict[str, Any]] = []
103+
for name in names:
104+
entry = metrics.get(name)
105+
if not isinstance(entry, dict):
106+
continue
107+
series = entry.get("series")
108+
if not isinstance(series, list):
109+
continue
110+
out.extend(s for s in series if isinstance(s, dict))
111+
return out
112+
113+
114+
def series_stat(
115+
series: dict[str, Any],
116+
preferred_keys: tuple[str, ...] = ("total", "sum", "max", "avg"),
117+
) -> float | None:
118+
stats = series.get("stats")
119+
if not isinstance(stats, dict):
120+
return None
121+
for key in preferred_keys:
122+
value = to_float(stats.get(key))
123+
if value is not None:
124+
return value
125+
return None
126+
127+
128+
def sum_stat(
129+
metrics: dict[str, dict[str, Any]],
130+
metric_names: str | list[str],
131+
*,
132+
preferred_keys: tuple[str, ...] = ("total", "sum", "max", "avg"),
133+
series_filter: Callable[[dict[str, Any]], bool] | None = None,
134+
) -> float | None:
135+
total = 0.0
136+
found = False
137+
for series in metric_series(metrics, metric_names):
138+
if series_filter is not None and not series_filter(series):
139+
continue
140+
value = series_stat(series, preferred_keys)
141+
if value is None:
142+
continue
143+
total += value
144+
found = True
145+
return total if found else None
146+
147+
148+
def gauge_stat(
149+
metrics: dict[str, dict[str, Any]],
150+
metric_names: str | list[str],
151+
*,
152+
preferred_keys: tuple[str, ...] = ("max", "avg", "total"),
153+
combine: str = "max",
154+
series_filter: Callable[[dict[str, Any]], bool] | None = None,
155+
) -> float | None:
156+
values: list[float] = []
157+
for series in metric_series(metrics, metric_names):
158+
if series_filter is not None and not series_filter(series):
159+
continue
160+
value = series_stat(series, preferred_keys)
161+
if value is not None:
162+
values.append(value)
163+
if not values:
164+
return None
165+
if combine == "avg":
166+
return statistics.mean(values)
167+
if combine == "sum":
168+
return sum(values)
169+
return max(values)
170+
171+
172+
def label_value(series: dict[str, Any], key: str) -> str | None:
173+
labels = series.get("labels")
174+
if not isinstance(labels, dict):
175+
return None
176+
value = labels.get(key)
177+
if value is None:
178+
return None
179+
return str(value)
180+
181+
182+
def label_equals(key: str, value: str) -> Callable[[dict[str, Any]], bool]:
183+
return lambda series: label_value(series, key) == value
184+
185+
186+
def sum_by_label(
187+
metrics: dict[str, dict[str, Any]],
188+
metric_names: str | list[str],
189+
label_keys: str | list[str],
190+
*,
191+
preferred_keys: tuple[str, ...] = ("total", "sum", "max", "avg"),
192+
) -> dict[str, float]:
193+
keys = [label_keys] if isinstance(label_keys, str) else label_keys
194+
out: dict[str, float] = {}
195+
for series in metric_series(metrics, metric_names):
196+
labels = series.get("labels")
197+
if not isinstance(labels, dict):
198+
continue
199+
label = None
200+
for key in keys:
201+
raw = labels.get(key)
202+
if raw is not None:
203+
label = str(raw)
204+
break
205+
if label is None:
206+
continue
207+
value = series_stat(series, preferred_keys)
208+
if value is None:
209+
continue
210+
out[label] = out.get(label, 0.0) + value
211+
return out

0 commit comments

Comments
 (0)