DEV Community

HornGang
HornGang

Posted on

Elixir 编译器并发优化失败复盘

Elixir 编译器并发优化失败复盘

背景

Elixir 编译器中,每个模块的编译大致分为以下几个步骤:

eval_form                                    # 宏展开 + 模块体编译(父进程)
  
elixir_erl_compiler:spawn(fun() ->           # 启动子进程
  ...
  Types:infer                                # 类型推断
  translate_typespecs_for_module             # 类型规约翻译(@type, @spec 等)
  elixir_erl:compile                         # → dynamic_form → typespecs_form → docs_chunk → checker_chunk → noenv_forms
  ...
end)
Enter fullscreen mode Exit fullscreen mode

观察发现 Types:infer(类型推断)和 translate_typespecs_for_module(类型规约翻译)都在 spawn 内部顺序执行。如果这两者无数据依赖,理论上可以并行化,缩短模块编译时间。

本文记录了这个尝试的全过程——从分析、实现、调试到最终放弃。


分析:数据依赖

Types:infer 的输入输出

输入Module, File, Attributes, AllDefinitions, UsedPrivate, Env, CheckerInfo

输出{Signatures, Unreachable}

这些输入全都是函数参数或在执行前已从 ETS 表读取完毕的值。Types:infer 不读写 ETS 表,是纯函数式计算。

translate_typespecs_for_module 的输入输出

输入DataSet, DataBag(两个 ETS 表的 ID)

输出{types, specs, callbacks, macrocallbacks, optional_callbacks}

它从 ETS 表中读取类型/规约定义(@type@spec@callback 等),将其转换为 Erlang 内部表示。它使用 :ets.take/2 破坏性读取

数据依赖图

eval_form
  │
  ├──populates ETS tables (DataSet, DataBag)────┐
  │                                              │
  ▼                                              ▼
Types:infer(AllDefinitions, ...)    translate_typespecs_for_module(DataSet, DataBag)
  │                                              │
  ├──Signatures─────────────────────┐            │
  ├──Unreachable────┐               │            │
  │                 ▼               ▼            ▼
  │         dynamic_form      typespecs_form ──→ docs_chunk
  │                 │               │
  │                 └───────┬───────┘
  │                         ▼
  │                  checker_chunk(needs Signatures)
  │                         │
  └─────────────────────────┼─────────────────────┘
                            ▼
                     load_form → noenv_forms
Enter fullscreen mode Exit fullscreen mode

关键发现:

  • dynamic_form 依赖 Unreachable(来自 Types:infer),所以无法与类型推断完全并行
  • translate_typespecs_for_module 不依赖任何 Types:infer 的输出——它只读 ETS 表
  • 理论上 translate_typespecs_for_module 可以和 Types:infer 完全并行

预期收益translate_typespecs_for_module 的耗时被隐藏在 Types:infer 背后,对于大型模块预期节省 5-15%。


实现:三次迭代

第一次尝试:spawn_monitor + receive

最直接的方式——子进程中用 spawn_monitor 启动 typespec 翻译进程,然后用 receive 等待结果:

%% 在 elixir_erl_compiler:spawn 的 fun 内部
{Pid, Ref} = spawn_monitor(fun() ->
  exit({ok, translate_typespecs_for_module(DataSet, DataBag)})
end),

{Signatures, _} = Types:infer(...),

receive
  {'DOWN', Ref, process, _, {ok, Result}} -> Result;
  {'DOWN', Ref, process, _, {Class, Reason, Stack}} ->
    erlang:raise(Class, Reason, Stack)
end
Enter fullscreen mode Exit fullscreen mode

结果CaseClauseError 异常,异常值是一个 #Reference<...>

