[譯] 論 Rust 和 WebAssembly 對原始碼地址索引的極限優化

kylin1484915235000發表於2019-02-27

Tom Tromey 和我嘗試使用 Rust 語言進行編碼,然後用 WebAssembly 進行編譯打包後替換 source-map(原始碼地址索引,以下行文為了理解方便均不進行翻譯)的 JavaScript 工具庫中效能敏感的部分。在實際場景中以相同的基準進行對比操作,WebAssembly 的效能要比已有的 source-map 庫快上 5.89 倍。另外,多次測試結果也更為一致:相對一致的情況下偏差值很小。

我們以提高效能的名義將那些令人費解又難以閱讀的 JavaScript 程式碼替換成更加語義化的 Rust 程式碼,這確實行之有效。

現在,我們把 Rust 結合 WebAssembly 使用的經驗分享給大家,也鼓勵程式設計師按照自己的需求對效能敏感的 JavaScript 進行重構。

背景

source-map 的技術規範

source map 檔案提供了 JavaScript 原始碼被編譯器[0]、壓縮工具、包管理工具轉譯成的檔案之間的地址索引供程式設計人員使用。JavaScript 開發者工具使用 source-map 後可以實現字元級別的回溯,除錯工具中的按步除錯也是依賴它來實現的。Source-map 對報錯資訊的編碼方式與 DWARF’s .debug_line 的部分標準很相似。

source-map 物件是 JSON 物件的其中一個分支。其中 “對映集” 用字串表示,是 source-map 的重要組成部分,包含了最終程式碼和定位物件的雙向索引。

我們用 extended Backus-Naur form (EBNF) 標準描述 “對映集” 的字串語法。

Mappings 是 JavaScript 程式碼塊的分組行號,每一個對映集只要以分號結尾了就代表一個獨立的對映集,它就自增 1。同一行 JavaScript 程式碼如果生成多個對映集,就用逗號分隔開:

<mappings> = [ <generated-line> ] ';' <mappings>
            | ''
            ;

<generated-line> = <mapping>
                    | <mapping> ',' <generated-line>
                    ;
複製程式碼

每一個獨立的對映集都能定位到當初生成它的那段 JavaScript 程式碼,還能有一個關聯名字的可選項能定位到那段程式碼中的原始碼字串:

<mapping> = <generated-column> [ <source> <original-line> <original-column> [ <name> ] ] ;
複製程式碼

每個對映集元件都通過一種叫做大數值的位數可變表示法(Variable Length Quantity,縮寫為 VLQ)編碼成二進位制數字。檔名和相關聯的名字被編碼後儲存在 source-map 的 JSON 物件中。每一個值標註了原始碼最後出現的位置,現在,給你一個 <source> 值那麼它跟前一個 <source> 值就給我們提供了一些資訊。如果這些值之間趨向于越來越小,就說明它們在被編碼的時候更加緊密:

<generated-column> = <vlq> ;
<source> = <vlq> ;
<original-line> = <vlq> ;
<original-column> = <vlq> ;
<name> = <vlq> ;
複製程式碼

利用 VLQ 編碼後的字元都能從 ASCII 字符集中找到,比如大小寫的字母,又或者是十進位制數字跟一些符號。每個字元都表示了一個 6 位大小的值。VLQ 編碼後的二進位制數前五位用來表示數值,最後一位只用來做標記正負。

與其向你解釋 EBNF 標準,不如來看一段簡單的 VLQ 轉換程式碼實現:

constant SHIFT = 5
constant CONTINUATION_BIT = 1 << SHIFT
constant MASK = (1 << SHIFT) - 1

decode_vlq(input):
    let accumulation = 0
    let shift = 0

    let next_digit_part_of_this_number = true;
    while next_digit_part_of_this_number:
        let [character, ...rest] = input
        let digit = decode_base_64(character)
        accumulation += (digit & MASK) << shift
        shift += SHIFT;
        next_digit_part_of_this_number = (digit & CONTINUATION_BIT) != 0
        input = rest

    let is_negative = accumulation & 1
    let value = accumulation >> 1
    if is_negative:
        return (-value, input)
    else:
        return (value, input)
複製程式碼

source-map JavaScript 工具庫

source-map 是由 火狐開發者工具團隊 維護,釋出在 npm 上。它是 JavaScript 社群最流行的依賴包之一,下載量達到 每週 1000 萬次

就像許多軟體專案一樣,source-map 工具庫最開始也沒有很好的去實現它,以至於後面只能通過不斷的修復來改善效能。截止到本文完成之前,其實已經有了不錯的效能表現了。

當我們使用 source-map,很大一部分的時間都是消耗在解析 “對映集” 字串和構建陣列對:一旦 JavaScript 的定位改變了,另一個檔案的程式碼標示的定位也要改變。選用合適的二進位制查詢方式對陣列進行查詢。解析和排序操作只有在特定的時機才會被呼叫。例如,在除錯工具中檢視原始碼時,不需要對任何的對映集進行解析和排序。一次性的解析和排序、查詢並不會成為效能瓶頸。

VLQ 編碼函式通過輸入字串,解析字串並返回一對由解析結果和其餘輸入組成的值。通常把函式的返回值寫成有兩個屬性組成的 物件 ,這樣更具有可讀性,也方便日後進行格式轉換。

function decodeVlqBefore(input) {
    // ...
    return { result, rest };
}
複製程式碼

我們發現返回這樣的物件成本很高。針對 JavaScript 的即時編譯(Just-In-Time,JIT)優化,很難用第三方編譯的方式來優化這部分花銷。因為 VLQ 的編碼事件總是頻繁產生,所以這部分的記憶體分配工作給垃圾收集機制帶來很大的壓力,導致垃圾收集工作就像是走走停停一樣。

為了禁用記憶體分配,我們 修改程式 的第二個引數:將返回 物件 進行變體並作為輸出引數,這樣就把結果當成一個外部 物件 的屬性。我們可以肯定這個外部物件與 VLQ 函式返回的物件是一致的。雖然損失了一點可讀性,但是執行效率更高:

function decodeVlqAfter(input, out) {
    // ...
    out.result = result;
    out.rest = rest;
}
複製程式碼

當查詢一個位置長度的字串或者 base 64 字元,VLQ 編碼函式會 丟擲 一個 報錯。我們發現如果 如果轉換 base 64 數字出現錯誤,編碼函式返回 -1 而不是 丟擲 一個 報錯,那麼 JavaScript 的即時編譯效率更高。雖然損失了一點可讀性,但是執行效率又高了那麼一丟丟。

剖析 SpiderMonkey 引擎中 JITCoach 原型,我們發現 SpiderMonkey 引擎即時編譯機制是使用多型短路徑實時快取 物件 的 getter 和 setter。它的即時編譯沒有如我們期待的那樣直接通過快速訪問得到物件的屬性,因為以同樣的 “形狀” (或者稱之為 “隱藏類”) 是訪問不到它返回出來的物件。有一些屬性可能都不是你存入物件時的鍵名,甚至鍵名是完全省略掉的,比如當它在對映集中定位不到名字時。建立一個 Mapping 類生成器,初始化每一個屬性,我們配合即時編譯,為 Mapping 類新增通用屬性。完整結果可以在這裡看到 另一種效能改進

function Mapping() {
    this.generatedLine = 0;
    this.generatedColumn = 0;
    this.lastGeneratedColumn = null;
    this.source = null;
    this.originalLine = null;
    this.originalColumn = null;
    this.name = null;
}
複製程式碼

