lightweight-simd 0.1.0

Lightweight generic fixed-array vector types relying on compiler auto-vectorization
Documentation
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
#!/usr/bin/env python3
"""Assembly-level vectorization checks for lightweight-simd (step 3).

Most or all contents generated by AI (model glm-5.3)

Compiles the probe functions (scripts/asm/probes.rs, built as the helper
package scripts/asm/probes so cargo resolves all dependencies — manual
`rustc --extern` cannot pair this cargo's split rlib/rmeta artifacts) to
assembly for several -C target-cpu rows via `cargo rustc --emit asm`,
extracts each probe body by symbol label, and asserts the expected vector
instruction families are present (with width-aware minimum counts).

Architectures are data-driven: `ARCHES` maps each architecture to its compile
rows and its probe -> row expectation table. x86_64 rows walk the ISA ladder
(baseline SSE2 -> AVX2 -> AVX-512); aarch64 NEON is baseline, so its rows are
`generic`/`native` plus the fp-contract and use_libm_fma variant rows, and its
counts pin the 128-bit v.2d/v.4s shapes (verified on an Apple-Silicon host).
Adding an architecture means adding one ARCHES entry, nothing else.

Expectation patterns are regexes matched case-insensitively against the whole
probe body; they deliberately cover instruction *families* (any VEX/EVEX
prefix, ps- vs pd-domain bitwise forms, blend vs k-mask selection) rather
than exact opcodes, so ordinary codegen-shape drift stays green while genuine
vectorization loss goes red.

Usage:
  python3 scripts/check_asm.py                      # host arch, full matrix
  python3 scripts/check_asm.py --arch aarch64       # explicit arch
  python3 scripts/check_asm.py --dump PROBE [ROW] [ARCH]
  python3 scripts/check_asm.py --hist [ROW] [ARCH]  # authoring aid
Exit:  0 all REQUIRED checks pass (or info mode), 1 any REQUIRED miss,
       2 harness error.
"""

import json
import os
import re
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
ASM_DIR = ROOT / "target" / "asm"
PROBES_MANIFEST = ROOT / "scripts" / "asm" / "probes" / "Cargo.toml"


def req(need=None, need_any=None, forbid=None):
    """REQUIRED cell: failure turns the run red."""
    return {"tier": "req", "need": need or {}, "need_any": need_any or [],
            "forbid": forbid or []}


def info(note, need=None):
    """INFORMATIONAL cell: reported, never fails."""
    return {"tier": "info", "note": note, "need": need or {}, "need_any": [],
            "forbid": []}


# --------------------------------------------------------------------------
# x86-64: rows and expectations (the reference table).
# --------------------------------------------------------------------------

# (row name, -C target-cpu, extra RUSTFLAGS, extra cargo args)
X86_ROWS = [
    ("baseline", "x86-64", [], []),
    ("v2", "x86-64-v2", [], []),
    ("v3", "x86-64-v3", [], []),
    ("v4", "x86-64-v4", [], []),
    ("native", "native", [], []),
    # fp-contract variant: separated mul+add expressions fuse to hardware FMA
    ("v3c", "x86-64-v3", ["-C", "llvm-args=-fp-contract=fast"], []),
    # use_libm_fma feature variant: MulAdd lowering (libm at v2, vfmadd at v3)
    ("v2f", "x86-64-v2", [], ["--features", "use_libm_fma"]),
    ("v3f", "x86-64-v3", [], ["--features", "use_libm_fma"]),
]

# Instruction-family regexes. `v?` absorbs the VEX prefix difference between
# SSE-encoded v2 code and VEX/EVEX v3+ code; ps/pd alternatives cover LLVM's
# domain-bypass habit of doing float sign-bit work in the ps domain, and
# integer bitwise work on ps/pd registers at v2/v3.
ANDF = r"v?and(n)?(ps|pd)|vpand"
ORF = r"v?or(ps|pd)|vpor|vpternlog"
XORF = r"v?xor(ps|pd)|vpxor|vpternlog"

