如何评价《王垠:C 编译器优化过程中的 Bug》?

关注者
601
被浏览
72405
垠神这篇挺好的啊。写C或C++程序的时候遇到前人给埋了一大堆UB坑那真是欲哭无泪。

我上周正好刚刚撞上一个因为我们的前人写的C++代码有UB坑而造成的bug…刚修。有时候有UB坑的代码未必会立即显现出问题,因为可能(C/C++)编译器还没利用上这块UB信息;这种才是最坑爹的——前人一甩锅,后面还不得不接。

我们内部在力求UBSan bug free,因为有些有问题的代码就算没有立即因为UB而被优化成错误的形式,它们常常也隐含着使用不正确的问题。例如说一个经典的,由于 << 导致int overflow的问题。这种问题排查起来真是极其痛苦…

========================================

在给编译器找bug方面,Zhendong Su老师的研究确实好玩。同 @Wish Night 的回答,推荐感兴趣的同学去看看那系列研究。

然后推荐一组UB入门演示稿:
BKK16-503 Undefined Behavior and Compiler Optimizations – Why Your Program Stopped Working With A Newer Compiler - Linaro - SlideShare
里面涉及的一些例子或许就是垠神会感兴趣用来进一步说明的。

========================================

下面开始跑个题。垠神所引用的例子是C语言的:
void contains_null_check(int* p) {
  int dead = *p;
  if (p == 0) {
    return;
  }
  *p = 4;
}
在C(以及C++)里,对空指针解引用确实是未定义行为,所以确实可以引出垠神所引用的Chris Lattner大大文章中所描述的问题——某个编译器有没有那样做是它们的自由,关键是根据规范所述的UB它们是可以那样做的。

那么或许会有吃瓜群众想了解一下像Java这样的语言在同样的场景下会是个什么状况。我就来跑一下这个题。
重点在于:在Java里,对null解引用是有明确定义其正确行为是怎样的——要抛出NullPointerException——所以在Java里具体到这个场景没有任何问题。放个传送门:在Java中,return null 是否安全, 为什么? - RednaxelaFX 的回答

用Java来写一个类似形式的例子:
public class TrapDemo {
  public static void demo(IntBox p) {
    int dead = p.value;
    if (p == null) return;
    p.value = 42;
  }

  public static void main(String[] args) throws Exception {
    demo(null);
  }
}

class IntBox {
  public int value;
}
这里的TrapDemo.demo(IntBox)就跟垠神引用的contains_null_check(int*)例子对应。

运行这个程序的正确结果是:
Exception in thread "main" java.lang.NullPointerException
	at TrapDemo.demo(TrapDemo.java:3)
	at TrapDemo.main(TrapDemo.java:9)

而当我们用Oracle JDK8u101在Mac OS X / x86-64上,其中的JIT编译器来编译TrapDemo.demo(IntBox)方法,会发现用其中的Server Compiler(C2)会在第一次编译时编译出等价于下面形式的代码:
  public static void demo(IntBox p) {
    p.value = 42;
  }
(注意:强调了“第一次编译时”。后面再展开解释)
这个形式有没有看似跟垠神引用的C语言例子的“错误形式”一样?——实际上是不一样的喔。
void contains_null_check_after_RNCE_and_DCE(int* p) {
  //int dead = *p;    // 死代码消除
  //if (false) {      // 死代码
  //  return;         // 死代码
  //}
  *p = 4;
}

上述Java例子的C1与C2初次编译的详细结果我放在gist里了,免得这个回答太长:gist.github.com/rednaxe

上面的JIT编译结果对Java来说为啥是正确的,待我慢慢道来。

解引用(dereference)动作隐含着null检查,如果被解引用的引用为null则需要当场抛出NullPointerException。这个语义是完全定义好的,没有回避的余地。

所以例子的原始形式,把null检查显式写出来的话,是这个样子的:
  public static void demo(IntBox p) {
    if (p == null) throw new NullPointerException(); // implied null check
    int dead = p.value;
    if (p == null) return;
    if (p == null) throw new NullPointerException(); // implied null check
    p.value = 42;
  }
