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

elang發表於2018-06-29

以下內容為本系列文章的第二部分,如果你還沒看第一部分,請移步或許你並不需要 Rust 和 WASM 來提升 JS 的執行效率 — 第一部分

我嘗試過三種不同的方法對 Base64 VLQ 段進行解碼。

第一個是 decodeCached,它與 source-map 使用的預設實現方式完全相同 — 我已經在上面列出了:

function decodeCached(aStr) {
    var length = aStr.length;
    var cachedSegments = {};
    var end, str, segment, value, temp = {value: 0, rest: 0};
    const decode = base64VLQ.decode;

    var index = 0;
    while (index < length) {
    // Because each offset is encoded relative to the previous one,
    // many segments often have the same encoding. We can exploit this
    // fact by caching the parsed variable length fields of each segment,
    // allowing us to avoid a second parse if we encounter the same
    // segment again.
    for (end = index; end < length; end++) {
        if (_charIsMappingSeparator(aStr, end)) {
        break;
        }
    }
    str = aStr.slice(index, end);

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

        if (segment.length === 2) {
        throw new Error('Found a source, but no line and column');
        }

        if (segment.length === 3) {
        throw new Error('Found a source and line, but no column');
        }

        cachedSegments[str] = segment;
    }

    index++;
    }
}
複製程式碼

第二個是 decodeNoCaching。它實際上就是沒有快取的 decodeCached。每個分段都被單獨解碼。我使用 Int32Array 來進行 segment 儲存,而不再使用 Array

function decodeNoCaching(aStr) {
    var length = aStr.length;
    var cachedSegments = {};
    var end, str, segment, temp = {value: 0, rest: 0};
    const decode = base64VLQ.decode;

    var index = 0, value;
    var segment = new Int32Array(5);
    var segmentLength = 0;
    while (index < length) {
    segmentLength = 0;
    while (!_charIsMappingSeparator(aStr, index)) {
        decode(aStr, index, temp);
        value = temp.value;
        index = temp.rest;
        if (segmentLength >= 5) throw new Error('Too many segments');
        segment[segmentLength++] = value;
    }

    if (segmentLength === 2) {
        throw new Error('Found a source, but no line and column');
    }

    if (segmentLength === 3) {
        throw new Error('Found a source and line, but no column');
    }

    index++;
    }
}
複製程式碼

最後,第三個是 decodeNoCachingNoString,它嘗試通過將字串轉換為 utf8 編碼的 Uint8Array 來避免處理 JavaScript 字串。這個優化受到了下面的啟發:JS VM 將陣列載入優化成單獨記憶體訪問的可能性更高。由於 JS VM 使用的不同的字串表示層級和結構非常複雜,所以將 String.prototype.charCodeAt 優化到相同的水準會更加困難。

我對比了兩個版本,一個是將字串編碼為 utf8 的版本,另一個是使用預編碼字串的版本。用後面的這個“優化”版本,我想評估一下,通過陣列 ⇒ 字串 ⇒ 陣列的轉化,可以給我們帶來多少的效能提升。“優化”版本的實現方式是我們將 source map 以載入到陣列緩衝區,直接從該緩衝區解析它,而不是先把它轉為字串。

let encoder = new TextEncoder();
function decodeNoCachingNoString(aStr) {
    decodeNoCachingNoStringPreEncoded(encoder.encode(aStr));
}

function decodeNoCachingNoStringPreEncoded(arr) {
    var length = arr.length;
    var cachedSegments = {};
    var end, str, segment, temp = {value: 0, rest: 0};
    const decode2 = base64VLQ.decode2;

    var index = 0, value;
    var segment = new Int32Array(5);
    var segmentLength = 0;
    while (index < length) {
    segmentLength = 0;
    while (arr[index] != 59 && arr[index] != 44) {
        decode2(arr, index, temp);
        value = temp.value;
        index = temp.rest;
        if (segmentLength < 5) {
        segment[segmentLength++] = value;
        }
    }

    if (segmentLength === 2) {
        throw new Error('Found a source, but no line and column');
    }

    if (segmentLength === 3) {
        throw new Error('Found a source and line, but no column');
    }

    index++;
    }
}
複製程式碼

下面是我在 Chrome Dev66.0.3343.3(V86.6.189)和 Firefox Nightly60.0a1 中執行我的基準測試得到的結果(2018-02-11):