# f64x8 / f32x16 / i64x8 / i32x16 fit 2 ymm at v3, 1 zmm at v4, and
# 4/4/2/4 xmm at v2; f64x16 spans double that.
R2 = 2  # xmm-pair count for an 8-lane f64 vector at v3

EXPECT_X86 = {
    "probe_add_f64x8": {
        "baseline": req(forbid=["%ymm", "%zmm"]),
        "v2": req(need={r"v?addpd": 4}),
        "v3": req(need={r"v?addpd": R2}),
        "v4": req(need={r"v?addpd": 1}),
        "native": req(need={r"v?addpd": 1}),
    },
    "probe_mul_f64x8": {
        "v2": req(need={r"v?mulpd": 4}),
        "v3": req(need={r"v?mulpd": R2}),
        "v4": req(need={r"v?mulpd": 1}),
        "native": req(need={r"v?mulpd": 1}),
    },
    "probe_div_f64x8": {
        "v2": req(need={r"v?divpd": 4}),
        "v3": req(need={r"v?divpd": R2}),
        "v4": req(need={r"v?divpd": 1}),
        "native": req(need={r"v?divpd": 1}),
    },
    "probe_neg_f64x8": {
        "v2": req(need={r"v?xor(ps|pd)": 4}),
        "v3": req(need={r"v?xor(ps|pd)": R2}),
        "v4": req(need={r"v?xor(ps|pd)": 1}),
        "native": req(need={r"v?xor(ps|pd)": 1}),
    },
    "probe_min_f64x8": {
        "v2": req(need={r"v?minpd": 4}),
        "v3": req(need={r"v?minpd": R2}),
        "v4": req(need={r"v?minpd": 1}),
        "native": req(need={r"v?minpd": 1}),
    },
    "probe_max_f64x8": {
        "v2": req(need={r"v?maxpd": 4}),
        "v3": req(need={r"v?maxpd": R2}),
        "v4": req(need={r"v?maxpd": 1}),
        "native": req(need={r"v?maxpd": 1}),
    },
    "probe_copysign_f64x8": {
        "v2": req(need={ANDF: 1, ORF: 1}),
        "v3": req(need={ANDF: 1, ORF: 1}),
        "v4": req(need_any=[[ANDF, r"vpternlog"], [ORF, r"vpternlog"]]),
        "native": req(need_any=[[ANDF, r"vpternlog"], [ORF, r"vpternlog"]]),
    },
    "probe_sqrt_f64x8": {
        "v2": req(need={r"v?sqrtpd": 4}),
        "v3": req(need={r"v?sqrtpd": R2}),
        "v4": req(need={r"v?sqrtpd": 1}),
        "native": req(need={r"v?sqrtpd": 1}),
    },
    "probe_abs_f64x8": {
        "v2": req(need={r"v?and(ps|pd)": 4}),
        "v3": req(need={r"v?and(ps|pd)": R2}),
        "v4": req(need={r"v?and(ps|pd)": 1}),
        "native": req(need={r"v?and(ps|pd)": 1}),
    },
    "probe_floor_f64x8": {
        "baseline": info("SSE2 lacks vector rounding: scalar libm calls"),
        "v2": req(need={r"v?roundpd": 4}),
        "v3": req(need={r"v?roundpd": R2}),
        "v4": req(need={r"v?(roundpd|rndscalepd)": 1}),
        "native": req(need={r"v?(roundpd|rndscalepd)": 1}),
    },
    # mul_add default mode: cfg-split per docs/adr/0005 — fused (vfmadd)
    # where the row's target-cpu has FMA (v3+), separated fast mul+add below
    # (v2/baseline); v2f/v3f are the use_libm_fma feature rows.
    "probe_mul_add_f64x8": {
        "baseline": req(forbid=["vfmadd"]),  # negative control
        "v2": req(need={r"v?mulpd": 4, r"v?addpd": 4}),
        "v3": req(need={"vfmadd": R2}),
        "v4": req(need={"vfmadd": 1}),
        "native": req(need={"vfmadd": 1}),
        "v3c": req(need={"vfmadd": R2}),
        "v2f": info("use_libm_fma: scalar libm fma fallback "
                    "(no FMA ISA; ~4.6x slower than separated)", need={"callq": 0}),
        "v3f": req(need={"vfmadd": R2}),
    },
    "probe_fma_from_f64x8": {
        "baseline": req(forbid=["vfmadd"]),
        "v2": req(need={r"v?mulpd": 4, r"v?addpd": 4}),
        "v3": req(need={"vfmadd": R2}),
        "v4": req(need={"vfmadd": 1}),
        "native": req(need={"vfmadd": 1}),
        "v3c": req(need={"vfmadd": R2}),
        "v2f": info("use_libm_fma: scalar libm fma fallback", need={"callq": 0}),
        "v3f": req(need={"vfmadd": R2}),
    },
    "probe_mul_add_f64x16": {
        "baseline": req(forbid=["vfmadd"]),
        "v2": req(need={r"v?mulpd": 8, r"v?addpd": 8}),
        "v3": req(need={"vfmadd": 4}),
        "v4": req(need={"vfmadd": 2}),
        "native": req(need={"vfmadd": 2}),
        "v3c": req(need={"vfmadd": 4}),
        "v2f": info("use_libm_fma: scalar libm fma fallback", need={"callq": 0}),
        "v3f": req(need={"vfmadd": 4}),
    },
    "probe_add_f32x16": {
        "v2": req(need={r"v?addps": 4}),
        "v3": req(need={r"v?addps": R2}),
        "v4": req(need={r"v?addps": 1}),
        "native": req(need={r"v?addps": 1}),
    },
    "probe_mul_add_f32x16": {
        "baseline": req(forbid=["vfmadd"]),
        "v2": req(need={r"v?mulps": 4, r"v?addps": 4}),
        "v3": req(need={"vfmadd": R2}),
        "v4": req(need={"vfmadd": 1}),
        "native": req(need={"vfmadd": 1}),
        "v3c": req(need={"vfmadd": R2}),
        "v2f": info("use_libm_fma: scalar libm fmaf fallback", need={"callq": 0}),
        "v3f": req(need={"vfmadd": R2}),
    },
    "probe_add_i64x8": {
        "v2": req(need={r"v?paddq": 2}),
        "v3": req(need={r"v?paddq": R2}),
        "v4": req(need={r"v?paddq": 1}),
        "native": req(need={r"v?paddq": 1}),
    },
    "probe_bitwise_i32x16": {
        "v2": req(need={ANDF: 1, ORF: 1, XORF: 1}),
        "v3": req(need={ANDF: 1, ORF: 1, XORF: 1}),
        "v4": req(need={ANDF: 1, ORF: 1, XORF: 1}),
        "native": req(need={ANDF: 1, ORF: 1, XORF: 1}),
    },
    "probe_shift_i64x8": {
        "v2": info("no vector i64 arithmetic shift before AVX-512: "
                   "scalarized to GPR shlq/sarq (semantics verified)"),
        "v3": req(need={r"v?psll": 1, r"v?psr(l|a)": 1}),
        "v4": req(need={r"v?psll": 1, r"v?psr(l|a)": 1}),
        "native": req(need={r"v?psll": 1, r"v?psr(l|a)": 1}),
    },
    "probe_cmp_select_f64x8": {
        "v2": req(need={r"v?cmp": 1, r"v?blendv": 1}),
        "v3": req(need={r"v?cmp": 1, r"v?blendv": 1}),
        "v4": req(need={r"v?cmp": 1}, need_any=[[r"\{%k", r"v?blend"]]),
        "native": req(need={r"v?cmp": 1}, need_any=[[r"\{%k", r"v?blend"]]),
    },
    "probe_eq_any_all_f64x8": {
        "v2": req(need={r"v?cmp": 1}),
        "v3": req(need={r"v?cmp": 1}),
        "v4": req(need={r"v?cmp": 1}),
        "native": req(need={r"v?cmp": 1}),
    },
    "probe_slice_io_unaligned": {
        "v2": req(need={r"v?movupd": 1, r"v?mulpd": 4}),
        "v3": req(need={r"v?movupd": 1, r"v?mulpd": R2}),
        "v4": req(need={r"v?movupd": 1, r"v?mulpd": 1}),
        "native": req(need={r"v?movupd": 1, r"v?mulpd": 1}),
    },
    "probe_slice_io_aligned": {
        "v2": req(need={r"v?movapd": 1, r"v?addpd": 4}),
        "v3": req(need={r"v?movapd": 1, r"v?addpd": R2}),
        "v4": req(need={r"v?movapd": 1, r"v?addpd": 1}),
        "native": req(need={r"v?movapd": 1, r"v?addpd": 1}),
    },
    "probe_store_slice_partial": {
        row: info("runtime tail length: no stable canonical form "
                  "(masked move not guaranteed)")
        for row in ("v2", "v3", "v4", "native")
    },
    # INFORMATIONAL probes
    "probe_reduce_sum_f64x8": {
        row: info("strict lane order: v3 = scalar vaddsd chain; wider rows "
                  "fold with vaddpd", need={r"v?add": 0})
        for row in ("v2", "v3", "v4", "native")
    },
    "probe_expr_fma_f64x8": {
        "v3": req(forbid=["vfmadd"]),  # default: expressions must not fuse
        "v3c": req(need={"vfmadd": 1}),  # with the flag: they do
        # the feature only changes the mul_add methods, not plain expressions
        "v3f": req(forbid=["vfmadd"]),
    },
    "probe_f16_add_f16x8": {
        row: info("known-scalar on x86: half soft-float (no f16 arith ISA)")
        for row in ("v2", "v3", "v4", "native")
    },
    "probe_complex_mul_c64x8": {
        row: info("vectorized: vmulpd/vshufpd/vaddsubpd family")
        for row in ("v2", "v3", "v4", "native")
    },
}

