File size: 32,479 Bytes
6154ddb
 
 
 
 
8e6dc72
6154ddb
 
 
 
 
8e6dc72
 
 
6154ddb
 
a9bf912
 
 
6154ddb
 
 
 
 
8e6dc72
6154ddb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8e6dc72
6154ddb
 
8e6dc72
6154ddb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8e6dc72
 
 
 
6154ddb
 
 
 
 
 
 
 
 
 
 
 
 
 
d3c3658
 
 
 
 
6154ddb
 
 
8e6dc72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6154ddb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8e6dc72
 
6154ddb
 
 
 
 
 
 
 
 
 
 
8e6dc72
6154ddb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8e6dc72
 
6154ddb
 
 
 
 
 
 
 
 
 
 
8e6dc72
6154ddb
 
 
 
 
8e6dc72
 
6154ddb
 
 
 
 
 
 
 
 
 
 
a9bf912
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6154ddb
 
 
 
 
 
 
 
 
8e6dc72
 
 
 
 
 
 
 
 
 
 
6154ddb
 
 
 
 
 
 
 
8e6dc72
 
 
 
 
 
 
6154ddb
 
 
 
 
 
 
 
 
 
 
8e6dc72
 
a9bf912
 
 
 
6154ddb
 
 
 
 
d3c3658
8e6dc72
 
 
 
 
d3c3658
 
 
 
 
 
 
 
 
 
 
a9bf912
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
# Copyright 2026 Jakevin
# SPDX-License-Identifier: Apache-2.0
"""Ternary GEMV and GEMM for MLX.

Codes {-1,0,1} are stored as 2-bit values {0,1,2}, four per byte, low bits
first. One scale per 64 weights (or 128, as an option) and no zero point: value = (code - 1) * scale.
The inner loop is the MLX 2-bit mask dot: pre-scale x by 1, 1/4, 1/16, 1/64
and AND the byte.

Small batches use a simdgroup GEMV. K multiple of 512 follows the MLX
qmv_fast walk (two rows per simdgroup, 16 weights per lane). Other K,
including 640, uses a 4-wide dot. Batch >= 32 with the row count and K
divisible by 32 uses a 32x32x32 simdgroup-matrix GEMM; if the batch is not a
multiple of 32, the leftover rows go through the GEMV. The weight
tile is half, the accumulator is float, and a tile never crosses a scale group.

Batches up to 8 run through a small LRU of compiled graphs keyed by shape.
Weights are passed as arguments, so the cache holds no weight arrays.
Larger batches dispatch directly.
"""
from __future__ import annotations

import mlx.core as mx

from .pack import GROUP, GROUPS


def set_cache_limit(gb: float = 2) -> int:
    """Opt-in: cap the MLX cache at ``gb`` gigabytes. Returns the previous limit.

    Importing the package does not change the MLX cache limit.
    """
    return mx.set_cache_limit(int(gb * 1024**3))


def configure() -> int:
    """Cap the MLX cache at 2GB. Same as ``set_cache_limit(2)``."""
    return set_cache_limit(2)

_KERNELS: dict = {}
_META: dict = {}

QMV_ROWS = 2
QMV_NSG = 1
GEMM_BM = 32
GEMM_BN = 32
GEMM_BK = 32
GEMM_MIN_BATCH = 32

_HEADER = r"""
#include <metal_simdgroup>
#include <metal_simdgroup_matrix>
#include <metal_stdlib>
using namespace metal;

// code*x for four 2-bit lanes packed in one byte. x1/x2/x3 are already
// divided by 4, 16 and 64 so the masked field does not need a shift.
#define TERN_DOT4(b, x0, x1, x2, x3) \
    (((x0) * float((b) & 3u) + (x1) * float((b) & 0x0cu)) \
     + ((x2) * float((b) & 0x30u) + (x3) * float((b) & 0xc0u)))
#define TERN_DOT4H(b, x0, x1, x2, x3) \
    (((x0) * half((b) & 3u) + (x1) * half((b) & 0x0cu)) \
     + ((x2) * half((b) & 0x30u) + (x3) * half((b) & 0xc0u)))
"""


def metal(name: str, source: str, header: str = _HEADER):
    # MLX 0.22 metal_kernel has no compile_options. The cache key does not
    # include the source, so a body change must use a new name.
    key = (name, header[:40])
    if key not in _KERNELS:
        _KERNELS[key] = mx.fast.metal_kernel(
            name=name,
            input_names=["x", "codes", "scales", "meta"],
            output_names=["y"],
            header=header,
            source=source,
            ensure_row_contiguous=False,
        )
    return _KERNELS[key]


def meta_u32(values: list[int]) -> mx.array:
    key = tuple(int(v) for v in values)
    buf = _META.get(key)
    if buf is None:
        buf = mx.array(list(key), dtype=mx.uint32)
        mx.eval(buf)
        _META[key] = buf
    return buf


