Redis on the Raspberry Pi: adventures in unaligned lands

Salvatore Sanfilippo

在 Raspberry Pi 上跑 Redis:未對齊領域的冒險

原文由 Salvatore Sanfilippo 發布,訂閱此部落格

在賣出 1000 萬台,以及幾乎無窮無盡的各式應用與周邊裝置(如感測器與顯示器)之後,我認為可以當之無愧地說,Raspberry Pi 不僅是一項成功,更已成為程式設計師在嵌入式領域進行實驗的首選平台之一。或許隨著 Pi Zero 這類產品的出現,它也正成為打造硬體產品的平台,讓人無需承擔為垂直整合裝置從頭設計、製造與撰寫軟體所帶來的種種風險與成本。

我也喜歡這樣想:Redis 同樣是程式設計師喜歡拿來動手實作、實驗與打造新事物的平台。再者,可用於嵌入式/IoT 應用的裝置,往往需要在裝置端暫時或永久地儲存資料,例如從感測器接收到的資料,以便在裝置上進行運算或傳送至遠端伺服器。Redis 正在新增一種特別適合用於資料串流與時間序列儲存的「Stream」資料型別,目前規格已接近完成,實作工作將在未來幾週內展開。Redis 現有的資料結構,加上新的 Stream,以及本身極小的記憶體佔用,即便在小型硬體上也能提供不錯的效能(也因此帶來低耗電),看起來與 Raspberry Pi 潛在的應用場景非常契合,廣義來說也適合各種小型的 ARM 裝置。唯一缺少的拼圖,也是最顯而易見的一塊:就是要在 Pi 上跑得順暢。

Pi 許多迷人之處的其中一點,在於它的開發環境一點也不像幾年前的嵌入式開發環境……它直接跑 Linux,具備你所期待的各種 Debian-like 工具。基本上,要讓 Redis 在 Pi 上運作並不是什麼浩大的工程。Linux 系統程式與 Pi 之間最根本的落差,可能是效能/佔用空間上的不匹配,但這對 Redis 本身的設計來說根本不是問題:一個空的實例 Resident Set Size 總共只佔用 1MB,從記憶體中提供查詢,因此速度夠快,也不會對快閃儲存造成太大負擔,而當需要持久化時,它使用的是 AOF,採 append-only 的寫入模式。然而 Pi 採用的是 ARM 處理器,這在處理未對齊存取時就需要格外小心。

在這篇部落格文章中,我會一邊展示自己如何讓 Redis 與 Raspberry Pi 相處得更融洽,一邊試著概述如何處理那些不像 x86 平台那樣能透明處理未對齊存取的架構。

關於 ARM 處理器的幾件事

把 Redis 移植到 ARM 上最有趣的地方在於,ARM 處理器是——或者說曾經是……嗯,並不怎麼喜歡未對齊的記憶體存取。如果你平常都在寫高階程式語言,可能不知道這回事,但歷史上許多處理器架構本來就無法在不是 word 大小倍數的位址上載入或儲存 word。舉例來說,如果 word 大小是 4 位元組(以 32 位元處理器為例),你可以在位址 0x4、0x8 等等載入或儲存一個 word,卻不能在位址 0x7 這麼做。結果有時會觸發例外,有時則會出現奇怪的行為,取決於 CPU 及其確切的設定。

後來 x86 處理器家族稱霸世界,大家也就有點忘了這個問題(除了處理 SSE 指令等情況之外,但現在就連那些指令也有支援未對齊的變體了)。不過話說回來,一開始大家也不是真的就此忘記這個問題。即使 x86 處理器能夠處理未對齊存取而不引發例外,這樣做仍會帶來不小的效能損失:在 word 邊界上的部分讀寫需要付出雙倍的工作量。但後來新一代的 x86 處理器加入了最佳化,讓未對齊存取在大多數情況下與對齊存取一樣快,所以基本上對現在的 x86 而言,這真的已經 Not An Issue 了。

ARM 在 ARM v5 之前,就是那種會讓未對齊存取產生奇怪結果的平台,而且結果還真的非常出人意料。根據 ARM 官方文件:「若位址不是 4 的倍數,LDR 指令會回傳一個旋轉過的結果,而不是執行真正的未對齊 word 載入。一般來說,這種旋轉並非程式設計師所預期的。」嗯,這也 *絕對* 不是程式設計師想要的。不過,即便是最初代的 Raspberry Pi 搭載的也是 ARM v6 處理器。v6 雖然會有效能損失,但已經能夠處理 word 大小的未對齊存取。然而,處理多個 word 的指令仍會引發例外,導致程式收到 bus signal 而終止,或是向核心求助(如稍後會更詳細說明的)。這意味著 Redis 在 Pi 上執行時並不會馬上就慘烈當掉,因為 Redis 所進行的大多數未對齊存取其實都是 word 大小。不過,編譯器為了加速運算,有時會產生使用多重載入/儲存指令的程式碼,或是 Redis 程式碼本身會嘗試從未對齊的位址載入/儲存 64 位元的值。理論上這通常會導致當機,不過 Linux 在這方面提供了一些幫助。

