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.
This commit is contained in:
loopyt 2026-10-26 12:53:47 +08:00
parent 32d3d2c6bf
commit dcf00aca7c
7 changed files with 707 additions and 1 deletions

View File

@ -23,7 +23,7 @@ main (8b16149)
| `h20-sm90-triton` | `73b8090` | 引擎侧 29 个 kernel 加 Triton 实现(17 个可用),`DEEPINFER_OPS_BACKEND=triton` 切换 | 融合算子库 `tile_kernels` 仍要 TileLang |
| `h20-sm90-triton-flagtree` | `c6a65fe` | 在 flagtree 镜像(torch 2.11 + cu13)里重编 `deepinfer._C` / `deep_gemm` / `deep_ep` / `deep_select`;`tile_kernels` 加 compat 层走 torch 参考,从而不 import TileLang;修掉参考实现在 sync 守卫下抛错的问题 | attention 仍要 TileLang |
| `h20-sm90-sparse-mla-triton` | `6d337a8` | **attention 也换成 FlagGems 的 Triton kernel,于是「屏蔽 tilelang 也能端到端」成立** | 采样器的 rejection-sampling 变体仍要 TileLang(关 MTP 可绕);不与 TileLang 版逐 token 一致 |
| **`h20-sm90-flaggems`** | **进行中** | **把 `tile_kernels` 的 torch 参考换成 FlagGems 的 Triton 实现**(先做已验证的 per-token quant) | 刚起步;`mhc_*` / `engram_*` 多数没有 1:1 对应 |
| **`h20-sm90-flaggems`** | **进行中** | FlagGems 白名单启用已端到端跑通(19 算子、8/8 DP、0 回退);per-token quant 已验证逐位相同 | 只覆盖点状/reduction 算子;GEMM 留 cuBLAS;token 级与不用时不同但确定性 |
## `h20-sm90-sparse-mla-triton` 到底做了什么

View File

