[譯] 或許你並不需要 Rust 和 WASM 來提升 JS 的執行效率 — 第一部分

shery發表於2018-06-16

幾個星期前,我在 Twitter 上看到一篇名為 “Oxidizing Source Maps with Rust and WebAssembly” 的推文,其內容主要是討論用 Rust 編寫的 WebAssembly 替換 source-map 庫中純 JavaScript 編寫的核心程式碼所帶來的效能優勢。

這篇文章使我感興趣的原因,並不是因為我擅長 Rust 或 WASM,而是因為我總是對語言特性和純 JavaScript 中缺少的效能優化感到好奇。

於是我從 GitHub 檢出了這個庫,然後逐字逐句的記錄了這次小型效能研究。

獲取程式碼

對於我的研究,當時使用的是近乎預設配置的 x64 V8 的釋出版本,V8 版本對應著 1 月 20 日的提交歷史 commit 69abb960c97606df99408e6869d66e014aa0fb51。為了能夠根據需要深入到生成的機器碼,我通過 GN 標誌啟用了反彙編程式,這是我唯一偏離預設配置的地方。

╭─ ~/src/v8/v8 ‹master›
╰─$ gn args out.gn/x64.release --list --short --overrides-only
is_debug = false
target_cpu = "x64"
use_goma = true
v8_enable_disassembler = true
複製程式碼

然後我獲取了兩個版本的 source-map,版本資訊如下:

  • commit c97d38b,在 Rust/WASM 實裝前最近一次更新 dist/source-map.js 的提交記錄;
  • commit 51cf770,當我進行這次調查時的最近一次提交記錄;

分析純 JavaScript 版本

在純 JavaScript 版本中進行基準測試很簡單:

╭─ ~/src/source-map/bench ‹ c97d38b›
╰─$ d8 bench-shell-bindings.js
Parsing source map
console.timeEnd: iteration, 4655.638000
console.timeEnd: iteration, 4751.122000
console.timeEnd: iteration, 4820.566000
console.timeEnd: iteration, 4996.942000
console.timeEnd: iteration, 4644.619000
[Stats samples: 5, total: 23868 ms, mean: 4773.6 ms, stddev: 161.22112144505135 ms]
複製程式碼

我做的第一件事是禁用基準測試的序列化部分:

diff --git a/bench/bench-shell-bindings.js b/bench/bench-shell-bindings.js
index 811df40..c97d38b 100644
--- a/bench/bench-shell-bindings.js
+++ b/bench/bench-shell-bindings.js
@@ -19,5 +19,5 @@ load("./bench.js");
    print("Parsing source map");
    print(benchmarkParseSourceMap());
    print();
-print("Serializing source map");
-print(benchmarkSerializeSourceMap());
+// print("Serializing source map");
+// print(benchmarkSerializeSourceMap());
複製程式碼

然後把它放到 Linux 的 perf 效能分析工具中:

╭─ ~/src/source-map/bench ‹perf-work›
╰─$ perf record -g d8 --perf-basic-prof bench-shell-bindings.js
Parsing source map
console.timeEnd: iteration, 4984.464000
^C[ perf record: Woken up 90 times to write data ]
[ perf record: Captured and wrote 24.659 MB perf.data (~1077375 samples) ]
複製程式碼

請注意,我將 --perf-basic-prof 標誌傳遞給了 d8 二進位制檔案,它通知 V8 生成一個輔助對映檔案 /tmp/perf-$pid.map。該檔案允許 perf report 理解 JIT 生成的機器碼。

這是我們切換到主執行執行緒後通過 perf report --no-children 獲得的內容:

