ImplicitNullChecks

800 多行的 Pass

这个 LLVM 后端 MachineFunctionPass —— ImplicitNullChecks,是一个非常经典的优化 Pass:**将显式空指针检查转换为隐式的 faulting load/store,从而减少分支指令、提升执行效率。将显式的“test ptr, ptr → je throw → load(ptr)”模式折叠成一个带故障跳转的内存访问指令,利用页面保护在发生空指针访问时隐式触发异常处理,从而消除多余的 null check。
下面是涉及的概念:

  • 显式 vs 隐式 Null Check
    可以先看一下汇编层面是怎么做显示 Null Check 的:
test %r10, %r10
je throw_npe
movl (%r10), %esi

在访问内存前先通过 test检查是否为 null,手动跳转。

隐式的 Null Check 如下:

faulting_load_op("movl (%r10), %esi", throw_npe)

不判断,直接执行内存访问,若触发 page fault,由 runtime 的 fault handler 处理。

  • 下一个概念:FaultMaps
  • LLVM 支持将「发生异常时跳转到某个 handler」的意图嵌入到 .fault_maps section中,供运行时识别
  • Addressing Mode & Displacement
    • Pass 要求能「确认 load 的地址计算逻辑」,确保在 ptr == null 时一定会 page fault,关键是偏移不能太大。
可调参数
static cl::opt<int> PageSize(
  "imp-null-check-page-size",
  cl::desc("The page size of the target in bytes"),
  cl::init(4096), cl::Hidden);
  • 默认页大小:4096 字节(4KB)
  • 用于判断 displacement 是否小于一个页,确保 dereference NULL 时会引发 page fault。
static cl::opt<unsigned> MaxInstsToConsider(
  "imp-null-max-insts-to-consider",
  cl::desc("The max number of instructions to consider hoisting loads over"),
  cl::Hidden, cl::init(8));
  • 默认值:8。
  • 限制最多向前看多少条指令以判断能否 hoist load。
源码层面

该 Pass 基本是一个线性的处理逻辑:

for (auto &MBB : MF)  
  analyzeBlockForNullChecks(MBB, NullCheckList);

核心处理函数 analyzeBlockForNullChecks

  1. 识别显式空指针检查点
    • 扫描每个机器基本块,看它的底层 IR(或 MachineBasicBlock)是否带有 “null-check” 的元信息标记。
    • 只有那些以类似 cmp ptr, 0(测试寄存器是否为 0)+ 条件跳转到“抛空指针异常” 的模式作为块末尾的,才进入后续分析。
  2. 定位分支目标
    • 根据条件谓词(EQ 或 NE)判定:
      • “不为 null” 的路径(NotNullSucc)── 继续执行正常内存访问的分支;
      • “为 null” 的路径(NullSucc)── 跳到抛异常的分支。
  3. 扫描真分支(NotNullSucc)内的指令
    • 从真分支入口开始,按顺序扫描最多 N 条指令(参数可调),寻找第一条可以当作“隐式 null check” 的内存访问指令(load/store)。
    • “可用”意味着:
      • 它读写的地址寄存器正是前面那个被测试的指针寄存器;
      • 它的偏移量在一个“安全范围”内(即小于页面大小),保证“ptr 为 0”时必定触发页错误;
      • 它与扫描过的那些指令之间、以及与后续可能的依赖指令之间不存在不可打乱的依赖关系。
  4. 检查可调度性
    • 若目标内存操作与前面扫描过的指令中,仅有不超过一个的“依赖”需要一并上移(hoist),且这样做不会破坏“空指针页必然触发” 的假设,也不会修改已检测到的分支环境(例如寄存器活跃性),则认定“能折叠”。
  5. 记录 NullCheck 描述
    • 一旦上面都满足,就把这条内存操作、原有的比较指令、真/假两条分支目标、以及(如果有)需要一起上移的依赖指令,都封装成一个 NullCheck 对象,推入候选列表。
  6. 早退出与失败条件
    • 如果在扫描过程中:
      • 扫描指令数超过阈值;
      • 扫描到修改了指针寄存器值的指令;
      • 找不到合适的内存操作;
      • 或者依赖、别名、安全范围校验失败;
    • 则退出。

下面再来看一个具体例子。

; --- Block A 开头 ---
test   %rax, %rax           ; 显式空指针检查(ptr == 0 ?)
je     LabelNull            ; 如果是 null,跳到抛异常
; --- 真分支 LabelNotNull 开头 ---
LabelNotNull:
  add    $8, %rax           ; 偏移指针(非关键指令)
  mov    (%rax), %rbx       ; ← 这条 load 将用作隐式空检查
  ; ... 还有更多跟指针无关的指令 ...
; --- 假分支 LabelNull 开头 ---
LabelNull:
  call   throw_null_pointer ; 抛空指针异常

下面我们来模拟这个 Pass 的执行流程。
①首先,LLVM 开始线性扫描指令,首先看到了 test 指令,那么此时就知道,这是在检查空指针了
②然后下面因为空指针检查必然是两个分支,一个真分支,一个假分支;这里先去真分支。
③在真分支里,会扫描所有内存操作 (直到达到参数上限的指令条数)
④例如add $8, %rax它修改了 %rax(我们关心的指针寄存器),所以不能折叠检查。Pass 在这里会停止,放弃本块的优化。
假设改成 nop 或其它不改 %rax 的指令,扫描继续……
遇到 mov (%rax), %rbx

  • 这是一次对 %rax 基址的 load,偏移为 0(小于页面大小)。
  • 它读取地址 %rax + 0,当 %rax == 0 时必定触发页错误(操作系统会因访问 “地址 0” 所在页而抛出异常)。
  • 并且这条 load 与前面扫描过的指令 不存在依赖冲突(因为那些指令都不改 %rbx、不改 %rax)。
  • 因此,这条 mov 就被判定为“可用来折叠空指针检查”的内存操作。
    ⑦发现合适的 load 后,Pass 会把以下信息封装成一个 NullCheck:
  • MemOperation:这条 mov (%rax), %rbx
  • CheckOperation:之前的 test %rax, %rax
  • CheckBlock:包含这两条的基本块(Block A)
  • NotNullSucc:LabelNotNull
  • NullSucc:LabelNull
  • OnlyDependency:如果在这两条之间还有一条其他指令依赖这条 load,就一并记录;否则为空
    • 然后函数返回 true,表示这个块可以做隐式检查优化,不再继续扫描。
      ⑧然后就是后续改写了,会写成
; 合并结果
faulting_load_op("mov (%rax), %rbx", LabelNull)
jmp    LabelNotNull
LabelNotNull:
  ; 原来的其他指令...
LabelNull:
  call throw_null_pointer

总的来说,这个 Pass 删掉了显式的比较和条件跳转,节省了 2 条机器指令,消除了可能得分支预测失败开销,提高了流水线利用率。