Skip to content

perf(deploy): compile independent dictionaries in parallel when ENABLE_THREADING is ON - #1203

Open
fxliang wants to merge 8 commits into
rime:masterfrom
fxliang:deploy
Open

fxliang wants to merge 8 commits into
rime:masterfrom
fxliang:deploy

Conversation

@fxliang

@fxliang fxliang commented Aug 1, 2026 •

Copy link
Copy Markdown
Contributor

Pull request

Issue tracker

Fixes will automatically close the related issue

Fixes #

Feature

Describe feature of pull request

Unit test

  • Done

Manual test

  • Done

Code Review

  1. Unit and manual test pass
  2. GitHub Action CI pass
  3. At least one contributor reviews and votes
  4. Can be merged clean without conflicts
  5. PR will be merged by rebase upstream base

Additional Info

实现方式

1. 编译单元与编译图

新增 src/rime/lever/compile_unit.{h,cc}:

  • CompileUnit 描述一个编译任务:
    • id:通常为 schema_id;
    • prerequisites:必须先完成的单元;
    • outputs:该单元可能写入的文件(不含扩展名);
    • estimated_memory:用于调度估算的峰值内存;
    • run:实际编译动作。
  • CompileGraph 负责收集单元并维护顺序:
    • 显式依赖通过 AddDependency(prerequisite, dependent) 表达;
    • 写入相同 outputs 的单元按声明顺序串行化,避免并发写同一个 table/prism/reverse 文件;
    • 声明顺序优先:如果依赖关系与声明顺序冲突,保留声明顺序,保证图无环;
    • AddUnit / AddDependency 的失败不再静默,调用方会记录日志并计入失败。
  • CompileScheduler 负责执行:
    • ENABLE_THREADING=OFF(RIME_NO_THREADING)时顺序执行,保持可回退路径;
    • 默认使用有界 worker 线程池:依赖计数归零即进入就绪队列,完成后立刻释放内存预算并派发新的就绪单元,不再使用整层等待的 level 调度;
    • 执行前做一次拓扑检查,发现环直接返回失败,避免调度阶段卡死。

2. 两阶段部署

WorkspaceUpdate 仍然保持“先收集、后执行”的两阶段结构:

  1. Phase 1(串行):更新 schema 配置、创建 Dictionary 对象、访问组件缓存,并构造 CompileUnit。
  2. Phase 2(并行):只执行已经构造好的编译单元,不再访问共享的非线程安全组件缓存。

SchemaUpdate 与 WorkspaceUpdate 复用 BuildDictionaryCompileUnit,避免两套词典编译逻辑。

3. 资源限制

并行度同时受 CPU 和内存约束:

  • 单本词典内存估算 = 源文件大小 × 32;
  • 并发度 ≤ min(hardware_concurrency, 4);
  • 在途单元内存总量 ≤ 检测到的内存上限 / 8;
  • 内存上限取物理内存与 Linux cgroup 限制的较小值;
  • 检测失败时回退到 512 MB;
  • 单个单元超过预算时仍允许单独执行,避免永久饥饿;
  • 当本轮只有一个单元可调度时,直接在调用线程执行,避免线程池分配器为串行负载保留额外内存;
  • 捕获 std::bad_alloc 和普通异常,单个单元失败不会导致进程崩溃。

4. 生命周期与所有权

  • BuildDictionaryCompileUnit 改为接收 the<Config>,调用方不再使用裸指针 release();
  • CompileUnit 的依赖和输出只能通过受控接口修改,减少绕过图约束的可能;
  • AddUnit / AddDependency 的返回值在调用点被检查,避免单元或顺序约束被静默丢弃。

风险与处理