@ -78,6 +78,66 @@ use_tma_aligned_col_major_sf=True)`,5 个形状:
`tilelang_ops/` 已经有 Triton 后端,`DEEPINFER_OPS_BACKEND=triton` 切换,与 flag_gems 无关。
## 端到端已跑通(2026-10-26):白名单模式
`DEEPINFER_ENABLE_FLAGGEMS=1` → `deepinfer/flaggems_bootstrap.py` 在 `create_llm` 里、
`setup_cuda_visible_devices()` **之后**调 `flag_gems.only_enable(include=[...])`。
结果:
| | 值 |
|---|---|
| exit code | **0** |
| 后端 | sparse MLA = triton,FlagGems mode=allow |
| DP 一致 | **8/8** |
| kernel 回退 | **无** |
| hard failures | **none** |
| 回答 | 「中国的首都→北京」「日本的首都→东京」,带分段格式 |
| 实际派发算子 | **19 个**、152 处调用点 |
| 注册 | 37 个请求名 → 96 个变体 → 43 个实际注册 |
| 警告 | 0 |
实际在跑的是 `abs/amax/bincount/clamp(3变体)/cos/exp/gather/index_select/neg/nonzero/
reciprocal/relu/rsqrt/sigmoid/sin/sqrt/stack` 这些——点状与 reduction 算子,正是 eager
PyTorch 一次一个 launch 吃亏、Triton 能合并的地方。
### 为什么改成白名单,而不是「全开 + 排除坏的」
我先按「全开+排除」做了 5 轮,每轮都在完整引擎里撞出一个新问题,每轮约 5 分钟:
`cat`(e8m0 dtype) → `mul`(广播形状) → `topk`(编译爆炸) → `rms_norm`(签名) → `mm`(编译断言)。
这条路的性质是「必须先被坑到才能修」,且中途任何状态都是不安全的。
白名单相反:**任何中间状态都是安全的**(没验证的算子留在 PyTorch),不完整只是性能问题,
不是正确性问题。这正好对应「能用就用、不能用就拉到」。
### 踩到的两个方法论错误(都已修)
1. **`torch.mul` 被替换后不能再当参考**。第一版 `ab_mul_dtype.py` 在 `enable()` 之后同时用了
`torch.mul` 和 `flag_gems.mul`,于是「bf16 mul 0 差异」是自比自。现在所有参考值都在
`enable()` **之前**算好并 clone。
2. **「ok」可能根本没走 FlagGems**。`torch.bitwise_xor` 在 FlagGems 里**没有实现**,所以
「bitwise_xor 通过」是 PyTorch 比 PyTorch。`screen_ops.py` 现在先判断该算子是否真的被注册,
否则标 `skip` 并说明「PyTorch answered」。`square` 同理(FlagGems 用别的名字)。
### 名字对不上是坑
FlagGems 的注册名是混合风格:`add`/`mul` 是裸名,但 `eq.Scalar`、`lt.Tensor`、`where.self`、
`repeat_interleave.self_int`、`_softmax`(不是 `softmax`)、`div.true_divide`、`pow.pow_scalar`
是 aten 风格的。只写 `softmax` 会**静默什么都不注册**。现在 `_expand()` 对着
`flag_gems._FULL_CONFIG` 自己做前缀展开,并在匹配不到时打印警告(这次就是靠它发现 `softmax`
应写 `_softmax`)。
### 仍未解决
- **`mm` 家族**:隔离探测(M/N/K 扫描、NT 布局、batch、addmm)全部正确,但引擎里仍在
`general_mm` 撞 `accumulator shape is incompatible`。触发条件没定位,因此 GEMM 继续走 cuBLAS
(本来也更快)。
- **token 级不一致,但是确定性的**:与不用 FlagGems 的版本对比,第 1 个 token 就分叉(上一版
sparse-MLA-only 是前 15 个一致)。但**同配置连跑两次逐 token 完全相同**,所以差异来自
Triton kernel 与 eager PyTorch 的累加顺序,不是不确定性。回答仍然正确连贯,与 SM90 已知
2.7% logits 漂移同源,被贪心解码放大。
- **性能完全没测**。白名单里只有 19 个算子真被用到,收益未知。
## 建议的推进顺序
每一步都是「先 A/B 单测、通过才接线」,沿用 sparse MLA 那次的规矩。

View File

@ -0,0 +1,227 @@
"""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

View File

@ -107,6 +107,13 @@ def create_llm(
import torch
# After the device pin, not before: importing flag_gems initialises a CUDA context, so doing it
# earlier puts every rank on device 0 and NCCL then refuses with "Multiple Ranks are using the
# same GPU". No-op unless DEEPINFER_ENABLE_FLAGGEMS is set.
from deepinfer.flaggems_bootstrap import maybe_enable as maybe_enable_flaggems
maybe_enable_flaggems()
from deepinfer.entrypoints.llm import LLM
with torch.profiler.profile() if profile_init else nullcontext():

View File

@ -0,0 +1,111 @@
"""Is FlagGems' `mul` correct, and for which dtypes?
Method note, because the first version of this script was wrong: once `flag_gems.enable()` runs it also
captures `torch.mul`, so comparing `torch.mul(...)` with `flag_gems.mul(...)` compares FlagGems with
itself. Every reference here is therefore computed **before** `enable()`, in the same process.
The defect it measures: `engram_hash_ref` does `int64 (1,T,N) * int64 (L,1,N)`, and FlagGems returns a
tensor of the *first operand's* shape rather than the broadcast shape, so most of the result is never
written. Excluding `mul` from `enable()` restores the reference result exactly, which is what pointed
here in the first place.
Run: PYTHONPATH=. python scripts/flaggems_port/ab_mul_dtype.py
"""
from __future__ import annotations
import sys
from collections.abc import Callable
import torch
FAILED: list[str] = []
def check(tag: str, cond: bool, detail: str = "") -> None:
print(f" {'ok ' if cond else 'BAD '} {tag}{' ' + detail if detail else ''}")
if not cond:
FAILED.append(tag)
def compare(tag: str, ref: torch.Tensor, got: torch.Tensor) -> None:
if ref.shape != got.shape:
check(tag, False, f"shape ref {tuple(ref.shape)} vs got {tuple(got.shape)}")
return
if ref.dtype != got.dtype:
check(tag, False, f"dtype ref {ref.dtype} vs got {got.dtype}")
return
if ref.dtype.is_floating_point:
diff = (ref.float() - got.float()).abs().max().item()
scale = ref.float().abs().max().item()
tol = 2.0**-8 * max(scale, 1e-9) * 2
check(tag, diff <= tol, f"max diff {diff:.3e} tol {tol:.3e}")
return
same = torch.equal(ref, got)
detail = ""
if not same:
bad = (ref != got).sum().item()
idx = (ref != got).nonzero()[:3]
sample = [(ref[tuple(i)].item(), got[tuple(i)].item()) for i in idx]
detail = f"{bad}/{ref.numel()} wrong, (ref, got) {sample}"
check(tag, same, detail)
def main() -> int:
torch.manual_seed(0)
dev = "cuda"
# ---- references, computed while torch.mul is still PyTorch's ----
a = torch.randint(0, 1000, (1, 64, 8), device=dev, dtype=torch.int64)
b = torch.randint(1, 100, (4, 1, 8), device=dev, dtype=torch.int64)
ref_bcast = torch.mul(a, b).clone()
a_l = torch.randint(2**40, 2**41, (256,), device=dev, dtype=torch.int64)
ref_large = torch.mul(a_l, a_l).clone()
a_i32 = torch.randint(0, 4096, (1024,), device=dev, dtype=torch.int32)
ref_i32 = torch.mul(a_i32, a_i32).clone()
a_bf = torch.randn((2048, 4096), device=dev, dtype=torch.bfloat16) * 0.1
b_bf = torch.randn((2048, 4096), device=dev, dtype=torch.bfloat16) * 0.1
ref_bf16 = torch.mul(a_bf, b_bf).clone()
a_bf_s = torch.randn((4096,), device=dev, dtype=torch.bfloat16)
ref_bf16_scalar = torch.mul(a_bf_s, 3).clone()
ref_i64_scalar = torch.mul(a, 3).clone()
import flag_gems
flag_gems.enable()
print(f"flag_gems {flag_gems.__version__}: enable() done")
print(f"reference broadcast shape: {tuple(ref_bcast.shape)}\n")
cases: list[tuple[str, torch.Tensor, Callable[[], torch.Tensor]]] = [
("int64 mul, broadcasting (1,64,8)*(4,1,8)", ref_bcast, lambda: flag_gems.mul(a, b)),
("int64 mul, large values", ref_large, lambda: flag_gems.mul(a_l, a_l)),
("int32 mul", ref_i32, lambda: flag_gems.mul(a_i32, a_i32)),
("bf16 mul, 8M elements", ref_bf16, lambda: flag_gems.mul(a_bf, b_bf)),
("bf16 mul by scalar", ref_bf16_scalar, lambda: flag_gems.mul(a_bf_s, 3)),
("int64 mul by scalar", ref_i64_scalar, lambda: flag_gems.mul(a, 3)),
]
for tag, ref, fn in cases:
got = fn()
torch.cuda.synchronize()
compare(tag, ref, got)
# torch.mul must show the same defect. If only the direct call did, the dispatcher would be
# fine and the fix would live elsewhere.
got_dispatch = torch.mul(a, b)
torch.cuda.synchronize()
compare("torch.mul (dispatched) shows the same defect", ref_bcast, got_dispatch)
print()
if FAILED:
print(f"DEFECTS: {FAILED}")
return 1
print("no defect found in the dtypes tested")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,73 @@
"""Reproduce the `engram_hash_ref` failure seen with FlagGems enabled, and bisect it.
The end-to-end run failed inside `tile_kernels.torch.engram.engram_hash_ref` with
IndexError: index 1 is out of bounds for dimension 0 with size 1
That function is a plain PyTorch reference, so either FlagGems changed an operator's semantics, or
the engine's own call path is at fault and enabling FlagGems merely shifted when it surfaced. Those
have different fixes, so this script answers "is it FlagGems" first, in-process, without a full
engine run.
Run with FLAGGEMS_REPRO_MODE=torch|full|unused:
torch -- do not enable (control)
full -- flag_gems.enable()
unused -- flag_gems.enable(unused=[...]) with FLAGGEMS_REPRO_EXCLUDE=op1,op2
"""
from __future__ import annotations
import os
import sys
import traceback
import torch
from tile_kernels.torch.engram import engram_hash_ref
MODE = os.environ.get("FLAGGEMS_REPRO_MODE", "torch")
def enable() -> str:
if MODE == "torch":
return "flag_gems: off (control)"
import flag_gems
if MODE == "full":
flag_gems.enable()
return f"flag_gems: full enable, {len(flag_gems.all_registered_ops())} ops"
exclude = [op.strip() for op in os.environ.get("FLAGGEMS_REPRO_EXCLUDE", "").split(",") if op.strip()]
flag_gems.enable(unused=exclude)
return f"flag_gems: enable(unused={exclude})"
def build(device: str = "cuda") -> tuple[torch.Tensor, ...]:
"""Shapes from the engine's config rather than invented ones."""
num_ngram_layers = int(os.environ.get("REPRO_LAYERS", "1"))
max_ngram_size = int(os.environ.get("REPRO_NGRAM", "2"))
tables = 2
num_tokens = int(os.environ.get("REPRO_TOKENS", "16"))
vocab = 4096
torch.manual_seed(0)
# (num_tokens, max_ngram_size) per the docstring: unsqueeze(0) then broadcast against
# multipliers.unsqueeze(1) is what builds (layers, tokens, ngram).
ngram_token_ids = torch.randint(0, 1000, (num_tokens, max_ngram_size), device=device, dtype=torch.int32)
multipliers = torch.randint(1, 100, (num_ngram_layers, max_ngram_size), device=device, dtype=torch.int64)
vocab_sizes = torch.full((num_ngram_layers, max_ngram_size - 1, tables), vocab, device=device, dtype=torch.int32)
offsets = torch.zeros((num_ngram_layers, (max_ngram_size - 1) * tables), device=device, dtype=torch.int32)
return ngram_token_ids, multipliers, vocab_sizes, offsets
if __name__ == "__main__":
print(enable(), flush=True)
args = build()
try:
out = engram_hash_ref(*args)
torch.cuda.synchronize()
print(f"OK out {tuple(out.shape)} {out.dtype} finite={bool(torch.isfinite(out.float()).all())}")
# Print a checksum so runs with different exclude lists can be compared.
print(f" checksum {int(out.to(torch.int64).abs().sum())}")
except Exception:
print("FAIL")
traceback.print_exc()
sys.exit(1)

View File

@ -0,0 +1,228 @@
"""Screen FlagGems ops for the defect class that broke `mul`, so the exclusion list is evidence-based.
`mul` returns the *first operand's shape* whenever broadcasting would expand it:
(1,64,8) * (4,1,8) -> FlagGems (1,64,8), PyTorch (4,64,8)
(64,1) * (1,128) -> FlagGems (64,1), PyTorch (64,128)
Silent, no error. It broke `engram_hash_ref` inside the engine, and it is not dtype-specific.
The traceback went through `flag_gems/utils/pointwise_dynamic.py` and `shape_utils.broadcast_shapes`,
which is shared by every pointwise op -- so the question is which *other* ops inherit it. This script
answers that: each op is run twice, same-shape and broadcasting, and the reference is always taken
before `enable()` so the comparison cannot be FlagGems-against-itself.
Output is a table, which is what the exclusion list in `deepinfer/flaggems_bootstrap.py` is built
from.
Run: PYTHONPATH=. python scripts/flaggems_port/screen_ops.py
"""
from __future__ import annotations
import sys
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
import torch
DEV = "cuda"
@dataclass
class Case:
op: str
label: str
run: Callable[[], Any]
ref: Any
def ref_of(fn: Callable[[], Any]) -> Any:
out = fn()
if isinstance(out, torch.Tensor):
return out.clone()
if isinstance(out, (tuple, list)):
return type(out)(t.clone() if isinstance(t, torch.Tensor) else t for t in out)
return out
def same(ref: Any, got: Any) -> tuple[bool, str]:
if isinstance(ref, torch.Tensor):
if not isinstance(got, torch.Tensor):
return False, f"type {type(got).__name__}"
if ref.shape != got.shape:
return False, f"shape {tuple(ref.shape)} -> {tuple(got.shape)}"
if ref.dtype != got.dtype:
return False, f"dtype {ref.dtype} -> {got.dtype}"
if ref.dtype.is_floating_point:
d = (ref.float() - got.float()).abs().max().item()
s = ref.float().abs().max().item()
tol = 2.0**-8 * max(s, 1e-9) * 2
return (d <= tol, f"max diff {d:.2e}")
eq = torch.equal(ref, got)
if eq:
return True, "exact"
return False, f"{(ref != got).sum().item()}/{ref.numel()} wrong"
if isinstance(ref, (tuple, list)):
for r, g in zip(ref, got, strict=False):
ok, msg = same(r, g)
if not ok:
return False, msg
return True, "exact"
return ref == got, ""
def build_cases() -> list[Case]:
torch.manual_seed(0)
b = 64
n = 8
# (1, b, n) vs (4, 1, n): the exact shape pattern that broke engram_hash.
x3 = torch.randn((1, b, n), device=DEV, dtype=torch.bfloat16)
y3 = torch.randn((4, 1, n), device=DEV, dtype=torch.bfloat16)
i3 = torch.randint(0, 1000, (1, b, n), device=DEV, dtype=torch.int64)
j3 = torch.randint(1, 100, (4, 1, n), device=DEV, dtype=torch.int64)
# (b, 1) vs (1, m): the most common broadcast in transformer code (masks, scales).
x2 = torch.randn((b, 1), device=DEV, dtype=torch.bfloat16)
y2 = torch.randn((1, 128), device=DEV, dtype=torch.bfloat16)
xs = torch.randn((256, 512), device=DEV, dtype=torch.bfloat16)
ys = torch.randn((256, 512), device=DEV, dtype=torch.bfloat16)
cases: list[Case] = []
def add(name: str, label: str, fn: Callable[[], Any]) -> None:
cases.append(Case(name, label, fn, ref_of(fn)))
# binary pointwise, same shape (control: isolates broadcast as the cause)
add("add", "same-shape", lambda: torch.add(xs, ys))
add("mul", "same-shape", lambda: torch.mul(xs, ys))
add("sub", "same-shape", lambda: torch.sub(xs, ys))
add("div", "same-shape", lambda: torch.div(xs, ys))
add("maximum", "same-shape", lambda: torch.maximum(xs, ys))
add("minimum", "same-shape", lambda: torch.minimum(xs, ys))
# binary pointwise, broadcasting (the suspect class)
add("add", "broadcast 3d", lambda: torch.add(x3, y3))
add("mul", "broadcast 3d", lambda: torch.mul(x3, y3))
add("sub", "broadcast 3d", lambda: torch.sub(x3, y3))
add("div", "broadcast 3d", lambda: torch.div(x3, y3))
add("maximum", "broadcast 3d", lambda: torch.maximum(x3, y3))
add("minimum", "broadcast 3d", lambda: torch.minimum(x3, y3))
add("add", "broadcast 2d", lambda: torch.add(x2, y2))
add("mul", "broadcast 2d", lambda: torch.mul(x2, y2))
add("sub", "broadcast 2d", lambda: torch.sub(x2, y2))
# integer bitwise / remainder: what engram_hash also used
add("bitwise_xor_", "broadcast int64", lambda: i3.bitwise_xor(j3))
add("remainder", "broadcast int64", lambda: torch.remainder(i3, j3))
# scalar variants: same code path, different signature
add("mul", "scalar", lambda: torch.mul(xs, 3.0))
add("add", "scalar", lambda: torch.add(xs, 3.0))
# unary: should not involve broadcasting at all
add("silu", "unary", lambda: torch.nn.functional.silu(xs))
add("gelu", "unary", lambda: torch.nn.functional.gelu(xs))
add("abs", "unary", lambda: torch.abs(xs))
# reductions
add("softmax", "last dim", lambda: torch.softmax(xs, dim=-1))
add("amax", "last dim", lambda: torch.amax(xs, dim=-1))
add("sum", "last dim", lambda: torch.sum(xs, dim=-1))
# normalisation; guarded because rms_norm is only in newer torch
if hasattr(torch.nn.functional, "rms_norm"):
add("rms_norm", "1x512", lambda: torch.nn.functional.rms_norm(xs, (512,)))
# more pointwise / reduction candidates for an allow-list
add("exp", "unary", lambda: torch.exp(xs))
add("sigmoid", "unary", lambda: torch.sigmoid(xs))
add("rsqrt", "unary", lambda: torch.rsqrt(xs.abs() + 1))
add("neg", "unary", lambda: torch.neg(xs))
add("relu", "unary", lambda: torch.relu(xs))
add("sqr", "unary", lambda: torch.square(xs))
add("clamp", "min/max", lambda: torch.clamp(xs, -0.5, 0.5))
add("pow", "scalar", lambda: torch.pow(xs, 2))
add("reciprocal", "unary", lambda: torch.reciprocal(xs.abs() + 1))
add("mean", "last dim", lambda: torch.mean(xs, dim=-1))
add("cumsum", "last dim", lambda: torch.cumsum(xs, dim=-1))
add("masked_fill", "scalar", lambda: xs.masked_fill(xs > 0, -1.0))
add("where", "self", lambda: torch.where(xs > 0, xs, -xs))
add("eq", "scalar", lambda: torch.eq(xs, 0.0))
add("lt", "scalar", lambda: torch.lt(xs, 0.0))
add("ge", "scalar", lambda: torch.ge(xs, 0.0))
add("nonzero", "unary", lambda: torch.nonzero(xs > 0.5))
add("gather", "dim0", lambda: torch.gather(xs, 0, torch.zeros((8, 512), device=DEV, dtype=torch.int64)))
add("index_select", "dim0", lambda: torch.index_select(xs, 0, torch.arange(8, device=DEV)))
add("sort", "last dim", lambda: torch.sort(xs, dim=-1).values)
add("sin", "unary", lambda: torch.sin(xs))
add("cos", "unary", lambda: torch.cos(xs))
add("sqrt", "unary", lambda: torch.sqrt(xs.abs()))
add("bitwise_and", "scalar", lambda: torch.bitwise_and(i3, 255))
add("bincount", "int", lambda: torch.bincount(torch.zeros(64, device=DEV, dtype=torch.int64), minlength=8))
# shape ops
add("cat", "2 tensors", lambda: torch.cat([xs, xs], dim=0))
add("stack", "2 tensors", lambda: torch.stack([xs, xs], dim=0))
add("repeat_interleave", "self_int 2", lambda: torch.repeat_interleave(xs, 2, dim=0))
return cases
def main() -> int:
cases = build_cases() # references captured here, before enable()
import flag_gems
flag_gems.enable()
registered = sorted(flag_gems.all_registered_ops())
print(f"flag_gems {flag_gems.__version__}, {len(registered)} ops registered\n")
def served(op: str) -> bool:
"""Did FlagGems actually take this op?
Without this the screen lies by omission: `torch.bitwise_xor` has no FlagGems registration at
all, so a "match" there is PyTorch compared with PyTorch and says nothing about FlagGems. Any
case that is not served is reported as such and cannot enter an allow-list.
"""
return any(n == op or n.startswith(f"{op}.") or n.startswith(f"{op}_") for n in registered)
bad: dict[str, list[str]] = {}
unserved: set[str] = set()
rows = []
for c in cases:
if not served(c.op):
unserved.add(c.op)
rows.append((c.op, c.label, None, "not registered in flag_gems (PyTorch answered)"))
continue
try:
got = c.run()
torch.cuda.synchronize()
ok, msg = same(c.ref, got)
except Exception as exc: # noqa: BLE001 - an exception is itself a finding
ok, msg = False, f"raised {type(exc).__name__}: {str(exc)[:70]}"
rows.append((c.op, c.label, ok, msg))
if not ok:
bad.setdefault(c.op, []).append(c.label)
width = max(len(r[0]) for r in rows) + 2
for op, label, ok, msg in rows:
mark = "skip" if ok is None else ("ok " if ok else "BAD ")
print(f" {mark} {op:<{width}} {label:<16} {msg}")
if unserved:
print(f"\nnot served by flag_gems (cannot be judged here): {sorted(unserved)}")
print()
if bad:
print("UNSAFE OPS (exclude these):")
for op, labels in sorted(bad.items()):
print(f" {op}: {', '.join(labels)}")
return 1
print("all served ops matched")
return 0
if __name__ == "__main__":
sys.exit(main())