對兩個對映集陣列進行排序時,我們使用自定義對比函式。當 source-map 工具庫原始碼被第一次寫入,SpiderMonkey 的 Array.prototype.sort 是用 C++ 實現來提升效能[1]。儘管如此,當使用外部提供的對比函式並對一個巨大的陣列進行 排序 的時候,排序程式碼也需要呼叫很多次對比函式。從 C++ 中呼叫 JavaScript 相對來說也是很昂貴的花銷,所以呼叫自定義對比函式會使得排序效能急速下降。

基於上述條件,我們 實現了另一個版本 Javascript 快排。它只能通過 C++ 呼叫 Javascript 時才能使用,它也允許 JavaScript 即時編譯時作為排序函式的對比函式傳入,用來獲取更好的效能。這個改進給我們帶來大幅度的效能提升,同時只需要損失很小的程式碼可讀性。

WebAssembly

WebAssembly 是一種新的技術,它以二進位制形式執行在 Web 瀏覽器底層,為瀏覽器隔離危險程式碼和減少程式碼量所設計的。現在已經作為 Web 的標準,而且大多數的瀏覽器廠商已經支援這個功能。

WebAssembly 開闢一塊新的棧區供機器執行,有現代處理器架構的支援能更好的處理對映,它可以直接操作一大塊連續的儲存 buffer 位元組。WebAssembly 不支援自動化的垃圾回收,不過 在不久的將來 它也會繼承 JavaScript 物件的垃圾回收機制。控制流是具有結構化的,比起在程式碼間隨意的打標記或者跳躍,它被設計用來提供一種更可靠、執行一致的執行流程。處理一些架構上的邊緣問題,比如:超出表示範圍的數值怎麼擷取、溢位問題、規範 NaN

WebAssembly 的目標是獲得或者逼近原始指令的執行速度。目前在大多數的基準測試中跟原始指令相比 只相差 1.5x 了。

因為缺乏垃圾收集器,要編譯成 WebAssembly 語言僅限那些沒有執行時和垃圾採集器的程式語言,除非把控制器和執行時也編譯成 WebAssembly。實際中這些一般很難做到。現在,語言開發者事實上是把 C,C++ 和 Rust 編譯成 WebAssembly。

Rust

Rust 是一種更加安全和高效的系統程式語言。它的記憶體管理更加安全,不依賴於垃圾回收機制,而是允許你通過靜態追蹤函式 ownershipborrowing 這兩個方法來申請和釋放記憶體。

使用 Rust 來編譯成 WebAssembly 是一種不錯的選擇。由於語言設計者一開始就沒有為 Rust 設計垃圾自動回收機制,也就不用為了編譯成 WebAssembly 做額外的工作。Web 開發者還發現一些在 C 和 C++ 沒有的優點:

  • Rust 庫更加容易構建、容易共享、打包簡單和容易提取公共部分,而且自成文件。Rust 有諸如 rustupcargocrates.io 的完整生態系統。這是 C 和 C++ 所不能比擬的。
  • 記憶體安全方面。在 迭代演算法 中不斷產生記憶體碎片。Rust 則可以在編譯時就避免大部分類似的效能陷阱。

Rust 對對映集的解析和查詢

當我們決定把 source-map 中使用頻率最高的解析和查詢功能進行重構,就需要考慮到 JavaScript 和 WebAssembly 的執行邊界問題。如果出現了 JavaScript 即時編譯和 WebAssembly 相互穿插執行可能會影響彼此原來的執行效率。關於這個問題可以回憶一下前面我們討論過的在 C++ 程式碼中呼叫 JavaScript 程式碼的例子[2]。所以確定好邊界來最小化兩個不同語言相互穿插執行的次數顯得尤為重要。

在 VLQ 編碼函式中供選擇的 JavaScript 和 WebAssembly 的執行邊界其實很少。VLQ 編碼函式對 “對映集” 字串的每一次 Mapping 時需要被引用 1~4 次,在整個解析過程不得不在 JavaScript 和 WebAssembly 的邊界來回切換很多次。

因此,我們決定只用 Rust/WebAssembly 解析整個 “對映集” 字串,然後把解析結果保留在記憶體中,WebAssembly 堆就可以直接查詢到解析後的資料。這意味著我們不用把資料從 WebAssembly 堆中複製出來,也就不需要頻繁的在 JavaScript 和 WebAssembly 邊界來回切換了。除此之外,每次的查詢只需要切換一次邊界,每執行一次 Mapping 只不過是在解析結果中多查詢一次。每次查詢只產生一個結果,而這樣的操作次數屈指可數。

通過這兩個單元化測試,我們確信利用 Rust 語言來實現是正確的。一個是 source-map 工具庫已有的單元測試,另一個是 快速查詢效能的單元測試。這個測試的是通過解析隨機輸入 “對映集” 字串,判斷執行結果的多個效能指標。

我們基於 Rust 實現 crates.io,利用 crates.io 的 api 作為 Mapping 函式對 “對映集” 進行解析和查詢。

Base 64 大數值的位數可變表示法

對 source-map 進行 Mapping 的第一步是 VLQ 編碼。這裡是我們實現的 vlq 工具庫,基於 Rust 實現,釋出到 crates.io 上。

decode64 函式解碼結果是一個 base 64 數值。它使用匹配模式和可讀性良好的 Result —— 處理錯誤。

Result<T, E> 函式執行得到一個型別為 T,值為 V 就返回 Ok(v);執行得到一個型別為 E,值為 error 就返回 Err(error) 來提供報錯細節。decode64 函式執行得到一個型別為 Result<u8, Error> 的返回值,如果成功,值為 u8,如果失敗,值為 vlq::Error

fn decode64(input: u8) -> Result<u8, Error> {
    match input {
        b'A'...b'Z' => Ok(input - b'A'),
        b'a'...b'z' => Ok(input - b'a' + 26),
        b'0'...b'9' => Ok(input - b'0' + 52),
        b'+' => Ok(62),
        b'/' => Ok(63),
        _ => Err(Error::InvalidBase64(input)),
    }
}
複製程式碼

通過 decode64 函式,我們可以對 VLQ 值進行解碼。decode 函式將可變引用作為輸入位元組的迭代器,消耗需要解碼的 VLQ,最後返回 Result 函式作為解碼結果。

pub fn decode<B>(input: &mut B) -> Result<i64>
where
    B: Iterator<Item = u8>,
{
    let mut accum: u64 = 0;
    let mut shift = 0;

    let mut keep_going = true;
    while keep_going {
        let byte = input.next().ok_or(Error::UnexpectedEof)?;
        let digit = decode64(byte)?;
        keep_going = (digit & CONTINUED) != 0;

        let digit_value = ((digit & MASK) as u64)
            .checked_shl(shift as u32)
            .ok_or(Error::Overflow)?;

        accum = accum.checked_add(digit_value).ok_or(Error::Overflow)?;
        shift += SHIFT;
    }

    let abs_value = accum / 2;
    if abs_value > (i64::MAX as u64) {
        return Err(Error::Overflow);
    }

    // The low bit holds the sign.
    if (accum & 1) != 0 {
        Ok(-(abs_value as i64))
    } else {
        Ok(abs_value as i64)
    }
}
複製程式碼

不像被替換掉的 JavaScript,這段程式碼沒有為了效能而降低錯誤處理程式碼的可讀性,可讀性更好的錯誤處理執行邏輯更容易理解,也沒有涉及到堆的值包裝和棧的壓棧出棧。

"mappings" 字串

我們開始定義一些輔助函式。is_mapping_separator 函式判斷給定的資料能否被 Mapping 如果可以就返回 true,否則返回 false。這是一個語法與 JavaScript 很相似的函式:

#[inline]
fn is_mapping_separator(byte: u8) -> bool {
    byte == b';' || byte == b','
}
複製程式碼