不同的解碼

注意幾點:

  • 在 V8 和 SpiderMonkey 上,使用快取的版本比的其他版本都要慢。隨著快取數量的增加,其效能急劇下降 — 而無快取版本的效能不會受此影響;
  • 在 SpiderMonkey 上,將字串轉換為型別化陣列再去解析是有利的,而在 V8 上直接字元訪問的速度就已經足夠快了 - 所以只有在把將字串到陣列的轉換移出基準的情況下,使用陣列是有利的。(例如,你將你的資料一開始就載入到型別陣列中)

我很懷疑 V8 團隊近年來沒有改進過 charCodeAt 的效能 — 我清楚地記得 Crankshaft 沒有花費力氣把 charCodeAt 作為特定字串的呼叫方法,反而是將其擴大到所有以字串表示的程式碼塊都能使用,使得從字串載入字元比從型別陣列載入元素慢。

我瀏覽了 V8 問題跟蹤器,發現了下面幾個問題:

這些問題的評論當中,有些引用了 2018 年 1 月末以後的提交版本,這表明正在積極地進行 charCodeAt 的效能改善。出於好奇,我決定在 Chrome Beta 版本中重新執行我的基準測試,並與 Chrome Dev 版本進行比較。

Different Decodes

事實上,通過比較可以發現 V8 團隊的所有提交都是卓有成效的:charCodeAt 的效能從“6.5.254.21”版本到“6.6.189”版本得到了很大提高。 通過對比“無快取”和“使用陣列”的程式碼行,我們可以看到,在老版本的 V8 中,charCodeAt 的表現差很多,所以只是將字串轉換為“Uint8Array”來加快訪問速度就可以帶來效果。然而,在新版本的 V8 中,只是在解析內部進行這種轉換的話,並不能帶來任何效果。

但是,如果您可以不通過轉換,就能直接使用陣列而不是字串,那麼就會帶來效能的提升。 這是為什麼呢? 為了解答這個問題,我在 V8 執行以下程式碼:

function foo(str, i) {
    return str.charCodeAt(i);
}

let str = "fisk";

foo(str, 0);
foo(str, 0);
foo(str, 0);
%OptimizeFunctionOnNextCall(foo);
foo(str, 0);
複製程式碼
╭─ ~/src/v8/v8 ‹master›
╰─$ out.gn/x64.release/d8 --allow-natives-syntax --print-opt-code --code-comments x.js
複製程式碼

這個命令產生了一個巨大的程式集列表,這個證實了我的懷疑,V8 的 “charCodeAt” 仍然沒有針對特定的字串進行特殊處理。這種弱點似乎源自 V8 中的這個程式碼,它可以解釋為什麼陣列訪問速度快於字串的 charCodeAt 的處理。

解析改進

基於這些發現,我們可以從 source-map 解析程式碼中刪除被解析分段的快取,再測試影響效果。

解析和排序時間

就像我們的基準測試預測的那樣,快取對整體效能是不利的:刪除它可以大大提升解析時間。

優化排序 - 演算法改進

現在我們改進了解析效能,讓我們再看一下排序。

有兩個正在排序的陣列:

  1. originalMappings 陣列使用 compareByOriginalPositions 比較器進行排序;
  2. generatedMappings 陣列使用 compareByGeneratedPositionsDeflated 比較器進行排序。

優化 originalMappings 排序

我首先看了一下 compareByOriginalPositions

function compareByOriginalPositions(mappingA, mappingB, onlyCompareOriginal) {
    var cmp = strcmp(mappingA.source, mappingB.source);
    if (cmp !== 0) {
    return cmp;
    }

    cmp = mappingA.originalLine - mappingB.originalLine;
    if (cmp !== 0) {
    return cmp;
    }

    cmp = mappingA.originalColumn - mappingB.originalColumn;
    if (cmp !== 0 || onlyCompareOriginal) {
    return cmp;
    }

    cmp = mappingA.generatedColumn - mappingB.generatedColumn;
    if (cmp !== 0) {
    return cmp;
    }

    cmp = mappingA.generatedLine - mappingB.generatedLine;
    if (cmp !== 0) {
    return cmp;
    }

    return strcmp(mappingA.name, mappingB.name);
}
複製程式碼

