Skip to content

Commit 564d62f

Browse files
committed
simd_crypto: AVX-512 chacha20_keystream backend + consumer re-export
Add the AVX-512 backend of the ChaCha20 ARX keystream and the runtime dispatcher the `encryption` AEAD hot path will draw from: - `chacha20_keystream(key, counter, nonce, &mut [[u8;64]])` — fills N keystream blocks, dispatching the AVX-512 backend in 16-block strides when `avx512f` is detected at runtime, scalar reference for the ragged tail and every non-AVX-512 target. - `chacha20_block16_avx512` — 16 consecutive blocks in parallel, word- sliced ("vertical") layout so every round is pure SIMD add/xor/rotate (`_mm512_add_epi32` / `_mm512_xor_si512` / `_mm512_rol_epi32`), no cross-lane shuffles; only the final LE serialization transposes. - Re-export `chacha20_{block,keystream,state}` through `ndarray::simd::*` so consumers pull from the polyfill surface per the W1a contract. Correctness gate (mandatory for SIMD crypto): two new tests pin the dispatcher byte-for-byte to the scalar RFC 8439 reference — `chacha20_keystream_dispatch_parity_scalar` (n=40 spans two AVX-512 strides + an 8-block scalar tail) and `chacha20_keystream_dispatch_ matches_rfc8439_kat` (drives the RFC §2.3.2 vector THROUGH the AVX-512 path when the host CPU has it). ChaCha20 is ARX (no secret-dependent branch/index) so the vectorization is constant-time by construction. 4 tests green, clippy -D warnings clean, fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6pT32kk6pnuAAqR3JiYqu
1 parent 7923c17 commit 564d62f

2 files changed

Lines changed: 187 additions & 16 deletions

File tree

src/simd.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,13 @@ pub use crate::simd_ops::{
674674
sub_f32_inplace,
675675
};
676676

677+
// ChaCha20 ARX keystream — the crypto hot path the `encryption` AEAD draws from.
678+
// `chacha20_block` is the scalar reference (RFC 8439 KAT); `chacha20_keystream`
679+
// is the runtime-dispatched accelerated fill (AVX-512 16-block strides + scalar
680+
// tail), byte-parity-pinned to the reference. Consumers pull from
681+
// `ndarray::simd::*` per the W1a contract.
682+
pub use crate::simd_crypto::{chacha20_block, chacha20_keystream, chacha20_state};
683+
677684
// ============================================================================
678685
// Tests
679686
// ============================================================================

src/simd_crypto.rs

Lines changed: 180 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -114,32 +114,156 @@ pub fn chacha20_state(key: &[u8; 32], counter: u32, nonce: &[u8; 12]) -> [u32; 1
114114
s
115115
}
116116