# --------------------------------------------------------------------------
# aarch64: rows and expectations.
# --------------------------------------------------------------------------

# NEON (armv8.0) is the aarch64 baseline, so — unlike x86-64's ISA ladder —
# every microarchitecture vectorizes these probes the same 128-bit way and
# there is no scalar-only negative-control row; `generic` covers all named
# -Ctarget-cpu values (neoverse-*, apple-* would be duplicate columns). The
# negative control for fused codegen is instead expr_fma's forbid, and the
# ladder's job is done by two variant rows: genc (fp-contract, expressions
# fuse) and genf (use_libm_fma, must stay hardware fmla — never libm).
AARCH64_ROWS = [
    ("generic", "generic", [], []),
    ("native", "native", [], []),
    # fp-contract variant: separated mul+add expressions fuse to hardware FMA
    ("genc", "generic", ["-C", "llvm-args=-fp-contract=fast"], []),
    # use_libm_fma feature variant: fused is already the aarch64 default
    # (fmla is baseline), so this must not degrade into libm fma calls
    ("genf", "generic", [], ["--features", "use_libm_fma"]),
]

# aarch64 f64x8/f32x16/i64x8 are 4 x v.2d/v.4s lanes at every row (NEON's
# fixed 128-bit vectors); f64x16 spans double that. V4 = that per-op count.
V4 = 4