Overhead  Symbol
    17.02%  *doQuickSort ../dist/source-map.js:2752
    11.20%  Builtin:ArgumentsAdaptorTrampoline
    7.17%  *compareByOriginalPositions ../dist/source-map.js:1024
    4.49%  Builtin:CallFunction_ReceiverIsNullOrUndefined
    3.58%  *compareByGeneratedPositionsDeflated ../dist/source-map.js:1063
    2.73%  *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
    2.11%  Builtin:StringEqual
    1.93%  *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
    1.66%  *doQuickSort ../dist/source-map.js:2752
    1.25%  v8::internal::StringTable::LookupStringIfExists_NoAllocate
    1.22%  *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
    1.21%  Builtin:StringCharAt
    1.16%  Builtin:Call_ReceiverIsNullOrUndefined
    1.14%  v8::internal::(anonymous namespace)::StringTableNoAllocateKey::IsMatch
    0.90%  Builtin:StringPrototypeSlice
    0.86%  Builtin:KeyedLoadIC_Megamorphic
    0.82%  v8::internal::(anonymous namespace)::MakeStringThin
    0.80%  v8::internal::(anonymous namespace)::CopyObjectToObjectElements
    0.76%  v8::internal::Scavenger::ScavengeObject
    0.72%  v8::internal::String::VisitFlat<v8::internal::IteratingStringHasher>
    0.68%  *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
    0.64%  *doQuickSort ../dist/source-map.js:2752
    0.56%  v8::internal::IncrementalMarking::RecordWriteSlow
複製程式碼

事實上, 就像 “Oxidizing Source Maps …” 那篇博文說的那樣,基準測試相當側重於排序上:doQuickSort 出現在配置檔案的頂部,並且在列表中還多次出現(這意味著它已被優化/去優化了幾次)。

優化排序 — 引數適配

在效能分析器中出現了一些可疑內容,分別是 Builtin:ArgumentsAdaptorTrampolineBuiltin:CallFunction_ReceiverIsNullOrUndefined,它們似乎是V8實現的一部分。如果我們讓 perf report 追加與它們關聯的呼叫鏈資訊,那麼我們會注意到這些函式大多也是從排序程式碼中呼叫的:

- Builtin:ArgumentsAdaptorTrampoline
    + 96.87% *doQuickSort ../dist/source-map.js:2752
    +  1.22% *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
    +  0.68% *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
    +  0.68% Builtin:InterpreterEntryTrampoline
    +  0.55% *doQuickSort ../dist/source-map.js:2752

- Builtin:CallFunction_ReceiverIsNullOrUndefined
    + 93.88% *doQuickSort ../dist/source-map.js:2752
    +  2.24% *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
    +  2.01% Builtin:InterpreterEntryTrampoline
    +  1.49% *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
複製程式碼

現在是檢視程式碼的時候了。快速排序實現本身位於 lib/quick-sort.js 中,並通過解析 lib/source-map-consumer.js 中的程式碼進行呼叫。用於排序的比較函式是 compareByGeneratedPositionsDeflatedcompareByOriginalPositions

通過檢視這些比較函式是如何定義,以及如何在快速排序中呼叫,可以發現呼叫時的引數數量不匹配:

function compareByOriginalPositions(mappingA, mappingB, onlyCompareOriginal) {
    // ...
}

function compareByGeneratedPositionsDeflated(mappingA, mappingB, onlyCompareGenerated) {
    // ...
}

function doQuickSort(ary, comparator, p, r) {
    // ...
        if (comparator(ary[j], pivot) <= 0) {
        // ...
        }
    // ...
}
複製程式碼

通過梳理原始碼發現除了測試之外,quickSort 只被這兩個函式呼叫過。

如果我們修復呼叫引數數量問題會怎麼樣?

