Skip to content

Commit 41bba98

Browse files
kmbandyclaude
andcommitted
fix(MAD-223 G.6.g): off-by-one in ml8_fp32_to_e4m3 overflow → +0.40 PPL
ml8.cu::ml8_fp32_to_e4m3 had `e_out >= 15` instead of `> 15` in the mantissa-round-up overflow path. E4M3 represents e=15, m=0..6 as the finite values 256, 288, 320, 352, 384, 416, 448 — all valid. Only the NaN slot (e=15, m=7) is handled separately by the existing m_e4m3==7 guard. The buggy check was prematurely saturating every fp8 activation in the |x|≈[208..448] post-scale band to ±448, with per-element error up to 1.75×. Impact on Cell E (Qwen3.5-4B, llama-perplexity ctx=512 wikitext): PPL before: 10.3862 (Δ vs f16 = +0.441) PPL after: 9.9849 (Δ vs f16 = +0.040) ← essentially lossless ml8-4 now beats Unsloth UD-Q4_K_XL (10.1345, Δ=+0.190) by 0.15 PPL at comparable size, while running 1.22× faster than f16 on prefill (842 t/s vs ~692 t/s) and 1.6× smaller (5.4 GB vs 8.5 GB). Bisect path that surfaced the bug (preserved as diagnostic scripts): - check_ml8_gguf_sidecars.py: byte-SHA on centroids/rotation/meta against .pt blob — proved sidecars were clean. - compare_hip_vs_python_layer0.py: per-stage tensor dump + diff (pre-rotation, post-rotation, final). Rotation matched fp32 epsilon; final diverged → bug downstream of rotation. - compare_quant_only.py: feed identical fp32 to HIP's ml8_quantize_activations_kernel and torch.float8_e4m3fn cast. 52 of 40,960 bytes mismatched — all at the e_out=15 boundary. Env-gated tensor dump hooks (ML8_DUMP=1) added to ml8.cu for future kernel divergence debugging. Compiled out at runtime when env unset. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent cf6da48 commit 41bba98

4 files changed

Lines changed: 426 additions & 1 deletion

File tree

ggml/src/ggml-cuda/ml8.cu

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,56 @@
1414

1515
#include <cstdio>
1616
#include <cstdlib>
17+
#include <atomic>
1718
#include <mutex>
1819
#include <unordered_map>
1920
#include <vector>
2021

