diff --git a/tests/run.sh b/tests/run.sh index ba59142..7b40609 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -1338,6 +1338,30 @@ fi # ------------------------------------------------------------ converter ---- head_ "converter" +# The fp8 converter path has two readers and a silent shape-preserving failure +# mode, so keep its tile mapping under a small synthetic test instead of +# relying on a multi-hour model conversion. Exit 77 is an explicit skip when +# torch is unavailable, never a pass. +# +# Through uv, like every other torch checker here: torch is not a dependency +# of this repo and is not a system package on the machines that run this. As +# bare `python3` the whole check reported "torch not installed" and skipped +# everywhere, CI included — where the Linux job is the one that installs uv, +# so this is exactly where it does get to run. +if ! command -v uv >/dev/null 2>&1; then + # The guard is on uv rather than python3 for the same reason: without uv + # run_uv exits 127 and the catch-all below would call that a failure. + sk "fp8 block-scale mapping" "uv not installed" +else + out=$(run_uv run --quiet --with torch --no-project \ + python tests/test_fp8_blocks.py 2>&1); rc=$? + case "$rc" in + 0) ok "fp8 block scales, partial tiles, missing companions, and reader agreement" ;; + 77) sk "fp8 block-scale mapping" "torch not installed" ;; + *) no "fp8 block-scale mapping"; printf '%s\n' "$out" | grep -E "FAIL|Error|Traceback" | head -5 ;; + esac +fi + # Resume is the one converter behaviour that cannot be checked by looking at # a finished container: it is about the partial states a crash leaves. The # quantizer is stubbed out, so this needs neither torch nor source weights. diff --git a/tests/test_fp8_blocks.py b/tests/test_fp8_blocks.py new file mode 100644 index 0000000..dbba939 --- /dev/null +++ b/tests/test_fp8_blocks.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 SQLite Cloud, Inc. +"""Regression checks for fp8 block-scale dequantization. + +These tests use small synthetic tensors so they do not need model weights. A +missing torch installation is an explicit skip, matching tests/run.sh's rule +that unavailable prerequisites must never look like a pass. + +The one case nothing here can catch, stated because it is the reason the tile +size is read from the checkpoint's config rather than inferred from the two +shapes: a *compatible but wrong* block size. 300 rows against 3 scale rows +admits both 128 (the truth, with a partial last tile) and 100 (a clean split). +Both satisfy the shape check below, both produce a tensor of the right size, +and the wrong one applies every scale to the wrong rows. No assertion over +shapes can separate them, which is why `unblock_scale` takes `block` as an +argument instead of deriving it — the check that matters happened before this +file was reached. +""" + +import json +import os +import struct +import sys +import tempfile + +try: + import torch +except ImportError: + print("SKIP: torch is not installed") + raise SystemExit(77) + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "tools")) +from convert import ShardReader +from mxfp4 import ST, unblock_scale + + +def expected_dequant(q, scale, block): + """Reference the tile lookup with explicit row and column indices.""" + bm, bn = block + rows = torch.arange(q.shape[0]) // bm + cols = torch.arange(q.shape[1]) // bn + return q.float() * scale[rows[:, None], cols[None, :]] + + +def write_safetensors_model(root, block, include_scale=True): + """Write the smallest safetensors model both readers can consume.""" + q = torch.tensor([[1.0, -2.0, 3.0, -4.0, 0.5], + [-1.0, 2.0, -3.0, 4.0, -0.5]], dtype=torch.float8_e4m3fn) + scale = torch.tensor([[1.0, 2.0]], dtype=torch.float32) + tensors = {"weight": ("F8_E4M3", list(q.shape), + bytes(q.contiguous().view(torch.uint8).flatten().tolist()))} + if include_scale: + tensors["weight_scale_inv"] = ("F32", list(scale.shape), + bytes(scale.contiguous().view(torch.uint8).flatten().tolist())) + + header = {} + payload = bytearray() + for name, (dtype, shape, raw) in tensors.items(): + start = len(payload) + payload.extend(raw) + header[name] = {"dtype": dtype, "shape": shape, + "data_offsets": [start, len(payload)]} + header_bytes = json.dumps(header, separators=(",", ":")).encode() + while (8 + len(header_bytes)) % 8: + header_bytes += b" " + with open(os.path.join(root, "shard.safetensors"), "wb") as f: + f.write(struct.pack("