然後我們定義一個輔助函式用來讀取 VLQ 資料並把它新增到前一個值中。這個函式沒法用 JavaScript 類比了,每讀取一段 VLQ 資料就要執行這個函式一遍。Rust 可以控制引數在記憶體中以怎樣的形式儲存,JavaScript 則沒有這個功能。雖然我們可以用一組數字屬性引用 Object 或者把數字變數通過閉包儲存下來,但是依然模擬不了 Rust 在引用一組陣列屬性的時候做到零花銷。JavaScript 只要執行時就一定會有相關的時間花銷。

#[inline]
fn read_relative_vlq<B>(
    previous: &mut u32,
    input: &mut B,
) -> Result<(), Error>
where
    B: Iterator<Item = u8>,
{
    let decoded = vlq::decode(input)?;
    let (new, overflowed) = (*previous as i64).overflowing_add(decoded);
    if overflowed || new > (u32::MAX as i64) {
        return Err(Error::UnexpectedlyBigNumber);
    }

    if new < 0 {
        return Err(Error::UnexpectedNegativeNumber);
    }

    *previous = new as u32;
    Ok(())
}
複製程式碼

總而言之,基於 Rust 實現的 “對映集” 解析與被替換調的 JavaScript 實現語法邏輯非常相似。儘管如此,使用 Rust 我們可以控制底層哪些功能要打包到一起,哪些用輔助函式來解決。JavaScript 語言對底層的控制權就小了很多,舉個簡單例子,解析對映 物件 只能用 JavaScript 原生方法。Rust 語言的優勢源於把記憶體的分配和垃圾回收交給程式設計人員自己去實現:

pub fn parse_mappings(input: &[u8]) -> Result<Mappings, Error> {
    let mut generated_line = 0;
    let mut generated_column = 0;
    let mut original_line = 0;
    let mut original_column = 0;
    let mut source = 0;
    let mut name = 0;

    let mut mappings = Mappings::default();
    let mut by_generated = vec![];

    let mut input = input.iter().cloned().peekable();

    while let Some(byte) = input.peek().cloned() {
        match byte {
            b';' => {
                generated_line += 1;
                generated_column = 0;
                input.next().unwrap();
            }
            b',' => {
                input.next().unwrap();
            }
            _ => {
                let mut mapping = Mapping::default();
                mapping.generated_line = generated_line;

                read_relative_vlq(&mut generated_column, &mut input)?;
                mapping.generated_column = generated_column as u32;

                let next_is_sep = input.peek()
                    .cloned()
                    .map_or(true, is_mapping_separator);
                mapping.original = if next_is_sep {
                    None
                } else {
                    read_relative_vlq(&mut source, &mut input)?;
                    read_relative_vlq(&mut original_line, &mut input)?;
                    read_relative_vlq(&mut original_column, &mut input)?;

                    let next_is_sep = input.peek()
                        .cloned()
                        .map_or(true, is_mapping_separator);
                    let name = if next_is_sep {
                        None
                    } else {
                        read_relative_vlq(&mut name, &mut input)?;
                        Some(name)
                    };

                    Some(OriginalLocation {
                        source,
                        original_line,
                        original_column,
                        name,
                    })
                };

                by_generated.push(mapping);
            }
        }
    }

    quick_sort::<comparators::ByGeneratedLocation, _>(&mut by_generated);
    mappings.by_generated = by_generated;
    Ok(mappings)
}
複製程式碼

最後,我們仍然在 Rust 程式碼中使用我們自己定義的快排,這可能是所有 Rust 程式碼中可讀性最差了。我們還發現,在原生程式碼環境中,標準庫的內建排序函式執行效率更高,但是一旦把執行環境換成 WebAssembly,我們定義的排序函式比標準庫的內建排序函式執行效率更高。(對於這樣的差異很意外,不過我們也沒有再深究了。)

JavaScript 介面

WebAssembly 的對外函式介面(foreign function interface,簡稱 FFI)受限於標量值,所以一些以 Rust 語言編寫,通過 WebAssembly 轉成 JavaScript 程式碼後的函式引數只能是標量數值型別,返回值也是標量數值型別。因此,JavaScript 要求 Rust 為 “對映集” 字串分配一塊緩衝區並返回該 buffer 位元組的地址指標。然後,JavaScript 必須複製出 “對映集” 字串的 buffer 位元組,這時候因為 FFI 的限制什麼也做不了,只能把整段連續的 WebAssembly 記憶體直接寫入。之後 JavaScript 呼叫 parse_mappings 函式進行 buffer 位元組的初始化工作,初始化完畢後返回解析結果的指標。完成上述這些前置工作後,JavaScript 就可以使用 WebAssembly 的 API ,給定一些數值查詢結果,或者給定一個指標得到解析後的對映集。所有查詢結果完畢以後,JavaScript 會告訴 WebAssembly 釋放儲存對映集結果的記憶體空間。

從 Rust 暴露 WebAssembly 的應用程式設計介面

所有的暴露出去的 WebAssembly APIs 都被封裝在一個 “小膠箱” 裡。這樣的分離很有用,它允許我們用測試環境來執行 source-map-mappings。如果你想編譯成純的 WebAssembly 程式碼也可以,只需要把編譯環境修改成 WebAssembly。

另外,受限於 FFI 的傳值要求,那麼輸出的函式必須滿足一下兩點:

  • 它不能有 #[無名] 屬性,要方便 JavaScript 能呼叫它 。
  • 它標記 外部 "C" 以便提取到 .wasm 公共檔案中。

不同於核心庫,這些程式碼暴露功能給 WebAssembly 轉 JavaScript,有必要提醒你,頻繁使用非常的 不安全。 只要呼叫 外部 函式和使用指標從 FFI 邊界接收指標,就是 不安全,因為 Rust 編譯器沒法校驗另一端是否安全。我們很少關心到這個安全性問題 —— 最壞的情況下我們可以做一個 陷阱(把 JavaScript 端的 報錯 全部抓住),或者直接返回一個報錯響應。在同一段地址中,可以向地址寫入內容要比只是將地址儲存的內容以二進位制位元組執行要危險的多,如果可寫入的話,攻擊者就可以欺騙程式跳轉到特定的記憶體地址,然後插入一段他自己的 shell 指令碼程式碼。

我們輸出的一個最簡單是函式功能是把工具庫產生的一個報錯捕獲到。它提供了 libcerrno 類似的功能,它會將 API 執行出錯時報告 JavaScript 到底是什麼樣的錯誤。我們總是把最近的報錯保留在全域性物件上,這個函式可以檢索錯誤值:

static mut LAST_ERROR: Option<Error> = None;

#[no_mangle]
pub extern "C" fn get_last_error() -> u32 {
    unsafe {
        match LAST_ERROR {
            None => 0,
            Some(e) => e as u32,
        }
    }
}
複製程式碼

JavaScript 和 Rust 的第一次互動發生在為 buffer 位元組分配記憶體空間來儲存 “對映集” 字串。我們希望能有一塊獨立的,由 u8 組成的連續塊,它建議使用 Vec<u8>,但我們想要暴露一個簡單的指標給 JavaScript。一個簡單的指標可以跨越 FFI 的邊界,但是很容易在 JavaScript 端引起報錯。我們可以用 Box<Vec<u8>> 新增一個連線層或者儲存在外部資料中,另一端有需要這份資料的時候再載體進行格式化。我們決定採用後一個方法。

這個載體由以下三者組成:

  1. 一個指標指向堆記憶體元素,
  2. 分配記憶體的容量有多大,
  3. 元素的初始化長度。

當我們暴露一個堆記憶體元素的指標給 JavaScript,我們需要一種方式來儲存長度和容量,將來通過 Vec 重建它。我們在堆元素的開頭新增兩個額外的詞來儲存長度和容量,然後我們把這個新增了兩個標註的指標傳給 JavaScript:

