Spaces:
Sleeping
Sleeping
Commit ·
a4508e8
0
Parent(s):
GPU Perf Prophet v1 — cross-vendor GPU performance forecasting & recommendation engine
Browse filesRoofline-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 +35 -0
- .gitignore +48 -0
- Dockerfile +46 -0
- README.md +92 -0
- app/streamlit_app.py +246 -0
- benchmarks/.gitignore +2 -0
- benchmarks/merge_calibration_rows.py +249 -0
- benchmarks/mi300x_calibration_results.csv +25 -0
- benchmarks/run_mi300x_calibration.py +476 -0
- data/data_card.md +193 -0
- data/gpu_specs.yaml +507 -0
- data/models/feature_metadata.json +37 -0
- data/models/prophet_v1.json +0 -0
- data/pricing.yaml +52 -0
- notebooks/01_mlperf_eda.ipynb +0 -0
- notebooks/02_roofline_sanity.ipynb +0 -0
- notebooks/03_model_training.ipynb +0 -0
- requirements.txt +28 -0
- scripts/fetch_mlperf.sh +86 -0
- setup.cfg +9 -0
- src/__init__.py +0 -0
- src/api/__init__.py +0 -0
- src/api/main.py +195 -0
- src/api/schemas.py +88 -0
- src/data/__init__.py +0 -0
- src/data/gpu_spec_db.py +294 -0
- src/data/mlperf_parser.py +602 -0
- src/features/__init__.py +0 -0
- src/features/build_features.py +335 -0
- src/models/__init__.py +0 -0
- src/models/predictor.py +360 -0
- src/models/train_final.py +133 -0
- src/recommend/__init__.py +0 -0
- src/recommend/recommender.py +314 -0
- tests/__init__.py +0 -0
- tests/test_api.py +218 -0
- tests/test_build_features.py +537 -0
- tests/test_gpu_spec_db.py +489 -0
- tests/test_mlperf_parser.py +1111 -0
- tests/test_predictor.py +442 -0
- tests/test_recommender.py +280 -0
- tests/test_reliability_gates.py +595 -0
.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 |
+
)
|