別直接當掉,去問核心吧!

Linux 核心在 ARM 處理器上執行時,能夠幫助使用者行程即使執行了 CPU 原本不支援的未對齊位址操作,也能如預期般運作。作法是在核心內部為這類例外註冊一個處理器:核心會檢查失敗的操作,並在函式中模擬執行,讓最終結果就如同處理器真的執行過一樣,然後再讓「肇事」的行程恢復繼續執行。

如果你對低階程式設計有興趣,這個 Linux 核心檔案值得一看:http://lxr.free-electrons.com/source/arch/arm/mm/alignment.c

當 CPU 引發未對齊存取例外時,核心實際的行為是由 /proc/cpu/alignment 這個檔案來控制的:

$ cat /proc/cpu/alignment
User:		0
System:		12590 (ip6_datagram_recv_common_ctl+0xc8/0xd4 [ipv6])
Skipped:	0
Half:		0
Word:		0
DWord:		0
Multi:		12590
User faults:	2 (fixup)

如你所見,核心為所有經它修正過的未對齊存取,分別在使用者空間與核心空間維護了各自的計數器。在上面的例子中,有 12590 次存取是在核心空間被修正的。沒有任何使用者空間行程被修正。請注意「User faults」這一行顯示的是當使用者空間行程執行 CPU 無法處理的未對齊存取時,核心的設定:它可以修正問題、發送 SIGBUS,或是在核心日誌中記錄該事件。這是由一個可寫入 /proc/cpu/alignment 的整數中的個別位元來控制的,例如若想在修正之外還記錄使用者空間的未對齊存取,就可以使用「echo 3 > /proc/cpu/alignment」(bit 1 啟用記錄,bit 2 啟用修正)。

我的感覺是,Linux 核心之所以啟用這樣的功能,與其說是核心開發者擔心那些無法處理未對齊記憶體存取的可憐使用者空間程式設計師,不如說是因為核心本身也不見得永遠都能避免未對齊存取,如同你在「System」計數器上看到的。因此,這是在 ARM 上修正 Linux 移植問題最簡單的方式,而不必逐一檢查程式碼中的每一個地方。

既然 Linux 會透明地處理這件事,人們或許會想,唉……也許這裡根本沒什麼好修的,只要把 /proc/cpu/alignment 設為透明修正,Redis 就會如預期般正常運作。其實不然,原因有二:

  1. 當發生未對齊存取並由核心修正時,會導致*非常*緩慢的執行。速度上的懲罰遠大於例如進行未對齊 word 大小存取時所需的那第二次記憶體存取。雖然這只會發生在使用多重載入與儲存指令的情況下,但在某些條件下讓 Redis 比實際需要慢上許多,仍是相當可惜。
  2. Linux 核心對 ARM 未對齊存取的實作並不完美。GCC 所產生的某些程式碼中包含的指令,在 Linux 4.4.34 上並無法被妥善處理。

一個簡單的例子如下:

#include <stdlib.h>

int main(int argc, char **argv) {
        int count = 1000;
        char *buf = malloc(count*sizeof(double));
        double sum = 0;
        double *l = (double*) (buf+1);
        while(count--) {
                l++;
                sum += *l;
        }
        return 0;
}
$ gcc foo.c -g -ggdb
$ ./a.out
Bus error

即使我的 Pi 上的核心設定已被設為處理並修正未對齊存取,程式仍然收到了 SIGBUS!讓我們用 GDB 來看看發生在哪裡:

$ gdb ./a.out
(gdb) run

Program received signal SIGBUS, Bus error.
0x00010484 in main (argc=1, argv=0xbefff3b4) at foo.c:10
10	                sum += *l;

嗯,正如預期,問題就出在內層迴圈中那個未對齊的 double 指標被取值的地方。但我們可能想進一步確認發生了什麼,來檢查引發例外的那道 ARM 指令:

