imazen / imazen/archmage

magetypes: technique backlog — 256-bit boolean reductions, dynamic byte swizzle, 8-bit multiply, NEON bitmask, i686 tokens

Open
#113 1 comment 0 reactions 1 assignee Claimed by @lilith View on GitHub
Dominant language
Rust
Stars
12
Forks
2
Avg merge
13h 55m
Merged PRs (30d)
28

Description

Replaces the 2026-09-04 technique-survey issue (number 81), which mixed shipped work, open work, and prose into one unranked list. This is the same backlog, re-verified against `main` on 2026-09-08 and reduced to what is actually left. Self-contained: every claim below points at a file and line in this repo.

## Status at a glance

| Item | State | Where |
|---|---|---|
| `f32`→`i32` conversion contract | **Done** — `to_i32_saturating`, uniform contract | closed as #80 |
| Safe token materialization | **Done** — `Token::from_context()`, 0.9.29 | PR #87 |
| Cross-ISA divergence docs | **Done** | `docs/site/content/magetypes/isa-quirks.md` |
| Boolean-reduction reformulation | **Half done** — 512-bit only | §1 |
| Dynamic byte swizzle | **Open** — nothing exists | §2 |
| 8-bit multiply | **Open** — nothing exists | §3 |
| NEON bitmask lowering | **Blocked** — needs ARM silicon | §4 |
| `min_ieee` / `max_ieee` naming | **Open** — never adopted | §5 |
| 32-bit x86 tokens | **Undecided** | §6 |
| Generation-time shared emission | **Undecided** | §7 |
| Macro surface soundness audit | **Open** | §8 |

Two things that are neither open nor done are recorded at the bottom: one reversal and one parked branch.

---

## §1. The boolean-reduction reformulation stopped at 512-bit

`all_true([a, b])` as `reduce(and(a, b))` beats two reductions plus a `&&`. That landed and measured **+10.2%** on the x86 512-bit polyfill (4740 → 5280 ns, interleaved).

It was applied to the **512-bit** polyfills on every backend and never to the **256-bit** ones. Exact state:

| Backend | 512-bit polyfill | 256-bit polyfill |
|---|---|---|
| `x86_v3.rs` | 8 sites, reformulated | n/a — 256-bit is native `__m256i` |
| `arm_neon.rs` | 8 sites, reformulated (`[_; 4]`) | **12 sites, still two-reduction** |
| `wasm128.rs` | 8 sites, reformulated (`[_; 4]`) | **16 sites, still two-reduction** |

The unconverted sites, `all_true` and `any_true` on each:

- `magetypes/src/simd/impls/arm_neon.rs` — `I8x32` 2870/2875, `U8x32` 3342/3347, `I16x16` 3931/3936, `U16x16` 4402/4407, `I32x8` 1630/1635, `U32x8` 2026/2031
- `magetypes/src/simd/impls/wasm128.rs` — the same six, plus `I64x4` 1973/1978 and `U64x4` 4252/4257

Current shape (`arm_neon.rs:1630`):

```rust
fn all_true(self, a: [int32x4_t; 2]) -> bool {
vminvq_u32(vreinterpretq_u32_s32(a[0])) != 0 && vminvq_u32(vreinterpretq_u32_s32(a[1])) != 0
}
```

Wanted, matching what the 512-bit path already does: `bitand` the halves, then one reduction. `any_true` is the same with `bitor`.

- [ ] Reformulate the 12 NEON sites
- [ ] Reformulate the 16 WASM sites
- [ ] Fix the generator, not the output — these files are generated
- [ ] Re-measure; the x86 number does not transfer

## §2. There is no dynamic byte swizzle

magetypes exposes no `pshufb` / `tbl`-class operation at any width. Confirmed absent: no `swizzle`, `shuffle_dyn`, `_mm_shuffle_epi8` or `vqtbl` anywhere in `magetypes/src/simd/backends/` or the generic types. This is the workhorse for palette expansion, pixel-format shuffles and byte transforms, and its absence forces callers back to scalar or to hand-written intrinsics outside the type system.

The design problem is out-of-range control bytes, where the ISAs disagree: x86 `pshufb` zeroes a lane when the control byte's high bit is set and otherwise masks to the low 4 bits; NEON `tbl` zeroes out-of-range; WASM `i8x16.swizzle` zeroes at ≥16. A single portable contract has to pick one, and the cheap answer differs per backend.

The two-tier split that other work in this space converged on is worth copying: a **relaxed** variant whose out-of-range behavior is explicitly unspecified (so every backend uses its bare instruction), and a **zeroing** variant with a uniform contract (backends that need it pay for a mask). A third, block-scoped variant handles the 256/512-bit case where the natural instruction only shuffles within 128-bit lanes.

- [ ] Decide the tier names and contracts, and write them down before implementing
- [ ] `u8xN` backend methods via xtask, all three widths
- [ ] Lane-order-pinned tests — this is exactly the shape that AVX2's per-lane behavior breaks silently
- [ ] Asm probe per backend

## §3. There is no 8-bit multiply

