Spaces:
Sleeping
Sleeping
File size: 9,655 Bytes
0623ff0 a4508e8 0623ff0 a4508e8 0623ff0 a4508e8 0623ff0 a4508e8 9f8362f a4508e8 9f8362f a4508e8 0623ff0 a4508e8 0623ff0 a4508e8 0623ff0 a4508e8 0623ff0 a4508e8 0623ff0 a4508e8 0623ff0 a4508e8 0623ff0 a4508e8 0623ff0 a4508e8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 | """GPU spec database loader: normalize_gpu_name() maps raw MLPerf names to canonical ids; enrich_df() joins spec columns onto a parsed MLPerf DataFrame."""
from __future__ import annotations
import functools
import logging
import re
import stat as _stat
import types
from pathlib import Path
from typing import Optional
import pandas as pd
import yaml
# Real spec files are ~10 KB; 1 MB cap blocks oversized files (mirrors mlperf_parser.py's _safe_read_text).
_MAX_SPEC_BYTES: int = 1 * 1024 * 1024 # 1 MB
log = logging.getLogger(__name__)
_DEFAULT_SPEC_PATH = Path(__file__).parent.parent.parent / "data" / "gpu_specs.yaml"
# Columns added by enrich_df, in output order.
SPEC_COLUMNS = [
"canonical_gpu_id",
"gpu_vendor",
"gpu_architecture",
"gpu_memory_type",
"gpu_vram_gb",
"gpu_hbm_bandwidth_tbps",
"gpu_peak_fp32_tflops",
"gpu_peak_bf16_tflops",
"gpu_peak_fp16_tflops",
"gpu_peak_fp8_tflops",
"gpu_peak_fp6_tflops",
"gpu_peak_fp4_tflops",
"gpu_peak_int8_tops",
"gpu_cu_sm_count",
"gpu_l2_cache_mb",
"gpu_tdp_w",
"gpu_in_model_scope",
"gpu_spec_confidence",
]
# Matches "(x8)", "(x16)", "(x32)", "(x87)" etc.
_COUNT_SUFFIX_RE = re.compile(r"\s*\(x\d+\)\s*$")
# Matches "(Power Cap 1000 W)" and similar parenthetical power notes
_POWER_SUFFIX_RE = re.compile(r"\s*\(Power Cap[^)]*\)\s*$", re.IGNORECASE)
@functools.lru_cache(maxsize=4)
def _build_spec_cache(spec_path_str: str) -> tuple[list, dict, dict]:
"""Build (specs, alias_index, id_map) once per unique path string; cached objects must not be mutated by callers."""
specs = load_specs(Path(spec_path_str))
index = _build_alias_index(specs)
# MappingProxyType wraps id_map values so callers can't mutate the cached spec rows.
id_map = {s["id"]: types.MappingProxyType(_spec_row(s)) for s in specs}
return specs, index, id_map
@functools.lru_cache(maxsize=8)
def _load_raw(spec_path: Path | str = _DEFAULT_SPEC_PATH) -> dict:
"""Guarded, cached parse of the full gpu_specs.yaml dict (not just the 'gpus' list) — the one place this file is actually opened and yaml.safe_load'd. load_specs() and spec_db_version() both build on this so the same ~10 KB file isn't parsed twice for two different facts about it."""
path = Path(spec_path)
try:
st = path.lstat()
except OSError as exc:
raise FileNotFoundError(f"GPU spec DB not found: {path}") from exc
if _stat.S_ISLNK(st.st_mode):
raise ValueError(f"GPU spec DB path is a symlink (refused): {path}")
if st.st_size > _MAX_SPEC_BYTES:
raise ValueError(
f"GPU spec DB too large ({st.st_size} bytes > {_MAX_SPEC_BYTES}): {path}"
)
with path.open(encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict) or "gpus" not in data:
raise ValueError(
f"GPU spec DB at {path} is missing required 'gpus' key "
f"(got {type(data).__name__})"
)
return data
@functools.lru_cache(maxsize=8)
def load_specs(spec_path: Path | str = _DEFAULT_SPEC_PATH) -> list[dict]:
"""Load the GPU spec dict list from YAML; raises ValueError for symlinks, oversized files, or a missing 'gpus' key."""
return _load_raw(spec_path)["gpus"]
@functools.lru_cache(maxsize=8)
def spec_db_version(spec_path: Path | str = _DEFAULT_SPEC_PATH) -> str:
"""The gpu_specs.yaml schema_version string (meta.gpu_spec_db_version)."""
return str(_load_raw(spec_path).get("schema_version", "unknown"))
def _build_alias_index(specs: list[dict]) -> dict[str, dict]:
"""Return lowercase-alias → spec dict for fast lookup."""
index: dict[str, dict] = {}
for spec in specs:
for alias in spec.get("aliases", []):
key = alias.lower()
if key in index and index[key]["id"] != spec["id"]:
# Only warn when the alias maps to two *different* GPU ids (same-GPU dupes are harmless).
log.warning(
"Conflicting alias %r maps to both %r and %r in GPU spec DB",
alias, index[key]["id"], spec["id"],
)
index[key] = spec
return index
def _is_heterogeneous(raw_name: str) -> bool:
"""Return True for multi-GPU strings mixing different SKUs."""
# "A and B" pattern (heterogeneous mix)
if " and " in raw_name.lower():
return True
# "A (xN), B (xM)" pattern
if "," in raw_name and re.search(r"\(x\d+\)", raw_name):
return True
return False
def _strip_suffixes(raw_name: str) -> str:
"""Remove parenthetical count and power suffixes from a GPU name."""
name = _COUNT_SUFFIX_RE.sub("", raw_name)
name = _POWER_SUFFIX_RE.sub("", name)
return name.strip()
def normalize_gpu_name(
raw_name: str | None,
specs: list[dict],
*,
_index: dict[str, dict] | None = None,
) -> Optional[str]:
"""Map a raw MLPerf accelerator_model_name to a canonical GPU id, or None for null/N/A, heterogeneous multi-GPU, or unmatched names."""
if not raw_name or raw_name.strip().upper() in {"N/A", "NA", "NONE", ""}:
return None
if _is_heterogeneous(raw_name):
log.debug("Skipping heterogeneous GPU string: %r", raw_name)
return None
index = _index if _index is not None else _build_alias_index(specs)
clean = _strip_suffixes(raw_name)
match = index.get(clean.lower())
if match is None:
log.debug("No GPU spec match for: %r (stripped: %r)", raw_name, clean)
return None
return match["id"]
def _spec_row(spec: dict) -> dict:
"""Flatten one GPU spec dict into the SPEC_COLUMNS schema."""
pt = spec.get("peak_tflops") or {}
return {
"canonical_gpu_id": spec["id"],
"gpu_vendor": spec.get("vendor"),
"gpu_architecture": spec.get("architecture"),
"gpu_memory_type": spec.get("memory_type"),
"gpu_vram_gb": spec.get("vram_gb"),
"gpu_hbm_bandwidth_tbps": spec.get("hbm_bandwidth_tbps"),
"gpu_peak_fp32_tflops": pt.get("fp32"),
"gpu_peak_bf16_tflops": pt.get("bf16"),
"gpu_peak_fp16_tflops": pt.get("fp16"),
"gpu_peak_fp8_tflops": pt.get("fp8"),
"gpu_peak_fp6_tflops": pt.get("fp6"),
"gpu_peak_fp4_tflops": pt.get("fp4"),
"gpu_peak_int8_tops": pt.get("int8"),
# Unified key for AMD compute_units / NVIDIA streaming_multiprocessors, so features stay vendor-agnostic.
"gpu_cu_sm_count": (
spec.get("compute_units") or spec.get("streaming_multiprocessors")
),
"gpu_l2_cache_mb": spec.get("l2_cache_mb"),
"gpu_tdp_w": spec.get("tdp_w"),
"gpu_in_model_scope": spec.get("in_model_scope"),
"gpu_spec_confidence": spec.get("spec_confidence"),
}
def enrich_df(
df: pd.DataFrame,
spec_path: Path | str = _DEFAULT_SPEC_PATH,
) -> pd.DataFrame:
"""Join GPU hardware spec columns onto a parsed MLPerf DataFrame by canonical_gpu_id; unmatched rows get NaN. Returns a new DataFrame."""
specs, index, id_map = _build_spec_cache(str(Path(spec_path)))
# Normalize once per unique gpu_name (not per row) — ~33 unique names across ~1223 rows.
unique_names = {n for n in df["gpu_name"].dropna().unique()}
name_to_id = {
name: normalize_gpu_name(name, specs, _index=index)
for name in unique_names
}
canonical_ids = df["gpu_name"].map(name_to_id)
spec_df = pd.DataFrame(
[id_map.get(cid, {}) for cid in canonical_ids],
index=df.index,
)
# Ensure all expected columns exist even if spec_rows were all empty.
for col in SPEC_COLUMNS:
if col not in spec_df.columns:
spec_df[col] = None
matched = canonical_ids.notna().sum()
unmatched = canonical_ids.isna().sum()
log.info(
"GPU spec enrichment: %d rows matched, %d unmatched (%.0f%%)",
matched, unmatched, 100 * matched / max(len(df), 1),
)
if unmatched:
unseen = set(df.loc[canonical_ids.isna(), "gpu_name"].dropna().unique())
# %r escapes control chars in GPU names, guarding against log injection.
for name in sorted(unseen):
log.debug("Unmatched gpu_name: %r", name)
result = pd.concat([df, spec_df[SPEC_COLUMNS]], axis=1)
# Some submitters report total system VRAM (parser's vram_gb) instead of per-GPU capacity (spec DB's gpu_vram_gb); use gpu_vram_gb for the memory-fit constraint.
if "vram_gb" in result.columns and "gpu_vram_gb" in result.columns:
conflict = (
result["vram_gb"].notna()
& result["gpu_vram_gb"].notna()
& (result["vram_gb"] != result["gpu_vram_gb"])
)
if conflict.any():
n = conflict.sum()
examples = (
result.loc[conflict, ["gpu_name", "num_gpus", "vram_gb", "gpu_vram_gb"]]
.drop_duplicates()
)
log.warning(
"%d rows: vram_gb (parser) != gpu_vram_gb (spec DB). "
"Some submitters report total system VRAM. "
"Use gpu_vram_gb for the memory-fit constraint.",
n,
)
# itertuples ~10x faster than iterrows; %r quotes gpu_name to prevent log injection.
for row in examples.itertuples(index=False):
log.warning(
" vram conflict: gpu=%r num_gpus=%s reported=%s spec=%s",
row.gpu_name, row.num_gpus, row.vram_gb, row.gpu_vram_gb,
)
return result
|