我注意到,對映首先由 source 元件進行排序,然後再由其他元件處理。source 指定對映最先來自哪個原始檔。一個顯而易見的想法是,我們可以將 originalMappings 變成陣列的集合:originalMappings [i] 是包含第 i 個原始檔所有對映的陣列,而不再使用巨大的 originalMappings 陣列直接將來自不同原始檔的對映混在一起。通過這種方式,我們可以把從原始檔解析出來的對映排序存到不同的 originalMappings [i] 陣列中,然後對單個較小的陣列再進行排序。

實際上是個[桶排序](https://en.wikipedia.org/wiki/Bucket_sort)

這是我們在解析迴圈中做的:

if (typeof mapping.originalLine === 'number') {
    // This code used to just do: originalMappings.push(mapping).
    // Now it sorts original mappings already by source during parsing.
    let currentSource = mapping.source;
    while (originalMappings.length <= currentSource) {
    originalMappings.push(null);
    }
    if (originalMappings[currentSource] === null) {
    originalMappings[currentSource] = [];
    }
    originalMappings[currentSource].push(mapping);
}
複製程式碼

在那之後:

var startSortOriginal = Date.now();
// The code used to sort the whole array:
//     quickSort(originalMappings, util.compareByOriginalPositions);
for (var i = 0; i < originalMappings.length; i++) {
    if (originalMappings[i] != null) {
    quickSort(originalMappings[i], util.compareByOriginalPositionsNoSource);
    }
}
var endSortOriginal = Date.now();
複製程式碼

“compareByOriginalPositionsNoSource”比較器幾乎與“compareByOriginalPositions”比較器完全相同,只是它不再比較“source”元件 - 根據我們構造 originalMappings [i] 陣列的方式,這樣可以保證是公平的。

解析和排序時間

這個演算法改進可同時提升 V8 和 SpiderMonkey 上的排序速度,還可以改進 V8 上的解析速度。

解析速度的提升是由於處理 originalMappings 陣列的消降低了:生成一個單一的巨大的 originalMappings 陣列比生成多個較小的 originalMappings [i] 陣列要消耗更多。不過,這只是我的猜測,沒有經過任何嚴格的分析。

優化 generatedMappings 排序

讓我們看一下 generatedMappingscompareByGeneratedPositionsDeflated 比較器。

function compareByGeneratedPositionsDeflated(mappingA, mappingB, onlyCompareGenerated) {
    var cmp = mappingA.generatedLine - mappingB.generatedLine;
    if (cmp !== 0) {
    return cmp;
    }

    cmp = mappingA.generatedColumn - mappingB.generatedColumn;
    if (cmp !== 0 || onlyCompareGenerated) {
    return cmp;
    }

    cmp = strcmp(mappingA.source, mappingB.source);
    if (cmp !== 0) {
    return cmp;
    }

    cmp = mappingA.originalLine - mappingB.originalLine;
    if (cmp !== 0) {
    return cmp;
    }

    cmp = mappingA.originalColumn - mappingB.originalColumn;
    if (cmp !== 0) {
    return cmp;
    }

    return strcmp(mappingA.name, mappingB.name);
}
複製程式碼

這裡我們首先比較 generatedLine 的對映。一般對比原始的原始檔,可能會生成更多的行,所以將 generatedMappings 分成多個單獨的陣列是沒有意義的。

但是,當我看到解析程式碼時,我注意到了以下的內容:

while (index < length) {
    if (aStr.charAt(index) === ';') {
    generatedLine++;
    // ...
    } else if (aStr.charAt(index) === ',') {
    // ...
    } else {
    mapping = new Mapping();
    mapping.generatedLine = generatedLine;

    // ...
    }
}
複製程式碼

這是程式碼中唯一出現 generatedLine 的地方,這意味著 generatedLine 是單調增長的 — 意味著 generatedMappings 陣列已經被 generatedLine 排序了,所以對整個陣列排序沒有意義。相反,我們可以對每個較小的子陣列進行排序。我們把程式碼改成下面這樣:

let subarrayStart = 0;
while (index < length) {
    if (aStr.charAt(index) === ';') {
    generatedLine++;
    // ...

    // Sort subarray [subarrayStart, generatedMappings.length].
    sortGenerated(generatedMappings, subarrayStart);
    subarrayStart = generatedMappings.length;
    } else if (aStr.charAt(index) === ',') {
    // ...
    } else {
    mapping = new Mapping();
    mapping.generatedLine = generatedLine;

    // ...
    }
}
// Sort the tail.
sortGenerated(generatedMappings, subarrayStart);
複製程式碼

我沒有使用 快速排序 來排序子陣列,而是決定使用插入排序,類似於一些 VM 用於 Array.prototype.sort 的混合策略。

注意:如果輸入陣列已經排序,插入排序會比快速排序更快...事實證明,用於基準測試的對映實際上是排序過的。如果我們期望 generatedMappings 在解析之後幾乎都是被排序過的,那麼在排序之前先簡單地檢查 generatedMappings 是否已經排序會更有效率。

const compareGenerated = util.compareByGeneratedPositionsDeflatedNoLine;

function sortGenerated(array, start) {
    let l = array.length;
    let n = array.length - start;
    if (n <= 1) {
    return;
    } else if (n == 2) {
    let a = array[start];
    let b = array[start + 1];
    if (compareGenerated(a, b) > 0) {
        array[start] = b;
        array[start + 1] = a;
    }
    } else if (n < 20) {
    for (let i = start; i < l; i++) {
        for (let j = i; j > start; j--) {
        let a = array[j - 1];
        let b = array[j];
        if (compareGenerated(a, b) <= 0) {
            break;
        }
        array[j - 1] = b;
        array[j] = a;
        }
    }
    } else {
    quickSort(array, compareGenerated, start);
    }
}
複製程式碼

這產生以下結果:

解析和排序時間

排序時間急劇下降,而解析時間稍微增加 — 這是因為程式碼將 generatedMappings 作為解析迴圈的一部分進行排序,使得我們的分解略顯無意義。讓我們對比下改善總時間(解析和排序一起)。

改善總時間

解析和排序時間

現在很明顯,我們大大提高了整體對映解析效能。

我們還可以做些什麼來改善效能嗎?

是的:我們可以從 asm.js/WASM 指南中抽出一頁,而不用在 JavaScript 程式碼基礎上全部換作使用 Rust。

優化解析 - 降低 GC 壓力

我們正在分配成千上萬的 Mapping 物件,這給 GC 帶來了相當大的壓力 - 然而我們並不是真的需要這樣的物件 - 我們可以將它們打包成一個型別陣列。這是我的做法。

幾年前,我對 Typed Objects 提案感到非常興奮,該提案將允許 JavaScript 程式設計師定義結構體和結構體陣列以及很多令人驚喜的東西,這樣很方便。但不幸的是,推動該提案的領導者離開去做其他方面的工作,這讓我們不得不要麼自己動手,要麼使用 C++程式碼來編寫這些東西。

首先,我將 Mapping 從一個普通物件變成一個指向型別陣列的一個包裝器,它將包含我們所有的對映。

function Mapping(memory) {
    this._memory = memory;
    this.pointer = 0;
}
Mapping.prototype = {
    get generatedLine () {
    return this._memory[this.pointer + 0];
    },
    get generatedColumn () {
    return this._memory[this.pointer + 1];
    },
    get source () {
    return this._memory[this.pointer + 2];
    },
    get originalLine () {
    return this._memory[this.pointer + 3];
    },
    get originalColumn () {
    return this._memory[this.pointer + 4];
    },
    get name () {
    return this._memory[this.pointer + 5];
    },
    set generatedLine (value) {
    this._memory[this.pointer + 0] = value;
    },
    set generatedColumn (value) {
    this._memory[this.pointer + 1] = value;
    },
    set source (value) {
    this._memory[this.pointer + 2] = value;
    },
    set originalLine (value) {
    this._memory[this.pointer + 3] = value;
    },
    set originalColumn (value) {
    this._memory[this.pointer + 4] = value;
    },
    set name (value) {
    this._memory[this.pointer + 5] = value;
    },
};
複製程式碼

然後我調整了解析和排序程式碼,如下所示:

BasicSourceMapConsumer.prototype._parseMappings = function (aStr, aSourceRoot) {
    // Allocate 4 MB memory buffer. This can be proportional to aStr size to
    // save memory for smaller mappings.
    this._memory = new Int32Array(1 * 1024 * 1024);
    this._allocationFinger = 0;
    let mapping = new Mapping(this._memory);
    // ...
    while (index < length) {
    if (aStr.charAt(index) === ';') {

        // All code that could previously access mappings directly now needs to
        // access them indirectly though memory.
        sortGenerated(this._memory, generatedMappings, previousGeneratedLineStart);
    } else {
        this._allocateMapping(mapping);

        // ...

        // Arrays of mappings now store "pointers" instead of actual mappings.
        generatedMappings.push(mapping.pointer);
        if (segmentLength > 1) {
        // ...
        originalMappings[currentSource].push(mapping.pointer);
        }
    }
    }

    // ...

    for (var i = 0; i < originalMappings.length; i++) {
    if (originalMappings[i] != null) {
        quickSort(this._memory, originalMappings[i], util.compareByOriginalPositionsNoSource);
    }
    }
};

BasicSourceMapConsumer.prototype._allocateMapping = function (mapping) {
    let start = this._allocationFinger;
    let end = start + 6;
    if (end > this._memory.length) {  // Do we need to grow memory buffer?
    let memory = new Int32Array(this._memory.length * 2);
    memory.set(this._memory);
    this._memory = memory;
    }
    this._allocationFinger = end;
    let memory = this._memory;
    mapping._memory = memory;
    mapping.pointer = start;
    mapping.name = 0x7fffffff;  // Instead of null use INT32_MAX.
    mapping.source = 0x7fffffff;  // Instead of null use INT32_MAX.
};

exports.compareByOriginalPositionsNoSource =
    function (memory, mappingA, mappingB, onlyCompareOriginal) {
    var cmp = memory[mappingA + 3] - memory[mappingB + 3];  // originalLine
    if (cmp !== 0) {
    return cmp;
    }

    cmp = memory[mappingA + 4] - memory[mappingB + 4];  // originalColumn
    if (cmp !== 0 || onlyCompareOriginal) {
    return cmp;
    }

    cmp = memory[mappingA + 1] - memory[mappingB + 1];  // generatedColumn
    if (cmp !== 0) {
    return cmp;
    }

    cmp = memory[mappingA + 0] - memory[mappingB + 0];  // generatedLine
    if (cmp !== 0) {
    return cmp;
    }

    return memory[mappingA + 5] - memory[mappingB + 5];  // name
};
複製程式碼

正如你所看到的,可讀性確實受到了很大影響。理想情況下,我希望在需要處理對應分段時分配臨時的“對映”物件。然而,這種程式碼風格將嚴重依賴於虛擬機器通過_allocation sinking_,_scalar replacement_或其他類似的優化來消除這些臨時包裝分配的能力。不幸的是,在我的實驗中,SpiderMonkey 無法很好地處理這樣的程式碼,因此我選擇了更多冗長且容易出錯的程式碼。

這種幾乎純手工進行記憶體管理的方式在 JS 中是不多見的。這就是為什麼我認為在這裡值得提出,“oxidized” source-map 實際上需要使用者手動管理它的生命週期,以確保 WASM 資源被釋放。

重新執行基準測試,證明緩解 GC 壓力產生了很好的改善效果。

重新分配後

重新分配後

有趣的是,在 SpiderMonkey 上,這種方法對於解析和排序都有改善效果,這對我來說真是一個驚喜。

SpiderMonkey 效能斷崖

當我使用這段程式碼時,我還發現了 SpiderMonkey 中令人困惑的效能斷崖現象:當我將預置記憶體緩衝區的大小從 4 MB 增加到 64 MB 來衡量重新分配的消耗時,基準測試顯示當進行第 7 次迭代後效能突然下降了。

重新分配後

這看起來像某種多型性,但我不能立即就搞明白如何改變陣列的大小可以導致這樣的多型行為。

我很困惑,但我找到了一個 SpiderMonkey 黑客 Jan de Mooij,他很快識別出 罪魁禍首是 asm.js 從 2012 年開始的相關優化......然後他將它從 SpiderMonkey 中刪除,這樣就不會有人再遇到這種情況了。

優化分析 - 使用 Uint8Array 替代字串。

最後,如果我們使用 Uint8Array 代替字串來解析,我們又可以得到小的改善效果。

重新分配後

需要我們重寫 source-map,直接使用型別陣列解析對映而不再使用 JavaScript 的字串方法 JSON.decode 進行解析。我沒有做過這樣的改寫,但我想應該沒有什麼問題。

對基線的總體改進

這是開始的情況:

$ d8 bench-shell-bindings.js
...
[Stats samples: 5, total: 24050 ms, mean: 4810 m
s, stddev: 155.91063145276527 ms]
$ sm bench-shell-bindings.js
...
[Stats samples: 7, total: 22925 ms, mean: 3275 ms, stddev: 269.5999093306804 ms]
複製程式碼

這是我們完成時的情況:

$ d8 bench-shell-bindings.js
...
[Stats samples: 22, total: 25158 ms, mean: 1143.5454545454545 ms, stddev: 16.59358125226469 ms]
$ sm bench-shell-bindings.js
...
[Stats samples: 31, total: 25247 ms, mean: 814.4193548387096 ms, stddev: 5.591064299397745 ms]
複製程式碼

重新分配後

重新分配後

這是 4 倍的效能提升!

也許值得注意的是,儘管這並不是必須的,但我們仍然對所有的 originalMappings 陣列進行了排序。只有兩個操作使用到 originalMappings

  • allGeneratedPositionsFor 它返回給定線的所有生成位置;
  • eachMapping(..., ORIGINAL_ORDER) 它按照原始順序對所有對映進行迭代。

如果我們假設 allGeneratedPositionsFor 是最常見的操作,並且我們只在少數 originalMappings [i] 陣列中搜尋,那麼無論何時我們需要搜尋其中的一個,我們都可以通過對 originalMappings [i] 陣列進行排序來大大提高解析時間。

最後比較 1 月 19 日的 V8 和 2 月 19 日的 V8 分別對應包含和不包含減少不可信程式碼的修改

重新分配後

比較 Oxidized source-map 版本

繼 2 月 19 日釋出這篇文章之後,我收到一些反饋要求將我改進的 source-map 與使用 Rust 和 WASM 的主線的 Oxidized source-map 相比較。

快速檢視 parse_mappings 的 Rust 原始碼,發現 Rust 版本沒有排序原始對映,只會生成等價的 generatedMappings 並且排序。為了匹配這種行為,我通過註釋掉 originalMappings [i] 陣列的排序來調整我的 JS 版本。

這裡是僅僅是解析的對比結果(其中還包括對 generatedMappings 進行排序),然後對所有 generatedMappings 進行解析和迭代。

只有解析時間

解析和迭代次數

請注意,這個對比有點誤導,因為 Rust 版本並未像我的 JS 版本那樣優化 generatedMappings 的排序。

因此,我不會說,“我們已經成功達到 Rust+WASM 版本的水平”。但是,在這樣成都的效能差異水準下,我們可能需要重新評估在 source-map 中使用如此複雜的 Rust 是否是真正值得的。

更新(2018 年 2 月 27 日)

source-map 的作者 Nick Fitzgerald 把本文描述的演算法已更新到 Rust+WASM 的版本。以下是解析和迭代的對比效能圖表:

解析和迭代次數

正如你可以看到 WASM+Rust 版本在 SpiderMonkey 上的速度現在增加了大約 15%,而在 V8 上的速度也大致相同。

學習

對於 JavaScript 開發人員

分析器是你的朋友

以各種形式進行分析和效能跟蹤是獲得高效能的最佳方法。它允許您在程式碼中放置熱點,來揭示執行時的潛在問題。基於這個原因,不要回避使用像 perf 這樣的底層分析工具 - “友好”的工具可能不會告訴你整個狀況,因為它們隱藏了底層的分析。

不同的效能問題需要不同的方法去分析並能夠視覺化地去收集分析結果。一定確保您熟悉各種可用的工具。

演算法很重要

能夠根據抽象複雜性來推理你的程式碼是一項重要的技能。快速排序一個具有十萬個元素的陣列好呢?還是快速排序 3333 個陣列,每個子陣列有 30 元素更好呢?

數學計算可以告訴我們((100000 log 100000)比(3333 倍的 30 log 30)大 3 倍)- 如果資料量越大,通常能夠數學變換就會變得越重要。

除了瞭解對數之外,你還需要知道一些常識,並且能夠評估你的程式碼在平均和最糟糕的情況下的使用情況:哪些操作很常見,昂貴的運算成本如何攤銷,昂貴的運算攤銷帶來的壞處是什麼?

虛擬機器也在工作。問題開發者!

不要猶豫,與開發人員討論奇怪的效能問題。並非所有事情都可以通過改變自己的程式碼來解決。俄國諺語說道:“製作罐子的不是上帝!”虛擬機器開發人員也是人,他們也一樣會犯錯誤。只要把問題理清,他們也相當擅長把這些問題修復。一封郵件或或一個聊天訊息或 DM 可能為您節省通過外部 C++ 程式碼進行除錯的時間。

虛擬機器仍然需要一點幫助

有時候您也需要編寫一些底層程式碼或者瞭解一些底層的實現細節,這樣有助於挖掘 JavaScript 的最後一絲效能。

人們可能希望有更好的語言級別的工具來實現這一點,但是我們能不能實現還有待觀察。

對於語言實現者/設計者

巧妙的優化必須是可檢測的

如果您的執行時具有任何內建的智慧優化,那麼您需要提供一個直觀的工具來診斷這些優化失敗的時間並向開發人員提供可操作的反饋。

在 JavaScript 這樣的語言環境中,至少有像 profiler 這樣的分析工具為您單個操作提供一種專業化方法來檢測,以確定虛擬機器優化的結果是好是壞並且指出原因。

這種排序的自檢工具不能依賴於在虛擬機器的某個版本上打個特殊的補丁,然後輸出一堆毫無可讀性的除錯結果。相反,它應該是你需要的任何時候,只要開啟除錯工具視窗,它就能把結果呈現出來。

語言和優化必須是朋友

最後,作為一名語言設計師,您應該嘗試預測語言缺乏哪些特性,從而更容易編寫出效能良好的程式碼。市場上的使用者是否需要手動設定和管理記憶體?我確定他們是的。如果大多數人使用了您的語言最後都寫出大量效能很低的程式碼,那就只能通過新增大量的語言特性或者通過其他途徑來提升程式碼的效能。(例如,通過更復雜的優化或請求使用者用 Rust 重構程式碼)

以下是一些語言設計的通用法則:如果要為您的語言新增新特性,請確保運算過程的合理性,而且這些特性很容易被理解和檢測。從整個語言層面去考慮優化工作,而不是對一些使用頻率很低、效能相對較差的非核心特性去做優化工作。

後記

我們在這篇文章中發現的優化大致分成三個部分:

  1. 演算法改進;
  2. 如何優化完全獨立的程式碼和有潛在依賴關係的程式碼;
  3. 針對 V8 的優化方法。

無論您使用哪種程式語言,都需要考慮到演算法效能。當您在本身就“比較慢”的程式語言中使用糟糕的演算法時,您能更容易的注意到這一點,但是如果只是換成使用“比較快”的程式語言,還繼續使用相同的演算法,即使問題會有所緩解,但依然無法從根本上解決問題。這篇文章中的很大一部分內容都致力於這個部分的優化:

  • 對子陣列排序優化效果要優於對整個陣列進行排序優化;
  • 討論使用或者不使用快取的優缺點。

第二部分是單態性。由於多型性而導致的效能降低不是 V8 特有的問題。這也不是一個 JS 特有的問題。您可以通過不同的實現方式,甚至跨語言的去應用單態。有些語言(Rust,實際上)已經在引擎內為您實現。

最後一個也是最有爭議的部分是引數適配問題。

最後,使用對映表示法進行的優化(將單個物件封裝到單個型別陣列中)橫跨了文中提及的三個部分。這是建立在對 GCed 系統的侷限性和效能花銷,以及 JS 虛擬機器作了哪些特殊優化的基礎上進行的。

所以... 為什麼我選擇了這個標題?這是因為我堅信第三部分涉及的問題都會隨著時間的推移而被修復。其他部分可通過常用程式語言進行跨語言實現。

很顯然,每個開發人員和每個團隊都可以自由的去選擇,到底是花費 N 小時去分析,閱讀和思考他們的 JavaScript 程式碼,還是花費 M 小時用 X 語言重寫他們的東西。

但是:(a)每個人都需要充分意識到這種選擇是存在的;(b)語言設計者和實現者應該共同努力使這樣的選擇越來越不明顯 - 也就是說在語言特徵和工具方面開展工作,減少“第 3 部分”優化的需求。

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


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

相關文章