(gdb) x/i $pc
=> 0x10484 <main+100>:	vldr	d6, [r11, #-20]	; 0xffffffec

VLDR 指令用於從記憶體位置載入擴充暫存器,用於浮點運算。不知為何,Linux 核心對未對齊存取的修正實作無法處理這個指令(我想是因為實作本身還不夠完整)。「dmesg」指令的確會顯示該指令未被修正未對齊存取的函式所辨識:

[317778.925569] Alignment trap: not handling instruction ed937b00 at [<00010480>]
[317778.925610] Unhandled fault: alignment exception (0x011) at 0x01cb8011

所以,既然 Pi 上預設的 C 編譯器可能會產生出預設 Linux 核心無法處理的程式碼,我真的很希望 Redis 即使在核心被設定為不修正未對齊存取的情況下,也能毫無問題地執行。這意味著在 ARM 上的 Redis 應該只進行 word 大小的未對齊存取,也就是 CPU 能夠透明處理的唯一類型。

修正錯誤

既然 ARM 對大多數未對齊記憶體存取都能處理得不錯,Redis 在 Pi 上看起來大多已經可以運作。尤其是在預設情況下,核心已被設為修正許多不被支援的未對齊存取。即使關閉對齊修正,它表面上也仍然能跑。然而執行測試後,卻暴露出不同的當機情況,尤其是在位元運算與雜湊函式這類顯而易見的區域。

現在 Redis 首先會在編譯到不支援未對齊存取的架構時定義 USE_ALIGNED_ACCESS。接著就只是修正程式碼,避免走那些會進行未對齊存取的快速路徑,或是以 memcpy() 操作來取代指標的取值。你可能會以為使用 memcpy() 會比直接對指標取值慢得多,但實際情況要好得多:對於像 memcpy(src,dst,sizeof(uint64_t)) 這樣固定大小的 memcpy 呼叫,編譯器聰明到足以避免真正呼叫函式。它實際上會產生一組最快的指令,即使位址未對齊也能完成工作。舉例來說,在 x86 處理器上,這個函式呼叫實際上會被轉譯為單一的 MOV 指令。

在完成這些修正後,Redis 與我的兩台 Raspberry Pi——一台是初代 model B,另一台是快得多的 Pi 3——開始變成好朋友:所有測試都通過,只剩一個關於在當機報告中產生呼叫堆疊追蹤的測試(不過我也會把這個修掉),以及由於 Pi 在建立主從架構時速度較慢,整合測試中偶爾會出現的一些失敗。然而到了這個階段,我對正確性的胃口被挑起了,我想要更多對齊問題來挑戰。

更進一步:SPARC

在我著手修正 Redis 在 ARM 上的問題時,GitHub 儲存庫中同時有一個關於讓 Redis 在 Solaris/SPARC 上良好運行的平行議題。SPARC 可不像 ARM 那麼溫和,它完全無法處理*任何*未對齊存取。我對此記得非常清楚,因為在我學習 C 語言的最初幾年,曾買過一台非常老舊的 SPARCstation 4:同時具備 big endian 且完全無法處理任何未對齊存取,讓我對程式移植有了一些體會。可惜在買來幾個月後,我不小心把伏特加灑在它上面,把主機板徹底燒毀了,不過我至今仍把它留在父母家裡。

Solaris/SPARC 處理未對齊存取的方式比 Linux/ARM 更為複雜:32 位元的未對齊存取永遠由核心修正,而 64 位元的未對齊存取則是根據編譯旗標,透過在使用者空間註冊 trap 來處理。Sun Studio C 編譯器有特定的選項可以非常精確地控制其行為,甚至還提供工具來輕鬆偵測並修正這類未對齊存取。

如果說 Redis 中非 word 大小的未對齊存取算是罕見,你可能會以為 word 大小的未對齊存取應該到處都是。但實際上並非如此,因為直到 Redis 3.0,我都還會不時用一台 OpenBSD/SPARC 機器來測試與修正 Redis。所以最大的問題在於用來雜湊鍵的函式。Redis 原本的字串函式庫 SDS 擁有固定大小的表頭,因此在雜湊鍵時存取永遠是對齊的。自 Redis 3.2 起,SDS 的表頭變為可變大小,因此情況不再如此。此外,自從幾年前上次在 SPARC 上測試 Redis 以來,也陸續累積了其他零星的新增未對齊存取。

為了修正雜湊函式,我也順勢改用了 SipHash,因此這同時也是針對 HashDoS 攻擊的安全性修正。不過值得注意的是,我目前使用的是減少了 C 與 D 回合數的 SipHash 變體:SipHash1-2。這是為了避免原本可能出現的不小效能衰退,然而據我所知,針對 SipHash1-2 應該不存在實際可行的攻擊,而且無論如何,它都肯定比我們先前使用的 MurmurHash2 更安全,後者在這方面弱到甚至可以產生與 seed 無關的碰撞。

我所使用的 SipHash 實作是參考實作,稍作修改以簡化程式碼並提供一個大小寫不敏感的變體。它被設計成能處理未對齊存取,且與位元組順序無關。這或許是我第一次看到寫得如此精良的雜湊函式參考實作……

其他針對 SPARC 的修正,很大程度上要感謝一位熱心的 Redis 使用者提供 Solaris/SPARC 的存取權限才得以簡化。在修正未對齊存取的過程中,我也嘗試修正了在 Solaris/SPARC 上建置與測試 Redis 的問題,所以整體來說這是一次很好的可攜性提升練習。在完成這項任務後,Redis 至少在單機程式碼的部分終於達到「對齊安全」(alignment safe)。在叢集(Cluster)領域仍有更多工作要做。

Redis 在 Raspberry Pi 上的效能

好,回到 Pi 吧 :-) 在這麼小的硬體上跑 Redis 有多快呢?嗯,由於市面上有不只一種 Pi 型號,這個問題有好幾個答案。Redis 在 Pi 3 上快得令人驚喜。我的基準測試是透過 loopback 介面進行的,因為在 Pi 上的 Redis 主要用途是讓本地程式寫入資料,或是作為訊息匯流排,用於行程間通訊(IPC)以及雲端與邊緣之間的資訊交換(這裡的雲端指的是設備的中央伺服器,邊緣則是指設備的本地部署)。不過,透過乙太網路埠存取時,它的表現也依然良好。

