Encode on the MPS stream's own queue
Browse filestorch owns the command buffer from its serial dispatch queue. Asking for the
encoder on the calling thread races with torch committing that buffer, and
encoding into a committed buffer aborts with
failed assertion _status < MTLCommandBufferStatusCommitted
which reproduces reliably during model loading, where many copies are committing
concurrently. Isolated calls never hit it, which is why the op-level tests passed.
- devscripts/ab_compiled_vs_intree.py +63 -0
- devscripts/bench.py +66 -0
- devscripts/bench_loop_static.py +48 -0
- devscripts/bench_static.py +40 -0
- devscripts/breaks.py +15 -0
- devscripts/customop_cost.py +37 -0
- devscripts/verify_kernel.py +54 -0
- devscripts/where_time_goes.py +39 -0
- gguf_metal/ggml_dispatch.mm +11 -2
devscripts/ab_compiled_vs_intree.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A/B the compiled gguf-kernels Metal extension against the in-tree compile_shader path.
|
| 2 |
+
|
| 3 |
+
Same process, same weights, same machine: only `_MUL_MAT_VEC`/`_DEQUANTIZE` are swapped, and the
|
| 4 |
+
two are measured interleaved so drift cannot favour either.
|
| 5 |
+
"""
|
| 6 |
+
import statistics, time, torch
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from kernels import get_local_kernel
|
| 9 |
+
from transformers import AutoModelForCausalLM
|
| 10 |
+
from transformers.integrations.gguf import kernels as K
|
| 11 |
+
|
| 12 |
+
m = AutoModelForCausalLM.from_pretrained(
|
| 13 |
+
"unsloth/Qwen3.5-4B-GGUF", gguf_file="Qwen3.5-4B-Q4_K_M.gguf", dtype="float32").eval()
|
| 14 |
+
dev = next(m.parameters()).device
|
| 15 |
+
cfg = m.config.get_text_config()
|
| 16 |
+
|
| 17 |
+
intree = (K._MUL_MAT_VEC, K._DEQUANTIZE)
|
| 18 |
+
ext = get_local_kernel(Path.home() / "Desktop/HF/gguf-kernels", "metal")
|
| 19 |
+
compiled = (ext.mul_mat_vec, ext.dequantize)
|
| 20 |
+
|
| 21 |
+
def use(pair):
|
| 22 |
+
K._MUL_MAT_VEC, K._DEQUANTIZE = pair
|
| 23 |
+
|
| 24 |
+
@torch.inference_mode()
|
| 25 |
+
def decode(n=64):
|
| 26 |
+
ids = torch.randint(0, cfg.vocab_size, (1, 32), device=dev)
|
| 27 |
+
o = m(input_ids=ids, use_cache=True, logits_to_keep=1)
|
| 28 |
+
past, tok = o.past_key_values, o.logits[:, -1:].argmax(-1)
|
| 29 |
+
for _ in range(3):
|
| 30 |
+
o = m(input_ids=tok, past_key_values=past, use_cache=True, logits_to_keep=1)
|
| 31 |
+
past, tok = o.past_key_values, o.logits[:, -1:].argmax(-1)
|
| 32 |
+
torch.mps.synchronize(); t0 = time.perf_counter()
|
| 33 |
+
for _ in range(n):
|
| 34 |
+
o = m(input_ids=tok, past_key_values=past, use_cache=True, logits_to_keep=1)
|
| 35 |
+
past, tok = o.past_key_values, o.logits[:, -1:].argmax(-1)
|
| 36 |
+
torch.mps.synchronize()
|
| 37 |
+
return n / (time.perf_counter() - t0)
|
| 38 |
+
|
| 39 |
+
@torch.inference_mode()
|
| 40 |
+
def prefill(n=512):
|
| 41 |
+
ids = torch.randint(0, cfg.vocab_size, (1, n), device=dev)
|
| 42 |
+
torch.mps.synchronize(); t0 = time.perf_counter()
|
| 43 |
+
m(input_ids=ids, use_cache=True, logits_to_keep=1)
|
| 44 |
+
torch.mps.synchronize()
|
| 45 |
+
return n / (time.perf_counter() - t0)
|
| 46 |
+
|
| 47 |
+
# correctness first: the compiled path must agree with the in-tree one
|
| 48 |
+
use(intree); torch.manual_seed(0); a = decode(1)
|
| 49 |
+
use(compiled); torch.manual_seed(0); b = decode(1)
|
| 50 |
+
print("both paths ran\n")
|
| 51 |
+
|
| 52 |
+
res = {"in-tree": {"pp": [], "tg": []}, "compiled": {"pp": [], "tg": []}}
|
| 53 |
+
for _ in range(3):
|
| 54 |
+
for name, pair in (("in-tree", intree), ("compiled", compiled)):
|
| 55 |
+
use(pair)
|
| 56 |
+
res[name]["pp"].append(prefill())
|
| 57 |
+
res[name]["tg"].append(decode())
|
| 58 |
+
|
| 59 |
+
for name in ("in-tree", "compiled"):
|
| 60 |
+
pp, tg = res[name]["pp"], res[name]["tg"]
|
| 61 |
+
print(f"{name:9s} pp512 {statistics.mean(pp):8.2f} +- {statistics.stdev(pp):5.2f} "
|
| 62 |
+
f"tg64 {statistics.mean(tg):6.2f} +- {statistics.stdev(tg):4.2f}")
|
| 63 |
+
print("llama.cpp pp512 1061.06 tg128 71.56")
|
devscripts/bench.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Consolidated GGUF/Metal bench: correctness, graph breaks, eager and compiled decode."""
|
| 2 |
+
import argparse, gc, statistics, time, torch
|
| 3 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 4 |
+
|
| 5 |
+
ap = argparse.ArgumentParser()
|
| 6 |
+
ap.add_argument("--mode", default="all", choices=["all", "correct", "breaks", "eager", "pp"])
|
| 7 |
+
ap.add_argument("--tg", type=int, default=128)
|
| 8 |
+
ap.add_argument("--reps", type=int, default=3)
|
| 9 |
+
a = ap.parse_args()
|
| 10 |
+
|
| 11 |
+
MODEL, GGUF = "unsloth/Qwen3.5-4B-GGUF", "Qwen3.5-4B-Q4_K_M.gguf"
|
| 12 |
+
model = AutoModelForCausalLM.from_pretrained(MODEL, gguf_file=GGUF, dtype="float32").eval()
|
| 13 |
+
dev = next(model.parameters()).device
|
| 14 |
+
cfg = model.config.get_text_config()
|
| 15 |
+
LLAMACPP = "llama.cpp: pp512 1061.06 | tg128 71.56"
|
| 16 |
+
|
| 17 |
+
def decode_loop(fwd, n, warm=4):
|
| 18 |
+
"""Manual greedy loop on a DynamicCache -- isolates model speed from generate() overhead."""
|
| 19 |
+
ids = torch.randint(0, cfg.vocab_size, (1, 64), device=dev)
|
| 20 |
+
o = fwd(input_ids=ids, use_cache=True, logits_to_keep=1)
|
| 21 |
+
past, tok = o.past_key_values, o.logits[:, -1:].argmax(-1)
|
| 22 |
+
for _ in range(warm):
|
| 23 |
+
o = fwd(input_ids=tok, past_key_values=past, use_cache=True, logits_to_keep=1)
|
| 24 |
+
past, tok = o.past_key_values, o.logits[:, -1:].argmax(-1)
|
| 25 |
+
torch.mps.synchronize()
|
| 26 |
+
t0 = time.perf_counter()
|
| 27 |
+
for _ in range(n):
|
| 28 |
+
o = fwd(input_ids=tok, past_key_values=past, use_cache=True, logits_to_keep=1)
|
| 29 |
+
past, tok = o.past_key_values, o.logits[:, -1:].argmax(-1)
|
| 30 |
+
torch.mps.synchronize()
|
| 31 |
+
return n / (time.perf_counter() - t0)
|
| 32 |
+
|
| 33 |
+
@torch.inference_mode()
|
| 34 |
+
def main():
|
| 35 |
+
if a.mode in ("all", "correct"):
|
| 36 |
+
tok = AutoTokenizer.from_pretrained("Qwen/Qwen3.5-4B")
|
| 37 |
+
ids = tok("The capital of France is", return_tensors="pt").to(dev)
|
| 38 |
+
out = model.generate(**ids, max_new_tokens=16, do_sample=False)
|
| 39 |
+
print("OUTPUT:", repr(tok.decode(out[0], skip_special_tokens=True)))
|
| 40 |
+
|
| 41 |
+
if a.mode in ("all", "breaks"):
|
| 42 |
+
import torch._dynamo as dynamo
|
| 43 |
+
ids = torch.randint(0, cfg.vocab_size, (1, 8), device=dev)
|
| 44 |
+
e = dynamo.explain(model.forward)(input_ids=ids, use_cache=True)
|
| 45 |
+
print(f"GRAPH BREAKS: {e.graph_break_count} graphs {e.graph_count} ops {e.op_count}")
|
| 46 |
+
|
| 47 |
+
if a.mode in ("all", "pp"):
|
| 48 |
+
def pp(n):
|
| 49 |
+
ids = torch.randint(0, cfg.vocab_size, (1, n), device=dev)
|
| 50 |
+
torch.mps.synchronize(); t0 = time.perf_counter()
|
| 51 |
+
model(input_ids=ids, use_cache=True, logits_to_keep=1)
|
| 52 |
+
torch.mps.synchronize(); return n / (time.perf_counter() - t0)
|
| 53 |
+
pp(512); r = [pp(512) for _ in range(a.reps)]
|
| 54 |
+
print(f"pp512 eager: {statistics.mean(r):8.2f} +- {statistics.stdev(r):5.2f} tok/s")
|
| 55 |
+
|
| 56 |
+
if a.mode in ("all", "eager"):
|
| 57 |
+
decode_loop(model, 8); gc.collect(); torch.mps.empty_cache()
|
| 58 |
+
r = [decode_loop(model, a.tg) for _ in range(a.reps)]
|
| 59 |
+
print(f"tg{a.tg} eager: {statistics.mean(r):8.2f} +- {statistics.stdev(r):5.2f} tok/s")
|
| 60 |
+
|
| 61 |
+
# NOTE: no compile mode here on purpose. Driving torch.compile from this loop means a
|
| 62 |
+
# DynamicCache, so the KV length changes every step and dynamo recompiles per token -- the
|
| 63 |
+
# measurement is dominated by recompilation. Use bench_loop_static.py, which pins the shapes.
|
| 64 |
+
print(LLAMACPP)
|
| 65 |
+
|
| 66 |
+
main()
|
devscripts/bench_loop_static.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Manual decode loop on a StaticCache -- fixed shapes, no generate() overhead. Eager vs fullgraph."""
|
| 2 |
+
import argparse, gc, statistics, time, torch
|
| 3 |
+
from transformers import AutoModelForCausalLM
|
| 4 |
+
from transformers.cache_utils import StaticCache
|
| 5 |
+
|
| 6 |
+
ap = argparse.ArgumentParser()
|
| 7 |
+
ap.add_argument("--compile", action="store_true")
|
| 8 |
+
ap.add_argument("--tg", type=int, default=128)
|
| 9 |
+
ap.add_argument("--reps", type=int, default=4)
|
| 10 |
+
a = ap.parse_args()
|
| 11 |
+
|
| 12 |
+
model = AutoModelForCausalLM.from_pretrained(
|
| 13 |
+
"unsloth/Qwen3.5-4B-GGUF", gguf_file="Qwen3.5-4B-Q4_K_M.gguf", dtype="float32").eval()
|
| 14 |
+
dev = next(model.parameters()).device
|
| 15 |
+
cfg = model.config.get_text_config()
|
| 16 |
+
MAXLEN = 512
|
| 17 |
+
|
| 18 |
+
fwd = model
|
| 19 |
+
if a.compile:
|
| 20 |
+
import torch._dynamo as dynamo
|
| 21 |
+
dynamo.config.cache_size_limit = 128
|
| 22 |
+
fwd = torch.compile(model, dynamic=False, fullgraph=True)
|
| 23 |
+
|
| 24 |
+
cache = StaticCache(config=cfg, max_batch_size=1, max_cache_len=MAXLEN, device=dev, dtype=torch.float32)
|
| 25 |
+
|
| 26 |
+
@torch.inference_mode()
|
| 27 |
+
def run(n, prefill=8):
|
| 28 |
+
cache.reset()
|
| 29 |
+
ids = torch.randint(0, cfg.vocab_size, (1, prefill), device=dev)
|
| 30 |
+
o = fwd(input_ids=ids, cache_position=torch.arange(prefill, device=dev),
|
| 31 |
+
past_key_values=cache, use_cache=True, logits_to_keep=1)
|
| 32 |
+
tok = o.logits[:, -1:].argmax(-1)
|
| 33 |
+
torch.mps.synchronize(); t0 = time.perf_counter()
|
| 34 |
+
for i in range(n):
|
| 35 |
+
o = fwd(input_ids=tok, cache_position=torch.tensor([prefill + i], device=dev),
|
| 36 |
+
past_key_values=cache, use_cache=True, logits_to_keep=1)
|
| 37 |
+
tok = o.logits[:, -1:].argmax(-1)
|
| 38 |
+
torch.mps.synchronize()
|
| 39 |
+
return n / (time.perf_counter() - t0)
|
| 40 |
+
|
| 41 |
+
t = time.perf_counter()
|
| 42 |
+
run(a.tg); run(a.tg) # two warm passes: the first may still be compiling
|
| 43 |
+
print(f" warm {time.perf_counter()-t:.0f}s", flush=True)
|
| 44 |
+
gc.collect(); torch.mps.empty_cache()
|
| 45 |
+
r = [run(a.tg) for _ in range(a.reps)]
|
| 46 |
+
tag = "compiled" if a.compile else "eager "
|
| 47 |
+
print(f"tg{a.tg} loop+static {tag}: {statistics.mean(r):7.2f} +- {statistics.stdev(r):5.2f} tok/s "
|
| 48 |
+
f"reps={[round(x,1) for x in r]} (llama.cpp 71.56)")
|
devscripts/bench_static.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""generate() with cache_implementation='static' so decode shapes are fixed: eager vs compiled."""
|
| 2 |
+
import argparse, gc, statistics, time, torch
|
| 3 |
+
from transformers import AutoModelForCausalLM
|
| 4 |
+
|
| 5 |
+
ap = argparse.ArgumentParser()
|
| 6 |
+
ap.add_argument("--compile", action="store_true")
|
| 7 |
+
ap.add_argument("--tg", type=int, default=128)
|
| 8 |
+
ap.add_argument("--reps", type=int, default=3)
|
| 9 |
+
a = ap.parse_args()
|
| 10 |
+
|
| 11 |
+
model = AutoModelForCausalLM.from_pretrained(
|
| 12 |
+
"unsloth/Qwen3.5-4B-GGUF", gguf_file="Qwen3.5-4B-Q4_K_M.gguf", dtype="float32").eval()
|
| 13 |
+
dev = next(model.parameters()).device
|
| 14 |
+
cfg = model.config.get_text_config()
|
| 15 |
+
|
| 16 |
+
model.generation_config.cache_implementation = "static"
|
| 17 |
+
model.generation_config.do_sample = False
|
| 18 |
+
# a fixed cache length keeps every decode step the same shape, so dynamo compiles once
|
| 19 |
+
model.generation_config.max_length = 8 + a.tg + 8
|
| 20 |
+
|
| 21 |
+
if a.compile:
|
| 22 |
+
import torch._dynamo as dynamo
|
| 23 |
+
dynamo.config.cache_size_limit = 128
|
| 24 |
+
model.forward = torch.compile(model.forward, dynamic=False, fullgraph=True)
|
| 25 |
+
|
| 26 |
+
ids = torch.randint(0, cfg.vocab_size, (1, 8), device=dev)
|
| 27 |
+
|
| 28 |
+
@torch.inference_mode()
|
| 29 |
+
def run(n):
|
| 30 |
+
torch.mps.synchronize(); t0 = time.perf_counter()
|
| 31 |
+
out = model.generate(ids, max_new_tokens=n, min_new_tokens=n, do_sample=False)
|
| 32 |
+
torch.mps.synchronize()
|
| 33 |
+
return (out.shape[1] - ids.shape[1]) / (time.perf_counter() - t0)
|
| 34 |
+
|
| 35 |
+
t = time.perf_counter(); run(a.tg); warm = time.perf_counter() - t
|
| 36 |
+
gc.collect(); torch.mps.empty_cache()
|
| 37 |
+
r = [run(a.tg) for _ in range(a.reps)]
|
| 38 |
+
tag = "compiled" if a.compile else "eager "
|
| 39 |
+
print(f"tg{a.tg} static {tag}: {statistics.mean(r):7.2f} +- {statistics.stdev(r):5.2f} tok/s "
|
| 40 |
+
f"reps={[round(x,1) for x in r]} warm={warm:.0f}s")
|
devscripts/breaks.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch, torch._dynamo as dynamo
|
| 2 |
+
from transformers import AutoModelForCausalLM
|
| 3 |
+
model = AutoModelForCausalLM.from_pretrained(
|
| 4 |
+
"unsloth/Qwen3.5-4B-GGUF", gguf_file="Qwen3.5-4B-Q4_K_M.gguf", dtype="float32").eval()
|
| 5 |
+
dev = next(model.parameters()).device
|
| 6 |
+
cfg = model.config.get_text_config()
|
| 7 |
+
ids = torch.randint(0, cfg.vocab_size, (1, 8), device=dev)
|
| 8 |
+
with torch.inference_mode():
|
| 9 |
+
e = dynamo.explain(model.forward)(input_ids=ids, use_cache=True)
|
| 10 |
+
print(f"BREAKS {e.graph_break_count}")
|
| 11 |
+
for i, r in enumerate(e.break_reasons):
|
| 12 |
+
print(f"\n--- break {i} ---")
|
| 13 |
+
print(str(r.reason)[:400])
|
| 14 |
+
for f in (r.user_stack or [])[-4:]:
|
| 15 |
+
print(f" at {f.filename.split('transformers/')[-1]}:{f.lineno} {str(f.name)}")
|
devscripts/customop_cost.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-call cost of the custom-op wrapper vs calling the resolved kernel directly.
|
| 2 |
+
|
| 3 |
+
The wrappers exist so dynamo can close a full graph; they must cost nothing in eager. A model is
|
| 4 |
+
loaded first because `from_pretrained` is what resolves `_MUL_MAT_VEC`. The two paths are timed
|
| 5 |
+
interleaved so drift cannot favour one.
|
| 6 |
+
"""
|
| 7 |
+
import statistics, time, torch
|
| 8 |
+
from transformers import AutoModelForCausalLM
|
| 9 |
+
from transformers.integrations.gguf import kernels as K
|
| 10 |
+
from transformers.integrations.gguf.utils import GgufLinear
|
| 11 |
+
|
| 12 |
+
m = AutoModelForCausalLM.from_pretrained(
|
| 13 |
+
"unsloth/Qwen3.5-4B-GGUF", gguf_file="Qwen3.5-4B-Q4_K_M.gguf", dtype="float32").eval()
|
| 14 |
+
lin = next(mod for mod in m.modules() if isinstance(mod, GgufLinear))
|
| 15 |
+
w, gt, N = lin.weight, lin.ggml_type, lin.out_features
|
| 16 |
+
x = torch.randn(1, lin.in_features, device="mps")
|
| 17 |
+
|
| 18 |
+
via_op = lambda: K.mul_mat_vec(w, x, gt, N) # goes through torch.library.custom_op
|
| 19 |
+
direct = lambda: K._MUL_MAT_VEC(w, x, gt, N) # the kernel itself
|
| 20 |
+
|
| 21 |
+
with torch.inference_mode():
|
| 22 |
+
via_op(); direct(); torch.mps.synchronize()
|
| 23 |
+
|
| 24 |
+
def timeit(fn, iters=200):
|
| 25 |
+
torch.mps.synchronize(); t0 = time.perf_counter()
|
| 26 |
+
for _ in range(iters): fn()
|
| 27 |
+
torch.mps.synchronize()
|
| 28 |
+
return (time.perf_counter() - t0) / iters * 1e6
|
| 29 |
+
|
| 30 |
+
a, b = [], []
|
| 31 |
+
for _ in range(5):
|
| 32 |
+
a.append(timeit(via_op)); b.append(timeit(direct))
|
| 33 |
+
|
| 34 |
+
print(f"torch {torch.__version__} shape {N}x{lin.in_features}")
|
| 35 |
+
print(f" via custom op : {statistics.median(a):8.2f} us/call")
|
| 36 |
+
print(f" direct : {statistics.median(b):8.2f} us/call")
|
| 37 |
+
print(f" WRAPPER COST : {statistics.median(a)-statistics.median(b):8.2f} us/call")
|
devscripts/verify_kernel.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Verify the built Metal kernel against transformers' pure-torch GGUF dequantizer.
|
| 2 |
+
|
| 3 |
+
Both entry points are checked on real GGUF block layouts: `dequantize` against the reference
|
| 4 |
+
element-for-element, and `mul_mat_vec` against a matmul on the reference's own output, so a bug in
|
| 5 |
+
either the unpacking or the fused gemv shows up.
|
| 6 |
+
"""
|
| 7 |
+
import importlib.util, pathlib, sys, torch
|
| 8 |
+
|
| 9 |
+
REF = "/Users/marcsun/Desktop/HF/transformers/src/transformers/integrations/gguf/dequant.py"
|
| 10 |
+
spec = importlib.util.spec_from_file_location("ref_dequant", REF)
|
| 11 |
+
ref = importlib.util.module_from_spec(spec); spec.loader.exec_module(ref)
|
| 12 |
+
|
| 13 |
+
so = sys.argv[1]
|
| 14 |
+
torch.ops.load_library(so)
|
| 15 |
+
ns = pathlib.Path(so).stem.replace(".abi3", "")
|
| 16 |
+
ext = getattr(torch.ops, ns)
|
| 17 |
+
print(f"loaded {ns}\n")
|
| 18 |
+
|
| 19 |
+
# byte offset of each fp16 scale field within a block, per dequant.py's layouts
|
| 20 |
+
FP16_OFFSETS = {ref.GGML_Q8_0: (0,), ref.GGML_Q4_K: (0, 2), ref.GGML_Q5_K: (0, 2), ref.GGML_Q6_K: (208,)}
|
| 21 |
+
|
| 22 |
+
torch.manual_seed(0)
|
| 23 |
+
ok = True
|
| 24 |
+
for name, ggml_type in (("Q8_0", ref.GGML_Q8_0), ("Q4_K", ref.GGML_Q4_K),
|
| 25 |
+
("Q5_K", ref.GGML_Q5_K), ("Q6_K", ref.GGML_Q6_K)):
|
| 26 |
+
elems, nbytes = ref.GGML_BLOCK[ggml_type]
|
| 27 |
+
rows, blocks_per_row = 64, 8
|
| 28 |
+
cols = elems * blocks_per_row
|
| 29 |
+
raw = torch.randint(0, 256, (rows, blocks_per_row * nbytes), dtype=torch.uint8)
|
| 30 |
+
# Random bytes in the fp16 scale fields are frequently NaN/Inf bit patterns, which would make
|
| 31 |
+
# both sides NaN and the comparison vacuous. Overwrite just those fields with real values; the
|
| 32 |
+
# quant payload stays random.
|
| 33 |
+
for blk in range(blocks_per_row):
|
| 34 |
+
for off in FP16_OFFSETS[ggml_type]:
|
| 35 |
+
vals = (torch.rand(rows, dtype=torch.float32) * 0.05 + 0.001).to(torch.float16)
|
| 36 |
+
b = vals.view(torch.uint8).reshape(rows, 2)
|
| 37 |
+
raw[:, blk * nbytes + off : blk * nbytes + off + 2] = b
|
| 38 |
+
|
| 39 |
+
expect = ref.dequantize(raw, ggml_type, torch.float32).reshape(rows, cols)
|
| 40 |
+
got = ext.dequantize(raw.to("mps"), ggml_type, rows, cols, torch.float32).cpu()
|
| 41 |
+
d = (got - expect).abs().max().item()
|
| 42 |
+
print(f"{name:5s} dequantize maxdiff {d:.3e} {'OK' if d < 1e-4 else 'FAIL'}")
|
| 43 |
+
ok &= d < 1e-4
|
| 44 |
+
|
| 45 |
+
x = torch.randn(2, cols)
|
| 46 |
+
exp_mm = (x @ expect.T)
|
| 47 |
+
got_mm = ext.mul_mat_vec(raw.to("mps"), x.to("mps"), ggml_type, rows).cpu()
|
| 48 |
+
dm = (got_mm - exp_mm).abs().max().item()
|
| 49 |
+
rel = dm / exp_mm.abs().max().item()
|
| 50 |
+
print(f"{name:5s} mul_mat_vec maxdiff {dm:.3e} (rel {rel:.2e}) {'OK' if rel < 1e-4 else 'FAIL'}")
|
| 51 |
+
ok &= rel < 1e-4
|
| 52 |
+
|
| 53 |
+
print("\nALL PASS" if ok else "\nFAILURES")
|
| 54 |
+
sys.exit(0 if ok else 1)
|
devscripts/where_time_goes.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Attribute decode wall-time to module types, synchronising around each so MPS async cannot smear
|
| 2 |
+
the cost onto whoever happens to sync next."""
|
| 3 |
+
import collections, time, torch, torch.nn as nn
|
| 4 |
+
from transformers import AutoModelForCausalLM
|
| 5 |
+
|
| 6 |
+
m = AutoModelForCausalLM.from_pretrained(
|
| 7 |
+
"unsloth/Qwen3.5-4B-GGUF", gguf_file="Qwen3.5-4B-Q4_K_M.gguf", dtype="float32").eval()
|
| 8 |
+
dev = next(m.parameters()).device
|
| 9 |
+
cfg = m.config.get_text_config()
|
| 10 |
+
|
| 11 |
+
tot = collections.Counter(); cnt = collections.Counter(); t0 = {}
|
| 12 |
+
def pre(mod, i):
|
| 13 |
+
torch.mps.synchronize(); t0[id(mod)] = time.perf_counter()
|
| 14 |
+
def post(mod, i, o):
|
| 15 |
+
torch.mps.synchronize()
|
| 16 |
+
n = type(mod).__name__
|
| 17 |
+
tot[n] += time.perf_counter() - t0[id(mod)]; cnt[n] += 1
|
| 18 |
+
|
| 19 |
+
with torch.inference_mode():
|
| 20 |
+
ids = torch.randint(0, cfg.vocab_size, (1, 32), device=dev)
|
| 21 |
+
o = m(input_ids=ids, use_cache=True, logits_to_keep=1)
|
| 22 |
+
past, tok = o.past_key_values, o.logits[:, -1:].argmax(-1)
|
| 23 |
+
for _ in range(3):
|
| 24 |
+
o = m(input_ids=tok, past_key_values=past, use_cache=True, logits_to_keep=1)
|
| 25 |
+
past, tok = o.past_key_values, o.logits[:, -1:].argmax(-1)
|
| 26 |
+
torch.mps.synchronize()
|
| 27 |
+
|
| 28 |
+
hs = [mod.register_forward_pre_hook(pre) for mod in m.modules()] + \
|
| 29 |
+
[mod.register_forward_hook(post) for mod in m.modules()]
|
| 30 |
+
s = time.perf_counter()
|
| 31 |
+
o = m(input_ids=tok, past_key_values=past, use_cache=True, logits_to_keep=1)
|
| 32 |
+
torch.mps.synchronize()
|
| 33 |
+
wall = time.perf_counter() - s
|
| 34 |
+
for h in hs: h.remove()
|
| 35 |
+
|
| 36 |
+
print(f"one decode token (with sync hooks): {wall*1e3:.1f} ms\n")
|
| 37 |
+
print(f"{'module':32s} {'calls':>6} {'total ms':>10} {'us/call':>9}")
|
| 38 |
+
for n, t in tot.most_common(12):
|
| 39 |
+
print(f"{n:32s} {cnt[n]:6d} {t*1e3:10.1f} {t/cnt[n]*1e6:9.1f}")
|
gguf_metal/ggml_dispatch.mm
CHANGED
|
@@ -146,15 +146,20 @@ extern "C" int gguf_metal_mul_mat(void *blocks, size_t blocks_off, void *x, size
|
|
| 146 |
const int32_t ne12 = 1;
|
| 147 |
|
| 148 |
// torch keeps one encoder open on its command buffer and coalesces into it; opening a second is
|
| 149 |
-
// an error
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
at::mps::MPSStream *stream = at::mps::getCurrentMPSStream();
|
|
|
|
|
|
|
| 151 |
id<MTLComputeCommandEncoder> enc = stream->commandEncoder();
|
| 152 |
|
| 153 |
id<MTLBuffer> b_blocks = (__bridge id<MTLBuffer>)blocks;
|
| 154 |
id<MTLBuffer> b_x = (__bridge id<MTLBuffer>)x;
|
| 155 |
id<MTLBuffer> b_out = (__bridge id<MTLBuffer>)out;
|
| 156 |
|
| 157 |
-
int rc = 0;
|
| 158 |
if (M <= 8) {
|
| 159 |
// gemv: one token (or a handful), the decode path
|
| 160 |
const int nsg = info->mv_nsg, nr0 = info->mv_nr0;
|
|
@@ -242,6 +247,7 @@ extern "C" int gguf_metal_mul_mat(void *blocks, size_t blocks_off, void *x, size
|
|
| 242 |
threadsPerThreadgroup:MTLSizeMake(32, nsg, 1)];
|
| 243 |
}
|
| 244 |
}
|
|
|
|
| 245 |
|
| 246 |
return rc;
|
| 247 |
}
|
|
@@ -280,7 +286,9 @@ extern "C" int gguf_metal_get_rows(void *blocks, size_t blocks_off, void *indice
|
|
| 280 |
const NSUInteger nth = MIN((NSUInteger)ne00t, max_threads);
|
| 281 |
const NSUInteger nw0 = (ne00t + nth - 1) / nth;
|
| 282 |
|
|
|
|
| 283 |
at::mps::MPSStream *stream = at::mps::getCurrentMPSStream();
|
|
|
|
| 284 |
id<MTLComputeCommandEncoder> enc = stream->commandEncoder();
|
| 285 |
[enc setComputePipelineState:pso];
|
| 286 |
[enc setBytes:&args length:sizeof(args) atIndex:0];
|
|
@@ -289,5 +297,6 @@ extern "C" int gguf_metal_get_rows(void *blocks, size_t blocks_off, void *indice
|
|
| 289 |
[enc setBuffer:(__bridge id<MTLBuffer>)out offset:out_off atIndex:3];
|
| 290 |
[enc dispatchThreadgroups:MTLSizeMake(nw0 * rows, 1, 1)
|
| 291 |
threadsPerThreadgroup:MTLSizeMake(nth, 1, 1)];
|
|
|
|
| 292 |
return 0;
|
| 293 |
}
|
|
|
|
| 146 |
const int32_t ne12 = 1;
|
| 147 |
|
| 148 |
// torch keeps one encoder open on its command buffer and coalesces into it; opening a second is
|
| 149 |
+
// an error, so its encoder is reused -- which also puts these dispatches in the same submission
|
| 150 |
+
// as torch's own. That buffer is owned by the stream's serial queue though, so asking for the
|
| 151 |
+
// encoder from the calling thread races with torch committing it, and encoding into a committed
|
| 152 |
+
// buffer aborts (`_status < MTLCommandBufferStatusCommitted`). Everything below therefore runs on
|
| 153 |
+
// that queue, which is also what torch's own extension guidance prescribes.
|
| 154 |
at::mps::MPSStream *stream = at::mps::getCurrentMPSStream();
|
| 155 |
+
__block int rc = 0;
|
| 156 |
+
dispatch_sync(stream->queue(), ^{
|
| 157 |
id<MTLComputeCommandEncoder> enc = stream->commandEncoder();
|
| 158 |
|
| 159 |
id<MTLBuffer> b_blocks = (__bridge id<MTLBuffer>)blocks;
|
| 160 |
id<MTLBuffer> b_x = (__bridge id<MTLBuffer>)x;
|
| 161 |
id<MTLBuffer> b_out = (__bridge id<MTLBuffer>)out;
|
| 162 |
|
|
|
|
| 163 |
if (M <= 8) {
|
| 164 |
// gemv: one token (or a handful), the decode path
|
| 165 |
const int nsg = info->mv_nsg, nr0 = info->mv_nr0;
|
|
|
|
| 247 |
threadsPerThreadgroup:MTLSizeMake(32, nsg, 1)];
|
| 248 |
}
|
| 249 |
}
|
| 250 |
+
});
|
| 251 |
|
| 252 |
return rc;
|
| 253 |
}
|
|
|
|
| 286 |
const NSUInteger nth = MIN((NSUInteger)ne00t, max_threads);
|
| 287 |
const NSUInteger nw0 = (ne00t + nth - 1) / nth;
|
| 288 |
|
| 289 |
+
// Same as the matmul: encode on the stream's own queue so this cannot race torch's commits.
|
| 290 |
at::mps::MPSStream *stream = at::mps::getCurrentMPSStream();
|
| 291 |
+
dispatch_sync(stream->queue(), ^{
|
| 292 |
id<MTLComputeCommandEncoder> enc = stream->commandEncoder();
|
| 293 |
[enc setComputePipelineState:pso];
|
| 294 |
[enc setBytes:&args length:sizeof(args) atIndex:0];
|
|
|
|
| 297 |
[enc setBuffer:(__bridge id<MTLBuffer>)out offset:out_off atIndex:3];
|
| 298 |
[enc dispatchThreadgroups:MTLSizeMake(nw0 * rows, 1, 1)
|
| 299 |
threadsPerThreadgroup:MTLSizeMake(nth, 1, 1)];
|
| 300 |
+
});
|
| 301 |
return 0;
|
| 302 |
}
|