#[no_mangle]
pub extern "C" fn allocate_mappings(size: usize) -> *mut u8 {
    // Make sure that we don't lose any bytes from size in the remainder.
    let size_in_units_of_usize = (size + mem::size_of::<usize>() - 1)
        / mem::size_of::<usize>();

    // Make room for two additional `usize`s: we'll stuff capacity and
    // length in there.
    let mut vec: Vec<usize> = Vec::with_capacity(size_in_units_of_usize + 2);

    // And do the stuffing.
    let capacity = vec.capacity();
    vec.push(capacity);
    vec.push(size);

    // Leak the vec's elements and get a pointer to them.
    let ptr = vec.as_mut_ptr();
    debug_assert!(!ptr.is_null());
    mem::forget(vec);

    // Advance the pointer past our stuffed data and return it to JS,
    // so that JS can write the mappings string into it.
    let ptr = ptr.wrapping_offset(2) as *mut u8;
    assert_pointer_is_word_aligned(ptr);
    ptr
}
複製程式碼

把 buffer 位元組初始化為 “字符集” 字串之後,JavaScript 把 buffer 位元組的控制器交給 parse_mappings,將字串解析為可查詢結構。解析成功會返回 Mappings 後的結構,失敗就返回 NULL

parse_mappings 要做的第一步就是恢復 Vec 的長度和容量。第二部,“對映集” 字串資料被擷取,在被擷取的整個生命週期內都無法從當前作用域檢測到,只有當他們被重新分配到記憶體中,並被我們的工具庫解析為 “字符集” 字串之後才能獲取到。不論解析結果有沒有成功,我們都重新申請 buffer 位元組來儲存 “字符集” 字串,然後返回一個指標指向解析成功的結果,或者返回一個指標指向 NULL

/// 留意在匹配的生命週期內作用域中的引用,
/// 某些 `不安全` 的操作,比如解除指標關聯引用。
/// 生命週期內返回一些不保留的引用,
/// 使用這個函式保證我們不會一不小心的使用了
/// 一個非法的引用值。
#[inline]
fn constrain<'a, T>(_scope: &'a (), reference: &'a T) -> &'a T
where
    T: ?Sized
{
    reference
}

#[no_mangle]
pub extern "C" fn parse_mappings(mappings: *mut u8) -> *mut Mappings {
    assert_pointer_is_word_aligned(mappings);
    let mappings = mappings as *mut usize;

    // 在指標指向對映集字串前將資料拿出
    // string.
    let capacity_ptr = mappings.wrapping_offset(-2);
    debug_assert!(!capacity_ptr.is_null());
    let capacity = unsafe { *capacity_ptr };

    let size_ptr = mappings.wrapping_offset(-1);
    debug_assert!(!size_ptr.is_null());
    let size = unsafe { *size_ptr };

    // 從指標的擷取片段構造一個指標並解析成對映集。
    let result = unsafe {
        let input = slice::from_raw_parts(mappings as *const u8, size);
        let this_scope = ();
        let input = constrain(&this_scope, input);
        source_map_mappings::parse_mappings(input)
    };

    // 重新分配對映集字串的記憶體並新增兩個前置的資料。
    let size_in_usizes = (size + mem::size_of::<usize>() - 1) / mem::size_of::<usize>();
    unsafe {
        Vec::<usize>::from_raw_parts(capacity_ptr, size_in_usizes + 2, capacity);
    }

    // 返回結果,儲存一些報錯給另一端語言提供幫助
    // 如果 JavaScript 需要的話。
    match result {
        Ok(mappings) => Box::into_raw(Box::new(mappings)),
        Err(e) => {
            unsafe {
                LAST_ERROR = Some(e);
            }
            ptr::null_mut()
        }
    }
}
複製程式碼

當我們進行查詢時,我們需要找一個方法來轉換結果,才能傳給 FFI 使用。查詢結果可能是一個 對映 或者集合組成的 對映對映 不能直接給 FFI 使用,除非我們進行封裝。我們肯定不希望對 對映 進行封裝,因為之後我們還可能需要從原來的結構中獲取內容,那時我們還要費時費力的分配記憶體和間接取值。我們的方法是呼叫一個引導進來的函式處理每一個 對映

mappings_callback 就是一個 外部 函式,它不是本地定義的函式,而是在 WebAssembly 模組例項化的時候由 JavaScript 引導進來。mappings_callback對映 分解成不同的部分:每個檔案都是被展平後的 對映,被轉換後可以作為引數傳遞給 FFI 使用。可選項 <T> 我們加入一個 bool 引數控制不同的轉換結果,由 可選項 <T>Some 還是 None 決定引數 T 是合法值還是無用值:

extern "C" {
    fn mapping_callback(
        // These two parameters are always valid.
        generated_line: u32,
        generated_column: u32,

        // The `last_generated_column` parameter is only valid if
        // `has_last_generated_column` is `true`.
        has_last_generated_column: bool,
        last_generated_column: u32,

        // The `source`, `original_line`, and `original_column`
        // parameters are only valid if `has_original` is `true`.
        has_original: bool,
        source: u32,
        original_line: u32,
        original_column: u32,

        // The `name` parameter is only valid if `has_name` is `true`.
        has_name: bool,
        name: u32,
    );
}

#[inline]
unsafe fn invoke_mapping_callback(mapping: &Mapping) {
    let generated_line = mapping.generated_line;
    let generated_column = mapping.generated_column;

    let (
        has_last_generated_column,
        last_generated_column,
    ) = if let Some(last_generated_column) = mapping.last_generated_column {
        (true, last_generated_column)
    } else {
        (false, 0)
    };

    let (
        has_original,
        source,
        original_line,
        original_column,
        has_name,
        name,
    ) = if let Some(original) = mapping.original.as_ref() {
        let (
            has_name,
            name,
        ) = if let Some(name) = original.name {
            (true, name)
        } else {
            (false, 0)
        };

        (
            true,
            original.source,
            original.original_line,
            original.original_column,
            has_name,
            name,
        )
    } else {
        (
            false,
            0,
            0,
            0,
            false,
            0,
        )
    };

    mapping_callback(
        generated_line,
        generated_column,
        has_last_generated_column,
        last_generated_column,
        has_original,
        source,
        original_line,
        original_column,
        has_name,
        name,
    );
}
複製程式碼

所有輸出的查詢函式都有相似的結構。它們一開始都是轉換 *mut Mappings 成一個 &mut Mappings 引用。&mut Mappings 生命週期僅限於當前範圍,以強制它只用於這個函式的呼叫,在它被重新分配記憶體後不能再使用。其次,每一個查詢方法都依賴於 Mapping 方法。每個被輸出的函式都呼叫 mapping_callback 的結果都是 對映

輸出一個典型的查詢函式 all_generated_locations_for,它包裹了Mappings::all_generated_locations_for 方法,並找到所有源標註的對映依賴:

#[inline]
unsafe fn mappings_mut<'a>(
    _scope: &'a (),
    mappings: *mut Mappings,
) -> &'a mut Mappings {
    mappings.as_mut().unwrap()
}

#[no_mangle]
pub extern "C" fn all_generated_locations_for(
    mappings: *mut Mappings,
    source: u32,
    original_line: u32,
    has_original_column: bool,
    original_column: u32,
) {
    let this_scope = ();
    let mappings = unsafe { mappings_mut(&this_scope, mappings) };

    let original_column = if has_original_column {
        Some(original_column)
    } else {
        None
    };

    let results = mappings.all_generated_locations_for(
        source,
        original_line,
        original_column,
    );
    for m in results {
        unsafe {
            invoke_mapping_callback(m);
        }
    }
}
複製程式碼