def _prepare(x, codes, scales, k, group):
    if group not in GROUPS:
        raise ValueError(f"group {group} not in {GROUPS}")
    if k % group != 0:
        raise ValueError(f"K {k} is not a multiple of {group}")
    if x.ndim == 1:
        x = x.reshape(1, -1)
    if not isinstance(x, mx.array):
        x = mx.array(x)
    if x.dtype != mx.float32:
        x = x.astype(mx.float32)
    if not isinstance(codes, mx.array):
        codes = mx.array(codes)
    if codes.dtype != mx.uint8:
        codes = codes.astype(mx.uint8)
    if not isinstance(scales, mx.array):
        scales = mx.array(scales)
    if int(x.shape[-1]) != k:
        raise ValueError(f"x last dim {x.shape[-1]} != K {k}")
    # The kernels index raw row-major buffers (ensure_row_contiguous=False),
    # so transposed or sliced views must be copied first. No-op if contiguous.
    x = mx.contiguous(x)
    codes = mx.contiguous(codes)
    scales = mx.contiguous(scales)
    return x, codes, scales, int(x.shape[0]), int(codes.shape[0])


# Kernel sources are written for group 64. Group 128 only changes how a
# weight index maps to its scale: k >> 7 instead of k >> 6, and one more
# lane bit per scale. Group 64 sources are left byte-identical.
_REGROUP_128 = (
    ("k >> 6", "k >> 7"),
    ("t >> 6", "t >> 7"),
    ("(lane >> 2)", "(lane >> 3)"),
    ("(lane >> 4)", "(lane >> 5)"),
    ("((k0 + wc) >> 6)", "((k0 + wc) >> 7)"),
)


def _regroup(source: str, group: int) -> str:
    if group == 64:
        return source
    if group != 128:
        raise ValueError(f"group {group} not in {GROUPS}")
    for old, new in _REGROUP_128:
        source = source.replace(old, new)
    if ">> 6" in source:
        raise AssertionError("group 128 source still indexes scales by 64")
    return source


def _gname(group: int) -> str:
    return "" if group == 64 else f"_gs{group}"


def _dot16_expr(w: str, vecdot: bool = False) -> str:
    if not vecdot:
        return (
            f"TERN_DOT4({w} & 0xffu, xv0, xv1, xv2, xv3)"
            f" + TERN_DOT4(({w} >> 8u) & 0xffu, xv4, xv5, xv6, xv7)"
            f" + TERN_DOT4(({w} >> 16u) & 0xffu, xv8, xv9, xv10, xv11)"
            f" + TERN_DOT4(({w} >> 24u) & 0xffu, xv12, xv13, xv14, xv15)"
        )
    # Packed dot so the four masked lanes stay in one float4.
    def one(byte: str, xs: str) -> str:
        return (
            f"dot(float4(float({byte} & 3u), float({byte} & 0x0cu), "
            f"float({byte} & 0x30u), float({byte} & 0xc0u)), float4({xs}))"
        )
    return (
        f"{one(f'({w} & 0xffu)', 'xv0, xv1, xv2, xv3')}"
        f" + {one(f'(({w} >> 8u) & 0xffu)', 'xv4, xv5, xv6, xv7')}"
        f" + {one(f'(({w} >> 16u) & 0xffu)', 'xv8, xv9, xv10, xv11')}"
        f" + {one(f'(({w} >> 24u) & 0xffu)', 'xv12, xv13, xv14, xv15')}"
    )