# Selection/copysign family: LLVM picks among bit-insert forms per lane group.
SELF = r"(bif|bsl|bit)\.16b"
# libm fma call (would appear as bl _fma on Mach-O, bl fma on ELF)
LIBM_FMA = r"\bbl\s+_?fma"

EXPECT_AARCH64 = {
    "probe_add_f64x8": {
        "generic": req(need={r"fadd\.2d": V4}),
        "native": req(need={r"fadd\.2d": V4}),
    },
    "probe_mul_f64x8": {
        "generic": req(need={r"fmul\.2d": V4}),
        "native": req(need={r"fmul\.2d": V4}),
    },
    "probe_div_f64x8": {
        "generic": req(need={r"fdiv\.2d": V4}),
        "native": req(need={r"fdiv\.2d": V4}),
    },
    "probe_neg_f64x8": {
        "generic": req(need={r"fneg\.2d": V4}),
        "native": req(need={r"fneg\.2d": V4}),
    },
    # Rust min/max (NaN-avoiding) lower to fminnm/fmaxnm plus per-lane fixups,
    # so min runs well over the full-lane count; max carries min's fixups too.
    "probe_min_f64x8": {
        "generic": req(need={r"fminnm": V4}),
        "native": req(need={r"fminnm": V4}),
    },
    "probe_max_f64x8": {
        "generic": req(need={r"fmaxnm": V4}),
        "native": req(need={r"fmaxnm": V4}),
    },
    "probe_copysign_f64x8": {
        "generic": req(need={SELF: 1}),
        "native": req(need={SELF: 1}),
    },
    "probe_sqrt_f64x8": {
        "generic": req(need={r"fsqrt\.2d": V4}),
        "native": req(need={r"fsqrt\.2d": V4}),
    },
    "probe_abs_f64x8": {
        "generic": req(need={r"fabs\.2d": V4}),
        "native": req(need={r"fabs\.2d": V4}),
    },
    # vector rounding is baseline NEON (frint*), unlike SSE2's libm calls
    "probe_floor_f64x8": {
        "generic": req(need={r"frint": V4}),
        "native": req(need={r"frint": V4}),
    },
    # mul_add default mode: FMA (fmla) is aarch64 baseline, so the fused path
    # is compiled in at every row (cfg split per docs/adr/0005); genf shows
    # use_libm_fma changes nothing here — hardware fmla, never libm calls.
    "probe_mul_add_f64x8": {
        "generic": req(need={r"fmla\.2d": V4}, forbid=[LIBM_FMA]),
        "native": req(need={r"fmla\.2d": V4}, forbid=[LIBM_FMA]),
        "genc": req(need={r"fmla\.2d": V4}),
        "genf": req(need={r"fmla\.2d": V4}, forbid=[LIBM_FMA]),
    },
    "probe_fma_from_f64x8": {
        "generic": req(need={r"fmla\.2d": V4}, forbid=[LIBM_FMA]),
        "native": req(need={r"fmla\.2d": V4}, forbid=[LIBM_FMA]),
        "genc": req(need={r"fmla\.2d": V4}),
        "genf": req(need={r"fmla\.2d": V4}, forbid=[LIBM_FMA]),
    },
    "probe_mul_add_f64x16": {
        "generic": req(need={r"fmla\.2d": 8}, forbid=[LIBM_FMA]),
        "native": req(need={r"fmla\.2d": 8}, forbid=[LIBM_FMA]),
        "genc": req(need={r"fmla\.2d": 8}),
        "genf": req(need={r"fmla\.2d": 8}, forbid=[LIBM_FMA]),
    },
    "probe_add_f32x16": {
        "generic": req(need={r"fadd\.4s": V4}),
        "native": req(need={r"fadd\.4s": V4}),
    },
    "probe_mul_add_f32x16": {
        "generic": req(need={r"fmla\.4s": V4}, forbid=[LIBM_FMA]),
        "native": req(need={r"fmla\.4s": V4}, forbid=[LIBM_FMA]),
        "genc": req(need={r"fmla\.4s": V4}),
        "genf": req(need={r"fmla\.4s": V4}, forbid=[LIBM_FMA]),
    },
    "probe_add_i64x8": {
        "generic": req(need={r"add\.2d": V4}),
        "native": req(need={r"add\.2d": V4}),
    },
    "probe_bitwise_i32x16": {
        "generic": req(need={r"and\.16b": 1, r"orr\.16b": 1, r"eor\.16b": 1}),
        "native": req(need={r"and\.16b": 1, r"orr\.16b": 1, r"eor\.16b": 1}),
    },
    # vector i64 variable shifts are baseline NEON (ushl for <<, sshl with a
    # negated count for >>) — the x86 v2 row's scalar fallback has no analog
    "probe_shift_i64x8": {
        "generic": req(need={r"[su]shl\.2d": V4}),
        "native": req(need={r"[su]shl\.2d": V4}),
    },
    # comparisons + select currently scalarize to branch-free per-lane
    # fcmp/fcsel (bool-array masks); LLVM does not pick fcmgt+bsl here
    "probe_cmp_select_f64x8": {
        row: info("scalarized: branch-free per-lane fcmp/fcsel "
                  "(no NEON fcmgt+bsl)", need={"fcmp": 0, "fcsel": 0})
        for row in ("generic", "native")
    },
    "probe_eq_any_all_f64x8": {
        row: info("scalarized: branch-free per-lane fcmp/csel",
                  need={"fcmp": 0, "csel": 0})
        for row in ("generic", "native")
    },
    # NEON loads/stores are alignment-agnostic: ldr/ldp q ... on an unaligned
    # &[f64] is the movupd analog, ldp q on the aligned &f64x8 the movapd one
    "probe_slice_io_unaligned": {
        "generic": req(need={r"ld[rp]\s+q": 1, r"fmul\.2d": V4}),
        "native": req(need={r"ld[rp]\s+q": 1, r"fmul\.2d": V4}),
    },
    "probe_slice_io_aligned": {
        "generic": req(need={r"ld[rp]\s+q": 1, r"fadd\.2d": V4}),
        "native": req(need={r"ld[rp]\s+q": 1, r"fadd\.2d": V4}),
    },
    "probe_store_slice_partial": {
        row: info("runtime tail length: no stable canonical form "
                  "(loop-shaped stores not guaranteed)")
        for row in ("generic", "native")
    },
    # INFORMATIONAL probes
    "probe_reduce_sum_f64x8": {
        row: info("strict lane order: scalar fadd d chain",
                  need={r"fadd\sd": 0})
        for row in ("generic", "native")
    },
    "probe_expr_fma_f64x8": {
        "generic": req(forbid=["fmla"]),  # default: expressions must not fuse
        "native": req(forbid=["fmla"]),
        "genc": req(need={r"fmla\.2d": V4}),  # with the flag: they do
        # the feature only changes the mul_add methods, not plain expressions
        "genf": req(forbid=["fmla"]),
    },
    "probe_f16_add_f16x8": {
        "generic": info("soft-float via half: GPR bit work "
                        "(no f16 arith at armv8.0 generic)"),
        "native": info("native-but-scalar: per-lane scalar fadd h "
                       "(half's intrinsic path does not auto-vectorize)",
                       need={r"fadd\sh": 0}),
    },
    "probe_complex_mul_c64x8": {
        row: info("vectorized: fmul/ext-shuffle/fsub/fadd family "
                  "(no fcnmla contraction)", need={r"fmul\.2d": 0})
        for row in ("generic", "native")
    },
}

