carmen-kernels 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- carmen/__init__.py +7 -0
- carmen/backends/__init__.py +11 -0
- carmen/backends/base.py +102 -0
- carmen/backends/metal.py +129 -0
- carmen/bench.py +102 -0
- carmen/broken.py +213 -0
- carmen/carmy.py +254 -0
- carmen/champions/__init__.py +49 -0
- carmen/champions/published.json +11 -0
- carmen/champions/qwen2.5-0.5b-4bit/mlp_down.json +15 -0
- carmen/champions/qwen2.5-0.5b-4bit/mlp_up.json +15 -0
- carmen/champions/qwen2.5-0.5b-4bit/result.json +14 -0
- carmen/cli.py +607 -0
- carmen/dotenv.py +36 -0
- carmen/e2e.py +665 -0
- carmen/events.py +30 -0
- carmen/golden/add_rmsnorm.metal +30 -0
- carmen/golden/attention.header.metal +8 -0
- carmen/golden/attention.metal +58 -0
- carmen/golden/layernorm.metal +45 -0
- carmen/golden/masked_softmax.metal +46 -0
- carmen/golden/matmul.metal +19 -0
- carmen/golden/mlp_down.metal +30 -0
- carmen/golden/mlp_up.metal +54 -0
- carmen/golden/residual_rmsnorm.metal +33 -0
- carmen/golden/rmsnorm.metal +29 -0
- carmen/golden/softmax.metal +44 -0
- carmen/judge/__init__.py +136 -0
- carmen/judge/check.py +136 -0
- carmen/judge/stats.py +38 -0
- carmen/judge/worker.py +294 -0
- carmen/loop.py +289 -0
- carmen/memory.py +138 -0
- carmen/ops/__init__.py +28 -0
- carmen/ops/add_rmsnorm.py +121 -0
- carmen/ops/attention.py +174 -0
- carmen/ops/base.py +208 -0
- carmen/ops/layernorm.py +125 -0
- carmen/ops/masked_softmax.py +133 -0
- carmen/ops/matmul.py +132 -0
- carmen/ops/mlp.py +322 -0
- carmen/ops/residual_rmsnorm.py +108 -0
- carmen/ops/rmsnorm.py +114 -0
- carmen/ops/softmax.py +140 -0
- carmen/profile.py +256 -0
- carmen/target.py +73 -0
- carmen/tui.py +1080 -0
- carmen/ui.py +64 -0
- carmen_kernels-0.1.0.dist-info/METADATA +144 -0
- carmen_kernels-0.1.0.dist-info/RECORD +54 -0
- carmen_kernels-0.1.0.dist-info/WHEEL +5 -0
- carmen_kernels-0.1.0.dist-info/entry_points.txt +2 -0
- carmen_kernels-0.1.0.dist-info/licenses/LICENSE +21 -0
- carmen_kernels-0.1.0.dist-info/top_level.txt +1 -0
carmen/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""carmen: a self-correcting harness that gets LLMs to write fast GPU kernels you can trust.
|
|
2
|
+
|
|
3
|
+
The model (Carmy) is the intelligence; the judge is the truth; correction is the loop
|
|
4
|
+
between them, and memory is that loop's verified residue.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .base import PAD, Adapter, Kernel, static_check
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get(name: str):
|
|
5
|
+
if name == "metal":
|
|
6
|
+
from .metal import MetalAdapter
|
|
7
|
+
return MetalAdapter()
|
|
8
|
+
raise KeyError(f"unknown backend {name!r}; available: metal")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
__all__ = ["PAD", "Adapter", "Kernel", "static_check", "get"]
|
carmen/backends/base.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""What a kernel is, and what a chip adapter must provide.
|
|
2
|
+
|
|
3
|
+
A kernel is Metal (or CUDA, later) *source text* plus launch configs. It is never
|
|
4
|
+
Python: Carmy cannot run code inside the judge, only submit a kernel body.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Callable, Protocol
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
# Extra elements allocated after the real output. They start as NaN and must stay NaN:
|
|
17
|
+
# a write here means the kernel wrote past the end of its output.
|
|
18
|
+
PAD = 64
|
|
19
|
+
|
|
20
|
+
MAX_SOURCE_CHARS = 30_000
|
|
21
|
+
MAX_CONFIGS = 6
|
|
22
|
+
BANNED_TOKENS = ("#include", "asm(", "__asm")
|
|
23
|
+
# Loop-unrolling hints are harmless and often faster; any other pragma is refused.
|
|
24
|
+
ALLOWED_PRAGMAS = ("#pragma unroll", "#pragma clang loop")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Kernel:
|
|
29
|
+
source: str
|
|
30
|
+
header: str = ""
|
|
31
|
+
# Each config maps template constant -> int. "TG" (threads per threadgroup) is required.
|
|
32
|
+
configs: list[dict[str, int]] = field(default_factory=lambda: [{"TG": 256}])
|
|
33
|
+
plan: str = ""
|
|
34
|
+
|
|
35
|
+
def digest(self) -> str:
|
|
36
|
+
blob = json.dumps([self.source, self.header], sort_keys=True).encode()
|
|
37
|
+
return hashlib.sha256(blob).hexdigest()[:12]
|
|
38
|
+
|
|
39
|
+
def to_json(self) -> dict:
|
|
40
|
+
return {"source": self.source, "header": self.header, "configs": self.configs, "plan": self.plan}
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def from_json(d: dict) -> "Kernel":
|
|
44
|
+
return Kernel(d["source"], d.get("header", ""), d.get("configs") or [{"TG": 256}], d.get("plan", ""))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def static_check(kernel: Kernel) -> str | None:
|
|
48
|
+
"""Cheap checks before touching the GPU. Returns an error message or None."""
|
|
49
|
+
text = kernel.source + "\n" + kernel.header
|
|
50
|
+
if not kernel.source.strip():
|
|
51
|
+
return "empty kernel source"
|
|
52
|
+
if len(text) > MAX_SOURCE_CHARS:
|
|
53
|
+
return f"kernel is {len(text)} chars; limit is {MAX_SOURCE_CHARS}"
|
|
54
|
+
for tok in BANNED_TOKENS:
|
|
55
|
+
if tok in text:
|
|
56
|
+
return f"banned token {tok!r} (MLX already includes the Metal standard library)"
|
|
57
|
+
for line in text.splitlines():
|
|
58
|
+
if line.strip().startswith("#pragma") and not line.strip().startswith(ALLOWED_PRAGMAS):
|
|
59
|
+
return f"only loop-unrolling pragmas are allowed, got {line.strip()!r}"
|
|
60
|
+
if not 1 <= len(kernel.configs) <= MAX_CONFIGS:
|
|
61
|
+
return f"give between 1 and {MAX_CONFIGS} configs"
|
|
62
|
+
for cfg in kernel.configs:
|
|
63
|
+
tg = cfg.get("TG")
|
|
64
|
+
if not isinstance(tg, int) or tg < 32 or tg > 1024 or tg % 32:
|
|
65
|
+
return f"config {cfg}: TG must be a multiple of 32 between 32 and 1024"
|
|
66
|
+
for k, v in cfg.items():
|
|
67
|
+
if not k.isidentifier() or not isinstance(v, int) or not 0 <= v <= 65536:
|
|
68
|
+
return f"config {cfg}: constants must be identifiers mapped to ints in [0, 65536]"
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Adapter(Protocol):
|
|
73
|
+
"""The only chip-specific surface. Adding a new chip means writing one of these."""
|
|
74
|
+
|
|
75
|
+
name: str
|
|
76
|
+
|
|
77
|
+
def chip(self) -> str: ...
|
|
78
|
+
|
|
79
|
+
def build(self, op, kernel: Kernel):
|
|
80
|
+
"""Compile the kernel. Raise on compile errors."""
|
|
81
|
+
|
|
82
|
+
def upload(self, inputs: dict[str, np.ndarray], dtype: str = "float32"):
|
|
83
|
+
"""Move numpy inputs to the device once, so timing excludes transfers. For bfloat16,
|
|
84
|
+
inputs arrive as float32 arrays of exact bf16 values and are cast on the device."""
|
|
85
|
+
|
|
86
|
+
def launch(self, built, op, config: dict, dev_inputs, rows: int, n: int, dtype: str,
|
|
87
|
+
inner: int = 1) -> Callable[[], None]:
|
|
88
|
+
"""Return a function that runs the kernel `inner` times back to back and blocks until done.
|
|
89
|
+
For timing only: must not add work the stock op doesn't do (e.g. no NaN pre-fill)."""
|
|
90
|
+
|
|
91
|
+
def run(self, built, op, config: dict, dev_inputs, rows: int, n: int, dtype: str) -> np.ndarray:
|
|
92
|
+
"""Run once; return the flat output (rows * n + PAD elements), NaN-poisoned before launch."""
|
|
93
|
+
|
|
94
|
+
def baseline(self, op, dev_inputs) -> Callable[[], np.ndarray]:
|
|
95
|
+
"""Return a function that runs the stock library op, blocks, and returns its output."""
|
|
96
|
+
|
|
97
|
+
def baseline_launch(self, op, dev_inputs, inner: int = 1, compiled: bool = False) -> Callable[[], None]:
|
|
98
|
+
"""Return a function that runs the stock library op `inner` times and blocks (for timing).
|
|
99
|
+
`compiled=True` wraps it in the library's graph compiler (mx.compile), which fuses element-wise ops."""
|
|
100
|
+
|
|
101
|
+
def measure_peak_gbps(self) -> float:
|
|
102
|
+
"""Practical memory bandwidth from a copy kernel."""
|
carmen/backends/metal.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Apple Metal via MLX's mx.fast.metal_kernel. The only file that knows about Metal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from ..ops.base import SCALAR_INPUTS
|
|
10
|
+
from .base import PAD, Kernel
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import mlx.core as mx
|
|
14
|
+
except ImportError as e: # pragma: no cover - only on machines without MLX
|
|
15
|
+
raise ImportError("the metal backend needs MLX on Apple Silicon: pip install 'carmen-kernels[metal]'") from e
|
|
16
|
+
|
|
17
|
+
_DTYPES = {"float32": mx.float32, "float16": mx.float16, "bfloat16": mx.bfloat16}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _host(a) -> np.ndarray:
|
|
21
|
+
"""numpy can't hold bfloat16: read it back as float32 (exact)."""
|
|
22
|
+
return np.array(a.astype(mx.float32) if a.dtype == mx.bfloat16 else a)
|
|
23
|
+
|
|
24
|
+
COPY_KERNEL = """
|
|
25
|
+
uint i = thread_position_in_grid.x;
|
|
26
|
+
if (i < inp_shape[0]) { out[i] = inp[i]; }
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MetalAdapter:
|
|
31
|
+
name = "metal"
|
|
32
|
+
|
|
33
|
+
def chip(self) -> str:
|
|
34
|
+
info = mx.metal.device_info() if hasattr(mx, "metal") and hasattr(mx.metal, "device_info") else mx.device_info()
|
|
35
|
+
return str(info.get("device_name", "apple-gpu"))
|
|
36
|
+
|
|
37
|
+
def build(self, op, kernel: Kernel):
|
|
38
|
+
return mx.fast.metal_kernel(
|
|
39
|
+
name=f"carmen_{op.name}_{kernel.digest()}",
|
|
40
|
+
input_names=list(op.input_names),
|
|
41
|
+
output_names=["out"],
|
|
42
|
+
source=kernel.source,
|
|
43
|
+
header=kernel.header,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def upload(self, inputs, dtype: str = "float32"):
|
|
47
|
+
def put(k, v):
|
|
48
|
+
a = mx.array(v)
|
|
49
|
+
cast = dtype == "bfloat16" and k not in SCALAR_INPUTS and np.asarray(v).dtype.kind == "f"
|
|
50
|
+
return a.astype(mx.bfloat16) if cast else a # integer inputs (packed weights) stay as they are
|
|
51
|
+
return {k: put(k, v) for k, v in inputs.items()}
|
|
52
|
+
|
|
53
|
+
def _call(self, built, op, config, dev_inputs, rows, n, dtype, poison: bool = True):
|
|
54
|
+
grid, threadgroup = op.grid(rows, n, config)
|
|
55
|
+
template = [("T", _DTYPES[dtype])] + [(k, v) for k, v in config.items()]
|
|
56
|
+
(out,) = built(
|
|
57
|
+
inputs=[dev_inputs[name] for name in op.input_names],
|
|
58
|
+
template=template,
|
|
59
|
+
# The harness sets the launch shape (one threadgroup per row, or per output tile).
|
|
60
|
+
# Carmy never picks the grid, which rules out MLX's silent dispatch truncation.
|
|
61
|
+
grid=grid,
|
|
62
|
+
threadgroup=threadgroup,
|
|
63
|
+
output_shapes=[(op.outputs * rows * n + PAD,)],
|
|
64
|
+
output_dtypes=[_DTYPES[dtype]],
|
|
65
|
+
# Correctness runs pre-fill the output with NaN so unwritten elements show up.
|
|
66
|
+
# Timing runs must not: the fill is an extra full write the stock op never pays.
|
|
67
|
+
**({"init_value": float("nan")} if poison else {}),
|
|
68
|
+
)
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
def launch(self, built, op, config, dev_inputs, rows, n, dtype, inner: int = 1):
|
|
72
|
+
# `inner` launches share one eval, so per-call Python/dispatch overhead is amortized
|
|
73
|
+
# and the timer sees GPU time, not host time.
|
|
74
|
+
def go():
|
|
75
|
+
mx.eval(*[self._call(built, op, config, dev_inputs, rows, n, dtype, poison=False) for _ in range(inner)])
|
|
76
|
+
return go
|
|
77
|
+
|
|
78
|
+
def run(self, built, op, config, dev_inputs, rows, n, dtype):
|
|
79
|
+
out = self._call(built, op, config, dev_inputs, rows, n, dtype)
|
|
80
|
+
mx.eval(out)
|
|
81
|
+
return _host(out)
|
|
82
|
+
|
|
83
|
+
def baseline(self, op, dev_inputs):
|
|
84
|
+
def go():
|
|
85
|
+
y = op.mlx_baseline(mx, dev_inputs)
|
|
86
|
+
mx.eval(y)
|
|
87
|
+
if isinstance(y, (tuple, list)): # several outputs: stacked, as the reference is
|
|
88
|
+
return np.concatenate([_host(a).reshape(-1, a.shape[-1]) for a in y])
|
|
89
|
+
return _host(y)
|
|
90
|
+
return go
|
|
91
|
+
|
|
92
|
+
def baseline_launch(self, op, dev_inputs, inner: int = 1, compiled: bool = False):
|
|
93
|
+
fn = lambda d: op.mlx_baseline(mx, d) # noqa: E731
|
|
94
|
+
if compiled:
|
|
95
|
+
# mx.compile fuses chains of element-wise ops (e.g. x * scale + mask) into one kernel:
|
|
96
|
+
# the strongest stock baseline a user gets without writing Metal.
|
|
97
|
+
# eps is a Python number to the stock op; read it before tracing (no reads inside compile).
|
|
98
|
+
consts = {"eps": float(dev_inputs["eps"].item())} if "eps" in dev_inputs else {}
|
|
99
|
+
names = [k for k in dev_inputs if k not in consts]
|
|
100
|
+
fused = mx.compile(lambda *arrs: op.mlx_baseline(mx, {**dict(zip(names, arrs)), **consts}))
|
|
101
|
+
fn = lambda d: fused(*[d[k] for k in names]) # noqa: E731
|
|
102
|
+
|
|
103
|
+
def go():
|
|
104
|
+
mx.eval(*[fn(dev_inputs) for _ in range(inner)])
|
|
105
|
+
return go
|
|
106
|
+
|
|
107
|
+
def release(self) -> None:
|
|
108
|
+
"""Hand MLX's cached buffers back to the OS between timing runs."""
|
|
109
|
+
(mx.clear_cache if hasattr(mx, "clear_cache") else mx.metal.clear_cache)()
|
|
110
|
+
|
|
111
|
+
def measure_peak_gbps(self) -> float:
|
|
112
|
+
n = 64 * 1024 * 1024 # 256 MB of float32
|
|
113
|
+
kern = mx.fast.metal_kernel(name="carmen_copy", input_names=["inp"], output_names=["out"], source=COPY_KERNEL)
|
|
114
|
+
inp = mx.random.uniform(shape=(n,))
|
|
115
|
+
mx.eval(inp)
|
|
116
|
+
|
|
117
|
+
def go():
|
|
118
|
+
(o,) = kern(inputs=[inp], grid=(n, 1, 1), threadgroup=(256, 1, 1),
|
|
119
|
+
output_shapes=[(n,)], output_dtypes=[mx.float32])
|
|
120
|
+
mx.eval(o)
|
|
121
|
+
|
|
122
|
+
for _ in range(3):
|
|
123
|
+
go()
|
|
124
|
+
best = float("inf")
|
|
125
|
+
for _ in range(20):
|
|
126
|
+
t = time.perf_counter()
|
|
127
|
+
go()
|
|
128
|
+
best = min(best, time.perf_counter() - t)
|
|
129
|
+
return 2 * n * 4 / best / 1e9
|
carmen/bench.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""`carmen bench`: does the feedback loop beat blind sampling?
|
|
2
|
+
|
|
3
|
+
For each op, run the loop and the best-of-N control arm the same number of times with the
|
|
4
|
+
same budget (rounds x k Carmy calls, no early stop), each from a fresh memory, and compare:
|
|
5
|
+
|
|
6
|
+
- final champion speed (vs MLX, and vs mx.compile(MLX))
|
|
7
|
+
- round-1 best vs final best: did later rounds add anything?
|
|
8
|
+
- how many kernels the judge verified
|
|
9
|
+
|
|
10
|
+
Writes bench.json and a markdown table (bench.md) ready to paste into a README.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import statistics
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Callable
|
|
20
|
+
|
|
21
|
+
from .events import read_events
|
|
22
|
+
|
|
23
|
+
MODES = ("loop", "bon")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _curve(run_dir: Path) -> list[float]:
|
|
27
|
+
"""Best verified speedup found in each round (0 when a round verified nothing)."""
|
|
28
|
+
best: dict[int, float] = {}
|
|
29
|
+
for e in read_events(run_dir):
|
|
30
|
+
if e["type"] == "judge_result":
|
|
31
|
+
best.setdefault(e["round"], 0.0)
|
|
32
|
+
if e.get("correct") and e.get("speedup"):
|
|
33
|
+
best[e["round"]] = max(best[e["round"]], e["speedup"])
|
|
34
|
+
return [best[r] for r in sorted(best)]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run(ops: list[str], *, repeats: int, rounds: int, k: int, root: Path, run_fn: Callable,
|
|
38
|
+
on_progress: Callable[[str], None] = print, **run_kw) -> dict:
|
|
39
|
+
root = Path(root)
|
|
40
|
+
results = []
|
|
41
|
+
for op in ops:
|
|
42
|
+
for i in range(repeats):
|
|
43
|
+
for mode in MODES:
|
|
44
|
+
tag = f"{op}-{mode}-{i}"
|
|
45
|
+
on_progress(f"{tag}: cooking ({rounds} rounds x {k} drafts)")
|
|
46
|
+
sm = run_fn(op, rounds=rounds, k=k, mode=mode, patience=rounds + 1,
|
|
47
|
+
runs_dir=root / tag, memory_dir=root / tag / "memory", **run_kw)
|
|
48
|
+
curve = _curve(Path(sm["run_dir"]))
|
|
49
|
+
results.append({"op": op, "mode": mode, "repeat": i, "speedup": sm.get("speedup"),
|
|
50
|
+
"speedup_compiled": sm.get("speedup_compiled"),
|
|
51
|
+
"hidden_speedup": sm.get("hidden_speedup"), "verified": sm["verified"],
|
|
52
|
+
"attempts": sm["attempts"], "curve": curve, "run_dir": sm["run_dir"]})
|
|
53
|
+
on_progress(f"{tag}: best {_x(sm.get('speedup'))}, per round {' '.join(_x(c) for c in curve)}")
|
|
54
|
+
out = {"when": time.strftime("%Y-%m-%d %H:%M"), "rounds": rounds, "k": k, "repeats": repeats,
|
|
55
|
+
"results": results, "table": table(results)}
|
|
56
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
(root / "bench.json").write_text(json.dumps(out, indent=2, default=float))
|
|
58
|
+
(root / "bench.md").write_text(markdown(out["table"], rounds, k, repeats))
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _mean(xs):
|
|
63
|
+
xs = [x for x in xs if x]
|
|
64
|
+
return statistics.mean(xs) if xs else None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def table(results: list[dict]) -> list[dict]:
|
|
68
|
+
rows = []
|
|
69
|
+
for op in dict.fromkeys(r["op"] for r in results):
|
|
70
|
+
for mode in MODES:
|
|
71
|
+
rs = [r for r in results if r["op"] == op and r["mode"] == mode]
|
|
72
|
+
if not rs:
|
|
73
|
+
continue
|
|
74
|
+
first = [r["curve"][0] for r in rs if r["curve"]]
|
|
75
|
+
rows.append({
|
|
76
|
+
"op": op, "mode": mode, "runs": len(rs),
|
|
77
|
+
"speedup": _mean(r["speedup"] for r in rs),
|
|
78
|
+
"speedup_min": min((r["speedup"] or 0 for r in rs), default=0),
|
|
79
|
+
"speedup_max": max((r["speedup"] or 0 for r in rs), default=0),
|
|
80
|
+
"speedup_compiled": _mean(r["speedup_compiled"] for r in rs),
|
|
81
|
+
"hidden_speedup": _mean(r["hidden_speedup"] for r in rs),
|
|
82
|
+
"round1": _mean(first),
|
|
83
|
+
"verified": sum(r["verified"] for r in rs), "attempts": sum(r["attempts"] for r in rs),
|
|
84
|
+
})
|
|
85
|
+
return rows
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _x(v) -> str:
|
|
89
|
+
return f"{v:.2f}x" if v else "-"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def markdown(rows: list[dict], rounds: int, k: int, repeats: int) -> str:
|
|
93
|
+
lines = [f"Each row: {repeats} run(s) of {rounds} rounds x {k} drafts, fresh memory, no early stop.",
|
|
94
|
+
"`loop` feeds the judge's results back; `best-of-N` makes the same number of calls blind.", "",
|
|
95
|
+
"| kernel | mode | speedup vs MLX (mean, range) | vs mx.compile | unseen sizes | round 1 → final | verified |",
|
|
96
|
+
"|---|---|---|---|---|---|---|"]
|
|
97
|
+
for r in rows:
|
|
98
|
+
mode = "loop" if r["mode"] == "loop" else "best-of-N"
|
|
99
|
+
lines.append(f"| {r['op']} | {mode} | {_x(r['speedup'])} ({_x(r['speedup_min'])}–{_x(r['speedup_max'])}) "
|
|
100
|
+
f"| {_x(r['speedup_compiled'])} | {_x(r['hidden_speedup'])} | {_x(r['round1'])} → {_x(r['speedup'])} "
|
|
101
|
+
f"| {r['verified']}/{r['attempts']} |")
|
|
102
|
+
return "\n".join(lines) + "\n"
|
carmen/broken.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Seeded broken kernels: how we measure the judge.
|
|
2
|
+
|
|
3
|
+
Each mutant is the golden kernel with one realistic bug, grouped into the fault
|
|
4
|
+
families from "Measuring the Checker" (Du et al., arXiv 2609.22220): boundary,
|
|
5
|
+
precision, sync, indexing, semantic. A strong judge kills all of them and never
|
|
6
|
+
rejects the golden kernel. Every mutant here is designed to be *actually* wrong
|
|
7
|
+
on some legal input; `carmen broken` reports which ones get through.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .backends import Kernel
|
|
16
|
+
|
|
17
|
+
GOLDEN_DIR = Path(__file__).resolve().parent / "golden"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Mutant:
|
|
22
|
+
name: str
|
|
23
|
+
family: str
|
|
24
|
+
old: str
|
|
25
|
+
new: str
|
|
26
|
+
why: str
|
|
27
|
+
every: bool = False # replace every occurrence, not just the first
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
MUTANTS = [
|
|
31
|
+
Mutant("no_max_subtraction", "precision", "metal::exp(float(xr[i]) - m)", "metal::exp(float(xr[i]))",
|
|
32
|
+
"exp overflows for large inputs"),
|
|
33
|
+
Mutant("fp16_accumulate", "precision", "float s = 0.0f;", "half s = 0.0h;",
|
|
34
|
+
"sum loses precision on long rows"),
|
|
35
|
+
Mutant("store_through_half", "precision", "o[i] = T(", "o[i] = T((half)",
|
|
36
|
+
"fp32 outputs rounded to fp16 (escapes allclose 1e-2)"),
|
|
37
|
+
Mutant("tail_write_dropped", "boundary", "for (int i = tid; i < n; i += TG) { o[i]",
|
|
38
|
+
"for (int i = tid; i < n - 1; i += TG) { o[i]", "last element never written"),
|
|
39
|
+
Mutant("tail_sum_dropped", "boundary", "for (int i = tid; i < n; i += TG) { s +=",
|
|
40
|
+
"for (int i = tid; i < n - 1; i += TG) { s +=", "last element missing from the sum"),
|
|
41
|
+
Mutant("max_start_at_one", "boundary", "for (int i = tid; i < n; i += TG) { m =",
|
|
42
|
+
"for (int i = tid + 1; i < n; i += TG) { m =", "first element of each thread skipped in max"),
|
|
43
|
+
Mutant("barrier_removed", "sync",
|
|
44
|
+
"if (lane == 0) { shared[0] = v; }\n}\nthreadgroup_barrier(mem_flags::mem_threadgroup);\nm = shared[0];",
|
|
45
|
+
"if (lane == 0) { shared[0] = v; }\n}\nm = shared[0];", "race: threads read the max before it is written"),
|
|
46
|
+
Mutant("partial_simd_reduce", "indexing", "(lane < n_sg) ? shared[lane] : 0.0f",
|
|
47
|
+
"(lane < n_sg / 2) ? shared[lane] : 0.0f", "half the simdgroups dropped from the sum"),
|
|
48
|
+
Mutant("wrong_row_stride", "indexing", "device T* o = out + row * n;", "device T* o = out + row * (n - 1);",
|
|
49
|
+
"rows overlap in the output"),
|
|
50
|
+
Mutant("max_sentinel_zero", "semantic", "float m = -INFINITY;", "float m = 0.0f;",
|
|
51
|
+
"all-negative rows underflow to 0/0"),
|
|
52
|
+
Mutant("sum_uses_local_max", "semantic", "m = shared[0];\nthreadgroup_barrier(mem_flags::mem_threadgroup);\n\n// 2.",
|
|
53
|
+
"threadgroup_barrier(mem_flags::mem_threadgroup);\n\n// 2.", "each simdgroup normalizes with its own max"),
|
|
54
|
+
Mutant("divide_by_n", "semantic", "/ s)", "/ float(n))", "normalizes by n instead of the sum"),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# The norms share one golden-kernel skeleton (sum, reduce, rsqrt, write), so one list covers both.
|
|
59
|
+
NORM_MUTANTS = [
|
|
60
|
+
Mutant("fp16_accumulate", "precision", "float s = 0.0f;", "half s = 0.0h;",
|
|
61
|
+
"sum kept in half: overflows or drifts on big or offset rows"),
|
|
62
|
+
Mutant("store_through_half", "precision", "o[i] = T(", "o[i] = T((half)",
|
|
63
|
+
"fp32 outputs rounded to fp16 (escapes allclose 1e-2)"),
|
|
64
|
+
Mutant("tail_write_dropped", "boundary", "for (int i = tid; i < n; i += TG) { o[i]",
|
|
65
|
+
"for (int i = tid; i < n - 1; i += TG) { o[i]", "last element never written"),
|
|
66
|
+
Mutant("tail_sum_dropped", "boundary", "for (int i = tid; i < n; i += TG) { s +=",
|
|
67
|
+
"for (int i = tid; i < n - 1; i += TG) { s +=", "last element missing from the sum"),
|
|
68
|
+
Mutant("barrier_removed", "sync",
|
|
69
|
+
"if (lane == 0) { shared[0] = v; }\n}\nthreadgroup_barrier(mem_flags::mem_threadgroup);\n",
|
|
70
|
+
"if (lane == 0) { shared[0] = v; }\n}\n", "race: threads read the sum before it is written"),
|
|
71
|
+
Mutant("partial_simd_reduce", "indexing", "(lane < n_sg) ? shared[lane] : 0.0f",
|
|
72
|
+
"(lane < n_sg / 2) ? shared[lane] : 0.0f", "half the simdgroups dropped from the sum"),
|
|
73
|
+
Mutant("wrong_row_stride", "indexing", "device T* o = out + row * n;", "device T* o = out + row * (n - 1);",
|
|
74
|
+
"rows overlap in the output"),
|
|
75
|
+
Mutant("eps_dropped", "semantic", "+ eps)", ")", "no eps: zero rows divide by zero, tiny rows blow up"),
|
|
76
|
+
Mutant("divide_by_n_minus_1", "semantic", "shared[0] / float(n)", "shared[0] / float(n - 1)",
|
|
77
|
+
"averages over n - 1 instead of n"),
|
|
78
|
+
Mutant("weight_ignored", "semantic", " * float(w[i])", "", "learned weight never applied"),
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
# matmul has its own skeleton (tiles, K loop), so its own bugs.
|
|
82
|
+
MATMUL_MUTANTS = [
|
|
83
|
+
Mutant("fp16_accumulate", "precision", "float acc = 0.0f;", "half acc = 0.0h;",
|
|
84
|
+
"dot products summed in half: drifts on long K"),
|
|
85
|
+
Mutant("store_through_half", "precision", "out[r * N + c] = T(acc);", "out[r * N + c] = T((half)acc);",
|
|
86
|
+
"fp32 outputs rounded to fp16"),
|
|
87
|
+
Mutant("k_tail_dropped", "boundary", "k < K; ++k", "k < K - 1; ++k", "last term of every dot product missing"),
|
|
88
|
+
Mutant("last_column_unwritten", "boundary", "if (r < M && c < N)", "if (r < M && c < N - 1)",
|
|
89
|
+
"the right edge of the output is never written"),
|
|
90
|
+
Mutant("k_starts_at_one", "boundary", "int k = 0; k < K", "int k = 1; k < K", "first term of every dot product missing"),
|
|
91
|
+
Mutant("wrong_leading_dim", "indexing", "a[r * K + k]", "a[r * N + k]",
|
|
92
|
+
"A indexed with N instead of K: right only when the matrices are square"),
|
|
93
|
+
Mutant("b_transposed", "indexing", "b[k * N + c]", "b[c * K + k]", "B read as if transposed"),
|
|
94
|
+
Mutant("tile_axes_swapped", "indexing", "int r = ty * BM + e / BN;\n int c = tx * BN + e % BN;",
|
|
95
|
+
"int r = tx * BM + e / BN;\n int c = ty * BN + e % BN;",
|
|
96
|
+
"grid x and y swapped: right only when the tile grid is square"),
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
# attention: the causal boundary, the cache offset, the softmax and the scale.
|
|
100
|
+
ATTENTION_MUTANTS = [
|
|
101
|
+
Mutant("no_max_subtraction", "precision", "metal::exp(SCORE(i, j) - m)", "metal::exp(SCORE(i, j))",
|
|
102
|
+
"exp overflows on large scores", every=True),
|
|
103
|
+
Mutant("fp16_accumulate", "precision", "float acc = 0.0f;", "half acc = 0.0h;",
|
|
104
|
+
"the weighted sum of v kept in half: drifts on long sequences"),
|
|
105
|
+
Mutant("causal_excludes_self", "boundary", "i + (Lk - Lq));", "i + (Lk - Lq) - 1);",
|
|
106
|
+
"each query can't see its own key"),
|
|
107
|
+
Mutant("causal_peeks_ahead", "boundary", "i + (Lk - Lq));", "i + (Lk - Lq) + 1);",
|
|
108
|
+
"each query sees one future key"),
|
|
109
|
+
Mutant("head_dim_tail_dropped", "boundary", "d < D; d += TG", "d < D - 1; d += TG",
|
|
110
|
+
"the last output column is never written"),
|
|
111
|
+
Mutant("cache_offset_ignored", "indexing", "metal::min(Lk - 1, i + (Lk - Lq))", "metal::min(Lk - 1, i)",
|
|
112
|
+
"causal mask aligned top-left: right only when Lq == Lk"),
|
|
113
|
+
Mutant("v_read_transposed", "indexing", "v[j * D + d]", "v[d * Lk + j]", "v read as if transposed"),
|
|
114
|
+
Mutant("barrier_removed", "sync",
|
|
115
|
+
"if (lane == 0) { shared[0] = x; }\n }\n threadgroup_barrier(mem_flags::mem_threadgroup);\n m = shared[0];",
|
|
116
|
+
"if (lane == 0) { shared[0] = x; }\n }\n m = shared[0];", "race: threads read the max before it is written"),
|
|
117
|
+
Mutant("scale_missing", "semantic", "* scl)", ")", "scores not divided by sqrt(D)"),
|
|
118
|
+
Mutant("not_normalized", "semantic", "T(acc / l)", "T(acc)", "weighted sum never divided by the softmax total"),
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
_SHARED_NORM = [m for m in NORM_MUTANTS if m.name not in ("eps_dropped", "wrong_row_stride")]
|
|
122
|
+
|
|
123
|
+
MUTANTS_BY_OP = {
|
|
124
|
+
"residual_rmsnorm": _SHARED_NORM + [
|
|
125
|
+
Mutant("eps_dropped", "semantic", "+ eps[0])", ")", "no eps: zero rows divide by zero"),
|
|
126
|
+
Mutant("eps_hardcoded", "semantic", "+ eps[0])", "+ 1e-5f)", "eps fixed at Llama's 1e-5: wrong for Qwen's 1e-6"),
|
|
127
|
+
Mutant("residual_tail_unwritten", "boundary", "for (int i = tid; i < n; i += TG) { hsum[i]",
|
|
128
|
+
"for (int i = tid; i < n - 1; i += TG) { hsum[i]", "last element of h never written"),
|
|
129
|
+
Mutant("y_overwrites_h", "indexing", "device T* o = out + (rows + row) * n;", "device T* o = out + row * n;",
|
|
130
|
+
"y written over h: the residual is lost"),
|
|
131
|
+
Mutant("residual_not_added", "semantic", "hsum[i] = T(float(xr[i]) + float(rr[i]));", "hsum[i] = T(float(xr[i]));",
|
|
132
|
+
"h is x alone: the block's output never joins the residual"),
|
|
133
|
+
],
|
|
134
|
+
"attention": ATTENTION_MUTANTS,
|
|
135
|
+
"mlp_up": [
|
|
136
|
+
Mutant("fp16_accumulate", "precision", "float g = 0.0f, u = 0.0f;", "half g = 0.0h, u = 0.0h;",
|
|
137
|
+
"dot products summed in half: long rows lose precision"),
|
|
138
|
+
Mutant("silu_overflows", "precision", "T(g / (1.0f + metal::exp(-g)) * u)",
|
|
139
|
+
"T(metal::exp(g) / (1.0f + metal::exp(g)) * g * u)", "silu as e^g/(1+e^g): inf/inf = NaN for big g"),
|
|
140
|
+
Mutant("bias_dropped", "semantic", "+ bgv) * hn;", ") * hn;", "gate weights lose their per-group bias"),
|
|
141
|
+
Mutant("nibble_order_reversed", "indexing", "float((qg >> (4 * t)) & 0xFu)", "float((qg >> (4 * (7 - t))) & 0xFu)",
|
|
142
|
+
"reads the 8 values of a word highest-first"),
|
|
143
|
+
Mutant("wrong_group", "indexing", "const int grp = j * groups + k0 / 64;", "const int grp = j * groups + k0 / 128;",
|
|
144
|
+
"scale and bias from the wrong group once K > 64"),
|
|
145
|
+
Mutant("last_column_dropped", "boundary", "if (j >= n) { break; }", "if (j >= n - 1) { break; }",
|
|
146
|
+
"the last output column is never written"),
|
|
147
|
+
Mutant("last_word_dropped", "boundary", "for (int wd = lane; wd < words; wd += 32)",
|
|
148
|
+
"for (int wd = lane; wd < words - 1; wd += 32)", "the last 8 values of K are left out of the dot product"),
|
|
149
|
+
Mutant("eps_dropped", "semantic", "+ eps[0])", ")", "no eps: a zero row divides by zero"),
|
|
150
|
+
Mutant("residual_not_added", "semantic", "const float hn = (float(xr[k]) + float(rr[k]))",
|
|
151
|
+
"const float hn = (float(xr[k]))", "the matmuls see x, not x + res"),
|
|
152
|
+
Mutant("barrier_removed", "sync", "threadgroup_barrier(mem_flags::mem_threadgroup);\nconst float inv",
|
|
153
|
+
"const float inv", "race: threads read the sum before it is written"),
|
|
154
|
+
Mutant("partial_simd_reduce", "indexing", "(lane < n_sg) ? shared[lane]", "(lane < n_sg / 2) ? shared[lane]",
|
|
155
|
+
"half the simdgroups dropped from the norm's sum"),
|
|
156
|
+
Mutant("wrong_row_stride", "indexing", "out[row * n + j]", "out[row * (n - 1) + j]", "rows overlap in the output"),
|
|
157
|
+
Mutant("gate_up_swapped", "semantic", "T(g / (1.0f + metal::exp(-g)) * u)", "T(u / (1.0f + metal::exp(-u)) * g)",
|
|
158
|
+
"silu applied to up instead of gate"),
|
|
159
|
+
],
|
|
160
|
+
"mlp_down": [
|
|
161
|
+
Mutant("fp16_accumulate", "precision", "float acc = 0.0f;", "half acc = 0.0h;",
|
|
162
|
+
"dot product summed in half: long rows lose precision"),
|
|
163
|
+
Mutant("bias_dropped", "semantic", "+ bv) *", ") *", "weights lose their per-group bias"),
|
|
164
|
+
Mutant("nibble_order_reversed", "indexing", "(q >> (4 * t))", "(q >> (4 * (7 - t)))",
|
|
165
|
+
"reads the 8 values of a word highest-first"),
|
|
166
|
+
Mutant("wrong_group", "indexing", "const int grp = j * groups + k0 / 64;", "const int grp = j * groups + k0 / 128;",
|
|
167
|
+
"scale and bias from the wrong group once K > 64"),
|
|
168
|
+
Mutant("last_column_dropped", "boundary", "if (j >= n) { break; }", "if (j >= n - 1) { break; }",
|
|
169
|
+
"the last output column is never written"),
|
|
170
|
+
Mutant("last_word_dropped", "boundary", "for (int wd = lane; wd < words; wd += 32)",
|
|
171
|
+
"for (int wd = lane; wd < words - 1; wd += 32)", "the last 8 values of K are left out of the dot product"),
|
|
172
|
+
Mutant("residual_dropped", "semantic", "float(x[row * n + j]) + float(res[row * n + j]) + acc",
|
|
173
|
+
"float(x[row * n + j]) + acc", "the attention output never joins the residual stream"),
|
|
174
|
+
Mutant("wrong_act_row", "indexing", "auto ar = act + row * K;", "auto ar = act + row * (K - 8);",
|
|
175
|
+
"rows after the first read the wrong activations"),
|
|
176
|
+
],
|
|
177
|
+
"matmul": MATMUL_MUTANTS,
|
|
178
|
+
"layernorm": NORM_MUTANTS,
|
|
179
|
+
"rmsnorm": NORM_MUTANTS,
|
|
180
|
+
"add_rmsnorm": NORM_MUTANTS + [
|
|
181
|
+
Mutant("residual_dropped_on_write", "semantic", "o[i] = T((float(xr[i]) + float(rr[i])) * inv",
|
|
182
|
+
"o[i] = T(float(xr[i]) * inv", "normalizes x + res but writes x, not the sum"),
|
|
183
|
+
],
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def has_golden(op: str) -> bool:
|
|
188
|
+
return (GOLDEN_DIR / f"{op}.metal").exists()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
GOLDEN_CONFIGS = {"matmul": [{"TG": 256, "BM": 32, "BN": 32}], "attention": [{"TG": 64, "BQ": 1}],
|
|
192
|
+
"mlp_up": [{"TG": 256, "BN": 8}], "mlp_down": [{"TG": 256, "BN": 8}]}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def golden(op: str) -> Kernel:
|
|
196
|
+
path = GOLDEN_DIR / f"{op}.metal"
|
|
197
|
+
header = GOLDEN_DIR / f"{op}.header.metal"
|
|
198
|
+
configs = GOLDEN_CONFIGS.get(op, [{"TG": 256}])
|
|
199
|
+
return Kernel(path.read_text(), header.read_text() if header.exists() else "",
|
|
200
|
+
configs=[dict(c) for c in configs], plan="golden reference kernel")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def mutants(op: str) -> list[tuple[Mutant, Kernel]]:
|
|
204
|
+
base = golden(op).source
|
|
205
|
+
out = []
|
|
206
|
+
for m in MUTANTS_BY_OP.get(op, MUTANTS):
|
|
207
|
+
old = m.old.replace("float(xr[i])", "(float(xr[i]) * scl + float(mr[i]))") if op == "masked_softmax" else m.old
|
|
208
|
+
new = m.new.replace("float(xr[i])", "(float(xr[i]) * scl + float(mr[i]))") if op == "masked_softmax" else m.new
|
|
209
|
+
if old not in base:
|
|
210
|
+
raise ValueError(f"mutant {m.name} does not apply to the {op} golden kernel")
|
|
211
|
+
g = golden(op)
|
|
212
|
+
out.append((m, Kernel(base.replace(old, new, -1 if m.every else 1), g.header, configs=g.configs, plan=m.why)))
|
|
213
|
+
return out
|