def _qmv_source(nsg: int, rows: int, guarded: bool, blocks: int | None = None, vecdot: bool = False, narrow: bool = False, half_dot: bool = False, chunk128: bool = False) -> str:
    if rows not in (2, 4, 8) or nsg not in (1, 2, 4):
        raise ValueError(f"qmv rows={rows} nsg={nsg}")
    if blocks is not None and blocks < 1:
        raise ValueError(f"qmv blocks={blocks}")
    acc_init = "\n".join(f"    float acc{r} = 0.0f;" for r in range(rows))

    def w16_loads() -> str:
        lines = []
        for r in range(rows):
            addr = (
                f"*reinterpret_cast<const device uint*>(codes + (row0 + {r}u) * row_bytes + byte0 + (lane << 2))"
            )
            if guarded:
                lines.append(f"uint w{r} = 0u; if (row0 + {r}u < N) w{r} = {addr};")
            else:
                lines.append(f"uint w{r} = {addr};")
        return "\n        ".join(lines)

    def w4_loads() -> str:
        lines = []
        for r in range(rows):
            addr = f"uint(codes[(row0 + {r}u) * row_bytes + byte0 + lane])"
            if guarded:
                lines.append(f"uint c{r} = 0u; if (row0 + {r}u < N) c{r} = {addr};")
            else:
                lines.append(f"uint c{r} = {addr};")
        return "\n        ".join(lines)

    def apply(scale_shift: int, src: str) -> str:
        lines = []
        for r in range(rows):
            fma = (
                f"acc{r} = fma(float(scales[(row0 + {r}u) * n_groups + g0 + (lane >> {scale_shift})]), "
                f"d{r} - sumx, acc{r});"
            )
            if guarded:
                lines.append(f"if (row0 + {r}u < N) {{ {fma} }}")
            else:
                lines.append(fma)
        return "\n        ".join(lines)

    def tail_body() -> str:
        lines = [
            "uint sh = (t & 3u) * 2u;",
            "uint byte = t >> 2;",
            "uint g = t >> 6;",
        ]
        for r in range(rows):
            body = (
                f"uint code = (uint(codes[(row0 + {r}u) * row_bytes + byte]) >> sh) & 3u;"
                f"acc{r} = fma((float(code) - 1.0f) * float(scales[(row0 + {r}u) * n_groups + g]), xv, acc{r});"
            )
            if guarded:
                lines.append(f"if (row0 + {r}u < N) {{ {body} }}")
            else:
                lines.append("{ " + body + " }")
        return "\n        ".join(lines)

    red = "\n    ".join(f"float red{r} = simd_sum(acc{r});" for r in range(rows))
    writes = []
    for r in range(rows):
        cond = f"lane == 0u && row0 + {r}u < N" if guarded else "lane == 0u"
        writes.append(f"if ({cond}) y[b * N + row0 + {r}u] = red{r};")
    store = red + "\n    " + "\n    ".join(writes)
    dots16 = "\n        ".join(
        f"float d{r} = {_dot16_expr(f'w{r}', vecdot)};" for r in range(rows)
    )
    dots4 = "\n        ".join(
        f"float d{r} = TERN_DOT4(c{r}, xv0, xv1, xv2, xv3);" for r in range(rows)
    )
    step512 = f"""        const device float* xp = x + b * K + k + (lane << 4);
        float4 a0 = *reinterpret_cast<const device float4*>(xp);
        float4 a1 = *reinterpret_cast<const device float4*>(xp + 4);
        float4 a2 = *reinterpret_cast<const device float4*>(xp + 8);
        float4 a3 = *reinterpret_cast<const device float4*>(xp + 12);
        float sumx = ((a0.x + a0.y) + (a0.z + a0.w)) + ((a1.x + a1.y) + (a1.z + a1.w));
        sumx += ((a2.x + a2.y) + (a2.z + a2.w)) + ((a3.x + a3.y) + (a3.z + a3.w));
        float xv0 = a0.x; float xv1 = a0.y * 0.25f; float xv2 = a0.z * 0.0625f; float xv3 = a0.w * 0.015625f;
        float xv4 = a1.x; float xv5 = a1.y * 0.25f; float xv6 = a1.z * 0.0625f; float xv7 = a1.w * 0.015625f;
        float xv8 = a2.x; float xv9 = a2.y * 0.25f; float xv10 = a2.z * 0.0625f; float xv11 = a2.w * 0.015625f;
        float xv12 = a3.x; float xv13 = a3.y * 0.25f; float xv14 = a3.z * 0.0625f; float xv15 = a3.w * 0.015625f;
        uint byte0 = k >> 2;
        uint g0 = k >> 6;
        {w16_loads()}
        {dots16}
        {apply(2, "w")}"""
    if narrow:
        # One float4 of x is live at a time so the large-N grid can stay resident.
        shifts = (0, 8, 16, 24)
        nlines = [
            "const device float* xp = x + b * K + k + (lane << 4);",
            "uint byte0 = k >> 2;",
            "uint g0 = k >> 6;",
            w16_loads(),
        ]
        for r in range(rows):
            nlines.append(f"float part{r} = 0.0f;")
        for bi, sh in enumerate(shifts):
            nlines.append("{")
            nlines.append(
                f"float4 a = *reinterpret_cast<const device float4*>(xp + {bi * 4});"
            )
            nlines.append("float sum4 = (a.x + a.y) + (a.z + a.w);")
            nlines.append(
                "float u0 = a.x; float u1 = a.y * 0.25f; "
                "float u2 = a.z * 0.0625f; float u3 = a.w * 0.015625f;"
            )
            for r in range(rows):
                expr = (
                    f"part{r} += TERN_DOT4((w{r} >> {sh}u) & 0xffu, u0, u1, u2, u3) - sum4;"
                )
                if guarded:
                    nlines.append(f"if (row0 + {r}u < N) {{ {expr} }}")
                else:
                    nlines.append(expr)
            nlines.append("}")
        for r in range(rows):
            fma = (
                f"acc{r} = fma(float(scales[(row0 + {r}u) * n_groups + g0 + (lane >> 2)]), "
                f"part{r}, acc{r});"
            )
            if guarded:
                nlines.append(f"if (row0 + {r}u < N) {{ {fma} }}")
            else:
                nlines.append(fma)
        step512 = "\n        ".join(nlines)
    if half_dot:
        # Half products, float partial. One float4 of x is live per byte.
        shifts = (0, 8, 16, 24)
        hlines = [
            "const device float* xp = x + b * K + k + (lane << 4);",
            "uint byte0 = k >> 2;",
            "uint g0 = k >> 6;",
            w16_loads(),
        ]
        for r in range(rows):
            hlines.append(f"float part{r} = 0.0f;")
        for bi, sh in enumerate(shifts):
            hlines.append("{")
            hlines.append(
                f"float4 a = *reinterpret_cast<const device float4*>(xp + {bi * 4});"
            )
            hlines.append(
                "half hx = half(a.x); half hy = half(a.y); "
                "half hz = half(a.z); half hw = half(a.w);"
            )
            hlines.append("half sum4 = (hx + hy) + (hz + hw);")
            hlines.append(
                "half u0 = hx; half u1 = hy * 0.25h; "
                "half u2 = hz * 0.0625h; half u3 = hw * 0.015625h;"
            )
            for r in range(rows):
                expr = (
                    f"part{r} += float(TERN_DOT4H((w{r} >> {sh}u) & 0xffu, u0, u1, u2, u3)) "
                    f"- float(sum4);"
                )
                if guarded:
                    hlines.append(f"if (row0 + {r}u < N) {{ {expr} }}")
                else:
                    hlines.append(expr)
            hlines.append("}")
        for r in range(rows):
            fma = (
                f"acc{r} = fma(float(scales[(row0 + {r}u) * n_groups + g0 + (lane >> 2)]), "
                f"part{r}, acc{r});"
            )
            if guarded:
                hlines.append(f"if (row0 + {r}u < N) {{ {fma} }}")
            else:
                hlines.append(fma)
        step512 = "\n        ".join(hlines)
    if chunk128:
        # Four values per lane. Fewer live registers than the 16-wide body.
        k_loops = (
            "    uint k = 0u;\n"
            "    for (; k + 128u <= K; k += 128u) {\n"
            "        const device float* xp = x + b * K + k + (lane << 2);\n"
            "        float4 a0 = *reinterpret_cast<const device float4*>(xp);\n"
            "        float sumx = (a0.x + a0.y) + (a0.z + a0.w);\n"
            "        float xv0 = a0.x; float xv1 = a0.y * 0.25f; float xv2 = a0.z * 0.0625f; float xv3 = a0.w * 0.015625f;\n"
            "        uint byte0 = k >> 2;\n"
            "        uint g0 = k >> 6;\n"
            f"        {w4_loads()}\n"
            f"        {dots4}\n"
            f"        {apply(4, 'c')}\n"
            "    }\n"
            "    for (uint t = k + lane; t < K; t += 32u) {\n"
            "        float xv = x[b * K + t];\n"
            f"        {tail_body()}\n"
            "    }\n"
        )
    elif blocks is None:
        # K is not a multiple of 512 (expert down is 640). Keep the short tail.
        k_loops = (
            "    uint k = 0u;\n"
            "    for (; k + 512u <= K; k += 512u) {\n"
            f"{step512}\n"
            "    }\n"
            "    for (; k + 128u <= K; k += 128u) {\n"
            "        const device float* xp = x + b * K + k + (lane << 2);\n"
            "        float4 a0 = *reinterpret_cast<const device float4*>(xp);\n"
            "        float sumx = (a0.x + a0.y) + (a0.z + a0.w);\n"
            "        float xv0 = a0.x; float xv1 = a0.y * 0.25f; float xv2 = a0.z * 0.0625f; float xv3 = a0.w * 0.015625f;\n"
            "        uint byte0 = k >> 2;\n"
            "        uint g0 = k >> 6;\n"
            f"        {w4_loads()}\n"
            f"        {dots4}\n"
            f"        {apply(4, 'c')}\n"
            "    }\n"
            "    for (uint t = k + lane; t < K; t += 32u) {\n"
            "        float xv = x[b * K + t];\n"
            f"        {tail_body()}\n"
            "    }\n"
        )
    else:
        # Bake the trip count and drop the tail so the mask body can unroll.
        k_loops = (
            "    #pragma unroll\n"
            f"    for (uint blk = 0u; blk < {int(blocks)}u; ++blk) {{\n"
            "        uint k = blk * 512u;\n"
            f"{step512}\n"
            "    }\n"
        )
    return f"""
    constexpr uint NSG = {nsg}u;
    constexpr uint ROWS = {rows}u;
    uint lane = thread_index_in_simdgroup;
    uint sg = simdgroup_index_in_threadgroup;
    uint tile = threadgroup_position_in_grid.x;
    uint b = threadgroup_position_in_grid.y;
    uint N = meta[0];
    uint K = meta[1];
    uint row_bytes = meta[2];
    uint n_groups = meta[3];
    uint row0 = (tile * NSG + sg) * ROWS;

    {acc_init}

    {k_loops}
    {store}
"""



