vxa8502 commited on
Commit
a4508e8
·
0 Parent(s):

GPU Perf Prophet v1 — cross-vendor GPU performance forecasting & recommendation engine

Browse files

Roofline-physics + XGBoost efficiency-gap model predicting inference throughput
across AMD Instinct and NVIDIA GPUs from MLPerf Inference v4.1-v6.0 data plus
self-run AMD Dev Cloud MI300X calibration, served via FastAPI + Streamlit and
deployed to Hugging Face Spaces.

.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Private working notes — session journal, plan docs, checklists. Never commit.
2
+ home/
3
+
4
+ # MLPerf repos — large, not committed; populate via scripts/fetch_mlperf.sh
5
+ data/raw/
6
+
7
+ # Generated outputs
8
+ data/processed/
9
+
10
+ # MLflow tracking
11
+ mlruns/
12
+ mlartifacts/
13
+
14
+ # Model artifacts
15
+ *.pkl
16
+ *.joblib
17
+ *.ubj
18
+ *.bin
19
+
20
+ # Python
21
+ __pycache__/
22
+ *.py[cod]
23
+ *.pyo
24
+ .pytest_cache/
25
+ .coverage
26
+ htmlcov/
27
+ dist/
28
+ build/
29
+ *.egg-info/
30
+
31
+ # Environments
32
+ .venv/
33
+ venv/
34
+ .env
35
+
36
+ # Claude Code config (local only — contains machine-specific paths)
37
+ .claude/
38
+
39
+ # IDE
40
+ .vscode/
41
+ .idea/
42
+ *.swp
43
+
44
+ # macOS
45
+ .DS_Store
46
+
47
+ # Jupyter
48
+ .ipynb_checkpoints/
Dockerfile ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GPU Perf Prophet — CPU-only HF Spaces compatible image
2
+ # Target: 2 vCPU / 16 GB RAM (HF Spaces free tier)
3
+ # Build context: project root (includes data/models/, data/gpu_specs.yaml,
4
+ # data/pricing.yaml, src/, app/)
5
+
6
+ FROM python:3.11-slim
7
+
8
+ WORKDIR /app
9
+
10
+ # Install system deps required by some Python packages
11
+ RUN apt-get update && apt-get install -y --no-install-recommends \
12
+ libgomp1 \
13
+ && rm -rf /var/lib/apt/lists/*
14
+
15
+ # Install Python deps first (layer cached unless requirements.txt changes)
16
+ COPY requirements.txt .
17
+ RUN pip install --no-cache-dir -r requirements.txt
18
+
19
+ # Create a non-root user; HF Spaces also runs as UID 1000 by convention.
20
+ RUN useradd -m -u 1000 appuser
21
+
22
+ # Copy source and data artifacts
23
+ COPY src/ src/
24
+ COPY app/ app/
25
+ COPY data/gpu_specs.yaml data/gpu_specs.yaml
26
+ COPY data/pricing.yaml data/pricing.yaml
27
+ COPY data/models/ data/models/
28
+
29
+ # Transfer ownership before dropping privileges.
30
+ RUN chown -R appuser /app
31
+
32
+ USER appuser
33
+
34
+ # HF Spaces expects the app to listen on port 7860
35
+ ENV PORT=7860
36
+
37
+ # Expose both ports: Streamlit (7860) and FastAPI (8000)
38
+ EXPOSE 7860
39
+ EXPOSE 8000
40
+
41
+ # Default: run Streamlit UI
42
+ # Override CMD to run FastAPI instead:
43
+ # docker run ... uvicorn src.api.main:app --host 0.0.0.0 --port 8000
44
+ CMD ["streamlit", "run", "app/streamlit_app.py", \
45
+ "--server.port=7860", "--server.address=0.0.0.0", \
46
+ "--server.headless=true", "--browser.gatherUsageStats=false"]
README.md ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: GPU Perf Prophet
3
+ emoji: ⚡
4
+ colorFrom: purple
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # GPU Perf Prophet
12
+
13
+ **Cross-vendor LLM inference performance forecasting and hardware recommendation engine.**
14
+
15
+ Predict throughput (tokens/sec) for major LLM workloads across AMD Instinct and NVIDIA GPU families — then get a Pareto-optimal recommendation ranked by throughput, cost-efficiency, and VRAM fit.
16
+
17
+ ---
18
+
19
+ ## What it does
20
+
21
+ 1. **Forecasts** per-GPU inference throughput for 5 LLM models × 8 GPU SKUs using a roofline physics model corrected by XGBoost trained on MLPerf Inference v4.1–v6.0 (1,112 benchmark rows).
22
+ 2. **Recommends** GPUs via multi-objective Pareto ranking across throughput, cost-efficiency (tok/$), and VRAM headroom.
23
+ 3. **Covers AMD Instinct** MI300X, MI325X, MI355X alongside NVIDIA H100, H200, A100, L4, RTX 4090.
24
+
25
+ ## Supported workloads
26
+
27
+ | LLM | Params |
28
+ |-----|--------|
29
+ | Llama 2 70B | 70B |
30
+ | Llama 3.1 8B | 8B |
31
+ | Llama 3.1 405B | 405B |
32
+ | Mixtral 8×7B | 46.7B total / 14.1B active |
33
+ | GPT-J 6B | 6B |
34
+
35
+ **Scenarios:** Offline · Server
36
+ **Accuracy tiers:** base (BF16) · 99 (FP8) · 99.9 (FP8 on AMD, FP16 on NVIDIA)
37
+ **Frameworks:** vLLM · TensorRT-LLM · ROCm/other
38
+
39
+ ## How to use
40
+
41
+ 1. Select an LLM model, scenario, accuracy tier, and framework in the sidebar.
42
+ 2. Optionally set a budget cap ($/GPU/hr) or minimum throughput threshold.
43
+ 3. Click **Recommend**.
44
+
45
+ The app returns:
46
+ - **Pareto-optimal GPUs** — no dominated option appears in this list.
47
+ - **Top pick** — the frontier member with the highest cost-efficiency.
48
+ - **Filtered GPUs** — candidates eliminated by VRAM, budget, or throughput constraints.
49
+ - **AMD vs NVIDIA breakdown** — best per-vendor throughput at a glance.
50
+
51
+ ## Model accuracy (LOGO-CV on 5 in-scope GPU SKUs)
52
+
53
+ | Metric | NVIDIA (H100, H200) | AMD (MI300X, MI325X, MI355X) |
54
+ |--------|--------------------|-----------------------------|
55
+ | Mean MAPE | ~21% | ~25% |
56
+ | Spearman ρ | 0.885 | 0.598 |
57
+ | Roofline violations | 0 / 461 rows | 0 / 188 rows |
58
+
59
+ **Use for relative ranking and hardware shortlisting, not precise capacity planning.**
60
+ AMD predictions carry higher uncertainty due to smaller training corpus (188 vs 461 NVIDIA rows in MLPerf).
61
+
62
+ ## Architecture
63
+
64
+ ```
65
+ MLPerf Inference v4.1–v6.0 → Roofline model (physics ceiling)
66
+ → XGBoost efficiency-gap correction (20 features)
67
+ → Multi-objective Pareto recommender
68
+ → FastAPI backend + Streamlit UI
69
+ ```
70
+
71
+ Key design principle borrowed from [NeuSight](https://arxiv.org/abs/2405.12031): physics-bounded ML generalizes to unseen GPUs; pure ML fails.
72
+
73
+ ## Data sources
74
+
75
+ - **MLPerf Inference results** v4.1, v5.0, v5.1, v6.0 — [mlcommons/inference_results_*](https://github.com/mlcommons)
76
+ - **GPU specs** — AMD and NVIDIA product pages (HBM bandwidth, TFLOPS, VRAM)
77
+ - **Pricing** — static estimates as of June 2026 from cloud provider list prices
78
+
79
+ ## Limitations
80
+
81
+ - Prices are static (June 2026). Cloud spot/reserved pricing varies significantly.
82
+ - MI355X predictions have higher variance (50 training rows, CDNA4 architecture with limited cross-GPU training signal).
83
+ - Multi-GPU scaling, training-time workloads, and Blackwell/MI400 families are out of scope.
84
+ - No live API calls — all inference is local to the Docker container.
85
+
86
+ ## Tech stack
87
+
88
+ Python · XGBoost · FastAPI · Streamlit · Docker · MLflow · SHAP
89
+
90
+ ---
91
+
92
+ *Built by [Victoria Alabi](https://github.com/vxa8502) · Trained on MLPerf Inference v4.1–v6.0*
app/streamlit_app.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GPU Perf Prophet — Streamlit UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ # Ensure src/ is importable when running from the project root.
9
+ # Use append (not insert) so the project root is searched LAST — if a file in
10
+ # the project root happened to share a name with a stdlib module, insert(0)
11
+ # would shadow the stdlib for the entire process.
12
+ sys.path.append(str(Path(__file__).parent.parent))
13
+
14
+ import pandas as pd
15
+ import streamlit as st
16
+
17
+ from src.models.predictor import GpuPredictor, VALID_MODELS
18
+ from src.recommend.recommender import GpuRecommender
19
+
20
+ _SORTED_MODELS: list[str] = sorted(VALID_MODELS)
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Page config
24
+ # ---------------------------------------------------------------------------
25
+
26
+ st.set_page_config(
27
+ page_title="GPU Perf Prophet",
28
+ layout="wide",
29
+ )
30
+
31
+ st.title("GPU Perf Prophet")
32
+ st.caption(
33
+ "Cross-vendor LLM inference forecasting · AMD Instinct + NVIDIA · "
34
+ "Powered by roofline physics + XGBoost"
35
+ )
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Load model (cached so it runs only once)
39
+ # ---------------------------------------------------------------------------
40
+
41
+ @st.cache_resource(show_spinner="Loading model …")
42
+ def _load() -> tuple[GpuPredictor, GpuRecommender]:
43
+ pred = GpuPredictor()
44
+ rec = GpuRecommender(pred)
45
+ return pred, rec
46
+
47
+
48
+ predictor, recommender = _load()
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Sidebar — workload inputs
52
+ # ---------------------------------------------------------------------------
53
+
54
+ with st.sidebar:
55
+ st.header("Workload")
56
+
57
+ model_name = st.selectbox(
58
+ "LLM model",
59
+ options=_SORTED_MODELS,
60
+ index=_SORTED_MODELS.index("llama2-70b"),
61
+ )
62
+ scenario = st.selectbox("Scenario", ["Offline", "Server"])
63
+ accuracy_tier = st.selectbox(
64
+ "Accuracy tier",
65
+ ["99", "99.9", "base"],
66
+ help="99 → FP8 | 99.9 → FP8 (AMD) / FP16 (NVIDIA) | base → BF16",
67
+ )
68
+ framework = st.selectbox(
69
+ "Framework",
70
+ ["vllm", "tensorrt", "rocm_other", "other"],
71
+ )
72
+
73
+ st.divider()
74
+ st.header("Constraints (optional)")
75
+ budget = st.number_input(
76
+ "Max $/GPU/hr", min_value=0.0, max_value=20.0,
77
+ value=0.0, step=0.25,
78
+ help="Set to 0 to disable budget filter",
79
+ )
80
+ min_tput = st.number_input(
81
+ "Min throughput (tok/s)", min_value=0.0,
82
+ value=0.0, step=100.0,
83
+ help="Set to 0 to disable throughput filter",
84
+ )
85
+
86
+ run_btn = st.button("Recommend", use_container_width=True, type="primary")
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Main panel
90
+ # ---------------------------------------------------------------------------
91
+
92
+ if not run_btn:
93
+ st.info("Configure your workload in the sidebar and click **Recommend**.")
94
+ st.stop()
95
+
96
+ with st.spinner("Running predictions …"):
97
+ result = recommender.recommend(
98
+ model_name=model_name,
99
+ scenario=scenario,
100
+ accuracy_tier=accuracy_tier,
101
+ framework=framework,
102
+ budget_per_gpu_hr=budget if budget > 0 else None,
103
+ min_throughput_tok_per_sec=min_tput if min_tput > 0 else None,
104
+ )
105
+
106
+ workload = result["workload"]
107
+ frontier = result["frontier"]
108
+ dominated = result["dominated"]
109
+ filtered = result["filtered"]
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Workload summary
113
+ # ---------------------------------------------------------------------------
114
+
115
+ col1, col2, col3, col4 = st.columns(4)
116
+ col1.metric("Model", workload["model_name"])
117
+ col2.metric("Model size", f"{workload['model_size_gb']:.1f} GB")
118
+ col3.metric("Scenario", workload["scenario"])
119
+ col4.metric("Accuracy tier", workload["accuracy_tier"])
120
+
121
+ st.divider()
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Pareto frontier
125
+ # ---------------------------------------------------------------------------
126
+
127
+ _ALL_CANDIDATES = frontier + dominated
128
+
129
+ if not _ALL_CANDIDATES:
130
+ st.warning("No GPUs passed the hard constraints (VRAM fit / budget / throughput).")
131
+ if filtered:
132
+ st.subheader("Filtered GPUs")
133
+ fdf = pd.DataFrame([
134
+ {
135
+ "GPU": r["gpu_name"],
136
+ "Vendor": r["vendor"].upper(),
137
+ "Pred. tput (tok/s)": f"{r['pred_throughput_tok_per_sec']:,.0f}",
138
+ "Reason": r["reject_reason"],
139
+ }
140
+ for r in filtered
141
+ ])
142
+ st.dataframe(fdf, use_container_width=True, hide_index=True)
143
+ st.stop()
144
+
145
+ st.subheader("Pareto-Optimal Recommendations")
146
+ st.caption(
147
+ "GPUs on the Pareto frontier are not dominated by any other candidate "
148
+ "across throughput, cost-efficiency (tok/$), and VRAM headroom."
149
+ )
150
+
151
+ def _make_table(rows: list[dict]) -> pd.DataFrame:
152
+ return pd.DataFrame([
153
+ {
154
+ "GPU": r["gpu_name"],
155
+ "Vendor": r["vendor"].upper(),
156
+ "Pred. tput (tok/s)": f"{r['pred_throughput_tok_per_sec']:,.0f}",
157
+ "Roofline (tok/s)": f"{r['roofline_tput_tok_per_sec']:,.0f}",
158
+ "Efficiency": f"{r['efficiency_ratio']:.2f}×",
159
+ "VRAM (GB)": r["vram_gb"],
160
+ "Model (GB)": f"{r['model_size_gb']:.1f}",
161
+ "VRAM headroom": f"{r['vram_headroom']:.0%}",
162
+ "$/GPU/hr": f"${r['price_per_gpu_hr']:.2f}" if r["price_per_gpu_hr"] else "—",
163
+ "Tok/$": f"{r['cost_efficiency']:,.0f}" if r["cost_efficiency"] else "—",
164
+ }
165
+ for r in rows
166
+ ])
167
+
168
+ if frontier:
169
+ st.dataframe(
170
+ _make_table(frontier),
171
+ use_container_width=True,
172
+ hide_index=True,
173
+ )
174
+
175
+ # Highlight the top pick
176
+ top = frontier[0]
177
+ st.success(
178
+ f"**Top pick: {top['gpu_name']}** — "
179
+ f"{top['pred_throughput_tok_per_sec']:,.0f} tok/s · "
180
+ f"${top['price_per_gpu_hr']:.2f}/hr · "
181
+ f"{top['cost_efficiency']:,.0f} tok/$ · "
182
+ f"{top['vram_headroom']:.0%} VRAM free"
183
+ )
184
+ else:
185
+ st.info("No Pareto-optimal candidates after constraints.")
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # All candidates (dominated)
189
+ # ---------------------------------------------------------------------------
190
+
191
+ if dominated:
192
+ with st.expander(f"Other passing GPUs ({len(dominated)} dominated)", expanded=False):
193
+ st.dataframe(
194
+ _make_table(dominated),
195
+ use_container_width=True,
196
+ hide_index=True,
197
+ )
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Filtered GPUs
201
+ # ---------------------------------------------------------------------------
202
+
203
+ if filtered:
204
+ with st.expander(f"Filtered out ({len(filtered)} GPUs)", expanded=False):
205
+ fdf = pd.DataFrame([
206
+ {
207
+ "GPU": r["gpu_name"],
208
+ "Vendor": r["vendor"].upper(),
209
+ "Pred. tput (tok/s)": f"{r['pred_throughput_tok_per_sec']:,.0f}",
210
+ "Reason": r["reject_reason"],
211
+ }
212
+ for r in filtered
213
+ ])
214
+ st.dataframe(fdf, use_container_width=True, hide_index=True)
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # AMD vs NVIDIA context
218
+ # ---------------------------------------------------------------------------
219
+
220
+ with st.expander("AMD vs NVIDIA breakdown", expanded=False):
221
+ all_rows = _ALL_CANDIDATES
222
+ amd_rows = [r for r in all_rows if r["vendor"] == "amd"]
223
+ nvidia_rows = [r for r in all_rows if r["vendor"] == "nvidia"]
224
+
225
+ c1, c2 = st.columns(2)
226
+ with c1:
227
+ st.markdown("**AMD Instinct**")
228
+ if amd_rows:
229
+ best_amd = max(amd_rows, key=lambda r: r["pred_throughput_tok_per_sec"])
230
+ st.metric("Best throughput", f"{best_amd['pred_throughput_tok_per_sec']:,.0f} tok/s", best_amd["gpu_name"])
231
+ else:
232
+ st.info("No AMD GPUs passed filters.")
233
+ with c2:
234
+ st.markdown("**NVIDIA**")
235
+ if nvidia_rows:
236
+ best_nv = max(nvidia_rows, key=lambda r: r["pred_throughput_tok_per_sec"])
237
+ st.metric("Best throughput", f"{best_nv['pred_throughput_tok_per_sec']:,.0f} tok/s", best_nv["gpu_name"])
238
+ else:
239
+ st.info("No NVIDIA GPUs passed filters.")
240
+
241
+ st.divider()
242
+ st.caption(
243
+ "Predictions use a roofline physics model + XGBoost trained on MLPerf Inference v4.1–v6.0. "
244
+ "Prices are static estimates (June 2026). AMD MAPE ≈ 25%, NVIDIA MAPE ≈ 21% — use for "
245
+ "ranking, not precise capacity planning."
246
+ )
benchmarks/.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __pycache__/
2
+ *.pyc
benchmarks/merge_calibration_rows.py ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Merge MI300X calibration results into the GPU Perf Prophet training set.
4
+
5
+ Run this on your local machine after SCP-ing mi300x_calibration_results.csv
6
+ back from AMD Developer Cloud.
7
+
8
+ Steps
9
+ -----
10
+ 1. Reads mi300x_calibration_results.csv
11
+ 2. Constructs a raw-MLPerf-format DataFrame (same schema as the parser output)
12
+ 3. Calls build_training_df() to compute all features (roofline, precision, etc.)
13
+ 4. Appends to data/processed/mlperf_features.parquet
14
+ 5. Re-trains the production model via src/models/train_final.py
15
+
16
+ Usage
17
+ -----
18
+ python benchmarks/merge_calibration_rows.py --csv benchmarks/mi300x_calibration_results.csv
19
+ python benchmarks/merge_calibration_rows.py --csv benchmarks/mi300x_calibration_results.csv --dry-run
20
+ python benchmarks/merge_calibration_rows.py --csv benchmarks/mi300x_calibration_results.csv --no-retrain
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import logging
26
+ import subprocess
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ import pandas as pd
31
+
32
+ # Ensure project root is on the path (script is in benchmarks/, project root is parent)
33
+ _PROJECT_ROOT = Path(__file__).parent.parent
34
+ sys.path.append(str(_PROJECT_ROOT))
35
+
36
+ from src.features.build_features import build_training_df # noqa: E402
37
+ from src.data.mlperf_parser import TOKENS_PER_SAMPLE # noqa: E402
38
+
39
+ log = logging.getLogger(__name__)
40
+ logging.basicConfig(
41
+ level=logging.INFO,
42
+ format="%(asctime)s %(levelname)-7s %(message)s",
43
+ datefmt="%H:%M:%S",
44
+ )
45
+
46
+ _FEATURES_PARQUET = _PROJECT_ROOT / "data" / "processed" / "mlperf_features.parquet"
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Construct raw-MLPerf-format rows from calibration CSV
50
+ # ---------------------------------------------------------------------------
51
+
52
+ def _build_raw_df(csv_path: Path) -> pd.DataFrame:
53
+ """Read calibration CSV and return a DataFrame in raw-MLPerf parser format.
54
+
55
+ The schema matches what mlperf_parser.py produces, so build_training_df()
56
+ can process it without modification.
57
+ """
58
+ raw = pd.read_csv(csv_path)
59
+
60
+ # Drop failed runs (throughput == 0 means the run errored out)
61
+ failed = raw[raw["throughput_tok_per_sec"].astype(float) == 0.0]
62
+ if len(failed):
63
+ log.warning("Dropping %d failed runs: %s", len(failed),
64
+ failed[["benchmark_base", "precision_used", "scenario"]].to_dict("records"))
65
+ raw = raw[raw["throughput_tok_per_sec"].astype(float) > 0.0].copy()
66
+
67
+ if raw.empty:
68
+ log.error("No successful runs in %s", csv_path)
69
+ sys.exit(1)
70
+
71
+ log.info("Loaded %d successful calibration rows from %s", len(raw), csv_path)
72
+
73
+ rows = []
74
+ for _, r in raw.iterrows():
75
+ bench_base = r["benchmark_base"]
76
+ tier = r["benchmark_accuracy_tier"]
77
+ scenario = r["scenario"]
78
+ tput = float(r["throughput_tok_per_sec"])
79
+ tps = TOKENS_PER_SAMPLE.get(bench_base, TOKENS_PER_SAMPLE.get(f"{bench_base}-{tier}"))
80
+
81
+ benchmark_name = bench_base if tier == "base" else f"{bench_base}-{tier}"
82
+
83
+ rows.append({
84
+ # MLPerf identity fields
85
+ "round": r["round_tag"],
86
+ "division": "open",
87
+ "submitter": "vxa8502",
88
+ "system_name": "1xMI300X_AMD_Dev_Cloud",
89
+ "gpu_name": r["gpu_name"],
90
+ "num_gpus": 1,
91
+ "vram_gb": 192.0, # MI300X per-GPU VRAM (matches spec DB)
92
+ "framework": f"vLLM {r.get('vllm_version', '')}".strip(),
93
+ "system_type": "single_node",
94
+ "hw_status": "available",
95
+
96
+ # Benchmark identity
97
+ "benchmark": benchmark_name,
98
+ "benchmark_base": bench_base,
99
+ "benchmark_accuracy_tier": tier,
100
+ "scenario": scenario,
101
+ "precision": None, # 0% populated in real MLPerf data — correct to leave null
102
+
103
+ # Throughput (the training target)
104
+ "throughput_tokens_per_sec": tput,
105
+ "throughput_tok_per_sec_per_gpu": tput, # single GPU
106
+
107
+ # Derived from throughput + tokens_per_sample
108
+ "throughput_samples_per_sec": tput / tps if tps else None,
109
+ "tokens_per_sample": tps,
110
+
111
+ # Latency fields — not measured in throughput benchmark
112
+ "latency_mean_ms": None,
113
+ "latency_p99_ms": None,
114
+ "ttft_mean_ms": None,
115
+ "ttft_p99_ms": None,
116
+ "tpot_mean_ms": None,
117
+ "tpot_p99_ms": None,
118
+
119
+ # Validation gate — True because we verified the run succeeded
120
+ "result_valid": True,
121
+
122
+ # Provenance
123
+ "log_path": f"benchmarks/results/{r.get('round_tag', 'self-run')}/{bench_base}_{tier}_{scenario}",
124
+ })
125
+
126
+ df = pd.DataFrame(rows)
127
+ log.info("Constructed %d raw rows ready for feature engineering", len(df))
128
+ return df
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Main
133
+ # ---------------------------------------------------------------------------
134
+
135
+ def main() -> None:
136
+ parser = argparse.ArgumentParser(description="Merge MI300X calibration results into training set")
137
+ parser.add_argument(
138
+ "--csv", required=True,
139
+ help="Path to mi300x_calibration_results.csv from AMD Dev Cloud",
140
+ )
141
+ parser.add_argument(
142
+ "--features-parquet", default=str(_FEATURES_PARQUET),
143
+ help="Path to existing mlperf_features.parquet (default: data/processed/mlperf_features.parquet)",
144
+ )
145
+ parser.add_argument(
146
+ "--dry-run", action="store_true",
147
+ help="Build features and print summary but do NOT write to disk or retrain",
148
+ )
149
+ parser.add_argument(
150
+ "--no-retrain", action="store_true",
151
+ help="Write updated parquet but skip model retraining",
152
+ )
153
+ args = parser.parse_args()
154
+
155
+ csv_path = Path(args.csv)
156
+ parquet_path = Path(args.features_parquet)
157
+
158
+ if not csv_path.exists():
159
+ log.error("CSV not found: %s", csv_path)
160
+ sys.exit(1)
161
+ if not parquet_path.exists():
162
+ log.error("Features parquet not found: %s", parquet_path)
163
+ sys.exit(1)
164
+
165
+ # 1 — Build raw rows and run feature engineering
166
+ raw_df = _build_raw_df(csv_path)
167
+ cal_features = build_training_df(raw_df)
168
+
169
+ if cal_features.empty:
170
+ log.error("build_training_df returned 0 rows — check GPU name aliases and benchmark_base values")
171
+ sys.exit(1)
172
+
173
+ log.info(
174
+ "Feature engineering complete: %d rows | "
175
+ "efficiency_ratio range [%.3f, %.3f] | "
176
+ "roofline violations: %d",
177
+ len(cal_features),
178
+ cal_features["efficiency_ratio"].min(),
179
+ cal_features["efficiency_ratio"].max(),
180
+ (cal_features["throughput_tok_per_sec_per_gpu"] > cal_features["roofline_tput"]).sum(),
181
+ )
182
+
183
+ # 2 — Load existing features and check for overlap
184
+ existing = pd.read_parquet(parquet_path)
185
+ log.info("Existing training set: %d rows", len(existing))
186
+
187
+ # Check for duplicates on (submitter, system_name, benchmark, scenario, round, division)
188
+ pk_cols = ["submitter", "system_name", "benchmark", "scenario", "round", "division"]
189
+ overlap_mask = cal_features[pk_cols].apply(tuple, axis=1).isin(
190
+ existing[pk_cols].apply(tuple, axis=1)
191
+ )
192
+ if overlap_mask.any():
193
+ log.warning(
194
+ "Dropping %d calibration rows that are already in the training set (PK collision)",
195
+ overlap_mask.sum(),
196
+ )
197
+ cal_features = cal_features[~overlap_mask]
198
+
199
+ if cal_features.empty:
200
+ log.info("No new rows to add — all calibration configs already present.")
201
+ return
202
+
203
+ # 3 — Print summary
204
+ print("\n=== Calibration rows to add ===")
205
+ print(
206
+ cal_features.groupby(["canonical_gpu_id", "benchmark_base", "benchmark_accuracy_tier", "scenario"])
207
+ [["throughput_tok_per_sec_per_gpu", "efficiency_ratio"]]
208
+ .agg({"throughput_tok_per_sec_per_gpu": "mean", "efficiency_ratio": "mean"})
209
+ .round(3)
210
+ .to_string()
211
+ )
212
+ print(f"\nTotal new rows: {len(cal_features)}")
213
+ print(f"MI300X rows before: {(existing.canonical_gpu_id == 'mi300x').sum()}")
214
+ print(f"MI300X rows after: {(existing.canonical_gpu_id == 'mi300x').sum() + len(cal_features[cal_features.canonical_gpu_id == 'mi300x'])}")
215
+
216
+ if args.dry_run:
217
+ log.info("Dry run — no files written.")
218
+ return
219
+
220
+ # 4 — Concatenate and write
221
+ combined = pd.concat([existing, cal_features], ignore_index=True)
222
+ log.info("Combined training set: %d rows", len(combined))
223
+ combined.to_parquet(parquet_path, index=False)
224
+ log.info("Wrote updated parquet: %s", parquet_path)
225
+
226
+ if args.no_retrain:
227
+ log.info("Skipping retraining (--no-retrain).")
228
+ return
229
+
230
+ # 5 — Retrain
231
+ log.info("Retraining production model via train_final.py ...")
232
+ env = {**__import__("os").environ, "PYTHONPATH": str(_PROJECT_ROOT)}
233
+ result = subprocess.run(
234
+ [sys.executable, str(_PROJECT_ROOT / "src" / "models" / "train_final.py")],
235
+ cwd=str(_PROJECT_ROOT),
236
+ env=env,
237
+ check=False,
238
+ )
239
+ if result.returncode != 0:
240
+ log.error("train_final.py exited with code %d", result.returncode)
241
+ sys.exit(result.returncode)
242
+
243
+ log.info("Retraining complete. Run pytest to verify gate tests still pass:")
244
+ log.info(" pytest -m gate -v")
245
+ log.info(" pytest --cov=src --cov-fail-under=85")
246
+
247
+
248
+ if __name__ == "__main__":
249
+ main()
benchmarks/mi300x_calibration_results.csv ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gpu_name,benchmark_base,benchmark_accuracy_tier,scenario,precision_used,hf_model_id,input_len,output_len,n_samples,total_output_tokens,elapsed_s,throughput_tok_per_sec,round_tag,vllm_version,rocm_version,notes
2
+ AMD Instinct MI300X,gptj,base,Offline,bf16,EleutherAI/gpt-j-6b,1919,128,256,32768,7.382,4438.81,v6.0,0.23.0,ROCM-SMI version: 4.0.0+c2d9476115,
3
+ AMD Instinct MI300X,gptj,base,Server,bf16,EleutherAI/gpt-j-6b,1919,128,64,8192,2.683,3052.99,v6.0,0.23.0,ROCM-SMI version: 4.0.0+c2d9476115,
4
+ AMD Instinct MI300X,gptj,99,Offline,fp8,EleutherAI/gpt-j-6b,1919,128,256,32768,7.047,4649.89,v6.0,0.23.0,ROCM-SMI version: 4.0.0+c2d9476115,
5
+ AMD Instinct MI300X,gptj,99.9,Offline,fp8,EleutherAI/gpt-j-6b,1919,128,256,32768,7.047,4649.89,v6.0,0.23.0,ROCM-SMI version: 4.0.0+c2d9476115,
6
+ AMD Instinct MI300X,gptj,99,Server,fp8,EleutherAI/gpt-j-6b,1919,128,64,8192,2.604,3145.90,v6.0,0.23.0,ROCM-SMI version: 4.0.0+c2d9476115,
7
+ AMD Instinct MI300X,gptj,99.9,Server,fp8,EleutherAI/gpt-j-6b,1919,128,64,8192,2.604,3145.90,v6.0,0.23.0,ROCM-SMI version: 4.0.0+c2d9476115,
8
+ AMD Instinct MI300X,llama3.1-8b,base,Offline,bf16,meta-llama/Meta-Llama-3.1-8B,1024,294,128,37632,5.417,6947.42,v6.0,0.23.0,ROCM-SMI version: 4.0.0+c2d9476115,
9
+ AMD Instinct MI300X,llama3.1-8b,base,Server,bf16,meta-llama/Meta-Llama-3.1-8B,1024,294,32,9408,2.566,3667.02,v6.0,0.23.0,ROCM-SMI version: 4.0.0+c2d9476115,
10
+ AMD Instinct MI300X,llama3.1-8b,99,Offline,fp8,meta-llama/Meta-Llama-3.1-8B,1024,294,128,37632,4.851,7758.06,v6.0,0.23.0,ROCM-SMI version: 4.0.0+c2d9476115,
11
+ AMD Instinct MI300X,llama3.1-8b,99.9,Offline,fp8,meta-llama/Meta-Llama-3.1-8B,1024,294,128,37632,4.851,7758.06,v6.0,0.23.0,ROCM-SMI version: 4.0.0+c2d9476115,
12
+ AMD Instinct MI300X,llama3.1-8b,99,Server,fp8,meta-llama/Meta-Llama-3.1-8B,1024,294,32,9408,2.281,4124.51,v6.0,0.23.0,ROCM-SMI version: 4.0.0+c2d9476115,
13
+ AMD Instinct MI300X,llama3.1-8b,99.9,Server,fp8,meta-llama/Meta-Llama-3.1-8B,1024,294,32,9408,2.281,4124.51,v6.0,0.23.0,ROCM-SMI version: 4.0.0+c2d9476115,
14
+ AMD Instinct MI300X,llama2-70b,base,Offline,bf16,meta-llama/Llama-2-70b-hf,1024,294,64,18816,15.682,1199.83,v6.0,0.23.0,ROCM-SMI version: 4.0.0+c2d9476115,
15
+ AMD Instinct MI300X,llama2-70b,base,Server,bf16,meta-llama/Llama-2-70b-hf,1024,294,16,4704,13.055,360.31,v6.0,0.23.0,ROCM-SMI version: 4.0.0+c2d9476115,
16
+ AMD Instinct MI300X,llama2-70b,99,Offline,fp8,meta-llama/Llama-2-70b-hf,1024,294,64,18816,11.160,1686.04,v6.0,0.23.0,ROCM-SMI version: 4.0.0+c2d9476115,
17
+ AMD Instinct MI300X,llama2-70b,99.9,Offline,fp8,meta-llama/Llama-2-70b-hf,1024,294,64,18816,11.160,1686.04,v6.0,0.23.0,ROCM-SMI version: 4.0.0+c2d9476115,
18
+ AMD Instinct MI300X,llama2-70b,99,Server,fp8,meta-llama/Llama-2-70b-hf,1024,294,16,4704,9.364,502.35,v6.0,0.23.0,ROCM-SMI version: 4.0.0+c2d9476115,
19
+ AMD Instinct MI300X,llama2-70b,99.9,Server,fp8,meta-llama/Llama-2-70b-hf,1024,294,16,4704,9.364,502.35,v6.0,0.23.0,ROCM-SMI version: 4.0.0+c2d9476115,
20
+ AMD Instinct MI300X,mixtral-8x7b,base,Offline,bf16,mistralai/Mixtral-8x7B-v0.1,1024,145,128,18560,4.034,4600.38,v6.0,0.23.0,ROCM-SMI version: 4.0.0+c2d9476115,
21
+ AMD Instinct MI300X,mixtral-8x7b,base,Server,bf16,mistralai/Mixtral-8x7B-v0.1,1024,145,32,4640,2.755,1684.40,v6.0,0.23.0,ROCM-SMI version: 4.0.0+c2d9476115,
22
+ AMD Instinct MI300X,mixtral-8x7b,99,Offline,fp8,mistralai/Mixtral-8x7B-v0.1,1024,145,128,18560,3.355,5531.64,v6.0,0.23.0,ROCM-SMI version: 4.0.0+c2d9476115,
23
+ AMD Instinct MI300X,mixtral-8x7b,99.9,Offline,fp8,mistralai/Mixtral-8x7B-v0.1,1024,145,128,18560,3.355,5531.64,v6.0,0.23.0,ROCM-SMI version: 4.0.0+c2d9476115,
24
+ AMD Instinct MI300X,mixtral-8x7b,99,Server,fp8,mistralai/Mixtral-8x7B-v0.1,1024,145,32,4640,2.001,2318.51,v6.0,0.23.0,ROCM-SMI version: 4.0.0+c2d9476115,
25
+ AMD Instinct MI300X,mixtral-8x7b,99.9,Server,fp8,mistralai/Mixtral-8x7B-v0.1,1024,145,32,4640,2.001,2318.51,v6.0,0.23.0,ROCM-SMI version: 4.0.0+c2d9476115,
benchmarks/run_mi300x_calibration.py ADDED
@@ -0,0 +1,476 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ MI300X calibration benchmark runner for GPU Perf Prophet.
4
+
5
+ Runs on AMD Developer Cloud (ROCm 7.x) with vLLM installed.
6
+ Measures output throughput (tok/s) for each (model, precision, scenario) config
7
+ and appends results to mi300x_calibration_results.csv.
8
+
9
+ Requirements (AMD Dev Cloud):
10
+ pip install vllm>=0.6 huggingface_hub
11
+
12
+ Gated models (llama2-70b, llama3.1-8b) require a HuggingFace token:
13
+ export HF_TOKEN=hf_...
14
+ huggingface-cli login --token $HF_TOKEN
15
+
16
+ Usage:
17
+ python run_mi300x_calibration.py # full sweep
18
+ python run_mi300x_calibration.py --dry-run # print configs only
19
+ python run_mi300x_calibration.py --models gptj mixtral-8x7b # subset
20
+ python run_mi300x_calibration.py --resume # skip completed rows
21
+
22
+ Output:
23
+ mi300x_calibration_results.csv (appended on each run; safe to resume)
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import csv
29
+ import gc
30
+ import logging
31
+ import os
32
+ import sys
33
+ import time
34
+ from dataclasses import dataclass, field
35
+ from pathlib import Path
36
+ from typing import Optional
37
+
38
+ log = logging.getLogger(__name__)
39
+ logging.basicConfig(
40
+ level=logging.INFO,
41
+ format="%(asctime)s %(levelname)-7s %(message)s",
42
+ datefmt="%H:%M:%S",
43
+ )
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Sweep matrix
47
+ # ---------------------------------------------------------------------------
48
+
49
+ @dataclass
50
+ class ModelConfig:
51
+ benchmark_base: str # key used in GPU Perf Prophet MODEL_PARAMS
52
+ hf_model_id: str # HuggingFace model identifier
53
+ input_len: int # fixed prompt length in tokens
54
+ output_len: int # max output tokens (matches MLPerf standard)
55
+ n_samples_offline: int # number of prompts for Offline throughput run
56
+ n_samples_server: int # number of prompts for Server (lower-concurrency) run
57
+ needs_hf_token: bool = False
58
+ max_model_len: Optional[int] = None # set if model's default context is too large
59
+ # trust_remote_code must remain False unless the model repo uses a custom
60
+ # architecture class not built into vLLM — setting it True allows the model
61
+ # repo to execute arbitrary Python at load time (RCE if repo is compromised).
62
+ trust_remote_code: bool = False
63
+
64
+
65
+ # MLPerf standard output lengths:
66
+ # gptj 128 tokens (OpenOCR fixed)
67
+ # llama2-70b 294 tokens (OpenOCR mean, MLPerf v4.1+)
68
+ # mixtral-8x7b 145 tokens (OpenOCR mean)
69
+ # llama3.1-8b 294 tokens (same OpenOCR dataset as llama2-70b per MLPerf rules)
70
+ MODEL_CONFIGS: list[ModelConfig] = [
71
+ ModelConfig(
72
+ benchmark_base="gptj",
73
+ hf_model_id="EleutherAI/gpt-j-6b",
74
+ input_len=1919,
75
+ output_len=128,
76
+ n_samples_offline=256,
77
+ n_samples_server=64,
78
+ needs_hf_token=False,
79
+ ),
80
+ ModelConfig(
81
+ benchmark_base="llama3.1-8b",
82
+ hf_model_id="meta-llama/Meta-Llama-3.1-8B",
83
+ input_len=1024,
84
+ output_len=294,
85
+ n_samples_offline=128,
86
+ n_samples_server=32,
87
+ needs_hf_token=True,
88
+ ),
89
+ ModelConfig(
90
+ benchmark_base="llama2-70b",
91
+ hf_model_id="meta-llama/Llama-2-70b-hf",
92
+ input_len=1024,
93
+ output_len=294,
94
+ n_samples_offline=64,
95
+ n_samples_server=16,
96
+ needs_hf_token=True,
97
+ ),
98
+ ModelConfig(
99
+ benchmark_base="mixtral-8x7b",
100
+ hf_model_id="mistralai/Mixtral-8x7B-v0.1",
101
+ input_len=1024,
102
+ output_len=145,
103
+ n_samples_offline=128,
104
+ n_samples_server=32,
105
+ needs_hf_token=False,
106
+ max_model_len=4096,
107
+ ),
108
+ ]
109
+
110
+ @dataclass
111
+ class PrecisionConfig:
112
+ name: str # "bf16" or "fp8"
113
+ vllm_dtype: str # passed as dtype= to LLM()
114
+ vllm_quantization: Optional[str] # passed as quantization= to LLM()
115
+ # MLPerf benchmark_accuracy_tiers this precision maps to.
116
+ # fp8 covers both "99" AND "99.9" for AMD (AMD achieves 99.9 accuracy with FP8).
117
+ tiers: list[str] = field(default_factory=list)
118
+
119
+
120
+ PRECISION_CONFIGS: list[PrecisionConfig] = [
121
+ PrecisionConfig(
122
+ name="bf16",
123
+ vllm_dtype="bfloat16",
124
+ vllm_quantization=None,
125
+ tiers=["base"],
126
+ ),
127
+ PrecisionConfig(
128
+ name="fp8",
129
+ vllm_dtype="bfloat16",
130
+ vllm_quantization="fp8",
131
+ tiers=["99", "99.9"], # AMD MI300X achieves both with FP8
132
+ ),
133
+ ]
134
+
135
+ # MLPerf scenario names
136
+ SCENARIOS = ["Offline", "Server"]
137
+
138
+ # GPU identifier that must match an alias in data/gpu_specs.yaml
139
+ GPU_NAME = "AMD Instinct MI300X"
140
+
141
+ # Use the most recent MLPerf round tag so mlperf_round_num maps to 4 (max maturity)
142
+ # in the training pipeline — we're running on a current ROCm/vLLM stack.
143
+ ROUND_TAG = "v6.0"
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # CSV schema
147
+ # ---------------------------------------------------------------------------
148
+
149
+ CSV_FIELDS = [
150
+ "gpu_name",
151
+ "benchmark_base",
152
+ "benchmark_accuracy_tier",
153
+ "scenario",
154
+ "precision_used",
155
+ "hf_model_id",
156
+ "input_len",
157
+ "output_len",
158
+ "n_samples",
159
+ "total_output_tokens",
160
+ "elapsed_s",
161
+ "throughput_tok_per_sec",
162
+ "round_tag",
163
+ "vllm_version",
164
+ "rocm_version",
165
+ "notes",
166
+ ]
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # vLLM helpers
170
+ # ---------------------------------------------------------------------------
171
+
172
+ def _rocm_version() -> str:
173
+ """Return ROCm version string, or 'unknown'."""
174
+ try:
175
+ import subprocess
176
+ result = subprocess.run(
177
+ ["rocm-smi", "--version"], capture_output=True, text=True, timeout=5
178
+ )
179
+ for line in result.stdout.splitlines():
180
+ if "ROCm" in line or "rocm" in line.lower():
181
+ return line.strip()
182
+ return result.stdout.strip()[:80] or "unknown"
183
+ except Exception:
184
+ return "unknown"
185
+
186
+
187
+ def _vllm_version() -> str:
188
+ try:
189
+ import vllm
190
+ return getattr(vllm, "__version__", "unknown")
191
+ except ImportError:
192
+ return "not_installed"
193
+
194
+
195
+ def _load_model(config: ModelConfig, prec: PrecisionConfig) -> "vllm.LLM":
196
+ """Load a vLLM LLM instance with the given precision."""
197
+ from vllm import LLM
198
+
199
+ kwargs: dict = {
200
+ "model": config.hf_model_id,
201
+ "dtype": prec.vllm_dtype,
202
+ "gpu_memory_utilization": 0.93,
203
+ "enforce_eager": False, # allow graph capture for throughput
204
+ "disable_log_stats": True,
205
+ }
206
+ if config.trust_remote_code:
207
+ kwargs["trust_remote_code"] = True
208
+ if prec.vllm_quantization:
209
+ kwargs["quantization"] = prec.vllm_quantization
210
+ if config.max_model_len:
211
+ kwargs["max_model_len"] = config.max_model_len
212
+
213
+ log.info(
214
+ "Loading %s dtype=%s quant=%s",
215
+ config.hf_model_id, prec.vllm_dtype, prec.vllm_quantization or "none",
216
+ )
217
+ t0 = time.perf_counter()
218
+ llm = LLM(**kwargs)
219
+ log.info("Model loaded in %.1f s", time.perf_counter() - t0)
220
+ return llm
221
+
222
+
223
+ def _unload_model(llm) -> None:
224
+ """Release GPU memory held by an LLM instance."""
225
+ try:
226
+ import torch
227
+ del llm
228
+ gc.collect()
229
+ torch.cuda.empty_cache()
230
+ except Exception:
231
+ pass
232
+
233
+
234
+ def _make_prompts(n: int, input_len: int) -> list[list[int]]:
235
+ """Return n identical prompt_token_ids of length input_len.
236
+
237
+ Using token ID 1 (BOS in most tokenizers) gives exact token-count control
238
+ without running a tokenizer. The content doesn't affect throughput measurement.
239
+ """
240
+ # Each prompt must be an independent list — vLLM may mutate the lists
241
+ # during tokenization, and [prompt] * n would share a single object.
242
+ prompt = [1] * input_len
243
+ return [list(prompt) for _ in range(n)]
244
+
245
+
246
+ def _run_throughput(
247
+ llm,
248
+ config: ModelConfig,
249
+ n_samples: int,
250
+ *,
251
+ warmup: bool = True,
252
+ ) -> tuple[float, int]:
253
+ """Run a throughput pass and return (elapsed_seconds, total_output_tokens).
254
+
255
+ If warmup=True, runs one warm-up batch first (results discarded).
256
+ """
257
+ from vllm import SamplingParams
258
+
259
+ params = SamplingParams(
260
+ temperature=0.0,
261
+ max_tokens=config.output_len,
262
+ ignore_eos=True, # always generate exactly output_len tokens
263
+ )
264
+
265
+ if warmup:
266
+ log.info(" Warmup pass (%d samples)...", min(n_samples, 8))
267
+ warmup_prompts = _make_prompts(min(n_samples, 8), config.input_len)
268
+ llm.generate(warmup_prompts, sampling_params=params, use_tqdm=False)
269
+
270
+ prompts = _make_prompts(n_samples, config.input_len)
271
+ log.info(" Timed pass (%d samples × %d output tokens)...", n_samples, config.output_len)
272
+
273
+ t0 = time.perf_counter()
274
+ outputs = llm.generate(prompts, sampling_params=params, use_tqdm=False)
275
+ elapsed = time.perf_counter() - t0
276
+
277
+ total_out = sum(len(o.outputs[0].token_ids) for o in outputs)
278
+ return elapsed, total_out
279
+
280
+
281
+ # ---------------------------------------------------------------------------
282
+ # CSV helpers
283
+ # ---------------------------------------------------------------------------
284
+
285
+ def _completed_keys(csv_path: Path) -> set[tuple]:
286
+ """Return set of (benchmark_base, precision_used, scenario) already in CSV."""
287
+ if not csv_path.exists():
288
+ return set()
289
+ keys = set()
290
+ with csv_path.open(newline="") as f:
291
+ reader = csv.DictReader(f)
292
+ for row in reader:
293
+ keys.add((row["benchmark_base"], row["precision_used"], row["scenario"]))
294
+ return keys
295
+
296
+
297
+ def _append_row(csv_path: Path, row: dict) -> None:
298
+ write_header = not csv_path.exists()
299
+ with csv_path.open("a", newline="") as f:
300
+ writer = csv.DictWriter(f, fieldnames=CSV_FIELDS)
301
+ if write_header:
302
+ writer.writeheader()
303
+ writer.writerow(row)
304
+
305
+
306
+ # ---------------------------------------------------------------------------
307
+ # Main sweep
308
+ # ---------------------------------------------------------------------------
309
+
310
+ def run_sweep(
311
+ model_filter: Optional[list[str]],
312
+ resume: bool,
313
+ dry_run: bool,
314
+ output: Path,
315
+ ) -> None:
316
+ models = [m for m in MODEL_CONFIGS if model_filter is None or m.benchmark_base in model_filter]
317
+ if not models:
318
+ log.error("No models matched filter %s", model_filter)
319
+ sys.exit(1)
320
+
321
+ vllm_ver = _vllm_version()
322
+ rocm_ver = _rocm_version()
323
+ completed = _completed_keys(output) if resume else set()
324
+
325
+ log.info("vLLM %s | ROCm: %s", vllm_ver, rocm_ver)
326
+ log.info("Output: %s", output)
327
+
328
+ # Count total configs
329
+ total = sum(len(PRECISION_CONFIGS) * len(SCENARIOS) for _ in models)
330
+ log.info("Sweep: %d model×precision×scenario configs", total)
331
+
332
+ if dry_run:
333
+ for m in models:
334
+ for prec in PRECISION_CONFIGS:
335
+ for scenario in SCENARIOS:
336
+ n = m.n_samples_offline if scenario == "Offline" else m.n_samples_server
337
+ key = (m.benchmark_base, prec.name, scenario)
338
+ status = "SKIP (completed)" if key in completed else "RUN"
339
+ log.info(
340
+ " [%s] %s | %s | %s | n=%d | input=%d out=%d",
341
+ status, m.benchmark_base, prec.name, scenario,
342
+ n, m.input_len, m.output_len,
343
+ )
344
+ return
345
+
346
+ hf_token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
347
+
348
+ for m in models:
349
+ if m.needs_hf_token and not hf_token:
350
+ log.warning(
351
+ "Skipping %s — set HF_TOKEN env var to access gated model.",
352
+ m.hf_model_id,
353
+ )
354
+ continue
355
+
356
+ for prec in PRECISION_CONFIGS:
357
+ # Check if all scenarios for this (model, prec) are already done
358
+ needed_scenarios = [
359
+ s for s in SCENARIOS
360
+ if (m.benchmark_base, prec.name, s) not in completed
361
+ ]
362
+ if not needed_scenarios:
363
+ log.info("Skipping %s/%s — all scenarios already completed.", m.benchmark_base, prec.name)
364
+ continue
365
+
366
+ # Load model once per (model, precision) pair
367
+ llm = None
368
+ try:
369
+ llm = _load_model(m, prec)
370
+ except Exception as exc:
371
+ log.error("Failed to load %s/%s: %s", m.benchmark_base, prec.name, exc)
372
+ if llm:
373
+ _unload_model(llm)
374
+ continue
375
+
376
+ for scenario in needed_scenarios:
377
+ n_samples = m.n_samples_offline if scenario == "Offline" else m.n_samples_server
378
+ log.info(
379
+ "Running %s | prec=%s | scenario=%s | n=%d",
380
+ m.benchmark_base, prec.name, scenario, n_samples,
381
+ )
382
+ try:
383
+ elapsed, total_out = _run_throughput(llm, m, n_samples, warmup=(scenario == "Offline"))
384
+ tput = total_out / elapsed
385
+ log.info(
386
+ " => %.1f tok/s (%d tokens in %.1f s)",
387
+ tput, total_out, elapsed,
388
+ )
389
+ # Write one row per (model, precision, scenario, tier) combination.
390
+ # fp8 covers both "99" and "99.9" tiers for AMD MI300X.
391
+ for tier in prec.tiers:
392
+ row = {
393
+ "gpu_name": GPU_NAME,
394
+ "benchmark_base": m.benchmark_base,
395
+ "benchmark_accuracy_tier": tier,
396
+ "scenario": scenario,
397
+ "precision_used": prec.name,
398
+ "hf_model_id": m.hf_model_id,
399
+ "input_len": m.input_len,
400
+ "output_len": m.output_len,
401
+ "n_samples": n_samples,
402
+ "total_output_tokens": total_out,
403
+ "elapsed_s": f"{elapsed:.3f}",
404
+ "throughput_tok_per_sec": f"{tput:.2f}",
405
+ "round_tag": ROUND_TAG,
406
+ "vllm_version": vllm_ver,
407
+ "rocm_version": rocm_ver,
408
+ "notes": "",
409
+ }
410
+ _append_row(output, row)
411
+ completed.add((m.benchmark_base, prec.name, scenario))
412
+
413
+ except Exception as exc:
414
+ log.error(
415
+ "Run failed for %s/%s/%s: %s",
416
+ m.benchmark_base, prec.name, scenario, exc,
417
+ )
418
+ _append_row(output, {
419
+ "gpu_name": GPU_NAME,
420
+ "benchmark_base": m.benchmark_base,
421
+ "benchmark_accuracy_tier": prec.tiers[0] if prec.tiers else "?",
422
+ "scenario": scenario,
423
+ "precision_used": prec.name,
424
+ "hf_model_id": m.hf_model_id,
425
+ "input_len": m.input_len,
426
+ "output_len": m.output_len,
427
+ "n_samples": n_samples,
428
+ "total_output_tokens": 0,
429
+ "elapsed_s": 0,
430
+ "throughput_tok_per_sec": 0,
431
+ "round_tag": ROUND_TAG,
432
+ "vllm_version": vllm_ver,
433
+ "rocm_version": rocm_ver,
434
+ "notes": f"FAILED: {exc}",
435
+ })
436
+
437
+ _unload_model(llm)
438
+
439
+ log.info("Sweep complete. Results: %s", output)
440
+
441
+
442
+ # ---------------------------------------------------------------------------
443
+ # CLI
444
+ # ---------------------------------------------------------------------------
445
+
446
+ def main() -> None:
447
+ parser = argparse.ArgumentParser(description="MI300X calibration benchmark runner")
448
+ parser.add_argument(
449
+ "--models", nargs="+",
450
+ choices=[m.benchmark_base for m in MODEL_CONFIGS],
451
+ help="Run only these models (default: all)",
452
+ )
453
+ parser.add_argument(
454
+ "--dry-run", action="store_true",
455
+ help="Print sweep plan without running",
456
+ )
457
+ parser.add_argument(
458
+ "--resume", action="store_true",
459
+ help="Skip configs already present in the output CSV",
460
+ )
461
+ parser.add_argument(
462
+ "--output", default="mi300x_calibration_results.csv",
463
+ help="Output CSV path (default: mi300x_calibration_results.csv)",
464
+ )
465
+ args = parser.parse_args()
466
+
467
+ run_sweep(
468
+ model_filter=args.models,
469
+ resume=args.resume,
470
+ dry_run=args.dry_run,
471
+ output=Path(args.output),
472
+ )
473
+
474
+
475
+ if __name__ == "__main__":
476
+ main()
data/data_card.md ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GPU Perf Prophet — Data Card v0.1
2
+
3
+ > **Status:** Hand-authored baseline. This file will be superseded once `build_data_card()`
4
+ > is implemented and `make data` is run.
5
+ > All figures verified against `data/processed/mlperf_raw.parquet` on 2026-06-12.
6
+
7
+ ---
8
+
9
+ ## 1 · Provenance
10
+
11
+ | Field | Value |
12
+ |-------|-------|
13
+ | Primary source | MLCommons MLPerf Inference results repos v4.1, v5.0, v5.1, v6.0 |
14
+ | Divisions | closed + open |
15
+ | Ingestor | `src/data/mlperf_parser.py` |
16
+ | GPU spec enrichment | `src/data/gpu_spec_db.py` → `data/gpu_specs.yaml` |
17
+ | Processed artifact | `data/processed/mlperf_raw.parquet` |
18
+ | Generated | 2026-06-12 |
19
+ | Total parsed rows | **1,223** |
20
+ | Unique submitters | 32 |
21
+ | Unique system configs | 167 |
22
+ | Result validity | 100% VALID (zero INVALID rows in corpus) |
23
+
24
+ Source repos are sparse-checked-out git clones. Fetch scripts in `scripts/fetch_mlperf.sh`.
25
+ Every parsed row carries `log_path` back to its originating `mlperf_log_summary.txt`.
26
+
27
+ ---
28
+
29
+ ## 2 · In-scope training corpus
30
+
31
+ Rows entering model training must pass the `gpu_in_model_scope == True` filter
32
+ (set in `data/gpu_specs.yaml`). CPU-only, heterogeneous, edge, and other
33
+ out-of-scope GPUs are excluded.
34
+
35
+ **Total in-scope rows: 649**
36
+
37
+ ### Per-GPU breakdown
38
+
39
+ | Canonical ID | GPU | Vendor | Rounds present | In-scope rows | Spec confidence |
40
+ |---|---|---|---|---|---|
41
+ | `mi300x` | AMD Instinct MI300X | AMD | v4.1, v5.0, v5.1, v6.0 | 56 | verified |
42
+ | `mi325x` | AMD Instinct MI325X | AMD | v5.0, v5.1, v6.0 | 82 | verified |
43
+ | `mi355x` | AMD Instinct MI355X | AMD | **v6.0 only** | **50** | estimated |
44
+ | `h100_sxm` | NVIDIA H100 SXM5 | NVIDIA | v4.1, v5.0, v5.1 | 178 | verified |
45
+ | `h200_sxm` | NVIDIA H200 SXM | NVIDIA | v4.1, v5.0, v5.1 | 283 | verified |
46
+
47
+ AMD total: **188 rows** (29 %)
48
+ NVIDIA total: **461 rows** (71 %)
49
+
50
+ The AMD/NVIDIA imbalance is the reason the AMD-specific MAPE target is relaxed to
51
+ < 20 % (vs. < 15 % overall).
52
+
53
+ ### Per-round in-scope row counts
54
+
55
+ | Round | mi300x | mi325x | mi355x | h100_sxm | h200_sxm | Round total |
56
+ |-------|-------:|-------:|-------:|---------:|---------:|------------:|
57
+ | v4.1 | 16 | — | — | 116 | 68 | 200 |
58
+ | v5.0 | 4 | 16 | — | 54 | 156 | 230 |
59
+ | v5.1 | 28 | 62 | — | 8 | 59 | 157 |
60
+ | v6.0 | 8 | 4 | **50** | — | — | 62 |
61
+ | **Total** | **56** | **82** | **50** | **178** | **283** | **649** |
62
+
63
+ ---
64
+
65
+ ## 3 · MI355X — v6.0-only GPU
66
+
67
+ **MI355X is the only in-scope GPU with data from a single round.**
68
+ This is the primary known limitation of the training corpus.
69
+
70
+ ### What "v6.0-only" means
71
+
72
+ | Property | Detail |
73
+ |---|---|
74
+ | First MLPerf round | v6.0 (no MI355X rows in v4.1, v5.0, or v5.1) |
75
+ | In-scope training rows | 50 |
76
+ | Benchmarks covered | `llama2-70b-99` (25 rows) and `llama2-70b-99.9` (25 rows) only |
77
+ | Benchmarks *not* covered | `gptj`, `mixtral-8x7b`, `llama3.1-405b` |
78
+ | Scenarios | Offline and Server |
79
+ | Spec confidence | **estimated** — CDNA4 dense FLOPS derived from with-sparsity ÷ 2 |
80
+
81
+ ### Why this matters for the model
82
+
83
+ 1. **No round-over-round variance.** The model cannot check MI355X consistency across
84
+ rounds, making it harder to detect submission anomalies or performance regressions.
85
+
86
+ 2. **Single benchmark family.** The model sees MI355X only on `llama2-70b`. Predictions
87
+ for MI355X on `gptj`, `mixtral-8x7b`, or `llama3.1-405b` are extrapolations,
88
+ not interpolations. SHAP explanations will not decompose MI355X benchmark-level
89
+ effects from GPU-level effects for those benchmarks.
90
+
91
+ 3. **50 rows < 100-row minimum.** If MI355X does not meet the 100-row minimum gate
92
+ after further AMD Dev Cloud calibration, it will be marked `enabled: false` in
93
+ `gpu_specs.yaml` and excluded from v1 recommendations. The current value is
94
+ `in_model_scope: true` as a placeholder pending that gate.
95
+
96
+ 4. **Estimated spec values.** `gpu_specs.yaml` MI355X entries for `peak_tflops` are
97
+ `sparse/2` derivations (flagged `spec_confidence: estimated`). These **must be
98
+ reconfirmed** against the AMD MI350-series whitepaper before the training data
99
+ is finalized.
100
+
101
+ ### Mitigation plan
102
+
103
+ | Step | Action |
104
+ |------|--------|
105
+ | 1 | Train with MI355X included; report MI355X MAPE separately; note in model card |
106
+ | 2 | Run MI300X + MI355X benchmarks on AMD Dev Cloud; add calibration rows |
107
+ | 3 | If MI355X row count ≥ 100 → keep `enabled: true`; else set `enabled: false` |
108
+ | 4 | Public writeup explicitly discloses the single-round limitation |
109
+
110
+ ---
111
+
112
+ ## 4 · Excluded rows
113
+
114
+ | Category | Count | Reason |
115
+ |---|---:|---|
116
+ | CPU-inference (`gpu_name = N/A`) | 26 | Intel EMR and similar; no GPU to predict |
117
+ | NVIDIA Blackwell (B200, B300, GB200, GB300) | 206 | Explicitly out of v1 scope |
118
+ | NVIDIA RTX PRO 6000 Blackwell | 52 | Not in spec DB; out of scope |
119
+ | Heterogeneous multi-GPU | 20 | Mixed SKUs; cannot normalize to a single canonical ID |
120
+ | Intel Arc | 16 | Not in spec DB |
121
+ | Edge GPUs (Jetson) | 4 | Edge, not datacenter |
122
+ | GH200, H100-NVL, H100-PCIe, H200-NVL, L40S | 250 | Insufficient LLM rows or out of form-factor scope |
123
+ | **Total excluded** | **574** | |
124
+
125
+ Excluded rows are present in `mlperf_raw.parquet` with `gpu_in_model_scope` null or false.
126
+ They are **not removed** — exclusion happens at training time via the scope filter.
127
+
128
+ ---
129
+
130
+ ## 5 · Feature encoding decisions
131
+
132
+ ### `precision` field
133
+
134
+ `precision` is 0 % populated across the entire corpus. MLPerf system names are hardware
135
+ identifiers (e.g., `8xMI300X_2xEPYC-9374F`), not precision-tagged. Regex extraction
136
+ returns `None` on all real submissions.
137
+
138
+ **`benchmark_accuracy_tier` is the reliable precision proxy** (100 % populated):
139
+
140
+ | Tier | Benchmark suffix | Typical precision regime |
141
+ |------|-----------------|--------------------------|
142
+ | `99.9` | `-99.9` | BF16 / FP16 (high-fidelity) |
143
+ | `99` | `-99` | FP8 or better |
144
+ | `base` | none | Throughput-optimized; any precision |
145
+
146
+ Distribution: `99` → 550 rows, `99.9` → 488 rows, `base` → 185 rows.
147
+
148
+ ### `tokens_per_sample` (verified 2026-06-12)
149
+
150
+ These values are the divisor in `throughput_tok_per_sec_per_gpu`.
151
+
152
+ | Benchmark base | Value used | Source-verified | Delta | Source |
153
+ |---|---:|---:|---:|---|
154
+ | `gptj` | 128 | 128 (fixed) | 0.0 % | MLPerf benchmark spec |
155
+ | `llama2-70b` | 294 | 294.45 | < 0.2 % | `mlcommons/inference` language/llama2-70b/README.md |
156
+ | `mixtral-8x7b` | 145 | 144.84 | < 0.1 % | `mlcommons/inference` language/mixtral-8x7b README |
157
+ | `llama3.1-405b` | 294 | 294.45 | < 0.2 % | Same Open ORCA dataset as llama2-70b per MLPerf rules |
158
+
159
+ All values verified within ± 5 % tolerance. No parquet re-export required.
160
+
161
+ ### `vram_gb` vs `gpu_vram_gb`
162
+
163
+ `vram_gb` (from the system JSON `accelerator_memory_capacity`) can reflect **total
164
+ system VRAM** for multi-GPU submissions. `gpu_vram_gb` (from `gpu_specs.yaml`) is
165
+ always per-GPU capacity.
166
+
167
+ **Rule: use `gpu_vram_gb` for the memory-fit constraint. Treat `vram_gb` as unreliable
168
+ for any system with `num_gpus > 1`.**
169
+
170
+ ---
171
+
172
+ ## 6 · Deduplication policy
173
+
174
+ No deduplication is applied. Rows from different rounds for the same GPU + benchmark
175
+ combination are intentional — they represent genuinely independent performance measurements
176
+ submitted to different MLPerf rounds.
177
+
178
+ **Primary key:** `(submitter, system_name, benchmark, scenario, round, division)`
179
+ **Duplicate rows in corpus:** 0
180
+
181
+ ---
182
+
183
+ ## 7 · Known limitations
184
+
185
+ | ID | Limitation | Impact | Mitigation |
186
+ |----|-----------|--------|-----------|
187
+ | KL-01 | **MI355X v6.0-only** — single round, one benchmark family, 50 rows | AMD MAPE for MI355X has higher variance; no multi-round consistency check | Further AMD Dev Cloud calibration; 100-row gate |
188
+ | KL-02 | `precision` field is 0 % populated | Cannot distinguish FP8 vs FP16 directly | Use `benchmark_accuracy_tier` as proxy |
189
+ | KL-03 | `tokens_per_sample` are rounded estimates | < 0.2 % systematic bias in `throughput_tok_per_sec_per_gpu` | Verified; rounding error is negligible |
190
+ | KL-04 | AMD corpus is 29 % of in-scope rows | AMD predictions are lower-confidence than NVIDIA | Relaxed AMD MAPE gate (< 20 % vs < 15 %) |
191
+ | KL-05 | No INVALID rows in current corpus | INVALID filter in training code is not currently exercised | Filter remains; future rounds may include INVALID |
192
+ | KL-06 | MI355X CDNA4 spec values are estimated | Roofline ceilings for MI355X may be slightly off | Reconfirm against AMD MI350 whitepaper before finalizing training data |
193
+ | KL-07 | `TOKENS_PER_SAMPLE` for llama3.1-405b inherits llama2-70b value | If Open ORCA output lengths differ across models, minor bias | Verify if llama3.1-405b round adds dataset-specific stats |
data/gpu_specs.yaml ADDED
@@ -0,0 +1,507 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ##############################################################################
2
+ # GPU Perf Prophet — GPU Specification Database
3
+ # schema_version: "1.0"
4
+ #
5
+ # Accuracy policy
6
+ # ───────────────
7
+ # spec_confidence: "verified" → from vendor spec sheet / whitepaper
8
+ # "estimated" → derived from architecture docs or cross-refs;
9
+ # verify before Phase-2 training freeze
10
+ #
11
+ # peak_tflops are DENSE (no structured sparsity) per design doc ADR-006.
12
+ # AMD "compute" throughput uses MFMA instruction peak.
13
+ # NVIDIA "compute" throughput uses Tensor Core peak for BF16/FP16/FP8;
14
+ # fp32 is scalar CUDA-core throughput.
15
+ # hbm_bandwidth_tbps is peak theoretical from memory specification.
16
+ # ~ (null) means not supported natively on that GPU.
17
+ #
18
+ # in_model_scope
19
+ # ──────────────
20
+ # true → GPU is a Phase-1 recommendation target
21
+ # false → GPU appears in the training corpus but is not exposed to users v1
22
+ # (Blackwell family, edge GPUs, GPU combos without clean single-SKU rows)
23
+ #
24
+ # aliases
25
+ # ───────
26
+ # Every unique accelerator_model_name string that appeared in MLPerf
27
+ # v4.1–v6.0 system JSONs for this GPU, including case variants and
28
+ # suffix variants without the count specifier "(xN)".
29
+ ##############################################################################
30
+
31
+ schema_version: "1.0"
32
+
33
+ gpus:
34
+
35
+ # ── AMD Instinct CDNA3 ─────────────────────────────────────────────────
36
+
37
+ - id: mi300x
38
+ name: "AMD Instinct MI300X"
39
+ vendor: amd
40
+ architecture: cdna3
41
+ memory_type: hbm3
42
+ vram_gb: 192
43
+ hbm_bandwidth_tbps: 5.3
44
+ peak_tflops:
45
+ fp32: 163.4
46
+ bf16: 1307.4
47
+ fp16: 1307.4
48
+ fp8: 2614.9
49
+ fp6: ~ # not supported natively in CDNA3
50
+ fp4: ~ # not supported natively in CDNA3
51
+ int8: 2614.9
52
+ compute_units: 304
53
+ l2_cache_mb: 32
54
+ tdp_w: 750
55
+ spec_confidence: verified # AMD MI300X Data Sheet, Dec 2023
56
+ in_model_scope: true
57
+ aliases:
58
+ - "AMD Instinct MI300X"
59
+ - "AMD Instinct MI300X 192GB HBM3"
60
+ - "AMD Instinct MI300X-NPS1-SPX-192GB-750W"
61
+ - "AMD MI300X-NPS1-SPX-192GB-750W"
62
+
63
+ - id: mi325x
64
+ name: "AMD Instinct MI325X"
65
+ vendor: amd
66
+ architecture: cdna3 # same compute die as MI300X; HBM3E memory upgrade
67
+ memory_type: hbm3e
68
+ vram_gb: 256
69
+ hbm_bandwidth_tbps: 6.0
70
+ peak_tflops:
71
+ fp32: 163.4 # same CDNA3 compute die
72
+ bf16: 1307.4
73
+ fp16: 1307.4
74
+ fp8: 2614.9
75
+ fp6: ~
76
+ fp4: ~
77
+ int8: 2614.9
78
+ compute_units: 304
79
+ l2_cache_mb: 32
80
+ tdp_w: 1000
81
+ spec_confidence: verified # AMD MI325X Announcement, Oct 2024
82
+ in_model_scope: true
83
+ aliases:
84
+ - "AMD Instinct MI325X"
85
+ - "AMD Instinct MI325X 256GB HBM3E"
86
+ - "AMD Instinct MI325X 256GB HBM3e" # lowercase variant in system JSONs
87
+
88
+ # ── AMD Instinct CDNA4 ─────────────────────────────────────────────────
89
+
90
+ - id: mi355x
91
+ name: "AMD Instinct MI355X"
92
+ vendor: amd
93
+ architecture: cdna4
94
+ memory_type: hbm3e
95
+ vram_gb: 288
96
+ hbm_bandwidth_tbps: 8.0 # AMD official spec sheet (amd-instinct-mi355x-gpu-brochure.pdf)
97
+ peak_tflops:
98
+ fp32: 157.3 # AMD official spec sheet — vector FP32; CDNA4 ratio = 16× (vs CDNA3 8×)
99
+ bf16: 2516.6 # AMD official spec sheet — matrix
100
+ fp16: 2516.6 # AMD official spec sheet — matrix
101
+ fp8: 5033.2 # AMD official spec sheet — matrix
102
+ fp6: 10066.3 # AMD official spec sheet — CDNA4 native FP6
103
+ fp4: 20132.6 # AMD official spec sheet — MXFP4
104
+ int8: 5033.2 # AMD official spec sheet
105
+ compute_units: 304 # (est) — not listed on spec sheet; no change
106
+ l2_cache_mb: 32 # (est)
107
+ tdp_w: 1400 # AMD official spec sheet (1.4 kW); 1000 W variant observed in MLPerf
108
+ spec_confidence: verified # confirmed 2026-06-17 against AMD official spec sheet
109
+ in_model_scope: true
110
+ aliases:
111
+ - "AMD Instinct MI355X"
112
+ - "AMD Instinct MI355X 288GB HBM3e"
113
+ - "AMD Instinct MI355X 288GB HBM3e (Power Cap 1000 W)"
114
+ - "AMD Instinct MI355X 288GB HBM3e (x87)" # (x87) stripped by normalizer
115
+
116
+ - id: mi350x
117
+ name: "AMD Instinct MI350X"
118
+ vendor: amd
119
+ architecture: cdna4
120
+ memory_type: hbm3e
121
+ vram_gb: 288
122
+ hbm_bandwidth_tbps: 8.0 # AMD official spec sheet (amd-instinct-mi350x-gpu-brochure.pdf)
123
+ peak_tflops:
124
+ fp32: 144.2 # vector FP32; 256 CUs × same clock as MI355X die; confirmed via platform FP16-vector 1153.6 TFLOPS ÷ 8
125
+ bf16: 2307.0 # matrix dense; derived from confirmed fp8 (4614.0) using CDNA4 2:1 ratio (verified against MI355X)
126
+ fp16: 2307.0 # matrix dense; same derivation
127
+ fp8: 4614.0 # matrix dense; AMD official spec sheet — platform 36.9 PFLOPS ÷ 8 GPUs = 4612.5 ≈ 4614 TFLOPS
128
+ fp6: 9228.0 # matrix dense; derived from confirmed fp8 × 2 (CDNA4 native MXFP6 support confirmed)
129
+ fp4: 18456.0 # matrix dense; derived from confirmed fp8 × 4 (CDNA4 native MXFP4 support confirmed)
130
+ int8: 4614.0 # same as fp8 per AMD CDNA4 pattern
131
+ compute_units: 256 # AMD official spec sheet — 8 XCDs × 32 CUs each
132
+ l2_cache_mb: 32 # (est)
133
+ tdp_w: 1000 # AMD official spec sheet — air-cooled 1000W variant (vs MI355X 1400W)
134
+ spec_confidence: verified # confirmed 2026-06-18 against AMD official spec sheet (amd-instinct-mi350x-gpu-brochure.pdf); bf16/fp6/fp4 derived from confirmed fp8 using CDNA4 ratios
135
+ in_model_scope: false # spec gate cleared (S33); scope gate blocked: LOGO-CV ρ=0.019 (14 rows, 35 rounds) — wait for v7.0+ MLPerf data
136
+ aliases:
137
+ - "AMD Instinct MI350X"
138
+ - "AMD Instinct MI350X 288GB HBM3e"
139
+
140
+ # ── NVIDIA Hopper ──────────────────────────────────────────────────────
141
+
142
+ - id: h100_sxm
143
+ name: "NVIDIA H100 SXM5"
144
+ vendor: nvidia
145
+ architecture: hopper
146
+ memory_type: hbm3
147
+ vram_gb: 80
148
+ hbm_bandwidth_tbps: 3.35
149
+ peak_tflops:
150
+ fp32: 66.9 # scalar CUDA-core FP32
151
+ bf16: 989.4 # Tensor Core, dense
152
+ fp16: 989.4
153
+ fp8: 1978.9
154
+ fp6: ~
155
+ fp4: ~
156
+ int8: 3957.8
157
+ streaming_multiprocessors: 132
158
+ l2_cache_mb: 50
159
+ tdp_w: 700
160
+ spec_confidence: verified # NVIDIA H100 GPU Architecture Whitepaper
161
+ in_model_scope: true
162
+ aliases:
163
+ - "NVIDIA H100-SXM-80GB"
164
+ - "Virtualized NVIDIA H100-SXM-80GB"
165
+
166
+ - id: h100_pcie
167
+ name: "NVIDIA H100 PCIe"
168
+ vendor: nvidia
169
+ architecture: hopper
170
+ memory_type: hbm2e
171
+ vram_gb: 80
172
+ hbm_bandwidth_tbps: 2.0
173
+ peak_tflops:
174
+ fp32: 51.2
175
+ bf16: 756.5
176
+ fp16: 756.5
177
+ fp8: 1513.0
178
+ fp6: ~
179
+ fp4: ~
180
+ int8: 3026.0
181
+ streaming_multiprocessors: 114
182
+ l2_cache_mb: 50
183
+ tdp_w: 350
184
+ spec_confidence: verified # NVIDIA H100 PCIe Datasheet
185
+ in_model_scope: false # not in v1 scope; included for training signal
186
+ aliases:
187
+ - "NVIDIA H100-PCIe-80GB"
188
+
189
+ - id: h100_nvl
190
+ name: "NVIDIA H100 NVL"
191
+ vendor: nvidia
192
+ architecture: hopper
193
+ memory_type: hbm3
194
+ vram_gb: 94
195
+ hbm_bandwidth_tbps: 3.93 # (est) scaled from H100 SXM die bandwidth
196
+ peak_tflops:
197
+ fp32: 66.9
198
+ bf16: 989.4 # same compute die as H100 SXM5
199
+ fp16: 989.4
200
+ fp8: 1978.9
201
+ fp6: ~
202
+ fp4: ~
203
+ int8: 3957.8
204
+ streaming_multiprocessors: 132
205
+ l2_cache_mb: 50
206
+ tdp_w: 700 # (est)
207
+ spec_confidence: estimated
208
+ in_model_scope: false
209
+ aliases:
210
+ - "NVIDIA H100-NVL-94GB"
211
+
212
+ - id: h200_sxm
213
+ name: "NVIDIA H200 SXM"
214
+ vendor: nvidia
215
+ architecture: hopper # same Hopper die as H100; HBM3E memory upgrade
216
+ memory_type: hbm3e
217
+ vram_gb: 141
218
+ hbm_bandwidth_tbps: 4.8
219
+ peak_tflops:
220
+ fp32: 66.9 # same compute die as H100 SXM5
221
+ bf16: 989.4
222
+ fp16: 989.4
223
+ fp8: 1978.9
224
+ fp6: ~
225
+ fp4: ~
226
+ int8: 3957.8
227
+ streaming_multiprocessors: 132
228
+ l2_cache_mb: 50
229
+ tdp_w: 700
230
+ spec_confidence: verified # NVIDIA H200 Tensor Core GPU Datasheet
231
+ in_model_scope: true
232
+ aliases:
233
+ - "NVIDIA H200-SXM-141GB"
234
+ - "NVIDIA H200-SXM-141GB-CTS"
235
+ - "Virtualized NVIDIA H200-SXM-141GB"
236
+
237
+ - id: h200_nvl
238
+ name: "NVIDIA H200 NVL"
239
+ vendor: nvidia
240
+ architecture: hopper
241
+ memory_type: hbm3e
242
+ vram_gb: 141
243
+ hbm_bandwidth_tbps: 4.8 # same HBM3E spec as H200 SXM
244
+ peak_tflops:
245
+ fp32: 66.9
246
+ bf16: 989.4
247
+ fp16: 989.4
248
+ fp8: 1978.9
249
+ fp6: ~
250
+ fp4: ~
251
+ int8: 3957.8
252
+ streaming_multiprocessors: 132
253
+ l2_cache_mb: 50
254
+ tdp_w: 600 # (est) NVL form-factor typically lower TDP
255
+ spec_confidence: estimated
256
+ in_model_scope: false # not in v1 scope; included for training signal
257
+ aliases:
258
+ - "NVIDIA H200-NVL-141GB"
259
+
260
+ # GH200 = H100 GPU die + Grace CPU on NVLink-C2C. GPU-side specs only.
261
+ - id: gh200_96gb
262
+ name: "NVIDIA GH200 96GB"
263
+ vendor: nvidia
264
+ architecture: hopper
265
+ memory_type: hbm3
266
+ vram_gb: 96
267
+ hbm_bandwidth_tbps: 3.35 # H100 GPU die bandwidth
268
+ peak_tflops:
269
+ fp32: 66.9
270
+ bf16: 989.4
271
+ fp16: 989.4
272
+ fp8: 1978.9
273
+ fp6: ~
274
+ fp4: ~
275
+ int8: 3957.8
276
+ streaming_multiprocessors: 132
277
+ l2_cache_mb: 50
278
+ tdp_w: 900 # full GH200 module TDP
279
+ spec_confidence: verified
280
+ in_model_scope: false
281
+ aliases:
282
+ - "NVIDIA GH200 Grace Hopper Superchip 96GB"
283
+
284
+ - id: gh200_144gb
285
+ name: "NVIDIA GH200 144GB"
286
+ vendor: nvidia
287
+ architecture: hopper
288
+ memory_type: hbm3e
289
+ vram_gb: 144
290
+ hbm_bandwidth_tbps: 4.9 # (est) HBM3E variant
291
+ peak_tflops:
292
+ fp32: 66.9
293
+ bf16: 989.4
294
+ fp16: 989.4
295
+ fp8: 1978.9
296
+ fp6: ~
297
+ fp4: ~
298
+ int8: 3957.8
299
+ streaming_multiprocessors: 132
300
+ l2_cache_mb: 50
301
+ tdp_w: 900
302
+ spec_confidence: estimated
303
+ in_model_scope: false
304
+ aliases:
305
+ - "NVIDIA GH200 Grace Hopper Superchip 144GB"
306
+
307
+ # ── NVIDIA Ampere ──────────────────────────────────────────────────────
308
+ # A100 has zero LLM rows in v4.1–v6.0 corpus; specs kept for the
309
+ # recommendation engine (roofline-only fallback for unseen GPUs).
310
+
311
+ - id: a100_sxm_80gb
312
+ name: "NVIDIA A100 SXM4 80GB"
313
+ vendor: nvidia
314
+ architecture: ampere
315
+ memory_type: hbm2e
316
+ vram_gb: 80
317
+ hbm_bandwidth_tbps: 2.0
318
+ peak_tflops:
319
+ fp32: 77.6 # scalar CUDA-core FP32
320
+ bf16: 312.0 # Tensor Core, dense
321
+ fp16: 312.0
322
+ fp8: ~ # FP8 not supported natively on Ampere
323
+ fp6: ~
324
+ fp4: ~
325
+ int8: 624.0
326
+ streaming_multiprocessors: 108
327
+ l2_cache_mb: 40
328
+ tdp_w: 400
329
+ spec_confidence: verified # NVIDIA A100 GPU Architecture Whitepaper
330
+ in_model_scope: true # in v1 scope; roofline-only prediction (no training rows)
331
+ aliases:
332
+ - "NVIDIA A100-SXM4-80GB"
333
+ - "NVIDIA A100-SXM-80GB"
334
+ - "NVIDIA A100 80GB"
335
+
336
+ # ── NVIDIA Ada Lovelace ────────────────────────────────────────────────
337
+ # L4 and RTX 4090 have zero LLM rows in corpus; included for recommender.
338
+
339
+ - id: l4
340
+ name: "NVIDIA L4"
341
+ vendor: nvidia
342
+ architecture: ada_lovelace
343
+ memory_type: gddr6
344
+ vram_gb: 24
345
+ hbm_bandwidth_tbps: 0.300 # 300 GB/s GDDR6
346
+ peak_tflops:
347
+ fp32: 30.3
348
+ bf16: 121.6 # Tensor Core, dense
349
+ fp16: 121.6
350
+ fp8: 242.6
351
+ fp6: ~
352
+ fp4: ~
353
+ int8: 485.0
354
+ streaming_multiprocessors: 60
355
+ l2_cache_mb: 48
356
+ tdp_w: 72
357
+ spec_confidence: verified # NVIDIA L4 Tensor Core GPU Datasheet
358
+ in_model_scope: true # in v1 scope; roofline-only (no training rows)
359
+ aliases:
360
+ - "NVIDIA L4"
361
+ - "NVIDIA L4 24GB"
362
+
363
+ - id: l40s
364
+ name: "NVIDIA L40S"
365
+ vendor: nvidia
366
+ architecture: ada_lovelace
367
+ memory_type: gddr6
368
+ vram_gb: 48
369
+ hbm_bandwidth_tbps: 0.864 # 864 GB/s GDDR6
370
+ peak_tflops:
371
+ fp32: 91.6
372
+ bf16: 362.1
373
+ fp16: 362.1
374
+ fp8: 724.1
375
+ fp6: ~
376
+ fp4: ~
377
+ int8: 1448.2
378
+ streaming_multiprocessors: 142
379
+ l2_cache_mb: 96
380
+ tdp_w: 350
381
+ spec_confidence: verified # NVIDIA L40S Datasheet
382
+ in_model_scope: false
383
+ aliases:
384
+ - "NVIDIA L40S"
385
+
386
+ - id: rtx4090
387
+ name: "NVIDIA GeForce RTX 4090"
388
+ vendor: nvidia
389
+ architecture: ada_lovelace
390
+ memory_type: gddr6x
391
+ vram_gb: 24
392
+ hbm_bandwidth_tbps: 1.008 # GDDR6X
393
+ peak_tflops:
394
+ fp32: 82.6
395
+ bf16: 165.2 # Tensor Core, dense
396
+ fp16: 165.2
397
+ fp8: 330.3 # (est) 2× FP16
398
+ fp6: ~
399
+ fp4: ~
400
+ int8: 660.6
401
+ streaming_multiprocessors: 128
402
+ l2_cache_mb: 72
403
+ tdp_w: 450
404
+ spec_confidence: verified # NVIDIA RTX 4090 Product Specifications
405
+ in_model_scope: true # in v1 scope; roofline-only (no training rows)
406
+ aliases:
407
+ - "NVIDIA GeForce RTX 4090"
408
+ - "NVIDIA RTX 4090"
409
+
410
+ # ── NVIDIA Blackwell — out of v1 scope; included for training signal ──
411
+ # Rows exist in corpus; having roofline bounds enables the model to train
412
+ # on them. They are never surfaced as recommendations in v1.
413
+
414
+ - id: b200_sxm
415
+ name: "NVIDIA B200 SXM"
416
+ vendor: nvidia
417
+ architecture: blackwell
418
+ memory_type: hbm3e
419
+ vram_gb: 180 # as reported in MLPerf system JSONs
420
+ hbm_bandwidth_tbps: 8.0 # (est) Blackwell specification
421
+ peak_tflops:
422
+ fp32: 147.0 # (est)
423
+ bf16: 2250.0 # (est)
424
+ fp16: 2250.0 # (est)
425
+ fp8: 4500.0 # (est)
426
+ fp6: ~
427
+ fp4: 9000.0 # (est) — Blackwell MXFP4
428
+ int8: 9000.0 # (est)
429
+ streaming_multiprocessors: 160 # (est)
430
+ l2_cache_mb: 96 # (est)
431
+ tdp_w: 1000 # (est)
432
+ spec_confidence: estimated
433
+ in_model_scope: false # Blackwell explicitly out of v1 scope
434
+ aliases:
435
+ - "NVIDIA B200-SXM-180GB"
436
+
437
+ - id: b300_sxm
438
+ name: "NVIDIA B300 SXM"
439
+ vendor: nvidia
440
+ architecture: blackwell
441
+ memory_type: hbm3e
442
+ vram_gb: 270 # as reported in MLPerf system JSONs
443
+ hbm_bandwidth_tbps: 10.0 # (est)
444
+ peak_tflops:
445
+ fp32: 147.0 # (est)
446
+ bf16: 2250.0 # (est)
447
+ fp16: 2250.0 # (est)
448
+ fp8: 4500.0 # (est)
449
+ fp6: ~
450
+ fp4: 9000.0 # (est)
451
+ int8: 9000.0 # (est)
452
+ streaming_multiprocessors: 160 # (est)
453
+ l2_cache_mb: 96 # (est)
454
+ tdp_w: 1200 # (est)
455
+ spec_confidence: estimated
456
+ in_model_scope: false
457
+ aliases:
458
+ - "NVIDIA B300-SXM-270GB"
459
+
460
+ # GB200 NVL module = 2× B200 GPU dies + 2× Grace CPUs.
461
+ # Per-module specs listed here; num_gpus normalization treats the module
462
+ # as the unit (matching how MLPerf system JSONs report accelerators_per_node).
463
+ - id: gb200
464
+ name: "NVIDIA GB200 NVL"
465
+ vendor: nvidia
466
+ architecture: blackwell
467
+ memory_type: hbm3e
468
+ vram_gb: 384 # 2× 192 GB per NVL72 spec (est)
469
+ hbm_bandwidth_tbps: 16.0 # (est) 2× B200
470
+ peak_tflops:
471
+ fp32: 294.0 # (est) 2× B200
472
+ bf16: 4500.0 # (est) 2× B200
473
+ fp16: 4500.0 # (est)
474
+ fp8: 9000.0 # (est)
475
+ fp6: ~
476
+ fp4: 18000.0 # (est)
477
+ int8: 18000.0 # (est)
478
+ streaming_multiprocessors: 320 # (est) 2× B200
479
+ l2_cache_mb: 192 # (est)
480
+ tdp_w: 2700 # (est) full NVL module
481
+ spec_confidence: estimated
482
+ in_model_scope: false
483
+ aliases:
484
+ - "NVIDIA GB200"
485
+
486
+ - id: gb300
487
+ name: "NVIDIA GB300"
488
+ vendor: nvidia
489
+ architecture: blackwell
490
+ memory_type: hbm3e
491
+ vram_gb: 384 # (est)
492
+ hbm_bandwidth_tbps: 16.0 # (est)
493
+ peak_tflops:
494
+ fp32: 294.0 # (est)
495
+ bf16: 4500.0 # (est)
496
+ fp16: 4500.0 # (est)
497
+ fp8: 9000.0 # (est)
498
+ fp6: ~
499
+ fp4: 18000.0 # (est)
500
+ int8: 18000.0 # (est)
501
+ streaming_multiprocessors: 320 # (est)
502
+ l2_cache_mb: 192 # (est)
503
+ tdp_w: 2700 # (est)
504
+ spec_confidence: estimated
505
+ in_model_scope: false
506
+ aliases:
507
+ - "NVIDIA GB300"
data/models/feature_metadata.json ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "feature_cols": [
3
+ "gpu_hbm_bandwidth_tbps",
4
+ "gpu_vram_gb",
5
+ "peak_tflops_selected",
6
+ "compute_ceiling_tok_per_sec",
7
+ "bandwidth_ceiling_tok_per_sec",
8
+ "model_total_params_b",
9
+ "model_compute_params_b",
10
+ "model_size_gb",
11
+ "model_to_vram_ratio",
12
+ "bytes_per_param",
13
+ "nvidia_arch_gen",
14
+ "amd_arch_gen",
15
+ "vendor_is_amd",
16
+ "scenario_offline",
17
+ "is_base_tier",
18
+ "fw_tensorrt",
19
+ "fw_vllm",
20
+ "fw_rocm_other",
21
+ "is_cdna4",
22
+ "mlperf_round_num"
23
+ ],
24
+ "target": "efficiency_ratio",
25
+ "model_version": "v1",
26
+ "prod_params": {
27
+ "n_estimators": 250,
28
+ "max_depth": 5,
29
+ "learning_rate": 0.03,
30
+ "subsample": 0.8,
31
+ "colsample_bytree": 0.8,
32
+ "min_child_weight": 5,
33
+ "tree_method": "hist",
34
+ "random_state": 42
35
+ },
36
+ "n_training_rows": 1136
37
+ }
data/models/prophet_v1.json ADDED
The diff for this file is too large to render. See raw diff
 
data/pricing.yaml ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ##############################################################################
2
+ # GPU Perf Prophet — Static Cloud GPU Pricing
3
+ #
4
+ # source_date: 2026-06-17
5
+ # Policy: static snapshot — no live API calls. Prices are per-GPU per-hour
6
+ # on a single on-demand instance. Costs vary by cloud provider,
7
+ # region, and contract term; these are representative on-demand
8
+ # rates for planning purposes only.
9
+ #
10
+ # Sources (June 2026):
11
+ # AMD Developer Cloud (amd.com/en/developer/resources/cloud-access.html)
12
+ # Lambda Labs (lambdalabs.com/service/gpu-cloud)
13
+ # Vast.ai / RunPod community rates (runpod.io/gpu-instance/pricing)
14
+ # getdeploying.com/gpus/amd-mi355x (multi-provider comparison)
15
+ #
16
+ # price_per_gpu_hr: USD, on-demand, single GPU, no reserved/spot discount
17
+ # source: human-readable provenance note
18
+ ##############################################################################
19
+
20
+ pricing:
21
+
22
+ mi300x:
23
+ price_per_gpu_hr: 1.99
24
+ source: "AMD Developer Cloud on-demand, June 2026"
25
+
26
+ mi325x:
27
+ price_per_gpu_hr: 2.50
28
+ source: "AMD Developer Cloud est. (MI325X premium over MI300X), June 2026"
29
+
30
+ mi355x:
31
+ price_per_gpu_hr: 4.50
32
+ source: "Mid-market on-demand est., June 2026 (range: $2.95 TensorWave – $8.60 OCI; mkt avg $5.45; up 232% since Oct 2025)"
33
+
34
+ h100_sxm:
35
+ price_per_gpu_hr: 2.49
36
+ source: "Lambda Labs H100 SXM5 80GB on-demand, June 2026"
37
+
38
+ h200_sxm:
39
+ price_per_gpu_hr: 3.99
40
+ source: "Lambda Labs H200 SXM 141GB on-demand est., June 2026"
41
+
42
+ a100_sxm_80gb:
43
+ price_per_gpu_hr: 1.50
44
+ source: "Lambda Labs A100 SXM4 80GB on-demand, June 2026"
45
+
46
+ l4:
47
+ price_per_gpu_hr: 0.44
48
+ source: "Vast.ai / RunPod L4 on-demand est., June 2026"
49
+
50
+ rtx4090:
51
+ price_per_gpu_hr: 0.39
52
+ source: "RunPod RTX 4090 on-demand, June 2026"
notebooks/01_mlperf_eda.ipynb ADDED
The diff for this file is too large to render. See raw diff
 
notebooks/02_roofline_sanity.ipynb ADDED
The diff for this file is too large to render. See raw diff
 
notebooks/03_model_training.ipynb ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Data processing
2
+ pandas>=2.0
3
+ numpy>=1.26
4
+ pyarrow>=14.0 # Parquet I/O
5
+ pyyaml>=6.0 # GPU/LLM spec databases
6
+
7
+ # ML
8
+ scikit-learn>=1.4
9
+ xgboost>=2.0
10
+ lightgbm>=4.0
11
+ shap>=0.45
12
+ optuna>=3.5
13
+
14
+ # Experiment tracking
15
+ mlflow>=2.10
16
+
17
+ # API + serving
18
+ fastapi>=0.110
19
+ uvicorn[standard]>=0.27
20
+ pydantic>=2.6
21
+
22
+ # UI
23
+ streamlit>=1.32
24
+
25
+ # Testing
26
+ pytest>=8.0
27
+ pytest-cov>=4.1
28
+ httpx>=0.27 # required by FastAPI TestClient (starlette.testclient)
scripts/fetch_mlperf.sh ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Sparse-clone MLPerf Inference results repos for GPU Perf Prophet.
3
+ #
4
+ # Fetches only the files the parser needs from each round:
5
+ # closed/*/systems/*.json (GPU hardware descriptions)
6
+ # closed/*/results/**/mlperf_log_summary.txt (performance logs)
7
+ #
8
+ # Repos are placed under data/raw/mlperf/<version>/.
9
+ # Full clones of these repos can exceed several GB; sparse checkout
10
+ # keeps each round under ~50 MB.
11
+ #
12
+ # Usage:
13
+ # bash scripts/fetch_mlperf.sh # all four rounds
14
+ # bash scripts/fetch_mlperf.sh v6.0 # single round
15
+ # ROUNDS="v5.1 v6.0" bash scripts/fetch_mlperf.sh
16
+
17
+ set -euo pipefail
18
+
19
+ DEST="data/raw/mlperf"
20
+ ORG="mlcommons"
21
+ ROUNDS="${ROUNDS:-v4.1 v5.0 v5.1 v6.0}"
22
+
23
+ # If a round argument is passed on the command line, use that instead.
24
+ if [[ $# -ge 1 ]]; then
25
+ ROUNDS="$*"
26
+ fi
27
+
28
+ mkdir -p "$DEST"
29
+
30
+ for ROUND in $ROUNDS; do
31
+ # Validate before using $ROUND in path or URL construction.
32
+ # Accepts only the form vN.N (e.g. v6.0) to prevent path traversal.
33
+ if ! [[ "$ROUND" =~ ^v[0-9]+\.[0-9]+$ ]]; then
34
+ echo "Error: invalid round tag '${ROUND}' — expected vN.N (e.g. v6.0)" >&2
35
+ exit 1
36
+ fi
37
+
38
+ REPO="${ORG}/inference_results_${ROUND}"
39
+ TARGET="${DEST}/${ROUND}"
40
+
41
+ if [[ -d "$TARGET/.git" ]]; then
42
+ echo "→ ${ROUND}: already cloned at ${TARGET}, skipping."
43
+ continue
44
+ fi
45
+
46
+ echo "→ Cloning ${REPO} (sparse) into ${TARGET} …"
47
+
48
+ git clone \
49
+ --filter=blob:none \
50
+ --sparse \
51
+ --depth=1 \
52
+ --no-checkout \
53
+ "https://github.com/${REPO}.git" \
54
+ "$TARGET"
55
+
56
+ pushd "$TARGET" > /dev/null
57
+
58
+ # Fetch only the paths the parser needs.
59
+ # --no-cone: use gitignore-style patterns so * wildcards are accepted.
60
+ # (default cone mode rejects wildcards with "specify directories rather
61
+ # than patterns")
62
+ git sparse-checkout set --no-cone \
63
+ "closed/*/systems" \
64
+ "closed/*/results/*/llama2-70b/*/performance" \
65
+ "closed/*/results/*/llama2-70b-99/*/performance" \
66
+ "closed/*/results/*/llama2-70b-99.9/*/performance" \
67
+ "closed/*/results/*/mixtral-8x7b/*/performance" \
68
+ "closed/*/results/*/mixtral-8x7b-99/*/performance" \
69
+ "closed/*/results/*/mixtral-8x7b-99.9/*/performance" \
70
+ "closed/*/results/*/llama3.1-405b/*/performance" \
71
+ "closed/*/results/*/llama3.1-405b-99/*/performance" \
72
+ "closed/*/results/*/llama3.1-405b-99.9/*/performance" \
73
+ "closed/*/results/*/gptj/*/performance" \
74
+ "closed/*/results/*/gptj-99/*/performance" \
75
+ "closed/*/results/*/gptj-99.9/*/performance"
76
+
77
+ git checkout
78
+
79
+ popd > /dev/null
80
+
81
+ echo " Done: ${TARGET}"
82
+ done
83
+
84
+ echo ""
85
+ echo "All requested rounds fetched. Run the parser with:"
86
+ echo " python -m src.data.mlperf_parser --repos-dir ${DEST} --parquet"
setup.cfg ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ [tool:pytest]
2
+ markers =
3
+ gate: GATE-level reliability target — blocks merge if failing (see JOURNAL.md §"Reliability Targets")
4
+
5
+ [flake8]
6
+ max-line-length = 100
7
+ # E203: whitespace before ':' — conflicts with slice notation
8
+ # W503: line break before binary operator — conflicts with Black style
9
+ extend-ignore = E203, W503
src/__init__.py ADDED
File without changes
src/api/__init__.py ADDED
File without changes
src/api/main.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GPU Perf Prophet — FastAPI application.
3
+
4
+ Routes
5
+ ------
6
+ GET /health liveness probe
7
+ GET /gpus list all GPU ids in the spec DB
8
+ GET /models list all supported LLM model names
9
+ POST /predict predict throughput for one (GPU, workload) pair
10
+ POST /predict/batch vectorised prediction for up to 50 pairs
11
+ POST /recommend Pareto GPU recommendation for a workload
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from contextlib import asynccontextmanager
18
+ from typing import Annotated
19
+
20
+ from fastapi import FastAPI, HTTPException, Request
21
+ from fastapi.middleware.cors import CORSMiddleware
22
+ from fastapi.responses import Response
23
+ from pydantic import Field
24
+
25
+ from src.api.schemas import (
26
+ PredictRequest,
27
+ PredictResponse,
28
+ RecommendRequest,
29
+ RecommendResponse,
30
+ )
31
+ from src.data.gpu_spec_db import load_specs
32
+ from src.models.predictor import GpuPredictor, VALID_MODELS
33
+ from src.recommend.recommender import GpuRecommender
34
+
35
+ # Hard cap on raw request body size — prevents parsing-based DoS before any
36
+ # Pydantic validation runs. 50 PredictRequest objects at ~200 bytes each ≈
37
+ # 10 KB; 1 MB is a generous ceiling that still blocks runaway bodies.
38
+ _MAX_BODY_BYTES = 1 * 1024 * 1024 # 1 MB
39
+
40
+ log = logging.getLogger(__name__)
41
+
42
+ _predictor: GpuPredictor | None = None
43
+ _recommender: GpuRecommender | None = None
44
+ _gpu_list: list[dict] | None = None
45
+ _model_list: list[str] = sorted(VALID_MODELS)
46
+
47
+
48
+ @asynccontextmanager
49
+ async def lifespan(app: FastAPI):
50
+ global _predictor, _recommender, _gpu_list
51
+ log.info("Loading model …")
52
+ _predictor = GpuPredictor()
53
+ _recommender = GpuRecommender(_predictor)
54
+ specs = load_specs()
55
+ _gpu_list = [
56
+ {
57
+ "id": s["id"],
58
+ "name": s.get("name", s["id"]),
59
+ "vendor": s.get("vendor"),
60
+ "architecture": s.get("architecture"),
61
+ "vram_gb": s.get("vram_gb"),
62
+ "in_model_scope": s.get("in_model_scope", False),
63
+ }
64
+ for s in specs
65
+ ]
66
+ log.info("Ready.")
67
+ yield
68
+ _predictor = None
69
+ _recommender = None
70
+ _gpu_list = None
71
+
72
+
73
+ app = FastAPI(
74
+ title="GPU Perf Prophet",
75
+ description=(
76
+ "Cross-vendor LLM inference performance forecasting "
77
+ "and workload-aware GPU recommendation."
78
+ ),
79
+ version="1.0.0",
80
+ lifespan=lifespan,
81
+ )
82
+
83
+ app.add_middleware(
84
+ CORSMiddleware,
85
+ allow_origins=["*"],
86
+ allow_methods=["GET", "POST"],
87
+ allow_headers=["Content-Type"],
88
+ )
89
+
90
+
91
+ @app.middleware("http")
92
+ async def limit_body_size(request: Request, call_next) -> Response:
93
+ # Fast path: reject if Content-Length header exceeds the limit.
94
+ # A malformed or missing Content-Length header is not an error here —
95
+ # it just means we must check the actual body below.
96
+ cl = request.headers.get("content-length")
97
+ if cl is not None:
98
+ try:
99
+ if int(cl) > _MAX_BODY_BYTES:
100
+ return Response(status_code=413, content="Request body too large")
101
+ except ValueError:
102
+ return Response(status_code=400, content="Invalid Content-Length header")
103
+
104
+ # GET / HEAD / OPTIONS carry no request body — no route handler reads one,
105
+ # so the size limit is irrelevant and buffering is wasted work.
106
+ if request.method in {"GET", "HEAD", "OPTIONS"}:
107
+ return await call_next(request)
108
+
109
+ # Slow path: consume the actual body so chunked-transfer or lying clients
110
+ # cannot bypass the Content-Length check above. Without this step, a
111
+ # client sending without Content-Length (chunked encoding) would bypass
112
+ # the limit entirely because uvicorn imposes no default body size cap.
113
+ body = await request.body()
114
+ if len(body) > _MAX_BODY_BYTES:
115
+ return Response(status_code=413, content="Request body too large")
116
+
117
+ # Re-inject the consumed body so downstream route handlers can read it.
118
+ async def _receive() -> dict:
119
+ return {"type": "http.request", "body": body, "more_body": False}
120
+
121
+ request._receive = _receive
122
+ return await call_next(request)
123
+
124
+
125
+ def _get_predictor() -> GpuPredictor:
126
+ if _predictor is None:
127
+ raise HTTPException(status_code=503, detail="Model not loaded")
128
+ return _predictor
129
+
130
+
131
+ def _get_recommender() -> GpuRecommender:
132
+ if _recommender is None:
133
+ raise HTTPException(status_code=503, detail="Recommender not loaded")
134
+ return _recommender
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Routes
139
+ # ---------------------------------------------------------------------------
140
+
141
+ @app.get("/health")
142
+ def health() -> dict:
143
+ return {"status": "ok", "model_loaded": _predictor is not None}
144
+
145
+
146
+ @app.get("/gpus")
147
+ def list_gpus() -> dict:
148
+ return {"gpus": _gpu_list or []}
149
+
150
+
151
+ @app.get("/models")
152
+ def list_models() -> dict:
153
+ return {"models": _model_list}
154
+
155
+
156
+ @app.post("/predict", response_model=PredictResponse)
157
+ def predict(req: PredictRequest) -> dict:
158
+ predictor = _get_predictor()
159
+ try:
160
+ return predictor.predict(
161
+ gpu_id=req.gpu_id,
162
+ model_name=req.model_name,
163
+ scenario=req.scenario,
164
+ accuracy_tier=req.accuracy_tier,
165
+ framework=req.framework,
166
+ )
167
+ except ValueError as exc:
168
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
169
+
170
+
171
+ @app.post("/predict/batch", response_model=list[PredictResponse])
172
+ def predict_batch(
173
+ requests: Annotated[list[PredictRequest], Field(max_length=50)],
174
+ ) -> list[dict]:
175
+ predictor = _get_predictor()
176
+ try:
177
+ return predictor.predict_batch([r.model_dump() for r in requests])
178
+ except ValueError as exc:
179
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
180
+
181
+
182
+ @app.post("/recommend", response_model=RecommendResponse)
183
+ def recommend(req: RecommendRequest) -> dict:
184
+ recommender = _get_recommender()
185
+ try:
186
+ return recommender.recommend(
187
+ model_name=req.model_name,
188
+ scenario=req.scenario,
189
+ accuracy_tier=req.accuracy_tier,
190
+ framework=req.framework,
191
+ budget_per_gpu_hr=req.budget_per_gpu_hr,
192
+ min_throughput_tok_per_sec=req.min_throughput_tok_per_sec,
193
+ )
194
+ except ValueError as exc:
195
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
src/api/schemas.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic request/response schemas for the GPU Perf Prophet API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal, Optional
6
+
7
+ from pydantic import BaseModel, Field, field_validator
8
+
9
+
10
+ Scenario = Literal["Offline", "Server"]
11
+ AccuracyTier = Literal["base", "99", "99.9"]
12
+ Framework = Literal["vllm", "tensorrt", "rocm_other", "other"]
13
+
14
+
15
+ class PredictRequest(BaseModel):
16
+ gpu_id: str = Field(..., max_length=100, examples=["mi300x"])
17
+ model_name: str = Field(..., max_length=100, examples=["llama2-70b"])
18
+ scenario: Scenario = "Offline"
19
+ accuracy_tier: AccuracyTier = "99"
20
+ framework: Framework = "vllm"
21
+
22
+
23
+ class PredictResponse(BaseModel):
24
+ gpu_id: str
25
+ model_name: str
26
+ scenario: str
27
+ accuracy_tier: str
28
+ framework: str
29
+ pred_throughput_tok_per_sec: float
30
+ roofline_tput_tok_per_sec: float
31
+ efficiency_ratio: float
32
+ vram_fits: bool
33
+ model_size_gb: float
34
+
35
+
36
+ class RecommendRequest(BaseModel):
37
+ model_name: str = Field(..., max_length=100, examples=["llama2-70b"])
38
+ scenario: Scenario = "Offline"
39
+ accuracy_tier: AccuracyTier = "99"
40
+ framework: Framework = "vllm"
41
+ budget_per_gpu_hr: Optional[float] = Field(None, gt=0, examples=[4.0])
42
+ min_throughput_tok_per_sec: Optional[float] = Field(None, gt=0)
43
+
44
+ @field_validator("budget_per_gpu_hr", "min_throughput_tok_per_sec", mode="before")
45
+ @classmethod
46
+ def _coerce_none(cls, v: object) -> object:
47
+ return None if v == 0 else v
48
+
49
+
50
+ class GpuResult(BaseModel):
51
+ gpu_id: str
52
+ gpu_name: str
53
+ vendor: str
54
+ model_name: str
55
+ scenario: str
56
+ accuracy_tier: str
57
+ framework: str
58
+ pred_throughput_tok_per_sec: float
59
+ roofline_tput_tok_per_sec: float
60
+ efficiency_ratio: float
61
+ vram_fits: bool
62
+ model_size_gb: float
63
+ vram_gb: Optional[float]
64
+ price_per_gpu_hr: Optional[float]
65
+ vram_headroom: float
66
+ cost_efficiency: Optional[float]
67
+ throughput: float
68
+
69
+
70
+ class FilteredGpuResult(GpuResult):
71
+ reject_reason: str
72
+
73
+
74
+ class WorkloadSummary(BaseModel):
75
+ model_name: str
76
+ scenario: str
77
+ accuracy_tier: str
78
+ framework: str
79
+ model_size_gb: float
80
+ budget_per_gpu_hr: Optional[float]
81
+ min_throughput_tok_per_sec: Optional[float]
82
+
83
+
84
+ class RecommendResponse(BaseModel):
85
+ frontier: list[GpuResult]
86
+ dominated: list[GpuResult]
87
+ filtered: list[FilteredGpuResult]
88
+ workload: WorkloadSummary
src/data/__init__.py ADDED
File without changes
src/data/gpu_spec_db.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GPU specification database loader for GPU Perf Prophet.
3
+
4
+ Loads data/gpu_specs.yaml and provides two public entry-points:
5
+
6
+ normalize_gpu_name(raw_name, specs) → Optional[str]
7
+ Maps a raw MLPerf accelerator_model_name to a canonical GPU id.
8
+ Returns None for heterogeneous multi-GPU strings, unknown GPUs, or
9
+ explicitly unrecognised names.
10
+
11
+ enrich_df(df, spec_path) → pd.DataFrame
12
+ Adds GPU hardware spec columns to a parsed MLPerf DataFrame.
13
+ Rows whose gpu_name cannot be normalized get NaN in spec columns.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import functools
19
+ import logging
20
+ import re
21
+ import stat as _stat
22
+ import types
23
+ from pathlib import Path
24
+ from typing import Optional
25
+
26
+ import pandas as pd
27
+ import yaml
28
+
29
+ # Real GPU spec files are ~10 KB. 1 MB cap blocks both accidental and
30
+ # adversarial oversized files; mirrors _safe_read_text in mlperf_parser.py.
31
+ _MAX_SPEC_BYTES: int = 1 * 1024 * 1024 # 1 MB
32
+
33
+ log = logging.getLogger(__name__)
34
+
35
+ _DEFAULT_SPEC_PATH = Path(__file__).parent.parent.parent / "data" / "gpu_specs.yaml"
36
+
37
+ # Columns added by enrich_df, in output order.
38
+ SPEC_COLUMNS = [
39
+ "canonical_gpu_id",
40
+ "gpu_vendor",
41
+ "gpu_architecture",
42
+ "gpu_memory_type",
43
+ "gpu_vram_gb",
44
+ "gpu_hbm_bandwidth_tbps",
45
+ "gpu_peak_fp32_tflops",
46
+ "gpu_peak_bf16_tflops",
47
+ "gpu_peak_fp16_tflops",
48
+ "gpu_peak_fp8_tflops",
49
+ "gpu_peak_fp6_tflops",
50
+ "gpu_peak_fp4_tflops",
51
+ "gpu_peak_int8_tops",
52
+ "gpu_cu_sm_count",
53
+ "gpu_l2_cache_mb",
54
+ "gpu_tdp_w",
55
+ "gpu_in_model_scope",
56
+ "gpu_spec_confidence",
57
+ ]
58
+
59
+ # Matches "(x8)", "(x16)", "(x32)", "(x87)" etc.
60
+ _COUNT_SUFFIX_RE = re.compile(r"\s*\(x\d+\)\s*$")
61
+ # Matches "(Power Cap 1000 W)" and similar parenthetical power notes
62
+ _POWER_SUFFIX_RE = re.compile(r"\s*\(Power Cap[^)]*\)\s*$", re.IGNORECASE)
63
+
64
+
65
+ @functools.lru_cache(maxsize=4)
66
+ def _build_spec_cache(spec_path_str: str) -> tuple[list, dict, dict]:
67
+ """
68
+ Load the spec file and build the two derived lookup structures.
69
+ Cached by path string so repeated enrich_df calls pay the I/O and
70
+ parse cost only once per unique path.
71
+
72
+ Returns (specs, alias_index, id_map).
73
+
74
+ The cache key is the literal path string (not resolved), so the
75
+ symlink guard in load_specs still fires on first access.
76
+ Cached objects must not be mutated by callers.
77
+ """
78
+ specs = load_specs(Path(spec_path_str))
79
+ index = _build_alias_index(specs)
80
+ # Wrap id_map values in MappingProxyType so callers cannot mutate the
81
+ # cached spec rows. The proxy supports all read operations (get, iter,
82
+ # subscript) that downstream code uses.
83
+ id_map = {s["id"]: types.MappingProxyType(_spec_row(s)) for s in specs}
84
+ return specs, index, id_map
85
+
86
+
87
+ @functools.lru_cache(maxsize=8)
88
+ def load_specs(spec_path: Path | str = _DEFAULT_SPEC_PATH) -> list[dict]:
89
+ """
90
+ Load and return the list of GPU spec dicts from the YAML file.
91
+
92
+ Raises ValueError for symlinks (path traversal guard) or oversized files.
93
+ Raises FileNotFoundError / OSError for I/O problems.
94
+ Raises ValueError for malformed YAML that is missing the 'gpus' key.
95
+ """
96
+ path = Path(spec_path)
97
+ try:
98
+ st = path.lstat()
99
+ except OSError as exc:
100
+ raise FileNotFoundError(f"GPU spec DB not found: {path}") from exc
101
+ if _stat.S_ISLNK(st.st_mode):
102
+ raise ValueError(f"GPU spec DB path is a symlink (refused): {path}")
103
+ if st.st_size > _MAX_SPEC_BYTES:
104
+ raise ValueError(
105
+ f"GPU spec DB too large ({st.st_size} bytes > {_MAX_SPEC_BYTES}): {path}"
106
+ )
107
+ with path.open(encoding="utf-8") as f:
108
+ data = yaml.safe_load(f)
109
+ if not isinstance(data, dict) or "gpus" not in data:
110
+ raise ValueError(
111
+ f"GPU spec DB at {path} is missing required 'gpus' key "
112
+ f"(got {type(data).__name__})"
113
+ )
114
+ return data["gpus"]
115
+
116
+
117
+ def _build_alias_index(specs: list[dict]) -> dict[str, dict]:
118
+ """Return lowercase-alias → spec dict for fast lookup."""
119
+ index: dict[str, dict] = {}
120
+ for spec in specs:
121
+ for alias in spec.get("aliases", []):
122
+ key = alias.lower()
123
+ if key in index and index[key]["id"] != spec["id"]:
124
+ # Warn only when the same alias maps to two *different* GPU ids.
125
+ # Duplicate aliases within the same GPU (e.g. "HBM3E" vs "HBM3e")
126
+ # are harmless after case-folding and should not produce noise.
127
+ log.warning(
128
+ "Conflicting alias %r maps to both %r and %r in GPU spec DB",
129
+ alias, index[key]["id"], spec["id"],
130
+ )
131
+ index[key] = spec
132
+ return index
133
+
134
+
135
+ def _is_heterogeneous(raw_name: str) -> bool:
136
+ """Return True for multi-GPU strings mixing different SKUs."""
137
+ # "A and B" pattern (heterogeneous mix)
138
+ if " and " in raw_name.lower():
139
+ return True
140
+ # "A (xN), B (xM)" pattern
141
+ if "," in raw_name and re.search(r"\(x\d+\)", raw_name):
142
+ return True
143
+ return False
144
+
145
+
146
+ def _strip_suffixes(raw_name: str) -> str:
147
+ """Remove parenthetical count and power suffixes from a GPU name."""
148
+ name = _COUNT_SUFFIX_RE.sub("", raw_name)
149
+ name = _POWER_SUFFIX_RE.sub("", name)
150
+ return name.strip()
151
+
152
+
153
+ def normalize_gpu_name(
154
+ raw_name: str | None,
155
+ specs: list[dict],
156
+ *,
157
+ _index: dict[str, dict] | None = None,
158
+ ) -> Optional[str]:
159
+ """
160
+ Map a raw MLPerf accelerator_model_name to a canonical GPU id.
161
+
162
+ Returns None for:
163
+ - null / "N/A" names
164
+ - heterogeneous multi-GPU strings (e.g. "MI300X ... and MI325X ...")
165
+ - names that don't match any alias in the spec DB (logged at DEBUG)
166
+ """
167
+ if not raw_name or raw_name.strip().upper() in {"N/A", "NA", "NONE", ""}:
168
+ return None
169
+
170
+ if _is_heterogeneous(raw_name):
171
+ log.debug("Skipping heterogeneous GPU string: %r", raw_name)
172
+ return None
173
+
174
+ index = _index if _index is not None else _build_alias_index(specs)
175
+ clean = _strip_suffixes(raw_name)
176
+
177
+ match = index.get(clean.lower())
178
+ if match is None:
179
+ log.debug("No GPU spec match for: %r (stripped: %r)", raw_name, clean)
180
+ return None
181
+
182
+ return match["id"]
183
+
184
+
185
+ def _spec_row(spec: dict) -> dict:
186
+ """Flatten one GPU spec dict into the SPEC_COLUMNS schema."""
187
+ pt = spec.get("peak_tflops") or {}
188
+ return {
189
+ "canonical_gpu_id": spec["id"],
190
+ "gpu_vendor": spec.get("vendor"),
191
+ "gpu_architecture": spec.get("architecture"),
192
+ "gpu_memory_type": spec.get("memory_type"),
193
+ "gpu_vram_gb": spec.get("vram_gb"),
194
+ "gpu_hbm_bandwidth_tbps": spec.get("hbm_bandwidth_tbps"),
195
+ "gpu_peak_fp32_tflops": pt.get("fp32"),
196
+ "gpu_peak_bf16_tflops": pt.get("bf16"),
197
+ "gpu_peak_fp16_tflops": pt.get("fp16"),
198
+ "gpu_peak_fp8_tflops": pt.get("fp8"),
199
+ "gpu_peak_fp6_tflops": pt.get("fp6"),
200
+ "gpu_peak_fp4_tflops": pt.get("fp4"),
201
+ "gpu_peak_int8_tops": pt.get("int8"),
202
+ # AMD uses compute_units; NVIDIA uses streaming_multiprocessors.
203
+ # Stored under a unified key so feature engineering is vendor-agnostic.
204
+ "gpu_cu_sm_count": (
205
+ spec.get("compute_units") or spec.get("streaming_multiprocessors")
206
+ ),
207
+ "gpu_l2_cache_mb": spec.get("l2_cache_mb"),
208
+ "gpu_tdp_w": spec.get("tdp_w"),
209
+ "gpu_in_model_scope": spec.get("in_model_scope"),
210
+ "gpu_spec_confidence": spec.get("spec_confidence"),
211
+ }
212
+
213
+
214
+ def enrich_df(
215
+ df: pd.DataFrame,
216
+ spec_path: Path | str = _DEFAULT_SPEC_PATH,
217
+ ) -> pd.DataFrame:
218
+ """
219
+ Add GPU hardware spec columns to a parsed MLPerf DataFrame.
220
+
221
+ Joins on the canonical_gpu_id derived from each row's gpu_name.
222
+ Rows whose gpu_name cannot be normalized receive NaN in all spec columns.
223
+ The original DataFrame is not modified; a new DataFrame is returned.
224
+ """
225
+ # Fix 1: derive lookup structures once per unique path (cached).
226
+ specs, index, id_map = _build_spec_cache(str(Path(spec_path)))
227
+
228
+ # Fix 2: normalize once per unique gpu_name, then broadcast to all rows.
229
+ # The corpus has ~33 unique names across ~1223 rows; calling
230
+ # normalize_gpu_name per-row wastes ~1190 redundant lookups.
231
+ unique_names = {n for n in df["gpu_name"].dropna().unique()}
232
+ name_to_id = {
233
+ name: normalize_gpu_name(name, specs, _index=index)
234
+ for name in unique_names
235
+ }
236
+ canonical_ids = df["gpu_name"].map(name_to_id)
237
+
238
+ spec_df = pd.DataFrame(
239
+ [id_map.get(cid, {}) for cid in canonical_ids],
240
+ index=df.index,
241
+ )
242
+
243
+ # Ensure all expected columns exist even if spec_rows were all empty.
244
+ for col in SPEC_COLUMNS:
245
+ if col not in spec_df.columns:
246
+ spec_df[col] = None
247
+
248
+ matched = canonical_ids.notna().sum()
249
+ unmatched = canonical_ids.isna().sum()
250
+ log.info(
251
+ "GPU spec enrichment: %d rows matched, %d unmatched (%.0f%%)",
252
+ matched, unmatched, 100 * matched / max(len(df), 1),
253
+ )
254
+ if unmatched:
255
+ unseen = set(df.loc[canonical_ids.isna(), "gpu_name"].dropna().unique())
256
+ # Use %r so control characters (newlines, ANSI escapes) in GPU names
257
+ # are escaped rather than injected into the log stream.
258
+ for name in sorted(unseen):
259
+ log.debug("Unmatched gpu_name: %r", name)
260
+
261
+ result = pd.concat([df, spec_df[SPEC_COLUMNS]], axis=1)
262
+
263
+ # Warn when the parser's reported vram_gb (from system JSON
264
+ # accelerator_memory_capacity) differs from the spec DB's gpu_vram_gb.
265
+ # This happens when submitters report total system VRAM rather than
266
+ # per-GPU capacity. Use gpu_vram_gb for the memory-fit constraint;
267
+ # treat vram_gb as unreliable for multi-GPU submissions.
268
+ if "vram_gb" in result.columns and "gpu_vram_gb" in result.columns:
269
+ conflict = (
270
+ result["vram_gb"].notna()
271
+ & result["gpu_vram_gb"].notna()
272
+ & (result["vram_gb"] != result["gpu_vram_gb"])
273
+ )
274
+ if conflict.any():
275
+ n = conflict.sum()
276
+ examples = (
277
+ result.loc[conflict, ["gpu_name", "num_gpus", "vram_gb", "gpu_vram_gb"]]
278
+ .drop_duplicates()
279
+ )
280
+ log.warning(
281
+ "%d rows: vram_gb (parser) != gpu_vram_gb (spec DB). "
282
+ "Some submitters report total system VRAM. "
283
+ "Use gpu_vram_gb for the memory-fit constraint.",
284
+ n,
285
+ )
286
+ # Fix 3: itertuples ~10x faster than iterrows; sufficient for
287
+ # attribute access. %r quotes gpu_name to prevent log injection.
288
+ for row in examples.itertuples(index=False):
289
+ log.warning(
290
+ " vram conflict: gpu=%r num_gpus=%s reported=%s spec=%s",
291
+ row.gpu_name, row.num_gpus, row.vram_gb, row.gpu_vram_gb,
292
+ )
293
+
294
+ return result
src/data/mlperf_parser.py ADDED
@@ -0,0 +1,602 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MLPerf Inference results parser for GPU Perf Prophet.
3
+
4
+ Walks one or more local MLPerf Inference results repos (v4.1–v6.0) and
5
+ produces a flat CSV/Parquet of (system, workload, scenario) performance rows.
6
+ GPU hardware specs and LLM model specs are joined separately (see gpu_spec_db.py).
7
+
8
+ Directory contract — consistent across v4.1–v6.0, closed and open divisions:
9
+
10
+ <repo_root>/
11
+ {closed,open}/<submitter>/
12
+ results/<system_name>/<benchmark>/<scenario>/
13
+ performance/run_<N>/mlperf_log_summary.txt
14
+ systems/<system_name>.json
15
+
16
+ Usage (CLI):
17
+ python -m src.data.mlperf_parser \\
18
+ --repos-dir data/raw/mlperf \\
19
+ --rounds v4.1 v5.0 v5.1 v6.0 \\
20
+ --output data/processed/mlperf_raw.csv --parquet
21
+
22
+ Expects repos at data/raw/mlperf/v4.1/, data/raw/mlperf/v5.0/, etc.
23
+ See scripts/fetch_mlperf.sh for sparse-checkout instructions.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import logging
31
+ import re
32
+ import stat as _stat
33
+ from pathlib import Path
34
+ from typing import Optional
35
+
36
+ import pandas as pd
37
+
38
+ log = logging.getLogger(__name__)
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Reference tables
42
+ # ---------------------------------------------------------------------------
43
+
44
+ # All LLM inference benchmark names used across MLPerf v4.1–v6.0 (and anticipated v7.0+).
45
+ # Accuracy-constrained variants (e.g. -99, -99.9) share the same dataset.
46
+ LLM_BENCHMARKS: frozenset[str] = frozenset({
47
+ "gptj", "gptj-99", "gptj-99.9",
48
+ "llama2-70b", "llama2-70b-99", "llama2-70b-99.9",
49
+ "mixtral-8x7b", "mixtral-8x7b-99", "mixtral-8x7b-99.9",
50
+ "llama3.1-405b", "llama3.1-405b-99", "llama3.1-405b-99.9",
51
+ "llama3.1-8b", "llama3.1-8b-99", "llama3.1-8b-99.9",
52
+ })
53
+
54
+ # Approximate mean output tokens per sample — used to convert samples/sec → tok/sec.
55
+ # Sources: MLPerf Inference LLM benchmark dataset documentation.
56
+ # gptj: fixed 128-token output per benchmark spec.
57
+ # llama2-70b: ~294 mean output tokens from Open ORCA v1 (MLPerf v4.1+).
58
+ # mixtral-8x7b: ~145 mean output tokens (Open ORCA; verify per round if needed).
59
+ # llama3.1-405b: same Open ORCA dataset as llama2-70b per MLPerf rules.
60
+ # llama3.1-8b: same Open ORCA dataset as llama2-70b per MLPerf rules.
61
+ # IMPORTANT: these are estimates; flag them in the data card and re-verify if the
62
+ # dataset changes between rounds.
63
+ TOKENS_PER_SAMPLE: dict[str, int] = {
64
+ "gptj": 128,
65
+ "gptj-99": 128,
66
+ "gptj-99.9": 128,
67
+ "llama2-70b": 294,
68
+ "llama2-70b-99": 294,
69
+ "llama2-70b-99.9": 294,
70
+ "mixtral-8x7b": 145,
71
+ "mixtral-8x7b-99": 145,
72
+ "mixtral-8x7b-99.9": 145,
73
+ "llama3.1-405b": 294,
74
+ "llama3.1-405b-99": 294,
75
+ "llama3.1-405b-99.9": 294,
76
+ "llama3.1-8b": 294,
77
+ "llama3.1-8b-99": 294,
78
+ "llama3.1-8b-99.9": 294,
79
+ }
80
+
81
+
82
+ def _base_benchmark(benchmark: str) -> str:
83
+ """Strip accuracy-constraint suffix: 'llama2-70b-99' → 'llama2-70b'."""
84
+ for suffix in ("-99.9", "-99"):
85
+ if benchmark.endswith(suffix):
86
+ return benchmark[: -len(suffix)]
87
+ return benchmark
88
+
89
+
90
+ def _accuracy_tier(benchmark: str) -> str:
91
+ """
92
+ Extract the MLPerf accuracy-constraint tier from a benchmark name.
93
+
94
+ Returns one of "99.9" | "99" | "base".
95
+
96
+ Used as a proxy for precision when direct precision extraction fails
97
+ (which is almost always — real MLPerf system names are hardware
98
+ identifiers, not precision-tagged strings). Correlation:
99
+ "99.9" → FP16 (high-fidelity submissions; AMD may use FP8 achieving this tier)
100
+ "99" → typically FP8 or better
101
+ "base" → throughput-optimized; any precision acceptable to submitter
102
+ """
103
+ if benchmark.endswith("-99.9"):
104
+ return "99.9"
105
+ if benchmark.endswith("-99"):
106
+ return "99"
107
+ return "base"
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Safe file reading
112
+ # ---------------------------------------------------------------------------
113
+
114
+ # Real MLPerf files are < 50 KB. Cap at 512 KB (10× safety margin) to
115
+ # refuse corrupted or adversarial files without loading them into memory.
116
+ _MAX_FILE_BYTES: int = 512 * 1024 # 512 KB
117
+
118
+
119
+ def _safe_read_text(path: Path) -> Optional[str]:
120
+ """
121
+ Read a text file with three safety checks:
122
+
123
+ 1. Refuse symlinks — git repos can contain symlinks pointing anywhere on disk.
124
+ 2. Enforce a size cap — prevents memory exhaustion from oversized/crafted files.
125
+ 3. Catch only I/O errors — lets MemoryError and other fatal signals propagate.
126
+
127
+ Returns None (logged at WARNING) instead of raising on any guarded condition.
128
+ """
129
+ # Single lstat() call supplies both the symlink bit and the file size,
130
+ # avoiding a second stat() syscall compared to is_symlink() + stat().
131
+ try:
132
+ st = path.lstat()
133
+ except OSError as exc:
134
+ log.warning("Could not stat %s: %s", path, exc)
135
+ return None
136
+ if _stat.S_ISLNK(st.st_mode):
137
+ log.warning("Refusing to read symlink: %s", path)
138
+ return None
139
+ if st.st_size > _MAX_FILE_BYTES:
140
+ log.warning("Skipping oversized file (%d bytes > %d limit): %s",
141
+ st.st_size, _MAX_FILE_BYTES, path)
142
+ return None
143
+ try:
144
+ return path.read_text(encoding="utf-8", errors="replace")
145
+ except (OSError, UnicodeDecodeError) as exc:
146
+ log.warning("Could not read %s: %s", path, exc)
147
+ return None
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Field-parsing helpers
152
+ # ---------------------------------------------------------------------------
153
+
154
+ def _parse_vram_gb(s: object) -> Optional[float]:
155
+ """'192 GB' → 192.0, '80 GiB' → 80.0, None → None."""
156
+ if not s:
157
+ return None
158
+ # Require match to start with a digit so a bare '.' can't slip through.
159
+ m = re.search(r"(\d[\d.]*)\s*(?:GB|GiB)", str(s), re.IGNORECASE)
160
+ if m is None:
161
+ return None
162
+ try:
163
+ return float(m.group(1))
164
+ except ValueError:
165
+ log.warning("Could not parse VRAM value: %r", s)
166
+ return None
167
+
168
+
169
+ def _parse_int(v: object) -> Optional[int]:
170
+ try:
171
+ return int(v)
172
+ except (TypeError, ValueError):
173
+ return None
174
+
175
+
176
+ def _ns_to_ms(ns: Optional[int]) -> Optional[float]:
177
+ return ns / 1_000_000 if ns is not None else None
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # system_desc.json parser
182
+ # ---------------------------------------------------------------------------
183
+
184
+ def _parse_system_json(path: Path) -> dict:
185
+ """
186
+ Parse one system description JSON file into a flat dict.
187
+
188
+ Field names are consistent across v4.1–v6.0. Missing fields return None
189
+ rather than raising, so schema drift between rounds is handled gracefully.
190
+ """
191
+ text = _safe_read_text(path)
192
+ if text is None:
193
+ return {}
194
+ try:
195
+ raw = json.loads(text)
196
+ except json.JSONDecodeError as exc:
197
+ log.warning("Malformed JSON in %s: %s", path, exc)
198
+ return {}
199
+
200
+ # json.loads can return any JSON type (list, string, int, …); the caller
201
+ # expects a dict and calls raw.get(...), which raises AttributeError on
202
+ # non-dict types. Guard here so a corrupted or adversarial system JSON
203
+ # doesn't abort parsing of the entire round directory.
204
+ if not isinstance(raw, dict):
205
+ log.warning(
206
+ "Expected JSON object in %s, got %s — skipping",
207
+ path, type(raw).__name__,
208
+ )
209
+ return {}
210
+
211
+ # num_gpus = accelerators_per_node × number_of_nodes.
212
+ # Multi-node MLPerf submissions report throughput for the entire cluster but
213
+ # only set accelerators_per_node (= per-node count). Multiplying by
214
+ # number_of_nodes gives the total GPU count that throughput was measured
215
+ # against, which is the correct divisor for per-GPU normalisation.
216
+ accel_per_node = _parse_int(raw.get("accelerators_per_node")) or 1
217
+ num_nodes = max(_parse_int(raw.get("number_of_nodes")) or 1, 1)
218
+
219
+ return {
220
+ "gpu_name": raw.get("accelerator_model_name") or raw.get("accelerators"),
221
+ "num_gpus": accel_per_node * num_nodes,
222
+ "vram_gb": _parse_vram_gb(raw.get("accelerator_memory_capacity")),
223
+ "framework": raw.get("framework"),
224
+ "system_type": raw.get("system_type"), # "datacenter" or "edge"
225
+ "hw_status": raw.get("status"), # "available" / "preview" / "rdi"
226
+ }
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # mlperf_log_summary.txt parser
231
+ # ---------------------------------------------------------------------------
232
+
233
+ # Regex patterns — cover formatting variations observed across v4.1–v6.0.
234
+ _RE_SCENARIO = re.compile(r"Scenario\s*:\s*(\S+)", re.I)
235
+ _RE_VALID = re.compile(r"Result is\s*:\s*(\w+)", re.I)
236
+ # Anchored to line-start so it doesn't match "Completed samples per second" in Server logs.
237
+ _RE_OFFLINE_TPUT = re.compile(r"^Samples per second\s*:\s*([\d.]+)", re.I | re.MULTILINE)
238
+ _RE_SERVER_TPUT = re.compile(r"Scheduled samples per second\s*:\s*([\d.]+)", re.I)
239
+ _RE_LAT_MEAN = re.compile(r"Mean latency\s*\(ns\)\s*:\s*(\d+)", re.I)
240
+ _RE_LAT_P99 = re.compile(r"99\.00 percentile latency\s*\(ns\)\s*:\s*(\d+)", re.I)
241
+ # LLM-specific metrics (present in v4.1+ for LLM benchmarks)
242
+ _RE_TTFT_MEAN = re.compile(r"Mean First Token Latency\s*\(ns\)\s*:\s*(\d+)", re.I)
243
+ _RE_TTFT_P99 = re.compile(r"99(?:th|\.00) Percentile First Token Latency\s*\(ns\)\s*:\s*(\d+)", re.I)
244
+ _RE_TPOT_MEAN = re.compile(r"Mean Time Per Output Token\s*\(ns\)\s*:\s*(\d+)", re.I)
245
+ _RE_TPOT_P99 = re.compile(r"99(?:th|\.00) Percentile Time Per Output Token\s*\(ns\)\s*:\s*(\d+)", re.I)
246
+
247
+
248
+ def _parse_log_summary(path: Path) -> dict:
249
+ """
250
+ Parse one mlperf_log_summary.txt into a flat dict of performance metrics.
251
+ All latencies are converted from nanoseconds → milliseconds.
252
+ Returns an empty dict on read/parse failure (logged at WARNING).
253
+ """
254
+ text = _safe_read_text(path)
255
+ if text is None:
256
+ return {}
257
+
258
+ def _f(pat: re.Pattern) -> Optional[float]:
259
+ m = pat.search(text)
260
+ return float(m.group(1)) if m else None
261
+
262
+ def _i(pat: re.Pattern) -> Optional[int]:
263
+ m = pat.search(text)
264
+ return int(m.group(1)) if m else None
265
+
266
+ scenario_m = _RE_SCENARIO.search(text)
267
+ valid_m = _RE_VALID.search(text)
268
+
269
+ tput = _f(_RE_OFFLINE_TPUT) or _f(_RE_SERVER_TPUT)
270
+
271
+ return {
272
+ "scenario_from_log": scenario_m.group(1).capitalize() if scenario_m else None,
273
+ "result_valid": (valid_m.group(1).upper() == "VALID") if valid_m else None,
274
+ "throughput_samples_per_sec": tput,
275
+ "latency_mean_ms": _ns_to_ms(_i(_RE_LAT_MEAN)),
276
+ "latency_p99_ms": _ns_to_ms(_i(_RE_LAT_P99)),
277
+ "ttft_mean_ms": _ns_to_ms(_i(_RE_TTFT_MEAN)),
278
+ "ttft_p99_ms": _ns_to_ms(_i(_RE_TTFT_P99)),
279
+ "tpot_mean_ms": _ns_to_ms(_i(_RE_TPOT_MEAN)),
280
+ "tpot_p99_ms": _ns_to_ms(_i(_RE_TPOT_P99)),
281
+ }
282
+
283
+
284
+ # ---------------------------------------------------------------------------
285
+ # Precision extraction
286
+ # ---------------------------------------------------------------------------
287
+
288
+ # Ordered from most-specific to least-specific; first match wins.
289
+ _PRECISION_PATTERNS: list[tuple[str, re.Pattern]] = [
290
+ ("fp4", re.compile(r"\bfp4\b|\bmxfp4\b", re.I)),
291
+ ("fp6", re.compile(r"\bfp6\b|\bmxfp6\b", re.I)),
292
+ ("fp8", re.compile(r"\bfp8\b|\be4m3\b|\be5m2\b", re.I)),
293
+ ("int8", re.compile(r"\bint8\b|\bw8a8\b", re.I)),
294
+ ("bf16", re.compile(r"\bbf16\b|\bbfloat16\b", re.I)),
295
+ ("fp16", re.compile(r"\bfp16\b|\bfloat16\b", re.I)),
296
+ ]
297
+
298
+
299
+ def _extract_precision(system_name: str, framework: Optional[str]) -> Optional[str]:
300
+ """
301
+ Best-effort precision extraction from system_name and framework fields.
302
+
303
+ MLPerf doesn't have a dedicated precision field; submitters encode it in
304
+ the system name (e.g., 'AMD_MI300X_FP8_vLLM') and/or the framework string
305
+ (e.g., 'ROCm 6.2.4, vLLM 0.5.0-fp8'). Returns None when not found —
306
+ callers should treat None as 'unknown' rather than a specific precision.
307
+ """
308
+ # Replace underscores with spaces so \b word-boundaries work on tokens like
309
+ # "AMD_MI300X_FP8_vLLM" → "AMD MI300X FP8 vLLM", making \bFP8\b match.
310
+ search_text = f"{system_name} {framework or ''}".replace("_", " ")
311
+ for label, pat in _PRECISION_PATTERNS:
312
+ if pat.search(search_text):
313
+ return label
314
+ return None
315
+
316
+
317
+ # ---------------------------------------------------------------------------
318
+ # Run-directory resolution
319
+ # ---------------------------------------------------------------------------
320
+
321
+ def _run_number(log_file: Path) -> int:
322
+ """Extract the integer N from a 'run_N' directory name, -1 if not found."""
323
+ m = re.search(r"run_(\d+)", log_file.parent.name)
324
+ return int(m.group(1)) if m else -1
325
+
326
+
327
+ def _find_best_run(scenario_dir: Path) -> Optional[Path]:
328
+ """
329
+ Return the mlperf_log_summary.txt from the highest-numbered run_N directory
330
+ under <scenario_dir>/performance/. Returns None if none exists.
331
+ Symlinked run directories are skipped (see _safe_read_text for rationale).
332
+ """
333
+ perf_dir = scenario_dir / "performance"
334
+ if not perf_dir.is_dir() or perf_dir.is_symlink():
335
+ return None
336
+
337
+ # Single pass: filter symlinks at both directory and file level, extract
338
+ # run number, and discard non-numeric names — all in one list comprehension.
339
+ runs = [
340
+ (p, n)
341
+ for p in perf_dir.glob("run_*/mlperf_log_summary.txt")
342
+ if not p.parent.is_symlink()
343
+ and not p.is_symlink()
344
+ and (n := _run_number(p)) >= 0
345
+ ]
346
+ if not runs:
347
+ return None
348
+
349
+ return max(runs, key=lambda x: x[1])[0]
350
+
351
+
352
+ # ---------------------------------------------------------------------------
353
+ # Repo walker
354
+ # ---------------------------------------------------------------------------
355
+
356
+ def parse_repo(
357
+ repo_root: Path,
358
+ round_tag: str,
359
+ divisions: tuple[str, ...] = ("closed", "open"),
360
+ llm_only: bool = True,
361
+ ) -> pd.DataFrame:
362
+ """
363
+ Walk one MLPerf Inference results repo and return a DataFrame.
364
+
365
+ Parameters
366
+ ----------
367
+ repo_root : Local clone of an inference_results_vX.Y repo.
368
+ round_tag : Version label written into every output row, e.g. "v6.0".
369
+ divisions : Submission divisions to include ("closed", "open", or both).
370
+ llm_only : When True (default), skip non-LLM benchmarks (vision, audio, etc.).
371
+ """
372
+ repo_root = Path(repo_root)
373
+ rows: list[dict] = []
374
+
375
+ for division in divisions:
376
+ div_dir = repo_root / division
377
+ if not div_dir.is_dir():
378
+ continue
379
+
380
+ for submitter_dir in sorted(div_dir.iterdir()):
381
+ if not submitter_dir.is_dir() or submitter_dir.is_symlink():
382
+ continue
383
+ submitter = submitter_dir.name
384
+
385
+ systems_dir = submitter_dir / "systems"
386
+ results_dir = submitter_dir / "results"
387
+ if not results_dir.is_dir() or results_dir.is_symlink():
388
+ continue
389
+
390
+ for system_dir in sorted(results_dir.iterdir()):
391
+ if not system_dir.is_dir() or system_dir.is_symlink():
392
+ continue
393
+ system_name = system_dir.name
394
+
395
+ sys_json_path = systems_dir / f"{system_name}.json"
396
+ # Single lstat() gives existence + symlink bit — avoids the
397
+ # exists()+is_symlink()+is_symlink() three-call pattern.
398
+ try:
399
+ _st = sys_json_path.lstat()
400
+ except OSError:
401
+ hw = {}
402
+ log.debug("No system JSON for %r/%r", submitter, system_name)
403
+ else:
404
+ if _stat.S_ISLNK(_st.st_mode):
405
+ hw = {}
406
+ log.warning("Skipping symlinked system JSON: %s", sys_json_path)
407
+ else:
408
+ hw = _parse_system_json(sys_json_path)
409
+
410
+ # Precision is a system-level attribute — compute it once here
411
+ # rather than once per (benchmark, scenario) row.
412
+ precision = _extract_precision(system_name, hw.get("framework"))
413
+ # Clamp to ≥ 1: accelerators_per_node = "0" appears for CPU-only
414
+ # inference systems (e.g. Intel EMR PyTorch); using 0 as the
415
+ # divisor would produce ±inf throughput_tok_per_sec_per_gpu.
416
+ # The stored num_gpus must match the divisor used for computation.
417
+ num_gpus = max(hw.get("num_gpus") or 1, 1)
418
+
419
+ for benchmark_dir in sorted(system_dir.iterdir()):
420
+ if not benchmark_dir.is_dir() or benchmark_dir.is_symlink():
421
+ continue
422
+
423
+ # Lower-case once; reused for LLM_BENCHMARKS check,
424
+ # TOKENS_PER_SAMPLE lookup, and storage.
425
+ bench_lower = benchmark_dir.name.lower()
426
+
427
+ if llm_only and bench_lower not in LLM_BENCHMARKS:
428
+ continue
429
+
430
+ for scenario_dir in sorted(benchmark_dir.iterdir()):
431
+ if not scenario_dir.is_dir() or scenario_dir.is_symlink():
432
+ continue
433
+ scenario = scenario_dir.name # "Offline", "Server", "SingleStream", …
434
+
435
+ log_path = _find_best_run(scenario_dir)
436
+ if log_path is None:
437
+ log.debug("No performance run found: %s", scenario_dir)
438
+ continue
439
+
440
+ metrics = _parse_log_summary(log_path)
441
+
442
+ # Cross-check: scenario from log should match directory name.
443
+ log_scenario = metrics.get("scenario_from_log")
444
+ if log_scenario and log_scenario.lower() != scenario.lower():
445
+ log.debug(
446
+ "Scenario mismatch: dir=%r log=%r (%s)",
447
+ scenario, log_scenario, log_path,
448
+ )
449
+
450
+ tput = metrics.get("throughput_samples_per_sec")
451
+ tps = TOKENS_PER_SAMPLE.get(bench_lower)
452
+ tok_sec = tput * tps if (tput and tps) else None
453
+ # Per-GPU throughput is the model target variable.
454
+ # Roofline bounds are computed per-GPU, so we normalize here.
455
+ tok_sec_per_gpu = tok_sec / num_gpus if tok_sec else None
456
+
457
+ rows.append({
458
+ # Provenance
459
+ "round": round_tag,
460
+ "division": division,
461
+ "submitter": submitter,
462
+ "system_name": system_name,
463
+ # Hardware (from system JSON)
464
+ **hw,
465
+ # num_gpus overrides **hw: the clamped value (≥ 1)
466
+ # must match the divisor used for tok_sec_per_gpu.
467
+ "num_gpus": num_gpus,
468
+ # Workload
469
+ "benchmark": bench_lower,
470
+ "benchmark_base": _base_benchmark(bench_lower),
471
+ "benchmark_accuracy_tier": _accuracy_tier(bench_lower),
472
+ "scenario": scenario,
473
+ # precision: best-effort from system_name/framework regex;
474
+ # typically None on real data (system names are hardware
475
+ # identifiers, not precision-tagged). Use
476
+ # benchmark_accuracy_tier as the reliable proxy feature.
477
+ "precision": precision,
478
+ # Token-rate (per-node and per-GPU)
479
+ "tokens_per_sample": tps,
480
+ "throughput_tokens_per_sec": tok_sec,
481
+ "throughput_tok_per_sec_per_gpu": tok_sec_per_gpu,
482
+ # Performance metrics
483
+ **{k: v for k, v in metrics.items() if k != "scenario_from_log"},
484
+ # Audit trail — relative path preferred; fall back to
485
+ # absolute if log_path resolves outside repo_root
486
+ # (e.g. via an unexpected symlink).
487
+ "log_path": (
488
+ str(log_path.relative_to(repo_root))
489
+ if log_path.is_relative_to(repo_root)
490
+ else str(log_path)
491
+ ),
492
+ })
493
+
494
+ if not rows:
495
+ log.warning("No rows parsed from %s — check repo layout and round tag.", repo_root)
496
+ return pd.DataFrame()
497
+
498
+ df = pd.DataFrame(rows)
499
+ log.info("Parsed %d rows from %s (%s)", len(df), repo_root.name, round_tag)
500
+ return df
501
+
502
+
503
+ def parse_repos(
504
+ repo_specs: list[tuple[Path | str, str]],
505
+ llm_only: bool = True,
506
+ ) -> pd.DataFrame:
507
+ """
508
+ Parse multiple repos and return a single concatenated DataFrame.
509
+
510
+ Parameters
511
+ ----------
512
+ repo_specs : [(repo_path, round_tag), ...]
513
+ e.g. [("data/raw/mlperf/v6.0", "v6.0"), ...]
514
+ Paths that don't exist are silently skipped.
515
+ llm_only : Passed through to parse_repo.
516
+ """
517
+ frames = [
518
+ parse_repo(Path(p), tag, llm_only=llm_only)
519
+ for p, tag in repo_specs
520
+ if Path(p).is_dir()
521
+ ]
522
+ frames = [f for f in frames if not f.empty]
523
+ if not frames:
524
+ return pd.DataFrame()
525
+ return pd.concat(frames, ignore_index=True)
526
+
527
+
528
+ # ---------------------------------------------------------------------------
529
+ # CLI
530
+ # ---------------------------------------------------------------------------
531
+
532
+ def _build_arg_parser() -> argparse.ArgumentParser:
533
+ p = argparse.ArgumentParser(
534
+ description="Parse MLPerf Inference results repos into a flat CSV/Parquet.",
535
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
536
+ )
537
+ p.add_argument(
538
+ "--repos-dir",
539
+ default="data/raw/mlperf",
540
+ help="Parent directory containing version-named subdirs (v4.1/, v5.0/, …).",
541
+ )
542
+ p.add_argument(
543
+ "--rounds", nargs="+",
544
+ default=["v4.1", "v5.0", "v5.1", "v6.0"],
545
+ help="Round tags to look for under --repos-dir.",
546
+ )
547
+ p.add_argument(
548
+ "--output",
549
+ default="data/processed/mlperf_raw.csv",
550
+ help="Output CSV path.",
551
+ )
552
+ p.add_argument("--parquet", action="store_true",
553
+ help="Also write a .parquet file alongside the CSV.")
554
+ p.add_argument("--all-benchmarks", action="store_true",
555
+ help="Include non-LLM benchmarks (ResNet, BERT, DLRM, …).")
556
+ p.add_argument("--log-level", default="INFO",
557
+ choices=["DEBUG", "INFO", "WARNING", "ERROR"])
558
+ return p
559
+
560
+
561
+ def main() -> None:
562
+ args = _build_arg_parser().parse_args()
563
+ logging.basicConfig(level=args.log_level, format="%(levelname)s: %(message)s")
564
+
565
+ repos_dir = Path(args.repos_dir)
566
+ repo_specs = [(repos_dir / tag, tag) for tag in args.rounds]
567
+ found = [(p, t) for p, t in repo_specs if p.is_dir()]
568
+
569
+ if not found:
570
+ log.error(
571
+ "No round directories found in %s. "
572
+ "Run scripts/fetch_mlperf.sh first, or pass --repos-dir.",
573
+ repos_dir,
574
+ )
575
+ raise SystemExit(1)
576
+
577
+ df = parse_repos(found, llm_only=not args.all_benchmarks)
578
+ if df.empty:
579
+ log.error("No rows collected — check repo layout and round tags.")
580
+ raise SystemExit(1)
581
+
582
+ out = Path(args.output)
583
+ out.parent.mkdir(parents=True, exist_ok=True)
584
+ df.to_csv(out, index=False)
585
+ log.info("Wrote %d rows → %s", len(df), out)
586
+
587
+ if args.parquet:
588
+ pq = out.with_suffix(".parquet")
589
+ df.to_parquet(pq, index=False)
590
+ log.info("Wrote Parquet → %s", pq)
591
+
592
+ # Quick summary to stdout
593
+ if "gpu_name" in df.columns:
594
+ print("\nGPU coverage:")
595
+ print(df.groupby(["gpu_name", "round"])["benchmark_base"].nunique()
596
+ .rename("benchmarks")
597
+ .to_string())
598
+ print(f"\nTotal rows: {len(df)}")
599
+
600
+
601
+ if __name__ == "__main__":
602
+ main()
src/features/__init__.py ADDED
File without changes
src/features/build_features.py ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Feature engineering for GPU Perf Prophet.
3
+
4
+ Public API
5
+ ----------
6
+ build_training_df(raw_df)
7
+ Filter to in-scope rows, join GPU specs, add roofline features,
8
+ and return a model-ready DataFrame.
9
+
10
+ roofline_ceilings(total_params_b, compute_params_b, bytes_per_param, hbm_bw_tbps, peak_tflops)
11
+ Pure-function roofline computation used by the pipeline and notebooks.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import re
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ import pandas as pd
22
+
23
+ from src.data.gpu_spec_db import enrich_df
24
+
25
+ log = logging.getLogger(__name__)
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Reference tables
29
+ # ---------------------------------------------------------------------------
30
+
31
+ # benchmark_base → (total_params_b, compute_params_b)
32
+ #
33
+ # total_params_b : all weights loaded into VRAM — used for the
34
+ # memory-bandwidth ceiling and the VRAM-fit check.
35
+ # compute_params_b : active params per forward pass — equals total_params_b
36
+ # for dense models; equals active-expert params for MoE.
37
+ #
38
+ # Mixtral 8×7B: 8 experts of ~7B params each, but only 2 are activated per
39
+ # token. Shared layers (attention, embeddings) add ~0.7B. We approximate
40
+ # total = 46.7B and active = 2 × 6.7B + 0.7B ≈ 14.1B. Memory-bandwidth
41
+ # ceiling uses total (all weights reside in HBM); compute ceiling uses active.
42
+ #
43
+ # Sources:
44
+ # llama2-70b : Meta "Llama 2" paper, Table 1 (2023) — 70B parameters
45
+ # llama3.1-405b : Meta "Llama 3" blog (2024) — 405B parameters
46
+ # llama3.1-8b : Meta "Llama 3" blog (2024) — 8B parameters
47
+ # gptj : EleutherAI GPT-J-6B model card (2021) — 6.05B parameters
48
+ # mixtral-8x7b : Mistral AI blog (2023) — 46.7B total / ~14.1B active
49
+ MODEL_PARAMS: dict[str, tuple[float, float]] = {
50
+ "llama2-70b": (70.0, 70.0),
51
+ "llama3.1-405b": (405.0, 405.0),
52
+ "llama3.1-8b": (8.03, 8.03), # anticipated for MLPerf v7.0+; no rows yet
53
+ "gptj": (6.05, 6.05),
54
+ "mixtral-8x7b": (46.7, 14.1), # (total, active)
55
+ }
56
+
57
+ # benchmark_accuracy_tier → precision label used to select peak TFLOPS
58
+ # and compute bytes-per-param.
59
+ #
60
+ # "99.9" requires near-lossless accuracy → FP16 (no quantization risk).
61
+ # "99" allows modest accuracy drop → FP8 (halves memory footprint).
62
+ # "base" has the loosest constraint → BF16 (widely supported baseline).
63
+ #
64
+ # If the selected precision is not supported on a GPU (peak column is NaN or
65
+ # None), _select_peak_tflops falls back to FP16.
66
+ TIER_TO_PRECISION: dict[str, str] = {
67
+ "99.9": "fp16",
68
+ "99": "fp8",
69
+ "base": "bf16",
70
+ }
71
+
72
+ # Bytes occupied per stored parameter at each precision.
73
+ BYTES_PER_PARAM: dict[str, float] = {
74
+ "fp32": 4.0,
75
+ "bf16": 2.0,
76
+ "fp16": 2.0,
77
+ "fp8": 1.0,
78
+ "fp6": 0.75,
79
+ "fp4": 0.5,
80
+ "int8": 1.0,
81
+ }
82
+
83
+ # GPU peak TFLOPS column name for each precision label.
84
+ _PRECISION_TO_COL: dict[str, str] = {
85
+ "fp32": "gpu_peak_fp32_tflops",
86
+ "bf16": "gpu_peak_bf16_tflops",
87
+ "fp16": "gpu_peak_fp16_tflops",
88
+ "fp8": "gpu_peak_fp8_tflops",
89
+ "fp6": "gpu_peak_fp6_tflops",
90
+ "fp4": "gpu_peak_fp4_tflops",
91
+ "int8": "gpu_peak_int8_tops",
92
+ }
93
+
94
+ # Framework string → normalized family label.
95
+ # Matched in order; first hit wins.
96
+ _FRAMEWORK_PATTERNS: list[tuple[re.Pattern, str]] = [
97
+ (re.compile(r"TensorRT", re.IGNORECASE), "tensorrt"),
98
+ (re.compile(r"vLLM", re.IGNORECASE), "vllm"),
99
+ (re.compile(r"ROCm|Mango", re.IGNORECASE), "rocm_other"),
100
+ ]
101
+
102
+ # Per-vendor architecture generation ordinals (higher = newer).
103
+ # Split by vendor so the model cannot learn spurious cross-vendor "newer = better"
104
+ # patterns from a single linear scale (e.g. CDNA3 > Hopper is meaningless).
105
+ # NaN for the other vendor's GPUs is intentional and correct.
106
+ _NVIDIA_ARCH_ORDINAL: dict[str, int] = {
107
+ "ampere": 1,
108
+ "ada_lovelace": 2,
109
+ "hopper": 3,
110
+ "blackwell": 4,
111
+ }
112
+ _AMD_ARCH_ORDINAL: dict[str, int] = {
113
+ "cdna3": 1,
114
+ "cdna4": 2,
115
+ }
116
+
117
+ # MLPerf round tag → chronological ordinal (higher = more recent submission round).
118
+ # Prices in framework/driver maturity (e.g. ROCm version) as a feature: early
119
+ # rounds for a given GPU tend to run on less-tuned software stacks, which the
120
+ # model can otherwise mistake for a hardware effect. Unrecognized round tags
121
+ # map to NaN rather than raising, the same convention as the arch ordinals
122
+ # above — a future round just needs a new entry here.
123
+ ROUND_ORDINAL: dict[str, int] = {
124
+ "v4.1": 1,
125
+ "v5.0": 2,
126
+ "v5.1": 3,
127
+ "v6.0": 4,
128
+ }
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Core roofline computation (pure function — also used by notebooks directly)
133
+ # ---------------------------------------------------------------------------
134
+
135
+ def roofline_ceilings(
136
+ total_params_b: float,
137
+ compute_params_b: float,
138
+ bytes_per_param: float,
139
+ hbm_bw_tbps: float,
140
+ peak_tflops: float,
141
+ ) -> tuple[float, float, float]:
142
+ """Return (bandwidth_ceiling, compute_ceiling, roofline_tput) in tokens/sec.
143
+
144
+ bandwidth_ceiling
145
+ A bandwidth-density proxy: how many tokens/sec HBM bandwidth could
146
+ sustain if reading the full model weight footprint per token. Uses
147
+ *total* params (VRAM footprint), not active params, so that for MoE
148
+ models the feature reflects how much HBM capacity the model occupies
149
+ rather than per-token access volume. Not a hard ceiling for batched
150
+ inference, but a useful feature capturing GPU memory-bandwidth richness
151
+ relative to model size.
152
+
153
+ compute_ceiling
154
+ The absolute maximum tokens/sec at any batch size, limited by peak
155
+ FLOP/s. Uses *compute* (active) params because only the activated
156
+ weights participate in each forward pass. This IS the hard physical
157
+ ceiling for throughput; MLPerf Offline/Server run large batches and
158
+ approach this bound in practice.
159
+
160
+ roofline_tput = compute_ceiling.
161
+ The compute ceiling is the correct upper bound for batched LLM
162
+ inference. We keep bandwidth_ceiling as a separate feature rather
163
+ than folding it into the roofline.
164
+ """
165
+ model_bytes = total_params_b * 1e9 * bytes_per_param # bytes
166
+ bw_bytes_per_sec = hbm_bw_tbps * 1e12 # bytes/s
167
+ bw_ceil = bw_bytes_per_sec / model_bytes # tokens/s
168
+
169
+ flops_per_token = 2.0 * compute_params_b * 1e9 # FLOPs
170
+ peak_flops_per_sec = peak_tflops * 1e12 # FLOPs/s
171
+ compute_ceil = peak_flops_per_sec / flops_per_token # tokens/s
172
+
173
+ return bw_ceil, compute_ceil, compute_ceil
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Internal helpers
178
+ # ---------------------------------------------------------------------------
179
+
180
+ def _normalize_framework(raw: Optional[str]) -> str:
181
+ if not isinstance(raw, str):
182
+ return "unknown"
183
+ for pattern, label in _FRAMEWORK_PATTERNS:
184
+ if pattern.search(raw):
185
+ return label
186
+ return "other"
187
+
188
+
189
+ def _select_peak_tflops(row: pd.Series, precision: str) -> Optional[float]:
190
+ """Return the peak TFLOPS for `precision`, falling back to fp16 if absent."""
191
+ col = _PRECISION_TO_COL.get(precision)
192
+ val = row.get(col) if col else None
193
+ if val is None or (isinstance(val, float) and pd.isna(val)):
194
+ # GPU doesn't support this precision natively — fall back to fp16.
195
+ val = row.get("gpu_peak_fp16_tflops")
196
+ return val
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # Public pipeline
201
+ # ---------------------------------------------------------------------------
202
+
203
+ def build_training_df(
204
+ raw_df: pd.DataFrame,
205
+ spec_path: Optional[Path] = None,
206
+ ) -> pd.DataFrame:
207
+ """Filter, enrich, and featurise raw MLPerf data for model training.
208
+
209
+ Steps
210
+ -----
211
+ 1. Enrich with GPU spec columns via ``enrich_df``.
212
+ 2. Filter: ``result_valid == True`` only. ``gpu_in_model_scope`` is a
213
+ serving-layer gate, not a training filter.
214
+ 3. Attach model-parameter reference values.
215
+ 4. Compute roofline ceilings (bandwidth, compute ceiling only).
216
+ 5. Derive secondary features (efficiency ratio, VRAM fit, framework family,
217
+ architecture ordinal, vendor indicator).
218
+
219
+ Returns
220
+ -------
221
+ pd.DataFrame with all original columns plus the features listed below.
222
+ Rows that cannot be featurised (unknown benchmark_base or missing GPU
223
+ specs) are dropped with a warning rather than silently propagating NaN
224
+ into training.
225
+ """
226
+ kwargs = {"spec_path": spec_path} if spec_path is not None else {}
227
+ df = enrich_df(raw_df, **kwargs).copy()
228
+
229
+ # --- filter ---
230
+ # gpu_in_model_scope gates recommendation exposure, not training inclusion.
231
+ # Out-of-scope GPUs (e.g. B200, H200 NVL) are valid training signal; they
232
+ # inform the model about architectural trends even if not served to users v1.
233
+ df = df[df["result_valid"]]
234
+ log.info("After result_valid filter: %d rows", len(df))
235
+
236
+ # --- model params ---
237
+ df["model_total_params_b"] = df["benchmark_base"].map(
238
+ {k: v[0] for k, v in MODEL_PARAMS.items()}
239
+ )
240
+ df["model_compute_params_b"] = df["benchmark_base"].map(
241
+ {k: v[1] for k, v in MODEL_PARAMS.items()}
242
+ )
243
+
244
+ unknown_benchmarks = df[df["model_total_params_b"].isna()]["benchmark_base"].unique()
245
+ if len(unknown_benchmarks):
246
+ log.warning("Dropping %d rows with unknown benchmark_base: %s",
247
+ df["model_total_params_b"].isna().sum(), unknown_benchmarks)
248
+ df = df[df["model_total_params_b"].notna()]
249
+
250
+ # --- precision selection ---
251
+ df["selected_precision"] = df["benchmark_accuracy_tier"].map(TIER_TO_PRECISION)
252
+ # AMD CDNA hardware achieves 99.9 accuracy with FP8, not FP16. The
253
+ # TIER_TO_PRECISION default (99.9→fp16) is correct for NVIDIA; override
254
+ # it for AMD so efficiency_ratio targets are computed against the right
255
+ # ceiling (2× the TFLOPS of the FP16 ceiling, halving the efficiency
256
+ # ratio for those rows and eliminating the ceiling violations in training).
257
+ amd_tier_99_9 = (df["gpu_vendor"] == "amd") & (df["benchmark_accuracy_tier"] == "99.9")
258
+ df.loc[amd_tier_99_9, "selected_precision"] = "fp8"
259
+ df["bytes_per_param"] = df["selected_precision"].map(BYTES_PER_PARAM)
260
+
261
+ # Vectorised equivalent of _select_peak_tflops over all rows.
262
+ # Covers the three precision values that TIER_TO_PRECISION produces;
263
+ # fp16 is the fallback for any unknown precision or for GPUs whose
264
+ # selected-precision column is NaN (same as the scalar helper).
265
+ _fp16 = df["gpu_peak_fp16_tflops"]
266
+ _fp8 = df["gpu_peak_fp8_tflops"].where(df["gpu_peak_fp8_tflops"].notna(), _fp16)
267
+ _bf16 = df["gpu_peak_bf16_tflops"].where(df["gpu_peak_bf16_tflops"].notna(), _fp16)
268
+ _prec = df["selected_precision"]
269
+ df["peak_tflops_selected"] = _fp8.where(
270
+ _prec == "fp8", _bf16.where(_prec == "bf16", _fp16)
271
+ )
272
+
273
+ # --- roofline ---
274
+ missing_specs = df[
275
+ df["gpu_hbm_bandwidth_tbps"].isna() | df["peak_tflops_selected"].isna()
276
+ ]
277
+ if len(missing_specs):
278
+ log.warning("Dropping %d rows with missing GPU specs (cannot compute roofline)",
279
+ len(missing_specs))
280
+ df = df[
281
+ df["gpu_hbm_bandwidth_tbps"].notna() & df["peak_tflops_selected"].notna()
282
+ ]
283
+
284
+ _model_bytes = df["model_total_params_b"] * 1e9 * df["bytes_per_param"]
285
+ df["bandwidth_ceiling_tok_per_sec"] = (df["gpu_hbm_bandwidth_tbps"] * 1e12) / _model_bytes
286
+ df["compute_ceiling_tok_per_sec"] = (df["peak_tflops_selected"] * 1e12) / (
287
+ 2.0 * df["model_compute_params_b"] * 1e9
288
+ )
289
+ df["roofline_tput"] = df["compute_ceiling_tok_per_sec"]
290
+
291
+ # --- derived features ---
292
+ df["efficiency_ratio"] = (
293
+ df["throughput_tok_per_sec_per_gpu"] / df["roofline_tput"]
294
+ )
295
+ df["model_size_gb"] = (
296
+ df["model_total_params_b"] * df["bytes_per_param"]
297
+ )
298
+ df["model_to_vram_ratio"] = df["model_size_gb"] / df["gpu_vram_gb"]
299
+ # Deduplicate framework strings before normalizing — ~10–20 unique values
300
+ # across ~1112 rows, so .apply() would call _normalize_framework (3 regex
301
+ # searches) ~1112 times. Map unique values once, then broadcast.
302
+ # Parallels the per-unique-gpu-name optimization in enrich_df ("Fix 2").
303
+ _fw_unique_map = {fw: _normalize_framework(fw) for fw in df["framework"].dropna().unique()}
304
+ df["framework_family"] = df["framework"].map(_fw_unique_map).fillna("unknown")
305
+ df["nvidia_arch_gen"] = df["gpu_architecture"].map(_NVIDIA_ARCH_ORDINAL)
306
+ df["amd_arch_gen"] = df["gpu_architecture"].map(_AMD_ARCH_ORDINAL)
307
+ df["vendor_is_amd"] = (df["gpu_vendor"] == "amd").astype(int)
308
+ df["mlperf_round_num"] = df["round"].map(ROUND_ORDINAL)
309
+ # Binary flag separating the "base" accuracy tier (BF16, loose accuracy
310
+ # constraint) from the "99" and "99.9" tiers (FP8 / FP16 or the AMD FP8
311
+ # override). Replaces the three-level accuracy_tier_ord: after the AMD
312
+ # FP8 override, tiers "99" and "99.9" are both FP8 for AMD, making
313
+ # accuracy_tier_ord a spurious discriminator within AMD LOGO folds.
314
+ # bytes_per_param already captures the FP8 vs FP16 precision split for
315
+ # NVIDIA; is_base_tier captures the remaining base-vs-non-base distinction
316
+ # that bytes_per_param cannot express (base BF16 = 2.0, same as NVIDIA FP16).
317
+ df["is_base_tier"] = (df["benchmark_accuracy_tier"] == "base").astype(int)
318
+
319
+ # Efficiency ratio > 1 means actual throughput exceeds the compute ceiling
320
+ # at the precision we inferred from benchmark_accuracy_tier. This is
321
+ # expected for AMD CDNA4 rows at the "99.9" tier: AMD's vLLM/ROCm stack
322
+ # achieves 99.9 accuracy with FP8 (higher TFLOPS ceiling), but our proxy
323
+ # maps 99.9 → FP16. These rows are valid training data — the model learns
324
+ # that AMD systematically outperforms the FP16 ceiling for this tier.
325
+ n_violations = (df["throughput_tok_per_sec_per_gpu"] > df["roofline_tput"]).sum()
326
+ if n_violations:
327
+ log.warning(
328
+ "%d rows (%.1f%%) have throughput > compute ceiling at selected "
329
+ "precision — likely precision-proxy mismatch (AMD FP8 at 99.9 tier).",
330
+ n_violations,
331
+ 100 * n_violations / len(df),
332
+ )
333
+
334
+ log.info("build_training_df complete: %d rows, %d columns", len(df), df.shape[1])
335
+ return df.reset_index(drop=True)
src/models/__init__.py ADDED
File without changes
src/models/predictor.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GPU Perf Prophet — inference module.
3
+
4
+ Public API
5
+ ----------
6
+ GpuPredictor(model_dir)
7
+ Load the trained XGBoost model and metadata from disk.
8
+
9
+ predictor.predict(gpu_id, model_name, scenario, accuracy_tier, framework)
10
+ Predict tokens/sec for one (GPU, workload) pair.
11
+
12
+ predictor.predict_batch(requests)
13
+ Vectorised prediction for a list of dicts.
14
+
15
+ Feature construction mirrors the exact encoding used in
16
+ notebooks/03_model_training.ipynb so the serving path cannot diverge.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import copy
22
+ import json
23
+ import logging
24
+ import stat as _stat
25
+ from pathlib import Path
26
+
27
+ import numpy as np
28
+ import xgboost as xgb
29
+
30
+ from src.data.gpu_spec_db import load_specs
31
+ from src.features.build_features import (
32
+ MODEL_PARAMS,
33
+ TIER_TO_PRECISION,
34
+ ROUND_ORDINAL,
35
+ BYTES_PER_PARAM,
36
+ _NVIDIA_ARCH_ORDINAL,
37
+ _AMD_ARCH_ORDINAL,
38
+ roofline_ceilings,
39
+ )
40
+
41
+ log = logging.getLogger(__name__)
42
+
43
+ _DEFAULT_MODEL_DIR = Path(__file__).parent.parent.parent / "data" / "models"
44
+
45
+ # File-guard caps for model artifacts — mirrors load_specs() and _load_pricing().
46
+ _MAX_META_BYTES: int = 1 * 1024 * 1024 # 1 MB; real metadata JSON is ~1 KB
47
+ _MAX_MODEL_BYTES: int = 50 * 1024 * 1024 # 50 MB; matches RT-5.3 disk budget
48
+
49
+ # At serving time we always predict for the most mature software stack —
50
+ # i.e. the most recent MLPerf round. This corrects the ROCm-maturity
51
+ # confound without exposing round_tag as an API parameter.
52
+ _SERVING_ROUND: float = float(max(ROUND_ORDINAL.values()))
53
+
54
+ # Feature column order — must match FEATURE_COLS in the training notebook exactly.
55
+ FEATURE_COLS: list[str] = [
56
+ # GPU hardware
57
+ "gpu_hbm_bandwidth_tbps",
58
+ "gpu_vram_gb",
59
+ "peak_tflops_selected",
60
+ "compute_ceiling_tok_per_sec",
61
+ "bandwidth_ceiling_tok_per_sec",
62
+ # Model
63
+ "model_total_params_b",
64
+ "model_compute_params_b",
65
+ "model_size_gb",
66
+ "model_to_vram_ratio",
67
+ "bytes_per_param",
68
+ # Architecture
69
+ "nvidia_arch_gen",
70
+ "amd_arch_gen",
71
+ "vendor_is_amd",
72
+ # Encoded categoricals
73
+ "scenario_offline",
74
+ "is_base_tier",
75
+ "fw_tensorrt",
76
+ "fw_vllm",
77
+ "fw_rocm_other",
78
+ "is_cdna4",
79
+ # Context
80
+ "mlperf_round_num",
81
+ ]
82
+
83
+ VALID_SCENARIOS: frozenset[str] = frozenset({"Offline", "Server"})
84
+ VALID_TIERS: frozenset[str] = frozenset({"base", "99", "99.9"})
85
+ VALID_FRAMEWORKS: frozenset[str] = frozenset({"vllm", "tensorrt", "rocm_other", "other"})
86
+ VALID_MODELS: frozenset[str] = frozenset(MODEL_PARAMS.keys())
87
+
88
+
89
+ def _build_feature_vector(
90
+ *,
91
+ gpu_spec: dict,
92
+ model_name: str,
93
+ scenario: str,
94
+ accuracy_tier: str,
95
+ framework: str,
96
+ ) -> tuple[list[float], float, float]:
97
+ """Return (feature_vector, roofline_tput, model_size_gb) for one (GPU, workload) pair.
98
+
99
+ roofline_tput and model_size_gb are returned so callers don't re-derive
100
+ them with a second copy of the AMD precision override logic.
101
+ """
102
+ total_params_b, compute_params_b = MODEL_PARAMS[model_name]
103
+
104
+ selected_precision = TIER_TO_PRECISION[accuracy_tier]
105
+ # Mirror the AMD 99.9-tier override from build_features.build_training_df.
106
+ if gpu_spec.get("vendor") == "amd" and accuracy_tier == "99.9":
107
+ selected_precision = "fp8"
108
+ bpp = BYTES_PER_PARAM[selected_precision]
109
+
110
+ # Peak TFLOPS: use selected precision, fall back to fp16 if None/NaN.
111
+ pt = gpu_spec.get("peak_tflops") or {}
112
+ peak_tflops = pt.get(selected_precision)
113
+ if peak_tflops is None or (isinstance(peak_tflops, float) and np.isnan(peak_tflops)):
114
+ peak_tflops = pt.get("fp16")
115
+
116
+ hbm_bw = gpu_spec["hbm_bandwidth_tbps"]
117
+ vram_gb = gpu_spec["vram_gb"]
118
+
119
+ bw_ceil, compute_ceil, roofline_tput = roofline_ceilings(
120
+ total_params_b, compute_params_b, bpp, hbm_bw, peak_tflops
121
+ )
122
+
123
+ model_size_gb = total_params_b * bpp
124
+ model_to_vram_ratio = model_size_gb / vram_gb
125
+
126
+ arch = gpu_spec.get("architecture", "")
127
+ nvidia_arch_gen = _NVIDIA_ARCH_ORDINAL.get(arch)
128
+ amd_arch_gen = _AMD_ARCH_ORDINAL.get(arch)
129
+ vendor_is_amd = int(gpu_spec.get("vendor", "") == "amd")
130
+
131
+ is_cdna4 = int(amd_arch_gen == 2) if amd_arch_gen is not None else 0
132
+
133
+ scenario_offline = int(scenario == "Offline")
134
+ is_base_tier = int(accuracy_tier == "base")
135
+ fw_tensorrt = int(framework == "tensorrt")
136
+ fw_vllm = int(framework == "vllm")
137
+ fw_rocm_other = int(framework == "rocm_other")
138
+
139
+ # NaN for the other vendor's arch ordinal — XGBoost handles missing natively.
140
+ features: list[float] = [
141
+ hbm_bw,
142
+ vram_gb,
143
+ peak_tflops,
144
+ compute_ceil,
145
+ bw_ceil,
146
+ total_params_b,
147
+ compute_params_b,
148
+ model_size_gb,
149
+ model_to_vram_ratio,
150
+ bpp,
151
+ float(nvidia_arch_gen) if nvidia_arch_gen is not None else float("nan"),
152
+ float(amd_arch_gen) if amd_arch_gen is not None else float("nan"),
153
+ vendor_is_amd,
154
+ scenario_offline,
155
+ is_base_tier,
156
+ fw_tensorrt,
157
+ fw_vllm,
158
+ fw_rocm_other,
159
+ is_cdna4,
160
+ _SERVING_ROUND,
161
+ ]
162
+ return features, roofline_tput, model_size_gb
163
+
164
+
165
+ class GpuPredictor:
166
+ """Load-once, predict-many XGBoost inference wrapper."""
167
+
168
+ def __init__(self, model_dir: Path | str = _DEFAULT_MODEL_DIR) -> None:
169
+ model_dir = Path(model_dir)
170
+ meta_path = model_dir / "feature_metadata.json"
171
+ model_path = model_dir / "prophet_v1.json"
172
+
173
+ # File guards — mirrors the policy in load_specs() and _load_pricing().
174
+ # Symlink checks prevent path-traversal redirects; size caps prevent
175
+ # unbounded memory consumption. Applied before open() so the checks
176
+ # are not compiled away (unlike assert statements under python -O).
177
+ for _path, _cap in ((meta_path, _MAX_META_BYTES), (model_path, _MAX_MODEL_BYTES)):
178
+ try:
179
+ _st = _path.lstat()
180
+ except OSError as exc:
181
+ raise FileNotFoundError(f"Model artifact not found: {_path}") from exc
182
+ if _stat.S_ISLNK(_st.st_mode):
183
+ raise ValueError(f"Model artifact path is a symlink (refused): {_path}")
184
+ if _st.st_size > _cap:
185
+ raise ValueError(
186
+ f"Model artifact too large ({_st.st_size} bytes > {_cap}): {_path}"
187
+ )
188
+
189
+ with meta_path.open() as f:
190
+ self._meta = json.load(f)
191
+
192
+ # assert is compiled away under `python -O` / PYTHONOPTIMIZE=1 — use
193
+ # explicit raise so this check is never a no-op in any deployment mode.
194
+ if self._meta["feature_cols"] != FEATURE_COLS:
195
+ raise ValueError(
196
+ "feature_metadata.json feature_cols mismatch — retrain the model. "
197
+ f"Expected {len(FEATURE_COLS)} cols, got "
198
+ f"{len(self._meta.get('feature_cols', []))}."
199
+ )
200
+
201
+ self._model = xgb.XGBRegressor()
202
+ self._model.load_model(str(model_path))
203
+
204
+ specs = load_specs()
205
+ # Deep-copy each spec dict so _id_map holds independent objects.
206
+ # load_specs() is lru_cache'd and returns its live list; storing direct
207
+ # references means any write to a spec value (e.g. caching a derived
208
+ # field) would silently corrupt the global cache for all callers.
209
+ self._id_map: dict[str, dict] = {s["id"]: copy.deepcopy(s) for s in specs}
210
+
211
+ log.info(
212
+ "GpuPredictor loaded: model=%s features=%d gpus=%d",
213
+ model_path.name, len(FEATURE_COLS), len(self._id_map),
214
+ )
215
+
216
+ # ------------------------------------------------------------------
217
+ # Public API
218
+ # ------------------------------------------------------------------
219
+
220
+ def predict(
221
+ self,
222
+ *,
223
+ gpu_id: str,
224
+ model_name: str,
225
+ scenario: str = "Offline",
226
+ accuracy_tier: str = "99",
227
+ framework: str = "vllm",
228
+ ) -> dict:
229
+ """Predict inference throughput for one (GPU, workload) pair.
230
+
231
+ Returns
232
+ -------
233
+ dict with keys:
234
+ gpu_id, model_name, scenario, accuracy_tier, framework,
235
+ pred_throughput_tok_per_sec, roofline_tput_tok_per_sec,
236
+ efficiency_ratio, vram_fits
237
+ """
238
+ self._validate(gpu_id, model_name, scenario, accuracy_tier, framework)
239
+ gpu_spec = self._id_map[gpu_id]
240
+
241
+ features, roofline_tput, model_size_gb = _build_feature_vector(
242
+ gpu_spec=gpu_spec,
243
+ model_name=model_name,
244
+ scenario=scenario,
245
+ accuracy_tier=accuracy_tier,
246
+ framework=framework,
247
+ )
248
+
249
+ X = np.array([features], dtype=np.float32)
250
+ pred_eff = float(self._model.predict(X)[0])
251
+ pred_tput = pred_eff * roofline_tput
252
+
253
+ # Enforce roofline ceiling on the output (< 2% violation rate in CV;
254
+ # clamp rather than raise so API remains responsive).
255
+ pred_tput = min(pred_tput, roofline_tput)
256
+
257
+ vram_fits = model_size_gb <= gpu_spec["vram_gb"]
258
+
259
+ return {
260
+ "gpu_id": gpu_id,
261
+ "model_name": model_name,
262
+ "scenario": scenario,
263
+ "accuracy_tier": accuracy_tier,
264
+ "framework": framework,
265
+ "pred_throughput_tok_per_sec": round(pred_tput, 2),
266
+ "roofline_tput_tok_per_sec": round(roofline_tput, 2),
267
+ "efficiency_ratio": round(pred_eff, 4),
268
+ "vram_fits": vram_fits,
269
+ "model_size_gb": round(model_size_gb, 2),
270
+ }
271
+
272
+ def predict_batch(self, requests: list[dict]) -> list[dict]:
273
+ """Vectorised prediction over a list of request dicts.
274
+
275
+ Each dict must contain the same keys as predict() keyword args.
276
+ """
277
+ if not requests:
278
+ return []
279
+
280
+ feature_matrix: list[list[float]] = []
281
+ roofline_tputs: list[float] = []
282
+ meta: list[dict] = []
283
+
284
+ for req in requests:
285
+ gpu_id = req["gpu_id"]
286
+ model_name = req["model_name"]
287
+ scenario = req.get("scenario", "Offline")
288
+ accuracy_tier = req.get("accuracy_tier", "99")
289
+ framework = req.get("framework", "vllm")
290
+
291
+ self._validate(gpu_id, model_name, scenario, accuracy_tier, framework)
292
+ gpu_spec = self._id_map[gpu_id]
293
+ features, roofline_tput, model_size_gb = _build_feature_vector(
294
+ gpu_spec=gpu_spec,
295
+ model_name=model_name,
296
+ scenario=scenario,
297
+ accuracy_tier=accuracy_tier,
298
+ framework=framework,
299
+ )
300
+ feature_matrix.append(features)
301
+ roofline_tputs.append(roofline_tput)
302
+
303
+ meta.append({
304
+ "gpu_id": gpu_id,
305
+ "model_name": model_name,
306
+ "scenario": scenario,
307
+ "accuracy_tier": accuracy_tier,
308
+ "framework": framework,
309
+ "model_size_gb": round(model_size_gb, 2),
310
+ "vram_fits": model_size_gb <= gpu_spec["vram_gb"],
311
+ })
312
+
313
+ X = np.array(feature_matrix, dtype=np.float32)
314
+ pred_effs = self._model.predict(X)
315
+
316
+ results = []
317
+ for req_meta, pred_eff, roofline_tput in zip(meta, pred_effs, roofline_tputs):
318
+ pred_tput = min(float(pred_eff) * roofline_tput, roofline_tput)
319
+ results.append({
320
+ **req_meta,
321
+ "pred_throughput_tok_per_sec": round(pred_tput, 2),
322
+ "roofline_tput_tok_per_sec": round(roofline_tput, 2),
323
+ "efficiency_ratio": round(float(pred_eff), 4),
324
+ })
325
+ return results
326
+
327
+ # ------------------------------------------------------------------
328
+ # Internal
329
+ # ------------------------------------------------------------------
330
+
331
+ def _validate(
332
+ self,
333
+ gpu_id: str,
334
+ model_name: str,
335
+ scenario: str,
336
+ accuracy_tier: str,
337
+ framework: str,
338
+ ) -> None:
339
+ if gpu_id not in self._id_map:
340
+ raise ValueError(
341
+ f"Unknown gpu_id {gpu_id!r}. "
342
+ f"Valid: {sorted(self._id_map)}"
343
+ )
344
+ if model_name not in VALID_MODELS:
345
+ raise ValueError(
346
+ f"Unknown model_name {model_name!r}. "
347
+ f"Valid: {sorted(VALID_MODELS)}"
348
+ )
349
+ if scenario not in VALID_SCENARIOS:
350
+ raise ValueError(
351
+ f"Invalid scenario {scenario!r}. Valid: {sorted(VALID_SCENARIOS)}"
352
+ )
353
+ if accuracy_tier not in VALID_TIERS:
354
+ raise ValueError(
355
+ f"Invalid accuracy_tier {accuracy_tier!r}. Valid: {sorted(VALID_TIERS)}"
356
+ )
357
+ if framework not in VALID_FRAMEWORKS:
358
+ raise ValueError(
359
+ f"Invalid framework {framework!r}. Valid: {sorted(VALID_FRAMEWORKS)}"
360
+ )
src/models/train_final.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Train and save the production XGBoost model for GPU Perf Prophet.
3
+
4
+ Reads : data/processed/mlperf_features.parquet
5
+ Writes : data/models/prophet_v1.json
6
+ data/models/feature_metadata.json
7
+
8
+ Run once LOGO-CV evaluation is complete:
9
+ python -m src.models.train_final
10
+
11
+ The feature encoding mirrors notebooks/03_model_training.ipynb cell 3 exactly
12
+ so the saved model is compatible with GpuPredictor in src/models/predictor.py.
13
+
14
+ Training scope
15
+ --------------
16
+ This script trains on ALL rows with a valid target (~1112 rows), including
17
+ out-of-scope GPUs (B200, H200 NVL, etc.). The training notebook's SHAP
18
+ analysis used only the 649 in-scope rows, but LOGO-CV training folds always
19
+ contained out-of-scope rows, so the production model mirrors what the CV folds
20
+ actually saw. Out-of-scope GPUs are never served to users (gated by
21
+ gpu_in_model_scope in GpuRecommender); they are included here purely as
22
+ additional training signal about architectural trends.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import logging
29
+ from pathlib import Path
30
+
31
+ import numpy as np
32
+ import pandas as pd
33
+ import xgboost as xgb
34
+
35
+ from src.models.predictor import FEATURE_COLS
36
+
37
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
38
+ log = logging.getLogger(__name__)
39
+
40
+ TARGET = "efficiency_ratio"
41
+ THROUGHPUT_COL = "throughput_tok_per_sec_per_gpu"
42
+
43
+ # Same hyperparameters as the training notebook's final model (no early stopping).
44
+ # n_estimators set to 250 — midpoint of observed best_iteration range across
45
+ # folds (77–341); conservative to avoid overfitting without an eval set.
46
+ PROD_PARAMS: dict = {
47
+ "n_estimators": 250,
48
+ "max_depth": 5,
49
+ "learning_rate": 0.03,
50
+ "subsample": 0.8,
51
+ "colsample_bytree": 0.8,
52
+ "min_child_weight": 5,
53
+ "tree_method": "hist",
54
+ "random_state": 42,
55
+ }
56
+
57
+ FEATURES_PATH = Path("data/processed/mlperf_features.parquet")
58
+ MODEL_DIR = Path("data/models")
59
+
60
+
61
+ def _encode(feat: pd.DataFrame) -> pd.DataFrame:
62
+ """Apply the same categorical encoding as notebook cell 3."""
63
+ df = feat.copy()
64
+
65
+ df["scenario_offline"] = (df["scenario"] == "Offline").astype(int)
66
+
67
+ df["fw_tensorrt"] = (df["framework_family"] == "tensorrt").astype(int)
68
+ df["fw_vllm"] = (df["framework_family"] == "vllm").astype(int)
69
+ df["fw_rocm_other"] = (df["framework_family"] == "rocm_other").astype(int)
70
+
71
+ df["is_cdna4"] = (df["amd_arch_gen"] == 2).astype(int)
72
+
73
+ return df
74
+
75
+
76
+ def train_and_save(
77
+ features_path: Path = FEATURES_PATH,
78
+ model_dir: Path = MODEL_DIR,
79
+ ) -> xgb.XGBRegressor:
80
+ feat = pd.read_parquet(features_path)
81
+ log.info("Loaded %d rows from %s", len(feat), features_path)
82
+
83
+ df = _encode(feat)
84
+
85
+ # Drop rows where target is NaN or infinite.
86
+ valid = df[TARGET].notna() & np.isfinite(df[TARGET])
87
+ df = df[valid].reset_index(drop=True)
88
+ log.info("Training rows (valid target): %d", len(df))
89
+
90
+ # Variance gate — matches the training notebook's assertion.
91
+ # assert compiled away under python -O; use explicit raise.
92
+ dead = [c for c in FEATURE_COLS if df[c].nunique() <= 1]
93
+ if dead:
94
+ raise ValueError(f"Dead features (constant across all training rows): {dead}")
95
+
96
+ X = df[FEATURE_COLS]
97
+ y = df[TARGET]
98
+
99
+ model = xgb.XGBRegressor(**PROD_PARAMS)
100
+ model.fit(X, y)
101
+ log.info("Model trained: %d trees, %d features", PROD_PARAMS["n_estimators"], len(FEATURE_COLS))
102
+
103
+ model_dir.mkdir(parents=True, exist_ok=True)
104
+ model_path = model_dir / "prophet_v1.json"
105
+ meta_path = model_dir / "feature_metadata.json"
106
+
107
+ # Write both artifacts atomically via temp-file + os.replace().
108
+ # If this process is killed between the two writes, the reader
109
+ # (GpuPredictor.__init__) sees either the old pair or the new pair —
110
+ # never a mixed state that would silently mismatch feature_cols.
111
+ tmp_model = model_path.with_name(model_path.stem + ".tmp.json")
112
+ model.save_model(str(tmp_model))
113
+ tmp_model.replace(model_path)
114
+ log.info("Saved model → %s", model_path)
115
+
116
+ meta = {
117
+ "feature_cols": FEATURE_COLS,
118
+ "target": TARGET,
119
+ "model_version": "v1",
120
+ "prod_params": PROD_PARAMS,
121
+ "n_training_rows": len(df),
122
+ }
123
+ tmp_meta = meta_path.with_name(meta_path.name + ".tmp")
124
+ with tmp_meta.open("w") as f:
125
+ json.dump(meta, f, indent=2)
126
+ tmp_meta.replace(meta_path)
127
+ log.info("Saved metadata → %s", meta_path)
128
+
129
+ return model
130
+
131
+
132
+ if __name__ == "__main__":
133
+ train_and_save()
src/recommend/__init__.py ADDED
File without changes
src/recommend/recommender.py ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GPU Perf Prophet — Pareto recommendation engine.
3
+
4
+ Public API
5
+ ----------
6
+ GpuRecommender(predictor, pricing_path)
7
+ Wraps GpuPredictor and adds Pareto multi-objective ranking.
8
+
9
+ recommender.recommend(model_name, scenario, accuracy_tier, framework,
10
+ budget_per_gpu_hr, min_throughput_tok_per_sec)
11
+ Return a ranked list of GPU recommendations for the given workload.
12
+
13
+ Objectives (all higher-is-better after normalisation):
14
+ 1. throughput — predicted tokens/sec
15
+ 2. cost_efficiency — tokens per dollar (throughput / price_per_hr)
16
+ 3. vram_headroom — fraction of VRAM unused (1 - model_to_vram_ratio)
17
+
18
+ Constraints (hard filters applied before ranking):
19
+ • VRAM fit: model must fit on a single GPU (model_size_gb ≤ gpu_vram_gb)
20
+ • Budget: price_per_gpu_hr ≤ budget_per_gpu_hr (if provided)
21
+ • Min tput: pred_throughput ≥ min_throughput (if provided)
22
+
23
+ Pareto frontier:
24
+ A GPU is Pareto-dominated if another GPU is at least as good on all
25
+ three objectives and strictly better on at least one. The frontier
26
+ contains all non-dominated GPUs, sorted by cost_efficiency descending
27
+ (best tokens-per-dollar first). Dominated GPUs are returned in a
28
+ separate list so the UI can show them as alternatives.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import logging
34
+ import stat as _stat
35
+ from pathlib import Path
36
+ from typing import Optional
37
+
38
+ import yaml
39
+
40
+ from src.data.gpu_spec_db import load_specs
41
+ from src.models.predictor import GpuPredictor, MODEL_PARAMS, TIER_TO_PRECISION, BYTES_PER_PARAM
42
+
43
+ log = logging.getLogger(__name__)
44
+
45
+ _DEFAULT_PRICING_PATH = Path(__file__).parent.parent.parent / "data" / "pricing.yaml"
46
+
47
+ # Real pricing files are < 1 KB. 1 MB cap matches gpu_spec_db.py's policy.
48
+ _MAX_PRICING_BYTES: int = 1 * 1024 * 1024 # 1 MB
49
+
50
+
51
+ def _load_pricing(path: Path) -> dict[str, float]:
52
+ # Mirror the symlink and size guards from gpu_spec_db.load_specs so the
53
+ # pricing file cannot be swapped out via a filesystem symlink.
54
+ try:
55
+ st = path.lstat()
56
+ except OSError as exc:
57
+ raise FileNotFoundError(f"Pricing DB not found: {path}") from exc
58
+ if _stat.S_ISLNK(st.st_mode):
59
+ raise ValueError(f"Pricing DB path is a symlink (refused): {path}")
60
+ if st.st_size > _MAX_PRICING_BYTES:
61
+ raise ValueError(
62
+ f"Pricing DB too large ({st.st_size} bytes > {_MAX_PRICING_BYTES}): {path}"
63
+ )
64
+ with path.open() as f:
65
+ data = yaml.safe_load(f)
66
+ if not isinstance(data, dict) or "pricing" not in data:
67
+ raise ValueError(f"pricing.yaml at {path} is missing required 'pricing' key")
68
+ result: dict[str, float] = {}
69
+ for gpu_id, entry in data["pricing"].items():
70
+ if not isinstance(entry, dict) or "price_per_gpu_hr" not in entry:
71
+ raise ValueError(
72
+ f"pricing.yaml entry {gpu_id!r} is missing 'price_per_gpu_hr' key"
73
+ )
74
+ result[gpu_id] = entry["price_per_gpu_hr"]
75
+ return result
76
+
77
+
78
+ def _pareto_frontier(candidates: list[dict]) -> tuple[list[dict], list[dict]]:
79
+ """Split candidates into (frontier, dominated).
80
+
81
+ Each candidate dict must have numeric keys:
82
+ throughput, cost_efficiency, vram_headroom (all higher-is-better)
83
+
84
+ None is treated as -inf for dominance comparisons and sorts last.
85
+ """
86
+ objectives = ["throughput", "cost_efficiency", "vram_headroom"]
87
+ frontier: list[dict] = []
88
+ dominated: list[dict] = []
89
+
90
+ # None objective value → treat as -inf so unpriced GPUs are always
91
+ # dominated by any priced GPU and don't crash comparisons.
92
+ def _obj(v) -> float:
93
+ return v if v is not None else float("-inf")
94
+
95
+ def _dominates(a: dict, b: dict) -> bool:
96
+ """Return True if a dominates b: a >= b on all objectives, a > b on ≥1.
97
+
98
+ Single pass over objectives: returns False immediately on the first
99
+ objective where a < b, avoiding the O(2k) double-evaluation of the
100
+ separate all()/any() generators.
101
+ """
102
+ has_strict = False
103
+ for obj in objectives:
104
+ ao, bo = _obj(a[obj]), _obj(b[obj])
105
+ if ao < bo:
106
+ return False
107
+ if ao > bo:
108
+ has_strict = True
109
+ return has_strict
110
+
111
+ for i, cand in enumerate(candidates):
112
+ is_dominated = any(
113
+ _dominates(other, cand)
114
+ for j, other in enumerate(candidates)
115
+ if j != i
116
+ )
117
+ if is_dominated:
118
+ dominated.append(cand)
119
+ else:
120
+ frontier.append(cand)
121
+
122
+ # Sort frontier by cost_efficiency descending (best tokens-per-dollar first).
123
+ # None sorts last so unpriced GPUs appear after all priced GPUs.
124
+ def _ce(x: dict) -> float:
125
+ v = x["cost_efficiency"]
126
+ return v if v is not None else float("-inf")
127
+
128
+ frontier.sort(key=_ce, reverse=True)
129
+ dominated.sort(key=_ce, reverse=True)
130
+ return frontier, dominated
131
+
132
+
133
+ class GpuRecommender:
134
+ """Multi-objective GPU recommender wrapping GpuPredictor."""
135
+
136
+ def __init__(
137
+ self,
138
+ predictor: GpuPredictor,
139
+ pricing_path: Path | str = _DEFAULT_PRICING_PATH,
140
+ ) -> None:
141
+ self._predictor = predictor
142
+ self._pricing = _load_pricing(Path(pricing_path))
143
+
144
+ specs = load_specs()
145
+ self._in_scope_ids: list[str] = [
146
+ s["id"] for s in specs if s.get("in_model_scope")
147
+ ]
148
+ # Re-use the predictor's already-deep-copied spec map — this class never
149
+ # writes to spec dicts, so sharing is safe and avoids a second full
150
+ # deepcopy of every spec at init time.
151
+ self._spec_map: dict[str, dict] = predictor._id_map
152
+
153
+ # Fail fast: a missing pricing entry produces cost_efficiency=None,
154
+ # which causes a TypeError in _pareto_frontier's sort and comparisons.
155
+ missing = [gid for gid in self._in_scope_ids if gid not in self._pricing]
156
+ if missing:
157
+ raise ValueError(
158
+ f"pricing.yaml is missing entries for in-scope GPUs: {missing}. "
159
+ "Add a price_per_gpu_hr entry before enabling these GPUs."
160
+ )
161
+
162
+ log.info(
163
+ "GpuRecommender ready: %d in-scope GPUs, %d pricing entries",
164
+ len(self._in_scope_ids), len(self._pricing),
165
+ )
166
+
167
+ # ------------------------------------------------------------------
168
+ # Public API
169
+ # ------------------------------------------------------------------
170
+
171
+ def recommend(
172
+ self,
173
+ *,
174
+ model_name: str,
175
+ scenario: str = "Offline",
176
+ accuracy_tier: str = "99",
177
+ framework: str = "vllm",
178
+ budget_per_gpu_hr: Optional[float] = None,
179
+ min_throughput_tok_per_sec: Optional[float] = None,
180
+ ) -> dict:
181
+ """Return a recommendation result dict.
182
+
183
+ Keys
184
+ ----
185
+ frontier : list[dict] — Pareto-optimal GPUs, best tokens/$ first
186
+ dominated: list[dict] — remaining GPUs that passed hard constraints
187
+ filtered : list[dict] — GPUs removed by hard constraints (vram / budget)
188
+ workload : dict — echoed inputs + model_size_gb
189
+ """
190
+ if model_name not in MODEL_PARAMS:
191
+ raise ValueError(
192
+ f"Unknown model_name {model_name!r}. Valid: {sorted(MODEL_PARAMS)}"
193
+ )
194
+ total_params_b, _ = MODEL_PARAMS[model_name]
195
+ bpp = BYTES_PER_PARAM[TIER_TO_PRECISION[accuracy_tier]]
196
+ model_size_gb = total_params_b * bpp # workload summary (canonical, FP16 for tier 99.9)
197
+
198
+ # Per-GPU effective model size — AMD uses FP8 at 99.9 tier, halving the
199
+ # footprint vs the FP16 default. The VRAM pre-filter and reject messages
200
+ # must use this per-GPU value; candidates use pred["model_size_gb"] which
201
+ # comes from predict_batch() and already applies the same override.
202
+ def _gpu_model_size_gb(gpu_id: str) -> float:
203
+ spec = self._spec_map[gpu_id]
204
+ eff_bpp = bpp
205
+ if spec.get("vendor") == "amd" and accuracy_tier == "99.9":
206
+ eff_bpp = BYTES_PER_PARAM["fp8"]
207
+ return total_params_b * eff_bpp
208
+
209
+ # Pre-filter by VRAM before calling predict_batch: skip XGBoost inference
210
+ # for GPUs where the model provably cannot fit. Matters most for large
211
+ # models (llama3.1-405b at fp8 = 405 GB; no in-scope GPU reaches that).
212
+ # Compute effective model size once per GPU (avoid 4× calls per failing GPU
213
+ # across the dual list comprehensions and the reject entry builder below).
214
+ gpu_sizes: dict[str, float] = {
215
+ gid: _gpu_model_size_gb(gid) for gid in self._in_scope_ids
216
+ }
217
+ vram_ok_ids: list[str] = []
218
+ vram_fail_ids: list[str] = []
219
+ for gid in self._in_scope_ids:
220
+ fits = self._spec_map[gid]["vram_gb"] >= gpu_sizes[gid]
221
+ (vram_ok_ids if fits else vram_fail_ids).append(gid)
222
+
223
+ requests = [
224
+ {
225
+ "gpu_id": gpu_id,
226
+ "model_name": model_name,
227
+ "scenario": scenario,
228
+ "accuracy_tier": accuracy_tier,
229
+ "framework": framework,
230
+ }
231
+ for gpu_id in vram_ok_ids
232
+ ]
233
+ predictions = self._predictor.predict_batch(requests)
234
+
235
+ candidates: list[dict] = []
236
+ filtered: list[dict] = []
237
+
238
+ # Build reject entries for VRAM-failing GPUs without running inference.
239
+ for gpu_id in vram_fail_ids:
240
+ spec = self._spec_map[gpu_id]
241
+ price = self._pricing.get(gpu_id)
242
+ _sz = gpu_sizes[gpu_id]
243
+ filtered.append({
244
+ "gpu_id": gpu_id,
245
+ "gpu_name": spec.get("name", gpu_id),
246
+ "vendor": spec.get("vendor", ""),
247
+ "model_name": model_name,
248
+ "scenario": scenario,
249
+ "accuracy_tier": accuracy_tier,
250
+ "framework": framework,
251
+ "pred_throughput_tok_per_sec": 0.0,
252
+ "roofline_tput_tok_per_sec": 0.0,
253
+ "efficiency_ratio": 0.0,
254
+ "vram_fits": False,
255
+ "model_size_gb": round(_sz, 2),
256
+ "vram_gb": spec.get("vram_gb"),
257
+ "price_per_gpu_hr": price,
258
+ "vram_headroom": 0.0,
259
+ "cost_efficiency": None,
260
+ "throughput": 0.0,
261
+ "reject_reason": (
262
+ f"model too large ({_sz:.1f} GB"
263
+ f" > {spec['vram_gb']} GB VRAM)"
264
+ ),
265
+ })
266
+
267
+ for pred in predictions:
268
+ gpu_id = pred["gpu_id"]
269
+ spec = self._spec_map[gpu_id]
270
+ price = self._pricing.get(gpu_id)
271
+ pred_tput = pred["pred_throughput_tok_per_sec"]
272
+
273
+ reject_reason = None
274
+ if budget_per_gpu_hr is not None and price is not None and price > budget_per_gpu_hr:
275
+ reject_reason = f"price ${price:.2f}/hr > budget ${budget_per_gpu_hr:.2f}/hr"
276
+ elif min_throughput_tok_per_sec is not None and pred_tput < min_throughput_tok_per_sec:
277
+ reject_reason = (
278
+ f"predicted {pred_tput:.0f} tok/s"
279
+ f" < minimum {min_throughput_tok_per_sec:.0f} tok/s"
280
+ )
281
+
282
+ entry = {
283
+ **pred,
284
+ "gpu_name": spec.get("name", gpu_id),
285
+ "vendor": spec.get("vendor", ""),
286
+ "vram_gb": spec.get("vram_gb"),
287
+ "price_per_gpu_hr": price,
288
+ "vram_headroom": max(0.0, 1.0 - pred["model_size_gb"] / spec["vram_gb"]),
289
+ "cost_efficiency": (pred_tput / price) if price else None,
290
+ "throughput": pred_tput,
291
+ }
292
+
293
+ if reject_reason:
294
+ entry["reject_reason"] = reject_reason
295
+ filtered.append(entry)
296
+ else:
297
+ candidates.append(entry)
298
+
299
+ frontier, dominated = _pareto_frontier(candidates)
300
+
301
+ return {
302
+ "frontier": frontier,
303
+ "dominated": dominated,
304
+ "filtered": filtered,
305
+ "workload": {
306
+ "model_name": model_name,
307
+ "scenario": scenario,
308
+ "accuracy_tier": accuracy_tier,
309
+ "framework": framework,
310
+ "model_size_gb": round(model_size_gb, 2),
311
+ "budget_per_gpu_hr": budget_per_gpu_hr,
312
+ "min_throughput_tok_per_sec": min_throughput_tok_per_sec,
313
+ },
314
+ }
tests/__init__.py ADDED
File without changes
tests/test_api.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Integration tests for src/api/main.py via FastAPI TestClient.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import pytest
8
+ from fastapi.testclient import TestClient
9
+
10
+ from src.api.main import app
11
+
12
+
13
+ @pytest.fixture(scope="module")
14
+ def client() -> TestClient:
15
+ with TestClient(app) as c:
16
+ yield c
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # /health
21
+ # ---------------------------------------------------------------------------
22
+
23
+ class TestHealth:
24
+ def test_returns_ok(self, client):
25
+ r = client.get("/health")
26
+ assert r.status_code == 200
27
+ assert r.json()["status"] == "ok"
28
+
29
+ def test_model_loaded(self, client):
30
+ r = client.get("/health")
31
+ assert r.json()["model_loaded"] is True
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # /gpus
36
+ # ---------------------------------------------------------------------------
37
+
38
+ class TestListGpus:
39
+ def test_returns_gpu_list(self, client):
40
+ r = client.get("/gpus")
41
+ assert r.status_code == 200
42
+ gpus = r.json()["gpus"]
43
+ # The spec DB has 8 required in-scope GPUs; len > 0 would pass even if
44
+ # all but one were dropped.
45
+ assert len(gpus) >= 8
46
+
47
+ def test_mi300x_present(self, client):
48
+ r = client.get("/gpus")
49
+ ids = [g["id"] for g in r.json()["gpus"]]
50
+ assert "mi300x" in ids
51
+
52
+ def test_gpu_fields_present(self, client):
53
+ r = client.get("/gpus")
54
+ gpu = r.json()["gpus"][0]
55
+ # Verify keys exist AND values are populated with correct types — a dict
56
+ # full of None values would pass a bare "in gpu" key-presence check.
57
+ assert isinstance(gpu["id"], str) and gpu["id"]
58
+ assert gpu["vendor"] in ("amd", "nvidia")
59
+ assert isinstance(gpu["vram_gb"], (int, float)) and gpu["vram_gb"] > 0
60
+ assert isinstance(gpu["in_model_scope"], bool)
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # /models
65
+ # ---------------------------------------------------------------------------
66
+
67
+ class TestListModels:
68
+ def test_returns_model_list(self, client):
69
+ from src.models.predictor import VALID_MODELS
70
+ r = client.get("/models")
71
+ assert r.status_code == 200
72
+ # Compare the complete set — spot-checking 2 models misses regressions
73
+ # that remove other models (e.g. mixtral-8x7b, llama3.1-405b).
74
+ assert set(r.json()["models"]) == VALID_MODELS
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # POST /predict
79
+ # ---------------------------------------------------------------------------
80
+
81
+ class TestPredict:
82
+ def test_response_has_throughput(self, client):
83
+ r = client.post("/predict", json={
84
+ "gpu_id": "h100_sxm",
85
+ "model_name": "llama2-70b",
86
+ "scenario": "Offline",
87
+ "accuracy_tier": "99",
88
+ "framework": "tensorrt",
89
+ })
90
+ assert r.status_code == 200
91
+ data = r.json()
92
+ assert data["pred_throughput_tok_per_sec"] >= 0.05 * data["roofline_tput_tok_per_sec"]
93
+
94
+ def test_throughput_not_exceeds_roofline(self, client):
95
+ r = client.post("/predict", json={
96
+ "gpu_id": "mi300x",
97
+ "model_name": "llama2-70b",
98
+ })
99
+ assert r.status_code == 200
100
+ data = r.json()
101
+ assert data["pred_throughput_tok_per_sec"] <= data["roofline_tput_tok_per_sec"] + 1e-2
102
+
103
+ def test_unknown_gpu_returns_422(self, client):
104
+ r = client.post("/predict", json={
105
+ "gpu_id": "titan_v",
106
+ "model_name": "llama2-70b",
107
+ })
108
+ assert r.status_code == 422
109
+
110
+ def test_unknown_model_returns_422(self, client):
111
+ r = client.post("/predict", json={
112
+ "gpu_id": "mi300x",
113
+ "model_name": "gpt5",
114
+ })
115
+ assert r.status_code == 422
116
+
117
+ def test_invalid_scenario_returns_422(self, client):
118
+ r = client.post("/predict", json={
119
+ "gpu_id": "mi300x",
120
+ "model_name": "gptj",
121
+ "scenario": "Interactive",
122
+ })
123
+ assert r.status_code == 422
124
+
125
+ def test_defaults_filled(self, client):
126
+ r = client.post("/predict", json={
127
+ "gpu_id": "h200_sxm",
128
+ "model_name": "gptj",
129
+ })
130
+ assert r.status_code == 200
131
+ data = r.json()
132
+ assert data["scenario"] == "Offline"
133
+ assert data["accuracy_tier"] == "99"
134
+ assert data["framework"] == "vllm"
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # POST /predict/batch
139
+ # ---------------------------------------------------------------------------
140
+
141
+ class TestPredictBatch:
142
+ def test_batch_returns_correct_count(self, client):
143
+ r = client.post("/predict/batch", json=[
144
+ {"gpu_id": "mi300x", "model_name": "llama2-70b"},
145
+ {"gpu_id": "h100_sxm", "model_name": "gptj"},
146
+ ])
147
+ assert r.status_code == 200
148
+ results = r.json()
149
+ assert len(results) == 2
150
+ # Verify identity, not just count — [mi300x, mi300x] would also have len 2.
151
+ assert results[0]["gpu_id"] == "mi300x" and results[0]["model_name"] == "llama2-70b"
152
+ assert results[1]["gpu_id"] == "h100_sxm" and results[1]["model_name"] == "gptj"
153
+
154
+ def test_empty_batch_returns_empty(self, client):
155
+ r = client.post("/predict/batch", json=[])
156
+ assert r.status_code == 200
157
+ assert r.json() == []
158
+
159
+ def test_batch_over_50_returns_422(self, client):
160
+ batch = [{"gpu_id": "mi300x", "model_name": "gptj"}] * 51
161
+ r = client.post("/predict/batch", json=batch)
162
+ assert r.status_code == 422
163
+
164
+ def test_body_too_large_returns_413(self, client):
165
+ # Send 1 MB + 1 byte — the limit_body_size middleware must intercept
166
+ # before Pydantic validation (which would return 422, not 413).
167
+ # The 51-item batch test above only exercises the Pydantic max_length path.
168
+ from src.api.main import _MAX_BODY_BYTES
169
+ r = client.post(
170
+ "/predict/batch",
171
+ content=b"x" * (_MAX_BODY_BYTES + 1),
172
+ headers={"Content-Type": "application/json"},
173
+ )
174
+ assert r.status_code == 413
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # POST /recommend
179
+ # ---------------------------------------------------------------------------
180
+
181
+ class TestRecommend:
182
+ def test_workload_echoed(self, client):
183
+ r = client.post("/recommend", json={
184
+ "model_name": "mixtral-8x7b",
185
+ "scenario": "Server",
186
+ "accuracy_tier": "base",
187
+ "framework": "rocm_other",
188
+ })
189
+ assert r.status_code == 200
190
+ wl = r.json()["workload"]
191
+ # All four echoed fields must round-trip — previously only model_name and
192
+ # scenario were asserted; accuracy_tier and framework were silently unchecked.
193
+ assert wl["model_name"] == "mixtral-8x7b"
194
+ assert wl["scenario"] == "Server"
195
+ assert wl["accuracy_tier"] == "base"
196
+ assert wl["framework"] == "rocm_other"
197
+
198
+ def test_budget_filter_in_recommend(self, client):
199
+ r = client.post("/recommend", json={
200
+ "model_name": "gptj",
201
+ "budget_per_gpu_hr": 1.0,
202
+ })
203
+ assert r.status_code == 200
204
+ candidates = r.json()["frontier"] + r.json()["dominated"]
205
+ # L4 ($0.44) and RTX4090 ($0.39) are both under $1.00/hr and fit gptj
206
+ # (6 GB). Without this guard the loop below fires zero assertions if
207
+ # all GPUs exceed the budget (e.g. after a pricing update).
208
+ assert len(candidates) > 0, (
209
+ "Expected at least one GPU within $1.00/hr (L4 $0.44, RTX4090 $0.39)"
210
+ )
211
+ # All in-scope GPUs have pricing (validated at startup), so
212
+ # price_per_gpu_hr is never None here — the is-None arm was dead code.
213
+ for gpu in candidates:
214
+ assert gpu["price_per_gpu_hr"] <= 1.0
215
+
216
+ def test_unknown_model_returns_422(self, client):
217
+ r = client.post("/recommend", json={"model_name": "invalid_model"})
218
+ assert r.status_code == 422
tests/test_build_features.py ADDED
@@ -0,0 +1,537 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for src/features/build_features.py.
3
+
4
+ Strategy: unit-test the pure functions with hand-computed expected values,
5
+ then run a smoke-test of the full pipeline on a synthetic fixture DataFrame
6
+ that mimics the enriched MLPerf schema (avoiding a real parquet dependency).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from pathlib import Path
13
+ from unittest.mock import patch
14
+
15
+ import pandas as pd
16
+ import pytest
17
+ import yaml
18
+
19
+ from src.features.build_features import (
20
+ BYTES_PER_PARAM,
21
+ MODEL_PARAMS,
22
+ TIER_TO_PRECISION,
23
+ _normalize_framework,
24
+ build_training_df,
25
+ roofline_ceilings,
26
+ )
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Fixtures
31
+ # ---------------------------------------------------------------------------
32
+
33
+ @pytest.fixture()
34
+ def spec_path(tmp_path: Path) -> Path:
35
+ """Minimal gpu_specs.yaml with two GPUs for pipeline tests."""
36
+ spec = {
37
+ "schema_version": "1.0",
38
+ "gpus": [
39
+ {
40
+ "id": "h200_sxm",
41
+ "name": "NVIDIA H200 SXM",
42
+ "vendor": "nvidia",
43
+ "architecture": "hopper",
44
+ "memory_type": "hbm3e",
45
+ "vram_gb": 141,
46
+ "hbm_bandwidth_tbps": 4.8,
47
+ "peak_tflops": {
48
+ "fp32": 66.9,
49
+ "bf16": 989.4,
50
+ "fp16": 989.4,
51
+ "fp8": 1978.9,
52
+ "fp6": None,
53
+ "fp4": None,
54
+ "int8": 3957.8,
55
+ },
56
+ "streaming_multiprocessors": 132,
57
+ "l2_cache_mb": 50,
58
+ "tdp_w": 700,
59
+ "spec_confidence": "verified",
60
+ "in_model_scope": True,
61
+ "aliases": ["NVIDIA H200-SXM-141GB"],
62
+ },
63
+ {
64
+ "id": "mi300x",
65
+ "name": "AMD Instinct MI300X",
66
+ "vendor": "amd",
67
+ "architecture": "cdna3",
68
+ "memory_type": "hbm3",
69
+ "vram_gb": 192,
70
+ "hbm_bandwidth_tbps": 5.3,
71
+ "peak_tflops": {
72
+ "fp32": 163.4,
73
+ "bf16": 1307.4,
74
+ "fp16": 1307.4,
75
+ "fp8": 2614.9,
76
+ "fp6": None,
77
+ "fp4": None,
78
+ "int8": 2614.9,
79
+ },
80
+ "compute_units": 304,
81
+ "l2_cache_mb": 32,
82
+ "tdp_w": 750,
83
+ "spec_confidence": "verified",
84
+ "in_model_scope": True,
85
+ "aliases": ["AMD Instinct MI300X 192GB HBM3"],
86
+ },
87
+ ],
88
+ }
89
+ p = tmp_path / "gpu_specs.yaml"
90
+ p.write_text(yaml.dump(spec))
91
+ return p
92
+
93
+
94
+ def _make_raw_df(spec_path: Path) -> pd.DataFrame:
95
+ """Synthetic raw MLPerf DataFrame with the minimal schema expected by build_training_df."""
96
+ rows = [
97
+ # H200 SXM, llama2-70b, 99-tier (FP8 selected), Offline, 1 node × 8 GPUs
98
+ {
99
+ "round": "v5.0",
100
+ "division": "closed",
101
+ "submitter": "TestOrg",
102
+ "system_name": "8xH200_test",
103
+ "gpu_name": "NVIDIA H200-SXM-141GB",
104
+ "num_gpus": 8,
105
+ "vram_gb": 1128.0,
106
+ "framework": "TensorRT 10.2.0, CUDA 12.4",
107
+ "system_type": "datacenter",
108
+ "hw_status": "available",
109
+ "benchmark": "llama2-70b-99",
110
+ "benchmark_base": "llama2-70b",
111
+ "benchmark_accuracy_tier": "99",
112
+ "scenario": "Offline",
113
+ "precision": None,
114
+ "tokens_per_sample": 294,
115
+ "throughput_tokens_per_sec": 50000.0,
116
+ "throughput_tok_per_sec_per_gpu": 6250.0,
117
+ "result_valid": True,
118
+ "throughput_samples_per_sec": 170.0,
119
+ "latency_mean_ms": None,
120
+ "latency_p99_ms": None,
121
+ "ttft_mean_ms": None,
122
+ "ttft_p99_ms": None,
123
+ "tpot_mean_ms": None,
124
+ "tpot_p99_ms": None,
125
+ "log_path": "fake/path",
126
+ },
127
+ # MI300X, llama2-70b, 99.9-tier (FP16 selected), Server, 1 node × 8 GPUs
128
+ {
129
+ "round": "v5.0",
130
+ "division": "closed",
131
+ "submitter": "TestOrg",
132
+ "system_name": "8xMI300X_test",
133
+ "gpu_name": "AMD Instinct MI300X 192GB HBM3",
134
+ "num_gpus": 8,
135
+ "vram_gb": 1536.0,
136
+ "framework": "vLLM 0.4.3+rocm614, PyTorch 2.3.0, ROCm 6.1.2",
137
+ "system_type": "datacenter",
138
+ "hw_status": "available",
139
+ "benchmark": "llama2-70b-99.9",
140
+ "benchmark_base": "llama2-70b",
141
+ "benchmark_accuracy_tier": "99.9",
142
+ "scenario": "Server",
143
+ "precision": None,
144
+ "tokens_per_sample": 294,
145
+ "throughput_tokens_per_sec": 24000.0,
146
+ "throughput_tok_per_sec_per_gpu": 3000.0,
147
+ "result_valid": True,
148
+ "throughput_samples_per_sec": 81.6,
149
+ "latency_mean_ms": None,
150
+ "latency_p99_ms": None,
151
+ "ttft_mean_ms": None,
152
+ "ttft_p99_ms": None,
153
+ "tpot_mean_ms": None,
154
+ "tpot_p99_ms": None,
155
+ "log_path": "fake/path2",
156
+ },
157
+ # H200 SXM, llama2-70b, result_valid=False — must be dropped
158
+ {
159
+ "round": "v5.0",
160
+ "division": "closed",
161
+ "submitter": "TestOrg",
162
+ "system_name": "8xH200_invalid",
163
+ "gpu_name": "NVIDIA H200-SXM-141GB",
164
+ "num_gpus": 8,
165
+ "vram_gb": 1128.0,
166
+ "framework": "TensorRT 10.2.0, CUDA 12.4",
167
+ "system_type": "datacenter",
168
+ "hw_status": "available",
169
+ "benchmark": "llama2-70b-99",
170
+ "benchmark_base": "llama2-70b",
171
+ "benchmark_accuracy_tier": "99",
172
+ "scenario": "Offline",
173
+ "precision": None,
174
+ "tokens_per_sample": 294,
175
+ "throughput_tokens_per_sec": 99999.0,
176
+ "throughput_tok_per_sec_per_gpu": 12499.8,
177
+ "result_valid": False,
178
+ "throughput_samples_per_sec": 340.0,
179
+ "latency_mean_ms": None,
180
+ "latency_p99_ms": None,
181
+ "ttft_mean_ms": None,
182
+ "ttft_p99_ms": None,
183
+ "tpot_mean_ms": None,
184
+ "tpot_p99_ms": None,
185
+ "log_path": "fake/path3",
186
+ },
187
+ # Unknown GPU — should be dropped (no spec DB entry → gpu_hbm_bandwidth_tbps is NaN
188
+ # → caught by the missing-specs guard in build_training_df, not by gpu_in_model_scope)
189
+ {
190
+ "round": "v5.0",
191
+ "division": "closed",
192
+ "submitter": "TestOrg",
193
+ "system_name": "8xUnknownGPU",
194
+ "gpu_name": "SomeFutureGPU 9000",
195
+ "num_gpus": 8,
196
+ "vram_gb": 512.0,
197
+ "framework": "TensorRT 99.0, CUDA 99.0",
198
+ "system_type": "datacenter",
199
+ "hw_status": "available",
200
+ "benchmark": "llama2-70b-99",
201
+ "benchmark_base": "llama2-70b",
202
+ "benchmark_accuracy_tier": "99",
203
+ "scenario": "Offline",
204
+ "precision": None,
205
+ "tokens_per_sample": 294,
206
+ "throughput_tokens_per_sec": 100.0,
207
+ "throughput_tok_per_sec_per_gpu": 12.5,
208
+ "result_valid": True,
209
+ "throughput_samples_per_sec": 0.34,
210
+ "latency_mean_ms": None,
211
+ "latency_p99_ms": None,
212
+ "ttft_mean_ms": None,
213
+ "ttft_p99_ms": None,
214
+ "tpot_mean_ms": None,
215
+ "tpot_p99_ms": None,
216
+ "log_path": "fake/path4",
217
+ },
218
+ ]
219
+ return pd.DataFrame(rows)
220
+
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # roofline_ceilings
224
+ # ---------------------------------------------------------------------------
225
+
226
+ class TestRooflineCeilings:
227
+ """Hand-verify the bandwidth and compute ceilings for known GPU–model pairs."""
228
+
229
+ def test_h200_llama2_70b_fp16(self):
230
+ # H200 SXM: HBM BW = 4.8 TB/s, FP16 peak = 989.4 TFLOPS
231
+ # Llama2-70B: 70B params (dense, total == compute)
232
+ # BW ceiling = 4.8e12 / (70e9 × 2) = 34.29 tok/s
233
+ # Compute ceil = 989.4e12 / (2 × 70e9) = 7067.14 tok/s
234
+ # roofline_tput = compute_ceil (not BW limit)
235
+ bw, compute, roofline = roofline_ceilings(
236
+ total_params_b=70.0,
237
+ compute_params_b=70.0,
238
+ bytes_per_param=2.0,
239
+ hbm_bw_tbps=4.8,
240
+ peak_tflops=989.4,
241
+ )
242
+ assert math.isclose(bw, 4.8e12 / (70e9 * 2), rel_tol=1e-6)
243
+ assert math.isclose(compute, 989.4e12 / (2 * 70e9), rel_tol=1e-6)
244
+ assert roofline == compute
245
+
246
+ def test_mi300x_llama2_70b_fp8(self):
247
+ # MI300X: HBM BW = 5.3 TB/s, FP8 peak = 2614.9 TFLOPS
248
+ # BW ceiling = 5.3e12 / (70e9 × 1) = 75.71 tok/s
249
+ # Compute ceil = 2614.9e12 / (2 × 70e9) = 18677.86 tok/s
250
+ bw, compute, roofline = roofline_ceilings(
251
+ total_params_b=70.0,
252
+ compute_params_b=70.0,
253
+ bytes_per_param=1.0,
254
+ hbm_bw_tbps=5.3,
255
+ peak_tflops=2614.9,
256
+ )
257
+ assert math.isclose(bw, 5.3e12 / (70e9 * 1), rel_tol=1e-6)
258
+ assert math.isclose(compute, 2614.9e12 / (2 * 70e9), rel_tol=1e-6)
259
+ assert roofline == compute
260
+
261
+ def test_moe_uses_active_params_for_compute(self):
262
+ # Mixtral 8×7B: total=46.7B, active=14.1B
263
+ # BW ceiling uses total (all weights in HBM): 5.3e12 / (46.7e9 × 2)
264
+ # Compute ceiling uses active: 1307.4e12 / (2 × 14.1e9)
265
+ bw, compute, roofline = roofline_ceilings(
266
+ total_params_b=46.7,
267
+ compute_params_b=14.1,
268
+ bytes_per_param=2.0,
269
+ hbm_bw_tbps=5.3,
270
+ peak_tflops=1307.4,
271
+ )
272
+ assert math.isclose(bw, 5.3e12 / (46.7e9 * 2), rel_tol=1e-6)
273
+ assert math.isclose(compute, 1307.4e12 / (2 * 14.1e9), rel_tol=1e-6)
274
+ assert roofline == compute
275
+
276
+ def test_roofline_equals_compute_ceiling(self):
277
+ # roofline_tput is defined as the compute ceiling (a project-level choice:
278
+ # the model learns the hardware efficiency ratio relative to compute, not BW).
279
+ # All three cases are compute-bound (compute >> bw) by design — the invariant
280
+ # is that roofline == compute regardless of what bw_tbps is.
281
+ for bw_tbps, tflops in [(0.3, 121.6), (4.8, 989.4), (8.0, 2620.0)]:
282
+ bw, compute, roofline = roofline_ceilings(
283
+ total_params_b=70.0,
284
+ compute_params_b=70.0,
285
+ bytes_per_param=2.0,
286
+ hbm_bw_tbps=bw_tbps,
287
+ peak_tflops=tflops,
288
+ )
289
+ assert roofline == compute, (
290
+ f"roofline should be compute ceiling, got roofline={roofline} compute={compute}"
291
+ )
292
+
293
+ def test_bandwidth_ceiling_scales_with_bytes_per_param(self):
294
+ # Halving bytes_per_param (FP16→FP8) doubles the BW ceiling.
295
+ bw_fp16, _, _ = roofline_ceilings(70.0, 70.0, 2.0, 4.8, 989.4)
296
+ bw_fp8, _, _ = roofline_ceilings(70.0, 70.0, 1.0, 4.8, 989.4)
297
+ assert math.isclose(bw_fp8, 2 * bw_fp16, rel_tol=1e-9)
298
+
299
+
300
+ # ---------------------------------------------------------------------------
301
+ # Reference tables
302
+ # ---------------------------------------------------------------------------
303
+
304
+ class TestReferenceTables:
305
+ def test_all_model_params_have_positive_values(self):
306
+ for name, (total, compute) in MODEL_PARAMS.items():
307
+ assert total > 0, f"{name}: total_params_b must be > 0"
308
+ assert compute > 0, f"{name}: compute_params_b must be > 0"
309
+ assert compute <= total, f"{name}: compute_params_b must be ≤ total_params_b"
310
+
311
+ def test_mixtral_moe_compute_lt_total(self):
312
+ total, compute = MODEL_PARAMS["mixtral-8x7b"]
313
+ assert compute < total
314
+
315
+ def test_dense_models_compute_equals_total(self):
316
+ for name in ("llama2-70b", "llama3.1-405b", "gptj"):
317
+ total, compute = MODEL_PARAMS[name]
318
+ assert total == compute, f"{name}: dense model should have compute == total"
319
+
320
+ def test_bytes_per_param_ordering(self):
321
+ # FP4 < FP8 < FP16 == BF16 < FP32
322
+ assert BYTES_PER_PARAM["fp4"] < BYTES_PER_PARAM["fp8"]
323
+ assert BYTES_PER_PARAM["fp8"] < BYTES_PER_PARAM["fp16"]
324
+ assert BYTES_PER_PARAM["fp16"] == BYTES_PER_PARAM["bf16"]
325
+ assert BYTES_PER_PARAM["bf16"] < BYTES_PER_PARAM["fp32"]
326
+
327
+ def test_tier_to_precision_coverage(self):
328
+ for tier in ("99.9", "99", "base"):
329
+ assert tier in TIER_TO_PRECISION
330
+ assert TIER_TO_PRECISION[tier] in BYTES_PER_PARAM
331
+
332
+
333
+ # ---------------------------------------------------------------------------
334
+ # _normalize_framework
335
+ # ---------------------------------------------------------------------------
336
+
337
+ class TestNormalizeFramework:
338
+ @pytest.mark.parametrize("raw,expected", [
339
+ ("TensorRT 10.2.0, CUDA 12.4", "tensorrt"),
340
+ ("TensorRT 9.3.0, CUDA 12.2", "tensorrt"),
341
+ ("vLLM 0.4.3+rocm614, PyTorch 2.3.0", "vllm"),
342
+ ("vLLM 0.9.0, Pytorch 2.7, ROCm 6.4", "vllm"),
343
+ ("PyTorch 2.9.1+git, ROCm 7.0.0", "rocm_other"),
344
+ ("ROCm 6.3.1", "rocm_other"),
345
+ ("Mango LLMBoost, ROCm 6.12", "rocm_other"),
346
+ ("SomeUnknownFramework 1.0", "other"),
347
+ ("", "other"),
348
+ (None, "unknown"),
349
+ (123, "unknown"),
350
+ ])
351
+ def test_classification(self, raw, expected):
352
+ assert _normalize_framework(raw) == expected
353
+
354
+
355
+ # ---------------------------------------------------------------------------
356
+ # build_training_df — integration smoke test
357
+ # ---------------------------------------------------------------------------
358
+
359
+ class TestBuildTrainingDf:
360
+ def test_filters_invalid_and_missing_spec_rows(self, spec_path, tmp_path):
361
+ raw = _make_raw_df(spec_path)
362
+ feat = build_training_df(raw, spec_path=spec_path)
363
+ # invalid row (result_valid=False) and unknown-GPU row (no spec → NaN HBM BW) are dropped
364
+ assert len(feat) == 2
365
+ # Verify the correct 2 rows survived — a filter bug that kept the wrong pair
366
+ # (e.g. result_valid=False row + unknown-GPU row) would still give len == 2.
367
+ assert set(feat["canonical_gpu_id"]) == {"h200_sxm", "mi300x"}
368
+
369
+ def test_new_columns_present(self, spec_path):
370
+ raw = _make_raw_df(spec_path)
371
+ feat = build_training_df(raw, spec_path=spec_path)
372
+ expected_cols = [
373
+ "model_total_params_b", "model_compute_params_b",
374
+ "bytes_per_param", "selected_precision", "peak_tflops_selected",
375
+ "bandwidth_ceiling_tok_per_sec", "compute_ceiling_tok_per_sec",
376
+ "roofline_tput", "efficiency_ratio", "model_size_gb",
377
+ "model_to_vram_ratio", "framework_family",
378
+ "nvidia_arch_gen", "amd_arch_gen", "vendor_is_amd",
379
+ "mlperf_round_num", "is_base_tier",
380
+ ]
381
+ for col in expected_cols:
382
+ assert col in feat.columns, f"Missing column: {col}"
383
+
384
+ def test_mlperf_round_num_mapping(self, spec_path):
385
+ raw = _make_raw_df(spec_path)
386
+ # _make_raw_df uses "round": "v5.0" → ROUND_ORDINAL["v5.0"] == 2
387
+ feat = build_training_df(raw, spec_path=spec_path)
388
+ assert (feat["mlperf_round_num"] == 2).all()
389
+
390
+ def test_is_base_tier_encoding(self, spec_path):
391
+ raw = _make_raw_df(spec_path).copy()
392
+ # Inject a base-tier row for MI300X.
393
+ base_row = raw[raw["gpu_name"] == "AMD Instinct MI300X 192GB HBM3"].iloc[0].copy()
394
+ base_row["benchmark_accuracy_tier"] = "base"
395
+ base_row["benchmark"] = "llama2-70b-base"
396
+ raw = pd.concat([raw, base_row.to_frame().T], ignore_index=True)
397
+ feat = build_training_df(raw, spec_path=spec_path)
398
+ base_rows = feat[feat["benchmark_accuracy_tier"] == "base"]
399
+ non_base_rows = feat[feat["benchmark_accuracy_tier"] != "base"]
400
+ assert (base_rows["is_base_tier"] == 1).all()
401
+ assert (non_base_rows["is_base_tier"] == 0).all()
402
+
403
+ def test_mlperf_round_num_unknown_round_is_nan(self, spec_path):
404
+ raw = _make_raw_df(spec_path).copy()
405
+ raw["round"] = "v99.0"
406
+ feat = build_training_df(raw, spec_path=spec_path)
407
+ assert feat["mlperf_round_num"].isna().all()
408
+
409
+ def test_roofline_equals_compute_ceiling(self, spec_path):
410
+ raw = _make_raw_df(spec_path)
411
+ feat = build_training_df(raw, spec_path=spec_path)
412
+ pd.testing.assert_series_equal(
413
+ feat["roofline_tput"],
414
+ feat["compute_ceiling_tok_per_sec"],
415
+ check_names=False,
416
+ )
417
+
418
+ def test_h200_fp8_precision_selection(self, spec_path):
419
+ raw = _make_raw_df(spec_path)
420
+ feat = build_training_df(raw, spec_path=spec_path)
421
+ h200_row = feat[feat["canonical_gpu_id"] == "h200_sxm"].iloc[0]
422
+ assert h200_row["selected_precision"] == "fp8"
423
+ assert h200_row["bytes_per_param"] == BYTES_PER_PARAM["fp8"]
424
+ # Pin the actual TFLOPS value — catches bugs in the vectorised peak-TFLOPS
425
+ # selection (e.g. accidentally returning fp16=989.4 instead of fp8=1978.9).
426
+ assert h200_row["peak_tflops_selected"] == pytest.approx(1978.9)
427
+
428
+ def test_amd_tier_99_9_uses_fp8(self, spec_path):
429
+ raw = _make_raw_df(spec_path)
430
+ feat = build_training_df(raw, spec_path=spec_path)
431
+ mi_row = feat[feat["canonical_gpu_id"] == "mi300x"].iloc[0]
432
+ # AMD 99.9 tier → fp8 override (not fp16). MI300X fp8 = 2614.9 TFLOPS.
433
+ assert mi_row["selected_precision"] == "fp8"
434
+ assert mi_row["bytes_per_param"] == BYTES_PER_PARAM["fp8"]
435
+ assert mi_row["peak_tflops_selected"] == pytest.approx(2614.9)
436
+
437
+ def test_nvidia_tier_99_9_still_uses_fp16(self, spec_path):
438
+ raw = _make_raw_df(spec_path).copy()
439
+ # Swap the H200 row to tier 99.9 — NVIDIA must NOT be overridden to fp8.
440
+ mask = raw["gpu_name"] == "NVIDIA H200-SXM-141GB"
441
+ raw.loc[mask, "benchmark_accuracy_tier"] = "99.9"
442
+ raw.loc[mask, "benchmark"] = "llama2-70b-99.9"
443
+ feat = build_training_df(raw, spec_path=spec_path)
444
+ h200 = feat[feat["canonical_gpu_id"] == "h200_sxm"].iloc[0]
445
+ assert h200["selected_precision"] == "fp16"
446
+ assert h200["bytes_per_param"] == BYTES_PER_PARAM["fp16"]
447
+ assert h200["peak_tflops_selected"] == pytest.approx(989.4)
448
+
449
+ def test_vendor_is_amd_flag(self, spec_path):
450
+ raw = _make_raw_df(spec_path)
451
+ feat = build_training_df(raw, spec_path=spec_path)
452
+ assert feat.loc[feat["canonical_gpu_id"] == "mi300x", "vendor_is_amd"].iloc[0] == 1
453
+ assert feat.loc[feat["canonical_gpu_id"] == "h200_sxm", "vendor_is_amd"].iloc[0] == 0
454
+
455
+ def test_framework_family_assigned(self, spec_path):
456
+ raw = _make_raw_df(spec_path)
457
+ feat = build_training_df(raw, spec_path=spec_path)
458
+ h200 = feat.loc[feat["canonical_gpu_id"] == "h200_sxm", "framework_family"].iloc[0]
459
+ mi = feat.loc[feat["canonical_gpu_id"] == "mi300x", "framework_family"].iloc[0]
460
+ assert h200 == "tensorrt"
461
+ assert mi == "vllm"
462
+
463
+ def test_no_nulls_in_feature_columns(self, spec_path):
464
+ raw = _make_raw_df(spec_path)
465
+ feat = build_training_df(raw, spec_path=spec_path)
466
+ feature_cols = [
467
+ "model_total_params_b", "model_compute_params_b", "bytes_per_param",
468
+ "bandwidth_ceiling_tok_per_sec", "compute_ceiling_tok_per_sec",
469
+ "roofline_tput", "efficiency_ratio", "model_size_gb",
470
+ "model_to_vram_ratio", "vendor_is_amd",
471
+ ]
472
+ for col in feature_cols:
473
+ assert feat[col].notna().all(), f"Nulls found in {col}"
474
+
475
+ def test_vendor_arch_gen_ordinals(self, spec_path):
476
+ raw = _make_raw_df(spec_path)
477
+ feat = build_training_df(raw, spec_path=spec_path)
478
+ h200 = feat[feat["canonical_gpu_id"] == "h200_sxm"].iloc[0]
479
+ mi300 = feat[feat["canonical_gpu_id"] == "mi300x"].iloc[0]
480
+ # H200 is hopper (nvidia_arch_gen=3); amd_arch_gen must be NaN
481
+ assert h200["nvidia_arch_gen"] == 3
482
+ assert pd.isna(h200["amd_arch_gen"])
483
+ # MI300X is cdna3 (amd_arch_gen=1); nvidia_arch_gen must be NaN
484
+ assert mi300["amd_arch_gen"] == 1
485
+ assert pd.isna(mi300["nvidia_arch_gen"])
486
+
487
+ def test_unknown_benchmark_dropped(self, spec_path):
488
+ raw = _make_raw_df(spec_path).copy()
489
+ # Inject a row with a benchmark_base not in MODEL_PARAMS
490
+ extra = raw.iloc[0].copy()
491
+ extra["benchmark_base"] = "future-model-1t"
492
+ extra["result_valid"] = True
493
+ raw = pd.concat([raw, extra.to_frame().T], ignore_index=True)
494
+ feat = build_training_df(raw, spec_path=spec_path)
495
+ assert "future-model-1t" not in feat["benchmark_base"].values
496
+ # Guard against a catastrophic bug that drops ALL rows — an empty
497
+ # DataFrame would also satisfy the assertion above.
498
+ assert len(feat) == 2
499
+
500
+ def test_rows_with_missing_gpu_specs_are_dropped(self, spec_path):
501
+ # Patch enrich_df in the build_features namespace (where it was imported).
502
+ import src.features.build_features as bf_mod
503
+ from src.data.gpu_spec_db import enrich_df as real_enrich
504
+
505
+ def patched_enrich(df, **kwargs):
506
+ enriched = real_enrich(df, **kwargs)
507
+ mask = enriched["canonical_gpu_id"] == "h200_sxm"
508
+ enriched.loc[mask, "gpu_hbm_bandwidth_tbps"] = float("nan")
509
+ return enriched
510
+
511
+ raw = _make_raw_df(spec_path)
512
+ with patch.object(bf_mod, "enrich_df", patched_enrich):
513
+ feat = build_training_df(raw, spec_path=spec_path)
514
+
515
+ # H200 row dropped; only MI300X survives
516
+ assert len(feat) == 1
517
+ assert feat["canonical_gpu_id"].iloc[0] == "mi300x"
518
+
519
+ def test_violation_rows_logged_not_dropped(self, spec_path, caplog):
520
+ import logging
521
+ # When throughput > roofline_tput, rows are kept (not dropped) AND a warning
522
+ # is emitted. The test name claims both; both are now asserted.
523
+ raw = _make_raw_df(spec_path).copy()
524
+ # Force throughput_tok_per_sec_per_gpu to a huge value to trigger the path.
525
+ h200_mask = raw["gpu_name"] == "NVIDIA H200-SXM-141GB"
526
+ raw.loc[h200_mask, "throughput_tok_per_sec_per_gpu"] = 1_000_000.0
527
+ with caplog.at_level(logging.WARNING, logger="src.features.build_features"):
528
+ feat = build_training_df(raw, spec_path=spec_path)
529
+ # Rows are kept, not filtered.
530
+ assert len(feat) == 2
531
+ assert (feat.loc[feat["canonical_gpu_id"] == "h200_sxm", "efficiency_ratio"] > 1).all()
532
+ # Violations must also be logged (the "logged" half of the test name).
533
+ assert any(
534
+ "compute ceiling" in r.getMessage().lower()
535
+ for r in caplog.records
536
+ if r.levelno == logging.WARNING
537
+ ), "Expected a WARNING about throughput > compute ceiling but none was emitted"
tests/test_gpu_spec_db.py ADDED
@@ -0,0 +1,489 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for src/data/gpu_spec_db.py.
3
+
4
+ All tests that require the real data/gpu_specs.yaml use the actual file —
5
+ it is curated reference data, not generated output, so reading it in tests
6
+ is appropriate. No MLPerf repo data required.
7
+ """
8
+
9
+ import logging
10
+ from pathlib import Path
11
+ import pandas as pd
12
+ import pytest
13
+ import yaml
14
+
15
+ from src.data.gpu_spec_db import (
16
+ _MAX_SPEC_BYTES,
17
+ SPEC_COLUMNS,
18
+ _build_alias_index,
19
+ _is_heterogeneous,
20
+ _strip_suffixes,
21
+ enrich_df,
22
+ load_specs,
23
+ normalize_gpu_name,
24
+ )
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Fixtures
29
+ # ---------------------------------------------------------------------------
30
+
31
+ @pytest.fixture(scope="module")
32
+ def specs():
33
+ # Shallow copy so test mutations (e.g. specs.append(...)) do not corrupt
34
+ # the load_specs() lru_cache, which is shared across the whole test process.
35
+ return list(load_specs())
36
+
37
+
38
+ @pytest.fixture(scope="module")
39
+ def alias_index(specs):
40
+ return _build_alias_index(specs)
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # YAML integrity
45
+ # ---------------------------------------------------------------------------
46
+
47
+ class TestYamlIntegrity:
48
+ REQUIRED_FIELDS = [
49
+ "id", "name", "vendor", "architecture", "memory_type",
50
+ "vram_gb", "hbm_bandwidth_tbps", "peak_tflops",
51
+ "in_model_scope", "spec_confidence", "aliases",
52
+ ]
53
+ # Precisions every in-scope GPU supports (A100 Ampere has no FP8 natively,
54
+ # so fp8 is intentionally absent here; covered by a separate test below).
55
+ REQUIRED_TFLOPS = ["fp32", "bf16", "fp16"]
56
+
57
+ def test_all_ids_unique(self, specs):
58
+ ids = [s["id"] for s in specs]
59
+ assert len(ids) == len(set(ids)), "Duplicate GPU ids in spec DB"
60
+
61
+ def test_required_fields_present(self, specs):
62
+ for spec in specs:
63
+ for field in self.REQUIRED_FIELDS:
64
+ assert field in spec, (
65
+ f"GPU {spec.get('id')!r} missing required field {field!r}"
66
+ )
67
+
68
+ def test_all_gpus_have_valid_spec_confidence(self, specs):
69
+ # Previously named "test_in_scope_gpus_have_verified_or_estimated_confidence"
70
+ # but the loop had no in_model_scope guard — it checked all GPUs.
71
+ # The name now matches the actual behavior.
72
+ valid = {"verified", "estimated"}
73
+ for spec in specs:
74
+ assert spec["spec_confidence"] in valid, (
75
+ f"{spec['id']}: spec_confidence must be 'verified' or 'estimated', "
76
+ f"got {spec['spec_confidence']!r}"
77
+ )
78
+
79
+ def test_in_scope_gpus_have_bandwidth_and_bf16(self, specs):
80
+ """In-scope GPUs must have positive bandwidth and BF16 TFLOPS.
81
+
82
+ Zero or None would silently corrupt the roofline model:
83
+ zero bandwidth → division-by-zero in arithmetic intensity calculation;
84
+ zero BF16 TFLOPS → roofline ceiling of 0, predicting 0 throughput always.
85
+ """
86
+ for spec in specs:
87
+ if not spec["in_model_scope"]:
88
+ continue
89
+ bw = spec["hbm_bandwidth_tbps"]
90
+ assert bw is not None and bw > 0, (
91
+ f"{spec['id']}: hbm_bandwidth_tbps must be > 0, got {bw!r}"
92
+ )
93
+ bf16 = spec["peak_tflops"].get("bf16")
94
+ assert bf16 is not None and bf16 > 0, (
95
+ f"{spec['id']}: peak_tflops.bf16 must be > 0, got {bf16!r}"
96
+ )
97
+
98
+ def test_in_scope_gpus_have_positive_required_tflops(self, specs):
99
+ """REQUIRED_TFLOPS must be positive for every in-scope GPU.
100
+
101
+ Catches accidentally-zero values that would silently make the roofline
102
+ predict 0 throughput for those precisions.
103
+ """
104
+ for spec in specs:
105
+ if not spec["in_model_scope"]:
106
+ continue
107
+ pt = spec.get("peak_tflops") or {}
108
+ for key in self.REQUIRED_TFLOPS:
109
+ val = pt.get(key)
110
+ assert val is not None and val > 0, (
111
+ f"{spec['id']}: peak_tflops.{key} must be > 0 for in-scope GPU, "
112
+ f"got {val!r}"
113
+ )
114
+
115
+ def test_non_null_tflops_are_positive(self, specs):
116
+ """Any non-null peak_tflops value (including fp8, fp6, fp4) must be > 0.
117
+
118
+ Null means unsupported (e.g. A100 fp8 is null — correct).
119
+ A non-null zero would silently corrupt roofline calculations.
120
+ """
121
+ for spec in specs:
122
+ pt = spec.get("peak_tflops") or {}
123
+ for key, val in pt.items():
124
+ if val is not None:
125
+ assert val > 0, (
126
+ f"{spec['id']}: peak_tflops.{key} is non-null but not > 0: {val!r}"
127
+ )
128
+
129
+ def test_no_conflicting_aliases_across_gpus(self, specs):
130
+ """No alias should map to two different GPU ids after case-folding."""
131
+ seen: dict[str, str] = {}
132
+ for spec in specs:
133
+ for alias in spec.get("aliases", []):
134
+ key = alias.lower()
135
+ if key in seen and seen[key] != spec["id"]:
136
+ pytest.fail(
137
+ f"Alias {alias!r} maps to both {seen[key]!r} and {spec['id']!r}"
138
+ )
139
+ seen[key] = spec["id"]
140
+
141
+ def test_amd_gpus_have_compute_units(self, specs):
142
+ for spec in specs:
143
+ if spec["vendor"] == "amd":
144
+ assert "compute_units" in spec, (
145
+ f"{spec['id']}: AMD GPU missing compute_units"
146
+ )
147
+
148
+ def test_nvidia_gpus_have_streaming_multiprocessors(self, specs):
149
+ for spec in specs:
150
+ if spec["vendor"] == "nvidia":
151
+ assert "streaming_multiprocessors" in spec, (
152
+ f"{spec['id']}: NVIDIA GPU missing streaming_multiprocessors"
153
+ )
154
+
155
+ def test_in_scope_gpu_ids_present(self, specs):
156
+ """The eight v1-scope GPUs must all be present and in-scope."""
157
+ required = {"mi300x", "mi325x", "mi355x", "h100_sxm", "h200_sxm",
158
+ "a100_sxm_80gb", "l4", "rtx4090"}
159
+ in_scope_ids = {s["id"] for s in specs if s["in_model_scope"]}
160
+ missing = required - in_scope_ids
161
+ assert not missing, f"Required GPUs missing from spec DB: {missing}"
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # Security tests: load_specs guards
166
+ # ---------------------------------------------------------------------------
167
+
168
+ class TestLoadSpecsSecurity:
169
+ def _minimal_yaml(self, tmp_path, content: dict) -> Path:
170
+ p = tmp_path / "specs.yaml"
171
+ p.write_text(yaml.dump(content), encoding="utf-8")
172
+ return p
173
+
174
+ def test_rejects_symlink(self, tmp_path):
175
+ real = self._minimal_yaml(tmp_path, {"gpus": []})
176
+ link = tmp_path / "link.yaml"
177
+ link.symlink_to(real)
178
+ with pytest.raises(ValueError, match="symlink"):
179
+ load_specs(link)
180
+
181
+ def test_rejects_symlink_to_outside(self, tmp_path):
182
+ link = tmp_path / "escape.yaml"
183
+ link.symlink_to("/etc/hosts")
184
+ with pytest.raises(ValueError, match="symlink"):
185
+ load_specs(link)
186
+
187
+ def test_rejects_oversized_file(self, tmp_path):
188
+ p = tmp_path / "huge.yaml"
189
+ p.write_bytes(b"# " + b"x" * _MAX_SPEC_BYTES)
190
+ with pytest.raises(ValueError, match="too large"):
191
+ load_specs(p)
192
+
193
+ def test_missing_file_raises_file_not_found(self, tmp_path):
194
+ with pytest.raises(FileNotFoundError):
195
+ load_specs(tmp_path / "nonexistent.yaml")
196
+
197
+ def test_missing_gpus_key_raises_value_error(self, tmp_path):
198
+ p = tmp_path / "bad.yaml"
199
+ p.write_text(yaml.dump({"schema_version": "1.0"}), encoding="utf-8")
200
+ with pytest.raises(ValueError, match="gpus"):
201
+ load_specs(p)
202
+
203
+ def test_empty_yaml_raises_value_error(self, tmp_path):
204
+ p = tmp_path / "empty.yaml"
205
+ p.write_text("", encoding="utf-8")
206
+ with pytest.raises(ValueError, match="gpus"):
207
+ load_specs(p)
208
+
209
+ def test_valid_minimal_yaml_loads(self, tmp_path):
210
+ p = self._minimal_yaml(tmp_path, {"gpus": [{"id": "test"}]})
211
+ result = load_specs(p)
212
+ assert result == [{"id": "test"}]
213
+
214
+
215
+ class TestLogInjection:
216
+ def test_newline_in_gpu_name_does_not_inject_log_line(self, caplog):
217
+ """A GPU name containing \\n must not create a spurious second log line."""
218
+ injected = "NVIDIA H100\n[CRITICAL] Forged log entry"
219
+ df = pd.DataFrame([{"gpu_name": injected}])
220
+ with caplog.at_level(logging.DEBUG, logger="src.data.gpu_spec_db"):
221
+ enrich_df(df)
222
+ # Every record produced must have been created by the logger itself —
223
+ # the injected newline must appear repr-quoted, not as a raw newline.
224
+ for record in caplog.records:
225
+ assert "\n[CRITICAL]" not in record.getMessage(), (
226
+ "Log injection: raw newline from GPU name appeared in log output"
227
+ )
228
+
229
+ def test_ansi_escape_in_gpu_name_repr_quoted(self, caplog):
230
+ """ANSI escape sequences in GPU names must be repr-quoted in debug logs."""
231
+ ansi_name = "GPU\x1b[31mRED\x1b[0m"
232
+ df = pd.DataFrame([{"gpu_name": ansi_name}])
233
+ with caplog.at_level(logging.DEBUG, logger="src.data.gpu_spec_db"):
234
+ enrich_df(df)
235
+ raw_ansi = "\x1b[31m"
236
+ for record in caplog.records:
237
+ assert raw_ansi not in record.getMessage(), (
238
+ "ANSI escape from GPU name appeared unescaped in log output"
239
+ )
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # _strip_suffixes
244
+ # ---------------------------------------------------------------------------
245
+
246
+ class TestStripSuffixes:
247
+ @pytest.mark.parametrize("raw,expected", [
248
+ ("AMD Instinct MI355X 288GB HBM3e (x87)", "AMD Instinct MI355X 288GB HBM3e"),
249
+ ("AMD Instinct MI300X 192GB HBM3 (x8)", "AMD Instinct MI300X 192GB HBM3"),
250
+ ("AMD Instinct MI355X 288GB HBM3e (Power Cap 1000 W)", "AMD Instinct MI355X 288GB HBM3e"),
251
+ ("NVIDIA H100-SXM-80GB", "NVIDIA H100-SXM-80GB"),
252
+ ("AMD Instinct MI300X 192GB HBM3", "AMD Instinct MI300X 192GB HBM3"),
253
+ ])
254
+ def test_strips_known_patterns(self, raw, expected):
255
+ assert _strip_suffixes(raw) == expected
256
+
257
+
258
+ # ---------------------------------------------------------------------------
259
+ # _is_heterogeneous
260
+ # ---------------------------------------------------------------------------
261
+
262
+ class TestIsHeterogeneous:
263
+ def test_mixed_and(self):
264
+ assert _is_heterogeneous(
265
+ "AMD Instinct MI300X 192GB HBM3 (x8) and AMD Instinct MI325X 256GB HBM3e (x8)"
266
+ )
267
+
268
+ def test_mixed_comma_count(self):
269
+ assert _is_heterogeneous(
270
+ "AMD Instinct MI300X 192GB HBM3 (x32), AMD Instinct MI325X 256GB HBM3e (x16)"
271
+ )
272
+
273
+ def test_single_gpu_not_heterogeneous(self):
274
+ assert not _is_heterogeneous("AMD Instinct MI300X 192GB HBM3")
275
+
276
+ def test_comma_without_count_not_flagged(self):
277
+ # A name with a comma but no "(xN)" is not a multi-SKU string.
278
+ assert not _is_heterogeneous("NVIDIA H100-SXM-80GB, Rev 2")
279
+
280
+
281
+ # ---------------------------------------------------------------------------
282
+ # normalize_gpu_name
283
+ # ---------------------------------------------------------------------------
284
+
285
+ class TestNormalizeGpuName:
286
+ # Known exact aliases from the real MLPerf corpus
287
+ @pytest.mark.parametrize("raw,expected_id", [
288
+ ("AMD Instinct MI300X 192GB HBM3", "mi300x"),
289
+ ("AMD Instinct MI300X-NPS1-SPX-192GB-750W", "mi300x"),
290
+ ("AMD MI300X-NPS1-SPX-192GB-750W", "mi300x"),
291
+ ("AMD Instinct MI325X 256GB HBM3E", "mi325x"), # uppercase E
292
+ ("AMD Instinct MI325X 256GB HBM3e", "mi325x"), # lowercase e
293
+ ("AMD Instinct MI355X 288GB HBM3e", "mi355x"),
294
+ ("AMD Instinct MI350X 288GB HBM3e", "mi350x"),
295
+ ("NVIDIA H100-SXM-80GB", "h100_sxm"),
296
+ ("Virtualized NVIDIA H100-SXM-80GB", "h100_sxm"),
297
+ ("NVIDIA H200-SXM-141GB", "h200_sxm"),
298
+ ("NVIDIA H200-SXM-141GB-CTS", "h200_sxm"),
299
+ ("Virtualized NVIDIA H200-SXM-141GB", "h200_sxm"),
300
+ ("NVIDIA H200-NVL-141GB", "h200_nvl"),
301
+ ("NVIDIA H100-PCIe-80GB", "h100_pcie"),
302
+ ("NVIDIA H100-NVL-94GB", "h100_nvl"),
303
+ ("NVIDIA GH200 Grace Hopper Superchip 96GB", "gh200_96gb"),
304
+ ("NVIDIA GH200 Grace Hopper Superchip 144GB", "gh200_144gb"),
305
+ ("NVIDIA L40S", "l40s"),
306
+ ("NVIDIA B200-SXM-180GB", "b200_sxm"),
307
+ ("NVIDIA B300-SXM-270GB", "b300_sxm"),
308
+ ("NVIDIA GB200", "gb200"),
309
+ ("NVIDIA GB300", "gb300"),
310
+ ])
311
+ def test_known_aliases(self, raw, expected_id, specs, alias_index):
312
+ result = normalize_gpu_name(raw, specs, _index=alias_index)
313
+ assert result == expected_id, f"Expected {expected_id!r}, got {result!r} for {raw!r}"
314
+
315
+ def test_count_suffix_stripped(self, specs, alias_index):
316
+ """'(x87)' suffix must be stripped before alias lookup."""
317
+ result = normalize_gpu_name(
318
+ "AMD Instinct MI355X 288GB HBM3e (x87)", specs, _index=alias_index
319
+ )
320
+ assert result == "mi355x"
321
+
322
+ def test_power_suffix_stripped(self, specs, alias_index):
323
+ result = normalize_gpu_name(
324
+ "AMD Instinct MI355X 288GB HBM3e (Power Cap 1000 W)",
325
+ specs, _index=alias_index,
326
+ )
327
+ assert result == "mi355x"
328
+
329
+ def test_heterogeneous_returns_none(self, specs, alias_index):
330
+ result = normalize_gpu_name(
331
+ "AMD Instinct MI300X 192GB HBM3 (x8) and AMD Instinct MI325X 256GB HBM3e (x8)",
332
+ specs, _index=alias_index,
333
+ )
334
+ assert result is None
335
+
336
+ def test_na_returns_none(self, specs, alias_index):
337
+ assert normalize_gpu_name("N/A", specs, _index=alias_index) is None
338
+
339
+ def test_none_input_returns_none(self, specs, alias_index):
340
+ assert normalize_gpu_name(None, specs, _index=alias_index) is None
341
+
342
+ def test_unknown_gpu_returns_none(self, specs, alias_index):
343
+ assert normalize_gpu_name("NVIDIA H9000-FAKE", specs, _index=alias_index) is None
344
+
345
+
346
+ # ---------------------------------------------------------------------------
347
+ # enrich_df
348
+ # ---------------------------------------------------------------------------
349
+
350
+ class TestEnrichDf:
351
+ def _minimal_df(self, gpu_name: str) -> pd.DataFrame:
352
+ return pd.DataFrame([{"gpu_name": gpu_name}])
353
+
354
+ def test_adds_all_spec_columns(self):
355
+ df = self._minimal_df("AMD Instinct MI300X 192GB HBM3")
356
+ enriched = enrich_df(df)
357
+ for col in SPEC_COLUMNS:
358
+ assert col in enriched.columns, f"Missing column {col!r}"
359
+
360
+ def test_known_gpu_values_populated(self):
361
+ df = self._minimal_df("NVIDIA H100-SXM-80GB")
362
+ enriched = enrich_df(df)
363
+ row = enriched.iloc[0]
364
+ assert row["canonical_gpu_id"] == "h100_sxm"
365
+ assert row["gpu_vendor"] == "nvidia"
366
+ assert row["gpu_architecture"] == "hopper"
367
+ assert row["gpu_vram_gb"] == pytest.approx(80.0)
368
+ assert row["gpu_hbm_bandwidth_tbps"] == pytest.approx(3.35)
369
+ assert row["gpu_peak_bf16_tflops"] == pytest.approx(989.4)
370
+ assert row["gpu_in_model_scope"] == True # noqa: E712 — numpy.bool_
371
+
372
+ def test_unknown_gpu_yields_null_spec_columns(self):
373
+ df = self._minimal_df("NVIDIA FAKE-9000")
374
+ enriched = enrich_df(df)
375
+ assert enriched.iloc[0]["canonical_gpu_id"] is None
376
+ assert pd.isna(enriched.iloc[0]["gpu_vram_gb"])
377
+
378
+ def test_heterogeneous_gpu_yields_null(self):
379
+ df = self._minimal_df(
380
+ "AMD Instinct MI300X 192GB HBM3 (x8) "
381
+ "and AMD Instinct MI325X 256GB HBM3e (x8)"
382
+ )
383
+ enriched = enrich_df(df)
384
+ assert enriched.iloc[0]["canonical_gpu_id"] is None
385
+
386
+ def test_original_df_not_mutated(self):
387
+ df = self._minimal_df("NVIDIA H200-SXM-141GB")
388
+ original_cols = set(df.columns)
389
+ enrich_df(df)
390
+ assert set(df.columns) == original_cols
391
+
392
+ def test_multi_row_df(self):
393
+ df = pd.DataFrame([
394
+ {"gpu_name": "AMD Instinct MI300X 192GB HBM3"},
395
+ {"gpu_name": "NVIDIA H200-SXM-141GB"},
396
+ {"gpu_name": "N/A"},
397
+ ])
398
+ enriched = enrich_df(df)
399
+ assert len(enriched) == 3
400
+ assert enriched.iloc[0]["canonical_gpu_id"] == "mi300x"
401
+ assert enriched.iloc[1]["canonical_gpu_id"] == "h200_sxm"
402
+ assert pd.isna(enriched.iloc[2]["canonical_gpu_id"])
403
+
404
+ def test_out_of_scope_gpu_flagged(self):
405
+ df = self._minimal_df("NVIDIA B200-SXM-180GB")
406
+ enriched = enrich_df(df)
407
+ assert enriched.iloc[0]["gpu_in_model_scope"] == False # noqa: E712 — numpy.bool_
408
+
409
+ def test_cu_sm_count_unified(self):
410
+ """AMD CUs and NVIDIA SMs both appear under gpu_cu_sm_count."""
411
+ amd = enrich_df(self._minimal_df("AMD Instinct MI300X 192GB HBM3")).iloc[0]
412
+ nv = enrich_df(self._minimal_df("NVIDIA H100-SXM-80GB")).iloc[0]
413
+ assert amd["gpu_cu_sm_count"] == 304
414
+ assert nv["gpu_cu_sm_count"] == 132
415
+
416
+ def test_duplicate_gpu_name_rows_all_enriched(self):
417
+ """All rows sharing the same gpu_name must get spec columns populated.
418
+
419
+ Correctness check: the deduplication must broadcast results to all rows,
420
+ not just the first occurrence.
421
+ """
422
+ df = pd.DataFrame([{"gpu_name": "NVIDIA H200-SXM-141GB"}] * 50)
423
+ enriched = enrich_df(df)
424
+ assert len(enriched) == 50
425
+ assert (enriched["canonical_gpu_id"] == "h200_sxm").all()
426
+ assert enriched["gpu_hbm_bandwidth_tbps"].notna().all()
427
+
428
+ def test_vram_conflict_warning_logged(self, caplog):
429
+ """enrich_df warns when parser vram_gb differs from spec DB gpu_vram_gb.
430
+
431
+ Simulates a multi-GPU system where the submitter reported total VRAM
432
+ (8 × 80 GB = 640 GB) rather than per-GPU capacity (80 GB).
433
+ Covers lines 262-282 in gpu_spec_db.py.
434
+ """
435
+ df = pd.DataFrame([{
436
+ "gpu_name": "NVIDIA H100-SXM-80GB",
437
+ "vram_gb": 640.0, # 8-GPU total — spec DB stores 80 GB per-GPU
438
+ "num_gpus": 8,
439
+ }])
440
+ with caplog.at_level(logging.WARNING, logger="src.data.gpu_spec_db"):
441
+ enriched = enrich_df(df)
442
+ assert enriched.iloc[0]["gpu_vram_gb"] == pytest.approx(80.0)
443
+ assert any(
444
+ "vram" in r.getMessage().lower()
445
+ for r in caplog.records
446
+ if r.levelno == logging.WARNING
447
+ )
448
+
449
+ def test_no_vram_conflict_no_warning(self, caplog):
450
+ """No warning when vram_gb already matches the spec DB value."""
451
+ df = pd.DataFrame([{
452
+ "gpu_name": "NVIDIA H100-SXM-80GB",
453
+ "vram_gb": 80.0, # matches spec DB
454
+ "num_gpus": 1,
455
+ }])
456
+ with caplog.at_level(logging.WARNING, logger="src.data.gpu_spec_db"):
457
+ enrich_df(df)
458
+ assert not any(
459
+ "vram" in r.getMessage().lower()
460
+ for r in caplog.records
461
+ if r.levelno == logging.WARNING
462
+ )
463
+
464
+
465
+ # ---------------------------------------------------------------------------
466
+ # _build_alias_index: conflicting alias warning
467
+ # ---------------------------------------------------------------------------
468
+
469
+ class TestBuildAliasIndex:
470
+ def test_conflicting_alias_warns(self, caplog):
471
+ """Same lowercase alias in two different specs emits a warning (line 122)."""
472
+ specs = [
473
+ {"id": "gpu_a", "aliases": ["Shared Alias", "only_a"]},
474
+ {"id": "gpu_b", "aliases": ["shared alias", "only_b"]}, # same after lower()
475
+ ]
476
+ with caplog.at_level(logging.WARNING, logger="src.data.gpu_spec_db"):
477
+ index = _build_alias_index(specs)
478
+ assert any("conflict" in r.getMessage().lower() for r in caplog.records)
479
+ # Last writer wins — gpu_b is processed second
480
+ assert index["shared alias"]["id"] == "gpu_b"
481
+
482
+ def test_same_alias_same_gpu_no_warning(self, caplog):
483
+ """Duplicate alias within one GPU (case variants) must not warn."""
484
+ specs = [
485
+ {"id": "gpu_a", "aliases": ["HBM3E", "hbm3e"]}, # same after lower()
486
+ ]
487
+ with caplog.at_level(logging.WARNING, logger="src.data.gpu_spec_db"):
488
+ _build_alias_index(specs)
489
+ assert not any(r.levelno == logging.WARNING for r in caplog.records)
tests/test_mlperf_parser.py ADDED
@@ -0,0 +1,1111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for src/data/mlperf_parser.py.
3
+
4
+ All tests use synthetic, in-memory fixture data — no real MLPerf repos required.
5
+ Fixtures mirror the exact directory layout and file content format of
6
+ MLPerf Inference v4.1–v6.0 closed-division submissions.
7
+ """
8
+
9
+ import json
10
+ import logging
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ import pandas as pd
15
+ import pytest
16
+
17
+ from src.data.mlperf_parser import (
18
+ LLM_BENCHMARKS,
19
+ TOKENS_PER_SAMPLE,
20
+ _MAX_FILE_BYTES,
21
+ _accuracy_tier,
22
+ _base_benchmark,
23
+ _extract_precision,
24
+ _find_best_run,
25
+ _parse_log_summary,
26
+ _parse_system_json,
27
+ _parse_vram_gb,
28
+ _run_number,
29
+ _safe_read_text,
30
+ main,
31
+ parse_repo,
32
+ parse_repos,
33
+ )
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Fixture content (mirrors real MLPerf file content)
38
+ # ---------------------------------------------------------------------------
39
+
40
+ SYSTEM_JSON_NVIDIA = {
41
+ "system_name": "H100-SXM5-80GBx8_TRT-LLM",
42
+ # Real MLPerf H100 SXM submissions use "H100-SXM-80GB" (no "5").
43
+ # This must match the aliases in data/gpu_specs.yaml for end-to-end
44
+ # parse_repo → enrich_df tests to produce a non-null canonical_gpu_id.
45
+ "accelerator_model_name": "NVIDIA H100-SXM-80GB",
46
+ "accelerators_per_node": "8",
47
+ "accelerator_memory_capacity": "80 GB",
48
+ "framework": "TensorRT-LLM v0.12.0, CUDA 12.6.3",
49
+ "system_type": "datacenter",
50
+ "status": "available",
51
+ "division": "closed",
52
+ }
53
+
54
+ SYSTEM_JSON_AMD = {
55
+ "system_name": "AMD_Instinct_MI300Xx8_vLLM",
56
+ "accelerator_model_name": "AMD Instinct MI300X",
57
+ "accelerators_per_node": "8",
58
+ "accelerator_memory_capacity": "192 GB",
59
+ "framework": "ROCm 6.2.4, vLLM 0.5.0",
60
+ "system_type": "datacenter",
61
+ "status": "available",
62
+ "division": "closed",
63
+ }
64
+
65
+ LOG_SUMMARY_OFFLINE = """\
66
+ ================================================
67
+ MLPerf Results Summary
68
+ ================================================
69
+ SUT name : PySUT
70
+ Scenario : Offline
71
+ Mode : PerformanceOnly
72
+ Samples per second: 25.34
73
+ Result is : VALID
74
+ Min duration satisfied : Yes
75
+ Min queries satisfied : Yes
76
+ Early stopping satisfied: Yes
77
+ ================================================
78
+ Additional Stats
79
+ ================================================
80
+ Min latency (ns) : 1000000000
81
+ Max latency (ns) : 5000000000
82
+ Mean latency (ns) : 2000000000
83
+ 50.00 percentile latency (ns) : 1800000000
84
+ 90.00 percentile latency (ns) : 3500000000
85
+ 95.00 percentile latency (ns) : 4000000000
86
+ 97.00 percentile latency (ns) : 4200000000
87
+ 99.00 percentile latency (ns) : 4800000000
88
+ 99.90 percentile latency (ns) : 4950000000
89
+ Mean First Token Latency (ns) : 150000000
90
+ 99th Percentile First Token Latency (ns) : 300000000
91
+ Mean Time Per Output Token (ns) : 50000
92
+ 99th Percentile Time Per Output Token (ns) : 80000
93
+ """
94
+
95
+ LOG_SUMMARY_SERVER = """\
96
+ ================================================
97
+ MLPerf Results Summary
98
+ ================================================
99
+ SUT name : PySUT
100
+ Scenario : Server
101
+ Mode : PerformanceOnly
102
+ Scheduled samples per second : 12.56
103
+ Result is : VALID
104
+ Min duration satisfied : Yes
105
+ Min queries satisfied : Yes
106
+ Early stopping satisfied: Yes
107
+ ================================================
108
+ Additional Stats
109
+ ================================================
110
+ Completed samples per second : 11.80
111
+ Min latency (ns) : 2000000000
112
+ Max latency (ns) : 9000000000
113
+ Mean latency (ns) : 4000000000
114
+ 50.00 percentile latency (ns) : 3800000000
115
+ 90.00 percentile latency (ns) : 7000000000
116
+ 95.00 percentile latency (ns) : 7800000000
117
+ 97.00 percentile latency (ns) : 8200000000
118
+ 99.00 percentile latency (ns) : 8900000000
119
+ 99.90 percentile latency (ns) : 8990000000
120
+ Mean First Token Latency (ns) : 200000000
121
+ 99th Percentile First Token Latency (ns) : 450000000
122
+ Mean Time Per Output Token (ns) : 60000
123
+ 99th Percentile Time Per Output Token (ns) : 95000
124
+ """
125
+
126
+ LOG_SUMMARY_INVALID = """\
127
+ ================================================
128
+ MLPerf Results Summary
129
+ ================================================
130
+ Scenario : Offline
131
+ Mode : PerformanceOnly
132
+ Samples per second: 99.99
133
+ Result is : INVALID
134
+ """
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Fixtures: build a minimal synthetic repo tree on disk
139
+ # ---------------------------------------------------------------------------
140
+
141
+ def _build_repo(
142
+ tmp_path: Path,
143
+ system_json: dict,
144
+ benchmarks: list[str] | None = None,
145
+ scenarios: list[str] | None = None,
146
+ ) -> Path:
147
+ """
148
+ Create a minimal MLPerf-layout repo under tmp_path.
149
+
150
+ Returns the repo root path.
151
+ """
152
+ if benchmarks is None:
153
+ benchmarks = ["llama2-70b"]
154
+ if scenarios is None:
155
+ scenarios = ["Offline", "Server"]
156
+
157
+ repo = tmp_path / "inference_results_v6.0"
158
+ submitter = "TestSubmitter"
159
+ system_name = system_json["system_name"]
160
+
161
+ # systems/<system>.json
162
+ systems_dir = repo / "closed" / submitter / "systems"
163
+ systems_dir.mkdir(parents=True)
164
+ (systems_dir / f"{system_name}.json").write_text(
165
+ json.dumps(system_json), encoding="utf-8"
166
+ )
167
+
168
+ # results/<system>/<benchmark>/<scenario>/performance/run_1/mlperf_log_summary.txt
169
+ for bm in benchmarks:
170
+ for sc in scenarios:
171
+ log_content = LOG_SUMMARY_OFFLINE if sc == "Offline" else LOG_SUMMARY_SERVER
172
+ run_dir = (
173
+ repo / "closed" / submitter / "results"
174
+ / system_name / bm / sc / "performance" / "run_1"
175
+ )
176
+ run_dir.mkdir(parents=True)
177
+ (run_dir / "mlperf_log_summary.txt").write_text(log_content, encoding="utf-8")
178
+
179
+ return repo
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # Unit tests: helper functions
184
+ # ---------------------------------------------------------------------------
185
+
186
+ class TestParseVramGb:
187
+ def test_gb_suffix(self):
188
+ assert _parse_vram_gb("192 GB") == 192.0
189
+
190
+ def test_gib_suffix(self):
191
+ assert _parse_vram_gb("80 GiB") == 80.0
192
+
193
+ def test_case_insensitive(self):
194
+ assert _parse_vram_gb("80 gb") == 80.0
195
+
196
+ def test_none_input(self):
197
+ assert _parse_vram_gb(None) is None
198
+
199
+ def test_empty_string(self):
200
+ assert _parse_vram_gb("") is None
201
+
202
+ def test_unrecognised_format(self):
203
+ assert _parse_vram_gb("lots") is None
204
+
205
+ def test_dot_only_match_returns_none(self):
206
+ # Regression: [\d.]+ matched ". GB" → float(".") raised ValueError and
207
+ # crashed the entire parse job. Fixed by requiring match to start with \d.
208
+ assert _parse_vram_gb(". GB") is None
209
+
210
+ def test_malformed_float_returns_none(self):
211
+ # "1.2.3" is not a valid float; must return None, not crash.
212
+ assert _parse_vram_gb("1.2.3 GB") is None
213
+
214
+
215
+ class TestBaseBenchmark:
216
+ @pytest.mark.parametrize("name,expected", [
217
+ ("llama2-70b", "llama2-70b"),
218
+ ("llama2-70b-99", "llama2-70b"),
219
+ ("llama2-70b-99.9", "llama2-70b"),
220
+ ("gptj-99", "gptj"),
221
+ ("mixtral-8x7b-99.9", "mixtral-8x7b"),
222
+ ])
223
+ def test_strip_suffix(self, name, expected):
224
+ assert _base_benchmark(name) == expected
225
+
226
+
227
+ class TestAccuracyTier:
228
+ @pytest.mark.parametrize("name,expected", [
229
+ ("llama2-70b", "base"),
230
+ ("gptj", "base"),
231
+ ("llama2-70b-99", "99"),
232
+ ("gptj-99", "99"),
233
+ ("llama2-70b-99.9", "99.9"),
234
+ ("mixtral-8x7b-99.9", "99.9"),
235
+ ])
236
+ def test_tier_extraction(self, name, expected):
237
+ assert _accuracy_tier(name) == expected
238
+
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # Unit tests: _parse_system_json
242
+ # ---------------------------------------------------------------------------
243
+
244
+ class TestParseSystemJson:
245
+ def test_nvidia_fields(self, tmp_path):
246
+ p = tmp_path / "H100.json"
247
+ p.write_text(json.dumps(SYSTEM_JSON_NVIDIA), encoding="utf-8")
248
+ hw = _parse_system_json(p)
249
+ assert hw["gpu_name"] == "NVIDIA H100-SXM-80GB"
250
+ assert hw["num_gpus"] == 8
251
+ assert hw["vram_gb"] == 80.0
252
+ assert "TensorRT-LLM" in hw["framework"]
253
+ assert hw["system_type"] == "datacenter"
254
+
255
+ def test_amd_fields(self, tmp_path):
256
+ p = tmp_path / "MI300X.json"
257
+ p.write_text(json.dumps(SYSTEM_JSON_AMD), encoding="utf-8")
258
+ hw = _parse_system_json(p)
259
+ assert hw["gpu_name"] == "AMD Instinct MI300X"
260
+ assert hw["num_gpus"] == 8
261
+ assert hw["vram_gb"] == 192.0
262
+ assert "ROCm" in hw["framework"]
263
+
264
+ def test_missing_file_returns_empty(self, tmp_path):
265
+ hw = _parse_system_json(tmp_path / "nonexistent.json")
266
+ assert hw == {}
267
+
268
+ def test_malformed_json_returns_empty(self, tmp_path):
269
+ p = tmp_path / "bad.json"
270
+ p.write_text("not json at all", encoding="utf-8")
271
+ hw = _parse_system_json(p)
272
+ assert hw == {}
273
+
274
+ def test_json_array_returns_empty(self, tmp_path):
275
+ # Bug fix: json.loads('[1,2,3]') returns a list; raw.get(...) on a list
276
+ # raised AttributeError (uncaught), aborting the entire round parse.
277
+ p = tmp_path / "array.json"
278
+ p.write_text("[1, 2, 3]", encoding="utf-8")
279
+ hw = _parse_system_json(p)
280
+ assert hw == {}
281
+
282
+ def test_json_scalar_returns_empty(self, tmp_path):
283
+ p = tmp_path / "scalar.json"
284
+ p.write_text('"just a string"', encoding="utf-8")
285
+ hw = _parse_system_json(p)
286
+ assert hw == {}
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # Unit tests: _parse_log_summary
291
+ # ---------------------------------------------------------------------------
292
+
293
+ class TestParseLogSummary:
294
+ def test_offline_throughput(self, tmp_path):
295
+ p = tmp_path / "summary.txt"
296
+ p.write_text(LOG_SUMMARY_OFFLINE, encoding="utf-8")
297
+ m = _parse_log_summary(p)
298
+ assert m["throughput_samples_per_sec"] == pytest.approx(25.34)
299
+ assert m["result_valid"] is True
300
+ assert m["scenario_from_log"] == "Offline"
301
+
302
+ def test_server_throughput(self, tmp_path):
303
+ p = tmp_path / "summary.txt"
304
+ p.write_text(LOG_SUMMARY_SERVER, encoding="utf-8")
305
+ m = _parse_log_summary(p)
306
+ assert m["throughput_samples_per_sec"] == pytest.approx(12.56)
307
+ assert m["result_valid"] is True
308
+ assert m["scenario_from_log"] == "Server"
309
+
310
+ def test_invalid_result_flag(self, tmp_path):
311
+ p = tmp_path / "summary.txt"
312
+ p.write_text(LOG_SUMMARY_INVALID, encoding="utf-8")
313
+ m = _parse_log_summary(p)
314
+ assert m["result_valid"] is False
315
+
316
+ def test_latency_ns_to_ms_conversion(self, tmp_path):
317
+ p = tmp_path / "summary.txt"
318
+ p.write_text(LOG_SUMMARY_OFFLINE, encoding="utf-8")
319
+ m = _parse_log_summary(p)
320
+ # 2_000_000_000 ns → 2000.0 ms
321
+ assert m["latency_mean_ms"] == pytest.approx(2000.0)
322
+ assert m["latency_p99_ms"] == pytest.approx(4800.0)
323
+
324
+ def test_ttft_parsed(self, tmp_path):
325
+ p = tmp_path / "summary.txt"
326
+ p.write_text(LOG_SUMMARY_OFFLINE, encoding="utf-8")
327
+ m = _parse_log_summary(p)
328
+ # 150_000_000 ns → 150.0 ms
329
+ assert m["ttft_mean_ms"] == pytest.approx(150.0)
330
+ assert m["ttft_p99_ms"] == pytest.approx(300.0)
331
+
332
+ def test_tpot_parsed(self, tmp_path):
333
+ p = tmp_path / "summary.txt"
334
+ p.write_text(LOG_SUMMARY_OFFLINE, encoding="utf-8")
335
+ m = _parse_log_summary(p)
336
+ # 50_000 ns → 0.05 ms
337
+ assert m["tpot_mean_ms"] == pytest.approx(0.05)
338
+ assert m["tpot_p99_ms"] == pytest.approx(0.08)
339
+
340
+ def test_missing_file_returns_empty(self, tmp_path):
341
+ m = _parse_log_summary(tmp_path / "nonexistent.txt")
342
+ assert m == {}
343
+
344
+ def test_no_llm_metrics_returns_none(self, tmp_path):
345
+ p = tmp_path / "summary.txt"
346
+ p.write_text(
347
+ "Scenario : Offline\nSamples per second: 5.0\nResult is : VALID\n",
348
+ encoding="utf-8",
349
+ )
350
+ m = _parse_log_summary(p)
351
+ assert m["ttft_mean_ms"] is None
352
+ assert m["tpot_mean_ms"] is None
353
+
354
+
355
+ # ---------------------------------------------------------------------------
356
+ # Integration tests: parse_repo
357
+ # ---------------------------------------------------------------------------
358
+
359
+ class TestParseRepo:
360
+ def test_correct_row_count(self, tmp_path):
361
+ # 2 scenarios × 1 benchmark = 2 rows — verify both scenarios are present,
362
+ # not just that the total count is right (two Offline rows would also give len==2)
363
+ repo = _build_repo(
364
+ tmp_path, SYSTEM_JSON_NVIDIA,
365
+ benchmarks=["llama2-70b"],
366
+ scenarios=["Offline", "Server"],
367
+ )
368
+ df = parse_repo(repo, "v6.0")
369
+ assert len(df) == 2
370
+ assert set(df["scenario"]) == {"Offline", "Server"}
371
+
372
+ def test_round_tag_propagated(self, tmp_path):
373
+ repo = _build_repo(tmp_path, SYSTEM_JSON_NVIDIA)
374
+ df = parse_repo(repo, "v6.0")
375
+ assert (df["round"] == "v6.0").all()
376
+
377
+ def test_gpu_name_extracted(self, tmp_path):
378
+ repo = _build_repo(tmp_path, SYSTEM_JSON_AMD)
379
+ df = parse_repo(repo, "v6.0")
380
+ assert (df["gpu_name"] == "AMD Instinct MI300X").all()
381
+
382
+ def test_throughput_tokens_computed(self, tmp_path):
383
+ repo = _build_repo(
384
+ tmp_path, SYSTEM_JSON_NVIDIA,
385
+ benchmarks=["llama2-70b"],
386
+ scenarios=["Offline"],
387
+ )
388
+ df = parse_repo(repo, "v6.0")
389
+ row = df.iloc[0]
390
+ expected = 25.34 * TOKENS_PER_SAMPLE["llama2-70b"]
391
+ assert row["throughput_tokens_per_sec"] == pytest.approx(expected)
392
+
393
+ def test_gptj_tokens_per_sample(self, tmp_path):
394
+ repo = _build_repo(
395
+ tmp_path, SYSTEM_JSON_NVIDIA,
396
+ benchmarks=["gptj"],
397
+ scenarios=["Offline"],
398
+ )
399
+ df = parse_repo(repo, "v6.0")
400
+ assert df.iloc[0]["tokens_per_sample"] == 128
401
+
402
+ def test_non_llm_benchmark_excluded_by_default(self, tmp_path):
403
+ repo = _build_repo(
404
+ tmp_path, SYSTEM_JSON_NVIDIA,
405
+ benchmarks=["resnet50", "llama2-70b"],
406
+ scenarios=["Offline"],
407
+ )
408
+ df = parse_repo(repo, "v6.0", llm_only=True)
409
+ assert set(df["benchmark"]) == {"llama2-70b"}
410
+
411
+ def test_non_llm_included_when_flag_off(self, tmp_path):
412
+ repo = _build_repo(
413
+ tmp_path, SYSTEM_JSON_NVIDIA,
414
+ benchmarks=["resnet50", "llama2-70b"],
415
+ scenarios=["Offline"],
416
+ )
417
+ df = parse_repo(repo, "v6.0", llm_only=False)
418
+ # Verify both sides: non-LLM included AND LLM not accidentally dropped
419
+ assert set(df["benchmark"]) == {"resnet50", "llama2-70b"}
420
+
421
+ def test_invalid_result_rows_present(self, tmp_path):
422
+ # Parser includes INVALID rows — filtering is caller's responsibility
423
+ system_name = SYSTEM_JSON_NVIDIA["system_name"]
424
+ repo = tmp_path / "repo"
425
+ run_dir = (
426
+ repo / "closed" / "Sub" / "results"
427
+ / system_name / "llama2-70b" / "Offline" / "performance" / "run_1"
428
+ )
429
+ run_dir.mkdir(parents=True)
430
+ (run_dir / "mlperf_log_summary.txt").write_text(
431
+ LOG_SUMMARY_INVALID, encoding="utf-8"
432
+ )
433
+ sys_dir = repo / "closed" / "Sub" / "systems"
434
+ sys_dir.mkdir(parents=True)
435
+ (sys_dir / f"{system_name}.json").write_text(
436
+ json.dumps(SYSTEM_JSON_NVIDIA), encoding="utf-8"
437
+ )
438
+ df = parse_repo(repo, "v6.0")
439
+ assert len(df) == 1
440
+ assert df.iloc[0]["result_valid"] == False # noqa: E712 — numpy.bool_ != Python bool
441
+
442
+ def test_multiple_runs_uses_highest(self, tmp_path):
443
+ # run_2 has higher throughput; parser should pick run_2
444
+ system_name = SYSTEM_JSON_NVIDIA["system_name"]
445
+ repo = tmp_path / "repo"
446
+ base = (
447
+ repo / "closed" / "Sub" / "results"
448
+ / system_name / "llama2-70b" / "Offline" / "performance"
449
+ )
450
+
451
+ (base / "run_1").mkdir(parents=True)
452
+ (base / "run_1" / "mlperf_log_summary.txt").write_text(
453
+ LOG_SUMMARY_OFFLINE, encoding="utf-8"
454
+ )
455
+ run2_content = LOG_SUMMARY_OFFLINE.replace("Samples per second: 25.34",
456
+ "Samples per second: 99.00")
457
+ (base / "run_2").mkdir(parents=True)
458
+ (base / "run_2" / "mlperf_log_summary.txt").write_text(
459
+ run2_content, encoding="utf-8"
460
+ )
461
+ sys_dir = repo / "closed" / "Sub" / "systems"
462
+ sys_dir.mkdir(parents=True)
463
+ (sys_dir / f"{system_name}.json").write_text(
464
+ json.dumps(SYSTEM_JSON_NVIDIA), encoding="utf-8"
465
+ )
466
+ df = parse_repo(repo, "v6.0")
467
+ assert df.iloc[0]["throughput_samples_per_sec"] == pytest.approx(99.00)
468
+
469
+ def test_empty_repo_returns_empty_df(self, tmp_path):
470
+ repo = tmp_path / "empty_repo"
471
+ repo.mkdir()
472
+ df = parse_repo(repo, "v6.0")
473
+ assert df.empty
474
+
475
+ def test_benchmark_base_column(self, tmp_path):
476
+ repo = _build_repo(
477
+ tmp_path, SYSTEM_JSON_NVIDIA,
478
+ benchmarks=["llama2-70b-99"],
479
+ scenarios=["Offline"],
480
+ )
481
+ df = parse_repo(repo, "v6.0")
482
+ assert df.iloc[0]["benchmark_base"] == "llama2-70b"
483
+
484
+ def test_submitter_field(self, tmp_path):
485
+ repo = _build_repo(tmp_path, SYSTEM_JSON_AMD)
486
+ df = parse_repo(repo, "v6.0")
487
+ assert (df["submitter"] == "TestSubmitter").all()
488
+
489
+ def test_benchmark_accuracy_tier_column(self, tmp_path):
490
+ """benchmark_accuracy_tier must be present and correctly derived."""
491
+ repo = _build_repo(
492
+ tmp_path, SYSTEM_JSON_NVIDIA,
493
+ benchmarks=["llama2-70b-99"],
494
+ scenarios=["Offline"],
495
+ )
496
+ df = parse_repo(repo, "v6.0")
497
+ assert "benchmark_accuracy_tier" in df.columns
498
+ assert df.iloc[0]["benchmark_accuracy_tier"] == "99"
499
+
500
+ def test_num_gpus_zero_clamped_to_one(self, tmp_path):
501
+ """accelerators_per_node=0 must produce num_gpus=1, not 0.
502
+
503
+ CPU-only inference systems (e.g. Intel EMR) submit with 0 accelerators.
504
+ Storing 0 would make the stored column inconsistent with the divisor
505
+ used to compute throughput_tok_per_sec_per_gpu.
506
+ """
507
+ zero_gpu_json = {**SYSTEM_JSON_NVIDIA, "accelerators_per_node": "0"}
508
+ repo = _build_repo(
509
+ tmp_path, zero_gpu_json,
510
+ benchmarks=["llama2-70b"],
511
+ scenarios=["Offline"],
512
+ )
513
+ df = parse_repo(repo, "v6.0")
514
+ assert df.iloc[0]["num_gpus"] == 1
515
+ # per-GPU throughput must be finite (not inf/nan)
516
+ assert df.iloc[0]["throughput_tok_per_sec_per_gpu"] > 0
517
+
518
+
519
+ # ---------------------------------------------------------------------------
520
+ # Security tests: _safe_read_text, symlink guards, _run_number
521
+ # ---------------------------------------------------------------------------
522
+
523
+ class TestSafeReadText:
524
+ def test_normal_file_read(self, tmp_path):
525
+ p = tmp_path / "ok.txt"
526
+ p.write_text("hello", encoding="utf-8")
527
+ assert _safe_read_text(p) == "hello"
528
+
529
+ def test_missing_file_returns_none(self, tmp_path):
530
+ assert _safe_read_text(tmp_path / "missing.txt") is None
531
+
532
+ def test_oversized_file_returns_none(self, tmp_path):
533
+ p = tmp_path / "huge.txt"
534
+ # Write a file just over the limit.
535
+ p.write_bytes(b"x" * (_MAX_FILE_BYTES + 1))
536
+ assert _safe_read_text(p) is None
537
+
538
+ def test_symlink_returns_none(self, tmp_path):
539
+ target = tmp_path / "real.txt"
540
+ target.write_text("secret", encoding="utf-8")
541
+ link = tmp_path / "link.txt"
542
+ link.symlink_to(target)
543
+ assert _safe_read_text(link) is None
544
+
545
+ def test_symlink_to_outside_returns_none(self, tmp_path):
546
+ # Simulates a git-repo symlink pointing outside the repo root.
547
+ link = tmp_path / "escape.txt"
548
+ link.symlink_to("/etc/hosts")
549
+ assert _safe_read_text(link) is None
550
+
551
+ def test_read_error_returns_none(self, tmp_path, monkeypatch):
552
+ """OSError from read_text() after lstat() succeeds returns None (lines 140-142)."""
553
+ p = tmp_path / "ok_to_stat.txt"
554
+ p.write_text("data", encoding="utf-8")
555
+
556
+ def _raise(*args, **kwargs):
557
+ raise OSError("simulated read failure")
558
+
559
+ monkeypatch.setattr(Path, "read_text", _raise)
560
+ assert _safe_read_text(p) is None
561
+
562
+
563
+ class TestRunNumber:
564
+ def test_extracts_integer(self, tmp_path):
565
+ p = tmp_path / "run_3" / "mlperf_log_summary.txt"
566
+ assert _run_number(p) == 3
567
+
568
+ def test_double_digit(self, tmp_path):
569
+ p = tmp_path / "run_12" / "mlperf_log_summary.txt"
570
+ assert _run_number(p) == 12
571
+
572
+ def test_no_match_returns_minus_one(self, tmp_path):
573
+ p = tmp_path / "audit" / "mlperf_log_summary.txt"
574
+ assert _run_number(p) == -1
575
+
576
+
577
+ class TestSymlinkGuards:
578
+ def test_symlinked_submitter_dir_skipped(self, tmp_path):
579
+ """A symlinked submitter directory must not be walked.
580
+
581
+ The target is a valid submission tree — without the guard, the parser
582
+ would follow the symlink and return rows. With it, df must be empty.
583
+ """
584
+ repo = tmp_path / "repo"
585
+ real_sub = tmp_path / "real_submitter"
586
+ # Build a complete, parseable submission inside real_sub.
587
+ run_dir = (
588
+ real_sub / "results" / "SomeSystem" / "llama2-70b"
589
+ / "Offline" / "performance" / "run_1"
590
+ )
591
+ run_dir.mkdir(parents=True)
592
+ (run_dir / "mlperf_log_summary.txt").write_text(
593
+ LOG_SUMMARY_OFFLINE, encoding="utf-8"
594
+ )
595
+ sys_dir = real_sub / "systems"
596
+ sys_dir.mkdir()
597
+ (sys_dir / "SomeSystem.json").write_text(
598
+ json.dumps(SYSTEM_JSON_NVIDIA), encoding="utf-8"
599
+ )
600
+ # Symlink real_sub into the repo's closed/ directory.
601
+ (repo / "closed").mkdir(parents=True)
602
+ (repo / "closed" / "EvilSub").symlink_to(real_sub)
603
+ df = parse_repo(repo, "v6.0")
604
+ assert df.empty
605
+
606
+ def test_symlinked_system_json_skipped(self, tmp_path):
607
+ """A symlinked system JSON must not be read."""
608
+ system_name = SYSTEM_JSON_NVIDIA["system_name"]
609
+ repo = tmp_path / "repo"
610
+ # Build the results tree normally.
611
+ run_dir = (
612
+ repo / "closed" / "Sub" / "results"
613
+ / system_name / "llama2-70b" / "Offline" / "performance" / "run_1"
614
+ )
615
+ run_dir.mkdir(parents=True)
616
+ (run_dir / "mlperf_log_summary.txt").write_text(
617
+ LOG_SUMMARY_OFFLINE, encoding="utf-8"
618
+ )
619
+ # Create a symlinked system JSON instead of a real one.
620
+ real_json = tmp_path / "real.json"
621
+ real_json.write_text(json.dumps(SYSTEM_JSON_NVIDIA), encoding="utf-8")
622
+ sys_dir = repo / "closed" / "Sub" / "systems"
623
+ sys_dir.mkdir(parents=True)
624
+ (sys_dir / f"{system_name}.json").symlink_to(real_json)
625
+ # Parser should produce a row (from the log) but with no hw fields —
626
+ # the **hw spread adds no keys when hw == {}, so gpu_name is absent.
627
+ df = parse_repo(repo, "v6.0")
628
+ assert len(df) == 1
629
+ assert "gpu_name" not in df.columns
630
+
631
+ def test_parse_log_summary_rejects_symlink(self, tmp_path):
632
+ """_parse_log_summary returns {} for a symlinked log file."""
633
+ real = tmp_path / "real.txt"
634
+ real.write_text(LOG_SUMMARY_OFFLINE, encoding="utf-8")
635
+ link = tmp_path / "link.txt"
636
+ link.symlink_to(real)
637
+ assert _parse_log_summary(link) == {}
638
+
639
+
640
+ # ---------------------------------------------------------------------------
641
+ # Unit tests: _extract_precision
642
+ # ---------------------------------------------------------------------------
643
+
644
+ class TestExtractPrecision:
645
+ @pytest.mark.parametrize("system_name,framework,expected", [
646
+ # FP8 in system name
647
+ ("AMD_MI300X_FP8_vLLM", "ROCm 6.2.4", "fp8"),
648
+ # FP16 in framework string
649
+ ("AMD_MI300X_vLLM", "ROCm 6.2.4 fp16", "fp16"),
650
+ # BF16
651
+ ("H100_bf16_TRT", "TRT-LLM", "bf16"),
652
+ # FP4 / MXFP4 — should win over FP8 when both present (priority order)
653
+ ("MI355X_MXFP4", "ROCm 7.2, fp8 fallback", "fp4"),
654
+ # INT8
655
+ ("H100_int8_TRT", None, "int8"),
656
+ # Nothing recognisable → None
657
+ ("H100_SXM5_80GBx8", "TensorRT-LLM v0.12.0", None),
658
+ # None framework arg handled gracefully
659
+ ("MI300X_FP16", None, "fp16"),
660
+ ])
661
+ def test_precision_extraction(self, system_name, framework, expected):
662
+ assert _extract_precision(system_name, framework) == expected
663
+
664
+
665
+ # ---------------------------------------------------------------------------
666
+ # Additional parse_repo behavior tests (post-fix)
667
+ # ---------------------------------------------------------------------------
668
+
669
+ class TestParseRepoPostFix:
670
+ def test_server_tput_uses_scheduled_not_completed(self, tmp_path):
671
+ """_RE_OFFLINE_TPUT must not match 'Completed samples per second'."""
672
+ log_with_diverged_completed = """\
673
+ ================================================
674
+ MLPerf Results Summary
675
+ ================================================
676
+ Scenario : Server
677
+ Mode : PerformanceOnly
678
+ Scheduled samples per second : 12.56
679
+ Result is : VALID
680
+ ================================================
681
+ Additional Stats
682
+ ================================================
683
+ Completed samples per second : 11.80
684
+ Mean latency (ns) : 4000000000
685
+ 99.00 percentile latency (ns) : 8900000000
686
+ """
687
+ system_name = SYSTEM_JSON_NVIDIA["system_name"]
688
+ repo = tmp_path / "repo"
689
+ run_dir = (
690
+ repo / "closed" / "Sub" / "results"
691
+ / system_name / "llama2-70b" / "Server" / "performance" / "run_1"
692
+ )
693
+ run_dir.mkdir(parents=True)
694
+ (run_dir / "mlperf_log_summary.txt").write_text(
695
+ log_with_diverged_completed, encoding="utf-8"
696
+ )
697
+ sys_dir = repo / "closed" / "Sub" / "systems"
698
+ sys_dir.mkdir(parents=True)
699
+ (sys_dir / f"{system_name}.json").write_text(
700
+ json.dumps(SYSTEM_JSON_NVIDIA), encoding="utf-8"
701
+ )
702
+ df = parse_repo(repo, "v6.0")
703
+ assert df.iloc[0]["throughput_samples_per_sec"] == pytest.approx(12.56)
704
+
705
+ def test_per_gpu_throughput_normalized(self, tmp_path):
706
+ """Both throughput columns are independently pinned to fixture constants.
707
+
708
+ Using row["throughput_tokens_per_sec"] / 8 as expected would only test
709
+ col_a / 8 ≈ col_b — it passes even if both columns are miscalculated by
710
+ the same factor. Pin to the known fixture value instead.
711
+ """
712
+ repo = _build_repo(
713
+ tmp_path, SYSTEM_JSON_NVIDIA, # 8 GPUs
714
+ benchmarks=["llama2-70b"],
715
+ scenarios=["Offline"],
716
+ )
717
+ df = parse_repo(repo, "v6.0")
718
+ row = df.iloc[0]
719
+ # 25.34 samples/sec (LOG_SUMMARY_OFFLINE) × 294 tokens/sample
720
+ per_node = 25.34 * TOKENS_PER_SAMPLE["llama2-70b"]
721
+ assert row["throughput_tokens_per_sec"] == pytest.approx(per_node)
722
+ assert row["throughput_tok_per_sec_per_gpu"] == pytest.approx(per_node / 8)
723
+
724
+ def test_precision_extracted_from_system_name(self, tmp_path):
725
+ fp8_json = {**SYSTEM_JSON_NVIDIA, "system_name": "H100_SXM5_80GBx8_FP8_TRT"}
726
+ repo = _build_repo(tmp_path, fp8_json, scenarios=["Offline"])
727
+ df = parse_repo(repo, "v6.0")
728
+ assert df.iloc[0]["precision"] == "fp8"
729
+
730
+ def test_benchmark_column_is_lowercase(self, tmp_path):
731
+ """benchmark must be lowercased even when the directory name is uppercase."""
732
+ # _build_repo default ("llama2-70b") is already lowercase — that fixture
733
+ # never exercises the .lower() call. Use an uppercase directory name here.
734
+ repo = _build_repo(
735
+ tmp_path, SYSTEM_JSON_NVIDIA,
736
+ benchmarks=["LLAMA2-70B"],
737
+ scenarios=["Offline"],
738
+ )
739
+ df = parse_repo(repo, "v6.0")
740
+ assert df.iloc[0]["benchmark"] == "llama2-70b"
741
+
742
+
743
+ # ---------------------------------------------------------------------------
744
+ # Coverage gap A: open division
745
+ # ---------------------------------------------------------------------------
746
+
747
+ class TestOpenDivision:
748
+ def test_open_division_rows_included(self, tmp_path):
749
+ """Submissions under open/ must be walked, not only closed/."""
750
+ repo = tmp_path / "repo"
751
+ system_name = SYSTEM_JSON_AMD["system_name"]
752
+ run_dir = (
753
+ repo / "open" / "OpenSub" / "results"
754
+ / system_name / "llama2-70b" / "Offline" / "performance" / "run_1"
755
+ )
756
+ run_dir.mkdir(parents=True)
757
+ (run_dir / "mlperf_log_summary.txt").write_text(
758
+ LOG_SUMMARY_OFFLINE, encoding="utf-8"
759
+ )
760
+ sys_dir = repo / "open" / "OpenSub" / "systems"
761
+ sys_dir.mkdir(parents=True)
762
+ (sys_dir / f"{system_name}.json").write_text(
763
+ json.dumps(SYSTEM_JSON_AMD), encoding="utf-8"
764
+ )
765
+ df = parse_repo(repo, "v6.0", divisions=("open",))
766
+ assert len(df) == 1
767
+ assert df.iloc[0]["division"] == "open"
768
+ assert df.iloc[0]["submitter"] == "OpenSub"
769
+
770
+ def test_both_divisions_combined(self, tmp_path):
771
+ """When both divisions are requested, rows from each are present."""
772
+ # closed submission
773
+ closed_repo = _build_repo(
774
+ tmp_path / "closed_build", SYSTEM_JSON_NVIDIA,
775
+ benchmarks=["llama2-70b"], scenarios=["Offline"],
776
+ )
777
+ # graft an open submission into the same repo root
778
+ open_sys = SYSTEM_JSON_AMD["system_name"]
779
+ run_dir = (
780
+ closed_repo / "open" / "OpenSub" / "results"
781
+ / open_sys / "llama2-70b" / "Offline" / "performance" / "run_1"
782
+ )
783
+ run_dir.mkdir(parents=True)
784
+ (run_dir / "mlperf_log_summary.txt").write_text(
785
+ LOG_SUMMARY_OFFLINE, encoding="utf-8"
786
+ )
787
+ sys_dir = closed_repo / "open" / "OpenSub" / "systems"
788
+ sys_dir.mkdir(parents=True)
789
+ (sys_dir / f"{open_sys}.json").write_text(
790
+ json.dumps(SYSTEM_JSON_AMD), encoding="utf-8"
791
+ )
792
+ df = parse_repo(closed_repo, "v6.0", divisions=("closed", "open"))
793
+ assert set(df["division"]) == {"closed", "open"}
794
+
795
+
796
+ # ---------------------------------------------------------------------------
797
+ # Coverage gap B: parse_repos (multi-round entry point)
798
+ # ---------------------------------------------------------------------------
799
+
800
+ class TestParseRepos:
801
+ def test_combines_two_rounds(self, tmp_path):
802
+ """Rows from two different rounds are concatenated."""
803
+ repo_a = _build_repo(
804
+ tmp_path / "a", SYSTEM_JSON_NVIDIA,
805
+ benchmarks=["llama2-70b"], scenarios=["Offline"],
806
+ )
807
+ repo_b = _build_repo(
808
+ tmp_path / "b", SYSTEM_JSON_AMD,
809
+ benchmarks=["gptj"], scenarios=["Offline"],
810
+ )
811
+ df = parse_repos([(repo_a, "v5.1"), (repo_b, "v6.0")])
812
+ assert set(df["round"]) == {"v5.1", "v6.0"}
813
+ assert len(df) == 2
814
+
815
+ def test_missing_round_silently_skipped(self, tmp_path):
816
+ """A path that doesn't exist must be skipped, not raise."""
817
+ repo = _build_repo(tmp_path, SYSTEM_JSON_NVIDIA, scenarios=["Offline"])
818
+ df = parse_repos([
819
+ (repo, "v6.0"),
820
+ (tmp_path / "does_not_exist", "v5.1"),
821
+ ])
822
+ assert len(df) == 1
823
+ assert df.iloc[0]["round"] == "v6.0"
824
+
825
+ def test_all_missing_returns_empty_df(self, tmp_path):
826
+ df = parse_repos([(tmp_path / "ghost_a", "v4.1"), (tmp_path / "ghost_b", "v5.0")])
827
+ assert df.empty
828
+
829
+ def test_deduplication_not_silently_applied(self, tmp_path):
830
+ """parse_repos must NOT silently drop duplicate (GPU, benchmark) pairs
831
+ that appear in multiple rounds — those are intentional cross-round rows."""
832
+ repo_a = _build_repo(
833
+ tmp_path / "a", SYSTEM_JSON_NVIDIA,
834
+ benchmarks=["llama2-70b"], scenarios=["Offline"],
835
+ )
836
+ repo_b = _build_repo(
837
+ tmp_path / "b", SYSTEM_JSON_NVIDIA,
838
+ benchmarks=["llama2-70b"], scenarios=["Offline"],
839
+ )
840
+ df = parse_repos([(repo_a, "v5.1"), (repo_b, "v6.0")])
841
+ # Same GPU + benchmark in two rounds → 2 rows (no silent dedup)
842
+ assert len(df) == 2
843
+ assert set(df["round"]) == {"v5.1", "v6.0"}
844
+
845
+
846
+ # ---------------------------------------------------------------------------
847
+ # Reference table sanity checks
848
+ # ---------------------------------------------------------------------------
849
+
850
+ class TestReferenceTables:
851
+ def test_all_llm_benchmarks_have_token_count(self):
852
+ for bm in LLM_BENCHMARKS:
853
+ assert bm in TOKENS_PER_SAMPLE, f"Missing TOKENS_PER_SAMPLE entry for {bm!r}"
854
+
855
+ def test_gptj_is_128(self):
856
+ assert TOKENS_PER_SAMPLE["gptj"] == 128
857
+
858
+ def test_accuracy_variants_match_base(self):
859
+ for bm in LLM_BENCHMARKS:
860
+ base = _base_benchmark(bm)
861
+ if base != bm:
862
+ assert TOKENS_PER_SAMPLE[bm] == TOKENS_PER_SAMPLE[base], (
863
+ f"{bm!r} and {base!r} should have same TOKENS_PER_SAMPLE"
864
+ )
865
+
866
+
867
+ # ---------------------------------------------------------------------------
868
+ # Coverage gap C: _find_best_run early-return paths
869
+ # ---------------------------------------------------------------------------
870
+
871
+ class TestFindBestRun:
872
+ def test_no_performance_dir_returns_none(self, tmp_path):
873
+ """scenario_dir with no performance/ subdirectory returns None (line 304)."""
874
+ scenario_dir = tmp_path / "Offline"
875
+ scenario_dir.mkdir()
876
+ assert _find_best_run(scenario_dir) is None
877
+
878
+ def test_no_valid_runs_returns_none(self, tmp_path):
879
+ """performance/ exists but is empty → no run_* files → returns None (line 316)."""
880
+ scenario_dir = tmp_path / "Offline"
881
+ (scenario_dir / "performance").mkdir(parents=True)
882
+ assert _find_best_run(scenario_dir) is None
883
+
884
+
885
+ # ---------------------------------------------------------------------------
886
+ # Coverage gap D: parse_repo continue-guards and debug-log branches
887
+ # ---------------------------------------------------------------------------
888
+
889
+ class TestParseRepoEdgeCases:
890
+ """Targets the five directory-guard continue statements and two debug branches."""
891
+
892
+ def test_no_system_json_row_still_produced(self, tmp_path):
893
+ """Missing systems/ dir → OSError branch fires (lines 369-371) → hw = {}.
894
+
895
+ The row is still produced but no hardware columns are present because
896
+ hw == {} contributes nothing to the row dict spread.
897
+ """
898
+ system_name = SYSTEM_JSON_NVIDIA["system_name"]
899
+ repo = tmp_path / "repo"
900
+ run_dir = (
901
+ repo / "closed" / "Sub" / "results"
902
+ / system_name / "llama2-70b" / "Offline" / "performance" / "run_1"
903
+ )
904
+ run_dir.mkdir(parents=True)
905
+ (run_dir / "mlperf_log_summary.txt").write_text(
906
+ LOG_SUMMARY_OFFLINE, encoding="utf-8"
907
+ )
908
+ # Intentionally NO systems/ directory so lstat raises FileNotFoundError
909
+ df = parse_repo(repo, "v6.0")
910
+ assert len(df) == 1
911
+ assert "gpu_name" not in df.columns # hw spread was empty — no hardware columns
912
+ # Performance data from the log must still be captured
913
+ assert df.iloc[0]["benchmark"] == "llama2-70b"
914
+ assert df.iloc[0]["throughput_tok_per_sec_per_gpu"] > 0
915
+
916
+ def test_results_dir_is_file_skipped(self, tmp_path):
917
+ """A file named 'results' (not a dir) triggers the is_dir() guard (line 357)."""
918
+ repo = tmp_path / "repo"
919
+ sub_dir = repo / "closed" / "Sub"
920
+ sub_dir.mkdir(parents=True)
921
+ (sub_dir / "results").write_text("not a directory", encoding="utf-8")
922
+ assert parse_repo(repo, "v6.0").empty
923
+
924
+ def test_system_dir_is_file_skipped(self, tmp_path):
925
+ """A file inside results/ triggers the system-dir is_dir() guard (line 361)."""
926
+ repo = tmp_path / "repo"
927
+ results_dir = repo / "closed" / "Sub" / "results"
928
+ results_dir.mkdir(parents=True)
929
+ (results_dir / "not_a_system.txt").write_text("file", encoding="utf-8")
930
+ assert parse_repo(repo, "v6.0").empty
931
+
932
+ def test_benchmark_dir_is_file_skipped(self, tmp_path):
933
+ """A file inside a system dir triggers the benchmark is_dir() guard (line 390).
934
+
935
+ The guard must be selective: the file is skipped while a valid benchmark
936
+ directory next to it is still processed. assert df.empty would not catch
937
+ an over-aggressive guard that skipped the entire system.
938
+ """
939
+ system_name = SYSTEM_JSON_NVIDIA["system_name"]
940
+ repo = tmp_path / "repo"
941
+ system_dir = repo / "closed" / "Sub" / "results" / system_name
942
+ system_dir.mkdir(parents=True)
943
+
944
+ # Invalid entry — must be skipped
945
+ (system_dir / "readme.txt").write_text("file", encoding="utf-8")
946
+
947
+ # Valid sibling — must still be processed
948
+ run_dir = system_dir / "llama2-70b" / "Offline" / "performance" / "run_1"
949
+ run_dir.mkdir(parents=True)
950
+ (run_dir / "mlperf_log_summary.txt").write_text(
951
+ LOG_SUMMARY_OFFLINE, encoding="utf-8"
952
+ )
953
+
954
+ sys_dir = repo / "closed" / "Sub" / "systems"
955
+ sys_dir.mkdir(parents=True)
956
+ (sys_dir / f"{system_name}.json").write_text(
957
+ json.dumps(SYSTEM_JSON_NVIDIA), encoding="utf-8"
958
+ )
959
+ df = parse_repo(repo, "v6.0")
960
+ assert len(df) == 1 # file skipped, valid dir kept
961
+ assert df.iloc[0]["benchmark"] == "llama2-70b"
962
+
963
+ def test_scenario_dir_is_file_skipped(self, tmp_path):
964
+ """A file inside a benchmark dir triggers the scenario is_dir() guard (line 401)."""
965
+ system_name = SYSTEM_JSON_NVIDIA["system_name"]
966
+ repo = tmp_path / "repo"
967
+ benchmark_dir = (
968
+ repo / "closed" / "Sub" / "results" / system_name / "llama2-70b"
969
+ )
970
+ benchmark_dir.mkdir(parents=True)
971
+ (benchmark_dir / "metadata.txt").write_text("file", encoding="utf-8")
972
+ sys_dir = repo / "closed" / "Sub" / "systems"
973
+ sys_dir.mkdir(parents=True)
974
+ (sys_dir / f"{system_name}.json").write_text(
975
+ json.dumps(SYSTEM_JSON_NVIDIA), encoding="utf-8"
976
+ )
977
+ assert parse_repo(repo, "v6.0").empty
978
+
979
+ def test_no_performance_run_row_skipped(self, tmp_path):
980
+ """Scenario dir with no performance/run_N tree produces no row (lines 406-407)."""
981
+ system_name = SYSTEM_JSON_NVIDIA["system_name"]
982
+ repo = tmp_path / "repo"
983
+ # Scenario dir exists but NO performance/ inside it
984
+ scenario_dir = (
985
+ repo / "closed" / "Sub" / "results"
986
+ / system_name / "llama2-70b" / "Offline"
987
+ )
988
+ scenario_dir.mkdir(parents=True)
989
+ sys_dir = repo / "closed" / "Sub" / "systems"
990
+ sys_dir.mkdir(parents=True)
991
+ (sys_dir / f"{system_name}.json").write_text(
992
+ json.dumps(SYSTEM_JSON_NVIDIA), encoding="utf-8"
993
+ )
994
+ assert parse_repo(repo, "v6.0").empty
995
+
996
+ def test_scenario_mismatch_debug_logged(self, tmp_path, caplog):
997
+ """Scenario in log != directory name emits a debug message (line 414)."""
998
+ system_name = SYSTEM_JSON_NVIDIA["system_name"]
999
+ repo = tmp_path / "repo"
1000
+ # Directory is "Offline" but the log file claims "Scenario : Server"
1001
+ mismatched = LOG_SUMMARY_OFFLINE.replace(
1002
+ "Scenario : Offline", "Scenario : Server"
1003
+ )
1004
+ run_dir = (
1005
+ repo / "closed" / "Sub" / "results"
1006
+ / system_name / "llama2-70b" / "Offline" / "performance" / "run_1"
1007
+ )
1008
+ run_dir.mkdir(parents=True)
1009
+ (run_dir / "mlperf_log_summary.txt").write_text(mismatched, encoding="utf-8")
1010
+ sys_dir = repo / "closed" / "Sub" / "systems"
1011
+ sys_dir.mkdir(parents=True)
1012
+ (sys_dir / f"{system_name}.json").write_text(
1013
+ json.dumps(SYSTEM_JSON_NVIDIA), encoding="utf-8"
1014
+ )
1015
+ with caplog.at_level(logging.DEBUG, logger="src.data.mlperf_parser"):
1016
+ df = parse_repo(repo, "v6.0")
1017
+ assert len(df) == 1 # row is still produced
1018
+ assert any("mismatch" in r.getMessage().lower() for r in caplog.records)
1019
+
1020
+
1021
+ # ---------------------------------------------------------------------------
1022
+ # Coverage gap E: CLI entry point — main() and _build_arg_parser()
1023
+ # ---------------------------------------------------------------------------
1024
+
1025
+ class TestMainCLI:
1026
+ """Integration tests for main() via monkeypatched sys.argv.
1027
+
1028
+ _build_repo(parent, ...) creates a repo at parent/inference_results_v6.0/,
1029
+ so --repos-dir parent --rounds inference_results_v6.0 is the correct shape.
1030
+ """
1031
+
1032
+ def test_no_round_dirs_exits_1(self, tmp_path, monkeypatch):
1033
+ """--repos-dir with no matching subdirectories → SystemExit(1)."""
1034
+ repos_dir = tmp_path / "repos"
1035
+ repos_dir.mkdir()
1036
+ monkeypatch.setattr(sys, "argv", [
1037
+ "mlperf_parser",
1038
+ "--repos-dir", str(repos_dir),
1039
+ "--rounds", "v6.0",
1040
+ ])
1041
+ with pytest.raises(SystemExit) as exc:
1042
+ main()
1043
+ assert exc.value.code == 1
1044
+
1045
+ def test_empty_round_exits_1(self, tmp_path, monkeypatch):
1046
+ """Round dir exists but is empty → parse_repos returns empty df → SystemExit(1)."""
1047
+ repos_dir = tmp_path / "repos"
1048
+ (repos_dir / "v6.0").mkdir(parents=True)
1049
+ monkeypatch.setattr(sys, "argv", [
1050
+ "mlperf_parser",
1051
+ "--repos-dir", str(repos_dir),
1052
+ "--rounds", "v6.0",
1053
+ ])
1054
+ with pytest.raises(SystemExit) as exc:
1055
+ main()
1056
+ assert exc.value.code == 1
1057
+
1058
+ def test_normal_run_writes_csv(self, tmp_path, monkeypatch, capsys):
1059
+ """Normal invocation writes a CSV and prints a row-count summary."""
1060
+ repos_dir = tmp_path / "repos"
1061
+ _build_repo(repos_dir, SYSTEM_JSON_NVIDIA, scenarios=["Offline"])
1062
+ out_csv = tmp_path / "out.csv"
1063
+ monkeypatch.setattr(sys, "argv", [
1064
+ "mlperf_parser",
1065
+ "--repos-dir", str(repos_dir),
1066
+ "--rounds", "inference_results_v6.0",
1067
+ "--output", str(out_csv),
1068
+ ])
1069
+ main()
1070
+ assert out_csv.exists()
1071
+ result = pd.read_csv(out_csv)
1072
+ assert len(result) == 1
1073
+ assert result.iloc[0]["benchmark"] == "llama2-70b" # not just any row
1074
+ assert result.iloc[0]["throughput_samples_per_sec"] == pytest.approx(25.34)
1075
+ assert "Total rows" in capsys.readouterr().out
1076
+
1077
+ def test_parquet_flag_writes_parquet(self, tmp_path, monkeypatch):
1078
+ """--parquet produces a .parquet file alongside the CSV."""
1079
+ repos_dir = tmp_path / "repos"
1080
+ _build_repo(repos_dir, SYSTEM_JSON_NVIDIA, scenarios=["Offline"])
1081
+ out_csv = tmp_path / "out.csv"
1082
+ monkeypatch.setattr(sys, "argv", [
1083
+ "mlperf_parser",
1084
+ "--repos-dir", str(repos_dir),
1085
+ "--rounds", "inference_results_v6.0",
1086
+ "--output", str(out_csv),
1087
+ "--parquet",
1088
+ ])
1089
+ main()
1090
+ pq_path = out_csv.with_suffix(".parquet")
1091
+ assert pq_path.exists()
1092
+ assert len(pd.read_parquet(pq_path)) == 1 # file is readable, not just present
1093
+
1094
+ def test_all_benchmarks_flag_includes_non_llm(self, tmp_path, monkeypatch):
1095
+ """--all-benchmarks passes llm_only=False, including non-LLM benchmarks."""
1096
+ repos_dir = tmp_path / "repos"
1097
+ _build_repo(
1098
+ repos_dir, SYSTEM_JSON_NVIDIA,
1099
+ benchmarks=["resnet50"],
1100
+ scenarios=["Offline"],
1101
+ )
1102
+ out_csv = tmp_path / "out.csv"
1103
+ monkeypatch.setattr(sys, "argv", [
1104
+ "mlperf_parser",
1105
+ "--repos-dir", str(repos_dir),
1106
+ "--rounds", "inference_results_v6.0",
1107
+ "--output", str(out_csv),
1108
+ "--all-benchmarks",
1109
+ ])
1110
+ main()
1111
+ assert "resnet50" in pd.read_csv(out_csv)["benchmark"].values
tests/test_predictor.py ADDED
@@ -0,0 +1,442 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit and integration tests for src/models/predictor.py.
3
+
4
+ All tests load the real trained model (data/models/prophet_v1.json) so they
5
+ verify the full inference path, not just the feature-construction logic.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import math
12
+
13
+ import pytest
14
+
15
+ from src.models.predictor import (
16
+ FEATURE_COLS,
17
+ GpuPredictor,
18
+ _build_feature_vector,
19
+ _SERVING_ROUND,
20
+ )
21
+ from src.data.gpu_spec_db import load_specs
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Fixtures
26
+ # ---------------------------------------------------------------------------
27
+
28
+ @pytest.fixture(scope="module")
29
+ def predictor() -> GpuPredictor:
30
+ return GpuPredictor()
31
+
32
+
33
+ @pytest.fixture(scope="module")
34
+ def spec_map() -> dict:
35
+ return {s["id"]: s for s in load_specs()}
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # _build_feature_vector — pure function tests (no model needed)
40
+ # ---------------------------------------------------------------------------
41
+
42
+ class TestBuildFeatureVector:
43
+ def test_amd_nvidia_arch_ordinals_mutually_exclusive(self, spec_map):
44
+ feat_amd, _, _ = _build_feature_vector(
45
+ gpu_spec=spec_map["mi300x"],
46
+ model_name="gptj",
47
+ scenario="Server",
48
+ accuracy_tier="base",
49
+ framework="rocm_other",
50
+ )
51
+ feat_nv, _, _ = _build_feature_vector(
52
+ gpu_spec=spec_map["h100_sxm"],
53
+ model_name="gptj",
54
+ scenario="Server",
55
+ accuracy_tier="base",
56
+ framework="tensorrt",
57
+ )
58
+ nvidia_idx = FEATURE_COLS.index("nvidia_arch_gen")
59
+ amd_idx = FEATURE_COLS.index("amd_arch_gen")
60
+
61
+ # AMD GPU: nvidia_arch_gen NaN, amd_arch_gen not NaN
62
+ assert math.isnan(feat_amd[nvidia_idx])
63
+ assert not math.isnan(feat_amd[amd_idx])
64
+
65
+ # NVIDIA GPU: amd_arch_gen NaN, nvidia_arch_gen not NaN
66
+ assert math.isnan(feat_nv[amd_idx])
67
+ assert not math.isnan(feat_nv[nvidia_idx])
68
+
69
+ def test_scenario_offline_flag(self, spec_map):
70
+ feat_off, _, _ = _build_feature_vector(
71
+ gpu_spec=spec_map["h200_sxm"],
72
+ model_name="llama2-70b",
73
+ scenario="Offline",
74
+ accuracy_tier="99",
75
+ framework="vllm",
76
+ )
77
+ feat_srv, _, _ = _build_feature_vector(
78
+ gpu_spec=spec_map["h200_sxm"],
79
+ model_name="llama2-70b",
80
+ scenario="Server",
81
+ accuracy_tier="99",
82
+ framework="vllm",
83
+ )
84
+ off_idx = FEATURE_COLS.index("scenario_offline")
85
+ assert feat_off[off_idx] == 1
86
+ assert feat_srv[off_idx] == 0
87
+
88
+ def test_is_cdna4_flag(self, spec_map):
89
+ feat_cdna4, _, _ = _build_feature_vector(
90
+ gpu_spec=spec_map["mi355x"],
91
+ model_name="llama2-70b",
92
+ scenario="Offline",
93
+ accuracy_tier="99.9",
94
+ framework="rocm_other",
95
+ )
96
+ feat_cdna3, _, _ = _build_feature_vector(
97
+ gpu_spec=spec_map["mi300x"],
98
+ model_name="llama2-70b",
99
+ scenario="Offline",
100
+ accuracy_tier="99.9",
101
+ framework="rocm_other",
102
+ )
103
+ cdna4_idx = FEATURE_COLS.index("is_cdna4")
104
+ assert feat_cdna4[cdna4_idx] == 1
105
+ assert feat_cdna3[cdna4_idx] == 0
106
+
107
+ def test_bytes_per_param_precision_selection(self, spec_map):
108
+ bpp_idx = FEATURE_COLS.index("bytes_per_param")
109
+ # AMD tier 99 → fp8 → 1.0 byte/param
110
+ feat_amd_99, _, _ = _build_feature_vector(
111
+ gpu_spec=spec_map["mi300x"],
112
+ model_name="llama2-70b",
113
+ scenario="Offline",
114
+ accuracy_tier="99",
115
+ framework="vllm",
116
+ )
117
+ assert feat_amd_99[bpp_idx] == pytest.approx(1.0)
118
+ # AMD tier 99.9 → fp8 override → 1.0 byte/param (not 2.0)
119
+ feat_amd_99_9, _, _ = _build_feature_vector(
120
+ gpu_spec=spec_map["mi300x"],
121
+ model_name="llama2-70b",
122
+ scenario="Offline",
123
+ accuracy_tier="99.9",
124
+ framework="vllm",
125
+ )
126
+ assert feat_amd_99_9[bpp_idx] == pytest.approx(1.0)
127
+ # NVIDIA tier 99.9 → fp16 → 2.0 bytes/param (no override)
128
+ feat_nv_99_9, _, _ = _build_feature_vector(
129
+ gpu_spec=spec_map["h100_sxm"],
130
+ model_name="llama2-70b",
131
+ scenario="Offline",
132
+ accuracy_tier="99.9",
133
+ framework="tensorrt",
134
+ )
135
+ assert feat_nv_99_9[bpp_idx] == pytest.approx(2.0)
136
+
137
+ def test_fw_other_all_dummies_zero(self, spec_map):
138
+ # framework="other" must set all three fw_* flags to 0.
139
+ # Previously untested — a bug that accidentally set one flag to 1 would
140
+ # be invisible to the model (wrong feature for unknown frameworks).
141
+ features, _, _ = _build_feature_vector(
142
+ gpu_spec=spec_map["mi300x"],
143
+ model_name="gptj",
144
+ scenario="Offline",
145
+ accuracy_tier="99",
146
+ framework="other",
147
+ )
148
+ for fw_col in ("fw_tensorrt", "fw_vllm", "fw_rocm_other"):
149
+ assert features[FEATURE_COLS.index(fw_col)] == 0, (
150
+ f"{fw_col} should be 0 for framework='other'"
151
+ )
152
+
153
+ def test_is_base_tier_flag(self, spec_map):
154
+ base_idx = FEATURE_COLS.index("is_base_tier")
155
+ feat_base, _, _ = _build_feature_vector(
156
+ gpu_spec=spec_map["mi300x"],
157
+ model_name="gptj",
158
+ scenario="Offline",
159
+ accuracy_tier="base",
160
+ framework="rocm_other",
161
+ )
162
+ feat_99, _, _ = _build_feature_vector(
163
+ gpu_spec=spec_map["mi300x"],
164
+ model_name="gptj",
165
+ scenario="Offline",
166
+ accuracy_tier="99",
167
+ framework="rocm_other",
168
+ )
169
+ feat_99_9, _, _ = _build_feature_vector(
170
+ gpu_spec=spec_map["h100_sxm"],
171
+ model_name="gptj",
172
+ scenario="Offline",
173
+ accuracy_tier="99.9",
174
+ framework="tensorrt",
175
+ )
176
+ assert feat_base[base_idx] == 1
177
+ assert feat_99[base_idx] == 0
178
+ assert feat_99_9[base_idx] == 0
179
+
180
+ def test_mlperf_round_num_serving_uses_latest(self, spec_map):
181
+ from src.features.build_features import ROUND_ORDINAL
182
+ # _SERVING_ROUND must be the maximum defined ordinal — any future round
183
+ # added to ROUND_ORDINAL automatically raises this floor.
184
+ assert _SERVING_ROUND == float(max(ROUND_ORDINAL.values()))
185
+ # The feature vector must include it at the correct position.
186
+ features, _, _ = _build_feature_vector(
187
+ gpu_spec=spec_map["mi300x"],
188
+ model_name="llama2-70b",
189
+ scenario="Offline",
190
+ accuracy_tier="99",
191
+ framework="vllm",
192
+ )
193
+ assert features[FEATURE_COLS.index("mlperf_round_num")] == _SERVING_ROUND
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # GpuPredictor.predict — full inference path
198
+ # ---------------------------------------------------------------------------
199
+
200
+ class TestGpuPredictorPredict:
201
+ def test_predict_returns_expected_keys(self, predictor):
202
+ result = predictor.predict(
203
+ gpu_id="mi300x",
204
+ model_name="llama2-70b",
205
+ )
206
+ expected = {
207
+ "gpu_id", "model_name", "scenario", "accuracy_tier", "framework",
208
+ "pred_throughput_tok_per_sec", "roofline_tput_tok_per_sec",
209
+ "efficiency_ratio", "vram_fits", "model_size_gb",
210
+ }
211
+ assert set(result.keys()) == expected
212
+
213
+ def test_predict_throughput_positive(self, predictor):
214
+ result = predictor.predict(
215
+ gpu_id="h100_sxm",
216
+ model_name="llama2-70b",
217
+ scenario="Offline",
218
+ accuracy_tier="99",
219
+ framework="tensorrt",
220
+ )
221
+ # Must reach at least 1% of the roofline — a degenerate model predicting
222
+ # ~0.001 tok/s would pass a bare > 0 check but not this threshold.
223
+ assert result["pred_throughput_tok_per_sec"] >= 0.01 * result["roofline_tput_tok_per_sec"]
224
+
225
+ def test_predict_never_exceeds_roofline(self, predictor):
226
+ for gpu_id in ["mi300x", "h100_sxm", "h200_sxm", "mi325x", "mi355x"]:
227
+ result = predictor.predict(
228
+ gpu_id=gpu_id,
229
+ model_name="llama2-70b",
230
+ accuracy_tier="99",
231
+ )
232
+ assert (
233
+ result["pred_throughput_tok_per_sec"]
234
+ <= result["roofline_tput_tok_per_sec"] + 1e-3
235
+ )
236
+
237
+ def test_predict_vram_fits_llama70b_on_mi300x(self, predictor):
238
+ result = predictor.predict(
239
+ gpu_id="mi300x",
240
+ model_name="llama2-70b",
241
+ accuracy_tier="99",
242
+ )
243
+ # llama2-70b at FP8 = 70 GB, MI300X has 192 GB → fits
244
+ assert result["vram_fits"] is True
245
+
246
+ def test_predict_vram_not_fit_405b_on_l4(self, predictor):
247
+ result = predictor.predict(
248
+ gpu_id="l4",
249
+ model_name="llama3.1-405b",
250
+ accuracy_tier="99",
251
+ )
252
+ # 405B FP8 = 405 GB >> 24 GB L4 VRAM
253
+ assert result["vram_fits"] is False
254
+
255
+ def test_unknown_gpu_raises(self, predictor):
256
+ with pytest.raises(ValueError, match="Unknown gpu_id"):
257
+ predictor.predict(gpu_id="rtx9090", model_name="gptj")
258
+
259
+ def test_unknown_model_raises(self, predictor):
260
+ with pytest.raises(ValueError, match="Unknown model_name"):
261
+ predictor.predict(gpu_id="mi300x", model_name="gpt4")
262
+
263
+ def test_invalid_scenario_raises(self, predictor):
264
+ with pytest.raises(ValueError, match="Invalid scenario"):
265
+ predictor.predict(gpu_id="mi300x", model_name="gptj", scenario="Interactive")
266
+
267
+ def test_invalid_tier_raises(self, predictor):
268
+ with pytest.raises(ValueError, match="Invalid accuracy_tier"):
269
+ predictor.predict(gpu_id="mi300x", model_name="gptj", accuracy_tier="99.5")
270
+
271
+ def test_invalid_framework_raises(self, predictor):
272
+ with pytest.raises(ValueError, match="Invalid framework"):
273
+ predictor.predict(gpu_id="mi300x", model_name="gptj", framework="pytorch")
274
+
275
+ def test_model_size_gb_correct_fp8(self, predictor):
276
+ result = predictor.predict(
277
+ gpu_id="mi300x",
278
+ model_name="llama2-70b",
279
+ accuracy_tier="99",
280
+ )
281
+ # 70B params × 1 byte/param (FP8) = 70 GB
282
+ assert result["model_size_gb"] == pytest.approx(70.0)
283
+
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # GpuPredictor.predict_batch
287
+ # ---------------------------------------------------------------------------
288
+
289
+ class TestGpuPredictorBatch:
290
+ def test_batch_preserves_order_and_identity(self, predictor):
291
+ reqs = [
292
+ {"gpu_id": "mi300x", "model_name": "llama2-70b"},
293
+ {"gpu_id": "h100_sxm", "model_name": "gptj"},
294
+ {"gpu_id": "h200_sxm", "model_name": "mixtral-8x7b"},
295
+ ]
296
+ results = predictor.predict_batch(reqs)
297
+ assert len(results) == 3
298
+ # Count alone doesn't catch a bug that returns the mi300x result 3 times.
299
+ for req, res in zip(reqs, results):
300
+ assert res["gpu_id"] == req["gpu_id"]
301
+ assert res["model_name"] == req["model_name"]
302
+
303
+ def test_batch_empty_returns_empty(self, predictor):
304
+ assert predictor.predict_batch([]) == []
305
+
306
+ @pytest.mark.parametrize("req", [
307
+ # tier 99 (fp8) — baseline case
308
+ {"gpu_id": "mi325x", "model_name": "llama2-70b",
309
+ "scenario": "Server", "accuracy_tier": "99", "framework": "rocm_other"},
310
+ # AMD tier 99.9 — fp8 override path; a batch/single divergence here would
311
+ # produce the wrong model_size_gb (70 vs 140 GB) and thus wrong vram_fits.
312
+ {"gpu_id": "mi300x", "model_name": "llama2-70b",
313
+ "scenario": "Offline", "accuracy_tier": "99.9", "framework": "vllm"},
314
+ ])
315
+ def test_batch_results_match_single(self, predictor, req):
316
+ single = predictor.predict(**{k: v for k, v in req.items()})
317
+ batch = predictor.predict_batch([req])
318
+ # Compare every numeric output — the batch path accumulates metadata in a
319
+ # separate dict; a divergence (e.g. vram_fits always True) was invisible
320
+ # when only pred_throughput_tok_per_sec was checked.
321
+ for key in ("pred_throughput_tok_per_sec", "roofline_tput_tok_per_sec",
322
+ "efficiency_ratio", "model_size_gb"):
323
+ assert batch[0][key] == pytest.approx(single[key], rel=1e-4), (
324
+ f"{key}: batch={batch[0][key]!r} single={single[key]!r}"
325
+ )
326
+ assert batch[0]["vram_fits"] == single["vram_fits"]
327
+
328
+ def test_batch_all_in_scope_gpus(self, predictor):
329
+ from src.data.gpu_spec_db import load_specs
330
+ in_scope = [s["id"] for s in load_specs() if s.get("in_model_scope")]
331
+ reqs = [{"gpu_id": g, "model_name": "llama2-70b"} for g in in_scope]
332
+ results = predictor.predict_batch(reqs)
333
+ assert len(results) == len(in_scope)
334
+ for r in results:
335
+ assert r["pred_throughput_tok_per_sec"] >= 0.01 * r["roofline_tput_tok_per_sec"]
336
+
337
+
338
+ # ---------------------------------------------------------------------------
339
+ # Cross-check: _encode() (training) vs _build_feature_vector() (serving)
340
+ # ---------------------------------------------------------------------------
341
+
342
+ class TestEncodeVsPredictor:
343
+ """Verify that train_final._encode() and predictor._build_feature_vector()
344
+ produce identical categorical encodings for all shared columns.
345
+
346
+ If these two implementations drift the model is trained on different features
347
+ than serving produces — a silent, test-invisible bug without this class.
348
+ """
349
+
350
+ @pytest.mark.parametrize("scenario,tier,framework,gpu_id", [
351
+ ("Offline", "99", "tensorrt", "h100_sxm"),
352
+ ("Server", "99.9", "vllm", "mi300x"),
353
+ ("Offline", "base", "rocm_other", "mi355x"),
354
+ ("Server", "99", "other", "h200_sxm"),
355
+ ("Offline", "99", "vllm", "mi325x"),
356
+ ])
357
+ def test_categorical_encodings_match(self, spec_map, scenario, tier, framework, gpu_id):
358
+ import math
359
+ import pandas as pd
360
+ from src.models.train_final import _encode
361
+
362
+ # Get ground-truth feature values from the serving path.
363
+ feat, _, _ = _build_feature_vector(
364
+ gpu_spec=spec_map[gpu_id],
365
+ model_name="gptj",
366
+ scenario=scenario,
367
+ accuracy_tier=tier,
368
+ framework=framework,
369
+ )
370
+ amd_arch_gen_raw = feat[FEATURE_COLS.index("amd_arch_gen")]
371
+
372
+ # Build a row that mimics what build_training_df produces before _encode().
373
+ # is_base_tier is computed in build_training_df (not _encode), so inject it directly.
374
+ row = pd.DataFrame([{
375
+ "scenario": scenario,
376
+ "benchmark_accuracy_tier": tier,
377
+ "framework_family": framework,
378
+ "amd_arch_gen": None if math.isnan(amd_arch_gen_raw) else amd_arch_gen_raw,
379
+ "is_base_tier": int(tier == "base"),
380
+ }])
381
+ enc = _encode(row).iloc[0]
382
+
383
+ # Compare every column that _encode() or build_training_df writes and
384
+ # _build_feature_vector() computes.
385
+ for col in ("scenario_offline", "is_base_tier",
386
+ "fw_tensorrt", "fw_vllm", "fw_rocm_other", "is_cdna4"):
387
+ feat_val = feat[FEATURE_COLS.index(col)]
388
+ enc_val = enc[col]
389
+ assert feat_val == enc_val, (
390
+ f"{col} mismatch for gpu={gpu_id} scenario={scenario!r} "
391
+ f"tier={tier!r} fw={framework!r}: "
392
+ f"serving={feat_val}, training={enc_val}"
393
+ )
394
+
395
+
396
+ # ---------------------------------------------------------------------------
397
+ # File-guard security tests — GpuPredictor.__init__
398
+ # ---------------------------------------------------------------------------
399
+
400
+ class TestGpuPredictorFileGuards:
401
+ """Security tests for the file guards in GpuPredictor.__init__.
402
+
403
+ These tests verify three properties:
404
+ 1. Feature-cols mismatch raises ValueError — not AssertionError — so the
405
+ check is never compiled away under `python -O` / PYTHONOPTIMIZE=1.
406
+ 2. Symlinked artifact files are refused (path-traversal defence).
407
+ 3. Oversized metadata is refused (memory-exhaustion defence).
408
+ """
409
+
410
+ def test_feature_cols_mismatch_raises_valueerror(self, tmp_path):
411
+ # Both files must pass the file guards (non-symlink, non-oversized)
412
+ # before the feature-cols check fires. prophet_v1.json is never
413
+ # loaded in this test path — the check raises before xgb.load_model.
414
+ (tmp_path / "feature_metadata.json").write_text(
415
+ json.dumps({"feature_cols": ["wrong", "cols"], "target": "efficiency_ratio"}),
416
+ encoding="utf-8",
417
+ )
418
+ (tmp_path / "prophet_v1.json").write_text("{}", encoding="utf-8")
419
+ with pytest.raises(ValueError, match="feature_cols mismatch"):
420
+ GpuPredictor(model_dir=tmp_path)
421
+
422
+ def test_symlink_meta_raises_valueerror(self, tmp_path):
423
+ link = tmp_path / "feature_metadata.json"
424
+ link.symlink_to(tmp_path / "nonexistent_target")
425
+ with pytest.raises(ValueError, match="symlink"):
426
+ GpuPredictor(model_dir=tmp_path)
427
+
428
+ def test_symlink_model_raises_valueerror(self, tmp_path):
429
+ # meta must pass guards so the loop reaches model_path.
430
+ (tmp_path / "feature_metadata.json").write_text(
431
+ json.dumps({"feature_cols": [], "target": ""}), encoding="utf-8"
432
+ )
433
+ (tmp_path / "prophet_v1.json").symlink_to(tmp_path / "nonexistent_target")
434
+ with pytest.raises(ValueError, match="symlink"):
435
+ GpuPredictor(model_dir=tmp_path)
436
+
437
+ def test_oversized_meta_raises_valueerror(self, tmp_path):
438
+ from src.models.predictor import _MAX_META_BYTES
439
+ (tmp_path / "feature_metadata.json").write_bytes(b"x" * (_MAX_META_BYTES + 1))
440
+ (tmp_path / "prophet_v1.json").write_text("{}", encoding="utf-8")
441
+ with pytest.raises(ValueError, match="too large"):
442
+ GpuPredictor(model_dir=tmp_path)
tests/test_recommender.py ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for src/recommend/recommender.py.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import pytest
8
+
9
+ from src.models.predictor import GpuPredictor
10
+ from src.recommend.recommender import GpuRecommender, _pareto_frontier
11
+
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # Fixtures
15
+ # ---------------------------------------------------------------------------
16
+
17
+ @pytest.fixture(scope="module")
18
+ def recommender() -> GpuRecommender:
19
+ pred = GpuPredictor()
20
+ return GpuRecommender(pred)
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # _pareto_frontier (pure function)
25
+ # ---------------------------------------------------------------------------
26
+
27
+ class TestParetoFrontier:
28
+ def _c(self, throughput, cost_efficiency, vram_headroom) -> dict:
29
+ return {
30
+ "throughput": throughput,
31
+ "cost_efficiency": cost_efficiency,
32
+ "vram_headroom": vram_headroom,
33
+ "gpu_id": "test",
34
+ }
35
+
36
+ def test_all_on_frontier_when_no_domination(self):
37
+ # A: best throughput; B: best cost; C: best vram — no one dominates
38
+ candidates = [
39
+ self._c(1000, 100, 0.3), # A
40
+ self._c(500, 200, 0.2), # B
41
+ self._c(600, 150, 0.8), # C
42
+ ]
43
+ frontier, dominated = _pareto_frontier(candidates)
44
+ assert len(frontier) == 3
45
+ assert len(dominated) == 0
46
+
47
+ def test_dominated_candidate_excluded_from_frontier(self):
48
+ # D is strictly worse than A on all objectives → dominated
49
+ candidates = [
50
+ self._c(1000, 200, 0.8), # A — best on all
51
+ self._c(500, 100, 0.3), # D — dominated by A
52
+ ]
53
+ frontier, dominated = _pareto_frontier(candidates)
54
+ assert len(frontier) == 1
55
+ assert frontier[0]["throughput"] == 1000
56
+ assert len(dominated) == 1
57
+
58
+ def test_frontier_sorted_by_cost_efficiency_desc(self):
59
+ candidates = [
60
+ self._c(800, 100, 0.5),
61
+ self._c(900, 300, 0.4),
62
+ self._c(700, 200, 0.6),
63
+ ]
64
+ frontier, _ = _pareto_frontier(candidates)
65
+ efficiencies = [c["cost_efficiency"] for c in frontier]
66
+ assert efficiencies == sorted(efficiencies, reverse=True)
67
+
68
+ def test_empty_input(self):
69
+ frontier, dominated = _pareto_frontier([])
70
+ assert frontier == []
71
+ assert dominated == []
72
+
73
+ def test_single_candidate_on_frontier(self):
74
+ frontier, dominated = _pareto_frontier([self._c(500, 100, 0.5)])
75
+ assert len(frontier) == 1
76
+ assert len(dominated) == 0
77
+
78
+ def test_none_cost_efficiency_sorts_last(self):
79
+ # A GPU with cost_efficiency=None (unpriced) must sort last on the
80
+ # frontier — and must not crash the Pareto comparison with TypeError.
81
+ # Choose values where neither candidate dominates the other:
82
+ # first has higher cost_efficiency, second has higher throughput & vram.
83
+ candidates = [
84
+ self._c(500, 200, 0.4), # better cost_efficiency
85
+ self._c(800, None, 0.8), # better throughput & vram; unpriced
86
+ ]
87
+ frontier, dominated = _pareto_frontier(candidates)
88
+ assert len(frontier) == 2
89
+ assert len(dominated) == 0
90
+ assert frontier[-1]["cost_efficiency"] is None # None sorts last
91
+
92
+ def test_none_cost_efficiency_dominated_by_priced(self):
93
+ # An unpriced GPU (cost_efficiency=None, treated as -inf) is dominated
94
+ # by any GPU that beats it on the remaining two objectives.
95
+ candidates = [
96
+ self._c(1000, 200, 0.8), # beats unpriced on all objectives
97
+ self._c(500, None, 0.3), # unpriced; worse on throughput & vram too
98
+ ]
99
+ frontier, dominated = _pareto_frontier(candidates)
100
+ assert len(frontier) == 1
101
+ assert frontier[0]["cost_efficiency"] == 200
102
+ assert len(dominated) == 1
103
+ assert dominated[0]["cost_efficiency"] is None
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # GpuRecommender.recommend
108
+ # ---------------------------------------------------------------------------
109
+
110
+ class TestGpuRecommender:
111
+ def test_workload_echoed(self, recommender):
112
+ result = recommender.recommend(
113
+ model_name="gptj",
114
+ scenario="Server",
115
+ accuracy_tier="base",
116
+ framework="tensorrt",
117
+ )
118
+ wl = result["workload"]
119
+ assert wl["model_name"] == "gptj"
120
+ assert wl["scenario"] == "Server"
121
+ assert wl["accuracy_tier"] == "base"
122
+ assert wl["framework"] == "tensorrt"
123
+
124
+ def test_model_size_in_workload(self, recommender):
125
+ result = recommender.recommend(model_name="llama2-70b", accuracy_tier="99")
126
+ # 70B × 1 byte (FP8) = 70 GB
127
+ assert result["workload"]["model_size_gb"] == pytest.approx(70.0)
128
+
129
+ def test_vram_filter_splits_correctly(self, recommender):
130
+ # llama2-70b at tier 99.9: FP16 for NVIDIA (140 GB), FP8 for AMD (70 GB).
131
+ # The VRAM filter is vendor-aware, so AMD GPUs use 70 GB for the check.
132
+ # In-scope GPUs that fit:
133
+ # MI355X(288), MI325X(256), MI300X(192) — AMD, 70 GB check
134
+ # H200 SXM(141) — NVIDIA, 140 GB check (barely fits)
135
+ # In-scope GPUs that don't fit:
136
+ # H100 SXM(80), A100 SXM 80GB(80), L4(24), RTX4090(24) — NVIDIA, 140 GB
137
+ result = recommender.recommend(model_name="llama2-70b", accuracy_tier="99.9")
138
+ candidate_ids = {r["gpu_id"] for r in result["frontier"] + result["dominated"]}
139
+ vram_filtered_ids = {
140
+ f["gpu_id"] for f in result["filtered"]
141
+ if "too large" in f.get("reject_reason", "")
142
+ }
143
+ # GPUs that must appear in candidates (they fit the 140 GB model)
144
+ for gid in ("mi355x", "mi325x", "mi300x", "h200_sxm"):
145
+ assert gid in candidate_ids, f"{gid} should fit 140 GB but is absent from candidates"
146
+ # GPUs that must be VRAM-filtered (they don't fit 140 GB FP16 model)
147
+ for gid in ("h100_sxm", "a100_sxm_80gb", "l4", "rtx4090"):
148
+ assert gid in vram_filtered_ids, f"{gid} should be VRAM-filtered but is missing"
149
+ # The two sets must be disjoint — no GPU can be both candidate and filtered
150
+ assert candidate_ids.isdisjoint(vram_filtered_ids)
151
+
152
+ def test_405b_fp16_all_filtered(self, recommender):
153
+ # 405B × 2 bytes = 810 GB; largest GPU is MI355X at 288 GB — none fit
154
+ result = recommender.recommend(
155
+ model_name="llama3.1-405b",
156
+ accuracy_tier="99.9",
157
+ )
158
+ assert len(result["frontier"]) == 0
159
+ assert len(result["dominated"]) == 0
160
+ assert len(result["filtered"]) > 0
161
+ for f in result["filtered"]:
162
+ assert "too large" in f["reject_reason"]
163
+
164
+ def test_budget_filter_respected(self, recommender):
165
+ # Budget $1.50/hr — should filter out expensive GPUs.
166
+ # All in-scope GPUs have pricing (validated at startup), so
167
+ # price_per_gpu_hr is never None — the is-None arm was dead code.
168
+ result = recommender.recommend(
169
+ model_name="gptj",
170
+ accuracy_tier="99",
171
+ budget_per_gpu_hr=1.50,
172
+ )
173
+ candidates = result["frontier"] + result["dominated"]
174
+ # RTX4090 ($0.39) and L4 ($0.44) are both priced under $1.50 and fit
175
+ # gptj (6 GB). Without this guard the loop below fires zero assertions
176
+ # if pricing changes so all GPUs exceed the budget.
177
+ assert len(candidates) > 0, (
178
+ "Expected at least one GPU within $1.50/hr — RTX4090 ($0.39) and "
179
+ "L4 ($0.44) are currently below this threshold"
180
+ )
181
+ for r in candidates:
182
+ assert r["price_per_gpu_hr"] <= 1.50
183
+
184
+ def test_no_dominated_option_strictly_worse_on_all(self, recommender):
185
+ result = recommender.recommend(model_name="llama2-70b", accuracy_tier="99")
186
+ frontier = result["frontier"]
187
+ dominated = result["dominated"]
188
+ objectives = ["throughput", "cost_efficiency", "vram_headroom"]
189
+
190
+ # Guard: without this assertion the loop below never fires when dominated
191
+ # is empty, giving zero assertions and false confidence.
192
+ # llama2-70b FP8 (70 GB) fits 6 in-scope GPUs; with 3 objectives and
193
+ # diverse pricing ($0.39–$3.99/hr) at least one must be dominated.
194
+ assert len(dominated) >= 1, (
195
+ "Expected at least one dominated candidate for llama2-70b fp8 — "
196
+ "if all 6 VRAM-fitting GPUs are now Pareto-optimal, verify this "
197
+ "is intentional (pricing or model change) before updating."
198
+ )
199
+
200
+ for dom in dominated:
201
+ # At least one frontier member must dominate this candidate
202
+ is_dominated_by_frontier = any(
203
+ all(f[obj] >= dom[obj] for obj in objectives)
204
+ and any(f[obj] > dom[obj] for obj in objectives)
205
+ for f in frontier
206
+ )
207
+ assert is_dominated_by_frontier, (
208
+ f"{dom['gpu_id']} is in dominated list but is not dominated by any frontier member"
209
+ )
210
+
211
+ def test_min_throughput_filter(self, recommender):
212
+ # High min_throughput should remove low-performing GPUs
213
+ result = recommender.recommend(
214
+ model_name="llama2-70b",
215
+ accuracy_tier="99",
216
+ min_throughput_tok_per_sec=10_000_000, # absurdly high
217
+ )
218
+ # Nothing should pass
219
+ assert len(result["frontier"]) == 0
220
+ assert len(result["dominated"]) == 0
221
+
222
+ def test_amd_99_9_vram_headroom_uses_fp8_model_size(self, recommender):
223
+ # AMD at 99.9 tier uses FP8 (70 GB), not FP16 (140 GB).
224
+ # vram_headroom for MI300X (192 GB) must be ~63.5% (70 GB model),
225
+ # not ~27.1% (140 GB model). This would catch the recommender
226
+ # computing vram_headroom from the workload-level FP16 model_size_gb
227
+ # instead of pred["model_size_gb"] which applies the AMD override.
228
+ result = recommender.recommend(model_name="llama2-70b", accuracy_tier="99.9")
229
+ candidates = result["frontier"] + result["dominated"]
230
+ mi300x = next((r for r in candidates if r["gpu_id"] == "mi300x"), None)
231
+ assert mi300x is not None, "MI300X should be a candidate for llama2-70b tier 99.9"
232
+ expected_headroom = (192 - 70) / 192
233
+ got_headroom = mi300x["vram_headroom"]
234
+ assert got_headroom == pytest.approx(expected_headroom, abs=0.01), (
235
+ f"MI300X vram_headroom={got_headroom:.3f} — expected ~{expected_headroom:.3f}"
236
+ " (FP8 70 GB model); got FP16 headroom instead?"
237
+ )
238
+
239
+ def test_frontier_is_pareto_optimal_gptj(self, recommender):
240
+ # gptj (6.7B, ~6.7 GB FP8) fits all 8 in-scope GPUs — full-field Pareto test
241
+ # with no VRAM pre-filter. After the MI355X CDNA4 spec update (FP8 5033 TFLOPS,
242
+ # confirmed from AMD official spec sheet 2026-06-17), MI355X dominates all other
243
+ # in-scope GPUs on throughput, cost_efficiency, and vram_headroom at current
244
+ # pricing ($3.50/hr). The test verifies: (1) frontier is non-empty, (2) every
245
+ # dominated GPU is correctly classified, (3) no frontier member dominates another.
246
+ result = recommender.recommend(model_name="gptj", accuracy_tier="99")
247
+ frontier = result["frontier"]
248
+ dominated = result["dominated"]
249
+ objectives = ["throughput", "cost_efficiency", "vram_headroom"]
250
+
251
+ assert len(frontier) >= 1, "Expected ≥ 1 Pareto-optimal GPU for gptj"
252
+ assert len(dominated) >= 1, (
253
+ "Expected ≥ 1 dominated candidate for gptj — "
254
+ "8 in-scope GPUs with diverse specs; not all can be Pareto-optimal"
255
+ )
256
+
257
+ # Every GPU in dominated must be dominated by at least one frontier member.
258
+ for dom in dominated:
259
+ is_dominated_by_frontier = any(
260
+ all(f[obj] >= dom[obj] for obj in objectives)
261
+ and any(f[obj] > dom[obj] for obj in objectives)
262
+ for f in frontier
263
+ )
264
+ assert is_dominated_by_frontier, (
265
+ f"{dom['gpu_id']} is in dominated list but not dominated by any frontier member"
266
+ )
267
+
268
+ # No frontier member may dominate another.
269
+ for i, a in enumerate(frontier):
270
+ for j, b in enumerate(frontier):
271
+ if i == j:
272
+ continue
273
+ dominates = (
274
+ all(b[obj] >= a[obj] for obj in objectives)
275
+ and any(b[obj] > a[obj] for obj in objectives)
276
+ )
277
+ assert not dominates, (
278
+ f"Frontier member {b['gpu_id']} dominates {a['gpu_id']} — "
279
+ "gptj frontier is not Pareto-optimal"
280
+ )
tests/test_reliability_gates.py ADDED
@@ -0,0 +1,595 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Reliability-target gate tests (RT-X.Y IDs from JOURNAL.md §"Reliability Targets").
3
+
4
+ These are the machine-executable counterpart of the Design Review Checklist.
5
+ Run all gates before merging any PR:
6
+
7
+ pytest tests/test_reliability_gates.py -v
8
+
9
+ Or run only GATE-marked tests across the entire suite:
10
+
11
+ pytest -m gate -v
12
+
13
+ Each test name encodes the RT it protects so a CI failure immediately names
14
+ the violated requirement.
15
+
16
+ Coverage scope
17
+ --------------
18
+ This file fills the gaps left by the existing per-module test files:
19
+
20
+ RT-2.2 MoE models (mixtral-8x7b) use total_params_b for the bandwidth
21
+ ceiling and compute_params_b (active expert params) for the compute
22
+ ceiling. An arg-swap would overestimate mixtral throughput 3.31×.
23
+ Not covered by any per-module test.
24
+
25
+ RT-3.6 Full 20-feature parity (training path vs serving path).
26
+ test_predictor.py::TestEncodeVsPredictor already covers the 6
27
+ categorical columns. This file covers all 13 continuous features
28
+ (roofline ceilings, model sizes, VRAM ratio, precision TFLOPS)
29
+ plus vendor_is_amd.
30
+ A formula divergence there is a silent bug: predictions appear
31
+ correct but the model is evaluated on features it was never trained on.
32
+
33
+ RT-4.1 Pydantic Literal types in schemas.py match VALID_* frozensets in
34
+ predictor.py. Both are defined independently; silent divergence
35
+ means valid API inputs are rejected by the predictor (500) or
36
+ invalid inputs accepted by Pydantic then fail in the predictor.
37
+
38
+ RT-5.3 Model artifact total disk size < 50 MB.
39
+ No other test verifies this; the limit matters for free-tier HF Spaces
40
+ deployment (2 vCPU / 16 GB RAM, shared bandwidth).
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import ast
46
+ import math
47
+ from pathlib import Path
48
+ from typing import get_args
49
+
50
+ import pandas as pd
51
+ import pytest
52
+ import yaml
53
+
54
+ from src.api.schemas import AccuracyTier, Framework, Scenario
55
+ from src.data.gpu_spec_db import load_specs
56
+ from src.features.build_features import (
57
+ BYTES_PER_PARAM,
58
+ MODEL_PARAMS,
59
+ TIER_TO_PRECISION,
60
+ _FRAMEWORK_PATTERNS,
61
+ _normalize_framework,
62
+ build_training_df,
63
+ )
64
+ from src.models.predictor import (
65
+ FEATURE_COLS,
66
+ VALID_FRAMEWORKS,
67
+ VALID_SCENARIOS,
68
+ VALID_TIERS,
69
+ GpuPredictor,
70
+ _build_feature_vector,
71
+ )
72
+ from src.recommend.recommender import GpuRecommender
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Helpers
77
+ # ---------------------------------------------------------------------------
78
+
79
+ def _nan_match(a: float, b: float) -> bool:
80
+ """True when both are NaN, or both are finite and within 1e-3 of each other."""
81
+ a_nan = math.isnan(a)
82
+ b_nan = math.isnan(b)
83
+ if a_nan and b_nan:
84
+ return True
85
+ if a_nan or b_nan:
86
+ return False
87
+ return abs(a - b) < 1e-3
88
+
89
+
90
+ def _make_single_row(
91
+ gpu_alias: str,
92
+ model_name: str,
93
+ scenario: str,
94
+ tier: str,
95
+ raw_framework: str,
96
+ throughput: float = 1000.0,
97
+ ) -> pd.DataFrame:
98
+ """One synthetic MLPerf row with every field build_training_df expects."""
99
+ return pd.DataFrame([{
100
+ "round": "v5.0",
101
+ "division": "closed",
102
+ "submitter": "test",
103
+ "system_name": "test_system",
104
+ "gpu_name": gpu_alias,
105
+ "num_gpus": 1,
106
+ "vram_gb": None,
107
+ "framework": raw_framework,
108
+ "system_type": "datacenter",
109
+ "hw_status": "available",
110
+ "benchmark": f"{model_name}-{tier}",
111
+ "benchmark_base": model_name,
112
+ "benchmark_accuracy_tier": tier,
113
+ "scenario": scenario,
114
+ "precision": None,
115
+ "tokens_per_sample": 294,
116
+ "throughput_tokens_per_sec": throughput,
117
+ "throughput_tok_per_sec_per_gpu": throughput,
118
+ "result_valid": True,
119
+ "throughput_samples_per_sec": throughput / 294,
120
+ "latency_mean_ms": None,
121
+ "latency_p99_ms": None,
122
+ "ttft_mean_ms": None,
123
+ "ttft_p99_ms": None,
124
+ "tpot_mean_ms": None,
125
+ "tpot_p99_ms": None,
126
+ "log_path": "fake/path",
127
+ }])
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # RT-2.2 MoE param split — bandwidth ceiling uses total, compute uses active
132
+ # ---------------------------------------------------------------------------
133
+
134
+ @pytest.mark.gate
135
+ class TestRT22MoEParamSplit:
136
+ """RT-2.2 GATE: For MoE models, the bandwidth ceiling must use total_params_b
137
+ (full model footprint in HBM) and the compute ceiling must use compute_params_b
138
+ (active-expert params per token).
139
+
140
+ mixtral-8x7b: total=46.7B, active=14.1B (ratio 3.31×). Swapping these args
141
+ in roofline_ceilings() would overestimate mixtral compute throughput 3.31×
142
+ and underestimate the bandwidth ceiling by the same factor — corrupting both
143
+ the feature values and the hard roofline cap in predict().
144
+ """
145
+
146
+ @pytest.fixture(scope="class")
147
+ def mi300x_spec(self) -> dict:
148
+ return {s["id"]: s for s in load_specs()}["mi300x"]
149
+
150
+ def test_mixtral_total_ne_active_params(self) -> None:
151
+ total_b, active_b = MODEL_PARAMS["mixtral-8x7b"]
152
+ assert total_b != active_b, "mixtral-8x7b must have distinct total and active params"
153
+ assert total_b == pytest.approx(46.7, abs=0.1)
154
+ assert active_b == pytest.approx(14.1, abs=0.1)
155
+
156
+ def test_bw_ceiling_uses_total_params(self, mi300x_spec: dict) -> None:
157
+ """Back-calculate which param count produced the BW ceiling in the serving path."""
158
+ total_b, active_b = MODEL_PARAMS["mixtral-8x7b"]
159
+ precision = TIER_TO_PRECISION["99"] # fp8
160
+ bpp = BYTES_PER_PARAM[precision]
161
+ hbm_bw = mi300x_spec["hbm_bandwidth_tbps"]
162
+
163
+ feats, _, _ = _build_feature_vector(
164
+ gpu_spec=mi300x_spec,
165
+ model_name="mixtral-8x7b",
166
+ scenario="Offline",
167
+ accuracy_tier="99",
168
+ framework="vllm",
169
+ )
170
+ bw_ceil = feats[FEATURE_COLS.index("bandwidth_ceiling_tok_per_sec")]
171
+
172
+ # bw_ceil = (hbm_bw * 1e12) / (params * 1e9 * bpp) ⟹ params = …
173
+ implied_params = (hbm_bw * 1e12) / (bw_ceil * 1e9 * bpp)
174
+ assert implied_params == pytest.approx(total_b, rel=1e-3), (
175
+ f"RT-2.2: BW ceiling implies {implied_params:.2f}B params in model footprint; "
176
+ f"expected total_params_b={total_b}B. "
177
+ f"Compute ceiling likely received total params instead of active params."
178
+ )
179
+
180
+ def test_compute_ceiling_uses_active_params(self, mi300x_spec: dict) -> None:
181
+ """Back-calculate which param count produced the compute ceiling in the serving path."""
182
+ total_b, active_b = MODEL_PARAMS["mixtral-8x7b"]
183
+ precision = TIER_TO_PRECISION["99"]
184
+ pt = mi300x_spec.get("peak_tflops") or {}
185
+ peak_tflops = pt.get(precision) or pt.get("fp16")
186
+
187
+ feats, _, _ = _build_feature_vector(
188
+ gpu_spec=mi300x_spec,
189
+ model_name="mixtral-8x7b",
190
+ scenario="Offline",
191
+ accuracy_tier="99",
192
+ framework="vllm",
193
+ )
194
+ compute_ceil = feats[FEATURE_COLS.index("compute_ceiling_tok_per_sec")]
195
+
196
+ # compute_ceil = (peak_tflops * 1e12) / (2 * params * 1e9) ⟹ params = …
197
+ implied_params = (peak_tflops * 1e12) / (2.0 * compute_ceil * 1e9)
198
+ assert implied_params == pytest.approx(active_b, rel=1e-3), (
199
+ f"RT-2.2: Compute ceiling implies {implied_params:.2f}B active params; "
200
+ f"expected compute_params_b={active_b}B. "
201
+ f"Bandwidth ceiling likely received active params instead of total params."
202
+ )
203
+
204
+
205
+ # ---------------------------------------------------------------------------
206
+ # RT-4.1 Schema/predictor agreement — Literal types match VALID_* frozensets
207
+ # ---------------------------------------------------------------------------
208
+
209
+ @pytest.mark.gate
210
+ class TestRT41SchemaPredictorAgreement:
211
+ """RT-4.1 GATE: Pydantic Literal types in src/api/schemas.py must exactly
212
+ match the VALID_* frozensets in src/models/predictor.py.
213
+
214
+ Both are defined independently. If schemas.py accepts a value that
215
+ VALID_* rejects, a well-formed API request causes a 500 (predictor raises
216
+ ValueError). If VALID_* accepts a value schemas.py rejects, a valid
217
+ predictor call cannot be reached via the API at all.
218
+ """
219
+
220
+ def test_scenario_literals_match_valid_scenarios(self) -> None:
221
+ assert set(get_args(Scenario)) == VALID_SCENARIOS, (
222
+ f"RT-4.1: schemas.Scenario={set(get_args(Scenario))} "
223
+ f"!= predictor.VALID_SCENARIOS={VALID_SCENARIOS}"
224
+ )
225
+
226
+ def test_accuracy_tier_literals_match_valid_tiers(self) -> None:
227
+ assert set(get_args(AccuracyTier)) == VALID_TIERS, (
228
+ f"RT-4.1: schemas.AccuracyTier={set(get_args(AccuracyTier))} "
229
+ f"!= predictor.VALID_TIERS={VALID_TIERS}"
230
+ )
231
+
232
+ def test_framework_literals_match_valid_frameworks(self) -> None:
233
+ assert set(get_args(Framework)) == VALID_FRAMEWORKS, (
234
+ f"RT-4.1: schemas.Framework={set(get_args(Framework))} "
235
+ f"!= predictor.VALID_FRAMEWORKS={VALID_FRAMEWORKS}"
236
+ )
237
+
238
+
239
+ # ---------------------------------------------------------------------------
240
+ # RT-3.6 Feature vector parity — training path vs serving path
241
+ # ---------------------------------------------------------------------------
242
+
243
+ @pytest.mark.gate
244
+ class TestRT36FeatureVectorParity:
245
+ """RT-3.6 GATE: all 20 FEATURE_COLS values match between build_training_df
246
+ (training path) and _build_feature_vector (serving path), with one
247
+ intentional exception noted below.
248
+
249
+ Column groups verified
250
+ ----------------------
251
+ Continuous (this file):
252
+ gpu_hbm_bandwidth_tbps, gpu_vram_gb, peak_tflops_selected,
253
+ compute_ceiling_tok_per_sec, bandwidth_ceiling_tok_per_sec,
254
+ model_total_params_b, model_compute_params_b, model_size_gb,
255
+ model_to_vram_ratio, bytes_per_param, vendor_is_amd,
256
+ nvidia_arch_gen, amd_arch_gen
257
+
258
+ Categorical (test_predictor.py::TestBuildFeatureVector):
259
+ scenario_offline, is_base_tier, fw_tensorrt, fw_vllm,
260
+ fw_rocm_other, is_cdna4
261
+
262
+ Intentionally excluded from parity check:
263
+ mlperf_round_num — training uses the actual submission round ordinal;
264
+ serving always uses _SERVING_ROUND (max(ROUND_ORDINAL.values())) to
265
+ predict for the most mature software stack. Verified separately in
266
+ test_predictor.py::TestBuildFeatureVector::test_mlperf_round_num_serving_uses_latest.
267
+ """
268
+
269
+ # (gpu_alias, gpu_id, model_name, scenario, tier, raw_framework)
270
+ # gpu_alias must match a known alias in data/gpu_specs.yaml.
271
+ # raw_framework is a realistic MLPerf string; _normalize_framework maps it
272
+ # to the family label that _build_feature_vector expects.
273
+ CASES = [
274
+ # NVIDIA Hopper, FP8 tier (99 → fp8, falls back for GPUs without fp8)
275
+ (
276
+ "NVIDIA H200-SXM-141GB", "h200_sxm", "llama2-70b",
277
+ "Offline", "99", "TensorRT 10.2.0",
278
+ ),
279
+ # AMD CDNA3, FP8 tier (99.9 → fp8 for AMD; parity check verifies both
280
+ # paths apply the vendor override consistently)
281
+ (
282
+ "AMD Instinct MI300X 192GB HBM3", "mi300x", "gptj",
283
+ "Server", "99.9", "vLLM 0.4.3+rocm614",
284
+ ),
285
+ # AMD CDNA3, BF16 tier (base → bf16); MoE model (active ≠ total params)
286
+ (
287
+ "AMD Instinct MI325X", "mi325x", "mixtral-8x7b",
288
+ "Offline", "99", "ROCm 7.0",
289
+ ),
290
+ # AMD CDNA4 — only GPU where amd_arch_gen=2 and is_cdna4=1.
291
+ # A divergence in CDNA4 arch-ordinal encoding (training vs serving)
292
+ # cannot be detected by the 3 cases above (all use CDNA3 or NVIDIA).
293
+ (
294
+ "AMD Instinct MI355X 288GB HBM3e", "mi355x", "llama2-70b",
295
+ "Offline", "99", "ROCm 7.0",
296
+ ),
297
+ # NVIDIA at tier 99.9 — selects fp16 TFLOPS (NOT fp8, no AMD override).
298
+ # The four cases above are all tier 99 (fp8) or AMD 99.9 (fp8 override);
299
+ # without this case a divergence in NVIDIA fp16 TFLOPS selection between
300
+ # the training and serving paths would pass the gate undetected.
301
+ (
302
+ "NVIDIA H100-SXM-80GB", "h100_sxm", "gptj",
303
+ "Server", "99.9", "TensorRT 10.4.0",
304
+ ),
305
+ ]
306
+
307
+ @pytest.fixture(scope="class")
308
+ def spec_map(self) -> dict:
309
+ return {s["id"]: s for s in load_specs()}
310
+
311
+ @pytest.mark.parametrize(
312
+ "gpu_alias, gpu_id, model_name, scenario, tier, raw_fw",
313
+ CASES,
314
+ )
315
+ def test_continuous_features_match(
316
+ self, spec_map, gpu_alias, gpu_id, model_name, scenario, tier, raw_fw,
317
+ ):
318
+ """RT-3.6 GATE: every continuous FEATURE_COL has the same value in
319
+ both paths. A mismatch means the model evaluates features at serving
320
+ time that it was never trained on.
321
+ """
322
+ # ── Training path ─────────────────────────────────────────────────
323
+ raw_df = _make_single_row(gpu_alias, model_name, scenario, tier, raw_fw)
324
+ feat_df = build_training_df(raw_df)
325
+ assert len(feat_df) == 1, (
326
+ f"build_training_df dropped the row — check gpu_alias={gpu_alias!r} "
327
+ f"and benchmark_base={model_name!r}"
328
+ )
329
+ train_row = feat_df.iloc[0]
330
+
331
+ # ── Serving path ──────────────────────────────────────────────────
332
+ fw_family = _normalize_framework(raw_fw)
333
+ serving_feats, _, _ = _build_feature_vector(
334
+ gpu_spec=spec_map[gpu_id],
335
+ model_name=model_name,
336
+ scenario=scenario,
337
+ accuracy_tier=tier,
338
+ framework=fw_family,
339
+ )
340
+
341
+ # ── Compare continuous columns ────────────────────────────────────
342
+ # mlperf_round_num is excluded: serving always uses the latest round
343
+ # ordinal (_SERVING_ROUND); training uses the actual submission round.
344
+ # This divergence is intentional — see class docstring.
345
+ continuous_cols = [
346
+ c for c in FEATURE_COLS
347
+ if c not in {
348
+ "scenario_offline", "is_base_tier",
349
+ "fw_tensorrt", "fw_vllm", "fw_rocm_other", "is_cdna4",
350
+ "mlperf_round_num",
351
+ }
352
+ ]
353
+ mismatches = []
354
+ for col in continuous_cols:
355
+ idx = FEATURE_COLS.index(col)
356
+ sv = float(serving_feats[idx])
357
+ tv_raw = train_row[col]
358
+ tv = float(tv_raw) if not pd.isna(tv_raw) else float("nan")
359
+
360
+ if not _nan_match(sv, tv):
361
+ mismatches.append(
362
+ f" {col:<40} serving={sv:.6g} training={tv:.6g}"
363
+ )
364
+
365
+ assert not mismatches, (
366
+ f"RT-3.6: feature mismatch for gpu={gpu_id!r} model={model_name!r} "
367
+ f"scenario={scenario!r} tier={tier!r}:\n"
368
+ + "\n".join(mismatches)
369
+ )
370
+
371
+
372
+ # ---------------------------------------------------------------------------
373
+ # RT-5.3 Model artifact total disk size < 50 MB
374
+ # ---------------------------------------------------------------------------
375
+
376
+ _MODEL_DIR = Path("data/models")
377
+ _DISK_LIMIT_MB = 50
378
+
379
+
380
+ @pytest.mark.gate
381
+ class TestRT53ModelDiskSize:
382
+ """RT-5.3 GATE: data/models/ total size is < 50 MB.
383
+
384
+ Free-tier Hugging Face Spaces (the deploy target) has shared storage
385
+ and slow cold-start I/O. 50 MB is the deployment budget.
386
+ """
387
+
388
+ def test_model_dir_exists(self) -> None:
389
+ assert _MODEL_DIR.is_dir(), (
390
+ f"RT-5.3: {_MODEL_DIR} not found — run train_final.py to generate artifacts"
391
+ )
392
+
393
+ def test_total_size_under_limit(self) -> None:
394
+ total_bytes = sum(f.stat().st_size for f in _MODEL_DIR.rglob("*") if f.is_file())
395
+ total_mb = total_bytes / (1024 ** 2)
396
+ assert total_mb < _DISK_LIMIT_MB, (
397
+ f"RT-5.3: data/models/ is {total_mb:.1f} MB — exceeds {_DISK_LIMIT_MB} MB limit. "
398
+ "Trim model artifacts before deploying to HF Spaces."
399
+ )
400
+
401
+ def test_required_artifacts_present(self) -> None:
402
+ required = ["prophet_v1.json", "feature_metadata.json"]
403
+ for name in required:
404
+ assert (_MODEL_DIR / name).exists(), (
405
+ f"RT-5.3: required artifact {name} is missing from {_MODEL_DIR}"
406
+ )
407
+
408
+
409
+ # ---------------------------------------------------------------------------
410
+ # RT-1.3 Pricing coverage — all in-scope GPUs have pricing entries
411
+ # ---------------------------------------------------------------------------
412
+
413
+ _PRICING_PATH = Path("data/pricing.yaml")
414
+
415
+
416
+ @pytest.mark.gate
417
+ class TestRT13PricingCoverage:
418
+ """RT-1.3 GATE: every GPU with in_model_scope=True in gpu_specs.yaml has a
419
+ corresponding entry in data/pricing.yaml.
420
+
421
+ GpuRecommender.__init__() raises ValueError for missing pricing, but that
422
+ failure is only visible at runtime (startup). This gate catches the gap
423
+ before the Docker image is built or before AMD Dev Cloud session starts.
424
+
425
+ Adding a new in-scope GPU without a pricing entry causes cost_efficiency=None,
426
+ which raises TypeError in _pareto_frontier's sort — silently breaking all
427
+ recommendation responses rather than failing cleanly at startup.
428
+ """
429
+
430
+ def test_pricing_file_exists(self) -> None:
431
+ assert _PRICING_PATH.is_file(), (
432
+ f"RT-1.3: {_PRICING_PATH} not found — create data/pricing.yaml before deployment"
433
+ )
434
+
435
+ def test_all_in_scope_gpus_have_pricing(self) -> None:
436
+ with _PRICING_PATH.open() as f:
437
+ pricing: dict = yaml.safe_load(f).get("pricing", {})
438
+ in_scope_ids = [s["id"] for s in load_specs() if s.get("in_model_scope")]
439
+ assert in_scope_ids, (
440
+ "RT-1.3: no in-scope GPUs found — check gpu_specs.yaml in_model_scope flags"
441
+ )
442
+ missing = [gid for gid in in_scope_ids if gid not in pricing]
443
+ assert not missing, (
444
+ f"RT-1.3: pricing.yaml missing entries for in-scope GPUs: {missing}. "
445
+ "GpuRecommender.__init__() will raise ValueError at startup."
446
+ )
447
+
448
+
449
+ # ---------------------------------------------------------------------------
450
+ # RT-3.7 AMD FP8 override — three-way consistency gate
451
+ # ---------------------------------------------------------------------------
452
+
453
+ @pytest.mark.gate
454
+ class TestRT37AmdFp8Override:
455
+ """RT-3.7 GATE: the AMD 99.9-tier FP8 override is applied consistently in
456
+ build_training_df (training), _build_feature_vector (serving), and
457
+ GpuRecommender._gpu_model_size_gb (VRAM pre-filter).
458
+
459
+ A regression in any one of the three paths causes:
460
+ - Training: efficiency_ratio > 1.0 for MI355X 99.9-tier rows
461
+ (FP16 ceiling used instead of FP8, ceiling violations in 22/50 rows,
462
+ reducing MI355X LOGO ρ from ~0.60 to ~0.40)
463
+ - Serving/recommender: wrong VRAM fit decision for llama2-70b at 99.9 tier
464
+ on MI300X (140 GB FP16 model exceeds 192 GB VRAM by ~27%, so MI300X
465
+ would be incorrectly rejected despite fitting comfortably as 70 GB FP8)
466
+
467
+ Verified end-to-end via recommender: if vram_headroom ≈ 0.635 (FP8 70 GB),
468
+ all three paths agree. If headroom ≈ 0.271 (FP16 140 GB), at least one
469
+ path has drifted.
470
+ """
471
+
472
+ @pytest.fixture(scope="class")
473
+ def recommender(self) -> GpuRecommender:
474
+ return GpuRecommender(GpuPredictor())
475
+
476
+ def test_mi300x_vram_headroom_uses_fp8_not_fp16(self, recommender) -> None:
477
+ result = recommender.recommend(model_name="llama2-70b", accuracy_tier="99.9")
478
+ candidates = result["frontier"] + result["dominated"]
479
+ mi300x = next((r for r in candidates if r["gpu_id"] == "mi300x"), None)
480
+ assert mi300x is not None, (
481
+ "RT-3.7: MI300X absent from llama2-70b tier-99.9 candidates — "
482
+ "VRAM filter may be applying FP16 model size (140 GB) instead of FP8 (70 GB), "
483
+ "causing MI300X (192 GB) to be incorrectly rejected."
484
+ )
485
+ fp8_model_gb = 70.0 # 70B params × 1 byte/param (FP8)
486
+ expected_headroom = (192.0 - fp8_model_gb) / 192.0 # ≈ 0.635
487
+ assert mi300x["vram_headroom"] == pytest.approx(expected_headroom, abs=0.01), (
488
+ f"RT-3.7: MI300X vram_headroom={mi300x['vram_headroom']:.3f}, "
489
+ f"expected {expected_headroom:.3f} (FP8 70 GB model). "
490
+ "A value near 0.271 indicates FP16 (140 GB) is being used instead."
491
+ )
492
+
493
+
494
+ # ---------------------------------------------------------------------------
495
+ # RT-3.8 Framework normalization — pattern outputs ⊆ VALID_FRAMEWORKS
496
+ # ---------------------------------------------------------------------------
497
+
498
+ @pytest.mark.gate
499
+ class TestRT38FrameworkNormalization:
500
+ """RT-3.8 GATE: every label returned by a _FRAMEWORK_PATTERNS entry is in
501
+ VALID_FRAMEWORKS, and the no-match fallback ("other") is also in VALID_FRAMEWORKS.
502
+
503
+ If a new framework pattern is added to _FRAMEWORK_PATTERNS with a label that
504
+ is NOT in VALID_FRAMEWORKS, that framework family enters the training data but
505
+ can never be requested via the API — predictor._validate() raises ValueError
506
+ on what appears to be a perfectly valid input, causing silent 500 errors for
507
+ any workload that uses that framework.
508
+ """
509
+
510
+ def test_all_pattern_outputs_in_valid_frameworks(self) -> None:
511
+ labels_from_patterns = {label for _, label in _FRAMEWORK_PATTERNS}
512
+ unknown_labels = labels_from_patterns - VALID_FRAMEWORKS
513
+ assert not unknown_labels, (
514
+ f"RT-3.8: _FRAMEWORK_PATTERNS returns labels not in VALID_FRAMEWORKS: "
515
+ f"{unknown_labels}. Add to predictor.VALID_FRAMEWORKS or remove from patterns."
516
+ )
517
+
518
+ def test_fallback_other_in_valid_frameworks(self) -> None:
519
+ fallback = _normalize_framework("some_completely_unknown_framework_xyz")
520
+ assert fallback == "other", (
521
+ f"RT-3.8: no-match fallback should be 'other', got {fallback!r}. "
522
+ "The fallback must be in VALID_FRAMEWORKS so unrecognized frameworks "
523
+ "can be requested via the API without a 422 validation error."
524
+ )
525
+ assert "other" in VALID_FRAMEWORKS, (
526
+ "RT-3.8: 'other' is _normalize_framework's no-match return value "
527
+ "but is not in VALID_FRAMEWORKS — unrecognized frameworks fail predictor._validate()."
528
+ )
529
+
530
+
531
+ # ---------------------------------------------------------------------------
532
+ # RT-6.1 Calibration runner/merge script CSV compatibility
533
+ # ---------------------------------------------------------------------------
534
+
535
+ _RUNNER_PATH = Path("benchmarks/run_mi300x_calibration.py")
536
+
537
+ # Fields that merge_calibration_rows.py accesses directly from CSV rows
538
+ # (r["field"] or r.get("field", ...)). Update whenever merge script adds
539
+ # a new field access.
540
+ _MERGE_REQUIRED_FIELDS: frozenset[str] = frozenset({
541
+ "round_tag",
542
+ "gpu_name",
543
+ "benchmark_base",
544
+ "benchmark_accuracy_tier",
545
+ "scenario",
546
+ "throughput_tok_per_sec",
547
+ "vllm_version",
548
+ # DataFrame subscript access on line 64 of merge_calibration_rows.py:
549
+ # failed[["benchmark_base", "precision_used", "scenario"]].to_dict("records")
550
+ # Not a .get() — raises KeyError if the column is absent from the CSV.
551
+ "precision_used",
552
+ })
553
+
554
+
555
+ def _parse_runner_csv_fields() -> frozenset[str]:
556
+ """Extract CSV_FIELDS list from runner script via AST — avoids importing
557
+ the script directly, which would fail without vllm installed."""
558
+ tree = ast.parse(_RUNNER_PATH.read_text())
559
+ for node in ast.walk(tree):
560
+ if isinstance(node, ast.Assign):
561
+ for target in node.targets:
562
+ if isinstance(target, ast.Name) and target.id == "CSV_FIELDS":
563
+ if isinstance(node.value, ast.List):
564
+ return frozenset(
565
+ elt.value
566
+ for elt in node.value.elts
567
+ if isinstance(elt, ast.Constant) and isinstance(elt.value, str)
568
+ )
569
+ raise RuntimeError("CSV_FIELDS constant not found in benchmarks/run_mi300x_calibration.py")
570
+
571
+
572
+ @pytest.mark.gate
573
+ class TestRT61RunnerMergeCompatibility:
574
+ """RT-6.1 GATE: benchmarks/run_mi300x_calibration.py CSV_FIELDS is a superset
575
+ of the columns that benchmarks/merge_calibration_rows.py reads from each row.
576
+
577
+ Drift between the runner's CSV schema and the merge script's column accesses
578
+ causes KeyError (or silent None via .get()) when processing real AMD Dev Cloud
579
+ results — after the 25-hr credit clock has already been consumed. This gate
580
+ runs locally before starting the timed session.
581
+ """
582
+
583
+ def test_runner_script_exists(self) -> None:
584
+ assert _RUNNER_PATH.is_file(), (
585
+ f"RT-6.1: {_RUNNER_PATH} not found — calibration runner script is missing"
586
+ )
587
+
588
+ def test_runner_csv_fields_cover_merge_required(self) -> None:
589
+ runner_fields = _parse_runner_csv_fields()
590
+ missing = _MERGE_REQUIRED_FIELDS - runner_fields
591
+ assert not missing, (
592
+ f"RT-6.1: merge_calibration_rows.py accesses fields not in runner CSV_FIELDS: "
593
+ f"{missing}. Add these to CSV_FIELDS in run_mi300x_calibration.py, or "
594
+ "remove the access from merge_calibration_rows.py."
595
+ )