即便p.value的结果被赋值给了一个无用的局部变量(int dead),使得p.value的值自身并没有被使用,但它的副作用——null检查——则必须留下。
<- 这个由规范所强制要求的行为,就是Java版例子与原本的C版例子最大的不同。

把 int dead = p.value; 这句无用代码消除并留下null检查的副作用之后,剩下的代码是:
  public static void demo(IntBox p) {
    if (p == null) throw new NullPointerException(); // implied null check
    if (p == null) return; // 'return' now becomes unreachable code
    if (p == null) throw new NullPointerException(); // implied null check
    p.value = 42;
  }
于是通过条件常量传播(conditional constant propagation)把相同条件的代码合并在一起,剩下的代码就只有:
  public static void demo(IntBox p) {
    if (p == null) throw new NullPointerException(); // implied null check
    p.value = 42;
  }

然后从这里就开始就有更有趣的事情了。
JVM对上面要实现JVM规范,而对下面则是依托于底层的具体平台。所以一个JVM实现可以用尽各种平台相关的办法,来实现出对上层Java应用来说一致的、符合JVM规范的行为。

在Mac OS X(以及诸如Linux等各种POSIX平台)上,对0地址表示的空指针以及0地址附近的一定范围内解引用(读或者写),会可靠地触发SIGSEGV信号。
利用这个平台相关行为,JVM实现就可以采用“隐式空指针检查”(implicit null check)方式来对通常非null的引用的解引用动作进行优化,而不需要显式生成null检查的代码。JVM可以给这些使用了隐式空指针检查的地方关联上一定的符号信息,并且向OS注册SIGSEGV信号的处理函数,在里面查询看fault pc是不是一个已知的隐式空指针检查指令,如果是的话则根据关联的符号信息分派到相应的处理代码去。

回到上文的例子,C2初次编译实际编译出来的代码逻辑是这样的:
  public static void demo(IntBox p) {
    p.value = 42; // implicit null check: dispatch to Label_null_check
    return;

Label_null_check:
    uncommon_trap(Reason_null_check); // go back to interpreter and throw NPE
  }
于是当p不是空指针的时候,这个代码就可以最快速度完成有用的写操作并返回;而当p真的是空指针的时候,它在尝试对p.value做写操作的时候就会触发SIGSEGV,然后经由HotSpot VM注册的信号处理函数跳转到Label_null_check的地方去抛出NullPointerException。

(HotSpot VM在Windows上的实现则是通过SEH来达到同样的隐式空指针检查的效果。微软自家的CLR里的编译器也有同样的优化)

细心的同学可能会留意到上文中的一些细节:如果在代码中某个位置,被解引用的引用绝大多数情况都不是null,那么用上面的隐式空指针检查显然是最快的,因为这个检查是硬件完成的,无论是否利用它硬件都得做这个检查,利用隐式检查可以避免生成显式的null检查+分支。
但如果这个位置上时常会遇到对null解引用,隐式空指针检查就不是最快的了。事实上如果null的情况占多数的话,这种需要通过发信号 -> 信号处理 -> 跳转到空指针检查的后续处理代码的路径,比起直接生成显式检查的路径要长得多也慢得多。所以这种“优化”并不是总是值得的。

HotSpot VM的C1追求实现简单,只针对常见情况优化,它在可以使用隐式空指针检查的平台上会总是选择生成这种形式的代码。
Oracle JDK8u101的C1编译出来的上面的例子是这样的形式:
  public static void demo(IntBox p) {
    p.value;      // implicit null check: dispatch to Label_null_check
    if (p == null) return;
    p.value = 42; // no null check here
    return;

Label_null_check:
    uncommon_trap(Reason_null_check); // go back to interpreter and throw NPE
  }
嗯…有改善空间。

而C2则追求高性能,所以当它发现某个被C2 JIT编译过的方法遇到了至少3次隐式空指针异常之后,就会抛弃这个JIT编译的版本,然后重新JIT编译并生成显式空指针检查的代码:
  public static void demo(IntBox p) {
    if (p == null) throw new NullPointerException(); // implied null check, explicit check
    p.value = 42;
  }

一个例子可以引出很多有趣的讨论对不对? >_<