高性能網絡框架 - DPDK,你不得不知道的點
轉自:
https://cloud.tencent.com/developer/article/1198333
一、網絡 IO 的處境和趨勢
從我們用戶的使用就可以感受到網速一直在提升,而網絡技術的發展也從 1GE/10GE/25GE/40GE/100GE 的演變,從中可以得出單機的網絡 IO 能力必須跟上時代的發展。
- 傳統的電信領域
IP 層及以下,例如路由器、交換機、防火牆、基站等設備都是採用硬件解決方案。基於專用網絡處理器(NP),有基於 FPGA,更有基於 ASIC 的。但是基於硬件的劣勢非常明顯,發生 Bug 不易修復,不易調試維護,並且網絡技術一直在發展,例如 2G/3G/4G/5G 等移動技術的革新,這些屬於業務的邏輯基於硬件實現太痛苦,不能快速迭代。傳統領域面臨的挑戰是急需一套軟件架構的高性能網絡 IO 開發框架。
- 雲的發展
私有云的出現通過網絡功能虛擬化(NFV)共享硬件成爲趨勢,NFV 的定義是通過標準的服務器、標準交換機實現各種傳統的或新的網絡功能。急需一套基於常用系統和標準服務器的高性能網絡 IO 開發框架。
- 單機性能的飆升
網卡從 1G 到 100G 的發展,CPU 從單核到多核到多 CPU 的發展,服務器的單機能力通過橫行擴展達到新的高點。但是軟件開發卻無法跟上節奏,單機處理能力沒能和硬件門當戶對,如何開發出與時並進高吞吐量的服務,單機百萬千萬併發能力。即使有業務對 QPS 要求不高,主要是 CPU 密集型,但是現在大數據分析、人工智能等應用都需要在分佈式服務器之間傳輸大量數據完成作業。這點應該是我們互聯網後臺開發最應關注,也最關聯的。
二、Linux + x86 網絡 IO 瓶頸
在數年前曾經寫過《網卡工作原理及高併發下的調優》一文,描述了 Linux 的收發報文流程。根據經驗,在 C1(8 核)上跑應用每 1W 包處理需要消耗 1% 軟中斷 CPU,這意味着單機的上限是 100 萬 PPS(Packet Per Second)。從 TGW(Netfilter 版)的性能 100 萬 PPS,AliLVS 優化了也只到 150 萬 PPS,並且他們使用的服務器的配置還是比較好的。假設,我們要跑滿 10GE 網卡,每個包 64 字節,這就需要 2000 萬 PPS(注:以太網萬兆網卡速度上限是 1488 萬 PPS,因爲最小幀大小爲 84B《Bandwidth, Packets Per Second, and Other Network Performance Metrics》),100G 是 2 億 PPS,即每個包的處理耗時不能超過 50 納秒。而一次 Cache Miss,不管是 TLB、數據 Cache、指令 Cache 發生 Miss,回內存讀取大約 65 納秒,NUMA 體系下跨 Node 通訊大約 40 納秒。所以,即使不加上業務邏輯,即使純收發包都如此艱難。我們要控制 Cache 的命中率,我們要了解計算機體系結構,不能發生跨 Node 通訊。
從這些數據,我希望可以直接感受一下這裏的挑戰有多大,理想和現實,我們需要從中平衡。問題都有這些
-
傳統的收發報文方式都必須採用硬中斷來做通訊,每次硬中斷大約消耗 100 微秒,這還不算因爲終止上下文所帶來的 Cache Miss。
-
數據必須從內核態用戶態之間切換拷貝帶來大量 CPU 消耗,全局鎖競爭。
-
收發包都有系統調用的開銷。
-
內核工作在多核上,爲可全局一致,即使採用 Lock Free,也避免不了鎖總線、內存屏障帶來的性能損耗。
-
從網卡到業務進程,經過的路徑太長,有些其實未必要的,例如 netfilter 框架,這些都帶來一定的消耗,而且容易 Cache Miss。
三、DPDK 的基本原理
從前面的分析可以得知 IO 實現的方式、內核的瓶頸,以及數據流過內核存在不可控因素,這些都是在內核中實現,內核是導致瓶頸的原因所在,要解決問題需要繞過內核。所以主流解決方案都是旁路網卡 IO,繞過內核直接在用戶態收發包來解決內核的瓶頸。
Linux 社區也提供了旁路機制 Netmap,官方數據 10G 網卡 1400 萬 PPS,但是 Netmap 沒廣泛使用。其原因有幾個:
-
Netmap 需要驅動的支持,即需要網卡廠商認可這個方案。
-
Netmap 仍然依賴中斷通知機制,沒完全解決瓶頸。
-
Netmap 更像是幾個系統調用,實現用戶態直接收發包,功能太過原始,沒形成依賴的網絡開發框架,社區不完善。
那麼,我們來看看發展了十幾年的 DPDK,從 Intel 主導開發,到華爲、思科、AWS 等大廠商的加入,核心玩家都在該圈子裏,擁有完善的社區,生態形成閉環。早期,主要是傳統電信領域 3 層以下的應用,如華爲、中國電信、中國移動都是其早期使用者,交換機、路由器、網關是主要應用場景。但是,隨着上層業務的需求以及 DPDK 的完善,在更高的應用也在逐步出現。
DPDK 旁路原理:
左邊是原來的方式數據從 網卡 -> 驅動 -> 協議棧 -> Socket 接口 -> 業務
右邊是 DPDK 的方式,基於 UIO(Userspace I/O)旁路數據。數據從 網卡 -> DPDK 輪詢模式 -> DPDK 基礎庫 -> 業務
用戶態的好處是易用開發和維護,靈活性好。並且 Crash 也不影響內核運行,魯棒性強。
DPDK 支持的 CPU 體系架構:x86、ARM、PowerPC(PPC)
DPDK 支持的網卡列表:https://core.dpdk.org/supported/,我們主流使用 Intel 82599(光口)、Intel x540(電口)
四、DPDK 的基石 UIO
爲了讓驅動運行在用戶態,Linux 提供 UIO 機制。使用 UIO 可以通過 read 感知中斷,通過 mmap 實現和網卡的通訊。
UIO 原理:
要開發用戶態驅動有幾個步驟:
-
開發運行在內核的 UIO 模塊,因爲硬中斷只能在內核處理
-
通過 / dev/uioX 讀取中斷
-
通過 mmap 和外設共享內存
五、DPDK 核心優化:PMD
DPDK 的 UIO 驅動屏蔽了硬件發出中斷,然後在用戶態採用主動輪詢的方式,這種模式被稱爲 PMD(Poll Mode Driver)。
UIO 旁路了內核,主動輪詢去掉硬中斷,DPDK 從而可以在用戶態做收發包處理。帶來 Zero Copy、無系統調用的好處,同步處理減少上下文切換帶來的 Cache Miss。
運行在 PMD 的 Core 會處於用戶態 CPU100% 的狀態
網絡空閒時 CPU 長期空轉,會帶來能耗問題。所以,DPDK 推出 Interrupt DPDK 模式。
Interrupt DPDK:
它的原理和 NAPI 很像,就是沒包可處理時進入睡眠,改爲中斷通知。並且可以和其他進程共享同個 CPU Core,但是 DPDK 進程會有更高調度優先級。
六、DPDK 的高性能代碼實現
- 採用 HugePage 減少 TLB Miss
默認下 Linux 採用 4KB 爲一頁,頁越小內存越大,頁表的開銷越大,頁表的內存佔用也越大。CPU 有 TLB(Translation Lookaside Buffer)成本高所以一般就只能存放幾百到上千個頁表項。如果進程要使用 64G 內存,則 64G/4KB=16000000(一千六百萬)頁,每頁在頁表項中佔用 16000000 * 4B=62MB。如果用 HugePage 採用 2MB 作爲一頁,只需 64G/2MB=2000,數量不在同個級別。
而 DPDK 採用 HugePage,在 x86-64 下支持 2MB、1GB 的頁大小,幾何級的降低了頁表項的大小,從而減少 TLB-Miss。並提供了內存池(Mempool)、MBuf、無鎖環(Ring)、Bitmap 等基礎庫。根據我們的實踐,在數據平面(Data Plane)頻繁的內存分配釋放,必須使用內存池,不能直接使用 rte_malloc,DPDK 的內存分配實現非常簡陋,不如 ptmalloc。
- SNA(Shared-nothing Architecture)
軟件架構去中心化,儘量避免全局共享,帶來全局競爭,失去橫向擴展的能力。NUMA 體系下不跨 Node 遠程使用內存。
- SIMD(Single Instruction Multiple Data)
從最早的 mmx/sse 到最新的 avx2,SIMD 的能力一直在增強。DPDK 採用批量同時處理多個包,再用向量編程,一個週期內對所有包進行處理。比如,memcpy 就使用 SIMD 來提高速度。
SIMD 在遊戲後臺比較常見,但是其他業務如果有類似批量處理的場景,要提高性能,也可看看能否滿足。
- 不使用慢速 API
這裏需要重新定義一下慢速 API,比如說 gettimeofday,雖然在 64 位下通過 vDSO 已經不需要陷入內核態,只是一個純內存訪問,每秒也能達到幾千萬的級別。但是,不要忘記了我們在 10GE 下,每秒的處理能力就要達到幾千萬。所以即使是 gettimeofday 也屬於慢速 API。DPDK 提供 Cycles 接口,例如 rte_get_tsc_cycles 接口,基於 HPET 或 TSC 實現。
在 x86-64 下使用 RDTSC 指令,直接從寄存器讀取,需要輸入 2 個參數,比較常見的實現:
static inline uint64_t
rte_rdtsc(void)
{
uint32_t lo, hi;
__asm__ __volatile__ (
"rdtsc" : "=a"(lo), "=d"(hi)
);
return ((unsigned long long)lo) | (((unsigned long long)hi) << 32);
}
這麼寫邏輯沒錯,但是還不夠極致,還涉及到 2 次位運算才能得到結果,我們看看 DPDK 是怎麼實現:
static inline uint64_t
rte_rdtsc(void)
{
union {
uint64_t tsc_64;
struct {
uint32_t lo_32;
uint32_t hi_32;
};
} tsc;
asm volatile("rdtsc" :
"=a" (tsc.lo_32),
"=d" (tsc.hi_32));
return tsc.tsc_64;
}
巧妙的利用 C 的 union 共享內存,直接賦值,減少了不必要的運算。但是使用 tsc 有些問題需要面對和解決
-
CPU 親和性,解決多核跳動不精確的問題
-
內存屏障,解決亂序執行不精確的問題
-
禁止降頻和禁止 Intel Turbo Boost,固定 CPU 頻率,解決頻率變化帶來的失準問題
-
編譯執行優化
- 分支預測
現代 CPU 通過 pipeline、superscalar 提高並行處理能力,爲了進一步發揮並行能力會做分支預測,提升 CPU 的並行能力。遇到分支時判斷可能進入哪個分支,提前處理該分支的代碼,預先做指令讀取編碼讀取寄存器等,預測失敗則預處理全部丟棄。我們開發業務有時候會非常清楚這個分支是 true 還是 false,那就可以通過人工干預生成更緊湊的代碼提示 CPU 分支預測成功率。
#pragma once
#if !__GLIBC_PREREQ(2, 3)
# if !define __builtin_expect
# define __builtin_expect(x, expected_value) (x)
# endif
#endif
#if !defined(likely)
#define likely(x) (__builtin_expect(!!(x), 1))
#endif
#if !defined(unlikely)
#define unlikely(x) (__builtin_expect(!!(x), 0))
#endif
- CPU Cache 預取
Cache Miss 的代價非常高,回內存讀需要 65 納秒,可以將即將訪問的數據主動推送的 CPU Cache 進行優化。比較典型的場景是鏈表的遍歷,鏈表的下一節點都是隨機內存地址,所以 CPU 肯定是無法自動預加載的。但是我們在處理本節點時,可以通過 CPU 指令將下一個節點推送到 Cache 裏。
API 文檔:https://doc.dpdk.org/api/rte__prefetch_8h.html
static inline void rte_prefetch0(const volatile void *p)
{
asm volatile ("prefetcht0 %[p]" : : [p] "m" (*(const volatile char *)p));
}
#if !defined(prefetch)
#define prefetch(x) __builtin_prefetch(x)
#endif
… 等等
- 內存對齊
內存對齊有 2 個好處:
l 避免結構體成員跨 Cache Line,需 2 次讀取才能合併到寄存器中,降低性能。結構體成員需從大到小排序和以及強制對齊。參考《Data alignment: Straighten up and fly right》
#define __rte_packed __attribute__((__packed__))
l 多線程場景下寫產生 False sharing,造成 Cache Miss,結構體按 Cache Line 對齊
#ifndef CACHE_LINE_SIZE
#define CACHE_LINE_SIZE 64
#endif
#ifndef aligined
#define aligined(a) __attribute__((__aligned__(a)))
#endif
- 常量優化
常量相關的運算的編譯階段完成。比如 C++11 引入了 constexp,比如可以使用 GCC 的__builtin_constant_p 來判斷值是否常量,然後對常量進行編譯時得出結果。舉例網絡序主機序轉換
#define rte_bswap32(x) ((uint32_t)(__builtin_constant_p(x) ? \
rte_constant_bswap32(x) : \
rte_arch_bswap32(x)))
其中 rte_constant_bswap32 的實現
#define RTE_STATIC_BSWAP32(v) \
((((uint32_t)(v) & UINT32_C(0x000000ff)) << 24) | \
(((uint32_t)(v) & UINT32_C(0x0000ff00)) << 8) | \
(((uint32_t)(v) & UINT32_C(0x00ff0000)) >> 8) | \
(((uint32_t)(v) & UINT32_C(0xff000000)) >> 24))
5)使用 CPU 指令
現代 CPU 提供很多指令可直接完成常見功能,比如大小端轉換,x86 有 bswap 指令直接支持了。
static inline uint64_t rte_arch_bswap64(uint64_t _x)
{
register uint64_t x = _x;
asm volatile ("bswap %[x]"
: [x] "+r" (x)
);
return x;
}
這個實現,也是 GLIBC 的實現,先常量優化、CPU 指令優化、最後才用裸代碼實現。畢竟都是頂端程序員,對語言、編譯器,對實現的追求不一樣,所以造輪子前一定要先了解好輪子。
Google 開源的 cpu_features 可以獲取當前 CPU 支持什麼特性,從而對特定 CPU 進行執行優化。高性能編程永無止境,對硬件、內核、編譯器、開發語言的理解要深入且與時俱進。
七、DPDK 生態
對我們互聯網後臺開發來說 DPDK 框架本身提供的能力還是比較裸的,比如要使用 DPDK 就必須實現 ARP、IP 層這些基礎功能,有一定上手難度。如果要更高層的業務使用,還需要用戶態的傳輸協議支持。不建議直接使用 DPDK。
目前生態完善,社區強大(一線大廠支持)的應用層開發項目是 FD.io(The Fast Data Project),有思科開源支持的 VPP,比較完善的協議支持,ARP、VLAN、Multipath、IPv4/v6、MPLS 等。用戶態傳輸協議 UDP/TCP 有 TLDK。從項目定位到社區支持力度算比較靠譜的框架。
Seastar 也很強大和靈活,內核態和 DPDK 都隨意切換,也有自己的傳輸協議 Seastar Native TCP/IP Stack 支持,但是目前還未看到有大型項目在使用 Seastar,可能需要填的坑比較多。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/c_ptlW1KK2VSXb2rx_c8HQ