- 原文地址:Blazingly fast parsing, part 1: optimizing the scanner
- 原文做者:tverwaes
- 譯文出自:掘金翻譯計劃
- 本文永久連接:github.com/xitu/gold-m…
- 譯者:nettee
- 校對者:suhanyujie
要運行 JavaScript 程序,首先要處理源代碼,讓 V8 能理解它。V8 首先將源代碼解析爲一個抽象語法樹(AST),這是用來表示程序結構的一系列對象。Ignition 會將它編譯爲字節碼(bytecode)。語法分析 + 編譯的這兩個步驟的性能很重要,由於 V8 只有等編譯完成才能運行代碼。在這個系列的博客文章中,咱們關注語法分析階段,以及 V8 爲提供一個超快速的分析器所作的工做。前端
實際上咱們的系列文章始於語法分析器的前一階段。V8 的語法分析器接收掃描器(也就是詞法分析器 —— 譯註)提供的標記(token)做爲輸入。Token 是由一個或多個字符相連而成、有單一語義含義的字符塊,例如字符串、標識符,或像 ++
這樣的操做符。掃描器經過組合底層字符流中的連續字符來構造這些 token。android
掃描器接收 Unicode 字符流。這些 Unicode 字符老是從一個有 UTF-16 碼元(code unit)的流中解碼。爲了不掃描器和語法分析器面對不一樣編碼的特殊處理,咱們只支持一種編碼。咱們選擇 UTF-16 是由於它是 JavaScript 字符串的編碼。而且源碼位置須要相對於該編碼而提供。UTF16CharacterStream
提供了(多是緩衝的)UTF-16 視圖,該視圖構建於 V8 從 Chrome 接收的 Latin一、UTF-8 或 UTF-16 編碼之上。除了能夠支持多種編碼,這種掃描器和字符流分離的方式使 V8 在掃描時能夠如同整個源代碼均可用同樣,即便可能只經過網絡接收了一部分代碼。ios
掃描器和字符流之間的接口是一個叫作 Utf16CharacterStream::Advance()
的方法。它要麼返回下一個 UTF-16 碼元,要麼返回 -1
來標識輸入的結束。UTF-16 沒法將每個 Unicode 字符都編碼在單個碼元中。Basic Multilingual Plane 以外的字符要編碼爲兩個碼元,它們也叫作代理對(surrogate pair)。掃描器在 Unicode 字符上進行工做,而不是 UTF-16 碼元,因此它使用 Scanner::Advance()
方法包裝底層流接口。這個方法將 UTF-16 碼元解碼爲完整的 Unicode 字符。當前解碼出的字符會被緩衝,而後被 Scanner::ScanString()
之類的掃描方法取走。git
掃描器會最多向前看 4 個字符 —— 這是 JavaScript 中歧義字符序列的最大長度 [1] —— 以此選擇特定的掃描器方法或 token。一旦選定了像 ScanString
這樣的方法,它會取走這個 token 餘下的字符,並將不屬於這個 token 的第一個字符緩衝,留給下一個掃描的 token。在 ScanString
的狀況中,它還將掃描到的字符拷貝到一個編碼爲 Latin1 或 UTF-16 的緩衝區中,同時解碼轉義序列。github
token 以前能夠由多種空白符分隔,如換行、空格、製表符、單行註釋、多行註釋等等。一類空白符能夠跟隨其餘類型的空白符。若是空白符致使了兩個 token 以前的換行,會增長含義:這可能致使自動插入分號。所以,在掃描下一個 token 以前,會跳過全部的空白符,並記錄是否遇到了換行。大多數真實生產環境中的 JavaScript 代碼都進行了縮小化(minify),因此幸運地,多字符的空白不是很常見。出於這個緣由,V8 統一獨立地掃描出每種空白符,就像是常規 token 同樣。例如,若是 token 的第一個字符是 /
,第二個字符也是 /
,V8 會將其掃描爲單行註釋,返回 Token::WHITESPACE
。這個過程會一直重複,直到咱們找到了一個不是 Token::WHITESPACE
的 token。這意味着,若是下一個 token 前面沒有空白符,咱們當即開始掃描相關的 token,而不須要顯式檢查空白符。後端
然而,這種循環會增長每一個掃描出的 token 的開銷 —— 它須要分支來判斷剛掃描出的 token。更好的方案是,只在咱們剛掃描出的 token 有多是 Token::WHITESPACE
的時候才繼續這個循環,不然就跳出循環。咱們經過將循環自己放在一個單獨的輔助方法中來作到這一點。在這個方法中,咱們在確信 token 不會是 Token::WHITESPACE
的時候就當即返回。也許這看起來只是一些小變更,但這能減小每個掃描的 token 的額外開銷。這對於標點符號之類的很是短的 token 尤爲有用:緩存
標識符是最複雜同時也最多見的 token,在 JavaScript 中用做變量名(以及其餘內容)。標識符以具備 ID_Start
屬性的 Unicode 字符開頭,後跟一串(可選的)具備 ID_Continue
屬性的字符。查看一個 Unicode 字符是否有 ID_Start
或 ID_Continue
屬性很是耗性能。咱們能夠經過添加一個從字符到它們的屬性的映射做爲緩存來稍微加速。網絡
不過,大多數 JavaScript 源代碼是用 ASCII 字符編寫的。在 ASCII 範圍的字符中,只有 a-z
、A-Z
、$
以及 _
是標識符的起始字符。ID_Continue
還另外包括 0-9
。咱們經過爲 128 個 ASCII 字符構建一個表來加速標識符的掃描。這個表中有標誌位,表示一個字符是不是 ID_Start
、是不是 ID_Continue
等。由於咱們查找的字符是在 ASCII 範圍內,咱們能夠用一個分支來查看錶中相應的標誌位,判斷字符的屬性。在咱們找到第一個沒有 ID_Continue
屬性的字符以前,全部的字符都是標識符的一部分。函數
這篇文章中提到的全部改進都會增長以下所示的標識符掃描的性能差距:性能
越長的標識符掃描得越快,這看起來彷佛違反直覺。這可能會讓你認爲增長標識符的長度有利於提高性能。就 MB/s 而言,掃描較長的標識符固然更快,由於咱們在很是緊湊的循環中停留了更長的時間,而沒有返回給語法分析器。然而,從你的應用的性能的角度來看,你關注的應當是掃描完整的 token 有多快。下圖粗略地展現了每秒鐘掃描的 token 數量與 token 長度之間的關係:
這裏很明顯,使用較短的標識符有利於提高你的應用程序的分析性能:咱們可能每秒鐘掃描更多的 token。這意味着,那些咱們看起來在 MB/s 上以較快的速度分析的位置,有着較低的信息密度,實際上每秒產出的 token 較少。
全部的字符串字面量和標識符,都會在掃描器和語法分析器的邊界上刪除重複的數據。若是分析器請求一個字符串或標識符的值,對每一個可能的字面量值,它會獲得一個惟一的字符串對象。這一般須要哈希表查找。因爲 JavaScript 代碼經常進行縮小化,V8 爲單個 ASCII 字符組成的字符串創建了簡單的查找表。
關鍵字是由語言定義的標識符的特殊子集,例如 if
、else
和 function
。V8 的掃描器會爲關鍵字返回與標識符不一樣的 token。在掃描出標識符以後,咱們須要識別該標識符是不是關鍵字。因爲 JavaScript 中的全部關鍵字僅包含小寫字母 a-z
,咱們也能夠記錄標誌位來代表一個 ASCII 字符是不是可能的關鍵字 start 和 continue 字符(相似標識符的 ID_Start
和 ID_Continue
—— 譯註)。
若是標誌位代表一個標識符多是關鍵字,咱們經過在這個標識符的第一個字符上進行條件判斷,縮減候選關鍵字的集合。因爲關鍵字的不一樣的第一個字符數量要多於關鍵字不一樣的長度數量,所以這種條件判斷能夠減小後續分支的數量。對於每一個字符,咱們基於可能的關鍵字長度進行條件判斷,只在長度也匹配的狀況下比較標識符與關鍵字。
更好的方式是使用一種叫作完美散列的技術。因爲關鍵字列表是事先肯定的,咱們能夠計算出一個完美散列函數,它對每一個標識符只給出至多一個候選關鍵字。V8 使用 gperf 來計算這個函數。結果是由標識符的長度和前兩個字符來尋找單個候選關鍵字。咱們在二者長度相等的狀況下才會將這個標識符與關鍵字進行比較。對於標識符不是關鍵字的狀況,這種方法尤爲提升了性能,由於咱們只須要較少的分支就能夠判斷出(它不是關鍵字)。
如前所述,咱們的掃描器工做在一個 UTF-16 編碼的字符流上,可是接收的是 Unicode 字符。補充平面中的字符只在標識符 token 中有特殊的含義。若是說這種字符出如今字符串中,它們不會是字符串的結尾。JavaScript 支持單獨代理(lone surrogate),它們也會簡單地從源碼中拷貝過來。出於這個緣由,除非絕對必要,最好避免組合代理對,讓掃描器直接工做在 UTF-16 碼元上,而不是 Unicode 字符上。當咱們掃描字符串時,咱們不須要尋找代理對,將它們組合,而後當咱們存儲字符構建字面量的時候再將它們拆開。只剩兩種狀況下掃描器確實須要處理代理對。在 token 掃描的開始處,只有當咱們沒法將字符識別爲其餘任何東西的時候咱們才須要組合代理對,看看組合結果是不是一個標識符的開頭。相似地,咱們須要在標識符掃描的慢速路徑中,組合代理對來處理非 ASCII 字符。
AdvanceUntil
掃描器和 UTF16CharacterStream
之間的接口是有狀態的。字符流會記錄它在緩衝區中的位置,在每次碼元被取走以後將位置遞增。掃描器會在返回給請求一個字符的掃描方法以前,先緩衝這個接收到的碼元。這個掃描方法會讀到已緩衝的字符,並根據字符的值繼續執行。這提供了漂亮的分層,但速度至關慢。去年秋天,咱們的實習生 Florian Sattler 提出了改進的接口,它保留了分層的好處,同時提供了更快訪問流中碼元的方法。一個模板化的函數 AdvanceUntil
(特化參數爲掃描幫助函數),會使用流中的每一個字符調用幫助函數,直到幫助函數返回 false。這本質上爲掃描器提供了直接訪問底層數據,而又不破壞抽象的方法。這個方案實際上簡化了掃描幫助函數,由於它們不須要處理 EndOfInput
了。
AdvanceUntil
對於加快那些須要處理大量字符的掃描函數尤爲有用。咱們使用它來加速以前提到的標識符,同時還同來加速字符串 [2] 和註釋。
掃描的性能是語法分析器性能的基石。咱們已經將掃描器調節得儘量高效了。這致使了全面性的提高,掃描單個 token 的性能提高了約 1.4 倍,字符串 1.3 倍,多行註釋 2.1 倍,標識符 1.2-1.5 倍,取決於標識符長度。
然而,咱們的掃描器也只能作這麼多。做爲開發者,你能夠經過提高程序的信息密度來進一步提高語法分析的性能。最簡單的方法是將你的源代碼縮小化,去除沒必要要的空白符,避免出現沒必要要的非 ASCII 字符。理想狀況下,這些步驟都做爲構建流程的一部分自動完成了,那麼你就不須要在寫代碼的時候擔憂這些了。
<!--
是 HTML 註釋的開頭,而 <!-
會被識別爲「小於」、「非」、「減」。↩︎
當前,沒法被編碼爲 Latin1 的字符串和標識符處理代價會較昂貴,由於咱們會先嚐試將他們緩衝爲 Latin1,當遇到一個沒法編碼爲 Latin1 的字符的時候再轉化爲 UTF-16。↩︎
做者:Toon Verwaest (@tverwaes),可恥的優化者。
若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。
掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。