最後,當 JavaScript 完成查詢 對映集 時,必須輸出 free_mappings 函式來為結果重新分配記憶體:

#[no_mangle]
pub extern "C" fn free_mappings(mappings: *mut Mappings) {
    unsafe {
        Box::from_raw(mappings);
    }
}
複製程式碼

將 Rust 編譯成 .wasm 檔案

為目標新增 wasm32-unknown-unknown 給 Rust 編譯成 WebAssembly 帶來可能,而且 rustup 使得安裝 Rust 的編譯工具指向 wasm32-unknown-unknown 更加便捷:

$ rustup update
$ rustup target add wasm32-unknown-unknown
複製程式碼

現在我們就有了一個 wasm32-unknown-unknown 編譯器, 通過修改 --target 標記就可以實現不同的語言到 WebAssembly 之間的編譯:

$ cargo build --release --target wasm32-unknown-unknown
複製程式碼

.wasm 字尾的編譯檔案儲存在 target/wasm32-unknown-unknown/release/source_map_mappings_wasm_api.wasm

儘管我們已經有一個可以執行的 .wasm 檔案,工作還沒完成:這個 .wasm 檔案體積仍然太大了。生產環境的 .wasm 檔案體積越小越好,我們通過以下工具一步步壓縮它:

  • wasm-gc--gc-sections 標記了要移除沒有使用過的物件檔案,對於 .wasm 檔案,ELF,Mach-O 除外。它會找到哪些輸出函式沒有被用過,然後從 .wasm 檔案中移除。

  • wasm-snip,用 非訪問性 的指令來替代 WebAssembly 的函式體,這對於那些執行時從頭到尾沒有沒呼叫過,但是 wasm-gc 靜態分析沒法移除掉,通過手動配置編譯結果。丟棄一個函式引用指標使得其他函式沒法訪問到失去引用指標的函式,所以很有必要在此操作之後再一次使用 wasm-gc

  • wasm-opt,用 binaryen 優化 .wasm 檔案,壓縮檔案體積並提高執行時的效能。實際上,隨著後端底層虛擬機器越來越成熟,這步操作變得可有可無。

我們的 生產流程配置wasm-gcwasm-snipwasm-gcwasm-opt

在 JavaScript 使用 WebAssembly APIs

在 JavaScript 使用 WebAssembly 的首要問題就是,如何載入 .wasm 檔案。 source-map 工具庫的執行環境主要有三個:

  1. Node.js
  2. 網頁
  3. 火狐開發者工具裡

不同的環境使用不同的方式將 .wasm 檔案載入為 ArrayBuffer 位元組,才能在 JavaScript 執行時進行編譯使用。在網頁和火狐瀏覽器裡可以用標準化的 fetch API 建立 HTTP 請求來載入 .wasm 檔案。它是一個工具庫,負責將 URL 指向需要從網路載入的 .wasm 檔案,載入完成後才能進行任何的 source-map 解析。當使用 Node.js 把工具庫換成 fs.readFile API 從硬碟中讀取 .wasm 檔案。在這個指令碼中,在進行任何 source-map 解析之前不需要執行初始化。我們只負責提供一個統一的介面,基於什麼環境、用什麼的工具庫才能正確的載入 .wasm 檔案,各位自己去擼程式碼吧。

當編譯和例項化 WebAssembly 模組時,我們必須提供 mapping_callback。這個回撥函式不能在例項化 WebAssembly 模組的生命週期外進行回撥,但是可以根據我們將要執行的查詢工作和不同的對映結果對返回結果進行一些調整。所以實際上 mapping_callback 只提供對分離後的對映成員進行物件結構化,然後把結果用一個閉包函式包裹起來後返回給你,你隨意進行查詢操作。

let currentCallback = null;

// ...

WebAssembly.instantiate(buffer, {
    env: {
    mapping_callback: function (
        generatedLine,
        generatedColumn,

        hasLastGeneratedColumn,
        lastGeneratedColumn,

        hasOriginal,
        source,
        originalLine,
        originalColumn,

        hasName,
        name
    ) {
        const mapping = new Mapping;
        mapping.generatedLine = generatedLine;
        mapping.generatedColumn = generatedColumn;

        if (hasLastGeneratedColumn) {
        mapping.lastGeneratedColumn = lastGeneratedColumn;
        }

        if (hasOriginal) {
        mapping.source = source;
        mapping.originalLine = originalLine;
        mapping.originalColumn = originalColumn;

        if (hasName) {
            mapping.name = name;
        }
        }

        currentCallback(mapping);
    }
    }
})
複製程式碼

為了 currentCallback 工程化和非工程化設定,我們定義了 withMappingCallback 輔助函式來完成這件事:它就像設定過的 currentCallback,如果不想設定的話直接呼叫 currentCallback 就可以。一旦 withMappingCallback 完成,我們就把 currentCallback 重置成 nullRAII 等價於以下程式碼:

function withMappingCallback(mappingCallback, f) {
    currentCallback = mappingCallback;
    try {
    f();
    } finally {
    currentCallback = null;
    }
}
複製程式碼

回想以下 JavaScript 最初的設想,當解析一段 source-map 時,需要告訴 WebAssembly 分配一段記憶體來儲存 “對映集” 字串,然後將字串複製到一段 buffer 位元組記憶體裡:

const size = mappingsString.length;
const mappingsBufPtr = this._wasm.exports.allocate_mappings(size);
const mappingsBuf = new Uint8Array(
    this._wasm.exports.memory.buffer,
    mappingsBufPtr,
    size
);
for (let i = 0; i < size; i++) {
    mappingsBuf[i] = mappingsString.charCodeAt(i);
}
複製程式碼

JavaScript 對 buffer 位元組進行初始化的時候,它會呼叫從 WebAssembly 匯出的 parse_mappings 函式,如果轉換過程失敗就 丟擲 一些 報錯

const mappingsPtr = this._wasm.exports.parse_mappings(mappingsBufPtr);
if (!mappingsPtr) {
    const error = this._wasm.exports.get_last_error();
    let msg = `Error parsing mappings (code ${error}): `;
    // XXX: 用 `fitzgen/source-map-mappings` 同步接收報錯資訊。
    switch (error) {
    case 1:
        msg += "the mappings contained a negative line, column, source index or name index";
        break;
    case 2:
        msg += "the mappings contained a number larger than 2**32";
        break;
    case 3:
        msg += "reached EOF while in the middle of parsing a VLQ";
        break;
    case 4:
        msg += "invalid base 64 character while parsing a VLQ";
        break
    default:
        msg += "unknown error code";
        break;
    }

    throw new Error(msg);
}

this._mappingsPtr = mappingsPtr;
複製程式碼

執行在 WebAssembly 中的查詢函式都有相似的結構,跟 Rust 語言定義的方法一樣。它們判斷傳入的查詢引數,傳入一個臨時的閉包回撥函式到 withMappingCallback 得到返回值,將 withMappingCallback 傳入 WebAssembly 就得到最終結果。

allGeneratedPositionsFor 在 JavaScript 中的實現如下:

BasicSourceMapConsumer.prototype.allGeneratedPositionsFor = function ({
    source,
    line,
    column,
}) {
    const hasColumn = column === undefined;
    column = column || 0;

    source = this._findSourceIndex(source);
    if (source < 0) {
    return [];
    }

    if (originalLine < 1) {
    throw new Error("Line numbers must be >= 1");
    }

    if (originalColumn < 0) {
    throw new Error("Column numbers must be >= 0");
    }

    const results = [];

    this._wasm.withMappingCallback(
    m => {
        let lastColumn = m.lastGeneratedColumn;
        if (this._computedColumnSpans && lastColumn === null) {
        lastColumn = Infinity;
        }
        results.push({
        line: m.generatedLine,
        column: m.generatedColumn,
        lastColumn,
        });
    }, () => {
        this._wasm.exports.all_generated_locations_for(
        this._getMappingsPtr(),
        source,
        line,
        hasColumn,
        column
        );
    }
    );

    return results;
};
複製程式碼