22+
// G.6.g.C: debug hooks to dump rotation input + ml8_mul_mat output to /tmp
23+
// for Python-side bit-equivalence comparison. Set env var ML8_DUMP=1 to
24+
// enable. First-call-only; the static atomics track which dumps have fired.
25+
namespace {
26+
std::atomic<bool> g_ml8_dump_rot_done {false};
27+
std::atomic<bool> g_ml8_dump_rotdst_done {false};
28+
std::atomic<bool> g_ml8_dump_mm_done {false};
29+
std::atomic<bool> g_ml8_dump_quant_done {false};
30+
31+
void ml8_dump_u8(const char * path, const uint8_t * d_ptr, size_t n_elems,
32+
cudaStream_t stream, int ndim, const int64_t * shape) {
33+
std::vector<uint8_t> host(n_elems);
34+
cudaMemcpyAsync(host.data(), d_ptr, n_elems, cudaMemcpyDeviceToHost, stream);
35+
cudaStreamSynchronize(stream);
36+
FILE * f = std::fopen(path, "wb");
37+
if (!f) { std::fprintf(stderr, "[ml8-dump] open %s failed\n", path); return; }
38+
std::fwrite(&ndim, sizeof(int32_t), 1, f);
39+
std::fwrite(shape, sizeof(int64_t), (size_t) ndim, f);
40+
std::fwrite(host.data(), 1, n_elems, f);
41+
std::fclose(f);
42+
std::fprintf(stderr, "[ml8-dump] wrote %s ndim=%d n=%zu\n", path, ndim, n_elems);
43+
}
44+
45+
bool ml8_dump_enabled() {
46+
static const bool e = (std::getenv("ML8_DUMP") != nullptr);
47+
return e;
48+
}
49+
50+
void ml8_dump_fp32(const char * path, const float * d_ptr, size_t n_elems,
51+
cudaStream_t stream, int ndim, const int64_t * shape) {
52+
std::vector<float> host(n_elems);
53+
cudaMemcpyAsync(host.data(), d_ptr, n_elems * sizeof(float),
54+
cudaMemcpyDeviceToHost, stream);
55+
cudaStreamSynchronize(stream);
56+
FILE * f = std::fopen(path, "wb");
57+
if (!f) { std::fprintf(stderr, "[ml8-dump] open %s failed\n", path); return; }
58+
// Header: int32 ndim, int64 * shape, then fp32 data
59+
std::fwrite(&ndim, sizeof(int32_t), 1, f);
60+
std::fwrite(shape, sizeof(int64_t), (size_t) ndim, f);
61+
std::fwrite(host.data(), sizeof(float), n_elems, f);
62+
std::fclose(f);
63+
std::fprintf(stderr, "[ml8-dump] wrote %s ndim=%d n=%zu\n", path, ndim, n_elems);
64+
}
65+
} // namespace
66+
2167
// On-disk per-block layout: 4-byte fp32 scale, then QK_ML8/2 = 32 packed
2268
// nibble bytes covering 64 K-elements. sizeof(block_ml8_4) == 36.
2369
static constexpr int ML8_BLOCK_BYTES = (int) sizeof(block_ml8_4);
@@ -251,7 +297,12 @@ static __device__ __forceinline__ uint8_t ml8_fp32_to_e4m3(float xv) {
251297
if (m_e4m3 == 8) {
252298
m_e4m3 = 0;
253299
e_out += 1;
254-
if (e_out >= 15) {
300+
// G.6.g.C BUGFIX (2026-05-26): was `e_out >= 15`, which prematurely
301+
// saturated valid e=15, m=0..6 values (256, 288, ..., 448) to ±448.
302+
// Only e>15 (= e_real > 8) overflows the E4M3 finite range. The
303+
// m=7 NaN slot is handled by the `m_e4m3 == 7` guard below. This
304+
// bug cost ~+0.33 PPL on Cell E vs the Python kernel reference.
305+
if (e_out > 15) {
255306
return (uint8_t)((sign << 7) | (0xFu << 3) | 0x6u);
256307
}
257308
}
@@ -438,6 +489,13 @@ void ggml_cuda_op_ml8_mul_mat(
438489
ggml_cuda_pool_alloc<uint8_t> a_fp8(ctx.pool(), (size_t) M_pad * (size_t) K);
439490
ggml_cuda_pool_alloc<float> a_scale(ctx.pool(), (size_t) M_pad);
440491

492+
// G.6.g.C: dump pre-quant fp32 activation that the kernel will see.
493+
if (ml8_dump_enabled() && !g_ml8_dump_quant_done.load()) {
494+
const int64_t shp[2] = { (int64_t) K, (int64_t) M_pad };
495+
ml8_dump_fp32("/tmp/ml8_hip_x_prequant.bin", x_src,
496+
(size_t) M_pad * (size_t) K, stream, 2, shp);
497+
}
498+
441499
ggml_cuda_ml8_quantize_activations(
442500
stream,
443501
x_src,
@@ -446,6 +504,16 @@ void ggml_cuda_op_ml8_mul_mat(
446504
M_pad,
447505
K);
448506

507+
// G.6.g.C: dump fp8 quantized activations + per-row scale on first call.
508+
if (ml8_dump_enabled() && !g_ml8_dump_quant_done.exchange(true)) {
509+
const int64_t shp_fp8[2] = { (int64_t) K, (int64_t) M_pad };
510+
const int64_t shp_scale[1] = { (int64_t) M_pad };
511+
ml8_dump_u8("/tmp/ml8_hip_a_fp8.bin", a_fp8.get(),
512+
(size_t) M_pad * (size_t) K, stream, 2, shp_fp8);
513+
ml8_dump_fp32("/tmp/ml8_hip_a_scale.bin", a_scale.get(),
514+
(size_t) M_pad, stream, 1, shp_scale);
515+
}
516+
449517
// ── 4. Allocate bf16 output (M_pad × N) and launch mt_ml8_gemm.
450518
ggml_cuda_pool_alloc<nv_bfloat16> c_bf16(ctx.pool(), (size_t) M_pad * (size_t) N);
451519

@@ -483,6 +551,13 @@ void ggml_cuda_op_ml8_mul_mat(
483551
GGML_ASSERT(bf16_to_fp32 != nullptr);
484552
bf16_to_fp32(c_bf16.get(), (float *) dst->data,
485553
(size_t) M * (size_t) N, stream);
554+
555+
// G.6.g.C: dump final mul_mat output on first call.
556+
if (ml8_dump_enabled() && !g_ml8_dump_mm_done.exchange(true)) {
557+
const int64_t shape[2] = { (int64_t) N, (int64_t) M };
558+
ml8_dump_fp32("/tmp/ml8_hip_y_out.bin", (const float *) dst->data,
559+
(size_t) M * (size_t) N, stream, 2, shape);
560+
}
486561
}
487562

488563
// ─────────────────────────────────────────────────────────────────────
@@ -559,6 +634,15 @@ void ggml_cuda_op_ml8_apply_rotation(
559634
const int n_tokens = (int) x->ne[1];
560635
const size_t total_elems = (size_t) n_tokens * (size_t) d_dim;
561636

637+
// G.6.g.C: dump rotation input (pre-rotation activations) on first call.
638+
if (ml8_dump_enabled() && !g_ml8_dump_rot_done.exchange(true)) {
639+
const int64_t shape[2] = { (int64_t) d_dim, (int64_t) n_tokens };
640+
ml8_dump_fp32("/tmp/ml8_hip_x_in.bin", (const float *) x->data,
641+
total_elems, stream, 2, shape);
642+
}
643+
644+
// (rotation kernel runs below; output dump happens after the kernel returns)
645+
562646
// Step 1: copy X into a scratch Z buffer (FWHT is in-place).
563647
ggml_cuda_pool_alloc<float> z_buf(ctx.pool(), total_elems);
564648
CUDA_CHECK(cudaMemcpyAsync(z_buf.get(), x->data,
@@ -578,4 +662,11 @@ void ggml_cuda_op_ml8_apply_rotation(
578662
(float *) dst->data,
579663
a_dim,
580664
b_dim);
665+
666+
// G.6.g.C: dump rotation output (post-FWHT + H_a^T) on first call.
667+
if (ml8_dump_enabled() && !g_ml8_dump_rotdst_done.exchange(true)) {
668+
const int64_t shape[2] = { (int64_t) d_dim, (int64_t) n_tokens };
669+
ml8_dump_fp32("/tmp/ml8_hip_x_rotated.bin", (const float *) dst->data,
670+
total_elems, stream, 2, shape);
671+
}
581672
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""Byte-level check: ml8 GGUF sidecars vs source .pt blob.
2+
3+
Reads centroid LUT, rotation H_a, rotation_meta, and ml8 weight bytes
4+
from a freshly-converted ml8 GGUF and the corresponding cell calibration
5+
.pt blob. Computes max_abs_diff and SHA256 on each pair.
6+
7+
Run: python3 scripts/calibration/check_ml8_gguf_sidecars.py
8+
"""
9+
from __future__ import annotations
10+
11+
import hashlib
12+
import sys
13+
from pathlib import Path
14+
15+
import numpy as np
16+
import torch
17+
18+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "gguf-py"))
19+
import gguf # noqa: E402
20+
21+
22+
GGUF_PATH = Path("/home/kmbandy/models/Qwen3.5-4B-ml8_4-cellE.gguf")
23+
PT_PATH = Path("/home/kmbandy/models/cell-e/model_layers_0_mlp_gate_proj.pt")
24+
BASE = "blk.0.ffn_gate"
25+
WEIGHT_NAME = "blk.0.ffn_gate.weight"
26+
27+
28+
def sha(b: bytes) -> str:
29+
return hashlib.sha256(b).hexdigest()[:16]
30+
31+
32+
def main():
33+
print(f"GGUF: {GGUF_PATH}")
34+
print(f"PT: {PT_PATH}")
35+
print()
36+
37+
reader = gguf.GGUFReader(str(GGUF_PATH))
38+
blob = torch.load(PT_PATH, map_location="cpu", weights_only=False)
39+
40+
# Map tensor name -> tensor
41+
tmap = {t.name: t for t in reader.tensors}
42+
43+
# ── 1. centroids
44+
cent_gguf = tmap[BASE + ".centroids"]
45+
cent_gguf_arr = np.asarray(cent_gguf.data) # uint8 buffer
46+
print(f" GGUF {cent_gguf.name} shape={tuple(cent_gguf.shape)} "
47+
f"dtype={cent_gguf.tensor_type} bytes={cent_gguf_arr.nbytes}")
48+
49+
cent_pt = blob["centroids_per_group"] # [n_groups_k, 16] fp32
50+
cent_pt_fp8 = cent_pt.to(torch.float32).to(torch.float8_e4m3fn)
51+
cent_pt_bytes = cent_pt_fp8.contiguous().view(torch.uint8).numpy()
52+
print(f" PT centroids_per_group → fp8 shape={tuple(cent_pt_fp8.shape)} "
53+
f"bytes={cent_pt_bytes.nbytes}")
54+
55+
# Compare
56+
if cent_gguf_arr.shape == cent_pt_bytes.shape and (cent_gguf_arr == cent_pt_bytes).all():
57+
print(f" ✓ centroids MATCH sha={sha(cent_gguf_arr.tobytes())}")
58+
else:
59+
print(f" ✗ centroids DIFFER")
60+
print(f" GGUF sha = {sha(cent_gguf_arr.tobytes())}")
61+
print(f" PT sha = {sha(cent_pt_bytes.tobytes())}")
62+
# Dequantize both to fp32 and diff
63+
g_fp32 = np.asarray(cent_gguf_arr).view(np.uint8)
64+
p_fp32 = cent_pt_bytes.flatten()
65+
print(f" GGUF first 16 bytes: {g_fp32[:16].tolist()}")
66+
print(f" PT first 16 bytes: {p_fp32[:16].tolist()}")
67+
print()
68+
69+
# ── 2. rotation_h_a
70+
rot_gguf = tmap.get(BASE + ".rotation_h_a")
71+
rot_pt = blob.get("rotation", {}).get("h_a")
72+
if rot_gguf is not None and rot_pt is not None:
73+
rot_gguf_fp32 = np.asarray(rot_gguf.data).view(np.float32).reshape(tuple(rot_gguf.shape)[::-1])
74+
rot_pt_fp32 = rot_pt.to(torch.float32).contiguous().numpy()
75+
print(f" GGUF {rot_gguf.name} shape={tuple(rot_gguf.shape)} "
76+
f"py-view-shape={rot_gguf_fp32.shape}")
77+
print(f" PT rotation.h_a shape={rot_pt_fp32.shape}")
78+
# gguf might have reversed dim order
79+
if rot_gguf_fp32.shape != rot_pt_fp32.shape:
80+
print(f" shape mismatch! trying transpose...")
81+
if rot_gguf_fp32.T.shape == rot_pt_fp32.shape:
82+
rot_gguf_fp32 = rot_gguf_fp32.T
83+
diff = np.abs(rot_gguf_fp32 - rot_pt_fp32).max() if rot_gguf_fp32.shape == rot_pt_fp32.shape else float("nan")
84+
if diff == 0.0:
85+
print(f" ✓ rotation_h_a EXACT MATCH sha={sha(rot_gguf_fp32.tobytes())}")
86+
else:
87+
print(f" ✗ rotation_h_a DIFFERS max|diff|={diff:.3e}")
88+
print(f" GGUF first 4 values: {rot_gguf_fp32.flatten()[:4]}")
89+
print(f" PT first 4 values: {rot_pt_fp32.flatten()[:4]}")
90+
else:
91+
print(f" (rotation_h_a missing from one side: gguf={rot_gguf is not None}, pt={rot_pt is not None})")
92+
print()
93+
94+
# ── 3. rotation_meta
95+
meta_gguf = tmap.get(BASE + ".rotation_meta")
96+
if meta_gguf is not None:
97+
meta_arr = np.asarray(meta_gguf.data).view(np.int32)
98+
print(f" GGUF {meta_gguf.name} shape={tuple(meta_gguf.shape)} values={meta_arr.tolist()}")
99+
# Expected: [a_dim, b_dim, in_features, kind_id=1]
100+
rot = blob.get("rotation", {})
101+
expected = [int(rot.get("a_dim", 0)), int(rot.get("b_dim", 0)),
102+
int(rot.get("in_features", 0)), 1]
103+
print(f" PT expected: {expected}")
104+
if meta_arr.tolist() == expected:
105+
print(f" ✓ rotation_meta MATCH")
106+
else:
107+
print(f" ✗ rotation_meta DIFFERS")
108+
print()
109+
110+
# ── 4. main ml8 weight: block_ml8_4 format
111+
# Skip detailed block-by-block for now — if centroids + scales + indices
112+
# all match, ml8 weight is right by construction.
113+
w_gguf = tmap[WEIGHT_NAME]
114+
print(f" GGUF {w_gguf.name} shape={tuple(w_gguf.shape)} "
115+
f"dtype={w_gguf.tensor_type} bytes={np.asarray(w_gguf.data).nbytes}")
116+
117+
indices = blob["indices"] # int8 [N, K]
118+
scales = blob["scale_per_group"] # fp32 [N, n_groups_k]
119+
print(f" PT indices shape={tuple(indices.shape)} scale shape={tuple(scales.shape)}")
120+
121+
# Sample first block bytes
122+
w_bytes = np.asarray(w_gguf.data)
123+
print(f" GGUF first 8 bytes (block 0): {w_bytes[:8].tolist()}")
124+
print(f" PT scale[0, 0] = {scales[0, 0].item()} "
125+
f"(if fp32 → bytes: {scales[0, 0].numpy().tobytes().hex()})")
126+
127+
128+
if __name__ == "__main__":
129+
main()
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Per-stage bit-equivalence: HIP vs Python ml8 pipeline (G.6.g.C).
2+
3+
Dumps loaded:
4+
/tmp/ml8_hip_x_in.bin — pre-rotation activations (input to ml8_apply_rotation)
5+
/tmp/ml8_hip_x_rotated.bin — post-rotation activations (input to ml8_mul_mat)
6+
/tmp/ml8_hip_y_out.bin — final ml8_mul_mat output (after bf16→fp32)
7+
8+
Tests:
9+
Stage A: Python rotation on x_in ↔ HIP x_rotated (isolates rotation kernel)
10+
Stage B: Python quant+gemm on HIP x_rotated ↔ HIP y_out (isolates quant + gemm)
11+
"""
12+
from __future__ import annotations
13+
14+
import struct
15+
import sys
16+
from pathlib import Path
17+
18+
import numpy as np
19+
import torch
20+
21+
sys.path.insert(0, str(Path(__file__).resolve().parent))
22+
from ml8_io import load_ml8_layer as load_blob # noqa: E402
23+
from ml8_runtime import ml8_layer_from_blob, ml8_gemm # noqa: E402
24+
from kronecker_rotation import KroneckerRotation # noqa: E402
25+
26+
27+
PT_PATH = Path("/home/kmbandy/models/cell-e/model_layers_0_mlp_gate_proj.pt")
28+
29+
30+
def read_dump(path: Path) -> np.ndarray:
31+
with open(path, "rb") as f:
32+
ndim = struct.unpack("<i", f.read(4))[0]
33+
shape = list(struct.unpack(f"<{ndim}q", f.read(8 * ndim)))
34+
n = 1
35+
for s in shape: n *= s
36+
data = np.frombuffer(f.read(4 * n), dtype=np.float32).copy()
37+
np_shape = tuple(reversed(shape)) # GGML ne[0]=innermost → reverse for numpy
38+
return data.reshape(np_shape)
39+
40+
41+
def stage_diff(a: np.ndarray, b: np.ndarray, label: str):
42+
if a.shape != b.shape:
43+
print(f" {label}: ✗ SHAPE MISMATCH a={a.shape} b={b.shape}")
44+
return
45+
diff = np.abs(a - b)
46+
denom = np.maximum(np.abs(b), 1e-12)
47+
rel = diff / denom
48+
a_flat = a.reshape(a.shape[0], -1)
49+
b_flat = b.reshape(b.shape[0], -1)
50+
cos = (a_flat * b_flat).sum(axis=1) / (
51+
np.linalg.norm(a_flat, axis=1) * np.linalg.norm(b_flat, axis=1) + 1e-12)
52+
print(f" {label}:")
53+
print(f" max|diff| = {diff.max():.6e}")
54+
print(f" mean|diff| = {diff.mean():.6e}")
55+
print(f" max rel diff = {rel.max():.6e}")
56+
print(f" cos(row) mean = {cos.mean():.6f} min = {cos.min():.6f}")
57+
flat_idx = diff.argmax()
58+
r, c = np.unravel_index(flat_idx, diff.shape)
59+
print(f" worst [{r},{c}]: HIP={a[r,c]:.6f} PY={b[r,c]:.6f}")
60+
61+
62+
def main():
63+
print("=== HIP dump load ===")
64+
x_in_hip = read_dump(Path("/tmp/ml8_hip_x_in.bin"))
65+
x_rotated_hip = read_dump(Path("/tmp/ml8_hip_x_rotated.bin"))
66+
y_out_hip = read_dump(Path("/tmp/ml8_hip_y_out.bin"))
67+
M, K = x_in_hip.shape
68+
N = y_out_hip.shape[1]
69+
print(f" M={M} K={K} N={N}")
70+
71+
blob = load_blob(PT_PATH)
72+
print(f"\n blob rotation kind: {blob.get('rotation', {}).get('kind')}")
73+
device = torch.device("cuda:0")
74+
75+
# Build the rotation from the blob (same recipe Ml8Linear uses internally)
76+
rotation_dict = blob["rotation"]
77+
rotation = KroneckerRotation.from_dict(rotation_dict)
78+
# rotation.h_a is on CPU by class contract; move to device explicitly.
79+
rotation.h_a = rotation.h_a.to(device=device, dtype=torch.float32)
80+
81+
# ──────────────── Stage A: rotation only ────────────────
82+
print("\n=== Stage A: Python rotation ↔ HIP rotation ===")
83+
x_in_torch = torch.from_numpy(x_in_hip).contiguous().to(device)
84+
x_rotated_py = rotation.forward(x_in_torch).float().cpu().numpy()
85+
stage_diff(x_rotated_hip, x_rotated_py, "rotation output (HIP vs Python dense)")
86+
87+
# ──────────────── Stage B: quant + gemm starting from HIP's rotated ─────────
88+
print("\n=== Stage B: Python quant+gemm (input = HIP rotated_x) ↔ HIP final ===")
89+
# Pad to multiple of 16
90+
pad_m = ((M + 15) // 16) * 16
91+
if pad_m != M:
92+
pad_rows = pad_m - M
93+
x_rot_padded = np.concatenate([x_rotated_hip, np.zeros((pad_rows, K), dtype=np.float32)], axis=0)
94+
else:
95+
x_rot_padded = x_rotated_hip
96+
97+
layer = ml8_layer_from_blob(blob, device=device)
98+
x_rot_torch = torch.from_numpy(x_rot_padded).contiguous().to(device)
99+
100+
# Quant: same math as Ml8Linear.forward
101+
FP8_MAX = 448.0
102+
row_max = x_rot_torch.abs().amax(dim=1, keepdim=True).clamp(min=1e-8)
103+
a_scale = (row_max / FP8_MAX).squeeze(1).contiguous()
104+
x_fp8 = (x_rot_torch / a_scale.unsqueeze(1)).to(torch.float8_e4m3fn).contiguous()
105+
106+
with torch.inference_mode():
107+
y_bf16 = ml8_gemm(x_fp8, layer, a_scale=a_scale, out_dtype=torch.bfloat16)
108+
y_py = y_bf16.float().cpu().numpy()[:M]
109+
stage_diff(y_out_hip, y_py, "final output (HIP vs Python quant+gemm)")
110+
111+
112+
if __name__ == "__main__":
113+
main()

0 commit comments

Comments
 (0)