Neither `I8x16Backend` nor `U8x16Backend` declares a `mul`. Widening to 16-bit and back is the obvious workaround, but on x86 `PMADDUBSW` does an unsigned×signed byte multiply with horizontal pairwise add in one instruction, which is the primitive most byte-domain kernels actually want. Related: `pairwise_widen_add` already exists on the unsigned byte and halfword types, so the pairwise shape is established here.

- [ ] Decide whether the exposed op is `mul` (lane-wise, needs the widen roundtrip on x86) or the pairwise multiply-add that maps to one instruction
- [ ] Implement on `i8`/`u8` at all three widths, native where possible
- [ ] Measure against the widen-multiply-narrow baseline before claiming a win

## §4. NEON bitmask packs lanes one at a time

`magetypes/src/simd/impls/arm_neon.rs:1398` (128-bit) and `:1639` (256-bit polyfill) extract each lane with `vgetq_lane_u32` and shift-or them together:

```rust
let shift = vshrq_n_u32::<31>(vreinterpretq_u32_s32(a));
let lane0 = vgetq_lane_u32::<0>(shift);
// ... lane1, lane2, lane3
lane0 | (lane1 << 1) | (lane2 << 2) | (lane3 << 3)
```

A shift-and-accumulate formulation should beat this. **Blocked, deliberately**: house rules forbid landing a perf change on measurement from a different machine class, and there is no ARM silicon in the loop here. Do not land this from a QEMU number or from reasoning about instruction counts.

- [ ] Measure the current form on M-series or Neoverse
- [ ] Only then evaluate a replacement

## §5. `min` / `max` do not say which one they are

IEEE 754 `minNum`/`maxNum` and the x86 `MINPS`/`MAXPS` family disagree on NaN and on signed zero, and our method names do not distinguish them. Naming the IEEE-semantics variants `min_ieee` / `max_ieee` and leaving the bare names for the native op makes the choice visible at the call site. Nothing named `min_ieee` or `max_ieee` exists today.

This is related to an already-recorded open divergence: scalar `f32x16` min/max differs from the narrower scalar vectors on NaN. Fixing the naming and fixing that divergence should be one decision, not two.

- [ ] Decide the naming, including what the bare names promise
- [ ] Settle the scalar `f32x16` NaN divergence in the same pass
- [ ] Update the ISA quirks page

## §6. Every x86 token stubs out on 32-bit x86

`src/tokens/generated/mod.rs:8-17` gates the real x86 tokens on `target_arch = "x86_64"`; 32-bit x86 gets `x86_stubs`, where every `summon()` returns `None`. So on `i686-unknown-linux-gnu` — a **primary CI target** for this project — nothing dispatches above scalar, silently.

Runtime detection on 32-bit x86 is possible; the stubs are a decision, not a limitation. The question is whether it is a deliberate one.

- [ ] Confirm whether i686 SIMD is intentionally out of scope
- [ ] If intentional: say so in the docs, because "our CI covers i686" currently implies more than it delivers
- [ ] If not: the registry already has the feature sets; this is a cfg and detection-path change

## §7. Generation emits per-backend × per-width where it could share

Operations with identical shape across backends are emitted separately for each backend and width, across 84 generated files. Deriving the shared-shape implementations from common generic code instead cuts metadata volume — a comparable project measured −25%. Our generation is already the slow part of `just ci`.

Speculative until measured here. Listed so it is not lost, not because it is ready to do.

- [ ] Measure current generated metadata volume and `just generate` wall time
- [ ] Prototype on one op family before committing to the shape

## §8. The macro surface has never had a soundness audit pass

`archmage-macros` exposes `#[doc(hidden)]` items that user code can technically name. The existing soundness verifier covers intrinsic call sites against target features; it does not ask what a caller can do by invoking hidden macro internals directly with hostile input.

- [ ] Enumerate the `#[doc(hidden)]` surface of `archmage-macros`
- [ ] For each, ask what a caller naming it directly can construct
- [ ] Add compile-fail cases for anything that turns out to be reachable

---

## Recorded, not open

**One reversal.** `dec6a463` made x86 `recip()`/`rsqrt()` exact IEEE division at every width. Measurement then showed that ~1.9x/~3.6x slower on Zen-class x86 (`benchmarks/recip_x86_zen5-9950x3d_2026-09-03.md`), which would have silently regressed every caller using `.recip()` as a speed idiom. `d523b40e` replaced it with the tier split that shipped: bare `recip()`/`rsqrt()` are the working tier — ≤4 ULP with exact IEEE rails, branchless — and `recip_portable()`/`rsqrt_portable()` are the precise tier. Both are on `main`; the second supersedes the first. Do not re-propose exact-everywhere without re-reading that benchmark.

**One parked branch.** `origin/tf-inner-experiment` (`c8f18fbe`, "compiler-verified intrinsic safety via nested target_feature fns") is not on `main` and was superseded: the storage-helper approach landed instead, and `magetypes/src/` is now down to **9** `unsafe` blocks — 8 in `simd_storage.rs`, 1 in the `x86_v3.rs` trampoline macro. Either merge the branch's remaining ideas or delete it; leaving it as an undated experiment invites someone to rediscover it.

Contributor guide

No contributing guide indexed for this repository

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.