上一篇文章中談到DPDK是一個(gè)高性能的用戶態(tài)驅(qū)動(dòng),改變了網(wǎng)卡驅(qū)動(dòng)原先的中斷為輪詢的模式,那么它的性能到底有多強(qiáng),用數(shù)據(jù)來說明吧。
1. DPDK性能有多強(qiáng)
DPDK的一個(gè)處理器核每秒可以處理約33M個(gè)報(bào)文,大概30納秒處理一個(gè)報(bào)文,在處理器頻率2.7GHz的情況下,處理一個(gè)數(shù)據(jù)報(bào)文需要80個(gè)時(shí)鐘周期。
在傳統(tǒng)的方法上,一個(gè)數(shù)據(jù)報(bào)文到達(dá)網(wǎng)口后,會(huì)經(jīng)歷如下過程:
- 寫接受描述符到內(nèi)存,填充數(shù)據(jù)緩沖區(qū)指針,網(wǎng)卡接收到報(bào)文后就根據(jù)該地址把報(bào)文內(nèi)容填進(jìn)去。
- 從內(nèi)存中讀取接收描述符(到接收到報(bào)文時(shí),網(wǎng)卡會(huì)更新該結(jié)構(gòu)),從而確認(rèn)是否收到報(bào)文。
- 從接收描述符確認(rèn)收到報(bào)文時(shí),從內(nèi)存中讀取控制結(jié)構(gòu)體的指針,再?gòu)膬?nèi)存中讀取控制結(jié)構(gòu)體,把從接收描述符中讀取的信息填充到該控制結(jié)構(gòu)體。
- 更新接收隊(duì)列寄存器,表示軟件接收到了新的報(bào)文。
- 從內(nèi)存讀取報(bào)文頭部,決定轉(zhuǎn)發(fā)端口。
- 從控制結(jié)構(gòu)體把報(bào)文信息填入到發(fā)送隊(duì)列發(fā)送描述符中,更新發(fā)送隊(duì)列寄存器。
- 從內(nèi)存中讀取發(fā)送描述符,檢查是否有包被硬件發(fā)送出去。
- 如果有的話,則從內(nèi)存中讀取相應(yīng)控制結(jié)構(gòu)體,釋放數(shù)據(jù)緩沖區(qū)。
在這8個(gè)步驟中,有6次內(nèi)存讀,而處理器從一級(jí)cache讀需要3-5時(shí)鐘周期,二級(jí)是十幾個(gè)時(shí)鐘周期,三級(jí)是幾十個(gè)時(shí)鐘周期,而從內(nèi)存讀取數(shù)據(jù),由于收到NUMA架構(gòu)(可以理解為,內(nèi)存也分給了不同的核,每個(gè)核訪問自己的內(nèi)存特別快,訪問別的核的內(nèi)存則需要很長(zhǎng)時(shí)間)的影響,尤其是不在一個(gè)Socket的核之間的內(nèi)存讀取,會(huì)花費(fèi)很長(zhǎng)時(shí)間,所以平均訪問內(nèi)存需要的時(shí)鐘周期大約是幾百個(gè)。處理一個(gè)報(bào)文80個(gè)時(shí)鐘周期,就要求數(shù)據(jù)在cache中,而且一旦不命中,性能會(huì)嚴(yán)重下降。
而在操作系統(tǒng)中,最容易造成性能下降的是線程的調(diào)度,尤其是核間線程的切換,最容易造成cache miss和cache write back。所以在DPDK中利用的是線程的CPU親和綁定的方式,來指定任務(wù)到不同的核上。再進(jìn)一步,可以限制一些核不參與Linux的系統(tǒng)調(diào)度,這樣就可以達(dá)到任務(wù)獨(dú)占的目的,最大限度地避免了cache不命中帶來的性能下降。
查閱DPDK資料,發(fā)現(xiàn)DPDK中的多線程是基于linux系統(tǒng)里的pthread實(shí)現(xiàn)的,lcore指的是EAL線程,并且在命令行參數(shù)中使用“-c”帶十六進(jìn)制參數(shù)作為coremask,該掩碼的意義是為二進(jìn)制數(shù)上為1的一位即表示將要綁定獨(dú)占的線程,例如:掩碼是16進(jìn)制的f,二進(jìn)制對(duì)應(yīng)為1111,即表示cpu0、cpu1、cpu2、cpu3作為邏輯核為程序所用。
2. lcore的初始化如下:
- rte_eal_cpu_init()函數(shù)中,通過讀取/sys/devices/system/cpu/cpuX/下的相關(guān)信息,確定當(dāng)前系統(tǒng)有哪些核,以及分別屬于哪些socket(這里的socket是NUMA架構(gòu)中socket,不是網(wǎng)絡(luò)中的套接字)。
- eal_parse_args()函數(shù),解析-c參數(shù),確認(rèn)哪些核是可以用的,并且設(shè)置第一個(gè)核為MASTER。
- 為每一個(gè)SLAVE核創(chuàng)建線程,并調(diào)用eal_thread_set_affinity()綁定CPU,每個(gè)線程的執(zhí)行的其實(shí)是一個(gè)主體是while死循環(huán)的調(diào)用不同模塊注冊(cè)到lcore_config[lcore_id].f的回調(diào)函數(shù)eal_thread_loop()。
*注:在eal_thread_loop()中,將線程綁定核,然后置于了等待的狀態(tài)。綁定核函數(shù)基于linux原型函數(shù)f_pthread_setaffinity_np,在pthread_shim.c中有對(duì)各種pthread函數(shù)封裝的實(shí)現(xiàn)。
3. lcore的注冊(cè):
不同模塊需要調(diào)用rte_eal_mp_remote_launch(),將自己的回調(diào)函數(shù)注冊(cè)到config[].f中。每個(gè)核上的線程都會(huì)調(diào)用該函數(shù)來實(shí)現(xiàn)自己的處理函數(shù)。lcore啟動(dòng)過程和任務(wù)分發(fā)如下:
另外,由于現(xiàn)網(wǎng)往往有流量潮汐的影響,所以為了尋求靈活的擴(kuò)展能力,EAL pthread與邏輯核之間允許打破1:1的綁定關(guān)系,允許綁定一個(gè)特定的lcore ID或者lcore ID組。
4. 程序解析
在example文件夾中,我們來看一個(gè)最簡(jiǎn)單的hello world程序。它建立了一個(gè)多核運(yùn)行的環(huán)境,每個(gè)線程都會(huì)打印“hello from core #”,有點(diǎn)類似pthread的入門程序。
注意:在DPDK代碼中,rte(runtime environment)開頭的函數(shù)是作為給開發(fā)者直接調(diào)用的接口,也就是說,只是使用DPDK的話,只要知曉這些函數(shù)的參數(shù)和作用,會(huì)調(diào)用即可,eal(environment abstraction layer)是DPDK核心庫(kù)中提供系統(tǒng)抽象的部分,因?yàn)殡m然現(xiàn)在的源碼是基于linux或者FreeBSD系統(tǒng)運(yùn)行,但它最早期的代碼是不依賴于操作系統(tǒng)的,就像自己本身就是個(gè)mini-os一樣。
helloword的代碼如下:
#include <stdio.h>
#include <string.h>
#include <stdint.h>
#include <errno.h>
#include <sys/queue.h>
#include <rte_memory.h>
#include <rte_memzone.h>
#include <rte_launch.h>
#include <rte_eal.h>
#include <rte_per_lcore.h>
#include <rte_lcore.h>
#include <rte_debug.h>
static int
lcore_hello(__attribute__((unused)) void *arg)
{
unsigned lcore_id;
lcore_id = rte_lcore_id();
printf("hello from core %u\n", lcore_id);
return 0;
}
int
main(int argc, char **argv)
{
int ret;
unsigned lcore_id;
ret = rte_eal_init(argc, argv);
if (ret < 0)
rte_panic("Cannot init EAL\n");
/* call lcore_hello() on every slave lcore */
RTE_LCORE_FOREACH_SLAVE(lcore_id) {
rte_eal_remote_launch(lcore_hello, NULL, lcore_id);
}
/* call it on master lcore too */
lcore_hello(NULL);
rte_eal_mp_wait_lcore();
return 0;
}
rte_eal_init(argc, argv)中兩個(gè)命令行入口參數(shù),可以是一系列很長(zhǎng)很復(fù)雜的設(shè)置,從頂往下追溯:
rte_eal_init→eal_log_level_parse→eal_parse_common_option,發(fā)現(xiàn)在該函數(shù)中,便是對(duì)common opinion進(jìn)行設(shè)置的地方。common opinion如下所示,分別用于命令行設(shè)置不同的值。
const char
eal_short_options[] =
"b:" /* pci-blacklist */
"c:" /* coremask */
"d:" /* driver */
"h" /* help */
"l:" /* corelist */
"m:" /* memory size */
"n:" /* memory channels */
"r:" /* memory ranks */
"v" /* version */
"w:" /* pci-whitelist */
;
其中最重要的就是-c,設(shè)置核掩碼,這塊內(nèi)容上面已經(jīng)說過了,運(yùn)行效果如下:
整體代碼的結(jié)構(gòu)很像pthread寫的多線程程序,先rte_eal_init()進(jìn)行一系列很復(fù)雜的初始化工作,在官方文檔上寫的這些初始化工作包括:
- 配置初始化
- 內(nèi)存初始化
- 內(nèi)存池初始化
- 隊(duì)列初始化
- 告警初始化
- 中斷初始化
- PCI初始化
- 定時(shí)器初始化
- 檢測(cè)內(nèi)存本地化(NUMA)
- 插件初始化
- 主線程初始化
- 輪詢?cè)O(shè)備初始化
- 建立主從線程通道
- 將從線程設(shè)置為等待模式
- PCI設(shè)備的探測(cè)和初始化
然后RTE_LCORE_FOREACH_SLAVE遍歷所有EAL指定可以使用lcore,通過rte_eal_remote_launch在每個(gè)lcore上,啟動(dòng)指定的線程。
需要注意的是lcore_id是一個(gè)unsigned變量,其實(shí)際作用就相當(dāng)于循環(huán)變量i,因?yàn)楹闞TE_LCORE_FOREACH_SLAVE里會(huì)啟動(dòng)for循環(huán)來遍歷所有可用的核。
#define RTE_LCORE_FOREACH_SLAVE(i) \
for (i = rte_get_next_lcore(-1, 1, 0); \
i<RTE_MAX_LCORE; \
i = rte_get_next_lcore(i, 1, 0))
在函數(shù)rte_eal_remote_launch(int (*f)(void *), void *arg, unsigned slave_id))中,第一個(gè)參數(shù)是從線程要調(diào)用的函數(shù),第二個(gè)參數(shù)是調(diào)用的函數(shù)的參數(shù),第三個(gè)參數(shù)是指定的邏輯核。詳細(xì)的函數(shù)執(zhí)行過程如下:
int
rte_eal_remote_launch(int (*f)(void *), void *arg, unsigned slave_id)
{
int n;
char c = 0;
int m2s = lcore_config[slave_id].pipe_master2slave[1]; //主線程對(duì)從線程的管道,管道是一個(gè)大小為2的int數(shù)組
int s2m = lcore_config[slave_id].pipe_slave2master[0]; //從線程對(duì)主線程的管道
if (lcore_config[slave_id].state != WAIT)
return -EBUSY;
lcore_config[slave_id].f = f;
lcore_config[slave_id].arg = arg;
/* send message */
n = 0;
while (n == 0 || (n < 0 && errno == EINTR))
n = write(m2s, &c, 1); //此處是調(diào)用的linux庫(kù)函數(shù)
if (n < 0)
rte_panic("cannot write on configuration pipe\n");
/* wait ack */
do {
n = read(s2m, &c, 1);
} while (n < 0 && errno == EINTR);
if (n <= 0)
rte_panic("cannot read on configuration pipe\n");
return 0;
}
lcore_config中的pipe_master2slave[2]和pipe_slave2master[2]分別是主線程到從線程核從線程到主線程的管道,與linux中的管道一樣,是一個(gè)大小為2的數(shù)組,數(shù)組的第一個(gè)元素為讀打開,第二個(gè)元素為寫打開。在這調(diào)用了linux庫(kù)函數(shù)read核write,把c作為消息傳遞。管道的模型如下圖所示:
這樣,每個(gè)從線程通過rte_eal_remote_launch函數(shù)運(yùn)行了自定義函數(shù)lcore_hello就打印出了“hello from core #”的輸出。
注:此篇文章部分引用自《深入淺出DPDK》中的觀點(diǎn)。