117+
/// Fill `out` with successive ChaCha20 keystream blocks: block `b` uses block
118+
/// counter `counter.wrapping_add(b)`, key and nonce fixed (RFC 8439 CTR mode).
119+
///
120+
/// This is the accelerated entry point the `encryption` AEAD hot path draws its
121+
/// keystream from. It runs the **AVX-512 backend in 16-block strides** when
122+
/// `avx512f` is detected at runtime, and the scalar [`chacha20_block`] reference
123+
/// for the ragged tail and on every non-x86 / non-AVX-512 target. Every backend
124+
/// is a drop-in for the scalar reference — see the module KAT and the
125+
/// `chacha20_keystream_avx512_parity` test that pins byte-for-byte equality.
126+
///
127+
/// **Constant-time:** all backends are straight-line ARX (add / xor / rotate),
128+
/// no secret-dependent control flow or memory indexing.
129+
pub fn chacha20_keystream(key: &[u8; 32], counter: u32, nonce: &[u8; 12], out: &mut [[u8; 64]]) {
130+
let mut i = 0usize;
131+
132+
#[cfg(target_arch = "x86_64")]
133+
{
134+
if std::is_x86_feature_detected!("avx512f") {
135+
while i + 16 <= out.len() {
136+
let state = chacha20_state(key, counter.wrapping_add(i as u32), nonce);
137+
// SAFETY: this arm is only reached after `is_x86_feature_detected!("avx512f")`
138+
// returned true, so every AVX-512F intrinsic in `chacha20_block16_avx512`
139+
// is supported on this CPU. The function reads only its `&state` argument
140+
// and returns an owned array — no raw pointers escape, no aliasing.
141+
let blocks = unsafe { chacha20_block16_avx512(&state) };
142+
out[i..i + 16].copy_from_slice(&blocks);
143+
i += 16;
144+
}
145+
}
146+
}
147+
148+
// Scalar reference: the ragged tail, and the whole slice on non-AVX-512 targets.
149+
while i < out.len() {
150+
let state = chacha20_state(key, counter.wrapping_add(i as u32), nonce);
151+
out[i] = chacha20_block(&state);
152+
i += 1;
153+
}
154+
}
155+
156+
/// AVX-512 backend: compute 16 consecutive ChaCha20 keystream blocks in parallel
157+
/// (block `l` uses `state[12].wrapping_add(l)` as its counter, all other words
158+
/// shared). Word-sliced ("vertical") layout — lane `l` of working vector `w`
159+
/// holds word `w` of block `l` — so every round is pure SIMD add/xor/rotate with
160+
/// no cross-lane shuffles; the only transpose is the final little-endian
161+
/// serialization.
162+
///
163+
/// # Safety
164+
/// Caller must ensure the CPU supports AVX-512F (guarded by
165+
/// `is_x86_feature_detected!("avx512f")` at the single call site in
166+
/// [`chacha20_keystream`]).
167+
#[cfg(target_arch = "x86_64")]
168+
#[target_feature(enable = "avx512f")]
169+
unsafe fn chacha20_block16_avx512(state: &[u32; 16]) -> [[u8; 64]; 16] {
170+
use core::arch::x86_64::*;
171+
172+
// One AVX-512 vertical quarter-round: same word indices as the scalar
173+
// `quarter_round`, applied across all 16 blocks at once. Pure ARX.
174+
macro_rules! qr512 {
175+
($v:expr, $a:expr, $b:expr, $c:expr, $d:expr) => {{
176+
$v[$a] = _mm512_add_epi32($v[$a], $v[$b]);
177+
$v[$d] = _mm512_rol_epi32::<16>(_mm512_xor_si512($v[$d], $v[$a]));
178+
$v[$c] = _mm512_add_epi32($v[$c], $v[$d]);
179+
$v[$b] = _mm512_rol_epi32::<12>(_mm512_xor_si512($v[$b], $v[$c]));
180+
$v[$a] = _mm512_add_epi32($v[$a], $v[$b]);
181+
$v[$d] = _mm512_rol_epi32::<8>(_mm512_xor_si512($v[$d], $v[$a]));
182+
$v[$c] = _mm512_add_epi32($v[$c], $v[$d]);
183+
$v[$b] = _mm512_rol_epi32::<7>(_mm512_xor_si512($v[$b], $v[$c]));
184+
}};
185+
}
186+
187+
// Build the 16 initial working vectors. Every word is broadcast identically
188+
// across the 16 lanes EXCEPT the counter (word 12), which gets lane-index
189+
// 0..=15 added (u32 wrapping) — the 16 consecutive block counters.
190+
let lane_index = _mm512_setr_epi32(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15);
191+
let mut orig = [_mm512_setzero_si512(); 16];
192+
for (w, o) in orig.iter_mut().enumerate() {
193+
*o = _mm512_set1_epi32(state[w] as i32);
194+
}
195+
orig[12] = _mm512_add_epi32(_mm512_set1_epi32(state[12] as i32), lane_index);
196+
197+
let mut v = orig;
198+
// 10 double-rounds = 20 rounds, identical schedule to the scalar reference.
199+
for _ in 0..10 {
200+
qr512!(v, 0, 4, 8, 12);
201+
qr512!(v, 1, 5, 9, 13);
202+
qr512!(v, 2, 6, 10, 14);
203+
qr512!(v, 3, 7, 11, 15);
204+
qr512!(v, 0, 5, 10, 15);
205+
qr512!(v, 1, 6, 11, 12);
206+
qr512!(v, 2, 7, 8, 13);
207+
qr512!(v, 3, 4, 9, 14);
208+
}
209+
210+
// Add the original input state back (RFC 8439 §2.3.1), then de-vectorize:
211+
// `words[w][l]` = final word `w` of block `l`.
212+
let mut words = [[0u32; 16]; 16];
213+
for (w, dst) in words.iter_mut().enumerate() {
214+
let summed = _mm512_add_epi32(v[w], orig[w]);
215+
let mut tmp = [0i32; 16];
216+
// SAFETY: `tmp` is a 16-element (64-byte) i32 array; `_mm512_storeu_si512`
217+
// writes exactly one 512-bit (64-byte) vector, unaligned, in bounds.
218+
_mm512_storeu_si512(tmp.as_mut_ptr().cast(), summed);
219+
for (l, slot) in dst.iter_mut().enumerate() {
220+
*slot = tmp[l] as u32;
221+
}
222+
}
223+
224+
// Serialize each block little-endian: block `l`, word `w` → bytes 4w..4w+4.
225+
let mut out = [[0u8; 64]; 16];
226+
for (l, block) in out.iter_mut().enumerate() {
227+
for w in 0..16 {
228+
block[w * 4..w * 4 + 4].copy_from_slice(&words[w][l].to_le_bytes());
229+
}
230+
}
231+
out
232+
}
233+
117234
#[cfg(test)]
118235
mod tests {
119236
use super::*;
120237

121-
/// RFC 8439 §2.3.2 Known-Answer-Test: the canonical ChaCha20 block. Key =
122-
/// `00,01,…,1f`, block counter = 1, nonce = `00 00 00 09 00 00 00 4a 00 00
123-
/// 00 00`. This pins the scalar reference; every SIMD backend added later
124-
/// MUST reproduce this exact 64-byte keystream (the parity gate).
125-
#[test]
126-
fn chacha20_block_rfc8439_kat() {
238+
/// The RFC 8439 §2.3.2 canonical keystream block (key = `00,01,…,1f`, block
239+
/// counter = 1, nonce = `00 00 00 09 00 00 00 4a 00 00 00 00`). The scalar
240+
/// reference and every SIMD backend MUST reproduce this exact 64-byte block.
241+
const RFC8439_KAT_BLOCK: [u8; 64] = [
242+
0x10, 0xf1, 0xe7, 0xe4, 0xd1, 0x3b, 0x59, 0x15, 0x50, 0x0f, 0xdd, 0x1f, 0xa3, 0x20, 0x71, 0xc4, 0xc7, 0xd1,
243+
0xf4, 0xc7, 0x33, 0xc0, 0x68, 0x03, 0x04, 0x22, 0xaa, 0x9a, 0xc3, 0xd4, 0x6c, 0x4e, 0xd2, 0x82, 0x64, 0x46,
244+
0x07, 0x9f, 0xaa, 0x09, 0x14, 0xc2, 0xd7, 0x05, 0xd9, 0x8b, 0x02, 0xa2, 0xb5, 0x12, 0x9c, 0xd1, 0xde, 0x16,
245+
0x4e, 0xb9, 0xcb, 0xd0, 0x83, 0xe8, 0xa2, 0x50, 0x3c, 0x4e,
246+
];
247+
248+
/// The RFC 8439 §2.3.2 key material (key `00..1f`, counter 1, the §2.3.2 nonce).
249+
fn rfc8439_kat_inputs() -> ([u8; 32], u32, [u8; 12]) {
127250
let mut key = [0u8; 32];
128251
for (i, b) in key.iter_mut().enumerate() {
129252
*b = i as u8;
130253
}
131254
let nonce: [u8; 12] = [0x00, 0x00, 0x00, 0x09, 0x00, 0x00, 0x00, 0x4a, 0x00, 0x00, 0x00, 0x00];
132-
let state = chacha20_state(&key, 1, &nonce);
133-
let block = chacha20_block(&state);
255+
(key, 1, nonce)
256+
}
134257

135-
// RFC 8439 §2.3.2 serialized keystream (64 bytes).
136-
let expected: [u8; 64] = [
137-
0x10, 0xf1, 0xe7, 0xe4, 0xd1, 0x3b, 0x59, 0x15, 0x50, 0x0f, 0xdd, 0x1f, 0xa3, 0x20, 0x71, 0xc4, 0xc7, 0xd1,
138-
0xf4, 0xc7, 0x33, 0xc0, 0x68, 0x03, 0x04, 0x22, 0xaa, 0x9a, 0xc3, 0xd4, 0x6c, 0x4e, 0xd2, 0x82, 0x64, 0x46,
139-
0x07, 0x9f, 0xaa, 0x09, 0x14, 0xc2, 0xd7, 0x05, 0xd9, 0x8b, 0x02, 0xa2, 0xb5, 0x12, 0x9c, 0xd1, 0xde, 0x16,
140-
0x4e, 0xb9, 0xcb, 0xd0, 0x83, 0xe8, 0xa2, 0x50, 0x3c, 0x4e,
141-
];
142-
assert_eq!(block, expected, "ChaCha20 block must match RFC 8439 §2.3.2");
258+
/// RFC 8439 §2.3.2 Known-Answer-Test on the scalar reference block. This pins
259+
/// the scalar reference; every SIMD backend added later MUST reproduce this
260+
/// exact 64-byte keystream (the parity gate).
261+
#[test]
262+
fn chacha20_block_rfc8439_kat() {
263+
let (key, counter, nonce) = rfc8439_kat_inputs();
264+
let state = chacha20_state(&key, counter, &nonce);
265+
let block = chacha20_block(&state);
266+
assert_eq!(block, RFC8439_KAT_BLOCK, "ChaCha20 block must match RFC 8439 §2.3.2");
143267
}
144268

145269
/// The quarter-round test vector (RFC 8439 §2.1.1) — the ARX core in
@@ -160,4 +284,44 @@ mod tests {
160284
"quarter-round must match RFC 8439 §2.1.1"
161285
);
162286
}
287+
288+
/// The mandatory SIMD-crypto correctness gate: every block produced by the
289+
/// `chacha20_keystream` dispatcher (AVX-512 backend when `avx512f` is present,
290+
/// scalar tail always) MUST be byte-identical to the scalar
291+
/// [`chacha20_block`] reference for the same key/counter/nonce. `n = 40`
292+
/// spans two full 16-block AVX-512 strides plus an 8-block scalar tail, so
293+
/// both the vectorized body and the ragged-tail path are exercised. If this
294+
/// test is red, no SIMD crypto backend may be committed.
295+
#[test]
296+
fn chacha20_keystream_dispatch_parity_scalar() {
297+
let mut key = [0u8; 32];
298+
for (i, b) in key.iter_mut().enumerate() {
299+
*b = (i as u8).wrapping_mul(7).wrapping_add(3);
300+
}
301+
let nonce: [u8; 12] = [0xde, 0xad, 0xbe, 0xef, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77];
302+
let base = 0x0102_0304u32;
303+
let n = 40;
304+
305+
let mut ks = vec![[0u8; 64]; n];
306+
chacha20_keystream(&key, base, &nonce, &mut ks);
307+
308+
for (b, got) in ks.iter().enumerate() {
309+
let state = chacha20_state(&key, base.wrapping_add(b as u32), &nonce);
310+
let want = chacha20_block(&state);
311+
assert_eq!(*got, want, "keystream block {b} must equal the scalar reference");
312+
}
313+
}
314+
315+
/// The dispatcher must reproduce the RFC 8439 §2.3.2 KAT at block 0. When the
316+
/// host CPU has AVX-512F this drives the RFC vector THROUGH the AVX-512
317+
/// backend (16 blocks per stride, block 0 uses counter 1), directly pinning
318+
/// the vectorized path to the published answer — not just to the scalar path.
319+
#[test]
320+
fn chacha20_keystream_dispatch_matches_rfc8439_kat() {
321+
let (key, counter, nonce) = rfc8439_kat_inputs();
322+
// 16 blocks forces the AVX-512 stride when available; block 0 == the KAT.
323+
let mut ks = vec![[0u8; 64]; 16];
324+
chacha20_keystream(&key, counter, &nonce, &mut ks);
325+
assert_eq!(ks[0], RFC8439_KAT_BLOCK, "dispatcher block 0 must match RFC 8439 §2.3.2");
326+
}
163327
}

0 commit comments

Comments
 (0)