如何優化 memcpy 函數
Linux 內核用到了許多方式來加強性能以及穩定性,本文探討的 memcpy 的匯編實現方式就是其中的一種,memcpy 的性能是否強大,拷貝延遲是否足夠低都直接影響着整個系統性能。通過對拷貝函數的理解可以加深對整個系統設計的一個理解,同時提升自身技術實力。
羅馬不是一天建設而成的,Linux 內核的拷貝函數也不是一開始就是那么優秀,在 3.14 之前(具體多少版本忘記了),Linux 尚且沒有完善對 ARM64 架構的支持,系統的內存拷貝函數就是一個簡單的 c 語言版本,也就是目前內核中的通用拷貝函數。
#ifndef __HAVE_ARCH_MEMCPY /** * memcpy - Copy one area of memory to another * @dest: Where to copy to * @src: Where to copy from * @count: The size of the area. * * You should not use this function to access IO space, use memcpy_toio() * or memcpy_fromio() instead. */ void *memcpy(void *dest, const void *src, size_t count) { char *tmp = dest; const char *s = src; while (count--) *tmp++ = *s++; return dest; } EXPORT_SYMBOL(memcpy); #endif
在沒有定義 __HAVE_ARCH_MEMCPY
之前,內核就會采用最簡單的逐字節拷貝,我相信一個剛入學的大學生也能寫得出一個這樣的代碼,完全不需要考慮對齊,不需要考慮性能等等,就是這么直白,這么暴力的拷貝數據。
當然,我們不可能真的采用這樣的代碼來運轉系統,不然再好的硬件能力也會被粗糙的代碼毀掉,那么不如一起來做一個簡單的優化?
現代計算機已經不再是 20 世紀時代的 16 位機甚至更早的 8 位機,一個寄存器寬度已經達到了驚人的 64 位(32 位機器也會在這兩年被主流淘汰掉,大部分的操作系統已經不再提供 32 位支持),既然如此,何不將這個一個特性利用起來。
void *memcpy(void *d, void *s, size_t count) { int i; for (i = 0; i < count / sizeof(int64_t); i++) { (int64_t *)d++ = (int64_t *)s++; } return d; }
這樣是不是舒服多了(代碼沒有考慮 count 不能被整除的情況,僅僅做一個演示),一條指令下去就可以完成 8 個字節的拷貝,這樣整個循環體直接縮減為原來的 1/8,效率是上一版本的 8 倍之多。那么僅此而已嗎?
不然,在 CPU 的指令上,跳轉指令的耗時是很高的,軟件應該盡可能的減少 CPU 跳轉,上面的代碼沒做完一次 8 字節的拷貝之后就需要完成一個跳轉,那么是不是可以減少一些跳轉呢?當然,那就是循環展開:
void *memcpy(void *d, void *s, size_t count) { int i; for (i = 0; i < count / sizeof(int) / 4; i++) { (int *)d++ = (int *)s++; (int *)d++ = (int *)s++; (int *)d++ = (int *)s++; (int *)d++ = (int *)s++; } return d; }
循環展開也做了,有沒有其他的方式可以繼續優化呢?當然有,盡管 ARM64 的機器指令寬度為 64 位,最多一次能存儲 8 個字節,但是他還有更為高級的寄存器,那就是向量寄存器,通過 NEON 指令處理,可以一次性搬移 128 位數據,也就是 16個字節,這樣效率又提升一倍,通過代碼演示一下:
#include <arm_neon.h> void *memcpy_128(void *dest, void *src, size_t count) { int i; unsigned long *s = (unsigned long *)src; unsigned long *d = (unsigned long *)dest; for (i = 0; i < count / 64; i++) { vst1q_u64(&d[0], vld1q_u64(&s[0])); vst1q_u64(&d[2], vld1q_u64(&s[2])); vst1q_u64(&d[4], vld1q_u64(&s[4])); vst1q_u64(&d[6], vld1q_u64(&s[6])); d += 8; s += 8; } return dest; }
上面的代碼通過 NEON 改造之后,一次循環體可以處理 64 字節的數據,大大的加快了拷貝效率。還有沒有更好的優化方式?當然是有的,那就是用匯編來寫1,結合上面提到的所有的優化方式,以匯編的形式實現,可以獲得最佳性能。我們接下來具體分析目前 Linux 內核下的 ARM64 架構 memcpy 的實現方式。
當前 ARM64 構架的實現方式
熟悉 Linux 內核的都知道,Linus 為了讓 kernel 跑得更快,更健壯,代碼能夠重復利用就一定重復利用,不但可以減少生成的二進制 bin 文件大小,而且能減少維護成本,arch/arm64/lib/memcpy.S 就是這樣的例子。
ENTRY(__memcpy) ENTRY(memcpy) #include "copy_template.S" ret ENDPIPROC(memcpy) ENDPROC(__memcpy)
memcpy.S 直接 include 了一個 copy_template.S 的文件,其實就是直接貼上了這樣的一份代碼,這個 copy_template.S 不僅僅只是在 memcpy.S 中用到,在其他的類似 copy_to_user.S 和 copy_from_user.S 中也被包含。
既然如此,我們只需要深入分析 copy_template.S 即可。這里不貼代碼進行逐行分析,因為也沒有什么好分析的,當你完全理解設計思想,再對着代碼你主需要理解每一行的匯編是什么意思即可。
從上圖可以看出,拷貝算法將數據分為 3 個大的部分,第一個部分就是不對齊部分,通過對傳入的 src 地址進行分析,首先處理掉不能被 16 整除的前面不對齊數據,然后處理對齊的數據。
對齊的數據以 128 為一個界限,每一個 128 字節數據都能通過大塊拷貝直接計算完畢,一直循環到最后剩余的尾部 128 以下的字節。
整體設計邏輯流程圖如下:
大體思想很簡單,那就是首先處理不對齊,之后處理大拷貝部分,然后細分到最小的各個部分,通過利用寄存器寬度來減少拷貝次數。
比如最后的 120 個字節會被分為:120 = 64 + 32 + 16 + 8
,這樣處理可以得到最佳的性能。
memcpy 拷貝性能測試
編寫一個新的算法當然需要對他進行性能測試,那么該如何做性能測試呢?當然是需要編寫一個內核驅動,可以隨意百度一個 HelloWorld 的模塊,參考其邏輯編寫一個簡單的模塊,在 module_init 的函數中寫入這樣的一段測試代碼,等模塊加載完畢之后,會附帶打印當前輸入的測試的 memcpy 算法的性能。
typedef void *(*memcpy_t)(void *, void *, size_t); void memcpy_speed_test(memcpy_t __memcpy, void *b1, void *b2) { int speed; unsigned long now, j; int i, count, max; preempt_disable(); max = 0; for (i = 0; i < 5; i++) { j = jiffies; count = 0; while ((now = jiffies) == j) cpu_relax(); while (time_before(jiffies, now + 1)) { mb(); /* prevent loop optimzation */ __memcpy(bench_size, b1, b2); mb(); count++; mb(); } if (count > max) max = count; } preempt_enable(); speed = max * (HZ * bench_size / 1024); printk(KERN_INFO "memcpy_test: %5d.%03d MB/sec\n", speed / 1000, speed % 1000); }