當 JavaScript 查詢 source-map,呼叫 SourceMapConsumer.prototype.destroy 方法,它會在內部呼叫從 WebAssembly 匯出的 free_mappings函式:

BasicSourceMapConsumer.prototype.destroy = function () {
    if (this._mappingsPtr !== 0) {
    this._wasm.exports.free_mappings(this._mappingsPtr);
    this._mappingsPtr = 0;
    }
};
複製程式碼

基準測試

所有測試都是執行在 2014 年年中生產的 MacBook Pro 上,具體配置是 2.8 GHz Intel i7 處理器,16 GB 1600 MHz DDR3 記憶體。膝上型電腦測試過程中一直插入電源,並且在進行網頁基準測試時,每次測試開始前都重新整理網頁。測試使用的瀏覽器的版本號非別是:Chrome Canary 65.0.3322.0, Firefox Nightly 59.0a1 (2018-01-15), Safari 11.0.2 (11604.4.7.1.6)[3]。為了保證測試環境一致,在採集執行時間前都執行 5 次來 預熱 瀏覽器的 JIT 編譯器,然後計算執行 100 次的總時間。

我們使用同一個 source-map 檔案,選用檔案中三個不同位置大小的片段作為測試素材:

  1. 用 JavaScript 實現的 壓縮版 source-map。這個 source-map 檔案用 UglifyJS 進行壓縮,最終的 “對映集” 字串長度只有 30,081 個字元。

  2. Angular.JS 最後版本壓縮得到的 source-map,這個 “對映集” 字串長度是 391,473 個字元。

  3. Scala.JS 執行時的計算得到 JavaScriptsource-map。這個對映體積最大,“對映集” 字串長度是 14,964,446 個字元。

另外,我們還專門增加兩種人為的 source-map 結構:

  1. 將 Angular.JS source map 原體積擴大 10 倍。“對映集” 字串長度是 3,914,739 個字元。

  2. 將 Scala.JS source map 原體積擴大 2 倍。“對映集” 字串長度是 29,928,893 個字元。這個 source-map 在保持其他基準的情況下我們只收集執行 40 次的時間。

精明的讀者可能會留意到,擴大後的 source-map 分別多出 9 個和 1 個字元,這多出的字元數量恰好是在擴大過程中將 suorce-map 分隔開的 ;

我們把目光集中到 Scala.JS source map,它是不經過人為擴大時體積最大的版本。另外,它還是我們所測試的過的瀏覽器環境中體積最大的。用 Chrome 測試體積最大的 source-map 時什麼資料也沒有 (擴大 2 倍的 Scala.JS source map)。用 JavaScript 實現的版本,我們沒法通過組合模擬出 Chrome 標籤的內容進行崩潰;用 WebAssembly 實現的版本,Chrome 將會丟擲 執行時錯誤:記憶體訪問超出界限,使用 Chrome 的 debugger 工具,可以發現是由於 .wasm 檔案缺少記憶體洩漏時的處理指令。其他瀏覽器在 WebAssembly 實現的版本都能成功通過基準測試,所以,我只能認為這是 Chrome 瀏覽器的一個bug

對於基準測試,值越小測試效果越好

在某個位置設定一個斷點

第一個基準測試程式通過在原始碼打上斷點來進行分步除錯。它需要 source-map 正在被解析成 “對映集” 字串,而且解析得到的對映以原始碼出現的位置進行排列,這樣我們就可以通過二分查詢的方法找到斷點對應 “對映集” 中的行號。查詢結果返回編譯後的檔案對應 JavaScript 原始碼的定位。

[譯] 論 Rust 和 WebAssembly 對原始碼地址索引的極限優化

[譯] 論 Rust 和 WebAssembly 對原始碼地址索引的極限優化

WebAssembly 的實現在瀏覽器中的執行效能要全面優於 JavaScript 的實現。對於 Scala.JS source map,使用 WebAssembly 實現的版本執行時間在 Chrome 瀏覽器只有原來的 0.65x、在 Firefox 瀏覽器只有原來的 0.30x、在 Safari 瀏覽器只有原來的 0.37x。使用 WebAssembly 實現,執行時間最短的是 Safari 瀏覽器,平均只需要 702 ms,緊跟著的是 Firefox 瀏覽器需要 877 ms,最後是 Chrome 瀏覽器需要 1140 ms。

此外,相對誤差值,WebAssembly 實現要遠遠小於 JavaScript 實現的版本,尤其是在 Firefox 瀏覽器中。以 Scala.JS source map 的 JavaScript 實現的版本為例,Chrome 瀏覽器相對誤差值是 ±4.07%,Firefox 瀏覽器是 ±10.52%,Safari 瀏覽器是 ±6.02%。WebAssembly 實現的版本中,Chrome 瀏覽器的相對誤差值縮小到 ±1.74%,在 Firefox 瀏覽器 ±2.44%,在 Safari 瀏覽器 ±1.58%。

在異常的位置暫停

第二個基準測試用來補充第一個基準測試中的意外情況。當逐步除錯暫停而且捕獲到一個未知的異常,但是沒有生成 JavaScript 程式碼,當一個控制檯列印資訊沒有給出生成 JavaScript 程式碼,或者逐步除錯生成的 JavaScript 來自於其他的 JavaScript 原始碼,就啟用第二個基準測試方案。

對 JavaScript 原始碼和編譯後的程式碼進行定位時,“對映集” 字串必須停止解析。已經解析好的對映經過排序建立 JavaScript 的定位,這樣就可以通過二分查詢定位到最接近的對映定位,根據對映定位找到最接近的原始檔定位。

[譯] 論 Rust 和 WebAssembly 對原始碼地址索引的極限優化

[譯] 論 Rust 和 WebAssembly 對原始碼地址索引的極限優化

再一次的,在所有瀏覽器對 WebAssembly 和 JavaScript 這兩種實現多維評估模型測試,WebAssembly 在執行時間上遙遙領先。對比 Scala.JS source map,在 Chrome 瀏覽器中 WebAssembly 實現的版本只需要花費 JavaScript 的 0.23x。在 Firefox 瀏覽器和 Safari 瀏覽器中只需要花費 0.17x。Safari 瀏覽器執行 WebAssembly 最快 (305ms),緊接著是 Firefox 瀏覽器 (397ms),最後是 Chrome 瀏覽器 (486ms)。

WebAssembly 實現的結果誤差值也更小,對比 Scala.JS 的實現,在 Chrome瀏覽器中相對誤差值從 ±4.04% 降到 2.35±%,在 Firefox 瀏覽器從 ±13.75% 降到 ±2.03%,在 Safari 瀏覽器從 ±6.65% 降到 ±3.86%。

伴隨斷點和異常暫停的基準測試

第三和第四個基準測試,通過觀察在第一個斷點緊接著又設定一個斷點,或者在發現異常暫停的位置後又設定暫停,或者轉換列印的執行日誌資訊的時間花銷。按照以往,這些操作都不會成為效能瓶頸:效能花銷最大的地方在於 “對映集” 字串的解析和可查詢資料的結構構建(對陣列進行排序)。

話說是這麼說,我們還是希望能確保這些花銷能維持的更加 穩定:我們不希望這些操作會在某些條件下效能花銷突然提高。

