Rust in Everything
基于 tenferro-runtime 0.8.0

12 个线程比 8 个慢

主线位置:直接计算主线的第 2 步,CpuEngine::new(Pool::builder()...build()?)。线程宽度由调用方在建池时定,tenferro 和 threadteam 都不替你挑核。engine 怎么把运算分给 team、批处理策略怎么判断值不值得并行,见 CpuEngine。

flowchart TB
  b["Pool::builder 不写宽度"] --> ap["可用并行度 12"]
  ap --> t["8 性能核 加 4 能效核"]
  t --> mm["matmul 分给 12 个成员"]
  mm --> slow["等能效核上的成员"]
  mm --> spin["运算后成员自旋"]
  spin --> cpu["空等时烧 CPU"]
  fix["threads 8"] --> ok["6.3 ms"]

实验 crate exp-tenferro8/pit1-pit,bin p08_thread_width,release;1024×1024 f64 a.matmul(&a, &mut engine),每种宽度一个 engine,热身 1 次后计 15 次,取最小值和中位数;连跑三轮,下面引第三轮,uptime 1 分钟负载 3.1 到 3.2(别的写手在跑)。M2 Max(sysctl 报 hw.perflevel0.physicalcpu: 8、hw.perflevel1.physicalcpu: 4)、macOS 27、rustc 1.99.0。

现象

建池时没写线程数,或者照「越多越好」写了 12:

let mut engine = CpuEngine::new(Pool::builder().build()?);
matmul 1024x1024 f64 threads(1)               num_threads= 1 min    42.40 ms  median    43.50 ms
matmul 1024x1024 f64 threads(4)               num_threads= 4 min    11.25 ms  median    11.43 ms
matmul 1024x1024 f64 threads(8)               num_threads= 8 min     6.32 ms  median     6.55 ms
matmul 1024x1024 f64 threads(10)              num_threads=10 min     7.74 ms  median     9.69 ms
matmul 1024x1024 f64 threads(12)              num_threads=12 min    10.56 ms  median    12.93 ms
matmul 1024x1024 f64 Pool::builder().build()  num_threads=12 min     9.36 ms  median    12.00 ms

4 线程到 8 线程接近线性,8 线程以后反而变慢:12 线程(也就是默认宽度)的最小值比 8 线程慢约 70%,中位数慢一倍。三轮里 12 线程的最小值在 9.1 到 10.6 ms 之间,8 线程都在 6.3 到 6.7 ms。

还有一笔不在计时里的账。每种宽度先算一次、睡 300 ms,再算一次,然后让调用线程睡 1 秒,用 getrusage 看这 1 秒里进程又用了多少 CPU:

threads(8)              : one matmul wall 13.18 ms, cpu 91.4 ms; cpu during next 1000 ms of caller sleep 251.5 ms
threads(12)             : one matmul wall 18.22 ms, cpu 145.8 ms; cpu during next 1000 ms of caller sleep 225.2 ms
threads(8).spin_polls(0): one matmul wall 10.00 ms, cpu 53.8 ms; cpu during next 1000 ms of caller sleep 0.0 ms

调用方什么都没做,进程却在一秒里烧了两三百毫秒 CPU。(隔 300 ms 后的这一次 matmul 比连续计时时慢,成员已经睡下,要先叫醒。)

出处

宽度的默认值在 threadteam 0.2 的 build_checked:

        let n = self
            .threads
            .unwrap_or_else(|| std::thread::available_parallelism().map_or(1, NonZeroUsize::get));

PoolBuilder::threads 的文档也写着「Defaults to the available parallelism」。available_parallelism 在 M2 Max 上是 12,不区分 8 个性能核和 4 个能效核。一次 matmul 的活分给 team 的所有成员,整次运算要等最后一个成员做完。宽度超过 8 以后,有成员落在能效核上,它们慢,最可能的解释就是它们拖慢了整体(本页没有去读 faer 的分块策略来证实这一点),CPU 时间也跟着涨(上面 12 线程一次 matmul 用了 146 ms CPU,8 线程 91 ms)。tenferro 自己不读 CPU 拓扑,CpuEngine 页的「threadteam 不替你挑核」说的就是这个。

运算后的 CPU 来自 team 成员的等待方式。PoolBuilder::spin_polls 的文档:预留的成员等活时先自旋这么多次轮询,再让出几次,最后才睡,形状照搬 libgomp 的 GOMP_SPINCOUNT,这样紧接着的下一个并行区域不用付唤醒的代价。连续做大运算时这是好事;只是偶尔做一次、又和别的程序共用机器时,这段自旋就是白烧的 CPU。默认值和测量见 CpuEngine。

修法

按性能核数写宽度,不靠默认值:

let mut engine = CpuEngine::new(Pool::builder().threads(8).build()?);

上面的 threads(8) 一行就是这个写法。性能核数在 macOS 上可以读 sysctl hw.perflevel0.physicalcpu;部署到别的机器时,宽度最好做成配置项,在那台机器上量一次。

运算稀疏、在意空闲 CPU 时,把自旋关掉:

let mut engine = CpuEngine::new(Pool::builder().threads(8).spin_polls(0).build()?);
matmul 1024x1024 f64 threads(8).spin_polls(0) num_threads= 8 min     6.66 ms  median     7.27 ms

连续计时和默认的 threads(8) 差不多(三轮最小值 6.35 到 7.31 ms),运算后的 1 秒里 CPU 是 0。spin_polls 不影响池能不能进 threadteam 的线程缓存(只有 thread_name、on_start、qos、stack_size 会,见每次调用都新建 engine)。

怎么确认没中招

  • 打印 engine.num_threads():大于性能核数就是这个坑。
  • 对自己的典型尺寸扫一遍宽度(4、6、8、10、12),取最快的;不同尺寸的最优宽度不一样,小矩阵上串行 engine 常常最快。
  • 服务空闲时看进程的 CPU 占用;运算后持续一阵的占用是成员在自旋,用 spin_polls 调小。

小结

  • Pool::builder() 不写 threads 时取 available_parallelism,M2 Max 上是 12,含 4 个能效核;1024² matmul 12 线程 10.6 ms,8 线程 6.3 ms。
  • team 成员在运算后先自旋,空等的 1 秒里进程烧掉两三百毫秒 CPU;spin_polls(0) 关掉它,连续计算的速度基本不变。
  • 宽度按性能核数写,或者在目标机器上量;tenferro 不替你挑核。

💬 关联讨论

发起讨论
加载中...