整合營銷服務商

          電腦端+手機端+微信端=數據同步管理

          免費咨詢熱線:

          探秘網頁性能提升利器之CSS硬件加速

          天我們將為大家介紹一個令網頁性能大幅提升的神奇技術——CSS硬件加速。隨著移動互聯網的蓬勃發展和網頁設計越發復雜,如何優化網頁性能成為了前端開發者們亟待解決的問題。在這篇文章中,我們將深入了解CSS硬件加速的原理,并通過一個生動的案例來展示它如何幫助我們改善網頁的渲染性能。

          一、什么是CSS硬件加速

          在傳統的網頁渲染中,瀏覽器使用中央處理器(CPU)來處理CSS樣式和頁面渲染。然而,隨著網頁變得越來越復雜,例如包含大量動畫、過渡效果或復雜的變換,CPU可能會承擔較重的負擔,導致頁面加載緩慢或卡頓。CSS硬件加速是一種解決方案,它充分利用了計算機的圖形處理單元(GPU)來加快CSS樣式的處理和渲染,從而提高頁面性能和流暢度。

          1.1 CPU

          CPU 即中央處理器。

          CPU是計算機的大腦,它提供了一套指令集,我們寫的程序最終會通過 CPU 指令來控制的計算機的運行。它會對指令進行譯碼,然后通過邏輯電路執行該指令。整個執行的流程分為了多個階段,叫做流水線。指令流水線包括取指令、譯碼、執行、取數、寫回五步,這是一個指令周期。CPU會不斷的執行指令周期來完成各種任務。

          1.2 GPU

          GPU 即圖形處理器。

          GPU,是Graphics ProcessingUnit的簡寫,是現代顯卡中非常重要的一個部分,其地位與CPU在主板上的地位一致,主要負責的任務是加速圖形處理速度。GPU是顯卡的“大腦”,它決定了該顯卡的檔次和大部分性能,同時也是2D顯示卡和3D顯示卡的區別依據。2D顯示芯片在處理3D圖像和特效時主要依賴CPU的處理能力,稱為“軟加速”。3D顯示芯片是將三維圖像和特效處理功能集中在顯示芯片內,也即所謂的“硬件加速”功能。

          二、CSS硬件加速原理

          CSS硬件加速的原理涉及到瀏覽器的渲染引擎、GPU以及優化渲染的過程。

          2.1 瀏覽器的渲染流程

          一個完整的渲染步驟大致可總結為如下:

          • 渲染進程將HTML內容轉換為能夠讀懂的DOM樹結構。
          • 渲染引擎將CSS樣式表轉化為瀏覽器可以理解的 styleSheets ,計算出DOM節點的樣式。
          • 創建布局樹,并計算元素的布局信息。
          • 對布局樹進行分層,并生成分層樹。
          • 為每個圖層生成繪制列表,并將其提交到合成線程。
          • 合成線程將圖層分成圖塊,并在光柵化線程池中將圖塊轉換成位圖。
          • 合成線程發送繪制圖塊命令DrawQuad給瀏覽器進程。
          • 瀏覽器進程根據DrawQuad消息生成頁面,并顯示到顯示器上。

          2.2 CSS硬件加速觸發

          在傳統的渲染過程中,布局和繪制是由CPU來完成的,而在CSS硬件加速下,GPU參與了渲染的處理,從而提高了性能。

          CSS 中的以下幾個屬性能觸發硬件加速:

          1.transform屬性:該屬性用于應用2D或3D變換效果,如旋轉、縮放、平移等。當使用transform屬性時,瀏覽器會將變換任務交給GPU處理,從而實現硬件加速。

          2.opacity屬性:該屬性用于設置元素的不透明度。雖然它主要用于控制透明度,但是一個不為1的值(例如0.99)也可以觸發硬件加速。

          3.will-change屬性:will-change屬性用于提示瀏覽器一個元素將要發生的變化,以便瀏覽器在渲染過程中做出優化。

          一旦CSS硬件加速被觸發,相關的渲染任務將被GPU處理。GPU在處理圖形和動畫方面通常比CPU更快和更高效。對于復雜的CSS動畫和變換,GPU可以并行處理多個任務,從而提高性能和流暢度。

          請注意,CSS硬件加速并不是適用于所有情況。雖然它在許多情況下可以帶來顯著的性能提升,但有時也可能導致額外的GPU資源占用,從而影響其他應用程序的性能。因此,在使用CSS硬件加速時,我們應該進行性能測試和優化,確保在特定情況下確實能獲得性能的提升。

          三、CSS硬件加速案例

          現在,我們來看一個實際的案例,通過啟用CSS硬件加速來改善網頁性能。

          <!DOCTYPE html>

          <html lang="en">

          <head>

          <meta charset="UTF-8" />

          <meta name="viewport" content="width=device-width, initial-scale=1.0" />

          <title>Document</title>

          <style>

          .app {

          position: relative;

          width: 400px;

          height: 400px;

          }

          .box {

          position: absolute;

          left: 0;

          top: 0;

          width: 100px;

          height: 100px;

          background-color: yellowgreen;

          }

          .box-run1 {

          -webkit-animation: run1 4s infinite;

          animation: run1 4s infinite;

          }

          .box-run2 {

          -webkit-animation: run2 4s infinite;

          animation: run2 4s infinite;

          }

          @keyframes run1 {

          0% {

          top: 0;

          left: 0;

          }

          25% {

          top: 0;

          left: 200px;

          }

          50% {

          top: 200px;

          left: 200px;

          }

          75% {

          top: 200px;

          left: 0;

          }

          }

          @keyframes run2 {

          0% {

          transform: translate(0, 0);

          }

          25% {

          transform: translate(200px, 0);

          }

          50% {

          transform: translate(200px, 200px);

          }

          75% {

          transform: translate(0, 200px);

          }

          }

          </style>

          </head>

          <body>

          <div class="app">

          <div class="box"></div>

          </div>

          <button class="btn1">循環轉換</button>

          <button class="btn2">硬件加速</button>

          <script>

          let box = document.querySelector(".box");

          let btn1 = document.querySelector(".btn1");

          let btn2 = document.querySelector(".btn2");

          btn1.addEventListener("click", function (e) {

          box.classList.remove("box-run2");

          box.classList.add("box-run1");

          });

          btn2.addEventListener("click", function (e) {

          box.classList.remove("box-run1");

          box.classList.add("box-run2");

          });

          </script>

          </body>

          </html>

          此時我們可以運行代碼,在頁面上可以看到,2個按鈕均能使box在app當中循環移動。但對于這兩種方式的移動,他們的效率卻有著很大的差異。我們可以使用開發者工具里的Performance去查看。

          當我們點擊btn1時,此時box盒子通過定位的left和top進行循環移動時。

          此時我們可以看到細節模塊的記錄詳情。

          藍色(Loading):網絡通信和HTML解析

          黃色(Scripting):Javascript執行

          紫色(Rendering):樣式計算和布局,即重排

          綠色(Painting):重繪

          灰色(Other):其他事件花費的時間

          白色(Idle):空閑時間

          細節模塊有4個面板,Summary面板每個事件都會有,其他三個只針對特定事件會有。

          當我們點擊btn2時,此時box盒子通過transform屬性進行css硬件加速后進行循環移動時。

          通過對比我們不難發現,當啟用硬件加速時,方塊的變換會更加流暢,其樣式計算和布局、重繪的時間都會減少。因為GPU參與了渲染過程。

          總結

          CSS硬件加速是一個強大的前端技術,可以顯著提高網頁的性能和流暢度。通過啟用硬件加速,我們可以將一些渲染任務交給GPU來處理,減輕CPU的負擔,從而優化網頁的渲染性能。然而,我們需要注意不要濫用硬件加速,避免觸發不必要的GPU渲染,以確保真正獲得性能提升。在日常的網頁開發中,我們可以靈活運用CSS硬件加速,為用戶帶來更好的瀏覽體驗。

          數適配器機制不僅復雜,而且成本很高。

          本文最初發表于 v8.dev(Faster JavaScript calls),基于 CC 3.0 協議分享,由 InfoQ 翻譯并發布。

          JavaScript 允許使用與預期形式參數數量不同的實際參數來調用一個函數,也就是傳遞的實參可以少于或者多于聲明的形參數量。前者稱為申請不足(under-application),后者稱為申請過度(over-application)。

          在申請不足的情況下,剩余形式參數會被分配 undefined 值。在申請過度的情況下,可以使用 rest 參數和 arguments 屬性訪問剩余實參,或者如果它們是多余的可以直接忽略。如今,許多 Web/Node.js 框架都使用這個 JS 特性來接受可選形參,并創建更靈活的 API。

          直到最近,V8 都有一種專門的機制來處理參數大小不匹配的情況:這種機制叫做參數適配器框架。不幸的是,參數適配是有性能成本的,但在現代的前端和中間件框架中這種成本往往是必須的。但事實證明,我們可以通過一個巧妙的技巧來拿掉這個多余的框架,簡化 V8 代碼庫并消除幾乎所有的開銷。

          我們可以通過一個微型基準測試來計算移除參數適配器框架可以獲得的性能收益。

          console.time();
          function f(x, y, z) {}
          for (let i = 0; i <  N; i++) {
            f(1, 2, 3, 4, 5);
          }
          console.timeEnd();

          移除參數適配器框架的性能收益,通過一個微基準測試來得出。

          上圖顯示,在無 JIT 模式(Ignition)下運行時,開銷消失,并且性能提高了 11.2%。使用 TurboFan 時,我們的速度提高了 40%。

          這個微基準測試自然是為了最大程度地展現參數適配器框架的影響而設計的。但是,我們也在許多基準測試中看到了顯著的改進,例如我們內部的 JSTests/Array 基準測試(7%)和 Octane2(Richards 子項為 4.6%,EarleyBoyer 為 6.1%)。

          太長不看版:反轉參數

          這個項目的重點是移除參數適配器框架,這個框架在訪問棧中被調用者的參數時為其提供了一個一致的接口。為此,我們需要反轉棧中的參數,并在被調用者框架中添加一個包含實際參數計數的新插槽。下圖顯示了更改前后的典型框架示例。

          移除參數適配器框架之前和之后的典型 JavaScript 棧框架。


          加快 JavaScript 調用

          為了講清楚我們如何加快調用,首先我們來看看 V8 如何執行一個調用,以及參數適配器框架如何工作。

          當我們在 JS 中調用一個函數調用時,V8 內部會發生什么呢?用以下 JS 腳本為例:

          function add42(x) {
            return x + 42;
          }
          add42(3);

          在函數調用期間 V8 內部的執行流程。


          Ignition

          V8 是一個多層 VM。它的第一層稱為 Ignition,是一個具有累加器寄存器的字節碼棧機。V8 首先會將代碼編譯為 Ignition 字節碼。上面的調用被編譯為以下內容:

          0d              LdaUndefined              ;; Load undefined into the accumulator
          26 f9           Star r2                   ;; Store it in register r2
          13 01 00        LdaGlobal [1]             ;; Load global pointed by const 1 (add42)
          26 fa           Star r1                   ;; Store it in register r1
          0c 03           LdaSmi [3]                ;; Load small integer 3 into the accumulator
          26 f8           Star r3                   ;; Store it in register r3
          5f fa f9 02     CallNoFeedback r1, r2-r3  ;; Invoke call

          調用的第一個參數通常稱為接收器(receiver)。接收器是 JSFunction 中的 this 對象,并且每個 JS 函數調用都必須有一個 this。CallNoFeedback 的字節碼處理器需要使用寄存器列表 r2-r3 中的參數來調用對象 r1。

          在深入研究字節碼處理器之前,請先注意寄存器在字節碼中的編碼方式。它們是負的單字節整數:r1 編碼為 fa,r2 編碼為 f9,r3 編碼為 f8。我們可以將任何寄存器 ri 稱為 fb - i,實際上正如我們所見,正確的編碼是- 2 - kFixedFrameHeaderSize - i。寄存器列表使用第一個寄存器和列表的大小來編碼,因此 r2-r3 為 f9 02。

          Ignition 中有許多字節碼調用處理器。可以在此處查看它們的列表。它們彼此之間略有不同。有些字節碼針對 undefined 的接收器調用、屬性調用、具有固定數量的參數調用或通用調用進行了優化。在這里我們分析 CallNoFeedback,這是一個通用調用,在該調用中我們不會積累執行過程中的反饋。

          這個字節碼的處理器非常簡單。它是用 CodeStubAssembler 編寫的,你可以在此處查看。本質上,它會尾調用一個架構依賴的內置 InterpreterPushArgsThenCall。

          這個內置方法實際上是將返回地址彈出到一個臨時寄存器中,壓入所有參數(包括接收器),然后壓回該返回地址。此時,我們不知道被調用者是否是可調用對象,也不知道被調用者期望多少個參數,也就是它的形式參數數量。

          內置 InterpreterPushArgsThenCall 執行后的框架狀態。

          最終,執行會尾調用到內置的 Call。它會在那里檢查目標是否是適當的函數、構造器或任何可調用對象。它還會讀取共享 shared function info 結構以獲得其形式參數計數。

          如果被調用者是一個函數對象,它將對內置的 CallFunction 進行尾部調用,并在其中進行一系列檢查,包括是否有 undefined 對象作為接收器。如果我們有一個 undefined 或 null 對象作為接收器,則應根據 ECMA 規范對其修補,以引用全局代理對象。

          執行隨后會對內置的 InvokeFunctionCode 進行尾調用。在沒有參數不匹配的情況下,InvokeFunctionCode 只會調用被調用對象中字段 Code 所指向的內容。這可以是一個優化函數,也可以是內置的 InterpreterEntryTrampoline。

          如果我們假設要調用的函數尚未優化,則 Ignition trampoline 將設置一個 IntepreterFrame。你可以在此處查看V8 中框架類型的簡短摘要。

          接下來發生的事情就不用多談了,我們可以看一個被調用者執行期間的解釋器框架快照。

          我們看到框架中有固定數量的插槽:返回地址、前一個框架指針、上下文、我們正在執行的當前函數對象、該函數的字節碼數組以及我們當前正在執行的字節碼偏移量。最后,我們有一個專用于此函數的寄存器列表(你可以將它們視為函數局部變量)。add42 函數實際上沒有任何寄存器,但是調用者具有類似的框架,其中包含 3 個寄存器。

          如預期的那樣,add42 是一個簡單的函數:

          25 02             Ldar a0          ;; Load the first argument to the accumulator
          40 2a 00          AddSmi [42]      ;; Add 42 to it
          ab                Return           ;; Return the accumulator

          請注意我們在 Ldar(Load Accumulator Register)字節碼中編碼參數的方式:參數 1(a0)用數字 02 編碼。實際上,任何參數的編碼規則都是[ai] = 2 + parameter_count - i - 1,接收器[this] = 2 + parameter_count,或者在本例中[this] = 3。此處的參數計數不包括接收器。

          現在我們就能理解為什么用這種方式對寄存器和參數進行編碼。它們只是表示一個框架指針的偏移量。然后,我們可以用相同的方式處理參數/寄存器的加載和存儲。框架指針的最后一個參數偏移量為 2(先前的框架指針和返回地址)。這就解釋了編碼中的 2。解釋器框架的固定部分是 6 個插槽(4 個來自框架指針),因此寄存器零位于偏移量-5 處,也就是 fb,寄存器 1 位于 fa 處。很聰明是吧?

          但請注意,為了能夠訪問參數,該函數必須知道棧中有多少個參數!無論有多少參數,索引 2 都指向最后一個參數!

          Return 的字節碼處理器將調用內置的 LeaveInterpreterFrame 來完成。該內置函數本質上是從框架中讀取函數對象以獲取參數計數,彈出當前框架,恢復框架指針,將返回地址保存在一個暫存器中,根據參數計數彈出參數并跳轉到暫存器中的地址。

          這套流程很棒!但是,當我們調用一個實參數量少于或多于其形參數量的函數時,會發生什么呢?這個聰明的參數/寄存器訪問流程將失敗,我們該如何在調用結束時清理參數?

          參數適配器框架

          現在,我們使用更少或更多的實參來調用 add42:

          add42();
          add42(1, 2, 3);

          JS 開發人員會知道,在第一種情況下,x 將被分配 undefined,并且該函數將返回 undefined + 42 = NaN。在第二種情況下,x 將被分配 1,函數將返回 43,其余參數將被忽略。請注意,調用者不知道是否會發生這種情況。即使調用者檢查了參數計數,被調用者也可以使用 rest 參數或 arguments 對象訪問其他所有參數。實際上,在 sloppy 模式下甚至可以在 add42 外部訪問 arguments 對象。

          如果我們執行與之前相同的步驟,則將首先調用內置的 InterpreterPushArgsThenCall。它將像這樣將參數推入棧:

          內置 InterpreterPushArgsThenCall 執行后的框架狀態。


          繼續與以前相同的過程,我們檢查被調用者是否為函數對象,獲取其參數計數,并將接收器補到全局代理。最終,我們到達了 InvokeFunctionCode。

          在這里我們不會跳轉到被調用者對象中的 Code。我們檢查參數大小和參數計數之間是否存在不匹配,然后跳轉到 ArgumentsAdaptorTrampoline。

          在這個內置組件中,我們構建了一個額外的框架,也就是臭名昭著的參數適配器框架。這里我不會解釋內置組件內部發生了什么,只會向你展示內置組件調用被調用者的 Code 之前的框架狀態。請注意,這是一個正確的 x64 call(不是 jmp),在被調用者執行之后,我們將返回到 ArgumentsAdaptorTrampoline。這與進行尾調用的 InvokeFunctionCode 正好相反。

          我們創建了另一個框架,該框架復制了所有必需的參數,以便在被調用者框架頂部精確地包含參數的形參計數。它創建了一個被調用者函數的接口,因此后者無需知道參數數量。被調用者將始終能夠使用與以前相同的計算結果來訪問其參數,即[ai] = 2 + parameter_count - i - 1。

          V8 具有一些特殊的內置函數,它們在需要通過 rest 參數或 arguments 對象訪問其余參數時能夠理解適配器框架。它們始終需要檢查被調用者框架頂部的適配器框架類型,然后采取相應措施。

          如你所見,我們解決了參數/寄存器訪問問題,但是卻添加了很多復雜性。需要訪問所有參數的內置組件都需要了解并檢查適配器框架的存在。不僅如此,我們還需要注意不要訪問過時的舊數據。考慮對 add42 的以下更改:

          function add42(x) {
            x += 42;
            return x;
          }

          現在,字節碼數組為:

          25 02             Ldar a0       ;; Load the first argument to the accumulator
          40 2a 00          AddSmi [42]   ;; Add 42 to it
          26 02             Star a0       ;; Store accumulator in the first argument slot
          ab                Return        ;; Return the accumulator

          如你所見,我們現在修改 a0。因此,在調用 add42(1, 2, 3)的情況下,參數適配器框架中的插槽將被修改,但調用者框架仍將包含數字 1。我們需要注意,參數對象正在訪問修改后的值,而不是舊值。

          從函數返回很簡單,只是會很慢。還記得 LeaveInterpreterFrame 做什么嗎?它基本上會彈出被調用者框架和參數,直到到達最大形參計數為止。因此,當我們返回參數適配器存根時,棧如下所示:

          被調用者 add42 執行之后的框架狀態。

          我們需要彈出參數數量,彈出適配器框架,根據實際參數計數彈出所有參數,然后返回到調用者執行。

          簡單總結:參數適配器機制不僅復雜,而且成本很高。

          移除參數適配器框架

          我們可以做得更好嗎?我們可以移除適配器框架嗎?事實證明我們確實可以。

          我們回顧一下之前的需求:


          1. 我們需要能夠像以前一樣無縫訪問參數和寄存器。訪問它們時無法進行檢查。那成本太高了。
          2. 我們需要能夠從棧中構造 rest 參數和 arguments 對象。
          3. 從一個調用返回時,我們需要能夠輕松清理未知數量的參數。
          4. 此外,當然我們希望沒有額外的框架!

          如果要消除多余的框架,則需要確定將參數放在何處:在被調用者框架中還是在調用者框架中。

          被調用者框架中的參數

          假設我們將參數放在被調用者框架中。這似乎是一個好主意,因為無論何時彈出框架,我們都會一次彈出所有參數!

          參數必須位于保存的框架指針和框架末尾之間的某個位置。這就要求框架的大小不會被靜態地知曉。訪問參數仍然很容易,它就是一個來自框架指針的簡單偏移量。但現在訪問寄存器要復雜得多,因為它會根據參數的數量而變化。

          棧指針總是指向最后一個寄存器,然后我們可以使用它來訪問寄存器而無需知道參數計數。這種方法可能行得通,但它有一個關鍵缺陷。它需要復制所有可以訪問寄存器和參數的字節碼。我們將需要 LdaArgument 和 LdaRegister,而不是簡單的 Ldar。當然,我們還可以檢查我們是否正在訪問一個參數或寄存器(正或負偏移量),但這將需要檢查每個參數和寄存器訪問。顯然這種方法太昂貴了!

          調用者框架中的參數

          好的,如果我們在調用者框架中放參數呢?

          記住如何計算一個框架中參數 i 的偏移量:[ai] = 2 + parameter_count - i - 1。如果我們擁有所有參數(不僅是形式參數),則偏移量將為[ai] = 2 + parameter_count - i - 1.也就是說,對于每個參數訪問,我們都需要加載實際的參數計數。

          但如果我們反轉參數會發生什么呢?現在可以簡單地將偏移量計算為[ai] = 2 + i。我們不需要知道棧中有多少個參數,但如果我們可以保證棧中至少有形參計數那么多的參數,那么我們就能一直使用這種方案來計算偏移量。

          換句話說,壓入棧的參數數量將始終是參數數量和形參數量之間的最大值,并且在需要時使用 undefined 對象進行填充。

          這還有另一個好處!對于任何 JS 函數,接收器始終位于相同的偏移量處,就在返回地址的正上方:[this] = 2。

          對于我們的第 1 和第 4 條要求,這是一個干凈的解決方案。另外兩個要求又如何呢?我們如何構造 rest 參數和 arguments 對象?返回調用者時如何清理棧中的參數?為此,我們缺少的只是參數計數而已。我們需要將其保存在某個地方。只要可以輕松訪問此信息即可,具體怎么做沒那么多限制。兩種基本選項分別是:將其推送到調用者框架中的接收者之后,或被調用者框架中的固定標頭部分。我們實現了后者,因為它合并了 Interpreter 和 Optimized 框架的固定標頭部分。


          如果在 V8 v8.9 中運行前面的示例,則在 InterpreterArgsThenPush 之后將看到以下棧(請注意,現在參數已反轉):

          內置 InterpreterPushArgsThenCall 執行后的框架狀態。

          所有執行都遵循類似的路徑,直到到達 InvokeFunctionCode。在這里,我們在申請不足的情況下處理參數,根據需要推送盡可能多的 undefined 對象。請注意,在申請過度的情況下,我們不會進行任何更改。最后,我們通過一個寄存器將參數數量傳遞給被調用者的 Code。在 x64 的情況下,我們使用寄存器 rax。

          如果被調用者尚未進行優化,我們將到達 InterpreterEntryTrampoline,它會構建以下棧框架。

          沒有參數適配器的棧框架。

          被調用者框架有一個額外的插槽,其中包含的參數計數可用于構造 rest 參數或 arguments 對象,并在返回到調用者之前清除棧中參數。

          返回時,我們修改 LeaveInterpreterFrame 以讀取棧中的參數計數,并彈出參數計數和形式參數計數之間的較大數字。

          TurboFan

          那么代碼優化呢?我們來稍微更改一下初始腳本,以強制 V8 使用 TurboFan 對其進行編譯:

          function add42(x) { return x + 42; }
          function callAdd42() { add42(3); }
          %PrepareFunctionForOptimization(callAdd42);
          callAdd42();
          %OptimizeFunctionOnNextCall(callAdd42);
          callAdd42();

          在這里,我們使用 V8 內部函數來強制 V8 優化調用,否則 V8 僅在我們的小函數變熱(經常使用)時才對其進行優化。我們在優化之前調用它一次,以收集一些可用于指導編譯的類型信息。在此處閱讀有關 TurboFan 的更多信息(https://v8.dev/docs/turbofan)。

          這里,我只展示與主題相關的部分生成代碼。

          movq rdi,0x1a8e082126ad    ;; Load the function object <JSFunction add42>
          push 0x6                   ;; Push SMI 3 as argument
          movq rcx,0x1a8e082030d1    ;; <JSGlobal Object>
          push rcx                   ;; Push receiver (the global proxy object)
          movl rax,0x1               ;; Save the arguments count in rax
          movl rcx,[rdi+0x17]        ;; Load function object {Code} field in rcx
          call rcx                   ;; Finally, call the code object!

          盡管這段代碼使用了匯編來編寫,但如果你仔細看我的注釋應該很容易能懂。本質上,在編譯調用時,TF 需要完成之前在 InterpreterPushArgsThenCall、Call、CallFunction 和 InvokeFunctionCall 內置組件中完成的所有工作。它應該會有更多的靜態信息來執行此操作并發出更少的計算機指令。

          帶參數適配器框架的 TurboFan

          現在,讓我們來看看參數數量和參數計數不匹配的情況。考慮調用 add42(1, 2, 3)。它會編譯為:

          movq rdi,0x4250820fff1    ;; Load the function object <JSFunction add42>
          ;; Push receiver and arguments SMIs 1, 2 and 3
          movq rcx,0x42508080dd5    ;; <JSGlobal Object>
          push rcx
          push 0x2
          push 0x4
          push 0x6
          movl rax,0x3              ;; Save the arguments count in rax
          movl rbx,0x1              ;; Save the formal parameters count in rbx
          movq r10,0x564ed7fdf840   ;; <ArgumentsAdaptorTrampoline>
          call r10                  ;; Call the ArgumentsAdaptorTrampoline

          如你所見,不難為 TF 添加對參數和參數計數不匹配的支持。只需調用參數適配器 trampoline 即可!

          然而這種方法成本很高。對于每個優化的調用,我們現在都需要進入參數適配器 trampoline,并像未優化的代碼一樣處理框架。這就解釋了為什么在優化的代碼中移除適配器框架的性能收益比在 Ignition 上大得多。

          但是,生成的代碼非常簡單。從中返回非常容易(結尾):

          movq rsp,rbp   ;; Clean callee frame
          pop rbp
          ret 0x8        ;; Pops a single argument (the receiver)

          我們彈出框架并根據參數計數發出一個返回指令。如果實參計數和形參計數不匹配,則適配器框架 trampoline 將對其進行處理。

          沒有參數適配器框架的 TurboFan

          生成的代碼本質上與參數計數匹配的調用代碼相同。考慮調用 add42(1, 2, 3)。這將生成:

          movq rdi,0x35ac082126ad    ;; Load the function object <JSFunction add42>
          ;; Push receiver and arguments 1, 2 and 3 (reversed)
          push 0x6
          push 0x4
          push 0x2
          movq rcx,0x35ac082030d1    ;; <JSGlobal Object>
          push rcx
          movl rax,0x3               ;; Save the arguments count in rax
          movl rcx,[rdi+0x17]        ;; Load function object {Code} field in rcx
          call rcx                   ;; Finally, call the code object!

          該函數的結尾如何?我們不再回到參數適配器 trampoline 了,因此結尾確實比以前復雜了一些。

          movq rcx,[rbp-0x18]        ;; Load the argument count (from callee frame) to rcx
          movq rsp,rbp               ;; Pop out callee frame
          pop rbp
          cmpq rcx,0x0               ;; Compare arguments count with formal parameter count
          jg 0x35ac000840c6  <+0x86>
          ;; If arguments count is smaller (or equal) than the formal parameter count:
          ret 0x8                    ;; Return as usual (parameter count is statically known)
          ;; If we have more arguments in the stack than formal parameters:
          pop r10                    ;; Save the return address
          leaq rsp,[rsp+rcx*8+0x8]   ;; Pop all arguments according to rcx
          push r10                   ;; Recover the return address
          retl

          小結

          參數適配器框架是一個臨時解決方案,用于實際參數和形式參數計數不匹配的調用。這是一個簡單的解決方案,但它帶來了很高的性能成本,并增加了代碼庫的復雜性。如今,許多 Web 框架使用這一特性來創建更靈活的 API,結果帶來了更高的性能成本。反轉棧中參數這個簡單的想法可以大大降低實現復雜性,并消除了此類調用的幾乎所有開銷。

          原文鏈接:

          https://v8.dev/blog/adaptor-frame

          延伸閱讀:

          Deno 2020 年大事記-InfoQ

          關注我并轉發此篇文章,即可獲得學習資料~若想了解更多,也可移步InfoQ官網,獲取InfoQ最新資訊~

          家好,很高興又見面了,我是"高級前端進階",由我帶著大家一起關注前端前沿、深入前端底層技術,大家一起進步,也歡迎大家關注、點贊、收藏、轉發!

          高級前端進階

          讓 JavaScript 在 WebAssembly 上疾速運行

          與二十年前相比,如今 JavaScript 在瀏覽器中的運行速度要快好多倍。而這多虧了瀏覽器廠商們在此期間堅持不懈地加強性能優化。

          而現在,我們又要開始在完全不同的運行環境中優化 JavaScript 的性能 —— 這些新環境中的游戲規則是截然不同的。而讓 JavaScript 能夠適應不同運行環境的,正是 WebAssembly。

          這里我們要明確一點 —— 如果你是在瀏覽器中運行 JavaScript,那么直接部署 JavaScript 就行了。瀏覽器中的 JavaScript 引擎已經被精心調校過,可以很快速地運行裝載進來的 JavaScript 程序。

          但如果是在無服務器(Serverless)功能中運行 JavaScript 呢?又或者說,如果想要在 iOS 或游戲機這類不支持通常的即時編譯的環境中運行 JavaScript,又該如何把控性能?

          在這些使用場景中,你會需要關注這新一輪的 JavaScript 優化。另外,若想要讓 Python、Ruby 或者 Lua 等其他運行時語言在上述使用場景中提速,JavaScript 優化也有參考價值。

          但在開始探索如何在不同環境中進行優化前,我們需要了解一下其中的基本原理。

          原理是什么?

          不論你在何時運行 Javascript 程序,JavaScript 代碼終歸要以機器編碼的形式執行。 JavaScript 引擎通過一系列技術來實現這一轉換,例如各種解釋器和 JIT 編譯器。(詳情請參見即時(JIT)編譯器速成課。)

          但如果你想要運行程序的平臺沒有 JavaScript 引擎怎么辦?那你就需要把 JavaScript 引擎和程序代碼一起部署。

          為了能讓 JavaScript 隨處運行,我們把 JavaScript 引擎部署為一個 WebAssembly 模塊,這樣就能夠跨越不同機器架構之間的差異。而且,借助 WASI,跨操作系統也同樣成為可能。

          這意味著,整個 JavaScript 運行環境被集成進了 WebAssembly 實例中。部署了 WebAssembly 后, 你只需把 JavaScript 代碼喂進去就行了,WebAssembly 實例會自行消化代碼。

          JavaScript 引擎并不會直接在機器內存中運轉,從二進制碼到二進制碼的垃圾回收對象,JavaScript 引擎把這一切都放到 Wasm 模塊的線性內存中。

          對于 JavaScript 引擎,我們選用了 SpiderMonkey,就是 Firefox 瀏覽器中用到的那個。SpiderMonkey 是行業級別的 JavaScript 虛擬機(VM)之一,在瀏覽器領域里是久經沙場的老將。當你運行不可信代碼,或者代碼會處理不可信輸入信息時,這種皮實耐用、安全性高的特性就顯得尤為重要了。

          SpiderMonkey 還使用了一種叫做精確堆棧掃描的技術,它對我下面將要說到的部分優化點極其重要。SpiderMonkey 還具有包容度極高的代碼庫,這一點也很重要,因為協作開發者們來自三個不同的組織 —— Fastly、Mozilla 和 Igalia。

          我剛剛描述的運行方式并沒有顯得具有什么顛覆性特征。幾年前大家就已經開始這樣用 WebAssembly 運行 JavaScript 了。

          但問題在于,這樣運行很慢。WebAssembly 并不支持動態地生成新的機器編碼,然后在純 Wasm 代碼里運行。這就意味著你無法使用即時編譯。你只能使用解釋器。

          知道了有這種局限性,你可能會問:

          那為何還要說性能優化?

          鑒于即時編譯讓瀏覽器能快速運行 JavaScript(且鑒于在 WebAssembly 模塊中不能進行即時編譯),還想提速似乎是反直覺的。

          但假如,即使不能用即時編譯,我們還有沒有辦法能讓 JavaScript 運行提速呢?

          讓我們通過幾個案例來看看,如果 WebAssembly 可以快速運行 JavaScript,將會產生多么大的效益。

          在 iOS(以及其他 JIT 受限的環境)中運行 JavaScript

          在有些環境下,由于安全原因,無法使用即時編譯,舉例來說,無特殊權限的 iOS 應用、部分智能電視以及游戲機設備都屬于此范疇。

          在這些平臺上,必須要使用解釋器才行。但想在這些平臺上運行的,都是那種運行周期長、代碼量大的應用。正是這些條件讓你不想用解釋器,因為解釋器會嚴重拖慢執行速度

          如果能讓 JavaScript 在這樣的環境中提速,那么開發者們就可以在不支持即時編譯的平臺使用 JavaScript 而無需顧慮性能了。

          讓無服務器即刻冷啟動

          在另外一些場景中,即時編譯不成問題,但啟動時間卻拖了后腿,比如在使用無服務器功能時。這就是冷啟動延遲的問題,你可能已經有所耳聞。

          即使用精簡到極致的 JavaScript 環境 , 一個僅啟動純 JavaScript 引擎的隔離環境,最低延遲也有 5 毫秒左右,還沒有把初始化應用的時間算進去。

          倒是有一些辦法可以把收到的請求的啟動延遲隱藏起來。但隨著 QUIC 這類提案在網絡層中對連接時長的優化,想要隱藏延遲越來越困難。而當你鏈式執行多個無服務器功能等這類操作時,要隱藏延遲更是難上加難。

          使用這些技術去隱藏延遲的平臺頁,常常會在多個請求間復用實例。某些情況下,這意味著在不同請求中都可以觀察到全局狀態,這就是拿安全當兒戲了。

          正是由于這個冷啟動問題,開發者們常常無法遵循最佳實踐來開發。他們會在一次無服務器部署中,塞入大量功能。這就導致了另一個安全問題 ,一處暴雷,全盤完蛋。如果這次部署中的一部分破防了,那么攻擊者就有了整個部署的訪問權限。

          但如果能把上述場景中 JavaScript 的啟動時間降到足夠低,那自然就無需再費盡心思去隱藏啟動時間了,因為能在幾微秒之間就啟動一個實例。

          如果能做到這種程度,就能為每個請求提供一個新實例,于是不會再有全局狀態橫穿多個請求。而且,由于這些實例足夠輕量,開發者能夠任意把代碼拆分成粒度更細的片段,把每一段代碼的故障范圍壓縮到最小。

          這種實現還有另外一個安全方面的優點。除了實例能保持輕量、代碼隔離粒度更優之外,Wasm 引擎能提供的安全壁壘也更堅固了。

          JavaScript 引擎過去用來創建隔離的代碼庫龐大無比,包含著大量用來進行極其復雜的優化工作的底層代碼,所以很容易產生 Bug,從而使得攻擊者跳出虛擬機、獲取到虛擬機所在系統的訪問權限。這就是為何像 Chrome 和 Firefox 這樣的瀏覽器要竭盡全力確保網站運行在完全隔離的進程中。

          相反的是,Wasm 引擎需要的代碼極少,因此便于檢查,而且它們中有許多是用 Rust 這種內存無害語言寫的。而由 WebAssembly 模塊生成的原生二進制碼,其內存隔離的安全性是可以驗證的。

          通過在 Wasm 引擎中運行 JavaScript 代碼,構筑起了這座安全性更高的外部沙盒堡壘,以此作為另一道防線。

          因此,在上述這些場景中,讓 JavaScript 在 Wasm 引擎上運行得更快,是裨益良多的。那我們怎么來實現呢?要回答這個問題,需要弄清楚 JavaScript 引擎把時間都消磨在哪里了。

          JavaScript 的兩個耗時之處

          可以粗略地把 JavaScript 引擎所做的工作拆分為兩個部分:初始化和運行時。

          把 JavaScript 看作是一個包工頭。這位包工頭被雇用來完成這樣一份工作,即運行 JavaScript 代碼,并得出結果。

          初始化階段

          在這位包工頭真正開始運作項目之前,它需要做一點預備工作。此初始化階段包括了在執行之初所有那些只需運行一次的操作

          應用初始化

          不論是什么項目,合同工都需要了解一下客戶的需求,然后配置要完成任務所需的資源。

          例如,合同工瀏覽一遍項目概要以及其他支持文檔,然后把它們轉化成自己能處理的東西,比如搭建一個項目管理系統,把所有文檔存儲并整理起來。

          在 JavaScript 引擎看來,這個任務更像是通讀頂層源碼并把各項功能解析為字節碼、為聲明的變量分配內存、給已經定義過的變量賦值。

          引擎初始化

          在無服務器等特定場景中,還有另一個需要初始化的部分,發生在應用初始化之前。

          那就是引擎初始化。引擎本身需要率先啟動起來,內置函數需要添加到環境當中。可以把這個過程看作在開始工作之前要先把辦公室布置好 ,組裝桌椅之類的事。

          這個過程也可能花費一定量的時間,也是導致冷啟動成為無服務器使用場景的大問題的原因之一。

          運行時階段

          一旦初始化階段結束,JavaScript 引擎就能開始運行代碼了。

          把這部分工作的完成速度稱為吞吐量(Throughput),能影響吞吐量的因素有很多。比如:

          • 功能使用哪種語言開發
          • JavaScript 引擎是否能預測代碼行為
          • 使用哪種數據結構
          • 代碼的運行周期是否足夠長到能從 JavaScript 引擎的優化編譯中獲益

          那么這就是 JavaScript 消耗時間的兩個階段。

          那該如何讓這兩個階段運行得更快呢?

          大幅壓縮初始化耗時

          先使用 Wizer 這個工具來加快初始化過程。稍后我會解釋如何操作,但為了讓心急的讀者一睹為快,下面先給出運行一個非常簡單的 JavaScript 應用時的加速情況。

          當用 Wizer 運行這個小應用時,只消耗了 0.36 毫秒(等于 360 微秒)。這要比純 JavaScript 的方式快了不止 13 倍。

          啟動能如此迅速,是因為借助了快照(Snapshot)。Nick Fitzgerald 在 WebAssembly 峰會上關于 Wizer 的演講中進行了更為詳盡的解釋。

          那么其中的原理是什么?在部署代碼之前,作為構建步驟的一部分,用 JavaScript 引擎運行 JavaScript 代碼,直到初始化結束。

          在此處,JavaScript 引擎把所有的 JavaScript 代碼解析成了字節碼,并存儲在了線性內存中。在這一階段,引擎還會進行大量的內存分配和初始化工作。

          由于線性內存的獨立完備性非常強,當所有的數據值被存進來后,直接把這塊內存綁定為 Wasm 模塊的數據區塊即可。

          當 JavaScript 引擎模塊被實例化后,它就能訪問數據區塊中的所有數據了。當引擎需要使用這塊內存時,它可以復制所需的區塊(或者內存頁)到自己的線性內存中去。這樣,JavaScript 引擎在啟動時就無需再做配置工作了。所有的預初始化的數據就都已經準備就緒、聽憑差遣了。

          眼下,把這個數據區塊和 JavaScript 引擎綁在了一起。但在將來,一旦模塊鏈接(Module linking)可用了,就能把數據區塊裝載為一個單獨的模塊了,也就能讓 JavaScript 引擎被多個不同的 JavaScript 應用復用了。

          這樣就實現了真正干凈清爽的解耦。

          JavaScript 引擎模塊只包含引擎本身的代碼。這意味著一經編譯完成,這部分代碼就可以高效率地被多個不同實例緩存和復用了。

          另一方面,特定的應用模塊不包含 Wasm 代碼。它只含有線性內存,而線性內存只含有 JavaScript 代碼字節碼,以及初始化生成的 JavaScript 引擎狀態數據。這讓內存整理和分配十分便利。

          就好像是包工頭 JavaScript 引擎根本不需要再去布置辦公室了。它直接可以拎包入住了。它的包里裝下了整個辦公室,所有器具一應俱全,全部都調校就緒,就等 JavaScript 引擎破土動工了。

          而最酷的就是,這不是特地為 JavaScript 實現的 —— 只需要使用 WebAssembly 現有的屬性即可。所以你也可以把這個辦法用在 Python、Ruby、Lua 或其他運行時環境中。

          下一步:提升吞吐量

          通過這種方式,可以讓啟動時長超級短了,那如何優化吞吐量呢?

          對于某些情況來說,吞吐量其實不算差。如果你的 JavaScript 應用運行周期非常短,它怎么也輪不到即時編譯來處理 —— 它的全程都在解釋器中完成。在這種情況中,吞吐量就和在瀏覽器中一樣了,在傳統的 JavaScript 引擎初始化完成之前,程序就已經運行完了。

          但是對于運行周期更長的 JavaScript 代碼,即時編譯用不了多久就得開始介入了。一旦發生這種情況,吞吐量的差異就開始變得懸殊了。

          如上面所言,在純 WebAssembly 環境中是不可能使用即時編譯的。但事實上,可以把即時編譯的一些想法應用到提前編譯模型中。

          快速 AOT 編譯 JavaScript 代碼(無分析)

          即時編譯用到的一個優化技術是內聯緩存(Inline caching)。通過內聯緩存,即時編譯創建一個存根鏈表,其中包含了機器編碼的快捷路徑,指向曾經運行過的 JavaScript 字節碼的所有運行方式。(詳情請參閱文章:即時編譯器速成課)

          之所以需要用鏈表,是因為 JavaScript 是動態類型語言。每當一行代碼變換了不同的類型,就需要生成一個新的存根,添加到鏈表中。但如果之前就處理過這個類型,那就可以直接使用已經生成好的存根。

          由于內聯緩存(IC)在即時編譯中比較常用,人們會認為它們是非常動態化的,并且專用于特定程序。但實際上,它們也可以用于 AOT 場景。

          即使還沒有看到 JavaScript 代碼,也對要生成的 IC 存根比較熟悉了。這是因為 JavaScript 中有一些模式是經常被使用到的。

          訪問對象屬性就是一個有力佐證。訪問對象屬性在 JavaScript 中非常常見,而使用 IC 存根就能為這個操作提速。對于那些有確定“形狀”或者“隱藏類”(即屬性的存儲位置相對固定)的對象來說,當你讀取這類對象的某個屬性,該屬性總在同樣的偏移位置(Offset)上。

          按照傳統,即時編譯中的這種 IC 存根會硬編碼為兩種值:一個是指向形狀的指針,一個是屬性的偏移量。而這所需的信息,是提前預知不到的。但能做的是把 IC 存根參數化。可以把形狀和屬性偏移量看作是傳到存根里的變量。

          這樣,就能創建出一個單獨的存根,它從內存中加載值,然后可以到處使用這個存根。可以把屬于常見模式的所有存根合成一個 AOT 編譯模塊,不去關心 JavaScript 代碼的具體功能細節。即使在瀏覽器設置中,這種 IC 共享也是有益處的,因為這讓 JavaScript 引擎生成更少的機器編碼,提升啟動速度,優化本地指令緩存。

          對于我們的使用場景來說,IC 共享尤其重要。它意味著可以把屬于常見模式的所有存根合成一個 AOT 編譯模塊,不去關心 JavaScript 代碼的具體實現細節。

          我們發現,僅需幾 KB 的 IC 存根,就能覆蓋全部 JavaScript 代碼中的絕大部分。例如,只需 2 KB 的 IC 存根,就足以覆蓋 Google Octane 基準測試中 95% 的 JavaScript 代碼。從初步測試結果來看,通常的網頁瀏覽場景似乎都能保持這個比率。

          因此,使用這種優化手段,我們應該能夠達到早期即時編譯的吞吐量水平。一旦我們做到這個程度,我們就將加入更細粒度的優化,進一步打磨性能,正如各個瀏覽器廠商的 JavaScript 引擎開發團隊在早期即時編譯中所做的那樣。

          下一步:或許該加一點分析?

          以上是能提前做的,無需知道程序是做什么的,也無需知道它都使用了什么類型的數據。但要是能像即時編譯一樣訪問到分析數據呢?那就可以全面優化代碼了。

          但這會引出一個問題 ,開發者分析起自己的代碼來往往十分困難。要想提取出有代表性的代碼樣本,實非易事。因此沒法確定是否能得到優質的分析數據。

          如果能找合適的工具來進行分析,那么還是有可能讓 JavaScript 代碼運行得像如今的即時編譯一樣快速(連熱身的時間都不需要!)的。

          如今該如何上手?

          這種新的方式讓我們激動不已,期盼著能更上一層樓。也很激動地看到,其他動態類型語言可以用這種方式擁抱 WebAssembly 了。

          因此,下面是有幾種上手的方式,如果有任何問題,可以在 Zulip 中提問。

          對于其他想支持 JavaScript 的平臺

          要想在自己的平臺運行 JavaScript,你需要嵌入一個支持 WASI 的 WebAssembly 引擎,比如Wasmtime。

          然后需要 JavaScript 引擎。在這一步里,我們為 Mozilla 的構建系統添加了對編譯 SpiderMonkey 到 WASI 的完全支持。Mozilla 將把 SpiderMonkey 的 WASI 構建添加到用于構建和測試 Firefox 的 CI 設置中。這讓 WASI 成為了 SpiderMonkey 的線上質量目標,確保了 WASI 構建能夠一直保持運轉。這意味著可以如文中所講的那樣使用 SpiderMonkey。

          最后,需要讓用戶提供預先初始化的 JavaScript 代碼。為了能助你一臂之力,我們還開源了 Wizer,可以集成到構建工具中,產出針對特定應用的 WebAssembly 模塊,以適用于 JavaScript 引擎模塊所用的預先初始化內存。

          對于其他想要使用這種方法的語言

          如果是 Python、Ruby、Lua 等語言的使用者,可以針對該語言構建出一個自己的版本。

          首先,需要把運行時編譯成 WebAssembly,使用 WASI 作為系統調用,可參考我們對 SpiderMonkey 的處理。然后,可以按照上文所說,把 Wizer 集成到構建工具中,生成內存快照,這樣就能用快照來加速啟動。

          參考資料

          原文鏈接:https://bytecodealliance.org/articles/making-javascript-run-fast-on-webassembly

          原文作者:Lin Clark

          中文參考翻譯:https://juejin.cn/post/6981685894470172679


          主站蜘蛛池模板: 日韩精品一区二区三区不卡| 精品日韩一区二区| 人妻少妇精品视频三区二区一区| 女女同性一区二区三区四区| 亚洲乱码一区二区三区在线观看 | 无码少妇一区二区三区浪潮AV| 无码人妻精品一区二区三区9厂| 精品一区二区三区波多野结衣 | 亚洲综合在线一区二区三区| 国产AV午夜精品一区二区入口| 国产福利电影一区二区三区,日韩伦理电影在线福| 亚洲码一区二区三区| 精品乱人伦一区二区三区| 国产福利一区二区在线视频| 狠狠爱无码一区二区三区| 精品国产日韩亚洲一区在线| 亚洲Av无码国产一区二区| 亚洲日韩中文字幕一区| 51视频国产精品一区二区| 色噜噜狠狠一区二区三区| 中文字幕视频一区| 国产免费一区二区三区| 国产大秀视频在线一区二区| 精品黑人一区二区三区| 国产一区二区久久久| 色一情一乱一区二区三区啪啪高| 国产日本一区二区三区| 人妻视频一区二区三区免费| 日韩美女在线观看一区| 免费在线视频一区| 国产福利电影一区二区三区,亚洲国模精品一区 | 精品无码中出一区二区| 国产伦精品一区二区| 日本精品一区二区三区四区| 天堂不卡一区二区视频在线观看 | 亚洲日韩AV一区二区三区中文| 亚洲高清一区二区三区电影| 麻豆精品人妻一区二区三区蜜桃| 精品国产亚洲一区二区三区在线观看| 国产主播一区二区三区在线观看 | 亚洲不卡av不卡一区二区|