def _gemm_source(bm: int, bn: int, bk: int, half_w: bool) -> str:
    if bm % 16 or bn % 16 or bk % 32 or 64 % bk:
        raise ValueError(f"bad tile {bm} {bn} {bk}")
    nsg_n = bn // 16
    nsg_m = bm // 16
    tg = nsg_n * nsg_m * 32
    per_w = (bn * bk) // tg
    per_x = (bm * bk) // tg
    if per_w % 4 or per_x % 4 or bk % per_w or bk % per_x or per_w > bk or per_x > bk:
        raise ValueError(f"tile {bm}x{bn}x{bk} does not split across {tg} threads")
    lda_x = bk + 4
    lda_w = bk + (8 if half_w else 4)
    wtype = "half" if half_w else "float"
    nbytes = per_w // 4
    if nbytes % 4 == 0:
        w_load = f"""
        uint e = tid * {per_w}u;
        uint wr = e / BK;
        uint wc = e - wr * BK;
        uint grow = n0 + wr;
        const device uint* wp = reinterpret_cast<const device uint*>(
            codes + grow * row_bytes + ((k0 + wc) >> 2));
        float s = float(scales[grow * n_groups + ((k0 + wc) >> 6)]);
        float s1 = s * 0.25f;
        float s2 = s * 0.0625f;
        float s3 = s * 0.015625f;
        #pragma unroll
        for (uint j = 0u; j < {nbytes // 4}u; ++j) {{
            uint w32 = wp[j];
            #pragma unroll
            for (uint i = 0u; i < 4u; ++i) {{
                uint bcode = (w32 >> (i * 8u)) & 0xffu;
                float d0 = s * float(bcode & 3u) - s;
                float d1 = s1 * float(bcode & 0x0cu) - s;
                float d2 = s2 * float(bcode & 0x30u) - s;
                float d3 = s3 * float(bcode & 0xc0u) - s;
                uint kc = wc + (j * 4u + i) * 4u;
                *reinterpret_cast<threadgroup {wtype}4*>(&WBUF[wr][kc]) = {wtype}4({wtype}(d0), {wtype}(d1), {wtype}(d2), {wtype}(d3));
            }}
        }}
"""
    else:
        w_load = f"""
        uint e = tid * {per_w}u;
        uint wr = e / BK;
        uint wc = e - wr * BK;
        uint grow = n0 + wr;
        const device uint8_t* wp = codes + grow * row_bytes + ((k0 + wc) >> 2);
        float s = float(scales[grow * n_groups + ((k0 + wc) >> 6)]);
        float s1 = s * 0.25f;
        float s2 = s * 0.0625f;
        float s3 = s * 0.015625f;
        #pragma unroll
        for (uint i = 0u; i < {nbytes}u; ++i) {{
            uint bcode = uint(wp[i]);
            float d0 = s * float(bcode & 3u) - s;
            float d1 = s1 * float(bcode & 0x0cu) - s;
            float d2 = s2 * float(bcode & 0x30u) - s;
            float d3 = s3 * float(bcode & 0xc0u) - s;
            uint kc = wc + i * 4u;
            *reinterpret_cast<threadgroup {wtype}4*>(&WBUF[wr][kc]) = {wtype}4({wtype}(d0), {wtype}(d1), {wtype}(d2), {wtype}(d3));
        }}
"""
    n4 = per_x // 4
    x_load = f"""
        uint ex = tid * {per_x}u;
        uint xr = ex / BK;
        uint xc = ex - xr * BK;
        const device float* xp = x + (b0 + xr) * K + (k0 + xc);
        #pragma unroll
        for (uint i = 0u; i < {n4}u; ++i) {{
            float4 v = *reinterpret_cast<const device float4*>(xp + i * 4u);
            *reinterpret_cast<threadgroup float4*>(&XBUF[xr][xc + i * 4u]) = v;
        }}
"""
    return f"""
    constexpr uint BM = {bm}u;
    constexpr uint BN = {bn}u;
    constexpr uint BK = {bk}u;
    constexpr uint LDAX = {lda_x}u;
    constexpr uint LDAW = {lda_w}u;

    uint tid = thread_position_in_threadgroup.x;
    uint sg = simdgroup_index_in_threadgroup;
    uint N = meta[0];
    uint K = meta[1];
    uint row_bytes = meta[2];
    uint n_groups = meta[3];
    uint B = meta[4];
    uint n0 = threadgroup_position_in_grid.x * BN;
    uint b0 = threadgroup_position_in_grid.y * BM;

    threadgroup float XBUF[BM][LDAX];
    threadgroup {wtype} WBUF[BN][LDAW];

    uint sg_n = sg % {nsg_n}u;
    uint sg_m = sg / {nsg_n}u;
    uint mbase = sg_m * 16u;
    uint nbase = sg_n * 16u;
    ushort lane = thread_index_in_simdgroup;
    short qid = short(lane) >> 2;
    short fm = (qid & 4) + ((short(lane) >> 1) & 3);
    short fn = ((qid & 2) << 1) + ((short(lane) & 1) << 1);
    uint ufm = uint(fm);
    uint ufn = uint(fn);

    simdgroup_matrix<float, 8, 8> c00(0.0f);
    simdgroup_matrix<float, 8, 8> c01(0.0f);
    simdgroup_matrix<float, 8, 8> c10(0.0f);
    simdgroup_matrix<float, 8, 8> c11(0.0f);

    for (uint k0 = 0u; k0 < K; k0 += BK) {{
        {x_load}
        {w_load}
        threadgroup_barrier(mem_flags::mem_threadgroup);
        #pragma unroll
        for (uint kk = 0u; kk < BK; kk += 8u) {{
            simdgroup_matrix<float, 8, 8> a0(0.0f);
            simdgroup_matrix<float, 8, 8> a1(0.0f);
            simdgroup_matrix<float, 8, 8> b0m(0.0f);
            simdgroup_matrix<float, 8, 8> b1m(0.0f);
            float2 av0 = *reinterpret_cast<threadgroup float2*>(&XBUF[mbase + ufm][kk + ufn]);
            float2 av1 = *reinterpret_cast<threadgroup float2*>(&XBUF[mbase + 8u + ufm][kk + ufn]);
            a0.thread_elements()[0] = av0.x;
            a0.thread_elements()[1] = av0.y;
            a1.thread_elements()[0] = av1.x;
            a1.thread_elements()[1] = av1.y;
            b0m.thread_elements()[0] = float(WBUF[nbase + ufn][kk + ufm]);
            b0m.thread_elements()[1] = float(WBUF[nbase + ufn + 1u][kk + ufm]);
            b1m.thread_elements()[0] = float(WBUF[nbase + 8u + ufn][kk + ufm]);
            b1m.thread_elements()[1] = float(WBUF[nbase + 8u + ufn + 1u][kk + ufm]);
            simdgroup_multiply_accumulate(c00, a0, b0m, c00);
            simdgroup_multiply_accumulate(c01, a0, b1m, c01);
            simdgroup_multiply_accumulate(c10, a1, b0m, c10);
            simdgroup_multiply_accumulate(c11, a1, b1m, c11);
        }}
        threadgroup_barrier(mem_flags::mem_threadgroup);
    }}

    uint gm = b0 + mbase + ufm;
    uint gn = n0 + nbase + ufn;
    if (gm < B) {{
        if (gn < N) y[gm * N + gn] = c00.thread_elements()[0];
        if (gn + 1u < N) y[gm * N + gn + 1u] = c00.thread_elements()[1];
        if (gn + 8u < N) y[gm * N + gn + 8u] = c01.thread_elements()[0];
        if (gn + 9u < N) y[gm * N + gn + 9u] = c01.thread_elements()[1];
    }}
    uint gm8 = gm + 8u;
    if (gm8 < B) {{
        if (gn < N) y[gm8 * N + gn] = c10.thread_elements()[0];
        if (gn + 1u < N) y[gm8 * N + gn + 1u] = c10.thread_elements()[1];
        if (gn + 8u < N) y[gm8 * N + gn + 8u] = c11.thread_elements()[0];
        if (gn + 9u < N) y[gm8 * N + gn + 9u] = c11.thread_elements()[1];
    }}
"""


