Skip to content

Commit 317f72f

Browse files
committed
Add AVX2 encode prototype
1 parent 87291e9 commit 317f72f

6 files changed

Lines changed: 253 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- Added a real non-dispatchable AVX2 fixed-block encode prototype for Standard
6+
and URL-safe alphabets. The prototype remains test-only and active runtime
7+
backend selection remains scalar-only.
8+
- Added AVX2 SIMD equivalence coverage for patterned blocks, all 64 emitted
9+
six-bit Base64 values, and custom alphabets that must fall back to scalar
10+
encoding.
11+
- Updated the SIMD unsafe inventory for the AVX2 prototype, including staged
12+
stack-copy wiping and AVX/SSE cleanup with `vzeroupper`.
13+
314
## 1.1.0 - 2026-06-20
415

516
- Started the SIMD encode foundation line with a real SSSE3/SSE4.1 fixed-block

docs/SIMD.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,11 @@ runtime behavior for that line.
8282
registers before returning. It is tested against scalar output only when
8383
SSSE3/SSE4.1 is available and is not compiled into release library builds.
8484
- An inactive AVX2 fixed-block encode prototype exists behind the SIMD boundary
85-
as test-only scaffolding and is tested against scalar output only when AVX2
86-
is available. It currently zeroes the destination with SIMD and then
87-
overwrites the block with scalar encoding; this is scaffolding, not vectorized
88-
Base64 correctness evidence, and it is not compiled into release library
89-
builds.
85+
as real non-dispatchable vector encode evidence for Standard and URL-safe
86+
alphabets. It uses AVX2 lane-local byte shuffling, vector shifts/masks, and
87+
byte blending for fixed 24-byte input blocks, then clears XMM/YMM state
88+
before returning. It is tested against scalar output only when AVX2 is
89+
available and is not compiled into release library builds.
9090
- An inactive NEON fixed-block encode prototype exists behind the same boundary
9191
as test-only scaffolding and is tested against scalar output only on
9292
NEON-capable ARM targets. It currently zeroes the destination with SIMD and

docs/SIMD_ADMISSION.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ added to `ActiveBackend` or used by runtime dispatch:
4545
| Backend | State | Required CPU features | Evidence |
4646
| --- | --- | --- | --- |
4747
| AVX-512 VBMI | candidate only | `avx512f`, `avx512bw`, `avx512vl`, `avx512vbmi` | inactive prototype equivalence only |
48-
| AVX2 | candidate only | `avx2` | inactive prototype equivalence only |
48+
| AVX2 | candidate only | `avx2` | real fixed-block encode prototype for Standard and URL-safe alphabets; non-dispatchable |
4949
| SSSE3/SSE4.1 | candidate only | `ssse3`, `sse4.1` | real fixed-block encode prototype for Standard and URL-safe alphabets; non-dispatchable |
5050
| NEON | candidate only | `neon` | inactive prototype equivalence only |
5151
| wasm `simd128` | candidate only | `simd128` | compile-time candidate detection only |

docs/UNSAFE.md

Lines changed: 98 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -415,33 +415,117 @@ Purpose:
415415

416416
- Exercise AVX2 target-feature plumbing.
417417
- Validate the unsafe boundary.
418-
- Provide scalar-equivalence scaffolding before any real vector path is
419-
admitted. Current tests do not prove vectorized Base64 correctness.
418+
- Provide real fixed-block vector encode evidence for Standard and URL-safe
419+
alphabets before any runtime dispatch is admitted.
420420

421421
Preconditions:
422422

423423
- Caller must prove AVX2 is available on the current CPU.
424424
- Input is exactly 24 bytes.
425425
- Output is exactly 32 bytes.
426+
- The vectorized path is used only for Standard-family alphabets (`A-Z`,
427+
`a-z`, `0-9`, and either `+/` or `-_`). Other alphabets fall back to the
428+
scalar prototype loop.
426429

427430
Unsafe operation:
428431

