【FHE】(十二):为什么我们把 OpenMP 换成了自研线程池

项目仓库

Gitee 主仓:https://gitee.com/pei-xiaoguang/kestrel-llm

GitHub 镜像:https://github.com/m13253246268-ship-it/kestrel-llm

相关文档

术语与数据口径 |

性能与基准 |

构建与复现 |

快速上手 |

架构总览

0. 一句话结论

vllm_tp.c 是一个自研线程池,用来替换 OpenMP 运行时 (tp = thread pool)。

动机很具体:OpenMP 每个并行区都要 fork/join 。而 FHE 的并行粒度往往很小(比如对 112 个素数分别做 NTT),一个并行区可能只有几微秒的活------此时唤醒开销比干活本身还大。

自研池的做法是:持久化的 worker 线程 + "自旋再等待"的混合等待 + 调用线程自己也干活。API 刻意做成 1:1 对应 OpenMP,让每个调用点能直接替换。


1. 动机:小并行区的唤醒税

OpenMP 的模型是"进入并行区 → 唤醒线程 → 分发任务 → 汇合"。对粗粒度任务(几百毫秒一段)这完全没问题。

但 FHE 的并行结构是嵌套且细碎的:

  • 112 个素数 → 每素数一个 NTT;
  • n = 2048 个系数 → 每系数一次模乘;
  • 密文分量 × 素数 × 系数,三层循环里处处可并行。

这些并行区的执行时间可能只有 微秒级 。而每次进入并行区,OpenMP 都要付一次线程池唤醒/汇合的成本。结果是:花了大量时间在"叫醒工人",而不是"干活"。


2. 三条设计决策

头注释把设计讲得很清楚,逐条看:

2.1 持久 worker + "先自旋,再等待"

Persistent worker threads are created once (lazy, on first parallel call) and reuse a spin-then-event wait between jobs, so small parallel regions pay ~µs of dispatch instead of OpenMP's thread-pool wake cost.

关键点:

  • 只创建一次(懒初始化:第一次发起并行调用时创建),之后复用;
  • 等待策略是混合的 :先自旋 (spin)一小段,如果还没活干再退化成事件等待(event wait)。

这个"spin-then-event"是并行编程里的经典取舍:纯自旋浪费 CPU,纯事件等待延迟高。先自旋后阻塞 两头兼顾------而这也正是我们在多进程环境下需要用 VLLM_TP_SPIN 之类的开关来调的原因(自旋策略在超订场景下会互相抢核)。

2.2 调用线程自己也干活

The CALLING thread participates: it executes its own chunk and joins the workers with a wait, so the common case adds no idle-thread wake latency.

这是一个很实在的优化:发起并行的那个线程不是"监工",它自己也分一块活。 于是常见情况下不需要额外唤醒------本来就醒着的那个线程立刻开始算。

API 上对应两条规则(头注释原文):

  • vllm_tp_parfor:调用者拿最后一块 (caller takes the last chunk);
  • vllm_tp_worker_id:在并行区内 caller 的 id 是 nthreads-1 ,在并行区外是 -1。

第二条尤其重要------它让"我是第几号工人"这件事在区内区外语义一致,避免调用点写出"区内一套、区外一套"的分支。

2.3 静态连续分块 + 绑物理核

Static contiguous chunking matches the OpenMP schedule(static) split the codebase already relies on (uniform per-row cost).

Workers bind to physical cores at creation (NUMA/affinity axioms: skip SMT siblings on x86, pin to the A76 cluster on RK3588).

两点:

  • 分块策略照抄 schedule(static) :因为 FHE 里"每一行的代价基本一致"(每个素数一个 NTT、每个系数一次模乘),静态连续分块不会有负载不均问题,而且分块确定 → 结果确定,这对我们是重要的(本系列第 13 篇);
  • 绑核 :x86 上跳过 SMT 兄弟核 (只绑物理核),RK3588 上绑 A76 大核簇。这两个平台策略不同,写在同一个池里。

3. API:刻意做成 OpenMP 的镜像

这个设计选择很值得学习------不发明新范式,只换实现。头注释直接给了映射表:

OpenMP 自研池
#pragma omp parallel for schedule(static) vllm_tp_parfor(...)
#pragma omp parallel(手工按 tid 拆分) vllm_tp_parcall(...)
omp_get_thread_num() vllm_tp_worker_id()
omp_get_max_threads() / omp_set_num_threads() vllm_tp_threads() / vllm_tp_init()
omp_get_wtime() vllm_tp_wtime()

好处是迁移风险极低:每个调用点都是机械替换,语义一一对应,出问题容易二分定位。

几个实现细节(都写在头文件里):

