Skip to main content

zenpixels_convert/orient/
mod.rs

1//! Physical orientation baking — rotate / flip a whole pixel buffer.
2//!
3//! [`apply_orientation`] takes a (possibly strided) [`PixelSlice`] and an
4//! [`Orientation`] and returns a fresh, tightly-allocated [`PixelBuffer`] with
5//! the pixels physically rearranged. It is the "bake" half of the zen
6//! orientation model: codecs that decode to a raster buffer and are asked to
7//! resolve orientation (`OrientationHint::bakes()` is true) call this; the
8//! cheap coordinate math (`Orientation::forward_map` / `output_dimensions`)
9//! lives in `zenpixels`, and this is the buffer operation that consumes it.
10//! [`apply_orientation_into`] writes a caller-owned buffer (no allocation), and
11//! [`apply_orientation_in_place`] permutes the backing allocation itself.
12//!
13//! # Algorithm
14//!
15//! The eight orientations split into two classes:
16//!
17//! * **Non-transposing** (`Identity`, `FlipH`, `FlipV`, `Rotate180`) — the
18//!   output has the same dimensions, so each output row maps to exactly one
19//!   input row. `Identity`/`FlipV` are pure row copies (memcpy, reordered for
20//!   `FlipV`); `FlipH`/`Rotate180` additionally reverse the `bpp`-sized
21//!   elements within each row. These are memory-bandwidth bound — a scalar
22//!   `copy_from_slice` per row already runs at copy speed.
23//!
24//! * **Transposing** (`Transpose`, `Rotate90`, `Rotate270`, `Transverse`) —
25//!   width and height swap, and the access pattern is a matrix transpose, which
26//!   is the cache-hostile case: a naïve element loop strides one of the two
27//!   buffers by a full row per step and thrashes the cache once the image
28//!   exceeds L1/L2. We use the standard fix — **loop tiling (cache blocking)**:
29//!   process the image in `TILE`×`TILE` blocks so each block's source and
30//!   destination footprints (`TILE*TILE*bpp` bytes each) stay resident while we
31//!   transpose them. The orientation's reflection (the `h-1-sy` / `w-1-sx`
32//!   terms that turn a bare transpose into a 90°/270° rotation or anti-diagonal
33//!   flip) is folded into the per-element destination address via
34//!   `forward_map`, so the whole thing is a single pass with no intermediate
35//!   buffer.
36//!
37//! The transpose runs in one of two tiers — selected at compile time by the
38//! `fast-transpose` feature and at runtime by CPU capability. Both share the
39//! cache-blocking structure above and fold the reflection into the destination
40//! address; they differ only in how each tile is moved. [`do_transpose`] picks.
41//!
42//! * **Portable scalar** — the default, and the path on pre-AVX2 x86 and any
43//!   arch without a SIMD kernel. [`transpose_tiled`] is a per-width
44//!   monomorphised gather: the four transposing maps are *separable*, so along
45//!   one destination row the source column is fixed and the source offset steps
46//!   by ±stride. That replaces the generic path's per-element `forward_map`,
47//!   `row_mut`, and variable-length copy with one bounds check and a fixed-size
48//!   `BPP`-byte copy per pixel, writing the destination sequentially (zenjpeg#150
49//!   measured the generic path losing to a naive linear-write gather at 3 bpp;
50//!   this beats both). 16-byte pixels use [`transpose16_deep`] — a transpose there
51//!   is pure block movement that autovectorises — and the generic
52//!   [`transpose_blocked`] scatter covers any other width.
53//!
54//! * **SIMD register transpose** (`fast-transpose`) — per-pixel-width kernels
55//!   that transpose a register-sized tile with an unpack/zip shuffle cascade,
56//!   the shape production transpose libraries (ermig1979/Simd, fast_transpose,
57//!   libyuv) win with. x86-64-v3 (AVX2) kernels cover 1/2/3/4/6/8/12 bpp in
58//!   `pxn_x86` (24-bit RGB8 in `rgb3_x86`, which expands 3→4 bytes, transposes
59//!   u32 lanes, then compresses); aarch64 NEON kernels cover the same widths in
60//!   `pxn_neon`; 16-byte pixels fall to [`transpose16_deep`]. On other
61//!   `fast-transpose` arches (wasm, …) only 4-byte pixels are SIMD — via
62//!   magetypes' `f32x4::transpose_4x4` (the classic `_MM_TRANSPOSE4_PS` shuffle,
63//!   generated for SSE/NEON/SIMD128/scalar from one `#[magetypes]` body and
64//!   dispatched by `incant!`) — and other widths take the scalar tier.
65//!
66//! A 4-byte pixel rides a transpose as one 32-bit lane; the kernels shuffle
67//! whole lanes only (no arithmetic), so the byte reinterpret is bit-exact for
68//! any 4-byte format, NaN patterns included. Every SIMD kernel transposes the
69//! full tiles and leaves the right/bottom edge strips to a scalar `forward_map`
70//! scatter (`scalar_rect` / `scalar_edges`). [`transpose_blocked`] (the generic
71//! `forward_map` scatter) is the parity oracle every fast path is tested against
72//! (`simd_transpose_matches_scalar_reference_rgba8`,
73//! `tiled_transpose_matches_blocked_reference_across_bpp`,
74//! `exhaustive_dense_dims_all_orientations_vs_oracle`) and the correct fallback
75//! for any orientation added to the `#[non_exhaustive]` enum later.
76
77use core::cmp::min;
78
79use zenpixels::{InPlacePixels, Orientation, PixelBuffer, PixelSlice, PixelSliceMut};
80
81use crate::error::ConvertError;
82
83// Cross-arch SIMD: the `#[magetypes(...)]` codegen attribute + `incant!`
84// runtime dispatch from the archmage prelude, and the token-parameterized
85// generic `f32x4` from magetypes — whose `transpose_4x4` lowers to SSE
86// `_MM_TRANSPOSE4_PS` on x86, the NEON `zip`/`trn` cascade on aarch64, and the
87// `i32x4.shuffle` cascade on wasm128.
88#[cfg(feature = "fast-transpose")]
89use archmage::prelude::*;
90#[cfg(feature = "fast-transpose")]
91use magetypes::simd::generic::f32x4 as GenericF32x4;
92
93/// Side length of the cache-blocking tile for transposing orientations, in
94/// pixels. At `bpp = 4` a 32×32 tile touches 4 KiB of source and 4 KiB of
95/// destination — comfortably inside L1 — while staying large enough to amortise
96/// the per-tile loop overhead.
97const TILE: u32 = 32;
98
99/// Apply `orientation` to `src`, returning a freshly-allocated buffer with the
100/// pixels physically rearranged.
101///
102/// The returned buffer's dimensions are
103/// [`orientation.output_dimensions(src.width(), src.rows())`](Orientation::output_dimensions)
104/// — width and height swap for the four axis-swapping orientations. The pixel
105/// descriptor is preserved exactly (this moves whole `bpp`-sized pixels; it
106/// never touches their contents), so it is format-, channel-, and bit-depth
107/// agnostic. Strided input is handled.
108///
109/// This allocates the output every call. Callers that reuse or pool a target
110/// buffer (e.g. a codec `decode_into`, or an image proxy processing same-size
111/// images) should use [`apply_orientation_into`] to avoid the allocation.
112/// `Orientation::Identity` still allocates and copies (callers that want to skip
113/// the copy entirely should check `orientation.is_identity()` themselves).
114#[must_use]
115pub fn apply_orientation(src: PixelSlice<'_>, orientation: Orientation) -> PixelBuffer {
116    let (ow, oh) = orientation.output_dimensions(src.width(), src.rows());
117    let desc = src.descriptor();
118    let mut out = PixelBuffer::new(ow, oh, desc);
119    // The buffer is constructed to the exact output geometry + descriptor, so
120    // the size/format check inside `apply_orientation_into` cannot fail.
121    apply_orientation_into(src, orientation, out.as_slice_mut())
122        .expect("apply_orientation: freshly allocated buffer matches output geometry");
123    out
124}
125
126/// Apply `orientation` to `src`, writing into a caller-provided `dst` — **no
127/// allocation**, so callers can reuse / pool the target across many calls.
128///
129/// `dst` must already have the oriented geometry
130/// ([`orientation.output_dimensions(src.width(), src.rows())`](Orientation::output_dimensions))
131/// and the same bytes-per-pixel as `src`; otherwise [`ConvertError::BufferSize`]
132/// is returned and `dst` is left untouched. The allocating [`apply_orientation`]
133/// is a thin wrapper over this.
134pub fn apply_orientation_into(
135    src: PixelSlice<'_>,
136    orientation: Orientation,
137    mut dst: PixelSliceMut<'_>,
138) -> Result<(), ConvertError> {
139    let w = src.width();
140    let h = src.rows();
141    let bpp = src.descriptor().bytes_per_pixel();
142    let (ow, oh) = orientation.output_dimensions(w, h);
143
144    let dst_bpp = dst.descriptor().bytes_per_pixel();
145    if dst.width() != ow || dst.rows() != oh || dst_bpp != bpp {
146        return Err(ConvertError::BufferSize {
147            expected: ow as usize * oh as usize * bpp,
148            actual: dst.width() as usize * dst.rows() as usize * dst_bpp,
149        });
150    }
151    if w == 0 || h == 0 || bpp == 0 {
152        return Ok(());
153    }
154
155    {
156        match orientation {
157            Orientation::Identity => {
158                for y in 0..h {
159                    dst.row_mut(y).copy_from_slice(src.row(y));
160                }
161            }
162            Orientation::FlipV => {
163                for y in 0..h {
164                    dst.row_mut(y).copy_from_slice(src.row(h - 1 - y));
165                }
166            }
167            Orientation::FlipH => {
168                for y in 0..h {
169                    reverse_row(src.row(y), dst.row_mut(y), w as usize, bpp);
170                }
171            }
172            Orientation::Rotate180 => {
173                for y in 0..h {
174                    reverse_row(src.row(h - 1 - y), dst.row_mut(y), w as usize, bpp);
175                }
176            }
177            // Axis-swapping: cache-blocked transpose with the orientation's
178            // reflection folded into the destination address. The `_` arm makes
179            // this the correct (if unoptimised) fallback for any orientation
180            // added to the `#[non_exhaustive]` enum in future — it scatters by
181            // `forward_map`, which is defined for every variant.
182            Orientation::Transpose
183            | Orientation::Rotate90
184            | Orientation::Rotate270
185            | Orientation::Transverse
186            | _ => {
187                do_transpose(&src, &mut dst, orientation, w, h, bpp);
188            }
189        }
190    }
191    Ok(())
192}
193
194/// Largest bytes-per-pixel the in-place path's per-element temp supports — covers
195/// every current format up to RGBA f32 (16 bytes).
196const MAX_INPLACE_BPP: usize = 16;
197
198/// Bake `orientation` into `dst` **in place**, reusing its allocation — no second
199/// pixel buffer (the transposing orientations would otherwise need a 2× transient).
200///
201/// Consumes the mutable view, permutes the bytes within the backing allocation,
202/// and returns a re-described **tight-stride** `PixelSliceMut` over the same
203/// memory — dimensions swapped for the four transposing orientations. Like the
204/// no-alloc reduction APIs, the returned view carries the new geometry; the
205/// source `PixelBuffer`'s own `width()`/`height()` go stale, so use the returned
206/// view. Square images transpose via an in-place diagonal swap; non-square via
207/// cycle-following (an `n`-element visited scratch — not a 2× pixel buffer).
208///
209/// Returns [`ConvertError::BufferSize`] if `bpp` exceeds 16 (the per-element temp
210/// limit) or if re-describing the output fails.
211pub fn apply_orientation_in_place(
212    dst: &mut PixelBuffer,
213    orientation: Orientation,
214) -> Result<(), ConvertError> {
215    let bpp = dst.descriptor().bytes_per_pixel();
216    if bpp == 0 || bpp > MAX_INPLACE_BPP {
217        return Err(ConvertError::BufferSize {
218            expected: MAX_INPLACE_BPP,
219            actual: bpp,
220        });
221    }
222    // The eight known orientations all have an in-place mapping; a future
223    // `#[non_exhaustive]` variant falls back to the allocating
224    // `apply_orientation` at the caller.
225    if !matches!(
226        orientation,
227        Orientation::Identity
228            | Orientation::FlipH
229            | Orientation::FlipV
230            | Orientation::Rotate180
231            | Orientation::Transpose
232            | Orientation::Rotate90
233            | Orientation::Rotate270
234            | Orientation::Transverse
235    ) {
236        return Err(ConvertError::BufferSize {
237            expected: MAX_INPLACE_BPP,
238            actual: 0,
239        });
240    }
241    dst.transform_in_place(|px| orient_in_place_impl(px, orientation));
242    Ok(())
243}
244
245/// The transform body behind [`apply_orientation_in_place`]: permute the
246/// bytes and return the re-described tight-stride view for
247/// [`PixelBuffer::transform_in_place`] to adopt.
248fn orient_in_place_impl(px: InPlacePixels<'_>, orientation: Orientation) -> PixelSliceMut<'_> {
249    let InPlacePixels {
250        bytes,
251        width: w,
252        rows: h,
253        stride: in_stride,
254        descriptor: desc,
255        color,
256        ..
257    } = px;
258    let bpp = desc.bytes_per_pixel();
259    let (ow, oh) = orientation.output_dimensions(w, h);
260    let tight = w as usize * bpp;
261    let out_stride = ow as usize * bpp;
262    let out_len = out_stride * oh as usize;
263
264    fn rewrap<'b>(
265        bytes: &'b mut [u8],
266        ow: u32,
267        oh: u32,
268        out_stride: usize,
269        desc: zenpixels::PixelDescriptor,
270        color: Option<alloc::sync::Arc<zenpixels::ColorContext>>,
271    ) -> PixelSliceMut<'b> {
272        let out = PixelSliceMut::new(bytes, ow, oh, out_stride, desc)
273            .expect("oriented in-place geometry is always valid");
274        match color {
275            Some(c) => out.with_color_context(c),
276            None => out,
277        }
278    }
279
280    if w == 0 || h == 0 {
281        return rewrap(&mut bytes[..out_len], ow, oh, out_stride, desc, color);
282    }
283
284    // 1. Compact to tight (drop any row padding) so the transpose is a clean
285    //    permutation of a contiguous element array.
286    if in_stride != tight {
287        for y in 1..h as usize {
288            bytes.copy_within(y * in_stride..y * in_stride + tight, y * tight);
289        }
290    }
291    let content = &mut bytes[..tight * h as usize];
292
293    // 2/3. Permute in place. Transposing orientations transpose the tight w×h
294    //      grid (→ h×w = ow×oh) then add the orientation's reflection.
295    match orientation {
296        Orientation::Identity => {}
297        Orientation::FlipH => inplace_flip_h(content, w, h, bpp),
298        Orientation::FlipV => inplace_flip_v(content, w, h, bpp),
299        Orientation::Rotate180 => inplace_reverse_elements(content, bpp),
300        Orientation::Transpose => inplace_transpose(content, w, h, bpp),
301        Orientation::Rotate90 => {
302            inplace_transpose(content, w, h, bpp);
303            inplace_flip_h(content, ow, oh, bpp); // transpose ∘ FlipH
304        }
305        Orientation::Rotate270 => {
306            inplace_transpose(content, w, h, bpp);
307            inplace_flip_v(content, ow, oh, bpp); // transpose ∘ FlipV
308        }
309        Orientation::Transverse => {
310            inplace_transpose(content, w, h, bpp);
311            inplace_reverse_elements(content, bpp); // transpose ∘ Rotate180
312        }
313        // Pre-checked in `apply_orientation_in_place`; unreachable here.
314        _ => {}
315    }
316
317    rewrap(&mut bytes[..out_len], ow, oh, out_stride, desc, color)
318}
319
320/// Reverse the `bpp`-sized elements within each row, in place (`FlipH`).
321fn inplace_flip_h(a: &mut [u8], w: u32, h: u32, bpp: usize) {
322    let w = w as usize;
323    let row_len = w * bpp;
324    for y in 0..h as usize {
325        let row = &mut a[y * row_len..y * row_len + row_len];
326        let (mut lo, mut hi) = (0usize, w - 1);
327        while lo < hi {
328            let (al, ah) = (lo * bpp, hi * bpp);
329            for k in 0..bpp {
330                row.swap(al + k, ah + k);
331            }
332            lo += 1;
333            hi -= 1;
334        }
335    }
336}
337
338/// Swap row `y` with row `h-1-y`, in place (`FlipV`). No temp row — the two rows
339/// are disjoint, so `split_at_mut` + `swap_with_slice` exchanges them directly.
340fn inplace_flip_v(a: &mut [u8], w: u32, h: u32, bpp: usize) {
341    let row_len = w as usize * bpp;
342    let h = h as usize;
343    let (mut top, mut bot) = (0usize, h - 1);
344    while top < bot {
345        let split = bot * row_len;
346        let (head, tail) = a.split_at_mut(split);
347        head[top * row_len..top * row_len + row_len].swap_with_slice(&mut tail[..row_len]);
348        top += 1;
349        bot -= 1;
350    }
351}
352
353/// Reverse the order of all `bpp`-sized elements in the buffer (`Rotate180` =
354/// `FlipH ∘ FlipV`).
355fn inplace_reverse_elements(a: &mut [u8], bpp: usize) {
356    let n = a.len() / bpp;
357    if n < 2 {
358        return;
359    }
360    let (mut lo, mut hi) = (0usize, n - 1);
361    while lo < hi {
362        let (al, ah) = (lo * bpp, hi * bpp);
363        for k in 0..bpp {
364            a.swap(al + k, ah + k);
365        }
366        lo += 1;
367        hi -= 1;
368    }
369}
370
371/// In-place transpose of a tight `w`×`h` (row-major) grid of `bpp`-byte elements
372/// into `h`×`w`, within the same buffer.
373///
374/// Square is the diagonal swap. Non-square follows the transpose permutation's
375/// cycles (`Wikipedia: in-place matrix transposition`): element index `k = r*w+c`
376/// maps to `c*h+r ≡ (k*h) mod (n-1)`; to fill position `cur` we gather from
377/// `(cur*w) mod (n-1)` (the inverse, since `w*h ≡ 1`), walking each cycle once
378/// with a one-element temp and an `n`-bit visited set. `0` and `n-1` are fixed.
379fn inplace_transpose(a: &mut [u8], w: u32, h: u32, bpp: usize) {
380    if w == h {
381        let n = w as usize;
382        for i in 0..n {
383            for j in (i + 1)..n {
384                let (p, q) = ((i * n + j) * bpp, (j * n + i) * bpp);
385                for k in 0..bpp {
386                    a.swap(p + k, q + k);
387                }
388            }
389        }
390        return;
391    }
392
393    let (w, h) = (w as usize, h as usize);
394    let n = w * h;
395    if n <= 1 {
396        return;
397    }
398    let mn1 = n - 1;
399    let mut moved = alloc::vec![false; n];
400    moved[0] = true;
401    moved[mn1] = true;
402    let mut tmp = [0u8; MAX_INPLACE_BPP];
403    let mut start = 1;
404    while start < mn1 {
405        if moved[start] {
406            start += 1;
407            continue;
408        }
409        tmp[..bpp].copy_from_slice(&a[start * bpp..start * bpp + bpp]);
410        let mut cur = start;
411        loop {
412            moved[cur] = true;
413            let prev = (cur * w) % mn1; // element that belongs at `cur`
414            if prev == start {
415                break;
416            }
417            a.copy_within(prev * bpp..prev * bpp + bpp, cur * bpp);
418            cur = prev;
419        }
420        a[cur * bpp..cur * bpp + bpp].copy_from_slice(&tmp[..bpp]);
421        start += 1;
422    }
423}
424
425/// Bench-only A/B handle: bake `orientation` via the cache-blocked **scalar**
426/// transpose, bypassing the SIMD kernel, so `bench_orient` can compare the two
427/// paths on identical input. Only meaningful for the transposing orientations.
428#[cfg(feature = "__bench_orient")]
429#[doc(hidden)]
430#[must_use]
431pub fn __bench_apply_orientation_scalar(
432    src: PixelSlice<'_>,
433    orientation: Orientation,
434) -> PixelBuffer {
435    let w = src.width();
436    let h = src.rows();
437    let desc = src.descriptor();
438    let bpp = desc.bytes_per_pixel();
439    let (ow, oh) = orientation.output_dimensions(w, h);
440    let mut out = PixelBuffer::new(ow, oh, desc);
441    if w == 0 || h == 0 || bpp == 0 {
442        return out;
443    }
444    {
445        let mut dst = out.as_slice_mut();
446        transpose_blocked(&src, &mut dst, orientation, w, h, bpp);
447    }
448    out
449}
450
451/// Copy one row, reversing the order of `bpp`-sized pixels (`FlipH` per row).
452#[inline]
453fn reverse_row(s: &[u8], d: &mut [u8], width: usize, bpp: usize) {
454    for x in 0..width {
455        let si = (width - 1 - x) * bpp;
456        let di = x * bpp;
457        d[di..di + bpp].copy_from_slice(&s[si..si + bpp]);
458    }
459}
460
461/// Scatter one source pixel `(sx, sy)` to its oriented destination.
462#[inline]
463#[allow(clippy::too_many_arguments)] // per-pixel helper; an args struct would add overhead/noise
464fn scatter_pixel(
465    s: &[u8],
466    dst: &mut PixelSliceMut<'_>,
467    orientation: Orientation,
468    sx: u32,
469    sy: u32,
470    w: u32,
471    h: u32,
472    bpp: usize,
473) {
474    let (dx, dy) = orientation.forward_map(sx, sy, w, h);
475    let si = sx as usize * bpp;
476    let di = dx as usize * bpp;
477    dst.row_mut(dy)[di..di + bpp].copy_from_slice(&s[si..si + bpp]);
478}
479
480/// Dispatch the four axis-swapping orientations to the best available
481/// transpose. With `fast-transpose` on x86-64 / aarch64, a dedicated AVX2 / NEON
482/// kernel per pixel width (`pxn_x86` / `rgb3_x86` / `pxn_neon`); on other
483/// `fast-transpose` arches, magetypes' 4×4 register transpose for 4-byte pixels
484/// (`incant!`-dispatched across WASM SIMD128 / scalar) with the tiled gather for
485/// the rest. Without the feature, the portable scalar tiers
486/// ([`transpose_tiled`] / [`transpose16_deep`]). Any future `#[non_exhaustive]`
487/// variant has no separable inverse map and falls to [`transpose_blocked`].
488fn do_transpose(
489    src: &PixelSlice<'_>,
490    dst: &mut PixelSliceMut<'_>,
491    orientation: Orientation,
492    w: u32,
493    h: u32,
494    bpp: usize,
495) {
496    // Only the four known transposing orientations have a `tile_dest` /
497    // separable-inverse mapping; a future `#[non_exhaustive]` variant falls
498    // through to the scalar scatter, whose `forward_map` is defined for every
499    // variant.
500    if let Some(flips) = inverse_flips(orientation) {
501        // AVX2 (x86-64-v3) register-transpose kernels, one per pixel width —
502        // the kernel shapes zune / fast_transpose / the C++ Simd library win
503        // with (transpose-shootout 2026-06-12, where our generic paths lost
504        // 2-5×). The token summon fails only on pre-AVX2 x86, which then falls
505        // through to the magetypes 4-byte kernel / scalar tiled gather below.
506        #[cfg(all(feature = "fast-transpose", target_arch = "x86_64"))]
507        {
508            macro_rules! v3_kernel {
509                ($kernel:path) => {
510                    if let Some(token) = X64V3Token::summon() {
511                        $kernel(token, src, dst, orientation, w, h);
512                        return;
513                    }
514                };
515            }
516            match bpp {
517                1 => v3_kernel!(pxn_x86::transpose1_v3),
518                2 => v3_kernel!(pxn_x86::transpose2_v3),
519                3 => v3_kernel!(rgb3_x86::transpose3_v3),
520                4 => v3_kernel!(pxn_x86::transpose4_v3),
521                6 => v3_kernel!(pxn_x86::transpose6_v3),
522                8 => v3_kernel!(pxn_x86::transpose8_v3),
523                12 => v3_kernel!(pxn_x86::transpose12_v3),
524                16 => v3_kernel!(pxn_x86::transpose16_v3),
525                _ => {}
526            }
527        }
528        // NEON kernels per pixel width. NEON is the aarch64 baseline, so the
529        // token always summons; 16-byte pixels use the deep-scalar block move,
530        // which beats hand-written NEON here (measured on Neoverse-N1 — see
531        // `transpose16_deep`).
532        #[cfg(all(feature = "fast-transpose", target_arch = "aarch64"))]
533        {
534            macro_rules! neon_kernel {
535                ($kernel:path) => {
536                    if let Some(token) = NeonToken::summon() {
537                        $kernel(token, src, dst, orientation, w, h);
538                        return;
539                    }
540                };
541            }
542            match bpp {
543                1 => neon_kernel!(pxn_neon::transpose1_neon),
544                2 => neon_kernel!(pxn_neon::transpose2_neon),
545                3 => neon_kernel!(pxn_neon::transpose3_neon),
546                4 => neon_kernel!(pxn_neon::transpose4_neon),
547                6 => neon_kernel!(pxn_neon::transpose6_neon),
548                8 => neon_kernel!(pxn_neon::transpose8_neon),
549                12 => neon_kernel!(pxn_neon::transpose12_neon),
550                16 => {
551                    transpose16_deep(src, dst, orientation, w, h);
552                    return;
553                }
554                _ => {}
555            }
556        }
557        #[cfg(feature = "fast-transpose")]
558        if bpp == 4 {
559            // Non-x86 tiers (NEON / WASM SIMD128 / scalar) via the magetypes
560            // 4×4 register transpose. Explicit tier list matching its
561            // `#[magetypes(v3, neon, wasm128, scalar)]` attribute: a bare
562            // `incant!` expands the full cascade and references a `_v4`
563            // variant that was never generated, breaking `--features avx512`
564            // builds (caught by the feature-powerset CI job).
565            incant!(
566                transpose4_simd(src, dst, orientation, w, h),
567                [v3, neon, wasm128, scalar]
568            );
569            return;
570        }
571        // Monomorphised per pixel size so the inner copy is a fixed-size
572        // load/store pair. The set covers every shipping descriptor width;
573        // an unlisted width (none today) takes the generic fallback below.
574        match bpp {
575            1 => return transpose_tiled::<1>(src, dst, flips, w, h),
576            2 => return transpose_tiled::<2>(src, dst, flips, w, h),
577            3 => return transpose_tiled::<3>(src, dst, flips, w, h),
578            4 => return transpose_tiled::<4>(src, dst, flips, w, h),
579            6 => return transpose_tiled::<6>(src, dst, flips, w, h),
580            8 => return transpose_tiled::<8>(src, dst, flips, w, h),
581            12 => return transpose_tiled::<12>(src, dst, flips, w, h),
582            16 => return transpose16_deep(src, dst, orientation, w, h),
583            _ => {}
584        }
585    }
586    transpose_blocked(src, dst, orientation, w, h, bpp);
587}
588
589/// The inverse-map structure shared by the four transposing orientations, as
590/// `(flip_sx, flip_sy)`: destination pixel `(dx, dy)` reads source pixel
591///
592/// ```text
593/// sx = if flip_sx { w-1-dy } else { dy }   // constant along a dst row
594/// sy = if flip_sy { h-1-dx } else { dx }   // steps ±1 along a dst row
595/// ```
596///
597/// Derived by inverting [`Orientation::forward_map`] — e.g. `Rotate90` maps
598/// `(sx, sy) → (h-1-sy, sx)`, so `sx = dy`, `sy = h-1-dx`. `None` for the
599/// non-transposing orientations and any future variant.
600#[inline]
601fn inverse_flips(orientation: Orientation) -> Option<(bool, bool)> {
602    match orientation {
603        Orientation::Transpose => Some((false, false)),
604        Orientation::Rotate90 => Some((false, true)),
605        Orientation::Rotate270 => Some((true, false)),
606        Orientation::Transverse => Some((true, true)),
607        _ => None,
608    }
609}
610
611/// Cache-blocked transpose for the four axis-swapping orientations,
612/// monomorphised per bytes-per-pixel (`BPP`).
613///
614/// Same loop-tiling idea as [`transpose_blocked`], but iterating *destination*
615/// tiles with the orientation's separable inverse map (see [`inverse_flips`])
616/// precomputed per row instead of calling `forward_map` per element: along one
617/// destination row the source column is fixed and the source byte offset steps
618/// by ±stride, so the inner loop is a strided gather (one bounds check) plus a
619/// fixed-size `BPP`-byte copy, with destination writes sequential — the
620/// store-friendly direction. This is what makes 3 bpp (and the other non-SIMD
621/// widths) competitive; zenjpeg#150 measured the `forward_map`-per-element
622/// path losing to a naive linear-write gather.
623fn transpose_tiled<const BPP: usize>(
624    src: &PixelSlice<'_>,
625    dst: &mut PixelSliceMut<'_>,
626    (flip_sx, flip_sy): (bool, bool),
627    w: u32,
628    h: u32,
629) {
630    debug_assert_eq!(src.descriptor().bytes_per_pixel(), BPP);
631    let sbytes = src.as_strided_bytes();
632    let sstride = src.stride();
633    let sstep: isize = if flip_sy {
634        -(sstride as isize)
635    } else {
636        sstride as isize
637    };
638    // Destination geometry (validated by `apply_orientation_into`).
639    let (ow, oh) = (h, w);
640
641    let mut ty = 0;
642    while ty < oh {
643        let ty_end = min(ty + TILE, oh);
644        let mut tx = 0;
645        while tx < ow {
646            let tx_end = min(tx + TILE, ow);
647            // Source row for the tile's first dst column (dx = tx); the
648            // offset then steps by `sstep` per dst pixel.
649            let sy0 = (if flip_sy { h - 1 - tx } else { tx }) as usize;
650            for dy in ty..ty_end {
651                let sx = (if flip_sx { w - 1 - dy } else { dy }) as usize;
652                let mut soff = (sy0 * sstride + sx * BPP) as isize;
653                let drow = &mut dst.row_mut(dy)[tx as usize * BPP..tx_end as usize * BPP];
654                for dpx in drow.chunks_exact_mut(BPP) {
655                    let s = soff as usize;
656                    let px: [u8; BPP] = sbytes[s..s + BPP].try_into().unwrap();
657                    dpx.copy_from_slice(&px);
658                    soff += sstep;
659                }
660            }
661            tx += TILE;
662        }
663        ty += TILE;
664    }
665}
666
667/// Cache-blocked scalar transpose for the four axis-swapping orientations. The
668/// per-element destination is `orientation.forward_map(sx, sy, w, h)`, which
669/// encodes transpose + whatever reflection the orientation adds; tiling keeps
670/// each block's scattered destination writes inside the cache. This is the
671/// portable path and the parity oracle for the SIMD kernel.
672fn transpose_blocked(
673    src: &PixelSlice<'_>,
674    dst: &mut PixelSliceMut<'_>,
675    orientation: Orientation,
676    w: u32,
677    h: u32,
678    bpp: usize,
679) {
680    let mut tile_y = 0;
681    while tile_y < h {
682        let y_end = min(tile_y + TILE, h);
683        let mut tile_x = 0;
684        while tile_x < w {
685            let x_end = min(tile_x + TILE, w);
686            for sy in tile_y..y_end {
687                let s = src.row(sy);
688                for sx in tile_x..x_end {
689                    scatter_pixel(s, dst, orientation, sx, sy, w, h, bpp);
690                }
691            }
692            tile_x += TILE;
693        }
694        tile_y += TILE;
695    }
696}
697
698// ── EXPERIMENTAL: staged fixed-micro-tile transpose (bench-gated) ────────────
699//
700// Hypothesis from the transpose-shootout (2026-06-12): a *fixed-size* staged
701// micro-tile written in plain safe Rust auto-vectorizes into shuffle networks
702// (the ejmahler `transpose` crate's 16×16 scalar block hit 22.5 GiB/s at 256²
703// RGBA, ~3× our explicit 4×4 SSE kernel, with only baseline SSE2 codegen).
704// Stage TH source rows of a tile into a fixed 2-D array (one bounds check per
705// row), then emit TW destination rows whose elements come from fixed array
706// indices — no per-element bounds checks, no `forward_map`, and a shape LLVM
707// can SLP-vectorize. The orientation's reflection folds into (a) reversed
708// fixed gather order inside the micro-tile (const-bool monomorphized) and
709// (b) the tile's destination base coordinates.
710//
711// Bench-only entry (`__bench_apply_orientation_staged`) until the shootout
712// proves which widths it wins; production dispatch is unchanged.
713
714/// One staged micro-tile: gather `T=16` source rows × `T=16` pixels into a
715/// fixed array, then write `T` destination rows from its columns.
716///
717/// Derived from the separable inverse maps (`inverse_flips`): destination row
718/// `dy0+c` reads the tile's source column `cc = c` (or `T-1-c` when `FLIP_C`,
719/// i.e. `flip_sx`), and its pixel `k` reads stage row `r = k` (or `T-1-k`
720/// when `FLIP_R`, i.e. `flip_sy`). Both indices are compile-time-shaped, so
721/// the inner loops are bounds-check-free and SLP-vectorizable.
722macro_rules! staged_micro {
723    ($name:ident, $bpp:literal) => {
724        #[cfg(feature = "__bench_orient")]
725        #[inline]
726        fn $name<const FLIP_R: bool, const FLIP_C: bool>(
727            sbytes: &[u8],
728            sstride: usize,
729            sx0: usize, // tile's first source column (pixels)
730            sy0: usize, // tile's first source row
731            dst: &mut PixelSliceMut<'_>,
732            dx0: usize, // tile's first dest column (pixels)
733            dy0: usize, // tile's first dest row
734        ) {
735            const T: usize = 16;
736            let mut stage = [[0u8; T * $bpp]; T];
737            for (r, row) in stage.iter_mut().enumerate() {
738                let off = (sy0 + r) * sstride + sx0 * $bpp;
739                row.copy_from_slice(&sbytes[off..off + T * $bpp]);
740            }
741            for c in 0..T {
742                let cc = if FLIP_C { T - 1 - c } else { c };
743                let drow = &mut dst.row_mut((dy0 + c) as u32)[dx0 * $bpp..(dx0 + T) * $bpp];
744                for (k, dpx) in drow.chunks_exact_mut($bpp).enumerate() {
745                    let r = if FLIP_R { T - 1 - k } else { k };
746                    dpx.copy_from_slice(&stage[r][cc * $bpp..cc * $bpp + $bpp]);
747                }
748            }
749        }
750    };
751}
752
753staged_micro!(staged_micro_1, 1);
754staged_micro!(staged_micro_2, 2);
755staged_micro!(staged_micro_3, 3);
756staged_micro!(staged_micro_4, 4);
757
758/// Staged-tile transpose for the four axis-swapping orientations, bpp ∈ 1..=4.
759/// Full 16×16 micro-tiles go through the staged kernel; edge remainders take
760/// the per-pixel `forward_map` scatter (identical to `transpose_edges`).
761#[cfg(feature = "__bench_orient")]
762fn transpose_staged(
763    src: &PixelSlice<'_>,
764    dst: &mut PixelSliceMut<'_>,
765    orientation: Orientation,
766    w: u32,
767    h: u32,
768    bpp: usize,
769) {
770    const T: u32 = 16;
771    let Some((flip_sx, flip_sy)) = inverse_flips(orientation) else {
772        return transpose_blocked(src, dst, orientation, w, h, bpp);
773    };
774    let sbytes = src.as_strided_bytes();
775    let sstride = src.stride();
776    let full_w = w & !(T - 1);
777    let full_h = h & !(T - 1);
778
779    let mut sy = 0;
780    while sy < full_h {
781        let mut sx = 0;
782        while sx < full_w {
783            // Destination tile base from the orientation's affine map:
784            // dst col base ← source rows [sy, sy+T), dst row base ← source
785            // cols [sx, sx+T); reflections pick the tile's far corner.
786            let dx0 = if flip_sy { h - T - sy } else { sy } as usize;
787            let dy0 = if flip_sx { w - T - sx } else { sx } as usize;
788            let (sx_eff, sy_eff) = (sx as usize, sy as usize);
789            // FLIP_R = flip_sy (reverses the stage-row gather inside a dst
790            // row), FLIP_C = flip_sx (flips which source column feeds dst
791            // row dy0+c). Monomorphised so the micro-tile stays branch-free.
792            macro_rules! call {
793                ($f:ident) => {
794                    match (flip_sy, flip_sx) {
795                        (false, false) => {
796                            $f::<false, false>(sbytes, sstride, sx_eff, sy_eff, dst, dx0, dy0)
797                        }
798                        (true, false) => {
799                            $f::<true, false>(sbytes, sstride, sx_eff, sy_eff, dst, dx0, dy0)
800                        }
801                        (false, true) => {
802                            $f::<false, true>(sbytes, sstride, sx_eff, sy_eff, dst, dx0, dy0)
803                        }
804                        (true, true) => {
805                            $f::<true, true>(sbytes, sstride, sx_eff, sy_eff, dst, dx0, dy0)
806                        }
807                    }
808                };
809            }
810            match bpp {
811                1 => call!(staged_micro_1),
812                2 => call!(staged_micro_2),
813                3 => call!(staged_micro_3),
814                4 => call!(staged_micro_4),
815                _ => unreachable!("staged path is dispatched for bpp 1..=4 only"),
816            }
817            sx += T;
818        }
819        sy += T;
820    }
821    transpose_edges(src, dst, orientation, w, h, bpp, full_w, full_h);
822}
823
824/// Bench-only handle for the staged experimental path (transposing
825/// orientations, bpp 1..=4; falls back to the blocked scatter otherwise).
826#[cfg(feature = "__bench_orient")]
827#[doc(hidden)]
828pub fn __bench_apply_orientation_staged(
829    src: PixelSlice<'_>,
830    orientation: Orientation,
831    mut dst: PixelSliceMut<'_>,
832) -> Result<(), ConvertError> {
833    let w = src.width();
834    let h = src.rows();
835    let bpp = src.descriptor().bytes_per_pixel();
836    let (ow, oh) = orientation.output_dimensions(w, h);
837    if dst.width() != ow || dst.rows() != oh || dst.descriptor().bytes_per_pixel() != bpp {
838        return Err(ConvertError::BufferSize {
839            expected: ow as usize * oh as usize * bpp,
840            actual: dst.width() as usize * dst.rows() as usize * dst.descriptor().bytes_per_pixel(),
841        });
842    }
843    if w == 0 || h == 0 || bpp == 0 {
844        return Ok(());
845    }
846    if (1..=4).contains(&bpp) {
847        transpose_staged(&src, &mut dst, orientation, w, h, bpp);
848    } else {
849        transpose_blocked(&src, &mut dst, orientation, w, h, bpp);
850    }
851    Ok(())
852}
853
854/// Scalar scatter for the edge strips a 4×4-tiled SIMD pass leaves uncovered:
855/// the right strip (`cols [full_w, w)`) and the bottom strip (`rows [full_h,
856/// h)`, which also covers the bottom-right corner). No overlap between strips.
857#[allow(clippy::too_many_arguments)] // edge-strip helper; mirrors the scatter-loop signature
858#[cfg(feature = "fast-transpose")]
859fn transpose_edges(
860    src: &PixelSlice<'_>,
861    dst: &mut PixelSliceMut<'_>,
862    orientation: Orientation,
863    w: u32,
864    h: u32,
865    bpp: usize,
866    full_w: u32,
867    full_h: u32,
868) {
869    for sy in 0..full_h {
870        let s = src.row(sy);
871        for sx in full_w..w {
872            scatter_pixel(s, dst, orientation, sx, sy, w, h, bpp);
873        }
874    }
875    for sy in full_h..h {
876        let s = src.row(sy);
877        for sx in 0..w {
878            scatter_pixel(s, dst, orientation, sx, sy, w, h, bpp);
879        }
880    }
881}
882
883// ── SIMD 4×4 register transpose (cross-arch, 4-byte pixels) ──────────────────
884
885/// Destination of transposed-tile row `r` (transposed row index 0..4) for a
886/// source 4×4 tile at `(bx, by)`, as `(dst_row, dst_col_start, reverse_lanes)`.
887/// Derived from `Orientation::forward_map`: a bare `Transpose` writes row `r` to
888/// `dst[bx+r][by..]`; the rotations/anti-diagonal add a row/col reflection.
889/// `by`/`bx` are multiples of 4 with `by+4 ≤ h`, `bx+4 ≤ w`, so the subtractions
890/// never underflow.
891#[cfg(feature = "fast-transpose")]
892#[inline]
893fn tile_dest(
894    orientation: Orientation,
895    bx: u32,
896    by: u32,
897    r: u32,
898    w: u32,
899    h: u32,
900) -> (u32, u32, bool) {
901    match orientation {
902        Orientation::Transpose => (bx + r, by, false),
903        Orientation::Rotate90 => (bx + r, h - 4 - by, true),
904        Orientation::Rotate270 => (w - 1 - bx - r, by, false),
905        Orientation::Transverse => (w - 1 - bx - r, h - 4 - by, true),
906        _ => unreachable!("tile_dest only handles the four transposing orientations"),
907    }
908}
909
910/// SIMD path: transpose full 4×4 tiles via `f32x4::transpose_4x4` (the classic
911/// `_MM_TRANSPOSE4_PS`-shaped shuffle cascade), scalar for the edges. The
912/// `#[magetypes]` attribute generates one variant per SIMD tier from this single
913/// body; `incant!` in [`do_transpose`] picks the best at runtime.
914///
915/// Each 4-byte pixel rides as one f32 lane. The transpose only *shuffles whole
916/// 32-bit lanes* (no float arithmetic), so the reinterpret is bit-exact for any
917/// 4-byte pixel format, including bit patterns that happen to be NaN.
918#[cfg(feature = "fast-transpose")]
919#[magetypes(v3, neon, wasm128, scalar)]
920fn transpose4_simd(
921    token: Token,
922    src: &PixelSlice<'_>,
923    dst: &mut PixelSliceMut<'_>,
924    orientation: Orientation,
925    w: u32,
926    h: u32,
927) {
928    #[allow(non_camel_case_types)]
929    type f32x4 = GenericF32x4<Token>;
930
931    let full_w = w & !3; // largest multiple of 4 ≤ w
932    let full_h = h & !3;
933
934    let mut by = 0;
935    while by < full_h {
936        let mut bx = 0;
937        while bx < full_w {
938            let xb = bx as usize * 4;
939            let f0: [f32; 4] =
940                bytemuck::cast::<[u8; 16], _>(src.row(by)[xb..xb + 16].try_into().unwrap());
941            let f1: [f32; 4] =
942                bytemuck::cast::<[u8; 16], _>(src.row(by + 1)[xb..xb + 16].try_into().unwrap());
943            let f2: [f32; 4] =
944                bytemuck::cast::<[u8; 16], _>(src.row(by + 2)[xb..xb + 16].try_into().unwrap());
945            let f3: [f32; 4] =
946                bytemuck::cast::<[u8; 16], _>(src.row(by + 3)[xb..xb + 16].try_into().unwrap());
947            let mut rows = [
948                f32x4::load(token, &f0),
949                f32x4::load(token, &f1),
950                f32x4::load(token, &f2),
951                f32x4::load(token, &f3),
952            ];
953            f32x4::transpose_4x4(&mut rows);
954
955            for r in 0..4u32 {
956                let mut lanes = [0f32; 4];
957                rows[r as usize].store(&mut lanes);
958                let (drow, dcol, rev) = tile_dest(orientation, bx, by, r, w, h);
959                if rev {
960                    lanes.reverse();
961                }
962                let bytes: [u8; 16] = bytemuck::cast(lanes);
963                let db = dcol as usize * 4;
964                dst.row_mut(drow)[db..db + 16].copy_from_slice(&bytes);
965            }
966            bx += 4;
967        }
968        by += 4;
969    }
970
971    transpose_edges(src, dst, orientation, w, h, 4, full_w, full_h);
972}
973
974// ── SIMD 3-byte (RGB8) transpose: expand 3→4, transpose u32 lanes, compress ──
975//
976// The only production-proven SIMD shape for 24-bit transposes (ermig1979/Simd;
977// no Rust crate has one): load four rows of four RGB pixels (16 B each, 12
978// payload + 4 slop), `pshufb`-expand 3→4 so each pixel rides one u32 lane,
979// transpose the 4×4 u32 block with the unpack cascade, `pshufb`-compress 4→3,
980// and store each 12-byte group as 8+4 bytes (no store slop → no clobber
981// hazard at row ends, and both store intrinsics have safe reference-taking
982// wrappers). The 16-byte *loads* are the only slop: safe for every tile band
983// except one ending on the image's final row, where the band's tile range is
984// narrowed so `offset+16` stays in bounds (the scalar edge pass covers the
985// rest). Value intrinsics are safe inside `#[arcane]` (Rust 1.87+); memory
986// ops come from `import_intrinsics`' safe wrappers — the crate keeps
987// `#![forbid(unsafe_code)]`.
988#[cfg(all(feature = "fast-transpose", target_arch = "x86_64"))]
989mod rgb3_x86;
990
991// ── SIMD 1/2/4-byte transposes: exact-width register cascades ────────────────
992//
993// Same construction as `rgb3_x86` (value intrinsics inside `#[arcane]`, safe
994// reference-taking memory wrappers, flat src/dst byte addressing, scalar
995// `forward_map` edges) but with NO load/store slop anywhere: tile rows are
996// exactly one register wide (8 px × 1 B = 8 B, 8 px × 2 B = 16 B, 4 px × 4 B =
997// 16 B, 8 px × 4 B = 32 B), so there is no last-row guard at all. Shapes are
998// the classic punpck cascades (AP-528 → libyuv lineage); the shootout showed
999// our previous paths losing 3-5× to exactly these kernel shapes in zune /
1000// fast_transpose / the C++ Simd library.
1001/// 16-byte pixels, all architectures: deep scalar tiles. A transpose at
1002/// this width is pure 16-byte block movement, and fixed-size 16-byte copies
1003/// autovectorize to full-width register moves on every supported target
1004/// (NEON is the aarch64 baseline, SSE2 the x86-64 baseline) — measured on
1005/// Neoverse-N1, this shape beats the hand-written NEON kernel (run depth
1006/// amortizes per-row bookkeeping 4×; N1 microcodes ld1/st1 multi-register
1007/// structure ops, so wide hand-SIMD has no edge to offer here).
1008fn transpose16_deep(
1009    src: &PixelSlice<'_>,
1010    dst: &mut PixelSliceMut<'_>,
1011    orientation: Orientation,
1012    w: u32,
1013    h: u32,
1014) {
1015    let Some((flip_sx, flip_sy)) = inverse_flips(orientation) else {
1016        return transpose_blocked(src, dst, orientation, w, h, 16);
1017    };
1018    let sbytes = src.as_strided_bytes();
1019    let sstride = src.stride();
1020    let dstride = dst.stride();
1021    let dbytes = dst.as_strided_bytes_mut();
1022
1023    const T: usize = 16;
1024    let full_w = w & !15;
1025    let full_h = h & !15;
1026    let ntx = (full_w / 16) as usize;
1027    let nty = (full_h / 16) as usize;
1028    for tyi in 0..nty {
1029        let ty = if flip_sy { nty - 1 - tyi } else { tyi } * T;
1030        let dx = if flip_sy { h as usize - T - ty } else { ty };
1031        for txi in 0..ntx {
1032            let tx = (if flip_sx { ntx - 1 - txi } else { txi }) * T;
1033            // One bounds check per source row segment and per destination
1034            // row run; the inner fixed-size copies need none.
1035            let mut srows: [&[u8]; T] = [&[]; T];
1036            for (r, sr) in srows.iter_mut().enumerate() {
1037                let off = (ty + r) * sstride + tx * 16;
1038                *sr = &sbytes[off..off + T * 16];
1039            }
1040            for c in 0..T {
1041                let dy = if flip_sx {
1042                    w as usize - 1 - (tx + c)
1043                } else {
1044                    tx + c
1045                };
1046                let drow = &mut dbytes[dy * dstride + dx * 16..dy * dstride + (dx + T) * 16];
1047                for (k, chunk) in drow.chunks_exact_mut(16).enumerate() {
1048                    let r = if flip_sy { T - 1 - k } else { k };
1049                    chunk.copy_from_slice(&srows[r][c * 16..c * 16 + 16]);
1050                }
1051            }
1052        }
1053    }
1054    // Edges (right columns, bottom rows).
1055    let (sb, db) = (sbytes, dbytes);
1056    for y in 0..full_h {
1057        for x in full_w..w {
1058            let (dxx, dyy) = orientation.forward_map(x, y, w, h);
1059            let so = y as usize * sstride + x as usize * 16;
1060            let dofs = dyy as usize * dstride + dxx as usize * 16;
1061            db[dofs..dofs + 16].copy_from_slice(&sb[so..so + 16]);
1062        }
1063    }
1064    for y in full_h..h {
1065        for x in 0..w {
1066            let (dxx, dyy) = orientation.forward_map(x, y, w, h);
1067            let so = y as usize * sstride + x as usize * 16;
1068            let dofs = dyy as usize * dstride + dxx as usize * 16;
1069            db[dofs..dofs + 16].copy_from_slice(&sb[so..so + 16]);
1070        }
1071    }
1072}
1073
1074#[cfg(all(feature = "fast-transpose", target_arch = "x86_64"))]
1075mod pxn_x86;
1076
1077// ── NEON transposes (aarch64): vzip cascades + tbl expand/compress ──────────
1078//
1079// Same construction discipline as `pxn_x86`: value intrinsics inside
1080// `#[arcane]`, safe reference-taking loads/stores, flat strided bytes,
1081// scalar `forward_map` edges, slop-free stores (the 3/6/12-byte kernels'
1082// reads carry small guarded slop like their x86 siblings). Kernel networks
1083// for 1/2/4 bpp follow ermig1979/Simd's NEON tier (vzipq cascades); the
1084// 24-bit kernel reuses our expand→zip→compress shape instead of their
1085// vtbl variant, whose 16-byte stores overhang the 12-byte destination run.
1086#[cfg(all(feature = "fast-transpose", target_arch = "aarch64"))]
1087mod pxn_neon;
1088
1089#[cfg(test)]
1090mod tests;