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;