Files

429 lines
15 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Comparable identity helpers for performance benchmark records."""
from __future__ import annotations
import dataclasses
import enum
import hashlib
import importlib.metadata
import json
import os
import platform
import re
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Any
import torch
RECIPE_SCHEMA_VERSION = 2
PROFILE_ID_LENGTH = 16
_PATH_EXCLUDED_GENERATION_KEYS = {"output_path", "output_video_name"}
_PROMPT_KEYS = {"prompt", "negative_prompt", "neg_prompt"}
REQUIRED_BENCHMARK_IDENTITY_KEYS = (
"benchmark_id",
"workload_id",
"variant_id",
"benchmark_version",
)
_PROFILE_ENV_VARS = (
"CUDA_VISIBLE_DEVICES",
"FASTVIDEO_ATTENTION_BACKEND",
"IMAGE_VERSION",
"UV_TORCH_BACKEND",
"FASTVIDEO_CONTAINER_IMAGE_REF",
)
_PACKAGE_DISTRIBUTIONS = {
"fastvideo_kernel": ("fastvideo-kernel", "fastvideo_kernel"),
"flash_attn": ("flash-attn", "flash_attn"),
"flash_attn_4": ("flash-attn-4", ),
"flash_attention_fp4": ("flash-attention-fp4", ),
"flashinfer": ("flashinfer-python", "flashinfer"),
"nvidia_cutlass_dsl": ("nvidia-cutlass-dsl", "cutlass-dsl"),
"sageattention": ("sageattention", ),
"sageattn3": ("sageattn3", ),
"triton": ("triton", ),
"xformers": ("xformers", ),
}
def canonical_json(payload: Any) -> str:
"""Return deterministic, compact JSON for a recipe/profile payload."""
return json.dumps(
_canonicalize(payload),
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
)
def sha256_hexdigest(payload: str | bytes) -> str:
if isinstance(payload, str):
payload = payload.encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def recipe_fingerprint(recipe: Mapping[str, Any]) -> str:
# Runtime-resolved values are audit metadata, not identity: hashing the
# resolved HF snapshot revision would churn the cohort (and via the
# no-baseline init path, silently reset the rolling baseline) on ANY
# upstream repo commit, including model-card edits with unchanged
# weights. The fingerprint is pinned to declared inputs only; the
# resolved values stay in the stored recipe for auditability.
pruned = {key: (dict(value) if isinstance(value, Mapping) else value) for key, value in recipe.items()}
model = pruned.get("model")
if isinstance(model, dict):
model.pop("resolved_revision", None)
attention = pruned.get("attention")
if isinstance(attention, dict):
# Same reasoning as resolved_revision: with requested_backend="auto",
# a silent runtime fallback (e.g. a broken flash-attn wheel) must NOT
# open a fresh ungated cohort and seed degraded numbers as the new
# baseline — it should fail the regression gate in the old cohort.
# Intentional backend changes are declared via requested_backend,
# which stays in the hash.
attention.pop("resolved_backend", None)
benchmark = pruned.get("benchmark")
if isinstance(benchmark, dict):
# benchmark_id names artifacts and storage directories, but is not one
# of the six v2 comparison fields. Keep it in the recipe for audit
# while allowing display-only renames to stay in the same cohort.
benchmark.pop("benchmark_id", None)
return sha256_hexdigest(canonical_json(pruned))
def profile_id(prefix: str, profile: Mapping[str, Any]) -> str:
return f"{prefix}-{sha256_hexdigest(canonical_json(profile))[:PROFILE_ID_LENGTH]}"
def hardware_profile_id(profile: Mapping[str, Any]) -> str:
return profile_id("hw", profile)
def software_profile_id(profile: Mapping[str, Any]) -> str:
return profile_id("sw", profile)
def environment_fingerprint(metadata: Mapping[str, Any]) -> str:
return profile_id("env", metadata)
def benchmark_identity_from_config(cfg: Mapping[str, Any]) -> dict[str, Any]:
missing = [key for key in REQUIRED_BENCHMARK_IDENTITY_KEYS if _none_if_empty(cfg.get(key)) is None]
if missing:
raise ValueError("Benchmark config missing required identity fields: " + ", ".join(missing))
return {key: cfg[key] for key in REQUIRED_BENCHMARK_IDENTITY_KEYS}
def build_recipe_from_benchmark_config(
cfg: Mapping[str, Any],
*,
attention_backend: str | None = None,
resolved_attention_backend: Any | None = None,
resolved_model_revision: Any | None = None,
measured_prompts: Sequence[Any] | None = None,
) -> dict[str, Any]:
"""Build a deterministic recipe document from a benchmark config.
The recipe captures fields that describe what benchmark workload was run. It
intentionally excludes timings, timestamps, commit metadata, and output paths.
"""
model = dict(cfg.get("model") or {})
init_kwargs = dict(cfg.get("init_kwargs") or {})
generation_kwargs = dict(cfg.get("generation_kwargs") or {})
benchmark_identity = benchmark_identity_from_config(cfg)
prompts = list(measured_prompts) if measured_prompts is not None else list(
cfg.get("test_prompts") or ["A cinematic video."])
if attention_backend is None:
attention_backend = os.environ.get("FASTVIDEO_ATTENTION_BACKEND")
negative_prompt = generation_kwargs.pop("negative_prompt", generation_kwargs.pop("neg_prompt", None))
generation_recipe = {
key: value
for key, value in generation_kwargs.items()
if key not in _PATH_EXCLUDED_GENERATION_KEYS and key not in _PROMPT_KEYS
}
return {
"recipe_schema_version": RECIPE_SCHEMA_VERSION,
"benchmark": benchmark_identity,
"model": {
"model_path": normalize_model_path(model.get("model_path")),
"revision": _none_if_empty(model.get("revision") or cfg.get("revision")),
"resolved_revision": _none_if_empty(resolved_model_revision),
},
"init_kwargs": init_kwargs,
"generation_kwargs": generation_recipe,
"inputs": {
"prompt_count": len(prompts),
"prompt_sha256": [_optional_digest(prompt) for prompt in prompts],
"negative_prompt_sha256": _optional_digest(negative_prompt),
},
"attention": {
"requested_backend": _none_if_empty(attention_backend) or "auto",
"resolved_backend": _none_if_empty(resolved_attention_backend),
},
}
def normalize_model_path(value: Any) -> str | None:
if value is None:
return None
text = os.fspath(value) if isinstance(value, os.PathLike) else str(value)
text = text.strip()
if not text:
return None
if _looks_like_local_path(text):
return Path(text).expanduser().as_posix()
return re.sub(r"/+", "/", text)
def resolved_revision_from_model_path(value: Any) -> str | None:
normalized = normalize_model_path(value)
if normalized is None:
return None
parts = Path(normalized).parts
for index, part in enumerate(parts[:-1]):
if part == "snapshots":
revision = parts[index + 1]
if re.fullmatch(r"[0-9a-f]{40}", revision):
return revision
return None
def hardware_profile(
*,
num_gpus: int | None = None,
gpu_devices: Sequence[Mapping[str, Any]] | None = None,
device_type: str | None = None,
interconnect: str | None = None,
) -> dict[str, Any]:
"""Return normalized hardware cohort metadata.
``gpu_devices`` is an injection point for tests and non-CUDA callers.
"""
if gpu_devices is not None:
devices = [_normalize_gpu_device(device) for device in gpu_devices]
return {
"device_type": device_type or "cuda",
"gpu_count": num_gpus if num_gpus is not None else len(devices),
"gpus": devices,
"interconnect": interconnect or "unknown",
}
if not torch.cuda.is_available():
return {
"device_type": device_type or "cpu",
"gpu_count": 0,
"gpus": [],
"interconnect": interconnect or "none",
}
gpu_count = int(num_gpus if num_gpus is not None else torch.cuda.device_count())
devices = []
cuda_device_count = torch.cuda.device_count()
for device_id in range(gpu_count):
if device_id >= cuda_device_count:
devices.append(_normalize_gpu_device({}))
continue
props = torch.cuda.get_device_properties(device_id)
capability = torch.cuda.get_device_capability(device_id)
devices.append(
_normalize_gpu_device({
"name": props.name,
"memory_bytes": props.total_memory,
"compute_capability": f"{capability[0]}.{capability[1]}",
}))
return {
"device_type": device_type or "cuda",
"gpu_count": gpu_count,
"gpus": devices,
"interconnect": interconnect or _detect_cuda_interconnect(gpu_count),
}
def software_profile(
*,
python_version: str | None = None,
torch_version: str | None = None,
cuda_version: str | None = None,
package_versions: Mapping[str, str | None] | None = None,
) -> dict[str, Any]:
"""Return normalized software cohort metadata.
Python, PyTorch, and CUDA use major/minor cohorts. Attention and kernel
packages keep exact versions because patch releases can change performance.
"""
versions = dict(package_versions) if package_versions is not None else _installed_package_versions()
return {
"python": _major_minor(python_version or platform.python_version()),
"pytorch": _major_minor(torch_version or torch.__version__),
"cuda": _major_minor(cuda_version or torch.version.cuda),
"packages": {
name: str(version)
for name, version in sorted(versions.items()) if version
},
}
def environment_metadata(
*,
env: Mapping[str, str] | None = None,
package_versions: Mapping[str, str | None] | None = None,
hardware: Mapping[str, Any] | None = None,
software: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Return audit metadata kept separate from comparable identity keys."""
source_env = env if env is not None else os.environ
full_package_versions = dict(package_versions) if package_versions is not None else _installed_package_versions()
return {
"python": {
"version": platform.python_version(),
"implementation": platform.python_implementation(),
},
"platform": {
"system": platform.system(),
"machine": platform.machine(),
"release": platform.release(),
},
"torch": {
"version": torch.__version__,
"cuda": torch.version.cuda,
"cudnn": torch.backends.cudnn.version(),
},
"env": {
key: source_env.get(key)
for key in _PROFILE_ENV_VARS if source_env.get(key) is not None
},
"packages": {
key: value
for key, value in sorted(full_package_versions.items()) if value
},
"hardware_profile":
dict(hardware) if hardware is not None else hardware_profile(),
"software_profile":
dict(software) if software is not None else software_profile(package_versions=full_package_versions),
}
def _canonicalize(value: Any) -> Any:
if dataclasses.is_dataclass(value) and not isinstance(value, type):
return _canonicalize(dataclasses.asdict(value))
if isinstance(value, Mapping):
return {str(key): _canonicalize(value[key]) for key in sorted(value, key=lambda item: str(item))}
if isinstance(value, (set, frozenset)):
return [_canonicalize(item) for item in sorted(value, key=lambda item: str(item))]
if isinstance(value, tuple):
return [_canonicalize(item) for item in value]
if isinstance(value, list):
return [_canonicalize(item) for item in value]
if isinstance(value, enum.Enum):
return value.name
if isinstance(value, Path):
return value.expanduser().as_posix()
if isinstance(value, os.PathLike):
return os.fspath(value)
if isinstance(value, type):
return f"{value.__module__}.{value.__qualname__}"
if isinstance(value, torch.dtype):
return str(value).removeprefix("torch.")
return value
def _none_if_empty(value: Any) -> Any | None:
if value is None:
return None
if isinstance(value, str) and not value.strip():
return None
return value
def _optional_digest(value: Any) -> str | None:
if value is None:
return None
return sha256_hexdigest(str(value))
def _looks_like_local_path(value: str) -> bool:
if value.startswith(("/", "./", "../", "~")):
return True
looks_like_hf_repo = re.match(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", value)
has_separator = "/" in value or "\\" in value or os.sep in value
return has_separator and looks_like_hf_repo is None
def _normalize_gpu_device(device: Mapping[str, Any]) -> dict[str, Any]:
memory_gb = device.get("memory_gb")
if memory_gb is None and device.get("memory_bytes") is not None:
memory_gb = round(float(device["memory_bytes"]) / (1024**3))
return {
"name": _none_if_empty(device.get("name")) or "unknown",
"memory_gb": memory_gb,
"compute_capability": _none_if_empty(device.get("compute_capability")),
}
def _detect_cuda_interconnect(gpu_count: int) -> str:
if gpu_count <= 1:
return "single_gpu"
try:
from fastvideo.platforms import current_platform
if current_platform.is_cuda() and current_platform.is_full_nvlink(list(range(gpu_count))):
return "full_nvlink"
return "none_or_partial"
except Exception:
return "unknown"
def _installed_package_versions() -> dict[str, str | None]:
return {
name: _distribution_version(*distribution_names)
for name, distribution_names in _PACKAGE_DISTRIBUTIONS.items()
}
def _distribution_version(*names: str) -> str | None:
for name in names:
try:
return importlib.metadata.version(name)
except importlib.metadata.PackageNotFoundError:
continue
return None
def _major_minor(version: str | None) -> str | None:
if not version:
return None
match = re.search(r"(\d+)\.(\d+)", version)
if match is None:
return version
return f"{match.group(1)}.{match.group(2)}"
__all__ = [
"RECIPE_SCHEMA_VERSION",
"REQUIRED_BENCHMARK_IDENTITY_KEYS",
"benchmark_identity_from_config",
"canonical_json",
"environment_fingerprint",
"environment_metadata",
"hardware_profile",
"hardware_profile_id",
"normalize_model_path",
"profile_id",
"recipe_fingerprint",
"resolved_revision_from_model_path",
"build_recipe_from_benchmark_config",
"sha256_hexdigest",
"software_profile",
"software_profile_id",
]