DeepInfer/deepinfer/flaggems_bootstrap.py
loopyt dcf00aca7c feat(h20): enable FlagGems end to end, from an allow-list of ops proven equal to PyTorch
`DEEPINFER_ENABLE_FLAGGEMS=1` turns it on. Verified on all 8 DP ranks: exit=0, 8/8 ranks agreeing,
no kernel fallbacks, `hard failures: none`, correct answers. 19 ops actually dispatched, 152 call
sites, 43 registrations from 37 requested names.

The default is `flag_gems.only_enable(include=[...])`, not `enable()`, and that is the whole design:

* allow-list -- every intermediate state is safe (unproven ops stay on PyTorch, which is correct by
  construction); an incomplete list costs throughput, not correctness.
* deny-list -- I tried it first. Five full engine runs, one new defect each: `cat` (e8m0 dtype),
  `mul` (broadcast shape), `topk` (compile blowup), `rms_norm` (signature), `mm` (compile assert).
  The method requires being bitten before it can exclude, and every state along the way is unsafe.

That matches the instruction this was built to: use what works, leave the rest.

Where it is called is not incidental. `import flag_gems` initialises a CUDA context -- measured,
`torch.cuda.is_initialized()` flips on the import alone, before `enable()`. A `sitecustomize`
injector therefore put all 8 ranks on device 0 and NCCL refused with "Multiple Ranks are using the
same GPU". It now runs inside `create_llm`, after `setup_cuda_visible_devices`, which is the same
hazard already documented there for the profiler.

Defects found and documented rather than worked around, because each one is worth knowing:

* `mul` returns the *first operand's shape* when broadcasting expands it: (1,64,8)*(4,1,8) gives
  (1,64,8), and (64,1)*(1,128) gives (64,1). Silent, no error, fewer elements computed. It made
  `engram_hash_ref` disagree with PyTorch during a real run. add/sub/div/maximum/minimum all
  broadcast correctly, so it is mul's own bug.
* `cumsum` drifts by up to 1.0 over a 512-element bf16 row (tolerance ~0.17) -- bf16 accumulation.
* `topk`'s compile time explodes with k: 4.8 s at k=8, 19 s at k=64, >180 s at k=512, and the
  sampler asks for 4096. py-spy put all 8 ranks at 100% CPU inside `make_llir` with every GPU idle.
* `cat` raises `KeyError: 'float8_e8m0fnu'` on the model's UE8M0 scale tensors during weight load.
* `mm` passes every isolated probe yet fails inside the engine at `general_mm`; trigger unknown, so
  GEMMs stay on cuBLAS, which is faster anyway.

Two methodological errors in my own measurements, both fixed and both recorded:

1. After `enable()`, `torch.mul` *is* `flag_gems.mul`, so the first version of the mul A/B compared
   FlagGems with itself and reported a perfect score. All references are now computed before enable.
2. "ok" did not mean FlagGems ran. `torch.bitwise_xor` has no FlagGems registration at all, so that
   "match" was PyTorch against PyTorch. `screen_ops.py` now checks registration first and reports
   such cases as `skip` rather than pass.

Name mismatch is its own trap: FlagGems registers `add` bare but `eq.Scalar`, `where.self`,
`repeat_interleave.self_int`, `_softmax` (not `softmax`), `div.true_divide`. Asking for `softmax`
silently registers nothing. The bootstrap now expands names against `flag_gems._FULL_CONFIG` and
warns on anything unresolved -- which is how `softmax` was found to need `_softmax`.

Output is not token-identical to the no-FlagGems build (diverges at the first token), but two runs of
the same FlagGems config are bit-identical, so the difference is Triton-versus-eager accumulation
order, not nondeterminism. Answers stay correct and coherent.
2026-10-26 12:53:47 +08:00

