Mojo 编译耗时分析指南:从--mlir-timing到可执行的编译时间分解
【免费下载链接】mojoThe Modular Platform (includes MAX & Mojo)项目地址: https://gitcode.com/GitHub_Trending/mo/mojo
导读
当一次 Mojo 编译变得缓慢时,首先要回答的问题是:编译器的时间到底花在了哪个环节?本文基于 Modular Mojo 仓库中的编译耗时分析文档,系统讲解如何用--mlir-timing/--llvm-timing定位耗时热点、如何对真实模型(如 gemma-4-31B)采集并解读计时报告、如何用消化脚本把 16 万行原始日志压缩成可行动的分解结论,以及如何通过仓库内置的 Kepler 基准目标把编译耗时纳入每日 CI 追踪。读完本文,你将能够独立完成"采集报告 → 解读结构 → 定位热点 → 长期监控"的完整编译性能分析流程。
一、三个核心标志:答案从哪来
当编译变慢,第一步是让编译器自己回答"时间花在哪里"。Mojo 编译驱动提供三个隐藏标志(通过mojo build --help-hidden或mojo run --help-hidden查看完整帮助文本):
| 标志 | 作用 |
|---|---|
--mlir-timing | 对每一个 MLIR pass 和分析计时 |
--llvm-timing | 对每一个 LLVM pass 和分析计时 |
--mlir-timing-display MODE | tree(默认)按 pipeline 结构嵌套;list按 pass 名称聚合、按耗时排序 |
这些标志的定义位于 Mojo/tools/mojo/Common/CompilationOptions.td。从 TableGen 定义可以看到几个关键细节:
--mlir-timing(Index<18>)的 HelpText 明确指出:报告在编译结束时打印到stderr;对mojo run而言打印发生在程序启动之前;编译缓存命中的 pass 永远不会运行,因此热缓存只会报告 parse 阶段,把MODULAR_CACHE_DIR指向空目录才能对完整 pipeline 计时。--llvm-timing(Index<20>)的 HelpText 解释了它的单线程约束:LLVM 的计时器是进程全局的、对多线程不安全,因此该标志会把编译器线程数设为 1,并覆盖--num-threads;同时 LLVM 拿不到编译缓存中已存在的目标代码,所以缓存命中时 LLVM 报告为空。--mlir-timing-display的list模式会把所有 pipeline 的 pass 合并,因此无法单独展示某一个 offload 目标,默认的tree模式更受推荐。
读任何报告前必须知道的四件事
- 两份报告都走 stderr,编译结束时输出,用
2>重定向捕获;mojo run时在程序启动前打印。 --llvm-timing强制单线程编译。报告度量的是总 CPU 工作量,而不是并行构建的墙钟时间。- 热缓存产生空报告或部分报告。缓存服务的 pass 从不运行,热缓存下 MLIR 报告只有 parse 等极少量内容,目标代码被缓存时 LLVM 报告完全为空。把
MODULAR_CACHE_DIR指向空目录即可对完整 pipeline 计时。 list模式合并所有 pipeline,无法单独呈现某个 offload 目标,优先用默认的tree。
二、报告包含什么:三个部分的结构
按顺序,报告包含三个部分:
MLIR pass timing (--mlir-timing)— MLIR pass 的树状结构。LLVM pass timing (--llvm-timing): offload <triple>— 每个加速器目标各一份。LLVM pass timing (--llvm-timing): host <triple>— 主机目标一份。
为加速器编译时,编译器会再次对内核运行同一套编译流程,因此加速器的工作会以两种不同形态同时出现在报告的两半中。
加速器 scope 的双重呈现
加速器的 MLIR pass 位于 MLIR 部分,以一行 scope 行打开ElaborateGenerators下的子树:
119.7262 ( 45.1%) ElaborateGenerators 101.8517 ( 38.4%) ===--- offload nvptx64-nvidia-cuda sm_100a ... (also in host) === 21.9535 ( 8.3%) ElaborateGenerators 18.3007 ( 6.9%) AutomaticInline ... 0.3545 ( 0.1%) SetFastMathFlags ... -59.0020 (-22.2%) Rest 265.5347 (100.0%) Total解读要点:
- scope 行下方缩进一层的行是加速器自己的 MLIR pass——第二个
ElaborateGenerators、第二个AutomaticInline等,因为这是同一套 pipeline 在内核上再次运行。 - scope 行与
ElaborateGenerators处于同一缩进层级,但(also in host)标记说明其时间被计入该 pass 内部,而非并列。 - 子树之后恢复外层缩进的行(此处为
SetFastMathFlags)又是主机侧的工作。 - MLIR 部分以两行收尾:
Total是计时根,即总编译时间;Rest是根内部没有任何 pass 认领的时间,MLIR 用"根减去子节点之和"计算,因此当子节点重复计数时会变为负数(本例为-59.0020)。详见下文 Rest 是主机代码生成 与 每个加速器 scope 被计数两次。
加速器的 LLVM pass不在该子树中,它们落在独立的offload <triple>LLVM 部分——尽管这些时间同样花在了ElaborateGenerators内部。因此要汇总加速器 pipeline 的总耗时,需要把MLIR 子树 + 对应 LLVM 部分相加。
三个部分并非同级,它们的总和不能相加:MLIR 根跨越整个编译过程(含代码生成)。具体后果见下文 手工解读数字。
三、实测一个模型:两步拿到真实报告
文档用 gemma-4-31B 演示完整流程,分两步。
第一步:获取图编译器产出的 Mojo
MODULAR_DEBUG=ir-output-dir=/tmp/gemma4/out-dir \ ./bazelw run //max/python/max/_entrypoints:pipelines -- warm-cache \ --model-path google/gemma-4-31B-it --target cuda:sm_100a这条命令会运行完整 pipeline,因此没有匹配的 GPU 会失败——但没关系,照跑即可:产出的文件会在失败之前落盘。输出目录保存每个阶段的图 IR,外加两个 Mojo 文件:
gemma4_vision+gemma4_language.mojo— 约 3 MB,待编译的目标文件。gemma4_vision_constant_subgraphs.mojo— 约 12 KB,常量子图,单独产出。
第二步:用两个计时标志编译它
source ./utils/start-modular.sh # 每个 shell 执行一次,把 mojo 加入 PATH MODULAR_CACHE_DIR=$(mktemp -d) \ mojo build --emit=object --mlir-timing --llvm-timing \ --target-accelerator=sm_100a gemma4_vision+gemma4_language.mojo \ -o /dev/null 2> log.txt--target-accelerator选择要生成代码的加速器架构,无需 GPU 在场,只需合法架构名(如sm_100a或gfx950)。-o /dev/null跳过目标文件写入——本次目的就是计时。MODULAR_CACHE_DIR=$(mktemp -d)保证冷缓存,得到完整 pipeline 报告。- 记录产生日志的是哪个构建版本:debug 构建与 production 构建不可比(见下文 基准测试注意事项)。
四、消化报告:把 16 万行日志压成结论
模型原始日志体量巨大:gemma-4 约165,000 行,大部分是"每个加速器模块的每个 pass 一行"。仓库的mojo-compile-timingskill 负责消化它:
/mojo-compile-timing log.txt --top 10也可以直接运行其脚本:
python3 .claude/skills/mojo-compile-timing/scripts/digest_timing_log.py log.txt有用选项
| 选项 | 作用 |
|---|---|
--compare BASELINE | 同时提供第二个日志,输出逐行加速比;第一个参数是被测对象,第二个是基线 |
--top N | 每组点名多少个 pass,余下卷进尾部(默认 5) |
--markdown | 树以围栏代码块输出,并附汇总表格 |
--json | 机器可读输出,供仪表盘使用 |
gemma-4 debug 编译的消化输出示例
MLIR root = whole compile 265.5s (100.0%) │ ├─ ElaborateGenerators 119.7s ( 45.1%) │ ├─ offload nvptx64 sm_100a scope "(also in host)" 101.9s ( 38.4%) │ │ ├─ MLIR passes 57.2s ( 21.5%) │ │ ├─ LLVM 23.0s ( 8.6%) │ │ └─ translation to LLVM IR, object emit 21.7s ( 8.2%) │ └─ elaboration itself 17.9s ( 6.7%) │ ├─ other host MLIR passes 102.2s ( 38.5%) │ └─ Rest = host code generation 42.8s ( 16.1%) ├─ LLVM 34.5s ( 13.0%) └─ translation to LLVM IR, object emit 8.4s ( 3.1%) Rolled up by compiler half MLIR 177.3s ( 66.8%) LLVM 57.4s ( 21.6%) translation, object emit, untimed 30.1s ( 11.3%) Accelerator modules compiled: ~765读图规则:每个百分比都是占整个编译的比例,任意深度的行可直接互比;子节点是其父节点的一部分、绝不额外叠加,因此只有同一缩进层级的行相加才等于其父节点。
这个模型的头条结论:ElaborateGenerators看似热点(占 45%),但其 119.7s 中只有17.9s是真正的 elaboration;其余101.9s是加速器代码的完整嵌套编译,由 elaborator 通过CompileOffloadOp驱动(相关实现可见 Mojo/lib/Compiler/KGENCompiler.cpp 中围绕 offload 分组的编译逻辑)。
三、手工解读数字:四个陷阱
消化脚本已处理全部四个问题;了解它们有助于核对脚本输出或自行编写消费程序。
MLIR 根就是整个编译
MLIR 根跨越解析、pass 与代码生成——因为MLIRPassTiming对象存活于build()的整个生命周期,见 Mojo/tools/mojo/Build/mojo-build.cpp。某次 gemma-4 编译中根读数为 272.34s,而墙钟为 272.56s。总编译时间取自根,绝不把各部分相加。
每个加速器 scope 被计数两次
它打印在最外层、与ElaborateGenerators并列,但时间同时也在其内部——这正是(also in host)的含义。同一份日志上,最外层各行求和得 323.80s,而真实根是 265.53s。
这个盈余正是上面示例中Rest为负的原因。MLIR 把Rest计算为"根减去子节点之和",重复计数的时间便以符号翻转的形式落入其中:行显示-59.0020,而真正未归属的时间是 42.85s。把 scope 加回去即可还原:-59.00 + 101.85 = 42.85秒。消费报告的代码要么跳过名称以===---开头的行,要么把它们减掉。
Rest是主机代码生成,不是噪声
它是 pass pipeline 之后未计时的尾巴。compileModuleToArchive先以计时 scope 运行runKGENPipeline,然后把工作交给没有计时 scope的目标代码编译器。对 gemma-4,42.8s 拆分为 34.5s 主机 LLVM 与 8.4s 的 LLVM IR 翻译 + 目标文件发射。
LLVM 分组互相重叠
每个 LLVM 部分最多打印四组,其中只有两组是互斥的:
| 分组 | 关系 |
|---|---|
Pass execution timing report | pass 本身 |
Analysis execution timing report | 分析,与 pass 分离 |
Instruction Selection and Scheduling | ISel pass 内部的子计时器 |
Register Allocation | RA pass 内部的子计时器 |
后两组来自SelectionDAGISel::CodeGenAndEmitDAG与贪心分配器内部的NamedRegionTimer对象,因此其时间已被 pass 报告计入。某目标上 LLVM 时间 =pass execution + analyses;另外两组仅作为其父 pass 的细分:例如 AArch64 指令选择 8.9s 中,3.3s 是DAG Combining after legalize types。
排名加速器 pass 时还有一个细节:报告按"每 pass 每模块"一行输出,所以InstCombinePass会出现 765 次、每次约 0.30s。必须按名称聚合并剥离#N实例后缀,否则列表顶部是十行相同记录。
五、追踪基准测试:把编译耗时纳入每日 CI
以上步骤只能测量"某台机器上的某次编译"。要观察编译耗时在数周内的变化趋势,CI 通过utils/benchmarking/kepler/mojo_compilation/每日运行同样的测量,包含两个目标:
| 目标 | 作用 |
|---|---|
make_snapshot | 构建冻结输入并发布到 S3 |
bench_mojo_compile_time | 编译该输入并报告分解 |
输入必须冻结:编译耗时只有在固定输入下才有意义。图编译器或内核库一变,产出的 Mojo 就会变,两者对编译耗时的影响可能不亚于 Mojo 编译器本身。快照把一切钉住,序列中的任何台阶变化都可归因。
快照以源码形式保存库,而非预编译的.mojoc:.mojoc只能在 MLIR dialect 校验和匹配的编译器中加载,而该校验和哈希的是 dialect.td文本、几乎每天变化——预编译包快照一天内即失效。源码只要语言仍接受就能编译,因此基准每次调用都会用被测编译器重建所有包。这次重建不是开销:它编译整个标准库和全部内核库,代码量远超单个产出的模型,并在报告中记为precompile.*。
生成快照
./bazelw run //utils/benchmarking/kepler/mojo_compilation:make_snapshot -- \ --out ~/mojo-compile-snapshot --skip-upload--skip-upload只构建并归档快照而不发布——这正是本地运行想要的,同时绕过节奏检查,保证快照总是重建。
该运行会以与"第一步获取产出 Mojo"相同的方式,为model_inputs.yaml中的每个模型产出 Mojo,复制库源码,从bazel aquery读取预编译配方、重建包一次以确认冻结树可编译,并统计每个源码的 offload 内核数。产出结构:
~/mojo-compile-snapshot/ ├── SNAPSHOT.yaml the recipe, plus a hash and size per file ├── library/ stdlib, kernel and max sources at their repo paths ├── sources/<dir>/ the emitted .mojo files └── mojo_compile_snapshot.tar.gz两件预期之事:图编译器运行需要 HuggingFace 访问模型、且会在当前环境无法到达的目标上失败(与第一步相同),其输出保留在日志中、仅当源码缺失时才展示——那才是真正的失败;而--skip-emit复用--out下已有的.mojo而非再次运行图编译器,在迭代 emit 之后的所有环节时快得多。
本地运行基准
./bazelw run //utils/benchmarking/kepler/mojo_compilation:bench_mojo_compile_time -- \ --snapshot ~/mojo-compile-snapshot \ --runs 1 \ --results /tmp/compile-time.json| 选项 | 含义 |
|---|---|
--list | 打印 manifest 声明的基准名称后退出 |
--snapshot | 编译已解包快照,而非拉取已发布快照 |
--runs | 每个源码的重复次数,每次含两次完整编译(默认 3) |
--results | 把结果写入文件;按扩展名识别.json/.yaml |
--benchmark | 只运行指定基准,可重复指定 |
前四项是本基准自有选项;--benchmark来自共享的 Kepler runner,命令行上其余未列出的选项同理。
先看有哪些可测:
./bazelw run //utils/benchmarking/kepler/mojo_compilation:bench_mojo_compile_time \ -- --listgemma-4-31b.language sm_100a gemma4/gemma4_vision+gemma4_language.mojo gemma-4-31b.constant-subgraphs sm_100a gemma4/gemma4_vision_constant_subgraphs.mojo再按--list打印的精确名称测量其中一个:
./bazelw run //utils/benchmarking/kepler/mojo_compilation:bench_mojo_compile_time -- \ --snapshot ~/mojo-compile-snapshot \ --runs 1 \ --benchmark gemma-4-31b.language库重建无论如何都会发生——它是被测对象的输入,因此过滤只省下模型编译、省不了别的。
每次重复编译源码两次:一次不带计时标志、用编译器默认线程数,记为wall.total;一次带--mlir-timing --llvm-timing获取阶段分解。只有前者是延迟指标——原因见前文标志部分,后者是单线程的。
省略--snapshot则通过 HTTPS 拉取最近发布的快照——这正是 CI 的行为。
成本按编译次数而非分钟预算(分钟随机器而异):一次调用 = 一次库重建 + 每个源码每次重复两次完整编译。重建每次调用只发生一次、不随重复次数增加(所有源码共享同一批包),且大体量源码主导一切。从--runs 1开始。
添加一个模型
基准追踪的每条序列都来自两个目标旁边的model_inputs.yaml。一个模型 = 一次提取(一次图编译器运行),提取产出的文件就是它的 sources,每个 source 单独编译、单独绘图。
添加步骤:
先用"第一步获取产出 Mojo"的命令手动提取新模型,观察输出目录里落下什么。你需要产出的文件名,而它们无法从模型名推断。
添加条目:
- name: my-model target_accelerator: sm_100a emitted_by: model_path: org/My-Model target: cuda:sm_100a command: >- MODULAR_DEBUG=ir-output-dir=<out-dir> ./bazelw run //max/python/max/_entrypoints:pipelines -- warm-cache --model-path org/My-Model --target cuda:sm_100a sources: - name: language file: my-model/the_emitted_file.mojo重新生成快照。emit、offload 内核计数与源码统计全部由 manifest 驱动,其余无需编辑。
四个必须搞对的点:
file是快照内部的路径,不是提取时写出的位置。只有 basename 需匹配某个产出文件;前面的目录由你决定,作用是隔开不同模型的源码。emitted_by在基准运行时不读取。它被记录下来以保证刷新可复现——图编译器产出什么取决于这些设置——所以command必须与上方字段保持一致。name与每个 source 的name组成序列名<model>.<source>,是仪表盘上该序列的身份标识:改名会开启新曲线而非延续旧曲线。模型名不得含点,因为 BigQuery 视图按第一个点切分序列名。- 每个 source 在每次 CI 运行中都要付出每次重复两次完整编译的成本,所以只加能回答问题的 source,而不是提取产物里的每个文件。
结果落入 BigQuery 与 Looker;表与仪表盘细节见 Kepler 文档docs/internal/KeplerBenchmarking.md。
五、基准测试注意事项
用 production 构建做基准。它是实际发布版本、更快、运行成本更低;更重要的是两种构建不可比:在本文测量的 gemma-4 组合上,production 运行 56 个最外层 pass,debug 为 60 个,LowerGlobalPOPToLLVM完全缺席,VerifyParameters运行两次而非五次。逐 pass 加速比在 1.2x 到 8.2x 之间——debug 测量值无法缩放成 production 估算。
由于计时标志强制单线程编译,这些数字是总 CPU 工作量,而非用户感知的延迟。追踪用户可见编译耗时需要另做一次测量:默认线程数、不带计时标志。
加速器模块数是输入属性,不是编译器属性。若它变化,说明产出的 Mojo 形状变了——只有在其保持稳定时,跨快照比较编译耗时才有意义。
仍有约 10% 的时间不属于任何 pass(目前测得各次编译):主机侧与加速器侧的 translation 与 object emit 行。落在那里的回归只会体现在总数上。
附:核心资源速查
| 资源 | 位置 |
|---|---|
| 三个计时标志的 TableGen 定义 | Mojo/tools/mojo/Common/CompilationOptions.td |
MLIRPassTiming配置与根 scope | Mojo/tools/mojo/Build/mojo-build.cpp |
| offload 目标分组编译实现 | Mojo/lib/Compiler/KGENCompiler.cpp |
| 原文档 | Mojo/docs/compiler/manual/CompileTimeProfiling.md |
注:文档中提到的
utils/start-modular.sh、utils/benchmarking/kepler/mojo_compilation/与docs/internal/KeplerBenchmarking.md属于上游仓库配套内容,未包含在当前仓库快照中,引用时以所在分支的实际情况为准。
【免费下载链接】mojoThe Modular Platform (includes MAX & Mojo)项目地址: https://gitcode.com/GitHub_Trending/mo/mojo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考