|
13 | 13 | The K side (wk) and weights_proj stay bfloat16 — they're tiny (head_dim ~32 |
14 | 14 | and n_heads ~32) so the FP8 overhead is not worth it. |
15 | 15 |
|
| 16 | +Fused FP8 GEMM wiring (Path C): |
| 17 | + The indexer's ``q`` projection ``qr @ wq_b.T`` — the inner block-FP8 |
| 18 | + weight × activation GEMM that feeds the score einsum — is routed through |
| 19 | + the TileLang cooperative-tensor ``fused_fp8_gemm`` prim (the keystone |
| 20 | + block-FP8 GEMM) via :meth:`LightningIndexerFP8._wq_b_apply`, under an |
| 21 | + *explicit* shape-dispatch (RULE #1: no silent dequant fallback hiding a |
| 22 | + broken fused path — a malformed FP8 weight RAISES). ``fused_fp8_gemm`` |
| 23 | + internally picks its cooperative-tensor prim or a numerically e4m3-exact |
| 24 | + scalar MSL kernel; both agree to FP8 tolerance. |
| 25 | +
|
| 26 | + The score contraction itself (``einsum bqhd,bkd,bqh->bqk``) is NOT a |
| 27 | + ``fused_fp8_gemm``-shaped op: both ``q`` and ``k`` are *dynamic bf16 |
| 28 | + activations* (there is no static block-FP8 weight operand), and the |
| 29 | + contraction is a per-head-weighted, head-summed attention score, not a |
| 30 | + plain ``A @ W.T``. It therefore stays pure-MLX by design — wiring it |
| 31 | + through ``fused_fp8_gemm`` would require fabricating a fake static FP8 |
| 32 | + weight, which RULE #1 forbids. |
| 33 | +
|
16 | 34 | Provenance / labelling note: |
17 | 35 | The only vendored upstream piece here is ``dequant_block_fp8`` (mlx-lm |
18 | | - PR #1224); everything else is plain MLX ops. There is NO fused Path-E |
19 | | - Metal indexer kernel — do not label this module "Path E". The GDN/KDA |
20 | | - Path E (``mlx_lm_gated_delta_update`` / ``mlx_lm_kda_update``) is a |
21 | | - separate, genuinely fused vendored Metal kernel. A future Metal/TileLang |
22 | | - fused indexer GEMM could replace the inner matmul here without touching |
23 | | - this wrapper's external API; until then this is a pure-MLX path. |
| 36 | + PR #1224). There is NO fused Path-E Metal indexer kernel — do not label |
| 37 | + this module "Path E". The GDN/KDA Path E |
| 38 | + (``mlx_lm_gated_delta_update`` / ``mlx_lm_kda_update``) is a separate, |
| 39 | + genuinely fused vendored Metal kernel. The ``wq_b`` projection here is a |
| 40 | + real fused TileLang block-FP8 GEMM (Path C), but the wrapper as a whole is |
| 41 | + not "Path E". |
24 | 42 | """ |
25 | 43 |
|
26 | 44 | from dataclasses import dataclass |
@@ -95,31 +113,93 @@ def __init__(self, config: LightningIndexerFP8Config): |
95 | 113 | self.weights_proj = nn.Linear(config.hidden_size, config.n_heads, bias=False) |
96 | 114 |
|
97 | 115 | def _wq_b_apply(self, qr: mx.array) -> mx.array: |
98 | | - """Apply wq_b: qr @ wq_b.T with dequant-on-the-fly. |
99 | | -
|
100 | | - For the fp8-blocks path this reuses the fused cooperative-tensor |
101 | | - ``fused_fp8_gemm`` (block-FP8 weight × activation -> ``qr @ W.T``) |
102 | | - when available, falling back to a pure-MLX ``dequant_block_fp8`` + |
103 | | - matmul otherwise. ``fused_fp8_gemm`` is itself fail-safe (it drops to |
104 | | - its own scalar kernel if TileLang is unavailable), so the indexer keeps |
105 | | - working on every host. |
| 116 | + """Apply the FP8 score-path projection ``qr @ wq_b.T``. |
| 117 | +
|
| 118 | + This is the indexer's one genuine block-FP8 weight × activation GEMM |
| 119 | + (the ``q`` projection that feeds the score einsum downstream): ``wq_b`` |
| 120 | + is stored as a static ``[out_dim, q_lora_rank]`` uint8 e4m3 weight with |
| 121 | + a ``[ceil(out_dim/128), ceil(q_lora_rank/128)]`` per-block ``scale_inv``, |
| 122 | + which is exactly the ``fused_fp8_gemm`` contract (``out = a @ W.T`` with |
| 123 | + a precomputed block-FP8 weight). |
| 124 | +
|
| 125 | + RULE #1 — explicit dispatch, NO silent fallback: |
| 126 | + * fp8-blocks path: the shape is validated against the |
| 127 | + ``fused_fp8_gemm`` block-FP8 contract and the fused |
| 128 | + cooperative-tensor kernel is called *directly*. If the kernel |
| 129 | + raises it propagates — we do NOT swallow it into a dequant matmul |
| 130 | + that would hide a broken fused path. ``fused_fp8_gemm`` itself |
| 131 | + chooses its TileLang cooperative-tensor prim vs scalar MSL kernel |
| 132 | + (both numerically e4m3-exact); that is an internal, numerically |
| 133 | + equivalent choice, not a degraded fallback. |
| 134 | + * a shape that does NOT satisfy the block-FP8 contract is handled by |
| 135 | + an *explicit*, shape-correct ``dequant_block_fp8`` + matmul path |
| 136 | + with a stated reason — reached only when the contract check says so, |
| 137 | + never as an exception-swallowing escape hatch. |
| 138 | + * non-fp8 (bf16) path: plain matmul, no fp8 involved. |
106 | 139 | """ |
107 | 140 | if self.config.fp8_blocks: |
108 | | - try: |
109 | | - from cppmega_v4._tilelang.fused_fp8_gemm import fused_fp8_gemm |
| 141 | + from cppmega_v4._tilelang.fused_fp8_gemm import fused_fp8_gemm |
110 | 142 |
|
| 143 | + if self._wq_b_fp8_gemm_supported(qr): |
111 | 144 | # W = wq_b [out_dim, q_lora_rank]; fused_fp8_gemm computes |
112 | | - # qr @ W.T = [B, T, out_dim]. |
| 145 | + # qr @ W.T = [B, T, out_dim]. Errors here propagate (RULE #1). |
113 | 146 | return fused_fp8_gemm( |
114 | 147 | self._wq_b_fp8, self._wq_b_scale_inv, qr |
115 | 148 | ).astype(mx.bfloat16) |
116 | | - except Exception: # noqa: BLE001 -- never break the indexer forward |
117 | | - w = dequant_block_fp8(self._wq_b_fp8, self._wq_b_scale_inv) |
| 149 | + # Explicit (not silent) shape-correct path: the weight does not |
| 150 | + # satisfy the block-FP8 GEMM contract, so dequant the e4m3 weight |
| 151 | + # and matmul in bf16. The reason is reported by |
| 152 | + # ``_wq_b_fp8_gemm_supported`` raising on a genuinely broken shape. |
| 153 | + w = dequant_block_fp8(self._wq_b_fp8, self._wq_b_scale_inv) |
118 | 154 | else: |
119 | 155 | w = self._wq_b_bf16 |
120 | 156 | # qr [B, T, q_lora_rank] @ w.T [q_lora_rank, out_dim] |
121 | 157 | return qr.astype(mx.bfloat16) @ w.T |
122 | 158 |
|
| 159 | + def _wq_b_fp8_gemm_supported(self, qr: mx.array) -> bool: |
| 160 | + """Explicit dispatch predicate for the ``fused_fp8_gemm`` score GEMM. |
| 161 | +
|
| 162 | + Returns ``True`` when the stored ``wq_b`` weight + ``qr`` activation |
| 163 | + satisfy the ``fused_fp8_gemm`` block-FP8 contract (uint8 2D weight, |
| 164 | + matching contraction dim, correctly-shaped per-128-block ``scale_inv``). |
| 165 | +
|
| 166 | + RULE #1: a malformed FP8 weight (wrong dtype / rank / scale-block shape) |
| 167 | + is a real bug, so this RAISES rather than silently steering to a |
| 168 | + dequant fallback. ``False`` is returned only for the one legitimate |
| 169 | + non-fused case — an empty contraction (``q_lora_rank == 0``) where the |
| 170 | + GEMM is degenerate — which the explicit dequant path then handles. |
| 171 | + """ |
| 172 | + w = self._wq_b_fp8 |
| 173 | + s = self._wq_b_scale_inv |
| 174 | + if w.dtype != mx.uint8: |
| 175 | + raise TypeError( |
| 176 | + f"_wq_b_apply: wq_b fp8 weight must be uint8 e4m3 storage; " |
| 177 | + f"got {w.dtype}" |
| 178 | + ) |
| 179 | + if w.ndim != 2: |
| 180 | + raise ValueError( |
| 181 | + f"_wq_b_apply: wq_b fp8 weight must be 2D [out_dim, q_lora_rank]; " |
| 182 | + f"got shape {w.shape}" |
| 183 | + ) |
| 184 | + out_dim, k = w.shape |
| 185 | + if qr.shape[-1] != k: |
| 186 | + raise ValueError( |
| 187 | + f"_wq_b_apply: qr last dim ({qr.shape[-1]}) must match wq_b " |
| 188 | + f"contraction dim ({k})" |
| 189 | + ) |
| 190 | + blocks_m = (out_dim + _FP8_BLOCK - 1) // _FP8_BLOCK |
| 191 | + blocks_k = (k + _FP8_BLOCK - 1) // _FP8_BLOCK |
| 192 | + if tuple(s.shape) != (blocks_m, blocks_k): |
| 193 | + raise ValueError( |
| 194 | + f"_wq_b_apply: wq_b scale_inv shape {tuple(s.shape)} != expected " |
| 195 | + f"({blocks_m}, {blocks_k}) for W={w.shape} block={_FP8_BLOCK}" |
| 196 | + ) |
| 197 | + if k == 0: |
| 198 | + # Degenerate empty contraction: fused_fp8_gemm has nothing to do; |
| 199 | + # the explicit dequant+matmul path returns the correct zeros. |
| 200 | + return False |
| 201 | + return True |
| 202 | + |
123 | 203 | def load_fp8_weights( |
124 | 204 | self, |
125 | 205 | wq_b_fp8: mx.array, |
|
0 commit comments