跳到內容

建構 JavaScript 編譯器時對效能的追求

最初發佈於 https://rustmagazine.org/issue-3/javascript-compiler/

關於效能

在撰寫 Rust 兩年後,效能已成為我根深蒂固的準則 - 它歸結為減少記憶體分配減少 CPU 週期

然而,如果沒有問題領域的知識或對潛在解決方案的了解,就很難實現最佳效能。

我將在以下章節中帶領您踏上我的效能和最佳化之旅。我偏好的學習方式是研究、試驗和錯誤的結合,因此以下章節將以此方式組織。

解析

Oxc 是一個標準編譯器,包含抽象語法樹 (AST)、詞法分析器和遞迴下降解析器。

抽象語法樹 (AST)

編譯器的第一個架構設計是其 AST。

所有 JavaScript 工具都在 AST 層級上運作,例如

  • Linter(例如 ESLint)檢查 AST 中是否有錯誤
  • 格式化程式(例如 Prettier)將 AST 列印回 JavaScript 文字
  • Minifier(例如 terser)轉換 AST
  • Bundler 連接來自不同檔案的 AST 之間的所有 import 和 export 陳述式

如果 AST 不易於使用,建構這些工具將會很痛苦。

對於 JavaScript,最常用的 AST 規範是 estree。我的第一個 AST 版本複製了 estree

rust
pub struct Program {
    pub node: Node,
    pub body: Vec<Statement>,
}

pub enum Statement {
    VariableDeclarationStatement(VariableDeclaration),
}

pub struct VariableDeclaration {
    pub node: Node,
    pub declarations: Vec<VariableDeclarator>,
}

在 Rust 中,宣告樹相對簡單,因為它涉及到使用結構和枚舉。

記憶體分配

我花了好幾個月的時間在這個版本的 AST 上,同時編寫解析器。有一天我決定對其進行效能分析。效能分析器顯示程式花費大量時間呼叫 drop

💡 AST 的節點透過 BoxVec 在堆積上分配,它們會個別分配,因此會依序捨棄。

是否有解決方案可以減輕這種情況?

因此,在開發解析器時,我研究了一些用 Rust 編寫的其他 JavaScript 解析器,主要是 rateljsparagus

這兩個解析器都使用生命週期註釋宣告其 AST,

rust
pub enum Statement<'ast> {
    Expression(ExpressionNode<'ast>),
}

而且它們都有一個伴隨檔案名為 arena.rs

我不了解它的作用,所以我忽略了它們,直到我開始閱讀它們對記憶體競技場的使用:bumpalotoolshed

總而言之,記憶體競技場會預先在區塊或頁面中分配記憶體,並在捨棄競技場時一併釋放記憶體。AST 會在競技場上分配,因此捨棄 AST 是一個快速的操作。

隨之而來的另一個不錯的副作用是,AST 會以特定順序建構,而樹狀結構遍歷也遵循相同的順序,從而在遍歷過程中產生線性記憶體存取。這種存取模式將非常有效率,因為所有鄰近記憶體都將讀取到 CPU 快取中的頁面,從而加快存取速度。

不幸的是,Rust 初學者很難使用記憶體競技場,因為所有資料結構和相關函式都需要透過生命週期註釋進行參數化。我嘗試了五次才將 AST 分配到 bumpalo 內部。

將 AST 變更為使用記憶體競技場,效能提升約 20%。

枚舉大小

由於 AST 的遞迴性質,我們需要以避免「沒有間接的遞迴」錯誤的方式定義類型

error[E0072]: recursive types `Enum` and `Variant` have infinite size
 --> crates/oxc_linter/src/lib.rs:1:1
  |