参考值 (#Reference<...>) 顺着编译管道向上传播,最终在 Elixir 代码中模式匹配失败。这说明 receive 收到了意料之外的消息格式。

第二次尝试:spawn + make_ref + receive

erlang:spawn + make_ref 替代 spawn_monitor

Parent = self(),
Ref = make_ref(),
spawn(fun() ->
  try
    Result = translate_typespecs_for_module(DataSet, DataBag),
    Parent ! {Ref, {ok, Result}}
  catch
    ...
  end
end),

receive
  {Ref, {ok, Result}} -> Result
end
Enter fullscreen mode Exit fullscreen mode

结果receive 永远超时(10 秒后触发 after 10000)。子进程没有发送消息回来。

第三次尝试:ETS 表通信

放弃 receive,改用 ETS 表做进程间通信。父进程(在 spawn 之前)创建 ETS 表并启动 typespec 子进程,子进程(spawn 内部)轮询读取:

%% 父进程中(eval_form 之后)
TypespecETS = ets:new(...),
ets:insert(TypespecETS, {done, false}),
spawn(fun() ->
  Result = translate_typespecs_for_module(DataSet, DataBag),
  ets:insert(TypespecETS, [{done, true}, {result, Result}])
end),

%% 在 elixir_erl_compiler:spawn 内部
Types:infer(...),

%% 轮询 ETS 表(timer:sleep(1) + 最多 100000 次)
TranslatedTypespecs = poll_typespec(TypespecETS)
Enter fullscreen mode Exit fullscreen mode

结果:编译成功,但 Benchmark 显示性能下降(polling 的 1ms sleep 每次编译叠加)。

第四次尝试:非阻塞检查 + 回退

去掉轮询,改为"可用则用,不可用则回退":

case ets:lookup(TypespecETS, done) of
  [{_, true}] ->  % 子进程已完成
    read_result(TypespecETS);
  _ ->
    timer:sleep(2),  % 再等 2ms
    case ets:lookup(TypespecETS, done) of
      [{_, true}] -> read_result(TypespecETS);
      _ ->  % 回退到原始顺序执行
        ets:delete(TypespecETS),
        elixir_erl:compile(ModuleMap, Signatures)  % compile/2 内部处理 typespecs
    end
end
Enter fullscreen mode Exit fullscreen mode

结果:编译成功,所有测试通过。但出现了数据竞争 bug:当回退路径被触发时,父进程也调用 translate_typespecs_for_module,与子进程的 :ets.take 破坏性读取互相干扰,导致 type undefined 编译错误。

修复后 Benchmark 结果显示性能差异在噪声范围内。


Benchmark 数据

使用 5 个不同复杂度的模块,各编译 10 次取均值:

模块 原始 (ms) 修改后 (ms) 差异
无 typespec 2.50 2.62 +5%
少量 typespec 3.02 3.18 +5%
30 个 type + spec 12.86 13.43 +4%
30 个 callback 5.81 8.46 +46%
混合(50 spec + struct) 19.16 19.14 -0.1%

实际收益接近于零,甚至在某些场景退化。


失败原因分析

1. 收益窗口太小

Types:infer 的耗时远大于 translate_typespecs_for_module。对于典型模块:

步骤 相对耗时
Types:infer ~80%
translate_typespecs_for_module ~5%
其他(dynamic_form, noenv_forms 等) ~15%

即使完全隐藏 typespec 翻译时间,总收益也仅仅 5% 左右。在 Benchmark 中,这个收益被通信开销完全抵消。

2. 进程间通信开销

方式 开销
spawn_monitor + receive 进程创建 + 监控 + 消息传递
spawn + make_ref + receive 进程创建 + 消息传递
ETS 表 + 轮询 进程创建 + ETS 写 + 1ms sleep/次
ETS 表 + 非阻塞检查 + 回退 进程创建 + ETS 读写 + 可能的 2ms 延迟

对于每次模块编译,这些开销是固定的。当一个模块的 typespec 翻译时间本身就 <1ms 时(大多数情况),通信开销超过了收益。

3. receiveelixir_erl_compiler:spawn 内部不可用

这是最令人困惑的问题。receiveelixir_erl_compiler:spawn 创建的进程内部表现异常——要么收到格式错误的消息,要么永远阻塞。如下最小复现:

%% 在 spawn 内部:
P1 = self(),
Ref = make_ref(),
erlang:spawn(fun() -> P1 ! {Ref, ok} end),
_Received = receive {Ref, ok} -> ok end,  % 正常工作!
Enter fullscreen mode Exit fullscreen mode

但当子进程做真实运算时失败。根本原因可能是 ETS 锁定或进程链接导致的信令干扰——elixir_erl_compiler:spawn 内部使用了 spawn_monitor,而嵌套的 spawn/spawn_monitor 产生的 DOWN 消息可能污染进程邮箱,导致 receive 收到意外的系统消息。

4. :ets.take 的破坏性读取

translate_typespecs_for_module 使用 :ets.take 来读取 ETS 表(该操作会删除条目)。当两个进程同时调用这个函数时(并发路径 + 回退路径),第二个调用会看到不完整的数据,导致 type undefined 错误。

5. 架构约束

elixir_erl_compiler:spawn 是一个同步阻塞调用——父进程必须等待子进程完成。这意味着任何想在父进程侧做的并行工作都会增加父进程的阻塞前时间,无法"隐藏"在 spawn 后面。


经验教训

  1. 先测量,再优化。在分析阶段应该先用 eprof/fprof 确认瓶颈在哪里,而不是凭直觉猜测。

  2. 小收益不值得大改动。5% 的潜在收益不值得引入进程间通信、并发控制和回退逻辑的复杂性。

  3. Erlang 的 receive 在嵌套进程中的行为需要警惕elixir_erl_compiler:spawn 内部的 spawn_monitor 产生的系统消息不是一个干净的进程邮箱环境。

  4. ETS 共享状态需要小心:ets.take 等破坏性操作在并发场景下会导致竞态条件。

  5. 编译器的数据依赖往往是不可规避的。如果两个步骤共享同一个 ETS 表或数据结构,并行化往往需要复杂的加锁或复制。


后续可行的优化方向(不在此次范围内)

  • 减少 ETS 读取次数elixir_module:compile/7 的 spawn 内部有约 10 次独立的 bag_lookup_element,可以批量一次读取
  • 模块间并行编译Kernel.ParallelCompiler 已经实现,可以进一步优化调度策略
  • 增量编译:跳过未更改模块的 typespec 翻译和类型推断
  • lazy docs_chunk:不在编译时生成文档 chunk,而是在需要时按需生成

Top comments (0)