【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() |
好处是迁移风险极低:每个调用点都是机械替换,语义一一对应,出问题容易二分定位。
几个实现细节(都写在头文件里):
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/parcallare 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. 这一篇的未解问题
- 缺少同轮 A/B 的量化证据。动机(唤醒税)在原理上成立,但"到底省了多少"没有实测归档。这是应该补的第一个实验。
- 自旋策略没有自适应。
VLLM_TP_SPIN这类开关目前是手工设置的经验值。理想情况下应该根据"上一轮等待时长"自动在自旋与阻塞之间切换。 - 非可重入是静默失败。加一个"当前是否已在并行区内"的断言(debug 构建下)成本极低,收益很直接。
- 绑核策略没有形成文档化的决策依据。x86 跳 SMT、RK3588 绑 A76,这两个选择背后的实测数据没有归档。换平台的人只能抄结论,抄不到理由。
下一篇是本系列里我认为最有价值的一篇:位级可复现性——4 线程与 8 线程为什么算出的结果位级不同,以及为什么"修好了"这句话我们到现在都说不出口。
浙公网安备 33010602011771号