429-
- `_mm256_storeu_si256` stores one 256-bit zero vector into the output buffer.
432+
- `_mm256_loadu_si256` loads from a local 32-byte staging array that contains
433+
two 12-byte input lanes plus four zero bytes per lane.
434+
- `_mm256_shuffle_epi8` reshapes each staged 128-bit lane into four 24-bit
435+
groups without reading from caller memory beyond the fixed input array.
436+
- AVX2 shifts, masks, and OR operations produce thirty-two 6-bit indices.
437+
- `encode_standard_family_indices_avx2` maps those indices to Standard or
438+
URL-safe alphabet bytes with AVX2 byte blends.
439+
- `_mm256_storeu_si256` stores the 32 encoded bytes into the output buffer.
440+
- `clear_ymm_registers_for_test_prototype` clears XMM lower halves and uses
441+
`vzeroupper` before return to reduce register retention and AVX/SSE
442+
transition state in this non-dispatchable test prototype.
443+
- The local staging array is wiped with the crate cleanup primitive before the
444+
function returns.
430445

431446
Safety argument:
432447

433-
- The output type is `&mut [u8; 32]`, so the store has enough initialized,
434-
writable memory.
435-
- The intrinsic is the unaligned store variant, so no stronger alignment is
436-
required.
448+
- The input and output array types provide fixed readable and writable bounds.
449+
- The SIMD load reads only from a local 32-byte staging array, so the prototype
450+
does not over-read the 24-byte caller input.
451+
- The staging array is mutable and wiped after the SIMD store and register
452+
cleanup, reducing stack retention of the copied caller bytes in this inactive
453+
prototype.
454+
- The load and store intrinsics are unaligned variants, so no stronger
455+
alignment is required.
437456
- The function is guarded by an AVX2 target-feature contract.
438-
- The prototype then overwrites the block with scalar-equivalent Base64 output.
439-
The SIMD zeroing is semantically overwritten and is not an implementation of
440-
vectorized Base64.
441-
- Register-retention note: this prototype does not load caller bytes into SIMD
442-
registers. Any future AVX2 implementation that does so must document and
443-
implement explicit cleanup for every secret-bearing YMM/XMM register before
444-
return, plus AVX transition cleanup such as `vzeroupper` where applicable.
457+
- The output length is fixed by the output array type.
458+
- The prototype remains test-only and non-dispatchable. Runtime acceleration is
459+
still blocked by the SIMD admission manifest.
460+
- Register-retention note: the prototype now loads caller bytes into YMM/XMM
461+
state. It calls `clear_ymm_registers_for_test_prototype` before return. This
462+
is retention reduction for the inactive prototype, not a formal
463+
microarchitectural side-channel proof.
464+
465+
### `encode_standard_family_indices_avx2`
466+
467+
Location: `src/simd/x86.rs`
468+
469+
Status: private helper for the inactive AVX2 test-only prototype, not compiled
470+
into release library builds and not dispatchable.
471+
472+
Purpose:
473+
474+
- Map thirty-two 6-bit indices to Standard or URL-safe alphabet bytes with AVX2
475+
blends instead of scalar per-byte table indexing.
476+
477+
Preconditions:
478+
479+
- Caller must prove AVX2 is available on the current CPU.
480+
- `indices` contains only byte values in `0..=63`.
481+
- The alphabet must be Standard-family as checked by the caller with a complete
482+
comparison of positions `0..62` and an explicit check of the two terminal
483+
symbols.
484+
485+
Unsafe operation:
486+
487+
- AVX2 byte comparisons and blends compute the ASCII offset for each index.
488+
489+
Safety argument:
490+
491+
- The helper does not dereference raw pointers or access memory.
492+
- The target-feature contract enables the required AVX2 instructions.
493+
- The caller constructs `indices` with masks that constrain every byte to a
494+
six-bit Base64 value.
495+
- The helper is private to the test-only prototype path.
496+
497+
### `clear_ymm_registers_for_test_prototype`
498+
499+
Location: `src/simd/x86.rs`
500+
501+
Status: private helper for inactive AVX2 test-only prototypes, not compiled
502+
into release library builds and not dispatchable.
503+
504+
Purpose:
505+
506+
- Clear lower XMM state and upper YMM state before returning from the AVX2
507+
prototype path that processes caller bytes in vector registers.
508+
509+
Preconditions:
510+
511+
- Called only after the prototype has stored its output and no later AVX/SSE
512+
value is needed by the function.
513+
514+
Unsafe operation:
515+
516+
- Calls `clear_xmm_registers_for_test_prototype` for lower XMM register state.
517+
- Inline assembly emits `vzeroupper` to clear upper YMM state and avoid
518+
carrying AVX upper halves back to scalar/SSE code.
519+
520+
Safety argument:
521+
522+
- The helper does not read or write memory.
523+
- The helper runs at the end of the inactive prototype path.
524+
- `vzeroupper` is valid under the AVX2 target-feature precondition inherited
525+
from the caller.
526+
- This is best-effort register-retention reduction for test evidence, not a
527+
guarantee that historical register, stack, cache, or microarchitectural
528+
copies do not exist.
445529

