Rust in Everything
基于 rayon 1.12.0

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 调一次空 join 14–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 片并从池外进池一次,开销远大于计算。

💬 关联讨论

发起讨论
加载中...