← 回首頁

每一篇「X 比 Y 快」的跑分,都有三個你沒看到的旋鈕

同樣的程式碼、同一台機器,結論可以相反。熱身、死碼消除、輸入大小各自都能決定誰贏——而跑分文章幾乎從來不說它們設在哪。

一個碼表的指針分裂成兩根,一根指左、一根指右。

有人貼了一份跑分:for 比 reduce 快 40%。留言分成兩派——本來就相信的人, 以及自己跑了一次拿到相反結果的人。兩邊量的都沒錯。

決定結果的是那個裝置,而那個裝置通常沒有被寫出來

一份微跑分至少有三個會改變誰贏的設定,而跑分文章幾乎不會提到任何一個:

  1. 熱身次數。 引擎先直譯執行,等函式被呼叫夠多次才編譯成最佳化過的機器碼。 零熱身量到的是直譯器加上編譯器——那是你的生產程式碼只付一次的工作, 在這裡被算進了每一輪。
  2. 結果有沒有被用到。 算完就丟,引擎有可能證明這個呼叫沒有可觀察的效果並把它 刪掉。你量到的就變成一個空迴圈。
  3. 輸入大小。 小輸入由呼叫開銷主導,大輸入由記憶體存取主導。漸進成本相同的 實作可以在這兩個區間裡交換位置。

自己把它們翻過來

下面是兩個做完全同一件事的實作——把陣列加總。先用預設值跑一次,然後一次只動一個旋鈕。

微跑分實驗室

熱身次數

陣列長度

結果

你這台瀏覽器跑出來的結果

還沒跑過。

預設值刻意是那個「壞」的設定:零熱身、結果丟掉。那就是一份花五分鐘寫出來的 跑分長的樣子,而你在網路上找得到的多數跑分都是那個形狀。

我不會告訴你哪一個贏,因為在我的機器上答案隨設定改變,在你的機器上也會。 那正是重點——如果一個數字會因為你改了文章沒提到的東西而移動,那個數字從來就 不是在講程式碼。

三個旋鈕各自在做什麼

熱身。 V8 先在直譯器裡跑一個函式、觀察它,等它看起來夠熱才升級成最佳化程式碼。 零熱身等於在計時慢路徑加上編譯。整天跑在迴圈裡的生產程式碼永遠在快路徑上, 所以沒有熱身的跑分量的是一個你的程式幾乎從不處於的狀態。

死碼消除。 這個會產生最壯觀的結果——某段程式碼看起來幾乎不花時間, 因為它被刪掉了。修法是讓結果逃逸:累加進一個之後會被讀到的變數。 上面那個元件的「累加起來」就是在做這件事,而它只有一行。

輸入大小。 這是最多人以為自己已經處理好的一個(「我用了實際的資料量」), 但一個尺寸就是一個點。你真正想知道的是那兩條線會不會交叉,而交叉是看不出來的, 如果你只量了一個點。

微跑分誠實的用途

它擅長否決,不擅長挑選。

一個在所有設定組合下都比較慢的改動,就是比較慢——那個結論很穩固。 一個只在某個熱身值加某個輸入大小才贏的改動,你學到的是那個裝置的事, 不是你的程式的事。

而當你真的要發表一份跑分時,把旋鈕寫出來。不是因為有人會去查, 而是寫下來這個動作本身,會逼你注意到那些值是你自己挑的。

常見追問

為什麼熱身影響這麼大?

JavaScript 引擎一開始是直譯執行,要等一個函式被呼叫夠多次才會編譯成最佳化過的 機器碼。沒有熱身的跑分量到的是直譯器,再加上編譯的成本——而那是你的生產程式碼 只付一次、卻被這個跑分算進每一輪的工作。

死碼消除對跑分做了什麼?

如果你算出一個值然後把它丟掉,引擎有可能證明整個呼叫沒有任何可觀察的效果, 然後把它刪掉。你量到的就變成一個空迴圈,然後得出「這段程式碼免費」的結論。 把結果累加進一個之後會被讀到的變數,那個證明就不成立了。

輸入大小不是等比例放大而已嗎?

不是。小輸入由呼叫開銷主導,大輸入由記憶體存取主導。兩個漸進複雜度相同的實作 可以在這兩個區間裡交換位置,所以單一個尺寸是一個點,不是一條曲線。

那微跑分是不是就沒用了?

它適合用來**否決**,不適合用來**挑選**。一個在所有設定下都比較慢的改動, 是真的比較慢。一個只在某個熱身值加某個輸入大小才贏的改動, 告訴你的是那個裝置的事,不是生產環境的事。