1 | enum Enum {
  | ^^^^^^^^^
2 |     Variant(Variant),
  |             ------- recursive without indirection
3 | }
4 | struct Variant {
  | ^^^^^^^^^^^^^^
5 |     field: Enum,
  |            ---- recursive without indirection
  |
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 ~     Variant(Box<Variant>),
3 | }
4 | struct Variant {
5 ~     field: Box<Enum>,

有兩種方法可以做到這一點。要麼將枚舉框在枚舉變體中,要麼將結構欄位框起來。

我在 2017 年的 Rust 論壇中找到了相同的問題:有沒有更好的方法來表示抽象語法樹?

Aleksey (matklad) 告訴我們將枚舉變體框起來,以保持 Expression 枚舉較小。但這意味著什麼?

事實證明,Rust 枚舉的記憶體佈局取決於其所有變體的大小,其總位元組大小取決於最大的變體。例如,以下枚舉將佔用 56 個位元組(1 個位元組用於標籤,48 個位元組用於有效負載,8 個位元組用於對齊)。

rust
enum Enum {
    A, // 0 byte payload
    B(String), // 24 byte payload
    C { first: String, last: String }, // 48 byte payload
}

在典型的 JavaScript AST 中,Expression 枚舉包含 45 個變體,而 Statement 枚舉包含 20 個變體。如果不由枚舉變體框起來,它們會佔用超過 200 個位元組。必須傳遞這些 200 個位元組,並且每次我們執行 matches!(expr, Expression::Variant(_)) 檢查時也要存取它們,這對於效能而言不是很適合快取。

因此,為了使記憶體存取有效率,最好將枚舉變體框起來。

perf-book 說明了有關如何尋找大型類型的其他資訊。

我也複製了用於限制小型枚舉大小的測試。

rust
#[cfg(all(target_arch = "x86_64", target_pointer_width = "64"))]
#[test]
fn no_bloat_enum_sizes() {
    use std::mem::size_of;
    use crate::ast::*;
    assert_eq!(size_of::<Statement>(), 16);
    assert_eq!(size_of::<Expression>(), 16);
    assert_eq!(size_of::<Declaration>(), 16);
}

框起枚舉變體大約使速度提升了 10%。

範圍

有時,我們可能不會意識到有可能使用更小的記憶體佔用空間,直到我們花費一些額外的時間檢查資料結構。

在這種情況下,所有 AST 節點的葉都包含一個名為「範圍」的小型資料結構,該結構用於儲存來源文字的位元組偏移,並且包含兩個 usize

rust
pub struct Node {
    pub start: usize,
    pub end: usize,
}

有人向我指出,我可以安全地將 usize 變更為 u32 以減少峰值記憶體,因為大於 u32 的是 4GB 檔案。

變更為 u32 可將大型檔案的效能提高最多 5%

字串和識別碼

在 AST 內部,人們可能會嘗試使用對來源文字的字串參考來作為識別碼名稱和字串字面值。

rust
pub struct StringLiteral<'a> {
    pub value: &'a str,
}

pub struct Identifier<'a> {
    pub name: &'a str,
}

但不幸的是,在 JavaScript 中,字串和識別碼可以具有跳脫序列,例如,'\251''\xA9''©' 對於版權符號是相同的。

這表示我們必須計算跳脫值並分配一個新的 String

字串駐留

當有大量堆積分配的字串時,可以使用一種稱為字串駐留的技術,來減少總記憶體,方法是僅儲存每個不同字串值的一個副本。

string-cache 是一個由 servo 團隊發佈的熱門且廣泛使用的程式庫。最初,我在 AST 中對識別碼和字串使用了 string-cache 程式庫。解析器的效能在單執行緒中很快,但當我開始實作 linter 時,其中有多個解析器使用 rayon 並行執行,CPU 使用率約為所有核心的 50%。

在進行效能分析時,一個名為 parking_lot::raw_mutex::RawMutex::lock_slow 的方法在執行時間的頂部出現。我對鎖定和多核心程式設計不太了解,但全域鎖定一開始就很奇怪,所以我決定移除 string-cache 程式庫以啟用完整的 CPU 使用率。

從 AST 中移除 string-cache 將平行解析的效能提高了約 30%。

string-cache

半年後,在開發另一個效能至關重要的專案時,string-cache 程式庫再次出現。它在平行文字解析期間封鎖了所有執行緒。

我決定研究 string-cache 的作用,因為在閱讀 Mara Bos 的 Rust Atomics and Locks 這本書後,我這次做好了準備。

以下是關於鎖定的相關程式碼。請注意,該程式碼是在 2015 年的八年前撰寫的。