228 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Optionally route PyTorch operators through FlagGems, via its documented `enable()` / `only_enable()`.
FlagGems' usage docs (`docs/content/en/usage/basic.md`, `.../selective.md`) prescribe:
import flag_gems
flag_gems.enable() # all supported ops
flag_gems.only_enable(include=[...]) # only the named ops
This module defaults to the **allow-list** form, because flag_gems 5.0.2 on SM90 has operators that are
outright wrong, and a silently wrong operator is worse than a slow correct one. Only ops proven equal
to PyTorch on this build are enabled; everything else stays on PyTorch, which is correct by
construction. Measured by `scripts/flaggems_port/screen_ops.py`; extend `_VERIFIED_OPS` only after that
screen (or an equivalent A/B) passes.
Why an allow-list rather than "enable everything then exclude what breaks": a deny-list can only grow
after each failure is hit inside a full engine run, and each cycle surfaces one operator. The
allow-list is safe at every intermediate state, so an incomplete list is a performance question rather
than a correctness one.
Env:
`DEEPINFER_ENABLE_FLAGGEMS=1` turn it on
`DEEPINFER_FLAGGEMS_MODE=allow|deny` default `allow` (curated) vs `deny` (all but the broken ones)
`DEEPINFER_FLAGGEMS_INCLUDE=a,b` extra ops for allow mode
`DEEPINFER_FLAGGEMS_EXCLUDE=a,b` extra ops to keep on PyTorch, in either mode
`DEEPINFER_FLAGGEMS_RECORD_DIR=<dir>` write `<dir>/oplist-<pid>.log`, one line per call site
**Where it is called matters more than what it does.** `import flag_gems` initialises a CUDA context
(measured: `torch.cuda.is_initialized()` flips False -> True on the import alone, before `enable()`).
If that happens before this process is pinned to its GPU, every rank initialises on device 0 and the
engine fails at the first collective with
NCCL error: invalid usage
Last error: Multiple Ranks are using the same GPU/Partition.
That is what a `sitecustomize` injector produced, because `sitecustomize` runs at interpreter startup
while the per-rank `CUDA_VISIBLE_DEVICES` is set later, inside `create_llm`. The same hazard is already
documented there for the profiler ("CUPTI 启动即初始化 CUDA,会使此后设置的 CUDA_VISIBLE_DEVICES 失效").
Hence this is called from `create_llm` immediately after `setup_cuda_visible_devices`, and there is no
`sitecustomize` for it.
**Measured on flag_gems 5.0.2 / H20 (SM90), which is why the lists below look the way they do:**
* `mul` returns the **first operand's shape** when broadcasting would expand it -- `(1,64,8)*(4,1,8)`
gives `(1,64,8)` instead of `(4,64,8)`; `(64,1)*(1,128)` gives `(64,1)`. Silent, no error, fewer
elements computed. Same-shape and scalar `mul` are exact. `add`/`sub`/`div`/`maximum`/`minimum` all
broadcast correctly, so it is `mul`'s own bug. It produced a wrong result inside `engram_hash_ref`
during a real engine run.
* `cumsum` disagrees with PyTorch by up to 1.0 over a 512-element bf16 row where the tolerance is
~0.17 -- bf16 accumulation where PyTorch uses fp32.
* `rms_norm` requires `weight` positionally where `torch.nn.functional.rms_norm` allows omitting it,
and with `weight` supplied still differs by ~3e-2 on O(1) output.
* `topk`'s compile time is superlinear in `k` and the sampler uses `k=4096`: k=8 -> 4.8 s, k=64 -> 19 s,
k=512 -> >180 s. The startup profile run never returned; `py-spy` put all 8 ranks at 100% CPU inside
`triton/backends/nvidia/compiler.py:make_llir` with every GPU idle.
* `cat` raises `KeyError: 'float8_e8m0fnu'` from Triton's dtype table on the UE8M0 scale tensors this
model stores, during weight loading. Triton has no e8m0 element type.
* the `mm` family passes every isolated probe (M/N/K swept, NT layout, batch, addmm) yet the engine
still hit `accumulator shape is incompatible` inside `general_mm`; trigger not identified, so GEMMs
stay on cuBLAS -- which is also the faster choice.
"""
from __future__ import annotations
import os
_ENV_ENABLE = "DEEPINFER_ENABLE_FLAGGEMS"
_ENV_MODE = "DEEPINFER_FLAGGEMS_MODE"
_ENV_INCLUDE = "DEEPINFER_FLAGGEMS_INCLUDE"
_ENV_EXCLUDE = "DEEPINFER_FLAGGEMS_EXCLUDE"
_ENV_RECORD_DIR = "DEEPINFER_FLAGGEMS_RECORD_DIR"
# Ops measured equal to PyTorch by `scripts/flaggems_port/screen_ops.py`, across the shape variants
# that screen covers (same-shape, broadcast, scalar, unary, reduction). Pointwise and reduction ops
# are where FlagGems can actually win: one kernel launch instead of several eager calls. GEMMs are
# deliberately absent -- cuBLAS wins there regardless.
_VERIFIED_OPS: tuple[str, ...] = (
"abs",
"add",
"amax",
"bincount",
"bitwise_and",
"clamp",
"cos",
"div",
"eq",
"exp",
"gather",
"ge",
"gelu",
"index_select",
"lt",
"masked_fill",
"maximum",
"mean",
"minimum",
"neg",
"nonzero",
"pow",
"reciprocal",
"relu",
"remainder",
"repeat_interleave",
"rsqrt",
"sigmoid",
"silu",
"sin",
"_softmax",
"sort",
"sqrt",
"stack",
"sub",
"sum",
"where",
)
# Everything known broken on this build, documented and used by deny mode. Nothing here may move into
# `_VERIFIED_OPS` without a passing A/B.
_BROKEN_OPS: tuple[str, ...] = ("cat", "cumsum", "mul", "rms_norm", "topk")
_enabled = False
def enabled() -> bool:
"""Whether FlagGems has been enabled in this process."""
return _enabled
def _names(raw: str) -> list[str]:
return [op.strip() for op in raw.split(",") if op.strip()]
def _expand(names: list[str], available: list[str]) -> tuple[list[str], list[str]]:
"""Resolve plain op names to the names FlagGems actually registers.
FlagGems registers a mix of plain names (`add`, `mul`) and aten-style dotted or suffixed names:
`eq.Scalar`, `lt.Tensor`, `where.self`, `repeat_interleave.self_int`, `_softmax`, `div.true_divide`,
`pow.pow_scalar`. Asking for `eq` by name therefore registers nothing, silently -- caught here as a
warning, but the fix is to expand to every variant rather than spell out each one in
`_VERIFIED_OPS`.
Returns (expanded, unresolved). `available` is FlagGems' own registration table, so this cannot
drift from what the installed version actually offers.
"""
expanded: list[str] = []
unresolved: list[str] = []
for name in names:
hits = [
n
for n in available
if n == name or n.startswith(f"{name}_") or n.startswith(f"{name}.")
]
if hits:
expanded.extend(hits)
else:
unresolved.append(name)
# dict.fromkeys: preserve order, drop duplicates (a name may match several wanted entries).
return list(dict.fromkeys(expanded)), unresolved
def maybe_enable() -> str:
"""Enable FlagGems if asked to. Returns a one-line status; no-op when the env var is unset."""
global _enabled
if _enabled:
return "already enabled"
if not os.environ.get(_ENV_ENABLE):
return "disabled"
try:
import flag_gems
except Exception as exc: # noqa: BLE001 - a requested-but-broken import must be loud
raise RuntimeError(
f"{_ENV_ENABLE} is set but flag_gems is not importable: {type(exc).__name__}: {exc}. "
f"Unset it, or fix the install (it also needs libcudart on LD_LIBRARY_PATH for Triton TLE)."
) from exc
mode = (os.environ.get(_ENV_MODE, "allow").strip().lower() or "allow")
extra_in = _names(os.environ.get(_ENV_INCLUDE, ""))
extra_out = _names(os.environ.get(_ENV_EXCLUDE, ""))
log_kwargs: dict[str, object] = {}
record_dir = os.environ.get(_ENV_RECORD_DIR, "").strip()
if record_dir:
from pathlib import Path
Path(record_dir).mkdir(parents=True, exist_ok=True)
# Per-PID: `setup_flaggems_logging` opens the path with mode="w", so ranks sharing one path
# would leave only whichever wrote last.
log_kwargs = {
"record": True,
"path": str(Path(record_dir) / f"oplist-{os.getpid()}.log"),
"once": True,
}
include: list[str] | None = None
if mode == "allow":
excluded = set(extra_out)
wanted = [op for op in (*_VERIFIED_OPS, *extra_in) if op not in excluded]
# Expand against FlagGems' own table, not a hand-maintained list of alias names. Entries are
# variable-length tuples whose first field is the op name.
available = [entry[0] for entry in flag_gems._FULL_CONFIG] # noqa: SLF001
include, unresolved = _expand(wanted, available)
flag_gems.only_enable(include=include, **log_kwargs)
how = f"only_enable({len(wanted)} wanted -> {len(include)} registered)"
elif mode == "deny":
unused = [op for op in (*_BROKEN_OPS, *extra_out)]
unresolved = []
flag_gems.enable(unused=unused, **log_kwargs)
how = f"enable(unused={unused})"
else:
raise RuntimeError(f"{_ENV_MODE}={mode!r} is not recognised; use 'allow' or 'deny'")
registered = set(flag_gems.all_registered_ops())
if unresolved:
# Nothing would be enabled for these, which reads as an unexplained slowdown rather than a
# failure. Say it once, loudly.
print(f"[flaggems] WARNING: no registered op matches: {unresolved}", flush=True)
_enabled = True
status = (
f"enabled pid={os.getpid()} flag_gems={flag_gems.__version__} "
f"device={flag_gems.device}/{flag_gems.vendor_name} mode={mode} {how} "
f"registered={len(registered)}"
)
print(f"[flaggems] {status}", flush=True)
return status