Skip to content

Commit 5845ab2

Browse files
committed
feat(hpc/bulk): bulk_apply + bulk_scan chunked traversal helpers (W4)
New module src/hpc/bulk.rs implementing chunked AoS traversal per .claude/knowledge/w3-w6-soa-aos-design.md v2 (commit 10151d7). bulk_apply<T, F: FnMut(&mut [T], usize)>: chunks &mut [T] via chunks_mut(chunk_size) and invokes the closure with each chunk plus its absolute starting index. Useful for cache-blocked traversal and for SoA-staging via composition with aos_to_soa inside the closure. bulk_scan: read-only sibling with the same chunking semantics. Both panic on chunk_size == 0. Scalar wrappers — no #[target_feature], no simd_{type}.rs imports. Out of scope: distance metrics (see .claude/knowledge/cognitive-distance-typing.md). The aos_to_soa composition integration test is gated behind cfg(any()) with a TODO note: Worker A's src/hpc/soa.rs has not yet landed on this branch base (ab20d11). When it does, drop the gate to enable the test.
1 parent 5095853 commit 5845ab2

2 files changed

Lines changed: 326 additions & 0 deletions

File tree

src/hpc/bulk.rs

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
//! Bulk traversal helpers for AoS slices.
2+
//!
3+
//! [`bulk_apply`] chunks a `&mut [T]` and invokes a closure with each chunk
4+
//! plus its starting index. Useful when you want predictable cache behavior
5+
//! (chunk_size matched to L1 working-set) or when staging chunks to SoA for
6+
//! SIMD processing inside the closure.
7+
//!
8+
//! [`bulk_scan`] is the read-only sibling for non-mutating traversal.
9+
//!
10+
//! Both helpers are scalar wrappers — no `#[target_feature]`, no per-arch
11+
//! dispatch. They are user-level code per the layering rule in
12+
//! `.claude/knowledge/vertical-simd-consumer-contract.md`: only the dispatch
13+
//! layer (`crate::simd`, `crate::simd_ops`) and per-tier impls
14+
//! (`simd_avx512.rs`, `simd_avx2.rs`, `simd_neon.rs`) may carry SIMD
15+
//! attributes. The public API here is forward-compatible: a future
16+
//! bench-justified wave can swap in SIMD-accelerated chunk iteration via
17+
//! the dispatch layer without breaking callers.
18+
//!
19+
//! # Composition with SoA staging
20+
//!
21+
//! `bulk_apply` composes naturally with `crate::hpc::soa::aos_to_soa` inside
22+
//! the closure body when the caller wants per-chunk SoA staging (e.g. for
23+
//! cache-blocked SIMD-style loops on each field):
24+
//!
25+
//! ```ignore
26+
//! use ndarray::hpc::bulk::bulk_apply;
27+
//! use ndarray::hpc::soa::aos_to_soa;
28+
//! struct Item { a: f32, b: f32, c: f32 }
29+
//! let mut items: Vec<Item> = (0..100)
30+
//! .map(|i| Item { a: i as f32, b: (i * 2) as f32, c: (i * 3) as f32 })
31+
//! .collect();
32+
//! bulk_apply(&mut items, 16, |chunk, _start| {
33+
//! let soa = aos_to_soa::<_, 3, _>(chunk, |it| [it.a, it.b, it.c]);
34+
//! // ... per-field SIMD-style loops over soa.field(0), soa.field(1), ...
35+
//! let _ = soa;
36+
//! });
37+
//! ```
38+
//!
39+
//! # Out of scope — distance metrics
40+
//!
41+
//! These helpers stay generic over `T`. They MUST NOT grow toward distance
42+
//! computation (no `bulk_distance<T>` umbrella, no `enum DistanceMetric`).
43+
//! Distance metrics in this codebase are typed — one named fn per metric.
44+
//! See `.claude/knowledge/cognitive-distance-typing.md` for the binding rule.
45+
46+
/// Apply `f` to consecutive chunks of `items`. Each invocation receives the
47+
/// chunk slice and the absolute index of the chunk's first element.
48+
///
49+
/// The last chunk may be shorter than `chunk_size` when `chunk_size` does
50+
/// not divide `items.len()`.
51+
///
52+
/// # Panics
53+
/// Panics if `chunk_size == 0` (`chunks_mut(0)` would otherwise return an
54+
/// iterator that does not make progress).
55+
///
56+
/// # Example
57+
/// ```
58+
/// use ndarray::hpc::bulk::bulk_apply;
59+
/// let mut v: Vec<i32> = (0..10).collect();
60+
/// bulk_apply(&mut v, 3, |chunk, start| {
61+
/// for (i, x) in chunk.iter_mut().enumerate() {
62+
/// *x = (start + i) as i32 * 10;
63+
/// }
64+
/// });
65+
/// assert_eq!(v, vec![0, 10, 20, 30, 40, 50, 60, 70, 80, 90]);
66+
/// ```
67+
pub fn bulk_apply<T, F>(items: &mut [T], chunk_size: usize, mut f: F)
68+
where
69+
F: FnMut(&mut [T], usize),
70+
{
71+
assert!(chunk_size > 0, "bulk_apply: chunk_size must be > 0");
72+
let mut start = 0;
73+
for chunk in items.chunks_mut(chunk_size) {
74+
let n = chunk.len();
75+
f(chunk, start);
76+
start += n;
77+
}
78+
}
79+
80+
/// Read-only sibling of [`bulk_apply`]. Applies `f` to consecutive immutable
81+
/// chunks of `items`, passing the absolute starting index of each chunk.
82+
///
83+
/// The last chunk may be shorter than `chunk_size`.
84+
///
85+
/// # Panics
86+
/// Panics if `chunk_size == 0`.
87+
///
88+
/// # Example
89+
/// ```
90+
/// use ndarray::hpc::bulk::bulk_scan;
91+
/// let v: Vec<i32> = (0..10).collect();
92+
/// let mut sum = 0i32;
93+
/// bulk_scan(&v, 4, |chunk, _start| {
94+
/// sum += chunk.iter().sum::<i32>();
95+
/// });
96+
/// assert_eq!(sum, 45);
97+
/// ```
98+
pub fn bulk_scan<T, F>(items: &[T], chunk_size: usize, mut f: F)
99+
where
100+
F: FnMut(&[T], usize),
101+
{
102+
assert!(chunk_size > 0, "bulk_scan: chunk_size must be > 0");
103+
let mut start = 0;
104+
for chunk in items.chunks(chunk_size) {
105+
let n = chunk.len();
106+
f(chunk, start);
107+
start += n;
108+
}
109+
}
110+
111+
#[cfg(test)]
112+
mod tests {
113+
use super::*;
114+
115+
// ----- bulk_apply -----
116+
117+
#[test]
118+
fn bulk_apply_chunk_size_divides_len() {
119+
// len == 10, chunk_size == 5 → exactly 2 chunks of 5.
120+
let mut v: Vec<i32> = (0..10).collect();
121+
let mut sizes = Vec::new();
122+
bulk_apply(&mut v, 5, |chunk, _start| {
123+
sizes.push(chunk.len());
124+
});
125+
assert_eq!(sizes, vec![5, 5]);
126+
}
127+
128+
#[test]
129+
fn bulk_apply_chunk_size_does_not_divide_len() {
130+
// len == 10, chunk_size == 3 → 3 + 3 + 3 + 1.
131+
let mut v: Vec<i32> = (0..10).collect();
132+
let mut sizes = Vec::new();
133+
bulk_apply(&mut v, 3, |chunk, _start| {
134+
sizes.push(chunk.len());
135+
});
136+
assert_eq!(sizes, vec![3, 3, 3, 1]);
137+
}
138+
139+
#[test]
140+
fn bulk_apply_chunk_size_greater_than_len() {
141+
// chunk_size > len → a single chunk of len rows.
142+
let mut v: Vec<i32> = (0..10).collect();
143+
let mut sizes = Vec::new();
144+
bulk_apply(&mut v, 100, |chunk, start| {
145+
assert_eq!(start, 0);
146+
sizes.push(chunk.len());
147+
});
148+
assert_eq!(sizes, vec![10]);
149+
}
150+
151+
#[test]
152+
fn bulk_apply_start_indices_3_3_3_1() {
153+
// The 3-3-3-1 chunking should produce starts [0, 3, 6, 9].
154+
let mut v: Vec<i32> = (0..10).collect();
155+
let mut start_indices: Vec<usize> = Vec::new();
156+
bulk_apply(&mut v, 3, |_chunk, start| {
157+
start_indices.push(start);
158+
});
159+
assert_eq!(start_indices, vec![0, 3, 6, 9]);
160+
}
161+
162+
#[test]
163+
fn bulk_apply_mutates_via_start_plus_offset() {
164+
// The closure can compute each element's absolute index from
165+
// `start + i` and overwrite. Verifies the start index is correct.
166+
let mut v: Vec<i32> = vec![0; 10];
167+
bulk_apply(&mut v, 3, |chunk, start| {
168+
for (i, x) in chunk.iter_mut().enumerate() {
169+
*x = (start + i) as i32 * 10;
170+
}
171+
});
172+
assert_eq!(v, vec![0, 10, 20, 30, 40, 50, 60, 70, 80, 90]);
173+
}
174+
175+
#[test]
176+
#[should_panic(expected = "chunk_size must be > 0")]
177+
fn bulk_apply_panics_on_zero_chunk_size() {
178+
let mut v: Vec<i32> = (0..4).collect();
179+
bulk_apply(&mut v, 0, |_, _| {});
180+
}
181+
182+
#[test]
183+
fn bulk_apply_chunk_size_usize_max_single_chunk() {
184+
// stdlib `chunks_mut(usize::MAX)` yields a single chunk equal to the
185+
// whole slice. Smoke-test: doesn't loop, doesn't panic, one chunk.
186+
let mut v: Vec<i32> = (0..4).collect();
187+
let mut count = 0;
188+
bulk_apply(&mut v, usize::MAX, |chunk, start| {
189+
count += 1;
190+
assert_eq!(start, 0);
191+
assert_eq!(chunk.len(), 4);
192+
});
193+
assert_eq!(count, 1);
194+
}
195+
196+
#[test]
197+
fn bulk_apply_empty_slice() {
198+
// Empty input: closure never invoked.
199+
let mut v: Vec<i32> = Vec::new();
200+
let mut count = 0;
201+
bulk_apply(&mut v, 4, |_, _| {
202+
count += 1;
203+
});
204+
assert_eq!(count, 0);
205+
}
206+
207+
// ----- bulk_scan -----
208+
209+
#[test]
210+
fn bulk_scan_chunk_size_divides_len() {
211+
let v: Vec<i32> = (0..10).collect();
212+
let mut sizes = Vec::new();
213+
bulk_scan(&v, 5, |chunk, _start| {
214+
sizes.push(chunk.len());
215+
});
216+
assert_eq!(sizes, vec![5, 5]);
217+
}
218+
219+
#[test]
220+
fn bulk_scan_chunk_size_does_not_divide_len() {
221+
let v: Vec<i32> = (0..10).collect();
222+
let mut sizes = Vec::new();
223+
bulk_scan(&v, 3, |chunk, _start| {
224+
sizes.push(chunk.len());
225+
});
226+
assert_eq!(sizes, vec![3, 3, 3, 1]);
227+
}
228+
229+
#[test]
230+
fn bulk_scan_chunk_size_greater_than_len() {
231+
let v: Vec<i32> = (0..10).collect();
232+
let mut sizes = Vec::new();
233+
bulk_scan(&v, 100, |chunk, start| {
234+
assert_eq!(start, 0);
235+
sizes.push(chunk.len());
236+
});
237+
assert_eq!(sizes, vec![10]);
238+
}
239+
240+
#[test]
241+
fn bulk_scan_start_indices_3_3_3_1() {
242+
let v: Vec<i32> = (0..10).collect();
243+
let mut start_indices: Vec<usize> = Vec::new();
244+
bulk_scan(&v, 3, |_chunk, start| {
245+
start_indices.push(start);
246+
});
247+
assert_eq!(start_indices, vec![0, 3, 6, 9]);
248+
}
249+
250+
#[test]
251+
fn bulk_scan_sums_chunks() {
252+
let v: Vec<i32> = (0..10).collect();
253+
let mut sum = 0i32;
254+
bulk_scan(&v, 4, |chunk, _start| {
255+
sum += chunk.iter().sum::<i32>();
256+
});
257+
assert_eq!(sum, 45);
258+
}
259+
260+
#[test]
261+
#[should_panic(expected = "chunk_size must be > 0")]
262+
fn bulk_scan_panics_on_zero_chunk_size() {
263+
let v: Vec<i32> = (0..4).collect();
264+
bulk_scan(&v, 0, |_, _| {});
265+
}
266+
267+
#[test]
268+
fn bulk_scan_chunk_size_usize_max_single_chunk() {
269+
let v: Vec<i32> = (0..4).collect();
270+
let mut count = 0;
271+
bulk_scan(&v, usize::MAX, |chunk, start| {
272+
count += 1;
273+
assert_eq!(start, 0);
274+
assert_eq!(chunk.len(), 4);
275+
});
276+
assert_eq!(count, 1);
277+
}
278+
279+
#[test]
280+
fn bulk_scan_empty_slice() {
281+
let v: Vec<i32> = Vec::new();
282+
let mut count = 0;
283+
bulk_scan(&v, 4, |_, _| {
284+
count += 1;
285+
});
286+
assert_eq!(count, 0);
287+
}
288+
289+
// ----- integration with aos_to_soa -----
290+
//
291+
// TODO: enable once W3+W5+W6 land — depends on
292+
// `crate::hpc::soa::{aos_to_soa, SoaVec::field}` from Worker A. The
293+
// soa module is not yet registered on this branch's base. When
294+
// Worker A's commit lands, drop the `#[cfg(any())]` gate.
295+
#[cfg(any())]
296+
#[test]
297+
fn bulk_apply_composes_with_aos_to_soa() {
298+
use crate::hpc::soa::aos_to_soa;
299+
300+
struct Item {
301+
a: f32,
302+
b: f32,
303+
c: f32,
304+
}
305+
306+
let mut items: Vec<Item> = (0..100)
307+
.map(|i| Item {
308+
a: i as f32,
309+
b: (i * 2) as f32,
310+
c: (i * 3) as f32,
311+
})
312+
.collect();
313+
314+
let mut chunk_count = 0;
315+
bulk_apply(&mut items, 16, |chunk, start_idx| {
316+
let soa = aos_to_soa::<_, 3, _>(chunk, |it| [it.a, it.b, it.c]);
317+
assert_eq!(soa.len(), chunk.len());
318+
// First row of the chunk corresponds to absolute index start_idx.
319+
assert_eq!(soa.field(0)[0], start_idx as f32);
320+
chunk_count += 1;
321+
});
322+
// 100 / 16 = 6 full chunks of 16 + 1 tail of 4 = 7 chunks total.
323+
assert_eq!(chunk_count, 7);
324+
}
325+
}

src/hpc/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ pub mod styles;
7272
pub mod nars;
7373
#[allow(missing_docs)]
7474
pub mod blackboard;
75+
pub mod bulk;
7576
#[allow(missing_docs)]
7677
pub mod bnn;
7778
#[allow(missing_docs)]

0 commit comments

Comments
 (0)