c 复制代码
void vllm_tp_parfor(int start, int end, void (*body)(void*, int), void *ctx);
/* static contiguous chunks,caller 拿最后一块;
   池只有 1 个线程、或区间小到没必要时,退化成调用者上的串行循环 */

int vllm_tp_worker_id(void);   /* workers 0..nworkers-1;区内 caller = nthreads-1;区外 -1 */
double vllm_tp_wtime(void);    /* QueryPerformanceCounter / CLOCK_MONOTONIC */
void vllm_tp_shutdown(void);   /* 幂等 */

vllm_tp_wtime() 的两套实现(Windows 用 QueryPerformanceCounter、Linux 用 CLOCK_MONOTONIC)说明这个池是跨平台的------这跟项目里"同一源码在 x86-64 与 aarch64 逐系数位级一致"的目标是一致的。


4. 代价与限制(必须说清)

自研的代价写在注释里,而且是明确的能力削减:

Thread-safety: vllm_tp_parfor/parcall are NOT re-entrant; nested parallel regions from a worker are not supported (the engine does not use them).

限制 含义
非可重入 不能从并行区内再发起一个并行区
不支持嵌套并行 引擎里确实没有这种用法,但这条约束没有编译期检查,只能靠约定
默认线程数 每物理核一个(x86 跳 SMT,RK3588 4×A76)

"引擎不用嵌套并行"是一句承诺,不是一条保证。 将来有人写了一个嵌套调用,编译器不会拦,运行时会静默出错。这是这笔交易里最需要警惕的地方。


5. 它与"位级可复现"的隐秘关联

我们把 OpenMP 换掉,不只是为了快。一个确定的线程池,是实现确定性的前提。

  • 自研池用静态连续分块,分块结果是确定的;
  • 而 OpenMP 的分块策略在不同版本、不同实现(GCC 的 libgomp vs LLVM 的 libomp)之间可能不同。

也就是说:如果并行分块本身不确定,"位级一致"就无从谈起。

但要说清楚------换掉 OpenMP 并没有解决位级一致问题 。真正的根因在别处(RNG 播种),本系列第 13 篇专门讲。这里只是说明:自研池是"位级可复现"这条路上的必要但不充分条件。


6. 安全边界(务请读完)

复制代码
本文所述参数为机制验证级,远低于 HE 参数标准的 128-bit 水平,不得用于保护真实数据。
本文主张的是:并行运行时替换的动机与设计。
本文不主张:性能优越性。

必须补一句 :绩效上,我们没有 归档"自研池 vs OpenMP"的同轮交错 A/B 数据。按本项目纪律(跨轮不可比绝对值),本文因此不给出任何加速比。这是本文最大的空缺。


7. 这一篇的未解问题

  1. 缺少同轮 A/B 的量化证据。动机(唤醒税)在原理上成立,但"到底省了多少"没有实测归档。这是应该补的第一个实验。
  2. 自旋策略没有自适应 。VLLM_TP_SPIN 这类开关目前是手工设置的经验值。理想情况下应该根据"上一轮等待时长"自动在自旋与阻塞之间切换。
  3. 非可重入是静默失败。加一个"当前是否已在并行区内"的断言(debug 构建下)成本极低,收益很直接。
  4. 绑核策略没有形成文档化的决策依据。x86 跳 SMT、RK3588 绑 A76,这两个选择背后的实测数据没有归档。换平台的人只能抄结论,抄不到理由。

下一篇是本系列里我认为最有价值的一篇:位级可复现性------4 线程与 8 线程为什么算出的结果位级不同,以及为什么"修好了"这句话我们到现在都说不出口。

相关推荐
瞬维AI1 小时前
RPA与AI智能体的跨平台自动化执行架构:从任务编排到异常处理
人工智能·自动化·rpa
呆呆槑_Xiong1 小时前
2026年AI网文写作指南:灵蟹创作与主流AI工具全解析
人工智能
SJZR1 小时前
图解归一化
人工智能·机器学习
aneasystone本尊2 小时前
学习大模型推理的加速技术:量化、投机采样与 PD 分离
人工智能
IT_陈寒2 小时前
Vite热更新失效?你可能漏了这个配置项
前端·人工智能·后端
EatFan2 小时前
从 Prompt 到 Harness:AI Agent 的竞争层为什么转移到了“外骨骼“
人工智能·ai agent
知了一笑2 小时前
企业的AI转型,真能找到出路吗?
人工智能·ai·aigc
whyutianict_vv2 小时前
从技术栈视角评估AI大模型培训机构:Python、RAG、Agent与部署的能力核对清单
人工智能
JAVA前线2 小时前
以餐厅后厨为例讲清楚AI十大技术概念
人工智能