def _qmv_mlx_source(nsg: int, rows: int, guarded: bool) -> str:
    """MLX qmv_fast layout: 16 values per lane, one K loop, byte loads."""
    if rows not in (2, 4, 8) or nsg not in (1, 2, 4):
        raise ValueError(f"qmv mlx rows={rows} nsg={nsg}")
    init = "\n    ".join(f"float result{r} = 0.0f;" for r in range(rows))
    dots = []
    for r in range(rows):
        body = (
            f"const device uint8_t* wl = codes + (out_row + {r}u) * row_bytes + (lane << 2) + (k >> 2);"
            f"float s = float(scales[(out_row + {r}u) * n_groups + (k >> 6) + (lane >> 2)]);"
            "float accum = 0.0f;"
            "accum += xt0 * float(wl[0] & 3u) + xt1 * float(wl[0] & 0x0cu);"
            "accum += xt2 * float(wl[0] & 0x30u) + xt3 * float(wl[0] & 0xc0u);"
            "accum += xt4 * float(wl[1] & 3u) + xt5 * float(wl[1] & 0x0cu);"
            "accum += xt6 * float(wl[1] & 0x30u) + xt7 * float(wl[1] & 0xc0u);"
            "accum += xt8 * float(wl[2] & 3u) + xt9 * float(wl[2] & 0x0cu);"
            "accum += xt10 * float(wl[2] & 0x30u) + xt11 * float(wl[2] & 0xc0u);"
            "accum += xt12 * float(wl[3] & 3u) + xt13 * float(wl[3] & 0x0cu);"
            "accum += xt14 * float(wl[3] & 0x30u) + xt15 * float(wl[3] & 0xc0u);"
            f"result{r} = fma(s, accum - sum, result{r});"
        )
        if guarded:
            dots.append(f"if (out_row + {r}u < N) {{ {body} }}")
        else:
            dots.append("{ " + body + " }")
    row_gate = "\n      ".join(dots)
    if guarded:
        store = "\n    ".join(
            f"float red{r} = simd_sum(result{r}); if (lane == 0u && out_row + {r}u < N) y[b * N + out_row + {r}u] = red{r};"
            for r in range(rows)
        )
    else:
        store = "\n    ".join(
            f"float red{r} = simd_sum(result{r}); if (lane == 0u) y[b * N + out_row + {r}u] = red{r};"
            for r in range(rows)
        )
    return f"""
    uint lane = thread_index_in_simdgroup;
    uint sg = simdgroup_index_in_threadgroup;
    uint tile = threadgroup_position_in_grid.x;
    uint b = threadgroup_position_in_grid.y;
    uint N = meta[0];
    uint K = meta[1];
    uint row_bytes = meta[2];
    uint n_groups = meta[3];
    uint out_row = (tile * {nsg}u + sg) * {rows}u;
    {init}
    for (uint k = 0u; k < K; k += 512u) {{
      const device float* xp = x + b * K + k + (lane << 4);
      float4 a0 = *reinterpret_cast<const device float4*>(xp);
      float4 a1 = *reinterpret_cast<const device float4*>(xp + 4);
      float4 a2 = *reinterpret_cast<const device float4*>(xp + 8);
      float4 a3 = *reinterpret_cast<const device float4*>(xp + 12);
      float sum = ((a0.x + a0.y) + (a0.z + a0.w)) + ((a1.x + a1.y) + (a1.z + a1.w));
      sum += ((a2.x + a2.y) + (a2.z + a2.w)) + ((a3.x + a3.y) + (a3.z + a3.w));
      float xt0 = a0.x; float xt1 = a0.y * 0.25f; float xt2 = a0.z * 0.0625f; float xt3 = a0.w * 0.015625f;
      float xt4 = a1.x; float xt5 = a1.y * 0.25f; float xt6 = a1.z * 0.0625f; float xt7 = a1.w * 0.015625f;
      float xt8 = a2.x; float xt9 = a2.y * 0.25f; float xt10 = a2.z * 0.0625f; float xt11 = a2.w * 0.015625f;
      float xt12 = a3.x; float xt13 = a3.y * 0.25f; float xt14 = a3.z * 0.0625f; float xt15 = a3.w * 0.015625f;
      {row_gate}
    }}
    {store}
"""


