[V8原始碼分析] V8 使用“常量摺疊”優化技巧,導致冪(**)運算有時候不等於 Math.pow()

justjavac發表於2019-02-13

本文是“V8原始碼分析”系列的一篇文章。


在如今的主流 Web 程式語言中,如 PHP 或 Python 等,都包含冪運算子(一般來說符號是 ^ 或者 **)。而最新的 ES7 中也增加了對冪運算的支援,使用符號 **,最新的 Chrome 已經提供了對冪運算的支援。

但是在 javascript 中,** 運算有時候並不等於 Math.pow(a,b),在最新的 Chrome 55 中:

Math.pow(99,99) 的結果是 3.697296376497263e+197,

但是 99**99 的結果是 3.697296376497268e+197。

兩者並不相等

3.697296376497263e+197
3.697296376497268e+197

而且 Math.pow(99,99) – 99**99 的結果也不是 0 而是 -5.311379928167671e+182。

因此我們猜測,** 操作符只是冪運算的另一個實現。但是當我們寫一個函式時,冪運算又表現出詭異的特性:

function diff(x) {
  return Math.pow(x,x) - x**x;
}複製程式碼

呼叫 diff(99) 返回 0。WTF?兩者又相等了!

猜猜下面程式碼輸出什麼?

var x = 99;
x**x - 99**99;複製程式碼

這段程式碼的執行結果是 -5.311379928167671e+182。


這簡直是薛定諤的冪

究其原因,V8 引擎使用了常量摺疊(const folding)。常量摺疊是一種編譯器的編譯優化技術。

考慮如下程式碼:

for (let i = 0; i < 100*100*100; i++){
  // 迴圈體
}複製程式碼

該迴圈的條件 i<100*100*100 是一個表示式(expression),如果放到判斷時再求值那麼 100*100*100 的計算將會進行 1000000 次。如果編譯器在語法分析階段進行常量合併,該迴圈將會變為這樣:

for (let i = 0; i < 1000000; i++){
  // 迴圈體
}複製程式碼

而上文中提到的 99**99 的計算也使用到了常量摺疊。也就是說 99**99 是在編譯時進行計算(常量摺疊),而 Math.pow 總是在執行時進行計算。當我們使用變數進行冪運算時(例 a**b)此時不存在常量摺疊,因此 a ** b 的值在執行時進行計算,** 會被編譯成 Math.pow 呼叫。

在原始碼 src/parsing/parser.cc 檔案中,編譯時計算程式碼:

case Token::EXP: {
double value = Pow(x_val, y_val);
int int_value = static_cast<int>(value);
*x = factory()->NewNumberLiteral(
    int_value == value && value != -0.0 ? int_value : value, pos,
    has_dot);
return true;複製程式碼

可以看到使用了 Pow 函式計算了冪運算的求值結果。Pow 是一個 inline 的函式,內部做了一些常規優化,對不能優化的情況則使用了 std::pow(x, y) 來計算最終結果。

而 Math.pow 的演算法為:

// ES6 section 20.2.2.26 Math.pow ( x, y )
TF_BUILTIN(MathPow, CodeStubAssembler) {
  Node* x = Parameter(1);
  Node* y = Parameter(2);
  Node* context = Parameter(5);
  Node* x_value = TruncateTaggedToFloat64(context, x);
  Node* y_value = TruncateTaggedToFloat64(context, y);
  Node* value = Float64Pow(x_value, y_value);
  Node* result = ChangeFloat64ToTagged(value);
  Return(result);
}複製程式碼

可見兩者使用了不同的演算法。但是當不做常量摺疊的時候,** 則轉換成了 Math.pow 函式呼叫:

Expression* Parser::RewriteExponentiation(
    Expression* left, 
    Expression* right,
    int pos) {
  ZoneList<Expression*>* args = new (zone()) ZoneList<Expression*>(2, zone());
  args->Add(left, zone());
  args->Add(right, zone());
  return factory()->NewCallRuntime(Context::MATH_POW_INDEX, args, pos);
}複製程式碼

於是就造成了 ** 有時不等於 Math.pow 的怪異問題。再看看如下程式碼:

console.log(99**99);
a = 99, b = 99;
console.log(a**b);
console.log(Math.pow(99, 99));複製程式碼

分別輸出:

3.697296376497268e+197
3.697296376497263e+197
3.697296376497263e+197

其實

9999=369729637649726772657187905628805440595668764281741102430259972423552570455277523421410650010128232727940978889548326540119429996769494359451621570193644014418071060667659301384999779999159200499899

因此第一個結果更接近準確的值。

上週(2017年1月16日)這個怪異的行為已經作為一個 bug 提交給了 V8 專案,bug 編號 #5848。

相關文章