diff --git a/dist/source-map.js b/dist/source-map.js
index ade5bb2..2d39b28 100644
--- a/dist/source-map.js
+++ b/dist/source-map.js
@@ -2779,7 +2779,7 @@ return /******/ (function(modules) { // webpackBootstrap
            //
            //   * Every element in `ary[i+1 .. j-1]` is greater than the pivot.
            for (var j = p; j < r; j++) {
-             if (comparator(ary[j], pivot) <= 0) {
+             if (comparator(ary[j], pivot, false) <= 0) {
                i += 1;
                swap(ary, i, j);
                }
複製程式碼

注意:因為我不想花時間搞清楚構建過程,所以我直接在 dist/source-map.js 中進行編輯。

╭─ ~/src/source-map/bench ‹perf-work› [Fix comparator invocation arity]
╰─$ d8 bench-shell-bindings.js
Parsing source map
console.timeEnd: iteration, 4037.084000
console.timeEnd: iteration, 4249.258000
console.timeEnd: iteration, 4241.165000
console.timeEnd: iteration, 3936.664000
console.timeEnd: iteration, 4131.844000
console.timeEnd: iteration, 4140.963000
[Stats samples: 6, total: 24737 ms, mean: 4122.833333333333 ms, stddev: 132.18789657150916 ms]
複製程式碼

僅僅通過修正引數不匹配,我們將 V8 的基準測試平均值從 4774 ms 提高到了 4123 ms,提升了 14% 的效能。如果我們再次對基準測試進行效能分析,我們會發現 ArgumentsAdaptorTrampoline 已經完全消失。為什麼最初它會出現呢?

事實證明,ArgumentsAdaptorTrampoline 是 V8 應對 JavaScript 可變引數呼叫約定的機制:您可以在呼叫有 3 個引數的函式時只傳入 2 個引數 —— 在這種情況下,第三個引數將被填充為 undefined。V8 通過在堆疊上建立一個新的幀,接著向下複製引數,然後呼叫目標函式來完成此操作:

引數適配

如果您從未聽說過執行棧,請檢視維基百科 和 Franziska Hinkelmann 的部落格文章

儘管對於真實程式碼這類開銷可以忽略不計,但在這段程式碼中,comparator 函式在基準測試執行期間被呼叫了數百萬次,這擴大了引數適配的開銷。

細心的讀者可能還會注意到,現在我們明確地將以前使用隱式 undefined 的引數設定為布林值 false。這看起來對效能改進有一定貢獻。如果我們用 void 0 替換 false,我們會得到稍微差一點的測試資料:

diff --git a/dist/source-map.js b/dist/source-map.js
index 2d39b28..243b2ef 100644
--- a/dist/source-map.js
+++ b/dist/source-map.js
@@ -2779,7 +2779,7 @@ return /******/ (function(modules) { // webpackBootstrap
            //
            //   * Every element in `ary[i+1 .. j-1]` is greater than the pivot.
            for (var j = p; j < r; j++) {
-             if (comparator(ary[j], pivot, false) <= 0) {
+             if (comparator(ary[j], pivot, void 0) <= 0) {
                i += 1;
                swap(ary, i, j);
                }
複製程式碼
╭─ ~/src/source-map/bench ‹perf-work U› [Fix comparator invocation arity]
╰─$ ~/src/v8/v8/out.gn/x64.release/d8 bench-shell-bindings.js
Parsing source map
console.timeEnd: iteration, 4215.623000
console.timeEnd: iteration, 4247.643000
console.timeEnd: iteration, 4425.871000
console.timeEnd: iteration, 4167.691000
console.timeEnd: iteration, 4343.613000
console.timeEnd: iteration, 4209.427000
[Stats samples: 6, total: 25610 ms, mean: 4268.333333333333 ms, stddev: 106.38947316346669 ms]
複製程式碼

對於引數適配開銷的爭論似乎是高度針對 V8 的。當我在 SpiderMonkey 下對引數適配進行基準測試時,我看不到採用引數適配後有任何顯著的效能提升:

╭─ ~/src/source-map/bench ‹ d052ea4› [Disabled serialization part of the benchmark]
╰─$ sm bench-shell-bindings.js
Parsing source map
[Stats samples: 8, total: 24751 ms, mean: 3093.875 ms, stddev: 327.27966571700836 ms]
╭─ ~/src/source-map/bench ‹perf-work› [Fix comparator invocation arity]
╰─$ sm bench-shell-bindings.js
Parsing source map
[Stats samples: 8, total: 25397 ms, mean: 3174.625 ms, stddev: 360.4636187025859 ms]
複製程式碼

多虧了 Mathias Bynens 的 jsvu 工具,SpiderMonkey shell 現在非常易於安裝。

讓我們回到排序程式碼。如果我們再次分析基準測試,我們會注意到 ArgumentsAdaptorTrampoline 從結果中消失了,但 CallFunction_ReceiverIsNullOrUndefined 仍然存在。這並不奇怪,因為我們仍在呼叫 comparator 函式。

優化排序 — 單態(monomorphise)

怎樣比呼叫函式的效能更好呢?不呼叫它!

這裡明顯的選擇是嘗試將 comparator 內聯到 doQuickSort。然而事實上使用不同 comparator 函式呼叫 doQuickSort 阻礙了內聯。

要解決這個問題,我們可以嘗試通過克隆 doQuickSort 來實現單態(monomorphise)。下面是我們如何做到的。

我們首先使用 SortTemplate 函式將 doQuickSort 和其他 helpers 包裝起來:

function SortTemplate(comparator) {
    function swap(ary, x, y) {
    // ...
    }

    function randomIntInRange(low, high) {
    // ...
    }

    function doQuickSort(ary, p, r) {
    // ...
    }

    return doQuickSort;
}
複製程式碼

然後,我們通過先將 SortTemplate 函式轉換為一個字串,再通過 Function 建構函式將它解析成函式,從而對我們的排序函式進行克隆:

function cloneSort(comparator) {
    let template = SortTemplate.toString();
    let templateFn = new Function(`return ${template}`)();
    return templateFn(comparator);  // Invoke template to get doQuickSort
}
複製程式碼

現在我們可以使用 cloneSort 為我們使用的每個 comparator 生成一個排序函式:

let sortCache = new WeakMap();  // Cache for specialized sorts.
exports.quickSort = function (ary, comparator) {
    let doQuickSort = sortCache.get(comparator);
    if (doQuickSort === void 0) {
    doQuickSort = cloneSort(comparator);
    sortCache.set(comparator, doQuickSort);
    }
    doQuickSort(ary, 0, ary.length - 1);
};
複製程式碼

重新執行基準測試生成的結果:

╭─ ~/src/source-map/bench ‹perf-work› [Clone sorting functions for each comparator]
╰─$ d8 bench-shell-bindings.js
Parsing source map
console.timeEnd: iteration, 2955.199000
console.timeEnd: iteration, 3084.979000
console.timeEnd: iteration, 3193.134000
console.timeEnd: iteration, 3480.459000
console.timeEnd: iteration, 3115.011000
console.timeEnd: iteration, 3216.344000
console.timeEnd: iteration, 3343.459000
console.timeEnd: iteration, 3036.211000
[Stats samples: 8, total: 25423 ms, mean: 3177.875 ms, stddev: 181.87633161024556 ms]
複製程式碼

我們可以看到平均時間從 4268 ms 變為 3177 ms(提高了 25%)。

分析器顯示了以下圖片:

Overhead Symbol
    14.95% *doQuickSort :44
    11.49% *doQuickSort :44
    3.29% Builtin:StringEqual
    3.13% *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
    1.86% v8::internal::StringTable::LookupStringIfExists_NoAllocate
    1.86% *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
    1.72% Builtin:StringCharAt
    1.67% *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
    1.61% v8::internal::Scavenger::ScavengeObject
    1.45% v8::internal::(anonymous namespace)::StringTableNoAllocateKey::IsMatch
    1.23% Builtin:StringPrototypeSlice
    1.17% v8::internal::(anonymous namespace)::MakeStringThin
    1.08% Builtin:KeyedLoadIC_Megamorphic
    1.05% v8::internal::(anonymous namespace)::CopyObjectToObjectElements
    0.99% v8::internal::String::VisitFlat<v8::internal::IteratingStringHasher>
    0.86% clear_page_c_e
    0.77% v8::internal::IncrementalMarking::RecordWriteSlow
    0.48% Builtin:MathRandom
    0.41% Builtin:RecordWrite
    0.39% Builtin:KeyedLoadIC
複製程式碼

與呼叫 comparator 相關的開銷現在已從結果中完全消失。

這個時候,我開始對我們花了多少時間來解析對映和對它們進行排序產生了興趣。我進入到解析部分的程式碼並新增了幾個 Date.now() 記錄耗時:

我想用 performance.now(),但是 SpiderMonkey shell 顯然不支援它。

diff --git a/dist/source-map.js b/dist/source-map.js
index 75ebbdf..7312058 100644
--- a/dist/source-map.js
+++ b/dist/source-map.js
@@ -1906,6 +1906,8 @@ return /******/ (function(modules) { // webpackBootstrap
            var generatedMappings = [];
            var mapping, str, segment, end, value;

+
+      var startParsing = Date.now();
            while (index < length) {
                if (aStr.charAt(index) === ';') {
                generatedLine++;
@@ -1986,12 +1988,20 @@ return /******/ (function(modules) { // webpackBootstrap
                }
                }
            }
+      var endParsing = Date.now();

+      var startSortGenerated = Date.now();
            quickSort(generatedMappings, util.compareByGeneratedPositionsDeflated);
            this.__generatedMappings = generatedMappings;
+      var endSortGenerated = Date.now();

+      var startSortOriginal = Date.now();
            quickSort(originalMappings, util.compareByOriginalPositions);
            this.__originalMappings = originalMappings;
+      var endSortOriginal = Date.now();
+
+      console.log(`${}, ${endSortGenerated - startSortGenerated}, ${endSortOriginal - startSortOriginal}`);
+      console.log(`sortGenerated: `);
+      console.log(`sortOriginal:  `);
            };
複製程式碼

這是生成的結果:

╭─ ~/src/source-map/bench ‹perf-work U› [Clone sorting functions for each comparator]
╰─$ d8 bench-shell-bindings.js
Parsing source map
parse:         1911.846
sortGenerated: 619.5990000000002
sortOriginal:  905.8220000000001
parse:         1965.4820000000004
sortGenerated: 602.1939999999995
sortOriginal:  896.3589999999995
^C
複製程式碼

以下是在 V8 和 SpiderMonkey 中每次迭代執行基準測試時解析對映和排序的耗時:

解析和排序耗時

在 V8 中,我們花費幾乎和排序差不多的時間來進行解析對映。在 SpiderMonkey 中,解析對映速度更快,反而是排序較慢。這促使我開始檢視解析程式碼。

優化解析 — 刪除分段快取

讓我們再看看這個效能分析結果

Overhead  Symbol
    18.23%  *doQuickSort :44
    12.36%  *doQuickSort :44
    3.84%  *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
    3.07%  Builtin:StringEqual
    1.92%  v8::internal::StringTable::LookupStringIfExists_NoAllocate
    1.85%  *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
    1.59%  *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
    1.54%  Builtin:StringCharAt
    1.52%  v8::internal::(anonymous namespace)::StringTableNoAllocateKey::IsMatch
    1.38%  v8::internal::Scavenger::ScavengeObject
    1.27%  Builtin:KeyedLoadIC_Megamorphic
    1.22%  Builtin:StringPrototypeSlice
    1.10%  v8::internal::(anonymous namespace)::MakeStringThin
    1.05%  v8::internal::(anonymous namespace)::CopyObjectToObjectElements
    1.03%  v8::internal::String::VisitFlat<v8::internal::IteratingStringHasher>
    0.88%  clear_page_c_e
    0.51%  Builtin:MathRandom
    0.48%  Builtin:KeyedLoadIC
    0.46%  v8::internal::IteratingStringHasher::Hash
    0.41%  Builtin:RecordWrite
複製程式碼

以下是在我們刪除了已知曉的 JavaScript 程式碼之後剩下的內容:

Overhead  Symbol
    3.07%  Builtin:StringEqual
    1.92%  v8::internal::StringTable::LookupStringIfExists_NoAllocate
    1.54%  Builtin:StringCharAt
    1.52%  v8::internal::(anonymous namespace)::StringTableNoAllocateKey::IsMatch
    1.38%  v8::internal::Scavenger::ScavengeObject
    1.27%  Builtin:KeyedLoadIC_Megamorphic
    1.22%  Builtin:StringPrototypeSlice
    1.10%  v8::internal::(anonymous namespace)::MakeStringThin
    1.05%  v8::internal::(anonymous namespace)::CopyObjectToObjectElements
    1.03%  v8::internal::String::VisitFlat<v8::internal::IteratingStringHasher>
    0.88%  clear_page_c_e
    0.51%  Builtin:MathRandom
    0.48%  Builtin:KeyedLoadIC
    0.46%  v8::internal::IteratingStringHasher::Hash
    0.41%  Builtin:RecordWrite
複製程式碼

當我開始檢視單個條目的呼叫鏈時,我發現其中很多都通過 KeyedLoadIC_Megamorphic 傳入 SourceMapConsumer_parseMappings

-    1.92% v8::internal::StringTable::LookupStringIfExists_NoAllocate
    - v8::internal::StringTable::LookupStringIfExists_NoAllocate
        + 99.80% Builtin:KeyedLoadIC_Megamorphic

-    1.52% v8::internal::(anonymous namespace)::StringTableNoAllocateKey::IsMatch
    - v8::internal::(anonymous namespace)::StringTableNoAllocateKey::IsMatch
        - 98.32% v8::internal::StringTable::LookupStringIfExists_NoAllocate
            + Builtin:KeyedLoadIC_Megamorphic
        + 1.68% Builtin:KeyedLoadIC_Megamorphic

-    1.27% Builtin:KeyedLoadIC_Megamorphic
    - Builtin:KeyedLoadIC_Megamorphic
        + 57.65% *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
        + 22.62% *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
        + 15.91% *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
        + 2.46% Builtin:InterpreterEntryTrampoline
        + 0.61% BytecodeHandler:Mul
        + 0.57% *doQuickSort :44

-    1.10% v8::internal::(anonymous namespace)::MakeStringThin
    - v8::internal::(anonymous namespace)::MakeStringThin
        - 94.72% v8::internal::StringTable::LookupStringIfExists_NoAllocate
            + Builtin:KeyedLoadIC_Megamorphic
        + 3.63% Builtin:KeyedLoadIC_Megamorphic
        + 1.66% v8::internal::StringTable::LookupString
複製程式碼

這種呼叫堆疊向我表明,程式碼正在執行很多 obj[key] 的鍵控查詢,同時 key 是動態構建的字串。當我檢視解析程式碼時,我發現了以下程式碼

// 由於每個偏移量都是相對於前一個偏移量進行編碼的,
// 因此許多分段通常具有相同的編碼。
// 從而我們可以通過快取每個分段解析後的可變長度欄位,
// 如果我們再次遇到相同的分段,
// 可以不再對他進行解析。
for (end = index; end < length; end++) {
    if (this._charIsMappingSeparator(aStr, end)) {
    break;
    }
}
str = aStr.slice(index, end);

segment = cachedSegments[str];
if (segment) {
    index += str.length;
} else {
    segment = [];
    while (index < end) {
    base64VLQ.decode(aStr, index, temp);
    value = temp.value;
    index = temp.rest;
    segment.push(value);
    }

    // ...

    cachedSegments[str] = segment;
}
複製程式碼

該程式碼負責解碼 Base64 VLQ 編碼序列,例如,字串 A 將被解碼為 [0],並且 UAAAA 被解碼為 [10,0,0,0,0]。如果你想更好地理解編碼本身,我建議你檢視這篇關於 source maps 內部實現細節的部落格文章

該程式碼不是對每個序列進行獨立解碼,而是試圖快取已解碼的分段:它向前掃描直到找到分隔符 (, or ;),然後從當前位置提取子字串到分隔符,並通過在快取中查詢提取的子字串來檢查我們是否有先前解碼過的這種分段——如果我們命中快取,則返回快取的分段,否則我們進行解析,並將分段快取到快取中。

快取(又名記憶化)是一種非常強大的優化技——然而,它只有在維護快取本身,以及查詢快取結果比再次執行計算這個過程開銷小時才有意義。

抽象分析

讓我們嘗試抽象地比較這兩個操作。

一種是直接解析:

解析分段只檢視一個分段的每個字元。對於每個字元,它執行少量比較和算術運算,將 base64 字元轉換為它所表示的整數值。然後它執行幾個按位操作來將此整數值併入較大的整數值。然後它將解碼值儲存到一個陣列中並移動到該段的下一部分。分段不得多於 5 個。

另一種是快取:

  1. 為了查詢快取的值,我們遍歷該段的所有字元以找到其結尾;
  2. 我們提取子字串,這需要分配資源和可能的複製,具體取決於 JS VM 中字串的實現方式;
  3. 我們使用這個字串作為 Dictionary 物件中的鍵名,其中:
    1. 首先需要 VM 為該字串計算雜湊值(再次遍歷它並對單個字元執行各種按位操作),這可能還需要 VM 將字串內部化(取決於實現方式);
    2. 那麼 VM 必須執行雜湊表查詢,這需要通過值與其他鍵進行探測和比較(這可能需要再次檢視字串中的單個字元);

總的來看,直接解析應該更快,假設 JS VM 在獨立運算/按位操作方面做得很好,僅僅是因為它只檢視每個單獨的字元一次,而快取需要遍歷該分段 2-4 次,以確定我們是否命中快取。

效能分析似乎也證實了這一點:KeyedLoadIC_Megamorphic 是 V8 用於實現上面程式碼中類似 cachedSegments[str] 等鍵控查詢的存根。

基於這些觀察,我著手做了幾個實驗。首先,我檢查瞭解析結尾有多大的 cachedSegments 快取。它越小快取效率越高。

結果發現它變得相當大:

Object.keys(cachedSegments).length = 155478
複製程式碼

獨立微型基準測試(Microbenchmarks)

現在我決定寫一個小的獨立基準測試:

// 用 [n] 個分段生成一個字串,分段在長度為 [v] 的迴圈中重複,
// 例如,分段數為 0,v,2 * v,... 都相等,
// 因此是 1, 1 + v, 1 + 2 * v, ...
// 使用 [base] 作為分段中的基本值 —— 這個引數允許分段很長。
//
// 注意:[v] 越大,[cachedSegments] 快取越大。
function makeString(n, v, base) {
    var arr = [];
    for (var i = 0; i < n; i++) {
    arr.push([0, base + (i % v), 0, 0].map(base64VLQ.encode).join(''));
    }
    return arr.join(';') + ';';
}

// 對字串 [str] 執行函式 [f]。
function bench(f, str) {
    for (var i = 0; i < 1000; i++) {
    f(str);
    }
}

// 衡量並報告 [f] 對 [str] 的表現。
// 它有 [v] 個不同的分段。
function measure(v, str, f) {
    var start = Date.now();
    bench(f, str);
    var end = Date.now();
    report(`${v}, ${f.name}, ${(end - start).toFixed(2)}`);
}

async function measureAll() {
    for (let v = 1; v <= 256; v *= 2) {
    // 製作一個包含 1000 個分段的字串和 [v] 個不同的字串
    // 因此 [cachedSegments] 具有 [v] 個快取分段。
    let str = makeString(1000, v, 1024 * 1024);

    let arr = encoder.encode(str);

    // 針對每種解碼方式執行 10 次迭代。
    for (var j = 0; j < 10; j++) {
        measure(j, i, str, decodeCached);
        measure(j, i, str, decodeNoCaching);
        measure(j, i, str, decodeNoCachingNoStrings);
        measure(j, i, arr, decodeNoCachingNoStringsPreEncoded);
        await nextTick();
    }
    }
}

function nextTick() { return new Promise((resolve) => setTimeout(resolve)); }
複製程式碼

以上為本文的第一部分,更多內容詳見 或許你並不需要 Rust 和 WASM 來提升 JS 的執行效率 — 第二部分

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章