def run_qmv_mlx(x, codes, scales, k, rows: int = 4, nsg: int = 2, group: int = GROUP):
    x, codes, scales, b, n = _prepare(x, codes, scales, k, group)
    if k % 512:
        raise ValueError(f"qmv mlx needs K multiple of 512, got {k}")
    rows_per_tg = nsg * rows
    guarded = (n % rows_per_tg) != 0
    tg = nsg * 32
    n_tiles = (n + rows_per_tg - 1) // rows_per_tg
    name = f"mlxtern3_qmvmlx_r{rows}_s{nsg}" + ("_g" if guarded else "_a") + _gname(group)
    ker = metal(name, _regroup(_qmv_mlx_source(nsg, rows, guarded), group))
    (y,) = ker(
        inputs=[x, codes, scales, meta_u32([n, k, k // 4, k // group, b])],
        template=[("T", mx.float32)],
        grid=(n_tiles * tg, b, 1),
        threadgroup=(tg, 1, 1),
        output_shapes=[(b, n)],
        output_dtypes=[mx.float32],
    )
    return y


def run_qmv(x, codes, scales, k, rows: int = QMV_ROWS, nsg: int = QMV_NSG, group: int = GROUP, vecdot: bool = False, specialize: bool = True, narrow: bool = False, half_dot: bool = False, chunk128: bool = False):
    x, codes, scales, b, n = _prepare(x, codes, scales, k, group)
    if rows not in (2, 4, 8) or nsg not in (1, 2, 4):
        raise ValueError(f"qmv rows={rows} nsg={nsg}")
    rows_per_tg = nsg * rows
    guarded = (n % rows_per_tg) != 0
    tg = nsg * 32
    n_tiles = (n + rows_per_tg - 1) // rows_per_tg
    blocks = None if chunk128 else ((k // 512) if (specialize and k % 512 == 0) else None)
    name = f"mlxtern3_u2q2_r{rows}_s{nsg}"
    if chunk128:
        name += "_c128"
    elif blocks is not None:
        name += f"_k{k}"
    if vecdot:
        name += "_v"
    if narrow:
        name += "_n"
    if half_dot:
        name += "_h"
    name += "_g" if guarded else "_a"
    name += "_fast" + _gname(group)
    ker = metal(name, _regroup(_qmv_source(nsg, rows, guarded, blocks, vecdot, narrow, half_dot, chunk128), group))
    (y,) = ker(
        inputs=[x, codes, scales, meta_u32([n, k, k // 4, k // group, b])],
        template=[("T", mx.float32)],
        grid=(n_tiles * tg, b, 1),
        threadgroup=(tg, 1, 1),
        output_shapes=[(b, n)],
        output_dtypes=[mx.float32],
    )
    return y


def run_gemm(x, codes, scales, k, bm: int = GEMM_BM, bn: int = GEMM_BN, bk: int = GEMM_BK, group: int = GROUP, half_w: bool = True):
    x, codes, scales, b, n = _prepare(x, codes, scales, k, group)
    if k % bk or n % bn or b % bm:
        raise ValueError(f"gemm tile {bm}x{bn}x{bk} does not cover N={n} K={k} B={b}")
    nsg = (bm // 16) * (bn // 16)
    tg = nsg * 32
    name = f"mlxtern3_u2g2_{bm}x{bn}x{bk}" + ("" if half_w else "_fw") + "_fast" + _gname(group)
    ker = metal(name, _regroup(_gemm_source(bm, bn, bk, half_w), group))
    (y,) = ker(
        inputs=[x, codes, scales, meta_u32([n, k, k // 4, k // group, b])],
        template=[("T", mx.float32)],
        grid=((n // bn) * tg, b // bm, 1),
        threadgroup=(tg, 1, 1),
        output_shapes=[(b, n)],
        output_dtypes=[mx.float32],
    )
    return y


from collections import OrderedDict

COMPILE_MAX_BATCH = 8
COMPILE_CACHE_SIZE = 64
_COMPILED: OrderedDict = OrderedDict()


def _compiled(k: int, n: int, b: int, group: int, x_dtype, c_dtype, s_dtype):
    key = (k, n, b, group, x_dtype, c_dtype, s_dtype)
    fn = _COMPILED.get(key)
    if fn is not None:
        _COMPILED.move_to_end(key)
        return fn

    def _run(z, c, s):
        return _dispatch(z, c, s, k, group)

    fn = mx.compile(_run)
    _COMPILED[key] = fn
    while len(_COMPILED) > COMPILE_CACHE_SIZE:
        _COMPILED.popitem(last=False)
    return fn


def _dispatch(x, codes, scales, k: int, group: int):
    b = 1 if x.ndim == 1 else int(x.shape[0])
    n = int(codes.shape[0])
    tile = _gemm_launch(b, n, k)
    if tile is not None:
        bm, bn, bk = tile
        return run_gemm(x, codes, scales, k, bm, bn, bk, group=group)
    head = _gemm_head(b, n, k)
    if head:
        # Batch >= 32 but not a multiple of 32: the leading multiple of 32
        # rows go through the GEMM, the remainder through the GEMV.
        top = run_gemm(x[:head], codes, scales, k, GEMM_BM, GEMM_BN, GEMM_BK, group=group)
        rest = _gemv(x[head:], codes, scales, k, group)
        return mx.concatenate([top, rest], axis=0)
    return _gemv(x, codes, scales, k, group)


def _gemv(x, codes, scales, k: int, group: int):
    # K multiple of 512 uses the MLX qmv_fast walk (2 rows, 1 simdgroup).
    # Shorter K, including the 640-wide expert down projection, keeps the
    # 128-wide body, which is faster there.
    if k % 512 == 0:
        return run_qmv_mlx(x, codes, scales, k, rows=2, nsg=1, group=group)
    return run_qmv(x, codes, scales, k, group=group)


def _gemm_head(b: int, n: int, k: int) -> int:
    """Rows of a ragged batch that the GEMM can take; 0 if none."""
    if b < GEMM_MIN_BATCH or n % 32 or k % 32:
        return 0
    return (b // 32) * 32


def _gemm_launch(b: int, n: int, k: int) -> tuple[int, int, int] | None:
    if b < GEMM_MIN_BATCH or b % 32 or n % 32 or k % 32:
        return None
    # 32x32x32 keeps the shared tile small enough to stay resident on the M4 Pro.
    # Larger tiles were slower on the expert and are re-checked on the wide d3 shapes.
    return 32, 32, 32


def tern_matmul(x, codes, scales, k: int, group: int = GROUP) -> mx.array:
    """x [B, K] or [K], codes uint8 [N, K/4], scales [N, K/group] -> [B, N].

    ``group`` is 64 (default) or 128 weights per scale.

    Batches up to ``COMPILE_MAX_BATCH`` reuse a compiled graph from a bounded
    LRU keyed by shape and dtype; weights are arguments, not captured, so the
    cache never keeps them alive. Larger batches dispatch the kernel directly.
    A vector input comes back as shape [1, N].
    """
    if not isinstance(x, mx.array):
        x = mx.array(x)
    if x.ndim == 0 or x.ndim > 2:
        raise ValueError(f"x must have shape [K] or [B, K], got ndim {x.ndim}")
    k = int(k)
    group = int(group)
    if group not in GROUPS:
        raise ValueError(f"group {group} not in {GROUPS}")
    if k <= 0 or k % group:
        raise ValueError(f"K {k} is not a positive multiple of {group}")
    if int(x.shape[-1]) != k:
        raise ValueError(f"x last dim {x.shape[-1]} != K {k}")
    if codes.ndim != 2 or int(codes.shape[1]) != k // 4:
        raise ValueError(f"codes shape {tuple(codes.shape)} != [N, K/4] with K={k}")
    n = int(codes.shape[0])
    if tuple(scales.shape) != (n, k // group):
        raise ValueError(f"scales shape {tuple(scales.shape)} != ({n}, {k // group})")
    b = 1 if x.ndim == 1 else int(x.shape[0])
    if b == 0 or n == 0:
        # An empty launch would hand Metal a zero-size buffer.
        return mx.zeros((b, n), dtype=mx.float32)
    if b > COMPILE_MAX_BATCH:
        return _dispatch(x, codes, scales, k, int(group))
    fn = _compiled(k, n, b, int(group), x.dtype, codes.dtype, scales.dtype)
    return fn(x, codes, scales)