宿爽 InfoQ
作者 | 宿爽
整理 | 王強
在 ApacheCon Asia 2021] 大會的“數據可視化論壇”上,Apache ECharts PMC 成員宿爽發表了題為“16 毫秒的挑戰:圖表庫渲染優化”的演講。本文是這次演講的內容總結。
今天我演講的主題叫做“16 毫秒挑戰:圖表庫渲染優化”。
標題里的 16 毫秒是怎么來的呢?因為 UI 系統最常見的刷新頻率是 60hz,也就是每一幀在約 16 毫秒內渲染完成就會比較流暢,交互不會有卡頓感。
后一部分叫“圖表庫渲染優化”。圖表庫是數據可視化的領域,涉及很豐富的呈現、動畫、以及交互等等;同時它會遇到比較大量的數據,從而延緩我們的渲染過程,這就是一個挑戰。
我來自 ECharts 團隊,所以今天講的內容都是 ECharts 在這個課題中所遇到的部分經歷。ECharts 是一個數據可視化圖表庫,主要在瀏覽器環境下運行,我們今天所講的也都是在瀏覽器中運行 JS 來進行渲染時的優化經驗。
1大數據渲染為何放在前端?
首先我們看看為什么要在前端進行大數據渲染。這里的前端就是通常意義上的瀏覽器環境。為什么我們不在后端先進行數據降級處理,然后再返回到瀏覽器?
有兩點考慮,一是在后端處理需要額外的計算資源,本來在客戶端的分布式計算全都挪到后端,會需要額外的資源,比較麻煩。
第二是不易交互。可視化分析時,你不光要看數據整體,可能還需要縮放,看各種細節。如果后端數據降級了返回給前端,細節就丟掉了,不太容易做這種比較流暢的交互。所以能在前端做的還是盡量在前端,如果做不到再到后端進行數據降級處理。
2前端處理交互時的挑戰
前端處理交互會面臨哪些挑戰?瀏覽器環境有性能限制,還會有實時性要求。不僅要求交互很流暢,而且有時實時數據每隔一秒鐘或者幾百毫秒全量刷新一次,這需要圖表庫做到性能足夠優化。為了做這些事情我們需要解決三點問題:
- 渲染的時間不能太長,比如說瀏覽器彈出一個窗口說你的一個長的執行腳本是不是要殺掉?那不行。
- 交互不卡頓。
- 呈現效果流暢,不能渲染了半天才把整個東西呈現給用戶,如果有可能就要渲染了多少就趕緊呈現多少。
3優化要點
下面我們挑一些優化點來給大家分析。
降采樣
第一個是降采樣。降采樣是普遍采用的一種優化方式。比如說我這里一個例子是一千萬折現圖的 LTTB 降采樣。
雖然這里面有一千萬個點,但是我們屏幕的尺寸沒有那么大,至多也就一千多個像素,所以大部分像素點如果要畫出來都是重疊的,你沒必要把所有點全都進行數據到視覺的映射,然后 Layer out。你可以選一個窗口,在這個窗口內挑一些代表的點來渲染,這就是降采樣。這樣就會極大優化性能。
另一件事就是你怎么挑這個點?你需要保留足夠的局部特征,比如說局部最大值最小值要保留,因為這些細節往往是需要人關注的,不能夠抹去。LTTB 算法就起到這個作用,經過這種優化以后縮放會比較流暢。
但降采樣也并非能夠解決所有的事情,它有一定的要求,需要你的數據有一定的局部性:你的數據的排序和你最后屏幕上展現出來的順序是一致的。要換成散點圖,點是分散在各處的,你就不太容易去選擇這些點,因為它根本就沒有重疊性了。第二個要求圖形展示的特征是能夠真的完全重疊的,比如要是一個平行坐標系,這些都是線,不能完全重疊,就不太容易去采樣。
減少 Canvas 狀態切換
下一件事情是減少 Canvas 狀態切換。ECharts 底層可以用 Canvas 渲染,也可以用 SVG 渲染,但我們在做大數據渲染的時候基本上都會用 Canvas。Canvas 的 API 設計足夠基礎,也足夠快,能夠承載大數據的渲染。即便如此我們仍需要對它的 API 進行一定的優化。
先講一下 ECharts 和 ECharts 所基于的庫是怎么樣去使用 Canvas 的。
最下面這個灰框里面是 Canvas 的 API。作為一個比較復雜的應用來說,它一般不會去直接在這些最原始的,命令式 API 之上進行業務邏輯開發,一般還是會抽象一層,就把這些需要繪制的東西抽象成一個個元素實例。每一個元素自己會維護我們需要繪制的這個元素上面的各種屬性,比如說它的縮放尺寸 scale X 跟 Y 之類的東西,全都作為元素這個實例的屬性維護著。每一幀的時候遍歷這些元素對它排序,然后讓它自己去繪制自己,也就是說把這些屬性最終轉換為 Canvas 的渲染指令,然后輸出給瀏覽器,讓瀏覽器繪制。
在真正渲染的時候,我們已經有了元素,上面有很多我們配置好的屬性,然后它先把這些屬性值,set 到 Canvas2D 的這個 context 上面去。下一步 built Path,就是根據 path 相關的屬性,把整個路徑給 Built 出來。
Canvas 這些接口雖然足夠快,但還是有一定開銷的。因為我們需要挑戰的數據量本身可能會是百萬、千萬的數據,這個 fillStyle 開銷累積起來還是很明顯的,百萬次可以達到 100ms 以上。所以我們需要對這里進行優化。
那么 fillStyle 為什么會有這些開銷?可以去看一看瀏覽器的實現,比如說這個是 Blink 的實現,在 fillStyle 里它可能會需要對字符串表達的顏色進行解析,有一定開銷。為了優化、避免掉這些東西,如果這次的 style 和上次的 style 相同的話,就不再向 Canvas 的 context 上設置。在海量數據繪制的場景下,前后的這些屬性會有差異的情況并不多,大多數會用同樣的顏色,因為數據量大的時候用各種差異性的東西已經不太能夠看清了。
合并
下一件事是合并。比如說我上面一行顯示的是繪制的過程中多個 set style,再 build path,再 set style,以此類推。但如果強制把它合并掉,從業務層就認為它們的 Style 完全相同,只 set style 一次就可以了,連比較都不需要比較。這樣就不需要在每個 element 里維護一個 build path 的結果,而是用一個數組來存放結果,就減少了很多內存開銷以及 JS 開銷等等。這種方式也是有限制的,就是它并不支持多個不同 style 的場景。
一維數組和 TypedArray
下一個優化方式非常常見和重要,就是用一維數組或者 TypedArray。因為我們在渲染圖表的時候,絕大多數都是在渲染一些二維表,比如說每一行都是一條條數據項,每一列是一個一個緯度。就數據來說,我們最直觀的就是用二維數組來表達,或者有可能還用 Object 組成的數組來表達。這些表達非常直觀,但是它會占用更多內存,因為每一項都是一個數組、一個對象。
處理大數據的時候把它降維,降成一維的,那么讀寫的時候也就稍微加一點計算量,但并不多,這樣的話會節省很多內存,并且速度會快很多。比如說我下面這一個圖里面演示用二維數組來繪制一個折線圖,大概五百萬數據,初始化時間用了將近 5 秒鐘。
下面這個滾動的小球相當于額外的一個動畫,這個動畫只是想演示出來在 ECharts 進行交互改變的時候,是不是會影響到頁面其他部分額外動畫的流暢度。比如說我上面進行縮放,下面的這個動畫就很卡,那就是影響到了。
最下面這個豎線表示它是一幀一幀的長度,上面一個格大概是 16.7 毫秒。如果這個豎線間隔變得很長,就說明一幀的時間變得很長。對于五百萬數據,二維數組還是相對來說比較卡,但如果換成一維數組就會好很多。首先它初始化時間大概只有 400 多毫秒,并且交互的卡頓時間就會好很多,雖然仍然每一幀的時間稍微長一點,但已經足夠可以接受。但它也稍微有一點弊端,就是它非常大的時候可能會崩掉。比如在我的機器上,一個數組到了三千萬瀏覽器就會崩掉。
最好的情況下還是 TypedArray,它申請的時候就是定長了,相當于你完全自己管理這個內存空間,并且每一個數據項都是固定類型的,所以有更好的穩定性。當然它的弊端是你自己要去管理它,要記錄它的長度,如果超長的話,你自己重新申請一片空間把原來的復制過來,要去自己實現這些數據結構。
GC 減少
GC 是不可忽視的一個因素,因為在數據量大的時候,GC 在每一幀中的消耗可能會很影響流暢度。
上面貼了一個圖,Minor GC 就花了 5 毫秒,可是你一幀也就幾十毫秒,或者十幾毫秒。想要 GC 能夠可控、減的很少,可能有很多不太確定的方式。在我們優化中使用過的方式有及時釋放,意思就是說,臨時的一個小對象對性能情況影響比較小,比如說我在函數里聲明一個臨時的對象,這個對象也沒有掛在哪,用完了后就釋放掉了,沒有太大的影響。但是另一種情況有影響,就是我這個臨時對象實際上掛了一個什么東西上,我在下一個階段可能又使用了它,再在下一個階段才準備把它釋放掉,這時候它的釋放可能就會影響很多了。GC 的時間是非常快的,但是你如果幾次的清掃都沒有能夠釋放,這種臨時的小對象存著就很影響機器的開銷,但如果把它都存成一個整個的 TypedArray 就會好很多。
前面講的這些手段都是在盡量減少一些開銷。但從理論上說,數據可視化的過程就是從用戶的數據轉換成最終渲染的指令的過程。數據越多指令就越多,渲染的時間就越長。但 UI 系統允許不可打斷的渲染時長是有限的,它需要 60hz,或者降低一點的 20hz,轉換成時間的話可能 16 毫秒、50 毫秒,或者更長一點 100 毫秒都還能接受,再長一點就明顯卡頓了。這里的有限和前面說的數據增長是矛盾,這個上限就導致了我們再怎么優化總歸會到一個瓶頸。下面所講的一些手段就是要打破這個瓶頸。
并發
我們可能會想到用并發的手段,比如說我利用操作系統本身提供的多線程機制,或者把數據整個持續的渲染任務分成順序不太相關的一些子任務,自己來調度。
首先來說多線程的嘗試,在瀏覽器里面多線程是只能用 Web Worker。在講這個之前先看一下 ECharts 的渲染管線。
最左邊是用戶輸入,然后是一系列的階段,進行渲染形成 Elements,最后轉換成渲染指令。這流程很長,是 CPU 密集型過程。如果用多線程,用 Web Worker 嘗試,就把流程都放到 Worker 里進行,主的 JS 線程只做用戶輸入,或者把那些最后得到的 Canvas 渲染指令輸出出來,然后用戶的鼠標之類的交互又傳給 Worker 線程,得到結果再傳給主線程。
這個結構是可以工作,但是它也有問題:它的交互可能不同步,大數據渲染實際上還是很慢的。雖然用戶交互沒有阻塞,但是沒有真的解決 Worker 線程里渲染慢的這個問題,看起來這個結果還是不很好。而且比如說拖動時它很不跟手,雖然用戶交互不阻塞,但用戶的交互響應還是在 Worker 線程,響應完了還是要更新視圖,這個 Worker 線程忙于計算,它就沒有足夠短的時間來響應這些用戶交互了,結果效果還是不行。所以說盡管多線程可以在主線程里不會影響到其他,但是渲染問題還是避免不了要在單線程上面解決。
還有些其他的問題。JS 多線程有一個很大的弊端就是它的內存不共享。渲染的過程中有需要給用戶以回調,回調用戶程序,這種回調如果放在一個多線程的環境下不能共享,它就只能來回傳輸,不光是要跨線程,而且要有傳輸開銷。基于這些東西來說,這個多線程渲染后續沒有真的落實到產品中,因為它可能帶來的收益有限,只是作為一個嘗試的過程,而最根本的還是要去解決單線程中渲染的優化。
漸進渲染
接下來講的是漸進渲染方式。漸進渲染能做到第一不阻塞交互,第二盡快渲染出效果,意思就是說它把用戶的長任務分割成一些各種各樣的短任務,然后做調度,然后實現一部分短任務,然后讓它響應一下用戶的交互,再執行下一步。
這就是長任務,兩個紅線表示的是幀。把這個任務分割成多個子任務就能響應很多次用戶。
沒有漸進渲染之前,整個 data 走完這四個過程最后才上屏,這一幀會比較長。如果有了漸進渲染,就把整個 data 分成好多好多 chunk,每個 chunk 走完這四個過程就直接上屏了,而且這一個幀比較短的情況下,就能夠響應下一次的用戶交互。
漸進渲染里面可能會涉及一些程序結構的調整。在沒有漸進渲染之前,以這個 layout 過程為例,它就是從 0 一直取到 data 的末尾。
而有了漸進渲染,所有的轉化過程沒變,但它只是從 start 處理到 end,這是由外層的調度器來決定的。
這里還有一點就是層的問題,因為它是在多幀中逐漸出來后續效果的。這里就是一個漸進渲染的例子,中間這個波浪的中間線就是一大堆散點,但是上面放了一個餅圖作為另一個層,意思是讓餅圖遮蓋著它。如果是不分層,你后續出來的這個點就會覆蓋到餅圖上面。所以漸進渲染要有單獨的層,上面不漸進渲染的東西和底下的東西都會分多層。
分片
漸進渲染的下一個問題就是分片,每一片分多大?最開始都是用自己的配置,比如說我配置成每片三千,這個在很多場景都是可以用的,因為大家需要處理大數據的這些機器性能也都不錯。但它沒辦法適應不同的環境,CPU 慢下來幀率就明顯降下來了,這就是一個弊端。
如何解決這個弊端呢?我們先把整個渲染流程抽象成一個最簡單的結構,就是 onframe。每個 onframe 從 0 走到 3000,里面處理單個數據項,處理完了 3000 個以后 break 出來,再申請下一個 frame,繼續這個過程,這是固定值的過程。
但是如果我們要不固定值,那么我們先把這個過程改成這么一個抽象。就是一個 task 先一直循環,循環不是固定三千,而是一直在檢查是不是應該 break,是的話就跳出來,不是的話就時序處理一個一個的單點。
那么怎么來判斷我是不是應該跳出來呢?有 requestIdleCallback 能夠讓你知道現在還有多少時間剩余,你可以邊進行你的工作,邊用 API 得到更短的剩余時間,看看是不是應該 break。這就能夠保證幀率是非常穩定并且非常快的。但這個看起來理想,但它是有問題的。第一個它并不足夠積極,因為它更多是為了后臺任務來設計的,它優先保證幀率,而并不優先保證你得到回調的機會。比如說它在有些安卓手機上面滑動的時候甚至得不到回調,對于一個 UI 來說這樣就不太合適了,如果你滑了半天,UI 不進行任何更新那是不行的。第二個就是它的兼容性還一直是有問題的。
如果用另一種方式,每次就是一直計時間,如果到了 16 毫秒就 break,然后繼續申請下一幀,這樣行不行?這個也不完全行,因為我們現在的瀏覽器做的處理用戶輸入以及后續過程都是按照幀來驅動的,每一幀以固定的流程來做這些。給 JS 執行這些事情只是這個流程的一部分,它還有很多別的工作。就比如說我現在 JS 提交了很多 Canvas 指令,它還需要把這些 Canvas 指令轉成繪制指令去繪制,這些東西也是耗時的,要想為這些東西預留時間也不容易。
下一個思路是,我既然不能夠留時間,我就把這個粒度縮的更小一點,比如我每五毫秒為一個單位去 break,break 就留出了系統調度的時間。你可能會立刻去調度下一個 5 毫秒的宏任務,也可能就到了一個幀的邊界,你就會去進行下一幀的周期來響應用戶輸入。這種方式用的是 MessageChannel,因為它是一種最快的申請宏任務的方式。如果 MessageChannel 里什么都不干,它在一個幀里面能一直申請無數個任務。
如果是這樣的方式來實現,剛才的代碼就變成這樣,while 的條件變成是否小于 5 毫秒。如果是 5 毫秒之內就執行每一個數據處理,如果跳出來就申請下一個宏任務。這種節奏看起來挺好,但是它也有一些弊端,它對程序結構是有一定要求的,就是在實踐中它渲染總的時長會變多,為什么?因為它粒度小導致一些重復工作的開銷。因為現實中不一定能抽象得那么好,可能每次渲染之前需要做一些別的工作,這些工作不一定全能拆開來循環。這些工作占用的時間過多的話,可能會導致總的渲染時長變長。如果是沒有這種制約的話,用這種方式也是挺好的。
樸素方法:自動調整片的尺寸
再換一種方式就是樸素的自動調整片的尺寸。我還是用一個固定的 step 值來渲染,但是這個 step 值是自動調整的。我邊渲染邊統計時間,大概估摸著這個 step 調整成多少,這個幀率能夠逼近于多少這么一個狀態,來做自動調整。這就主要看你的公式做得是不是足夠好了。
4總結
總體來說,各種各樣的優化方案都有各種利弊,我們要根據具體的場景選擇合適的方法。
最后還有一些小要點,就是渲染中會有一些重繪的體驗優化。比如說一個 K 線圖的漸進渲染,如果每次都是從左到右渲染就挺傻的。但如果我只是做一個簡單的變化,把漸進渲染片的執行順序變成了取一個模,這樣體驗就好很多。
這是一個路線圖的渲染,每次我拖動的時候因為破壞了原來的環境,它就必須重新渲染,這樣體驗也不是很好。這個在 2D 渲染里不太好解決,但如果是用 EChartsGL 來變成 3D 就好辦,因為它機制不一樣。它進行這個 zoom 是通過攝像頭改變,實際上已有的數據不必擦除,可以重用了,這樣的話就會好很多。