在 Pi 3 上,我得到以下數據:

測試 1:以 100 萬個鍵進行 500 萬次寫入(鍵之間均勻分佈)。無持久化、無 pipelining。28000 ops/sec。
測試 2:與測試 1 相同,但使用 pipelining,每組 8 個操作:80000 ops/sec。
測試 3:與測試 1 相同,但啟用 AOF,fsync 設為每秒一次:23000 ops/sec
測試 4:與測試 3 相同,但在 AOF 重寫進行中:21000 ops/sec

基本上,Redis 在 Pi 3 上的速度對任何使用情境來說都已足夠快。要知道 Redis 大多是單執行緒的,或是在重寫 AOF 日誌時是雙執行緒,因為還有另一個背景行程,所以你可以預期在 Pi 上同時執行其他行程的情況下,仍能達到上述效能。重點是:這些數字並不代表我們已經讓 Pi 達到飽和。

至於初代 model B,情況就*截然*不同了,那些數字要低得多,例如未使用 pipelining 時約 2000 ops/sec,使用 pipelining 時為 15000 ops/sec。這麼大的差距似乎暗示了像 write 與 read 這類需要情境切換的 syscall 處理效率非常低落。不過,對於大多數應用來說,這些數字仍然足夠,因為大多數時候 Redis 並不是要服務外部客戶端,而且當需要進行高負載的資料記錄時,實作 pipelining 往往相當簡單。

不過目前我手邊還沒有另一台最令人感興趣(除了 Pi 3 之外)可供測試的裝置,也就是 Pi Zero。看看它能跑出什麼樣的數據將會很有意思。它的表現應該會比我現在用的 Model B 更好。

Pi 的延續

我喜歡 Redis 在 Pi 上跑得順暢的一點在於,我很期待 Raspberry Pi 能夠藉由 Pi Zero 這類產品,成為 IoT 產品的首選平台。我指的是甚至是面向最終使用者的成品。我不禁一直想,如果有時間,我會想在硬體領域做些什麼:感測器、顯示器、GPIO 埠,以及極低的價格,讓建立一家硬體新創公司變得比過去簡單許多,而我很喜歡這樣的想法——全世界的駭客現在都能出貨各式各樣的智慧裝置。我想在其中盡一份力,即使只是微薄之力,在 Pi 上(未來還有 Android 與其他 ARM 系統)提供良好的 Redis 體驗。Redis 結合了低資源需求、append-only 操作,以及適合用於記錄與裝置內資料分析的資料模型,能夠根據歷史事件採取行動,因此我真的相信它能在這個領域有所幫助。

因此從現在起,對我而言 Raspberry Pi 就像是 Redis 的主要目標平台之一,就像最初將 Linux 伺服器設定為 Redis 的「標準」一樣。在接下來的幾週內,我會繼續進行修正,這些都會納入 Redis 4.0。同時,我也會在 Redis 官方網站上撰寫一個新章節,提供所有關於 Redis 與 Pi 的資訊:不同裝置上的基準測試、最佳實務等等。也許未來我還能釋出概念驗證的「agent」,將 Redis 作為 IoT 裝置與雲端之間的資料匯流排,讓裝置只需將資料記錄到 Redis 中,由 agent 負責在對外連線可用時將資料搬移到雲端,同時抓取要讓裝置執行的指令並回傳結果。當 Redis 4.2 中的 stream 資料結構推出後,這將會變得更加有趣。

我很樂意聽聽你認為 Redis 能在嵌入式場景中發揮作用的應用,以及我可以怎麼做來在這方面讓它變得更好。

本文章由 muse-spark-1.2-contributor 進行翻譯

留言