透過 EVM 程式碼默克爾化縮減見證資料大小

買賣虛擬貨幣
摘要:區塊中每發生一次合約呼叫,無狀態客戶端都需要完整的合約程式碼作為區塊見證(witnesss)的一部分,而傳輸合約程式碼佔用無狀態客戶端頻寬的比例,高居其頻寬開銷的第二位。人們認為,程式碼默克爾化方法(Merklization)能夠最佳化頻寬開銷。本文解釋瞭如何將程式碼拆分為 “塊( chunk ) ”,默克爾化這些 chunk,並只在交易需要的情況下傳遞這些 chunk 。實驗證明,基於目前的主網情況,我們能看到合約程式碼傳輸的開銷節省了 40%~60% 。巨大的無狀態區塊程式碼默克爾化的概念已經被提出好一陣子了,一開始主要用於程式碼去重,但其他用途還未被很好地探索。現在它重新進入大眾視線,卻是因為另一個目的 —— 用於降低無狀態客戶端所需要的頻寬開銷(e.g. 詳見此處)。如果你想知道無狀態客戶端為什麼出現,我推薦這篇總結,或是 Alexey Akhunov 的推文(編者注:中譯本見文末《以太坊無狀態客戶端初探》),裡面還附上了他的實驗資料。為求簡短扼要,我不會深入整個無狀態客戶端模型的細節,僅提供相關細節的簡要總結(如果你早已瞭解相關的背景,可以跳過下一段的內容)。

在無狀態模式下,(至少有部分)節點可以依賴其他節點(例如礦工節點)來取得區塊內容(包含合約程式碼)並使用相關默克爾證明加以驗證,而不必自己儲存所有區塊狀態 —— 這會給網路頻寬帶來巨大的效能提升。Alexey Akhunov 和 turbo-geth 團隊一直在研究,希望能確定已經產出的主網區塊的區塊見證大小。下圖是對最近 50000 個區塊的測量結果:紅線追蹤每個無狀態區塊需要傳送的合約程式碼量(合約程式碼目前佔見證大小的第二大頭)。如果以太坊從當前的 hexary 字典樹結構轉為二進位制 trie,則見證資料所包含的雜湊值資料大小約能減小 3 倍(編者注:中譯本見文末《二進位制狀態樹實驗》),這時候合約程式碼量就成為構成見證大小的第一大頭了。

不要傳送整段程式碼

我們假設,其實每筆交易只會呼叫部分的合約程式碼(例如只呼叫 4 個函式中的 2 個),所以我們的目標就是拆分這些程式碼塊(chunk),每次交易只傳送需要的 chunk(以及相應的有效性證明)的區塊見證。如果這種假設合理,而且每筆交易真的只用到一小部分位元組碼(劇透:歷史資料表示的確這樣),那麼區塊見證的合約程式碼部分就能大大的減小。

為了更好地理解,想象我們正在部署一份新的合約,我們需要傳遞程式碼和並確定 basic block (詳見此處演算法)。請注意,為了進行 JUMPDEST 分析,客戶端已經傳遞過一次程式碼,因此這次傳遞不會增加開銷。這些 basic block(譯者注:指只有一個入口且只有一個出口的程式碼塊,沒有跳轉進入,也沒有跳轉退出)有兩種特性:

· Basic block 要麼從索引 0 開始,要麼從 JUMPDEST 開始 —— 這麼做能保證每個無狀態客戶端都能安全地進行 JUMPDEST 分析(不只如此)。

· 每個 basic block 都無法更改控制流(例如沒有 jump 操作碼)。因此,我們可以確定一旦開始執行程式碼,只會存在兩種情況:正確執行結束,或是 gas 耗盡。雖然還沒有和其他方案進行比較,我們先假設這麼執行是相對更有效率的。

出於效率考量,我們合併相鄰塊,直到每個程式碼塊都至少有 128 位元組(可自行設定)為止。接著以第一個位元組作為 key,將這些合併後的程式碼塊插進 Trie(樹狀資料結構)。最後,客戶端將此 Trie 的根作為該合約賬戶的新記錄儲存下來。如下圖所示,記錄程式碼的 Trie 會成為狀態樹的子樹(類似於 “儲存樹”)。

