slice producer
主线位置:主线 2 的源头。
v.par_iter()的v是切片时,bridge调with_producer拿到的就是本页的IterProducer;split_at一行slice.split_at(index)。切片是最容易并行的数据源:长度已知、任意位置可切、切开后两半在内存里互不相干。
flowchart TB s["&[T] / &mut [T]"] --> it["par_iter / par_iter_mut:IterProducer,split_at(i) = slice.split_at(i)"] s --> ch["par_chunks(k):ChunksProducer,split_at(i) 在元素 i·k 处切"] s --> wi["par_windows(w):左半多带 w-1 个元素"] s --> aw["par_array_windows::<N>():Windows(N) + map(try_into)"] s --> cb["par_chunk_by(pred):UnindexedProducer,从中点找边界"] s --> sp["par_split(sep):UnindexedProducer,从中点找分隔符"] it --> idx["Indexed:zip / enumerate / collect 就地写入"] ch --> idx wi --> idx aw --> idx cb --> un["Unindexed"] sp --> un
两个 trait
ParallelSlice<T: Sync> 给 &[T] 用,ParallelSliceMut<T: Send> 给 &mut [T] 用,都只为 [T] 实现(Vec<T> 经 Deref 得到)。必须实现的方法只有 as_parallel_slice() / as_parallel_slice_mut(),其余全是默认方法,构造一个结构体就返回。
约束不同:只读的要 T: Sync(多个线程同时拿 &T),可写的要 T: Send(每个元素的 &mut T 只交给一个线程,相当于把元素「送」过去)。par_iter() 本身不在这两个 trait 里,而是 &[T] / &mut [T] 的 IntoParallelIterator(slice/mod.rs:776、794),见 两个 trait。
src/slice/ 共 4233 行,其中 sort.rs 1686 行(下一页),mod.rs 1173 行,chunks.rs 387、rchunks.rs 385、chunk_by.rs 239、windows.rs 147。
par_iter 与 par_iter_mut
impl<'data, T: 'data + Sync> Producer for IterProducer<'data, T> {
type Item = &'data T;
type IntoIter = ::std::slice::Iter<'data, T>;
fn into_iter(self) -> Self::IntoIter {
self.slice.iter()
}
fn split_at(self, index: usize) -> (Self, Self) {
let (left, right) = self.slice.split_at(index);
(IterProducer { slice: left }, IterProducer { slice: right })
}
}
(slice/mod.rs:863-875。)叶子上用的就是 std 的 slice::Iter,所以一个叶子内部的循环和顺序代码一样能被向量化。
IterMutProducer 把 split_at 换成 split_at_mut(slice/mod.rs:930-938)。不重叠是类型保证的:split_at_mut 消费一个 &'data mut [T],返回两个互不重叠的 &'data mut [T],这是 std 里少数用 unsafe 实现、对外完全安全的 API 之一。生产者按值被切开、按值被送到别的线程,同一段内存在任一时刻只有一个 &mut 持有者,借用检查器在编译期就保证了这一点,rayon 这里一行 unsafe 都没有。
对比 collect 里的 CollectConsumer:它写的是 Vec 尾部的未初始化内存,不能造 &mut [T],只能用裸指针加断言。切片已经初始化,所以能直接用 &mut [T]。
par_chunks 家族
ChunksProducer::split_at(index) 的下标是块号,换成元素位置要乘块长:
fn split_at(self, index: usize) -> (Self, Self) {
let elem_index = Ord::min(index * self.chunk_size, self.slice.len());
let (left, right) = self.slice.split_at(elem_index);
(
ChunksProducer { chunk_size: self.chunk_size, slice: left },
ChunksProducer { chunk_size: self.chunk_size, slice: right },
)
}
len() 是 slice.len().div_ceil(chunk_size),最后一块可能不满。min 处理的就是最后一块:切到最后时 index * chunk_size 可能超过切片长度。ChunksMutProducer 换成 split_at_mut。par_chunks(0) 在构造时 assert!(chunk_size != 0, "chunk_size must not be zero")。
| 方法 | 元素 | 加入版本(首个包含该方法的 tag) |
|---|---|---|
par_chunks(k) / par_chunks_mut(k) | &[T] / &mut [T],最后一块可能短 | 0.4 |
par_chunks_exact(k) / _mut | 每块恰好 k 个,余数用 remainder() 取 | 1.3.1 |
par_rchunks(k) / par_rchunks_exact(k) / _mut | 从尾部开始分块 | 1.5.2 |
par_windows(w) | 重叠的窗口 &[T] | 0.7 |
par_array_windows::<N>() | &[T; N] | 1.12.0 |
par_chunk_by(pred) / _mut | 相邻元素满足 pred 的连续段 | 1.10.0 |
par_split(sep) / par_split_inclusive(sep) / _mut | 按分隔符切 | 0.8 / 1.9.0(_inclusive) |
块迭代器的叶子数是按块数算的:100 万个 f32、每块 1000 个,len() 是 1000,切分器在 1000 上工作,每片若干个整块。块长决定了每个「元素」的工作量,所以 par_chunks 本身就是一种粗粒度控制,和 with_min_len 的作用类似(见 bridge 与自适应切分)。
par_windows 与 par_array_windows
窗口会重叠,左右两半要共享边界附近的元素。WindowsProducer::split_at:
fn split_at(self, index: usize) -> (Self, Self) {
let left_index = Ord::min(self.slice.len(), index + (self.window_size - 1));
let left = &self.slice[..left_index];
let right = &self.slice[index..];
(
WindowsProducer { window_size: self.window_size, slice: left },
WindowsProducer { window_size: self.window_size, slice: right },
)
}
左半是 [0, index + w - 1),恰好包含前 index 个窗口;右半从 index 开始。两半有 w - 1 个元素重叠,这对 &[T] 没问题。也因此没有 par_windows_mut:可变的窗口无法互不重叠。
par_array_windows::<N>() 是 1.12.0 新加的(发布说明:「works like par_windows but with a constant length, producing &[T; N] items」)。它没有自己的生产者,with_producer 直接借 Windows::new(N, slice).map(|s| s.try_into().unwrap())(windows.rs:138-146),注释写着等 MSRV 升到 1.94 再换成自己的生产者和 std 的 array_windows。try_into().unwrap() 在这里不会失败:Windows 产出的每个切片长度都恰好是 N。
par_chunk_by 与 par_split:unindexed 的切片
这两个的「元素」个数事先不知道(有多少段要扫一遍才知道),所以是 UnindexedProducer,走 bridge_unindexed。
ChunkByProducer::split 从中点 mid 往后找第一个不满足 pred 的相邻对(段的边界),找不到就从中点往前找;找到就在那里切开,并记下哪一边已经「扫过」(tail),避免下次重复扫。SplitProducer(split_producer.rs:104-144,par_split 和 str 的 par_split 共用)是同一个思路,找的是分隔符。注释说两者「very similar」。
和 indexed 的切片相比,每次切分都要扫描一段数据找边界,而且切出来的两半不一定等长。段很长、段数很少时,split 往往找不到边界,返回 None,并行度就上不去。
例子:candle 的 softmax
candle 的 CPU softmax(candle 卷:自定义算子):
src.par_chunks(dim_m1)
.zip(dst.par_chunks_mut(dim_m1))
.for_each(|(src, dst)| {
// 每一行:求 max、exp、求和、除
});
par_chunks(dim_m1)把输入按行切,par_chunks_mut(dim_m1)把输出按行切,zip让两边在同一个行号上切开(适配器选讲),每个叶子拿到的输入行和输出行一一对应,输出写入不需要任何同步。- 「每个元素」是一整行。行数就是切分器看到的长度。
- candle 卷实测,生成时上下文 32 的
softmax_last_dim只有 9 行 × 33 个数,却要几十微秒,RAYON_NUM_THREADS=1时约 9 µs(candle 卷:quantized-llama)。按 E2 的规则:9 行从池外调用,len / 2 >= 1一路成立,池外的下限又是 64,于是切到每行一片,9 个叶子、8 次join_context,其中第一次是从非 worker 线程注入进池再阻塞等待。几十微秒的大头是这一次进池和唤醒(规划阶段 E13:从main调一次空join14–18 µs),不是这 9 行的计算。改法是在调用方按数据量决定要不要并行,或者给切片迭代器加with_min_len,让小输入根本不切(E2:with_min_len(len)时整个计算留在调用线程上,不进线程池)。
小结
- 切片的生产者是最简单的:
split_at(index)就是slice.split_at(index),叶子用 std 的slice::Iter。 - 可写版本用
split_at_mut,「各片不重叠」由借用检查器在编译期保证,没有unsafe。 par_chunks的下标是块号,长度是div_ceil;块长本身就是粒度控制。par_windows的左右两半重叠w - 1个元素,所以没有可变版本;par_array_windows(1.12.0)借Windows加map。par_chunk_by/par_split是 unindexed:每次切分从中点扫描找边界,切不均匀,找不到边界就停。- candle 的 softmax 是
par_chunks.zip(par_chunks_mut)的标准用法;9 行的小输入会被切成 9 片并从池外进池一次,开销远大于计算。