ARCHES = {
    "x86_64": {"rows": X86_ROWS, "expect": EXPECT_X86},
    "aarch64": {"rows": AARCH64_ROWS, "expect": EXPECT_AARCH64},
}


def die(msg, code=2):
    print(msg, file=sys.stderr)
    sys.exit(code)


def sh(cmd, env=None, **kw):
    try:
        return subprocess.run(cmd, check=True, capture_output=True,
                              text=True, env=env, **kw)
    except subprocess.CalledProcessError as e:
        die(f"command failed: {' '.join(cmd)}\n{e.stdout}\n{e.stderr}")


def host_arch():
    vv = sh(["rustc", "-vV"]).stdout
    m = re.search(r"^host: (\S+)", vv, re.M)
    return m.group(1).split("-")[0] if m else None


def host_triple():
    vv = sh(["rustc", "-vV"]).stdout
    m = re.search(r"^host: (\S+)", vv, re.M)
    return m.group(1) if m else None


# Host artifacts (build scripts, proc macros) execute on the build host, so
# they must never receive a row's target-cpu: a build script compiled for
# x86-64-v4 SIGILLs on hosts without AVX-512 (observed on CI runners). Row
# flags reach only the target graph (see compile_rows).
HOST_BASELINE = {"x86_64": "x86-64", "aarch64": "generic"}


