【Bug已解决】[Performance] QMoECPU intermittently livelocks (100% CPU, never returns) under multi-threaded intra-op execution on Apple Silicon 解决方案一、现象长什么样在 Apple SiliconM 系列上用 ONNX Runtime CPU EP 跑一个 QMoE量化 MoE模型专家权重量化类型是MLFloat16fp16。开启多线程 intra-op 并行默认就开时推理偶尔永远不返回CPU 占满 100%进程卡死import onnxruntime as ort so ort.SessionOptions() so.intra_op_num_threads 8 # 多线程 intra-op sess ort.InferenceSession(qmoe_cpu_fp16.onnx, so, providers[CPUExecutionProvider]) out sess.run(None, feed) # 偶发永远卡住CPU 100%最小信号单线程intra_op_num_threads1- 正常返回 多线程 fp16 QMoE - 偶发 livelock100% CPU永不返回 重启/换输入有时能过不稳定注意这是偶发死锁/活锁livelock不是崩溃、不是结果错。CPU 全忙却不出结果典型是线程同步问题。二、背景QMoE 的 CPU 内核为了利用多核会把专家权重的反量化 矩阵乘按输出行/块拆分到多个 intra-op 线程并行做。线程之间需要同步比如一个 barrier等所有线程算完某一块再继续。在 Apple Silicon 的 ARM 架构上线程调度和内存模型有特点核心数多、大小核异构、缓存一致性协议对内存屏障memory barrier敏感。QMoE fp16 内核如果用了一个自旋等待spin-wait 普通变量来实现 barrier即某个线程在while (!ready) {}里空转等别的线程把ready置 1在 ARM 上极易出问题如果ready的写没有正确的释放语义release、读没有获取语义acquire某个线程可能永远看不到ready变成 1于是一直空转 —— 这就是 livelock大家都在转但永远等不到对方。Apple Silicon 的大小核调度可能把一个线程放到小核、另一个放大核调度延迟 缺少屏障让“等待方永远看不到更新”的概率变大于是偶发。三、根因根因是QMoE CPU fp16 内核的线程 barrier 用了无正确内存屏障的自旋等待在 Apple Silicon 的多线程调度下偶发永远看不到对方线程的就绪信号导致活锁100% CPU 空转、永不返回自旋 barrier 缺内存屏障ready标志的写/读没有std::atomic的memory_order_release/memory_order_acquireARM 弱内存模型下更新可能对等待线程不可见。偶发性来自调度Apple Silicon 大小核 调度延迟让“不可见”窗口被放大于是多线程时才偶发单线程没有 barrier自然不触发。fp16 路径特有的内核问题在内核的 fp16 变体QMoECPUMLFloat16说明 fp32 变体的 barrier 实现没问题或用了不同同步进一步指向是该内核特有的同步写法。不是结果错线程都在忙等永远不前进所以 CPU 100% 且不出结果。所以这不是数值错而是ARM 上多线程同步缺少内存屏障导致的活锁。四、最小可运行复现下面用 C 标准库模拟“自旋 barrier 缺内存屏障导致活锁”在 ARM 上极易复现x86 强内存模型下偶发#include atomic #include thread #include iostream // 有 bug 的 barrier用普通 bool 自旋无内存屏障语义 bool g_ready false; // 应为 std::atomicbool 且用 acquire/release void worker() { // 等待主线程把 g_ready 置 true自旋 while (!g_ready) { // 弱内存模型下可能永远看不到更新 - 活锁 // 空转 } std::cout worker 看到 ready\n; } int main() { std::thread t(worker); // 主线程置 ready无 release 语义 g_ready true; t.join(); return 0; }在 Apple Silicon 上用clang -O2编译这个程序经常会永远卡住worker 自旋看不到g_ready更新。把g_ready改成std::atomicbool并用store(true, release)/load(acquire)就立刻正常。这复现了“自旋 barrier 缺屏障 - 活锁”。五、解决方案第一层最小直接修复最小修复把 QMoE fp16 内核的 barrier 换成正确的std::atomic acquire/release 语义或改用条件变量/OS 提供的 barrier不再裸自旋。对使用者临时规避是关掉多线程 intra-op牺牲并行换稳定import onnxruntime as ort so ort.SessionOptions() so.intra_op_num_threads 1 # 单线程避开多线程 barrier 活锁 sess ort.InferenceSession(qmoe_cpu_fp16.onnx, so, providers[CPUExecutionProvider])对 ORT 仓库侧修复是改 QMoE CPU fp16 内核的同步代码// 修复用 atomic 正确内存序 std::atomicbool g_ready{false}; // worker: while (!g_ready.load(std::memory_order_acquire)) { std::this_thread::yield(); // 让出 CPU别空转 100% } // 主线程 g_ready.store(true, std::memory_order_release);这一层立刻消除活锁且yield()避免 100% 空转。六、解决方案第二层结构性改进把“多线程内核的同步必须用正确的内存屏障、不可裸自旋”收口成唯一的配置对象OrtQmoeCpuFp16LivelockPolicy内核实现与评审读它from dataclasses import dataclass, field from typing import Tuple, Literal dataclass(frozenTrue) class OrtQmoeCpuFp16LivelockPolicy: QMoE CPU fp16 多线程同步的单一事实来源。 # barrier 必须用带内存屏障的原子或 OS 原语禁止裸自旋 barrier_kind: Literal[atomic_acq_rel, condition_variable, os_barrier] atomic_acq_rel # 自旋等待必须让出 CPUyield不可 100% 空转 spin_must_yield: bool True # 必须覆盖的平台Apple Silicon ARM 弱内存模型最易触发 must_fix_platforms: Tuple[str, ...] (apple-silicon, arm, arm64) # 受影响内核 affected_kernels: Tuple[str, ...] (QMoECPUMLFloat16,) def describe(self) - str: return QMoE fp16 多线程 barrier 用 atomic acquire/release自旋必须 yield POLICY OrtQmoeCpuFp16LivelockPolicy() def plan_barrier(policy: OrtQmoeCpuFp16LivelockPolicy POLICY) - dict: return { kind: policy.barrier_kind, yield: policy.spin_must_yield, platforms: policy.must_fix_platforms, }所有多线程内核读同一份POLICY同步写法被固化ARM 上不再活锁。七、解决方案第三层断言 / CI 守护把“同步用正确内存屏障、不裸自旋”做成断言。下面用 pytest 风格守护import pytest def test_barrier_uses_memory_barrier(policy): assert policy.barrier_kind in (atomic_acq_rel, condition_variable, os_barrier) assert policy.barrier_kind ! raw_spin def test_spin_must_yield(policy): assert policy.spin_must_yield is True def test_apple_silicon_covered(policy): assert apple-silicon in policy.must_fix_platforms def test_affected_kernel_listed(policy): assert QMoECPUMLFloat16 in policy.affected_kernels这四组断言锁住(1) barrier 用正确内存屏障非裸自旋(2) 自旋必须 yield(3) Apple Silicon 已覆盖(4) 受影响内核已列入。CI 跑通即代表同步写法被守护。八、排查清单遇到 QMoE CPU fp16 多线程偶发卡死CPU 100%先单线程验证intra_op_num_threads1正常 - 锁定多线程同步问题。看是不是只 fp16 路径fp32 正常、fp16 卡 - 锁定该内核特有 barrier。查内核 barrier 实现有没有裸自旋、有没有atomicacquire/release。临时规避关多线程 intra-op或暂时用 fp32 QMoE。根本修复barrier 改用std::atomicacquire/release或条件变量自旋yield()。统一策略对象用OrtQmoeCpuFp16LivelockPolicy固化。CI 守护断言同步用正确屏障、自旋 yield、平台覆盖。九、小结[Performance] QMoECPUMLFloat16 intermittently livelocks under multi-threaded intra-op execution on Apple Silicon的根因是QMoE CPU fp16 内核的线程 barrier 用了无正确内存屏障的裸自旋等待在 Apple Silicon 的 ARM 弱内存模型 大小核调度下等待线程偶发永远看不到对方的就绪信号于是所有线程空转100% CPU却永不前进形成活锁。最小修复是把 barrier 换成std::atomic的 acquire/release 语义或条件变量自旋时yield()避免空转临时规避是关掉多线程 intra-op结构性改进是用唯一的OrtQmoeCpuFp16LivelockPolicy固化同步写法CI 用四组断言守护“barrier 用正确屏障、自旋 yield、平台覆盖、内核列入”。记住ARM 弱内存模型下跨线程标志必须 atomic 正确内存序裸自旋必偶发活锁。