446530
### `encode_12_bytes_ssse3_sse41`
447531

src/simd/tests.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,22 @@ fn fill_indices_pattern(output: &mut [u8; 12], seed: u8) {
3838
}
3939
}
4040

41+
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
42+
fn fill_indices_pattern_wide(output: &mut [u8; 24], seed: u8) {
43+
let mut write = 0;
44+
for group in 0..8 {
45+
let i0 = seed.wrapping_add(group * 4) & 0x3f;
46+
let i1 = seed.wrapping_add(group * 4 + 1) & 0x3f;
47+
let i2 = seed.wrapping_add(group * 4 + 2) & 0x3f;
48+
let i3 = seed.wrapping_add(group * 4 + 3) & 0x3f;
49+
50+
output[write] = (i0 << 2) | (i1 >> 4);
51+
output[write + 1] = (i1 << 4) | (i2 >> 2);
52+
output[write + 2] = (i2 << 6) | i3;
53+
write += 3;
54+
}
55+
}
56+
4157
#[cfg(all(feature = "std", any(target_arch = "x86", target_arch = "x86_64")))]
4258
#[test]
4359
fn avx512_encode_prototype_matches_scalar_when_available() {
@@ -119,6 +135,50 @@ fn avx2_encode_prototype_matches_scalar_when_available() {
119135
assert_eq!(scalar_len, avx2_url_safe.len());
120136
assert_eq!(avx2_url_safe, scalar_url_safe);
121137
}
138+
139+
for seed in 0..64 {
140+
fill_indices_pattern_wide(&mut input, seed);
141+
142+
let mut avx2_standard = [0x55; 32];
143+
let mut scalar_standard = [0xaa; 32];
144+
// SAFETY: The feature check above proves AVX2 availability for this
145+
// test invocation.
146+
unsafe {
147+
encode_24_bytes_avx2::<Standard>(&input, &mut avx2_standard);
148+
}
149+
let scalar_len = Engine::<Standard, true>::new()
150+
.encode_slice(&input, &mut scalar_standard)
151+
.unwrap();
152+
assert_eq!(scalar_len, avx2_standard.len());
153+
assert_eq!(avx2_standard, scalar_standard);
154+
155+
let mut avx2_url_safe = [0x55; 32];
156+
let mut scalar_url_safe = [0xaa; 32];
157+
// SAFETY: The feature check above proves AVX2 availability for this
158+
// test invocation.
159+
unsafe {
160+
encode_24_bytes_avx2::<UrlSafe>(&input, &mut avx2_url_safe);
161+
}
162+
let scalar_len = Engine::<UrlSafe, true>::new()
163+
.encode_slice(&input, &mut scalar_url_safe)
164+
.unwrap();
165+
assert_eq!(scalar_len, avx2_url_safe.len());
166+
assert_eq!(avx2_url_safe, scalar_url_safe);
167+
}
168+
169+
fill_indices_pattern_wide(&mut input, 0);
170+
let mut avx2_custom = [0x55; 32];
171+
let mut scalar_custom = [0xaa; 32];
172+
// SAFETY: The feature check above proves AVX2 availability for this test
173+
// invocation.
174+
unsafe {
175+
encode_24_bytes_avx2::<AnchorMatchingCustom>(&input, &mut avx2_custom);
176+
}
177+
let scalar_len = Engine::<AnchorMatchingCustom, true>::new()
178+
.encode_slice(&input, &mut scalar_custom)
179+
.unwrap();
180+
assert_eq!(scalar_len, avx2_custom.len());
181+
assert_eq!(avx2_custom, scalar_custom);
122182
}
123183