def compile_rows(arch, rows):
    """Emit assembly for every row through the helper package.

    The row's -C target-cpu (and any extra flags) ride the target graph via
    `[target.<host-triple>] rustflags` config rather than the RUSTFLAGS
    environment: RUSTFLAGS also applies to build scripts and proc macros,
    which then crash with SIGILL on hosts lacking the row's instruction set.
    `-Ztarget-applies-to-host -Zhost-config` (nightly; this repo pins
    nightly) let `host.rustflags` pin those host artifacts to the arch's
    baseline. The emit path must be absolute because cargo runs rustc with a
    different working directory.
    """
    ASM_DIR.mkdir(parents=True, exist_ok=True)
    # dedicated target dir: the per-row flags must not churn the root
    # build's fingerprints
    env = {**os.environ, "CARGO_TARGET_DIR": str(ROOT / "target" / "asm-build")}
    env.pop("RUSTFLAGS", None)
    triple = host_triple()
    host_cfg = json.dumps(["-C", f"target-cpu={HOST_BASELINE[arch]}"])
    out = {}
    for name, cpu, extra, cargo_extra in rows:
        asm_path = ASM_DIR / f"{arch}-{name}.s"
        target_cfg = json.dumps(["-C", f"target-cpu={cpu}", *extra])
        sh(["cargo", "rustc",
            "--manifest-path", str(PROBES_MANIFEST),
            "--release",
            "-Ztarget-applies-to-host", "-Zhost-config",
            "--config", f"target.{triple}.rustflags={target_cfg}",
            "--config", f"host.rustflags={host_cfg}",
            *cargo_extra,
            "--", "--emit", f"asm={asm_path}"], env=env)
        out[name] = asm_path
    return out