為了測試部署的合約,我們試著發起一筆呼叫該合約的交易。礦工會執行這筆交易,並標記執行過程中觸及的每個 chunk(例子裡假設觸及 chunk#1 和 chunk#3 )。當要釋出區塊的時候,礦工會將合約狀態的證明,以及觸及哪些程式碼 chunk 的 turboproof 證明,一起打包在區塊內。

收到這個區塊後,無狀態客戶端就能驗證合約是否屬於區塊狀態的一部分,也能驗證合約的餘額、nonce 、狀態根、 codeRoot 等其他引數。這些資訊足以讓客戶端從 chunk 中重構部分位元組碼,同時清空其他不需要的 chunk 。因為 chunk 演算法的設計,所以客戶端知道所有的 chunk(除了首個 chunk )都是從 JUMPDEST 開始,因此能夠安全地進行 jump 操作。

為了驗證,我們編寫了一份測試原型,該原型可以從 Geth 客戶端的 RPC 埠獲取主網的區塊和過去的狀態,然後模擬執行交易。每當執行過程中遇到新的合約,就將合約拆分為多個 chunk,並標記執行交易時觸及的 chunk 。當區塊中的交易全部執行完畢後,會為所觸及的 chunk 生成證明 —— turboproof 。

接著重置狀態,用 turboproof 重構出來的程式碼,替換掉原本的合約程式碼,然後再次執行剛才的交易。為了檢查執行的正確性,我們比較前後兩次消耗的 gas 量和區塊的 bloom 過濾器。

對最近的 50 個區塊執行此過程,我們可以看到合約程式碼量減少了40% ~ 60%。

提醒:上圖的資料結果似乎令人充滿希望,但請記住,我們還需要成千上萬個區塊中的資料,才能得出令人信服的實驗結論;目前原型處於早期階段,一切結論都還為時尚早!

後續發展

你應該還記得,每個程式碼塊的最小長度是可設定的引數(文中取的是 128 位元組),修改這個引數會在截然不同的兩個方面影響見證的大小。假設我們將引數設為 32 位元組,則 chunk 的粒度變得更小,要傳遞的程式碼量也就變得更少。但是這樣一來, Trie 的深度就必須增加;換句話說,為了生成 chunks 的證明,我們需要進行更多次雜湊運算。

所以下一步,我們將會深入分析——究竟要將區塊最小長度設為多少,才能獲得最優解。當然不論如何,只要將 hexary 字典樹結構二進位制 Trie ,我們就能減少 3/4 的雜湊運算(詳見此處),從而降低見證資料的大小。

在測試原型中,我們將合約程式碼拆分為 basic block;而可選的程式碼拆分演算法當然有很多,有的簡單有的複雜。最簡單的一種就是拆分為固定大小的 chunk(比如,32 bytes/chunk),從目前來看,這種方法只會有 push 和 jumpdest 分析的問題。

更進一步地說,如果我們任意設定位元組碼的最小值,則客戶端在收到 chunk 之後,可能會因為 PUSH 操作或任何多位元組碼的操作,而碰上 JUMPDEST ( 0x5b ) 報錯的情況。如下圖所示,有完整程式碼的客戶端會發現這裡的 jump 操作是非法的,因為 0x5b 屬於 PUSh2 的運算元,執行到這裡應該終止。但如果客戶端只收到 chunks #6 和 #8,而沒有收到 #7 ,則他會跳到位置 41 繼續執行,就產生了對同一份合約程式碼的不同解釋。後面我們會扼要地說明怎麼在任意設定位元組碼的情況下,避免這種錯誤。

為了解決這個問題,Martin Holst Swende 建議向每個 chunk 新增一個後設資料,該後設資料記錄了有多少個 chunk 的首位元組是 push 操作;然後,驗證者就能在 jumpdest 分析過程中跳過那些位元組。Alexey 正在探索的另一種方法是 “不允許在 EVM 中進行動態跳轉操作”(至少,不允許使用程式碼默克爾化的合約進行跳轉操作),這使我們只需在部署合約時做一次靜態的跳轉分析,而不需要在每次執行程式碼時進行。Alex Beregszaszi建議使用合約控制流程圖,以更好地規範默克爾化流程;與之類似,Christian Reitweissner 提出了一種執行證明方法,從合約的控制流程圖建立默克爾 DAG(有向無環圖)。我不會在本文中評價這些想法,希望之後能披露更多資訊。

最終結果可能表明,不同的 chunk 拆分演算法之間的效率提升可以忽略不計,這麼一來選擇的演算法就越簡單越好。而好訊息是,基於早期資料實驗,我們至少有一種演算法可以顯著減少無狀態區塊中需要傳輸的程式碼量。

本文著重討論如何默克爾化 EVM 位元組碼,但總體思路並不侷限於 EVM 。實際上, Ewasm 團隊的其他成員也在嘗試默克爾化 Wasm 程式碼,也遇到了相應的挑戰。這些挑戰主要是因為 Wasm 程式碼由多個部分組成,並且在執行之前需要經過嚴格的驗證——這意味著重構的位元組碼也必須透過驗證。

敬請期待後續更多資訊!

免責聲明:

  1. 本文版權歸原作者所有,僅代表作者本人觀點,不代表鏈報觀點或立場。
  2. 如發現文章、圖片等侵權行爲,侵權責任將由作者本人承擔。
  3. 鏈報僅提供相關項目信息,不構成任何投資建議

推荐阅读

;