124184
#[cfg(all(feature = "std", any(target_arch = "x86", target_arch = "x86_64")))]

src/simd/x86.rs

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,21 @@ use crate::{Alphabet, encode_base64_value};
66
use core::arch::x86::{
77
__m128i, __m256i, __m512i, _mm_add_epi8, _mm_and_si128, _mm_blendv_epi8, _mm_cmpeq_epi8,
88
_mm_cmpgt_epi8, _mm_loadu_si128, _mm_or_si128, _mm_set1_epi8, _mm_set1_epi32, _mm_setr_epi8,
9-
_mm_shuffle_epi8, _mm_slli_epi32, _mm_srli_epi32, _mm_storeu_si128, _mm256_setzero_si256,
10-
_mm256_storeu_si256, _mm512_setzero_si512, _mm512_storeu_si512,
9+
_mm_shuffle_epi8, _mm_slli_epi32, _mm_srli_epi32, _mm_storeu_si128, _mm256_add_epi8,
10+
_mm256_and_si256, _mm256_blendv_epi8, _mm256_cmpeq_epi8, _mm256_cmpgt_epi8, _mm256_loadu_si256,
11+
_mm256_or_si256, _mm256_set1_epi8, _mm256_set1_epi32, _mm256_setr_epi8, _mm256_shuffle_epi8,
12+
_mm256_slli_epi32, _mm256_srli_epi32, _mm256_storeu_si256, _mm512_setzero_si512,
13+
_mm512_storeu_si512,
1114
};
1215
#[cfg(target_arch = "x86_64")]
1316
use core::arch::x86_64::{
1417
__m128i, __m256i, __m512i, _mm_add_epi8, _mm_and_si128, _mm_blendv_epi8, _mm_cmpeq_epi8,
1518
_mm_cmpgt_epi8, _mm_loadu_si128, _mm_or_si128, _mm_set1_epi8, _mm_set1_epi32, _mm_setr_epi8,
16-
_mm_shuffle_epi8, _mm_slli_epi32, _mm_srli_epi32, _mm_storeu_si128, _mm256_setzero_si256,
17-
_mm256_storeu_si256, _mm512_setzero_si512, _mm512_storeu_si512,
19+
_mm_shuffle_epi8, _mm_slli_epi32, _mm_srli_epi32, _mm_storeu_si128, _mm256_add_epi8,
20+
_mm256_and_si256, _mm256_blendv_epi8, _mm256_cmpeq_epi8, _mm256_cmpgt_epi8, _mm256_loadu_si256,
21+
_mm256_or_si256, _mm256_set1_epi8, _mm256_set1_epi32, _mm256_setr_epi8, _mm256_shuffle_epi8,
22+
_mm256_slli_epi32, _mm256_srli_epi32, _mm256_storeu_si256, _mm512_setzero_si512,
23+
_mm512_storeu_si512,
1824
};
1925