def split_bodies(asm_path):
    """Map probe symbol -> body text.

    Emitted assembly has no column-0 .size directives; each function's extent
    runs from its `probe_x:` label to the next `.globl` (the following
    function's export) or to end of file. Mach-O (macOS) prefixes every
    symbol with `_`, which is stripped so keys match the expectation table.
    """
    bodies = {}
    current, buf = None, []
    for line in Path(asm_path).read_text().splitlines():
        m = re.fullmatch(r"_?(probe_\w+):", line)
        if m:
            if current:
                bodies[current] = "\n".join(buf)
            current, buf = m.group(1), []
        elif current is not None:
            if line.startswith(".globl") or line.startswith(".size"):
                bodies[current] = "\n".join(buf)
                current = None
            else:
                buf.append(line)
    if current:
        bodies[current] = "\n".join(buf)
    return bodies


def count(body, pattern):
    return len(re.findall(pattern, body, re.IGNORECASE))


def evaluate(cell, body):
    """Return (ok, detail) for a REQUIRED cell against one probe body."""
    for pat, n in cell["need"].items():
        have = count(body, pat)
        if have < n:
            return False, f"need >= {n} x /{pat}/, found {have}"
    for group in cell["need_any"]:
        if not any(count(body, pat) > 0 for pat in group):
            return False, f"need any of {group}"
    for pat in cell["forbid"]:
        if count(body, pat) > 0:
            return False, f"forbidden /{pat}/ present"
    return True, ""


def load_row(arch, row):
    path = ASM_DIR / f"{arch}-{row}.s"
    if not path.exists():
        die(f"{path} not built yet; run the script once first")
    return split_bodies(path)


# --------------------------------------------------------------------------
# maintenance subcommands
# --------------------------------------------------------------------------

def cmd_dump(probe, row, arch):
    body = load_row(arch, row).get(probe)
    if body is None:
        die(f"{probe} not found in {ASM_DIR}/{arch}-{row}.s")
    print(f"// {probe} @ {arch} {row}\n{body}")


def cmd_hist(row, arch):
    """Per-probe instruction-mnemonic histogram: the authoring aid for
    writing a new architecture's expectation table."""
    bodies = load_row(arch, row)
    for probe in sorted(bodies):
        hist = {}
        for line in bodies[probe].splitlines():
            m = re.match(r"\s+([a-z][a-z0-9.]*)", line)
            if m:
                hist[m.group(1)] = hist.get(m.group(1), 0) + 1
        top = " ".join(f"{k}x{v}" for k, v in
                       sorted(hist.items(), key=lambda kv: -kv[1])[:6])
        print(f"{probe}\n    {top}")


# --------------------------------------------------------------------------
# main run
# --------------------------------------------------------------------------