风险 说明 处理方式
共享文件并发写 多个方案可能共享同一个 table/prism/reverse 文件,并行写会产生损坏或不确定产物 CompileUnit 声明 outputs;写同一输出的单元按声明顺序串行化;benchmark 校验冷启动产物逐字节一致
组件缓存线程安全 rime 的部分组件缓存不是线程安全的 Phase 1 串行完成配置更新和 Dictionary 构造;Phase 2 只执行已构造单元
内存峰值过高 / OOM 词典编译需要持有全部词条,多个大词典并行可能 OOM 源文件大小估算、内存预算、cgroup 感知、并发上限、单个超大词典独占执行、bad_alloc 兜底
并行路径额外 RSS 预算强制串行时,任务仍在线程池中执行,分配器可能保留额外内存 本轮只选中一个单元时在调用线程执行;真实词库复测峰值与串行路径一致
并行顺序不确定 独立单元完成顺序不确定,可能改变日志或部署顺序 共享输出的顺序由声明顺序固定;独立输出互不影响;产物一致性验证覆盖该风险
串行行为变化 RIME_NO_THREADING 现在也走统一调度器,而不是原来的 WorkspaceUpdate 串行循环 顺序执行路径与并行路径共用同一图逻辑;产物与并行路径逐字节一致;保留 ENABLE_THREADING=OFF 作为回退开关
失败传播 一个词典失败时,依赖它的方案可能仍继续编译,导致部分产物 失败按单元计数,WorkspaceUpdate 最终返回 failure == 0;独立单元继续执行,避免一个坏方案阻塞全部部署;单元失败会记录具体 id
依赖顺序与声明顺序冲突 AddDependency 在冲突时保留声明顺序,依赖请求会被忽略 该行为已写入接口注释,并与原上游“先声明先编译”的行为一致;输出顺序优先保证图无环
平台差异 Windows/macOS/Linux 内存检测方式不同,容器内物理内存不等于可用内存 分平台探测;Linux 额外读取 cgroup v1/v2 限制;失败时使用保守 fallback
调度器死锁 依赖环或线程池退出不干净会导致部署卡住 启动前拓扑检查;worker 通过停止标志和 join 退出;单元测试覆盖依赖顺序、输出串行化和失败隔离
测试覆盖不足 合成数据不能覆盖所有真实词典结构 新增 8 个 CompileUnit 单元测试;Linux benchmark CI 将 6 个生成方案与模拟或真实 kongmingma 方案混合部署,并检查产物一致性
benchmark 基线偏差 benchmark 的 “old” 是同一分支的串行调度,不是 master 的旧实现 文档已明确;产物一致性检查保证重构不改变结果;如需严格对比 master,可另加一个 master 构建作为基线

验证

单元测试

ctest --output-on-failure

结果:全部通过。新增 RimeCompileUnitTest 共 8 个用例,覆盖:

  • 空图;
  • 依赖顺序;
  • 共享输出串行化;
  • 同一单元重复输出不产生自环;
  • 单单元失败不影响后继;
  • 非法/重复单元拒绝;
  • 声明顺序优先于冲突依赖;
  • 预算强制串行时在调用线程执行。

部署 benchmark

环境:8 核 / 7.7 GB,合成 6 个方案、每个 15 万词条。

场景 serial (ms) parallel (ms) 加速比
cold(全量重建) 12612 5718 2.21×
warm(无变化) 64 27 2.37×
单本词典改动 2337 2269 1.03×

多次运行的 cold 加速比约在 2.0–2.3×。冷启动产物在 serial 与 parallel 之间逐字节一致。

“单本词典改动”场景基本持平,符合预期:只有一个词典需要重编译,并行度无法发挥作用,该场景主要用于防止回归。

引入主线程回退后复测:cold 15624 ms → 7230 ms(2.16×),warm 2.54×,单本改动 1.04×,产物仍逐字节一致。

真实大词库 benchmark

环境:8 核 / 7.7 GB。数据:kongmingma(11 MB,77 万行)+ kongmingmabig(78 MB,422 万行),按此顺序部署。

默认 kMemoryBudgetDivisor=8 时,kongmingmabig 的估算内存(约 2.5 GB)超过预算(约 1 GB),调度器只允许它单独执行。因此并行构建下两个单元实际仍按小、大顺序串行:

场景 old(串行) new(并行,/8) 说明
cold(全量) 78.4–78.8 s 77.9–86.8 s 基本持平;大词典独占,无法重叠
warm(无变化) 229 ms 222 ms 1.03×
单本改动 13697 ms 13651 ms 1.00×

峰值 RSS:old 约 2.44 GB,new 约 2.85 GB。冷启动产物逐字节一致。本机 old 峰值未复现 >3.2 GB,可能与插件或词典结构有关。

调参实验:临时把预算改为 memory_limit / 2(本机约 4 GB)时,两个单元可以同时启动;相邻测量 cold 84.0 s(/8)→ 73.7 s(/2),峰值 2.86 GB → 2.79 GB。大词典自身因资源争用从约 69 s 放慢到约 74 s,净收益约 10%。4 GB 设备上该策略的预算为 2 GB,超大词典仍会独占,但会提高中等词典并发的 OOM 风险,暂未采用;当前保持 /8。

加入主线程回退后,/8 路径重新退化为与 old 相同的执行线程:峰值降至 2.44 GB,cold 用时 80.7 s。

混合方案 benchmark 与 Linux CI