2026
#[allow(dead_code, reason = "inactive prototype is not dispatchable yet")]
@@ -49,15 +55,44 @@ pub(crate) unsafe fn encode_24_bytes_avx2<A>(input: &[u8; 24], output: &mut [u8;
4955
where
5056
A: Alphabet,
5157
{
52-
let zeros = _mm256_setzero_si256();
53-
// SAFETY: `output` is a valid 32-byte mutable array. AVX2 is guaranteed by
54-
// this function's target feature precondition, and the unaligned store does
55-
// not require any stronger pointer alignment.
56-
unsafe {
57-
_mm256_storeu_si256(output.as_mut_ptr().cast::<__m256i>(), zeros);
58+
if !is_standard_or_url_safe_family::<A>() {
59+
scalar_encode_block::<A, 24, 32>(input, output);
60+
return;
5861
}
5962

60-
scalar_encode_block::<A, 24, 32>(input, output);
63+
let mut staged = [
64+
input[0], input[1], input[2], input[3], input[4], input[5], input[6], input[7], input[8],
65+
input[9], input[10], input[11], 0, 0, 0, 0, input[12], input[13], input[14], input[15],
66+
input[16], input[17], input[18], input[19], input[20], input[21], input[22], input[23], 0,
67+
0, 0, 0,
68+
];
69+
70+
// SAFETY: `staged` and `output` are valid 32-byte arrays. The function's
71+
// target-feature contract enables AVX2. The load and store are unaligned
72+
// variants, and the shuffle mask uses `0x80` lanes only for zero-filled
73+
// bytes. The SIMD path is non-dispatchable test evidence.
74+
unsafe {
75+
let input_vec = _mm256_loadu_si256(staged.as_ptr().cast::<__m256i>());
76+
let shuffle = _mm256_setr_epi8(
77+
2, 1, 0, -128, 5, 4, 3, -128, 8, 7, 6, -128, 11, 10, 9, -128, 2, 1, 0, -128, 5, 4, 3,
78+
-128, 8, 7, 6, -128, 11, 10, 9, -128,
79+
);
80+
let lanes = _mm256_shuffle_epi8(input_vec, shuffle);
81+
82+
let index0 = _mm256_and_si256(_mm256_srli_epi32(lanes, 18), _mm256_set1_epi32(0x0000_003f));
83+
let index1 = _mm256_and_si256(_mm256_srli_epi32(lanes, 4), _mm256_set1_epi32(0x0000_3f00));
84+
let index2 = _mm256_and_si256(_mm256_slli_epi32(lanes, 10), _mm256_set1_epi32(0x003f_0000));
85+
let index3 = _mm256_and_si256(_mm256_slli_epi32(lanes, 24), _mm256_set1_epi32(0x3f00_0000));
86+
let indices = _mm256_or_si256(
87+
_mm256_or_si256(index0, index1),
88+
_mm256_or_si256(index2, index3),
89+
);
90+
91+
let encoded = encode_standard_family_indices_avx2::<A>(indices);
92+
_mm256_storeu_si256(output.as_mut_ptr().cast::<__m256i>(), encoded);
93+
clear_ymm_registers_for_test_prototype();
94+
}
95+
crate::wipe_bytes(&mut staged);
6196
}
6297

6398
#[allow(dead_code, reason = "inactive prototype is not dispatchable yet")]
@@ -166,6 +201,38 @@ where
166201
_mm_add_epi8(indices, offset)
167202
}
168203

204+
#[target_feature(enable = "avx2")]
205+
unsafe fn encode_standard_family_indices_avx2<A>(indices: __m256i) -> __m256i
206+
where
207+
A: Alphabet,
208+
{
209+
let offset62 = if A::ENCODE[62] == b'-' { -17 } else { -19 };
210+
let offset63 = if A::ENCODE[63] == b'_' { 32 } else { -16 };
211+
212+
let lt26 = _mm256_cmpgt_epi8(_mm256_set1_epi8(26), indices);
213+
let lt52 = _mm256_cmpgt_epi8(_mm256_set1_epi8(52), indices);
214+
let lt62 = _mm256_cmpgt_epi8(_mm256_set1_epi8(62), indices);
215+
let eq62 = _mm256_cmpeq_epi8(_mm256_set1_epi8(62), indices);
216+
217+
let mut offset = _mm256_set1_epi8(offset63);
218+
offset = _mm256_blendv_epi8(offset, _mm256_set1_epi8(offset62), eq62);
219+
offset = _mm256_blendv_epi8(offset, _mm256_set1_epi8(-4), lt62);
220+
offset = _mm256_blendv_epi8(offset, _mm256_set1_epi8(71), lt52);
221+
offset = _mm256_blendv_epi8(offset, _mm256_set1_epi8(65), lt26);
222+
223+
_mm256_add_epi8(indices, offset)
224+
}
225+
226+
unsafe fn clear_ymm_registers_for_test_prototype() {
227+
// SAFETY: The helper runs after the AVX2 prototype stores its output. The
228+
// XMM cleanup zeroes the lower halves declared to the compiler, and
229+
// `vzeroupper` clears upper YMM state before returning to scalar code.
230+
unsafe {
231+
clear_xmm_registers_for_test_prototype();
232+
core::arch::asm!("vzeroupper", options(nostack, preserves_flags, nomem));
233+
}
234+
}
235+
169236
#[cfg(target_arch = "x86")]
170237
unsafe fn clear_xmm_registers_for_test_prototype() {
171238
// SAFETY: This test-only cleanup runs after the prototype stores its

0 commit comments

Comments
 (0)