mirror of https://github.com/razor-ai/soup.git
317 lines
10 KiB
Python
317 lines
10 KiB
Python
"""Kernel auto-composition — benchmark and pick the fastest kernel combo.
|
|
|
|
Enumerates installed performance kernels (Liger, FlashAttention, torch
|
|
baseline) and picks the fastest combination for the current GPU. Benchmarks
|
|
each candidate on a small warm-up loop and selects the one with the lowest
|
|
observed step time.
|
|
|
|
This is a config-resolver helper: the benchmarking loop is expected to be
|
|
driven by the trainer wrapper (a few warm-up steps before the real train
|
|
loop). Here we only provide:
|
|
|
|
- ``enumerate_kernel_combos(backend, device)`` — list candidate combos
|
|
- ``pick_best_kernel(candidates)`` — choose the fastest from timing results
|
|
|
|
Design: we never auto-enable combos that the user has *disabled* via their
|
|
TrainingConfig (e.g. if ``use_liger: false`` explicitly, it stays false). The
|
|
picker only searches within combos the user hasn't opted out of.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
|
|
def enumerate_kernel_combos(
|
|
backend: str, device: str,
|
|
) -> list[dict[str, Any]]:
|
|
"""Enumerate candidate kernel combinations for the current environment.
|
|
|
|
Returns a list of dicts: ``{"name", "use_liger", "use_flash_attn",
|
|
"use_cut_ce"}``. The list always contains a ``baseline`` entry (no special
|
|
kernels) so that the picker has a reference point.
|
|
|
|
Rules:
|
|
- CPU → only baseline.
|
|
- unsloth backend → baseline only (unsloth uses its own kernels internally).
|
|
- mlx backend → baseline only (Apple Silicon path doesn't share kernels).
|
|
- cuda + transformers → baseline + each available kernel + known-good combos.
|
|
"""
|
|
baseline = {
|
|
"name": "baseline",
|
|
"use_liger": False,
|
|
"use_flash_attn": False,
|
|
"use_cut_ce": False,
|
|
}
|
|
|
|
# CPU: nothing to compose
|
|
if device != "cuda":
|
|
return [baseline]
|
|
|
|
# Unsloth + MLX have their own kernel paths - picker would just confuse them
|
|
if backend in ("unsloth", "mlx"):
|
|
return [baseline]
|
|
|
|
combos: list[dict[str, Any]] = [baseline]
|
|
|
|
# Probe availability — lazy imports inside each helper
|
|
try:
|
|
from soup_cli.utils.liger import check_liger_available
|
|
|
|
liger_ok = check_liger_available()
|
|
except ImportError:
|
|
liger_ok = False
|
|
|
|
try:
|
|
from soup_cli.utils.flash_attn import check_flash_attn_available
|
|
|
|
flash_ok = check_flash_attn_available() is not None
|
|
except ImportError:
|
|
flash_ok = False
|
|
|
|
try:
|
|
from soup_cli.utils.cut_ce import check_cut_ce_available
|
|
|
|
cce_ok = check_cut_ce_available()
|
|
except ImportError:
|
|
cce_ok = False
|
|
|
|
if liger_ok:
|
|
combos.append({
|
|
"name": "liger",
|
|
"use_liger": True,
|
|
"use_flash_attn": False,
|
|
"use_cut_ce": False,
|
|
})
|
|
|
|
if flash_ok:
|
|
combos.append({
|
|
"name": "flash",
|
|
"use_liger": False,
|
|
"use_flash_attn": True,
|
|
"use_cut_ce": False,
|
|
})
|
|
|
|
if liger_ok and flash_ok:
|
|
combos.append({
|
|
"name": "liger+flash",
|
|
"use_liger": True,
|
|
"use_flash_attn": True,
|
|
"use_cut_ce": False,
|
|
})
|
|
|
|
if cce_ok:
|
|
combos.append({
|
|
"name": "cut_ce",
|
|
"use_liger": False,
|
|
"use_flash_attn": False,
|
|
"use_cut_ce": True,
|
|
})
|
|
|
|
if liger_ok and flash_ok and cce_ok:
|
|
combos.append({
|
|
"name": "liger+flash+cut_ce",
|
|
"use_liger": True,
|
|
"use_flash_attn": True,
|
|
"use_cut_ce": True,
|
|
})
|
|
|
|
return combos
|
|
|
|
|
|
def benchmark_kernel_combos(
|
|
model: Any,
|
|
candidates: list[dict[str, Any]],
|
|
*,
|
|
device: str = "cuda",
|
|
batch_size: int = 1,
|
|
seq_len: int = 16,
|
|
num_steps: int = 10,
|
|
vocab_size: int = 32_000,
|
|
) -> list[dict[str, Any]]:
|
|
"""Run a tiny forward+backward warm-up loop per candidate and record time_ms.
|
|
|
|
v0.35.0 #45 — closes the picker's "deterministic name-hash tiebreak"
|
|
fallback by feeding it real measurements from the trainer's own model.
|
|
Returns a NEW list of candidate dicts annotated with ``time_ms``; the
|
|
original list is not mutated.
|
|
|
|
On CPU / no-CUDA / torch unavailable, every candidate's ``time_ms`` is
|
|
set to ``None`` — :func:`pick_best_kernel` will reject the result and
|
|
the caller is expected to degrade to picker-without-bench.
|
|
|
|
The benchmark intentionally does NOT swap kernels mid-loop (that would
|
|
require model re-instantiation per candidate which is too expensive on
|
|
CI-sized models). Instead we run identical forward+backward across
|
|
candidates and record the relative ordering — useful as a coarse
|
|
"did anything install correctly" health check.
|
|
"""
|
|
# bool is a subclass of int — reject explicitly so that True / False
|
|
# don't sneak in as 1 / 0 (matches v0.30.0 Candidate / v0.34.0 cost
|
|
# estimator policy).
|
|
for arg_name, arg_val in (
|
|
("batch_size", batch_size), ("seq_len", seq_len),
|
|
("num_steps", num_steps), ("vocab_size", vocab_size),
|
|
):
|
|
if isinstance(arg_val, bool):
|
|
raise TypeError(f"{arg_name} must be int, not bool")
|
|
|
|
out: list[dict[str, Any]] = [dict(c) for c in candidates]
|
|
|
|
if device != "cuda":
|
|
for entry in out:
|
|
entry["time_ms"] = None
|
|
return out
|
|
|
|
try:
|
|
import torch
|
|
except ImportError:
|
|
for entry in out:
|
|
entry["time_ms"] = None
|
|
return out
|
|
|
|
if not torch.cuda.is_available():
|
|
for entry in out:
|
|
entry["time_ms"] = None
|
|
return out
|
|
|
|
if model is None:
|
|
for entry in out:
|
|
entry["time_ms"] = None
|
|
return out
|
|
|
|
# Bound caller-supplied numbers so a misconfigured caller cannot OOM the
|
|
# CI runner with seq_len=10**6.
|
|
bs = max(1, min(int(batch_size), 32))
|
|
sl = max(1, min(int(seq_len), 512))
|
|
steps = max(1, min(int(num_steps), 50))
|
|
vs = max(1024, min(int(vocab_size), 200_000))
|
|
|
|
try:
|
|
input_ids = torch.randint(
|
|
0, vs, (bs, sl), device="cuda", dtype=torch.long,
|
|
)
|
|
except (RuntimeError, OSError):
|
|
for entry in out:
|
|
entry["time_ms"] = None
|
|
return out
|
|
|
|
for entry in out:
|
|
entry["time_ms"] = _time_one_combo(model, input_ids, steps=steps)
|
|
return out
|
|
|
|
|
|
def _time_one_combo(
|
|
model: Any, input_ids: Any, *, steps: int,
|
|
) -> "float | None":
|
|
"""Run ``steps`` forward passes on ``model`` and return mean ms per step.
|
|
|
|
v0.35.0 review fix — the benchmark is forward-only under ``torch.no_grad``
|
|
so the live training model's parameter ``.grad`` tensors stay clean. The
|
|
relative ordering between kernel combos is preserved (backward time is
|
|
approximately proportional to forward time across HF causal LMs), and
|
|
backward+optimizer state is what we MUST NOT pollute — corrupting the
|
|
first real optimizer step with benchmark gradients was the original bug.
|
|
|
|
Returns ``None`` on any error (OOM / model rejects shape) so the caller
|
|
can degrade gracefully.
|
|
"""
|
|
import math
|
|
import time
|
|
|
|
import torch # benchmark only runs on CUDA path; torch is guaranteed here.
|
|
|
|
try:
|
|
with torch.no_grad():
|
|
torch.cuda.synchronize()
|
|
# Warm-up step (not timed) — first call triggers cuDNN autotune.
|
|
outputs = model(input_ids=input_ids)
|
|
if _extract_logits_or_loss(outputs) is None:
|
|
return None
|
|
torch.cuda.synchronize()
|
|
|
|
start = time.perf_counter()
|
|
for _ in range(steps):
|
|
outputs = model(input_ids=input_ids)
|
|
if _extract_logits_or_loss(outputs) is None:
|
|
return None
|
|
torch.cuda.synchronize()
|
|
elapsed_ms = (time.perf_counter() - start) * 1000.0 / steps
|
|
if not math.isfinite(elapsed_ms):
|
|
return None
|
|
return elapsed_ms
|
|
except (RuntimeError, ValueError, TypeError, AttributeError):
|
|
return None
|
|
|
|
|
|
def _extract_logits_or_loss(outputs: Any) -> Any:
|
|
"""Return a model-output tensor — used only to verify the forward pass
|
|
produced something. Forward-only benchmarks don't need a backward-able
|
|
loss, just a sentinel to confirm the model accepted the input shape.
|
|
"""
|
|
for attr in ("loss", "logits", "last_hidden_state"):
|
|
value = getattr(outputs, attr, None)
|
|
if value is not None:
|
|
return value
|
|
return None
|
|
|
|
|
|
def pick_best_kernel(
|
|
candidates: list[dict[str, Any]],
|
|
) -> dict[str, Any]:
|
|
"""Pick the fastest kernel combo given benchmarked timings.
|
|
|
|
Args:
|
|
candidates: List of dicts each with ``name`` and ``time_ms`` fields.
|
|
|
|
Returns:
|
|
The single candidate dict with the lowest ``time_ms``.
|
|
Ties are broken by order (first candidate wins) so callers can place
|
|
the preferred default (baseline) first.
|
|
|
|
Raises:
|
|
ValueError: if ``candidates`` is empty or if **all** candidates are
|
|
missing a finite ``time_ms`` (no benchmark signal — picking blindly
|
|
would mask a silent infrastructure failure).
|
|
"""
|
|
if not candidates:
|
|
raise ValueError("pick_best_kernel requires at least one candidate")
|
|
|
|
finite = [c for c in candidates if _finite_time_ms(c.get("time_ms"))]
|
|
if not finite:
|
|
raise ValueError(
|
|
"pick_best_kernel: no candidate has a finite time_ms — "
|
|
"benchmarking appears to have failed for every combo."
|
|
)
|
|
|
|
# Stable sort: ties go to the one earlier in the list (baseline usually).
|
|
return min(candidates, key=lambda c: _sortable_time_ms(c.get("time_ms")))
|
|
|
|
|
|
def _finite_time_ms(value: Any) -> bool:
|
|
"""True if ``value`` is a real finite number (not None, not NaN, not inf)."""
|
|
import math
|
|
|
|
if value is None:
|
|
return False
|
|
try:
|
|
as_float = float(value)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
return math.isfinite(as_float)
|
|
|
|
|
|
def _sortable_time_ms(value: Any) -> float:
|
|
"""Convert ``time_ms`` to a sortable float; missing/NaN → +inf."""
|
|
import math
|
|
|
|
if value is None:
|
|
return float("inf")
|
|
try:
|
|
as_float = float(value)
|
|
except (TypeError, ValueError):
|
|
return float("inf")
|
|
if math.isnan(as_float):
|
|
return float("inf")
|
|
return as_float
|