benchmark-only 提交跟踪了真实空明码 kongmingma / kongmingmabig 方案和词典(约 89 MB),并添加 Linux CI。该提交标记为 [don't merge this],仅用于跑分验证,不进入上游。CI 使用同一数据集混合 6 个生成测试方案(每个 15 万条)和两本空明码方案,分别测试:

  • 模拟空明码:使用真实 schema,生成 5 万条和 20 万条词典;cold 跑 2 轮;
  • 真实空明码:使用跟踪的真实词典(约 77 万和 422 万条);cold 跑 1 轮。

CI 对比串行与并行冷启动产物,发现差异即失败,并上传跑分报告。Linux 本机混合测试结果如下;warm / modified 详细数据见 benchmarks/deploy/report-kongmingma.md:

混合负载 serial cold parallel cold 加速比
6 个生成方案 + 模拟空明码 20.631 s 9.034 s 2.28×
6 个生成方案 + 真实空明码 113.862 s 100.968 s 1.13×

两种负载的串行与并行冷启动产物均逐字节一致。真实词典场景的加速有限,主要受大词典单本编译时间和 /8 内存预算约束。

回退方式

  • 构建时使用 -DENABLE_THREADING=OFF 即回到顺序执行路径;
  • 不改变部署产物格式,不需要迁移用户数据;
  • 如线上出现并发问题,可直接关闭线程开关回退,无需回滚代码。

后续可做

  • 在 4 GB 设备或容器中验证更激进的预算;当前默认 /8 对超大词典会安全退化为串行;
  • 增加 master 基线构建,区分“相对 master 的收益”和“并行调度本身的收益”;
  • 视需要在配置层暴露并发度和内存预算,而不是固定常量。

@fxliang
fxliang requested a review from lotem August 1, 2026 10:02
@fxliang fxliang changed the title Deployment enhancement Deployment enhancement[draft] Aug 2, 2026
@lotem

lotem commented Sep 29, 2026

Copy link
Copy Markdown
Member

"Compile unit" is a great idea.

We need to modularise the compiling process rather than hard code everything in the main deploy function.

Ideally, each compiling module declares its dependencies, and the main deploy function only executes the constructed graph of compile units.

Wait, isn't that similar to what I want to achieve in rime-make...

@fxliang

fxliang commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor Author

好像还有点东西要改我还没推,资源预估上的

WorkspaceUpdate now collects schema compile units serially (phase 1),
then compiles independent dictionaries concurrently (phase 2). Units whose
output files overlap (shared dictionaries, packs, prisms) fall back to
serial compilation. Batching is constrained by a memory budget (1/4 of
physical memory, estimated per dictionary from source size) and a
concurrency cap (min(hardware_concurrency, 8)); oversized dictionaries are
compiled alone. Compilation exceptions are caught and counted as failures.

ENABLE_THREADING=OFF keeps the original serial path via RIME_NO_THREADING.
Move schema/dependencies processing out of process_schema and into the
top-level loop, so a schema's own dictionary is compiled before its
direct dependencies, matching upstream serial build order. Only one
level of dependencies is processed; nested dependencies are not compiled.
…error handling

- Reduce kMaxConcurrency from 8 to 4 to limit memory usage on mobile devices
- Lower memory budget from physical_memory/4 to physical_memory/8
- Reduce fallback budget from 2GB to 512MB for constrained environments
- Increase kEstimatedBytesPerSourceByte from 16 to 32 for more accurate estimation
- Add std::bad_alloc catch in compile_unit for graceful OOM handling
- Wrap future.get() in try-catch to prevent std::terminate from escaped exceptions
Extract CompileUnit, CompileGraph and CompileScheduler from
WorkspaceUpdate into rime/lever/compile_unit. Each compiling module
declares its dependencies, output files and memory estimate; the deploy
function only constructs the graph and executes it. Units sharing an
output file are chained in declaration order instead of falling back to
a fully serial path, so independent units stay parallel.
SchemaUpdate now goes through BuildDictionaryCompileUnit and the
CompileScheduler like WorkspaceUpdate, dropping the duplicated
dictionary creation and compile invocation.
@fxliang fxliang changed the title Deployment enhancement[draft] Deployment enhancement Oct 30, 2026
@fxliang fxliang changed the title Deployment enhancement perf(deploy): compile independent dictionaries in parallel when ENABLE_THREADING is ON Oct 30, 2026
@fxliang
fxliang requested a review from a team October 30, 2026 05:34

@ksqsf ksqsf left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall LGTM. Some minor comments.

Comment thread src/rime/lever/compile_unit.h Resolved
Comment thread src/rime/lever/compile_unit.h Resolved
Comment thread src/rime/lever/compile_unit.h Resolved
Comment thread src/rime/lever/deployment_tasks.cc Resolved
* the in-flight set may not exceed 1/8 of detected memory (512 MB fallback if
the limit cannot be detected);
* detected memory is the smaller of physical memory and the Linux cgroup limit;
* concurrency is ≤ min(hardware cores, 4);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@lotem

lotem commented Oct 31, 2026

Copy link
Copy Markdown
Member

很好很強大。強烈支持。

- Rename BuildDictionaryCompileUnit to CreateDictionaryCompileUnit
- Rename CompileUnitResult enum values for clarity:
  - kBuilt -> kCreated
  - kNoDictionary -> kTrivial
- Simplify MaybeCreateDirectory by removing redundant fs::exists check
- Move MaybeCreateDirectory closer to its original position to reduce diff
- Change CompileScheduler::Run to return std::optional<Result>
- Update all call sites and tests

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@fxliang

fxliang commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

改好了,如合并记得去掉那个跑分提交

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants