GCC 与 Clang 内部

本页目录

在系统编程中,理解编译器内部最实用的入口是跟踪产物:源码在哪一步变成 IR,跨文件信息何时可见,最终链接了哪些运行库。这样才能判断一个选项影响语义、优化、目标指令,还是链接过程。

用同一个程序贯穿各阶段

先在独立目录保存 prog.c。它以参数个数选择两种数据,所有计算都使用无符号值,避免引入有符号溢出的未定义行为。结果用于退出状态和输出,方便检查不同构建是否保持行为。

#include <stdint.h>
#include <stddef.h>
#include <stdio.h>

uint32_t sum(const uint32_t *values, size_t count) {
    uint32_t total = 0;
    for (size_t i = 0; i < count; ++i) total += values[i];
    return total;
}
int main(int argc, char **argv) {
    (void)argv;
    const uint32_t values[] = {1, 2, 3, 4};
    uint32_t result = argc > 1 ? sum(values, 4) : sum(values, 2);
    printf("%lu\n", (unsigned long)result);
    return result == (argc > 1 ? 10u : 3u) ? 0 : 1;
}

无参数应打印 3,带任意一个参数应打印 10。这个很小的工作负载用于观察编译链,不足以证明某个优化选项更快;编译器甚至可能直接折叠 main 里的已知数据计算,这正是需要检查最终产物的原因。

驱动程序组织多个阶段

gcc、clang 通常是面向用户的驱动入口,它们选择前端、汇编器、链接器、启动文件与运行库。C++ 最终链接应使用相应的 g++ 或 clang++ 驱动,以带入匹配的 C++ 运行支持;不能因为 .o 已有机器码就认定直接调用 ld 完全等价。

clang '-###' -O2 prog.c -o prog
clang -E prog.c -o prog.i
clang -S -emit-llvm -O2 prog.c -o prog.ll
clang -S -O2 prog.c -o prog.s
clang -c -O2 prog.c -o prog.o

第一条展示计划调用的命令而不执行,后面分别停在预处理、LLVM IR、汇编和目标文件阶段。完整构建可以把多个阶段融合,不一定真的落盘每一种中间文件。Clang 可使用集成汇编器和不同链接器,LLVM IR 也不要求最终必须交给 LLD。Clang 工具链

同一编译任务:前端语义、优化表示与目标代码 源码与编译参数 GCC 前端 语言树 → GENERIC / GIMPLE Clang 前端 AST → LLVM IR GIMPLE SSA → RTL 优化与目标相关处理 LLVM IR → Machine IR 优化与目标相关处理 驱动程序协调编译、汇编、链接与运行库;中间层也会使用目标布局和成本信息。

GIMPLE 与 LLVM IR 都不是简单的“高低级排名”

GCC 前端把语言结构降为通用表示,GIMPLE 提供较简化的语句形式,很多优化在其 SSA 表示上工作,之后进入 RTL 等更接近目标机器的处理。LLVM IR 同样使用 SSA 值,但含目标数据布局、调用约定、内存语义和属性。

中间层复用不意味着完全架构无关:向量宽度、合法指令和成本模型都可能影响优化。后端也不是固定只运行一次“选指令、分配寄存器、调度”,不同目标和优化管线会在不同位置组合这些处理。GCC passes

gcc -O2 -c -fdump-tree-optimized prog.c
clang -O2 -c -Rpass=loop-vectorize -Rpass-missed=loop-vectorize \
  -Rpass-analysis=loop-vectorize prog.c

dump 用来观察结果,优化 remark 用来了解决策。某个循环没有向量化可能因为别名、安全性、收益估计或目标能力,不能仅凭“开了 O3”判断应生成 SIMD。概念推导见 经典优化。

读 IR 时先找数据流和控制流

sum 在源码中反复修改 total,SSA 表示则让每个结果名只有一个定义;循环通常通过 phi 从入口和回边选择不同的上一轮值。phi 不是在所有前驱上都求值,也不是一定对应一条机器指令。优化可能把循环展开、向量化或整体替换,因此具体输出应按当前参数阅读。LLVM phi

观察位置应提出的问题不能直接下的结论
预处理输出 prog.i宏和头文件最终展开成什么业务已经完成类型检查
AST / IR类型、分支、调用和内存访问怎样表达每条 IR 都有对应机器指令
优化 remark某个 pass 为什么接受或拒绝变换没有消息就代表没有任何优化
prog.s该目标实际选择哪些指令链接后布局和地址已确定
最终可执行文件调用是否保留、符号如何解析符号消失就必然运行更快

例如为逐元素变换增加 restrict,可能帮助编译器排除指针重叠,但它同时要求调用者遵守更严格的别名契约。若调用者仍让输入输出以不允许的方式重叠,不能把随后出现的结果变化叫作“优化不稳定”。应先证明前提,再利用前提优化。

LTO:把部分优化推迟到链接可见范围

为下面的双文件命令准备 a.c 与 b.c。a.c 仅知道函数声明,b.c 才包含实现:

/* a.c */
#include <stdio.h>
int doubled(int value);
int main(void) {
    int result = doubled(21);
    printf("%d\n", result);
    return result == 42 ? 0 : 1;
}
/* b.c:本例只以 21 调用,乘法不会溢出 */
int doubled(int value) { return value * 2; }

普通分离编译主要看当前翻译单元;LTO 在对象或其他中间产物中保存可供链接阶段处理的信息,使跨文件内联、常量传播和删除成为可能。

gcc -O2 -flto -c a.c b.c
gcc -O2 -flto a.o b.o -o prog

两阶段都传入所需选项,并通过编译器驱动选择匹配工具链。参与 LTO 的对象、归档工具插件、编译器版本和链接器支持需要兼容;并非所有名为 .o 的文件都包含可供同一种 LTO 消费的表示。

Clang ThinLTO 可写成:

clang -O2 -flto=thin -c a.c b.c
clang -O2 -flto=thin -fuse-ld=lld a.o b.o -o prog

ThinLTO 用摘要完成跨模块分析,并让后端处理可并行、可缓存。它不是“只少做一点优化”,也不是无需链接阶段。版本匹配和缓存策略会影响构建效果。ThinLTO 文档

LTO 不自动看到运行时加载的所有代码;共享库边界、符号可见性、动态替换语义和外部调用都限制可做的假设。它可能减小或增大代码、缩短或延长运行时间,必须测量目标负载,而不是承诺固定收益区间。

跨文件优化:先建立可见性,再看最终代码 a.c:doubled(21) 普通编译只见声明与调用 b.c:value * 2 函数体在另一翻译单元 LTO 在允许的链接范围联合分析 可能内联并传播常量 42,也可能保留独立符号 外部可见性、动态替换和优化策略会限制变换;以最终反汇编为准。

对照普通链接与 LTO 的产物

gcc -O2 -c a.c b.c
gcc a.o b.o -o normal
objdump -d normal
nm normal
gcc -O2 -flto a.c b.c -o with-lto
objdump -d with-lto
nm with-lto

两份程序都应打印 42。先在 main 的反汇编中找 doubled 的调用,再看 LTO 版本是否改为直接使用常量;外部符号是否仍存在与调用是否被内联是相关但不同的问题。nm 适合查符号,不能单独证明每个调用点怎样执行。

工具版本也必须对应:PATH 中的 clang、ld.lld、llvm-profdata 可能来自不同安装。记录各自 --version,出现 bitcode 或 profile 版本不兼容时先核对实际被调用工具;不要仅凭它们都带 LLVM 名称就认为可以任意混搭。

跨文件常量传播,缺的是什么信息?

普通分离编译时,a.c 只有声明,即使实参21是常量,也不能凭函数名字推导42。链接阶段取得实现后,还要确认这个调用能绑定到该实现;LTO 提供跨文件信息,符号语义决定可以用哪些信息。是否内联或折叠,最终仍应检查编译产物。

正在呈现知识画面
跨文件常量传播,缺的是什么信息?

知道函数名和参数还不够;跨文件传播需要看到实现,并允许对实际绑定目标作相应假设。优化机会不等于每个版本一定采用。

PGO:用训练执行指导优化决策

PGO 将实际路径频度反馈给编译器,帮助内联、布局和冷热区分。它不是修改 CPU 分支预测器的静态配置,也不保证训练之外的输入仍最快。

以下为 Clang 插桩式流程,prog.c 应能直接运行;需要参数的程序在训练步骤传入代表性参数:

clang -O2 -fprofile-instr-generate prog.c -o prog-train
LLVM_PROFILE_FILE='train-%p.profraw' ./prog-train
llvm-profdata merge -output=prog.profdata train-*.profraw
clang -O2 -fprofile-instr-use=prog.profdata prog.c -o prog

原始数据先合并,再用于反馈构建;训练文件名含进程 ID 有助于避免并发进程写到同一文件。实际应用还要考虑动态库、多次训练数据的权重、短路退出未写盘和源代码变化造成的匹配问题。Clang PGO 使用说明

训练输入与独立验收输入分别安排

对本页 prog.c,训练阶段至少分别执行无参数与带参数两条路径,再合并本轮生成的 profile。不要让目录里旧源码遗留的 train-*.profraw 自动混入当前实验;为每次训练保留独立目录或明确输入列表。

反馈构建完成后,再运行 ./prog 和 ./prog sample,验证输出仍为 3 和 10。还可用 llvm-profdata show --all-functions --counts prog.profdata 查看是否确实采到函数计数;有一个非空文件不代表关键业务路径已被训练,也不代表版本匹配警告可以忽略。

训练集应覆盖生产中重要的路径与输入规模。只用启动自检训练吞吐服务,得到的热点很可能无代表性。用独立评估负载验证最终构建,并记录编译器版本、源码、训练数据来源;LTO 与 PGO 可以组合,但应分别确认构建链和数据有效。

优化等级是策略集合

选项主要意图常见误解
-O0保留便于调试的低优化构建并不等于每行源码逐字翻译
-O1 / -O2 / -O3不同成本与优化策略集合O3 不保证比 O2 快,O1 不保证不增大体积
-Os / -Oz倾向减小代码不等于所有速度优化都关闭
-march=native允许本机构架相关指令与调优产物可能不能在较旧或不同 CPU 运行
-fno-omit-frame-pointer请求保留便于展开的帧链具体函数和目标上的效果仍依实现

各等级包含哪些 pass 会随编译器、版本和目标改变,不能固定写成“向量化只有 O3 才开”。GCC 可用 gcc -Q -O2 --help=optimizers 查询相关状态。GCC 优化选项

-fno-semantic-interposition 改变对符号可被外部替换的优化假设,不是简单的“不生成 PLT”开关。-ffast-math 等选项会改变可依赖的浮点语义,不能与不改变语言契约的性能开关混为一谈。

将正确性与性能验证分开

Sanitizer 插桩适合发现运行时问题,见 Valgrind 与 Sanitizers 实战。它们会改变布局、耗时和运行库,不能把其测量值直接当发布构建基线。

比较编译选项时固定完成的工作、目标平台和输入,先检查行为,再看时间、代码体积和构建成本。若优化后错误只在 O2 出现,优先检查 UB、数据竞争和 ABI 不匹配,再用最小用例判断是否为编译器问题;不应把永久关闭优化当成已经解释了原因。

关键词:GCC, Clang, driver, GIMPLE, LLVM IR, RTL, LTO, ThinLTO, PGO, optimization remarks, toolchain