以下是在基準測試中,不同的編譯後檔案定位到原始檔的二分查詢所花的時間。

[譯] 論 Rust 和 WebAssembly 對原始碼地址索引的極限優化
[譯] 論 Rust 和 WebAssembly 對原始碼地址索引的極限優化

[譯] 論 Rust 和 WebAssembly 對原始碼地址索引的極限優化
[譯] 論 Rust 和 WebAssembly 對原始碼地址索引的極限優化

這個基準測試比其他基準測試的結果要更豐富。檢視 Scala.JS source map 以不同的實現方式輸入到不同瀏覽器中可以看到更細小的差異。因為都是用很小的時間單位去衡量測試結果,所以細小的時間差異也能顯現出來。我們可以看到 Chrome 瀏覽器只用了十分之一毫秒,Firefox 瀏覽器只用了 0.02 毫秒,Safari 瀏覽器用了 1 毫秒。

根據這些資料,我們可以得出結論,後續查詢操作在 JavaScript 和 WebAssembly 實現中大部分都保持在毫秒級以下。後續查詢從來不會成為用 WebAssembly 來重新實現時的瓶頸。

遍歷所有對映

最後兩個基準測試的是解析 source-map 並立即遍歷所有對映所花的時間,而且遍歷的對映都是假定為已經解析完畢的。這是一個很普通的操作,通過構建工具消耗和重建 source-map。它們有時也通過逐步偵錯程式向使用者強呼叫戶可以設定斷點的原始源內的哪些行 —— 在沒有轉換為生成中的任何位置的 JavaScript 行上設定斷點沒有意義。

這些基準測試也有一個地方讓我們十分擔憂:它涉及了很多 JavaScript↔WebAssembly 兩種程式碼相互穿插執行,在對映 source-map 時還要注意 FFI。對於所有基準測試,我們已經最大限度的減少這種 FFI 呼叫。

[譯] 論 Rust 和 WebAssembly 對原始碼地址索引的極限優化
[譯] 論 Rust 和 WebAssembly 對原始碼地址索引的極限優化

[譯] 論 Rust 和 WebAssembly 對原始碼地址索引的極限優化
[譯] 論 Rust 和 WebAssembly 對原始碼地址索引的極限優化

事實證明,我們的擔心是多餘的。 WebAssembly 實現不僅滿足 JavaScript 實現的效能,即使 source-map 已被解析,也超過了 JavaScript 實現的效能。對於分析迭代和迭代已解析的基準測試,WebAssembly 在 Chrome 瀏覽器中的時間花費是 JavaScript 的 0.61 倍和 0.71 倍。在 Firefox 瀏覽器中,WebAssembly 的時間花費 JavaScript 的 0.56 倍和 0.77 倍。在 Safari 瀏覽器中,WebAssembly 實現是 JavaScript 實現的時間 0.63 倍和 0.87倍。 Safari 瀏覽器再一次以最快的速度執行 WebAssembly 實現,Firefox 瀏覽器和 Chrome 瀏覽器基本上排在第二位。 Safari 瀏覽器在迭代已解析的基準測試中值得對 JavaScript 效能給予特別優化:除了超越其他瀏覽器的 JavaScript 時間之外,Safari 瀏覽器執行 JavaScript 的速度比其他瀏覽器執行WebAssembly 的速度還要快!

這符合早期基準測試趨勢,我們還看到 WebAssembly 相對誤差比 JavaScript 的相對誤差要小。經過解析和遍歷,Chrome 瀏覽器的相對誤差從 ±1.80% 降到 ±0.33%,Firefox 瀏覽器從 ±11.63% 降到 ±1.41%,Safari 瀏覽器從 ±2.73% 降到 ±1.51%。當遍歷一個已經解析完的對映,Firefox 瀏覽器的相對誤差從 ±12.56% 降到 ±1.40%,Safari 瀏覽器從 ±1.97% 降到 ±1.40%。Chrome 瀏覽器的相對誤差從 ±0.61% 升到 ±1.18%,這是基準測試中唯一一個趨勢上升的瀏覽器。

程式碼體積

使用 wasm32-unknown-unknownwasm32-unknown-emscripten 的好處在於生成的 WebAssembly 程式碼體積更小。wasm32-unknown-emscripten 包含了許多補丁,比如 libc,比如在檔案系統頂部建立 IndexedDB,對於 source-map 庫,我們只使用 wasm32-unknown-unknown

我們考慮的是最終交付到客戶端的 JavaScript 和 WebAssembly 程式碼體積。也就是說,我們在將 JavaScript 模組捆綁到一個 .js 檔案後檢視程式碼大小。我們看看使用 wasm-gcwasm-snipwasm-opt 縮小 .wasm 檔案體積的效果,以及使用網頁上都支援的 gzip 壓縮。

在這個衡量標準下,JavaScript 的體積總是指壓縮後的大小, 用 Google Closure 編譯器 建立屬於 “簡單” 的優化級別。我們使用 Closure Compiler 只因為 UglifyJS 對於一些新的 ECMAScript 標準無效(例如 let 和箭頭函式)。我們使用 “簡單” 的優化級別,因為 “高階” 優化級別對於沒有用 Closure Compiler 編寫的 JavaScript 具有破壞性。

標記為 “JavaScript” 的條形圖用於原始的純 JavaScript source-map 庫實現的變體。標記為 “WebAssembly” 的條形圖用於新的 source-map 庫實現的變體,它使用 WebAssembly 來解析字串的 “對映” 並查詢解析的對映。請注意,“WebAssembly” 實現仍然使用 JavaScript 來實現所有其他功能! source-map 庫有額外的功能,比如生成對映地圖,這些功能仍然在 JavaScript 中實現。對於 “WebAssembly” 實現,我們報告 WebAssembly 和 JavaScript 的大小。

[譯] 論 Rust 和 WebAssembly 對原始碼地址索引的極限優化

在最小處,新的 WebAssembly 實現總程式碼體積要比舊的 JavaScript 實現大很多:分別是 20,996 位元組與 8,365位元組。儘管如此,使用 .wasm 的工具進行程式碼壓縮,得到的 WebAssembly 檔案只有原來體積的 0.16 倍。程式碼量跟 JavaScript 差不多。

如果我們用 WebAssembly 替換 JavaScript 解析和查詢程式碼,為什麼 WebAssembly 實現不包含更少的 JavaScript?有兩個因素導致 JavaScript 無法剔除。首先,需要引入一些新的 JavaScript 來載入 .wasm 檔案並給 WebAssembly 提供介面。其次,更重要的是,我們 “替換” 的一些 JavaScript 事務與 suorce-map 庫的其他部分共享。雖然現在事務已經不再共享,但是其他庫可能仍然在使用。

讓我們把目光投向 gzip 壓縮過的 .wasm 檔案。執行 wasm-objdump -h 給出每一部分的體積:

[譯] 論 Rust 和 WebAssembly 對原始碼地址索引的極限優化

CodeData 幾乎佔據了 .wasm 檔案的體積。Code 部分包含組成函式體的 WebAssembly 編碼指令。Data 部分包含要載入到 WebAssembly 模組的連續記憶體空間中的靜態資料。

使用 wasm-objdump 手動檢查 Data 部分的內容,顯示它主要由用於構建診斷訊息的字串片段組成,比如 Rust 程式碼執行出錯的。但是,在定位 WebAssembly 時,Rust 執行錯誤會轉化為 WebAssembly 陷阱,並且陷阱不會攜帶額外的診斷資訊。我們認為這是 rustc 中的一個錯誤,即這些字串片段被提交出去。不幸的是,wasm-gc 目前還不能移除沒有使用過的 Data 片段,所以我們在這段時間內一直處於這種臃腫的狀態。WebAssembly 和相關工具仍然不成熟,我們希望工具鏈隨著時間的推移在這方面得到改進。