rust
pub(crate) static DYNAMIC_SET: Lazy<Mutex<Set>> = Lazy::new(|| {
    Mutex::new({

// ... in another place
let ptr: std::ptr::NonNull<Entry> =
    DYNAMIC_SET.lock().insert(string_to_add, hash.g);

這很簡單。每次插入字串時,它都會鎖定資料結構 Set。由於此常式會在解析器內頻繁呼叫,因此其效能會受到同步的負面影響。

現在讓我們看看 Set 資料結構,看看它的作用

rust
pub(crate) fn insert(&mut self, string: Cow<str>, hash: u32) -> NonNull<Entry> {
    let bucket_index = (hash & BUCKET_MASK) as usize;
    {
        let mut ptr: Option<&mut Box<Entry>> = self.buckets[bucket_index].as_mut();

        while let Some(entry) = ptr.take() {
            if entry.hash == hash && *entry.string == *string {
                if entry.ref_count.fetch_add(1, SeqCst) > 0 {
                    return NonNull::from(&mut **entry);
                }
                entry.ref_count.fetch_sub(1, SeqCst);
                break;
            }
            ptr = entry.next_in_bucket.as_mut();
        }
    }
    debug_assert!(mem::align_of::<Entry>() >= ENTRY_ALIGNMENT);
    let string = string.into_owned();
    let mut entry = Box::new(Entry {
        next_in_bucket: self.buckets[bucket_index].take(),
        hash,
        ref_count: AtomicIsize::new(1),
        string: string.into_boxed_str(),
    });
    let ptr = NonNull::from(&mut *entry);
    self.buckets[bucket_index] = Some(entry);

    ptr
}

看起來它正在尋找一個儲存字串的儲存區,並且如果儲存區中沒有該字串,它就會插入該字串。

💡 這是否為線性探測?如果這是線性探測,那麼這個 Set 只是沒有說它是 HashMapHashMap。💡 如果這是 HashMap,那麼 Mutex<HashMap> 就是一個並行的雜湊映射。

雖然當我們知道要尋找什麼時,解決方案可能看起來很簡單,但我花了一個月的時間才弄清楚這一點,因為我沒有意識到這個問題。當顯然這只是一個並行的雜湊映射時,將 Mutex 應用於儲存區而不是整個雜湊映射是一個明確且合乎邏輯的解決方案。在實作此變更的一小時內,我提交了一個 pull request,並對結果感到滿意 😃。

https://github.com/servo/string-cache/pull/268

值得一提的是,字串拘留(string interning)在 Rust 社群中是一個競爭激烈的領域。如這篇部落格文章所示的範例,有許多單執行緒的函式庫,例如 string-internerlassolalrpop-internintagliostrena

由於我們是平行處理檔案,一個選項是使用多執行緒的字串拘留函式庫,例如 ustr。然而,在分析了 ustrstring-cache 的增強版本後,很明顯地發現,與我將在下面解釋的方法相比,效能仍然低於預期。

針對效能不佳的一些初步猜測是:

  • 雜湊 - 拘留器需要雜湊字串以進行去重複化。
  • 間接 - 我們需要從「遙遠」的堆積中讀取字串值,這對快取不友善。

字串內聯

所以我們回到了必須配置大量字串的最初問題。幸運的是,如果我們看看正在處理的資料類型:簡短的 JavaScript 變數名稱和一些短字串,這個問題有一個部分解決方案。有一種稱為字串內聯的技術,我們將字串的所有位元組都儲存在堆疊上。

本質上,我們希望使用以下列舉來儲存字串。

rust
enum Str {
    Static(&'static str),
    Inline(InlineReprensation),
    Heap(String),
}

為了最小化列舉的大小,InlineRepresentation 應與 String 的大小相同。

rust
#[cfg(all(target_arch = "x86_64", target_pointer_width = "64"))]
#[test]
fn test_size() {
    use std::mem::size_of;
    assert_eq!(size_of::<String>(), size_of::<InlineReprensation>());
}

Rust 社群中有許多 crate 旨在優化記憶體使用率。這也是社群內另一個競爭激烈的領域。最受歡迎的有:

這些 crate 中的每一個都有其獨特的特性和方法來實現記憶體優化,導致在選擇使用哪個時需要考慮各種權衡和考量。例如,smol_strflexstr 的複製是 O(1)。 flexstr 可以儲存 22 個位元組,smol_strsmartstring 可以儲存 23 個位元組,而 compact_str 在 64 位元的系統上可以儲存 24 個位元組。

https://fasterthanli.me 有一篇關於這個主題的深入探討

String 變更為 compact_str::CompactStr 大幅減少了記憶體配置。

詞法分析器

Token

詞法分析器(也稱為 tokenizer)的工作是將原始文字轉換為稱為 token 的結構化資料。

rust
pub struct Token {
    pub kind: Kind,
}

為了更易於使用,token 的種類通常在 Rust 中定義為列舉。列舉的變體包含每個 token 的對應資料。

rust
pub enum Kind {
    // Keywords
    For,
    While,
    ...
    // Literals
    String(String),
    Num(f64),
    ...
}

這個列舉目前使用 32 個位元組,而詞法分析器通常需要建構數百萬個這種 token Kind。每次建構 Kind::ForKind::While 時,它都必須在堆疊上配置 32 個位元組的記憶體。

一個改進這個問題的聰明方法是將列舉變體分解,以將 Kind 保留為單一位元組,並將值移動到另一個列舉中,

rust
pub struct Token<'a> {
    pub kind: Kind,
    pub value: TokenValue
}

pub enum TokenValue {
    None,
    String(String),
    Num(f64),
}

由於我們控制了所有的解析程式碼,因此我們的職責是透過始終將相應的 token 值宣告為其種類來保持其安全性。

雖然 32 位元組的 TokenValue 已經很小了,但由於它被頻繁配置,仍然可能對效能產生負面影響。

讓我們看看 String 類型,看看我們能找到什麼,透過在我們的程式碼編輯器中使用「前往定義」,我們將遍歷 String -> Vec -> RawVec

rust
pub struct String {
    vec: Vec<u8>,
}

pub struct Vec {
    buf: RawVec<T, A>,
    len: usize,
}

pub struct RawVec {
    ptr: Unique<T>,
    cap: usize,
    alloc: A,
}

正如所宣傳的,String 僅僅是一個 u8Vec,而 Vec 有一個長度和容量欄位。由於我們永遠不會修改這個字串,因此在記憶體使用方面的一個優化是捨棄 cap 欄位,而是使用字串切片 (&str)。

rust
pub enum TokenValue<'a> {
    None,
    String(&'a str),
    Num(f64),
}

TokenValue 變為 24 個位元組。

雖然在 TokenValue 中使用字串切片而不是 String 可以減少記憶體使用量,但它確實帶來了新增生命週期註釋的缺點。這可能會導致借用檢查器出現問題,並且生命週期註釋將傳播到其餘的程式碼庫,使我們的程式碼有些難以管理。我 8 個月前輸掉了借用檢查遊戲,但在重新審視這個問題時最終獲勝了

在有意義的情況下,我們總是可以使用不可變資料的擁有版本,而不是使用參考。例如,Box<str> 用於 String,而 Box<[u8]> 用於 Vec<u8>

總之,我們總是能想出一些技巧來保持我們的資料結構很小,有時它會回報我們效能的提升。

Cow

我第一次遇到 Cow 這個詞是在學習 jsparagus 的程式碼時,它有一個名為 AutoCow 的基礎設施。

我模糊地理解了程式碼的作用。當一個 JavaScript 字串被標記化時,如果它遇到逸出序列,它會配置一個新的字串,否則如果沒有遇到,則會傳回原始的字串切片。

rust
fn finish(&mut self, lexer: &Lexer<'alloc>) -> &'alloc str {
    match self.value.take() {
        Some(arena_string) => arena_string.into_bump_str(),
        None => &self.start[..self.start.len() - lexer.chars.as_str().len()],
    }
}

這很聰明,因為 99.9% 的時間它不會配置一個新的字串,因為逸出字串很少見。

但是 Cow 或「寫入時複製智慧指標」這個詞對我來說從來沒有意義。

類型 Cow 是一個智慧指標,提供寫入時複製功能:它可以封閉並提供對借用資料的不可變存取,並在需要變更或所有權時延遲複製資料。該類型旨在透過 Borrow trait 與一般借用資料一起使用。

如果您是 Rust 的新手(就像我一樣),那麼這種描述根本沒有幫助(我仍然不理解它在說什麼)。

有人向我指出寫入時複製 只是這個資料結構的一個用例。更好的名稱應該稱為 RefOrOwned,因為它是一個包含擁有資料或參考的類型。

SIMD

當我瀏覽舊的 Rust 部落格時,宣布可攜式 SIMD 專案組引起了我的注意。我一直想嘗試使用 SIMD,但從未有機會。經過一些研究,我找到了一個可能適用於解析器的用例:Daniel Lemire 的 如何快速從字串中刪除空格?。事實證明,這以前就有人做過,在一個名為 RapidJSON 的 JSON 解析器中,它使用 SIMD 來刪除空格

因此,最終在可攜式 SIMD 和 RapidJSON 程式碼的幫助下,我不僅成功地跳過空格,還成功地跳過多行註解

這兩個變更都使效能提高了幾個百分點。

關鍵字匹配

在效能分析的最頂層,有一個熱門程式碼路徑約佔總執行時間的 1 - 2%。

它嘗試將字串與 JavaScript 關鍵字匹配。

rust
fn match_keyword(s: &str) -> Self {
    match s {
        "as" => As,
        "do" => Do,
        "if" => If,
        ...
        "constructor" => Constructor,
        _ => Ident,
    }
}

隨著 TypeScript 的加入,我們需要匹配 84 個字串。經過一些研究,我找到了一篇來自 V8 的部落格文章 Blazingly fast parsing, part 1: optimizing the scanner,它詳細描述了它的關鍵字匹配程式碼

由於關鍵字列表是靜態的,我們可以計算一個完美的雜湊函式,對於每個識別符,最多給我們一個候選關鍵字。V8 使用 gperf 來計算這個函式。結果會從長度和前兩個識別符字元計算雜湊,以找到單個候選關鍵字。只有當該關鍵字的長度與輸入識別符長度匹配時,我們才會將識別符與關鍵字進行比較。

因此,快速雜湊加上整數比較應該比 84 個字串比較快。但我們再次嘗試再次嘗試,但都沒有成功。

事實證明,LLVM 已經優化了我們的程式碼。透過在 rustc 上使用 --emit=llvm-ir,我們找到了相關的程式碼

  switch i64 %s.1, label %bb6 [
    i64 2, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit.i"
    i64 3, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit280.i"
    i64 4, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit325.i"
    i64 5, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit380.i"
    i64 6, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit450.i"
    i64 7, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit540.i"
    i64 8, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit590.i"
    i64 9, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit625.i"
    i64 10, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit655.i"
    i64 11, label %"_ZN4core5slice3cmp81_$LT$impl$u20$core..cmp..PartialEq$LT$$u5b$B$u5d$$GT$$u20$for$u20$$u5b$A$u5d$$GT$2eq17h46d405acb5da4997E.exit665.i"
  ], !dbg !191362

%s 是字串,%s.1 是它的長度...它正在分支字串長度!編譯器比我們聰明 😃。

(是的,我們對此非常認真,因此我們開始查看 LLVM IR 和組合語言程式碼。)

後來,@strager 發布了一個非常有教育意義的 YouTube 影片Faster than Rust and C++: the PERFECT hash table ,內容關於這個主題。該影片教導我們一種系統性的方法來推理微調效能問題

最後,我們得出結論,簡單的關鍵字匹配對我們來說就足夠了,因為它只佔效能的 1 - 2%,並且在花了幾天時間後,努力不值得 - Rust 沒有我們建構這個完美的雜湊映射所需的所有東西。

Linter

Linter 是一個分析原始碼中問題的程式。

最簡單的 linter 會訪問每個 AST 節點並檢查規則。可以使用訪問者模式

rust
pub trait Visit<'a>: Sized {
    // ... lots of visit functions

    fn visit_debugger_statement(&mut self, stmt: &'a DebuggerStatement) {
        // report error
    }
}

父指標樹

透過使用訪問者可以輕鬆地向下遍歷 AST,但是如果我們想要向上遍歷樹以收集一些資訊呢?

這個問題在 Rust 中特別難以解決,因為無法在 AST 的節點中新增指標。

讓我們暫時忘記 AST,並專注於具有節點指向其父節點的屬性的通用樹。要建構通用樹,每個樹節點都需要是相同的類型 Node,我們可以使用 Rc 來參考它們的父節點

rust
struct Node {
    parent: Option<Rc<Node>>,
}

如果我們需要變更,使用這種模式會很繁瑣,而且由於節點必須在不同的時間被刪除,因此效能不高。

更有效率的解決方案是使用 Vec 作為其後端儲存,並使用索引作為指標。

rust
struct Tree {
    nodes: Vec<Node>
}

struct Node {
    parent: Option<usize> // index into `nodes`
}

indextree 是一個適合這項任務的好用的函式庫。

回到我們的 AST,我們可以透過讓節點指向一個包裹著每一種 AST 節點的列舉 (enum) 來建立一個 indextree。我們稱之為非類型化的 AST。

rust
struct Node<'a> {
    kind: AstKind<'a>
}

enum AstKind<'a> {
    BlockStatement(&'a BlockStatement<'a>),
    // ...
    ArrayExpression(&'a ArrayExpression<'a>),
    // ...
    Class(&'a Class<'a>),
    // ...
}

最後缺少的部分是在建立這個樹狀結構的訪問者模式 (visitor pattern) 內加入回呼函式 (callbacks)。

rust
pub trait Visit<'a> {
    fn enter_node(&mut self, _kind: AstKind<'a>) {}
    fn leave_node(&mut self, _kind: AstKind<'a>) {}

    fn visit_block_statement(&mut self, stmt: &'a BlockStatement<'a>) {
        let kind = AstKind::BlockStatement(stmt);
        self.enter_node(kind);
        self.visit_statements(&stmt.body);
        self.leave_node(kind);
    }
}

impl<'a> Visit<'a> for TreeBuilder<'a> {
    fn enter_node(&mut self, kind: AstKind<'a>) {
        self.push_ast_node(kind);
    }

    fn leave_node(&mut self, kind: AstKind<'a>) {
        self.pop_ast_node();
    }
}

最終的資料結構會變成 indextree::Arena<Node<'a>>,其中每個 Node 都有一個指向 AstKind<'a> 的指標。可以呼叫 indextree::Node::parent 來取得任何節點的父節點。

建立這種指向父節點的樹狀結構的好處是,可以方便地訪問 AST 節點而無需實作任何訪問者。一個 linter 就變成在 indextree 內對所有節點進行簡單的迴圈。

rust
for node in nodes {
    match node.get().kind {
        AstKind::DebuggerStatement(stmt) => {
        // report error
        }
        _ => {}
    }
}

完整的範例 在此 提供。

乍看之下,這個過程可能看起來很慢且效率低下。然而,透過記憶體配置區 (memory arena) 訪問類型化的 AST 並將指標推入 indextree 是有效率的線性記憶體存取模式。目前的基準測試表明,這種方法比 ESLint 快 84 倍,因此對我們的目的而言絕對夠快。

平行處理檔案

linter 使用 ignore crate 進行目錄遍歷,它支援 .gitignore 並加入額外的忽略檔案,例如 .eslintignore

這個 crate 的一個小問題是它沒有平行介面,對於 ignore::Walk::new(".") 沒有 par_iter

相反地,需要使用基本型別

rust
let walk = Walk::new(&self.options);
rayon::spawn(move || {
    walk.iter().for_each(|path| {
        tx_path.send(path).unwrap();
    });
});

let linter = Arc::clone(&self.linter);
rayon::spawn(move || {
    while let Ok(path) = rx_path.recv() {
        let tx_error = tx_error.clone();
        let linter = Arc::clone(&linter);
        rayon::spawn(move || {
            if let Some(diagnostics) = Self::lint_path(&linter, &path) {
                tx_error.send(diagnostics).unwrap();
            }
            drop(tx_error);
        });
    }
});

這開啟了一個有用的功能,我們可以單執行緒印出所有診斷訊息,這引導我們到本文的最後一個主題。

列印速度慢

列印診斷訊息的速度很快,但我已經在這個專案上工作太久了,以至於每次我在大型 monorepos 上執行 linter 時,都感覺印出數千條診斷訊息像過了一個世紀一樣。所以我開始搜尋 Rust GitHub issue,最終找到了相關的 issue

總而言之,每次 println! 呼叫遇到換行符號時都會鎖定 stdout,這稱為行緩衝 (line buffering)。為了使列印速度更快,我們需要選擇區塊緩衝,相關文件在此

rust
use std::io::{self, Write};

let stdout = io::stdout(); // get the global stdout entity
let mut handle = io::BufWriter::new(stdout); // optional: wrap that handle in a buffer
writeln!(handle, "foo: {}", 42); // add `?` if you care about errors here

或是取得 stdout 的鎖。

rust
let stdout = io::stdout(); // get the global stdout entity
let mut handle = stdout.lock(); // acquire a lock on it
writeln!(handle, "foo: {}", 42); // add `?` if you care about errors here

以 MIT 授權釋出。