def run_checks(arch, expect, rows):
    row_order = [name for name, *_ in rows]
    asm_paths = compile_rows(arch, rows)
    all_bodies = {row: split_bodies(path) for row, path in asm_paths.items()}
    n_probes = len(all_bodies[row_order[0]])

    # body-presence smoke signal for every row (compilation sanity even in
    # informational mode)
    for row in row_order:
        present = len(all_bodies[row])
        flag = "" if present == n_probes else "  <-- MISSING BODIES"
        print(f"{arch}-{row}: {present}/{n_probes} probe bodies{flag}")

    if not expect:
        print(f"\n{arch}: expectation table not populated — informational "
              "mode, nothing is asserted.")
        print("author one with: python3 scripts/check_asm.py "
              f"--hist [{row_order[0]}] {arch}  and  --dump PROBE ROW {arch}")
        return 0

    results = {}  # (probe, row) -> (status, detail)
    failures = []
    notes = []
    for probe, cells in expect.items():
        for row in row_order:
            cell = cells.get(row)
            if cell is None:
                results[(probe, row)] = ("-", "")
                continue
            body = all_bodies[row].get(probe)
            if body is None:
                results[(probe, row)] = ("miss", "probe body not found")
                if cell["tier"] == "req":
                    failures.append((probe, row, "probe body not found"))
                continue
            if cell["tier"] == "info":
                extra = "".join(f" [{pat}={count(body, pat)}]"
                                for pat in cell["need"])
                results[(probe, row)] = ("info", extra)
                notes.append(f"{probe}@{row}: {cell['note']}{extra}")
                continue
            ok, detail = evaluate(cell, body)
            results[(probe, row)] = ("ok" if ok else "miss", detail)
            if not ok:
                failures.append((probe, row, detail))

    name_w = max(len(p) for p in expect) + 1
    header = "probe".ljust(name_w) + "".join(c.ljust(10) for c in row_order)
    print("\n" + header)
    print("-" * len(header))
    for probe in expect:
        cells = []
        for row in row_order:
            status, _ = results[(probe, row)]
            cells.append({"ok": "ok", "miss": "MISS",
                          "info": "info", "-": "-"}.get(status, "?").ljust(10))
        print(probe.ljust(name_w) + "".join(cells))
    print()
    for probe, row, detail in failures:
        print(f"REQUIRED MISS: {probe}@{row}: {detail}")
    print()
    for note in notes:
        print(f"note: {note}")
    print(f"\nREQUIRED misses: {len(failures)}  "
          f"(informational notes: {len(notes)})")
    print("assembly artifacts: " + str(ASM_DIR.relative_to(ROOT)))
    print("inspect one body: python3 scripts/check_asm.py "
          "--dump <probe> [row] [arch]")
    return 1 if failures else 0


def main():
    args = sys.argv[1:]
    arch_kw = None
    if "--arch" in args:
        i = args.index("--arch")
        if i + 1 >= len(args):
            die("--arch needs a value")
        arch_kw = args[i + 1]
        args = args[:i] + args[i + 2:]

    host = host_arch()
    arch = arch_kw or host
    if arch not in ARCHES:
        die(f"unknown arch {arch!r}; known: {', '.join(ARCHES)}. "
            "Add an ARCHES entry (rows + expectation table) to extend.")
    if arch_kw and arch_kw != host:
        print(f"{arch_kw}: cross-emission is not wired up (cargo cross "
              f"artifacts can be metadata-only stubs); scaffolding only. "
              f"Run on an {arch_kw} host to compile and report.")
        return 0

    spec = ARCHES[arch]
    rows = spec["rows"]

    if args and args[0] == "--dump":
        if len(args) < 2:
            die("--dump PROBE [ROW] [ARCH]")
        cmd_dump(args[1], args[2] if len(args) > 2 else rows[0][0], arch)
        return 0
    if args and args[0] == "--hist":
        cmd_hist(args[1] if len(args) > 1 else rows[0][0], arch)
        return 0
    if args:
        die(f"unrecognized arguments: {' '.join(args)}")

    sys.exit(run_checks(arch, spec["expect"], rows))


if __name__ == "__main__":
    main()