接下來,我們對 wasm-objdump 的反彙編輸出進行後處理,以計算 Code 部分中每個函式體的大小,並得到用 Rust 建立時的大小:

[譯] 論 Rust 和 WebAssembly 對原始碼地址索引的極限優化

最重要的程式碼塊是 dlmalloc,它通過 alloc 實現 Rust 底層的記憶體分配 APIs。dlmallocalloc 加起來一共是 10,126 位元組,佔總函式程式碼量的 50.98%。從某種意義上說,這是一種解脫:分配器的程式碼大小是一個常數,不會隨著我們將更多的 JavaScript 程式碼移植到 Rust 而增長。

我們自己實現的程式碼總量是(vlqsource_map_mappingssource_map_mappings_wasm_api)9,320 位元組,佔總函式體積的 46.92%。只留了 417 位元組(2.10%)給其它函式。這足以說明 wasm-gcwasm-snipwasm-opt 的功效:std 比我們的程式碼要多,但我們只使用了一小部分 API,所以只保留我們用過的函式。

總結和展望

用 Rust 和 WebAssembly 重構 source-map 中效能最敏感的解析和查詢的功能已經完成。在我們的基準測試中,WebAssembly 實現只需要原始 JavaScript 實現所花費時間的一小部分 —— 僅為 0.17倍。我們觀察到在所有瀏覽器中,WebAssembly 實現總是比 JavaScript 實現的效能要好。WebAssembly 實現也比 JavaScript 實現更加一致和可靠的效能:WebAssembly 實現的進行遍歷操作的時間相對誤差值更小。

JavaScript 已經以效能的名義積累了許多令人費解的程式碼,我們用可讀性更好的 Rust 替代了它。Rust 並不強迫我們在清晰表達意圖和執行時間表現之間進行選擇。

換句話說,我們仍然要為此做許多工作。

下一步工作的首要目標是徹底瞭解為什麼 Rust 標準庫的排序在 WebAssembly 中沒有達到我們實現的快排效能。這個表現另我們驚訝不已,因為我們實現的快排依舊很粗糙,而標準庫的快排在模式設計上很失敗,投機性的使用了最小插入排序和大範圍排序。事實上,在原生環境下,標準庫的排序效能要比我們實現的排序要好。我們推測是行內函數引起執行目標轉移,而我們的比較函式沒有內聯到標準庫中,所以當目標轉移到 WebAssembly 時,標準庫的排序效能就會下降。這需要進一步的驗證。

我們發現 WebAssembly 體積分析太困難而顯得不是很必要。為了獲得更有意義的資訊,我們只能編寫 我們自己實現的反編譯指令碼 wasm-objdump。該指令碼構造呼叫圖,並讓我們查詢某些函式的呼叫者是誰,幫助我們理解為什麼該函式是在 .wasm 檔案中被提交,即使我們沒有預料到它。很不好意思,這個指令碼對行內函數不起作用。一個適當的 WebAssembly 體積分析器會有所幫助,並且任何人都能從追蹤得到有用的資訊。

記憶體分配器的程式碼體積相對較大,重構或者調整一個分配器的程式碼量可以為 WebAssembly 生態系統提供相當大的作用。至少對於我們的用例,記憶體分配器的效能幾乎不用考慮,我們只需要手動分配很小的動態記憶體。對於記憶體分配器,我們會毫不猶豫的選擇程式碼體積小的。

Data 部分中沒有使用的片段需要用 wasm-gc 或者其他工具進行高亮,檢測和刪除永遠不會被使用的靜態資料。

我們仍然可以對庫的下游使用者進行一些 JavaScript API 改進。在我們當前的實現中引入 WebAssembly 需要引入在使用者完成對映解析時手動釋放記憶體。對於大多數習慣依賴垃圾回收器的 JavaScript 程式設計師來說,這並非自然而然,他們通常不會考慮任何特定物件的生命週期。我們可以傳入 SourceMapConsumer.with 函式,它包含一個未解析的 source-map 和一個 async 函式。 with 函式將構造一個 SourceMapConsumer 例項,用它呼叫 async 函式,然後在 async 函式呼叫完成後呼叫 SourceMapConsumer 例項的 destroy。這就像 JavaScript 的async RAII。

SourceMapConsumer.with = async function (rawSourceMap, f) {
    const consumer = await new SourceMapConsumer(rawSourceMap);
    try {
    await f(consumer);
    } finally {
    consumer.destroy();
    }
};
複製程式碼

另一個使 API 更容易被 JavaScript 程式設計人員使用的方法是把 SourceMapConsumer 傳入每一個 WebAssembly 模組。因為 SourceMapConsumer 例項佔據了 WebAssembly 模組例項的 GC 邊緣,垃圾回收器就管理了 SourceMapConsumer 例項、WebAssembly 模組例項和模組例項堆。通過這個策略,我們用一個簡單的 static mut MAPPINGS: Mappings 就可以把 Rust 和 WebAssembly 膠粘起來,並且 Mapping 例項在所有匯出的查詢函式都是不可見的。在 parse_mappings 函式中不再有 Box :: new(mappings) ,並且不再傳遞 * mut Mappings 指標。謹慎期間,我們可能需要把 Rust 庫所有記憶體分配函式移除,這樣可以把需要提交的 WebAssembly 體積縮小一半。當然,這一切都取決於建立相同 WebAssembly 模組的多個例項是一個相對簡單的操作,這需要進一步調查。

wasm-bindgen 專案的目標是移除所有需要手動編寫的 FFI 膠粘程式碼,實現 WebAssembly 和 JavaScript 的自動化對接。使用它,我們能夠刪除所有涉及將 Rust API 匯出到 JavaScript 的手寫 不安全 指標操作程式碼。

在這個專案中,我們將 source-map 解析和查詢移植到 Rust 和 WebAssembly 中,但這只是 source-map 庫功能的一半。另一半是生成源對映,它也是效能敏感的。我們希望在未來的某個時候重寫 Rust 和 WebAssembly 中構建和編碼源對映的核心。我們希望將來能看到生成源對映也能達到這樣的效能。

WebAssembly 實現的 mozilla/source-map 庫所有提交申請的合集 這個提交申請包含了基準測試程式碼,可以將結果重現,你也可以繼續完善它。

最後,我想感謝 Tom Tromey 對這個專案的支援。同時也感謝 Aaron TuronAlex CrichtonBenjamin BouvierJeena LeeJim BlandyLin ClarkLuke WagnerMike Cooper 以及 Till Schneidereit 閱審閱原稿並提供了寶貴的意見。非常感謝他們對基準測試程式碼和 source-map 庫的貢獻。


[0] 或者你堅持叫做 “轉譯器”

[1] 當你傳入自己定義的對比函式,SpiderMonkey 引擎會使用 JavaScript 陣列原型的排序方法 Array.prototype.sort;如果不傳入對比函式,SpiderMonkey 引擎會使用 C++ 實現的排序方法

[2] 一旦 Firefox 瀏覽器出現 1319203 錯誤碼,WebAssembly 和 JavaScript 之間的呼叫效能將會急速下降。WebAssembly 和 JavaScript 的呼叫和 JavaScript 之間的呼叫開銷都是非線性增長的,截止本文發表前各大瀏覽器廠商仍然沒能改進這個問題。

[3] Firefox 瀏覽器和 Chrome 瀏覽器我們都進行了 每日構建 測試,但是沒有對 Safari 瀏覽器進行這樣的測試。因為最新的 Safari Technology Preview 需要比 El Capitan 更新的 macOS 版本,而這款電腦就執行這個版本了。

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


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

相關文章