在前面介绍了DPDK中的上层对并行的支持,特别是对多核的支持。但是,大家都知道,再怎么好的设计和架构,再优秀的编码,最终都要落到硬件和固件对整个上层应用的支持。单纯的硬件好处理,一个核不够多个核,在可能的情况下把CPU的频率增加,加大缓存等等。在现有水平的范围内,这些都是可以比较容易做到的。
但是另外一个,就是在CPU上如何最终运行指令(也可以叫做固件设计),这个就需要设计人员动脑子了。一般来说,IPC(Instruction Per Clock,一个时钟周期内执行的指令数量,可不要看成进程间通信)的数量越高,CPU运行性能越高(频率和核数相同)。
现代CPU基本使用了越标量(superscalar)体系结构,通过以空间换时间的方式实行了指令级并行运算。不同的架构的处理器,可能在硬件设计本身有所不同,但在追求并行度上,原理基本相同。
在前面的多核编程中,介绍过几种指令,目前常用的基本以SIMD(单指令流多数据流)和MIMD(多指令流多数据流)为主。后者一般是多核和多CPU(当然更高层次的多计算机也算),但在分析本文中更倾向的是SIMD,毕竟一个核心能处理多少更能体现性能和效率。
SIMD其实很容易理解,可以认为是一种并行的批处理。原来只能一次取一条指令处理一条数据,这次可以一条指令处理多条数据。举个最简单的例子,加指令,需要有两次读操作数,而如果使用SIMD,则一次就可以都读进来。其后的处理周期也是如此,那么效率至少增加了一倍。
而这些指令设计和处理会形成一个指令集,它的发展也有一个过程,intel的SIMD指令集主要有MMX, SSE, AVX, AVX-512,主流就是SSE/AVX。AMD的比较复杂,有兴趣可以查找看一下。
在DPDK中对SIMD的应用体现在数据的处理上,DPDK提供了一个化化的拷贝memcpy函数,它充分利用了SIMD指令集:
static __rte_always_inline void *
rte_memcpy(void *dst, const void *src, size_t n)
{
if (!(((uintptr_t)dst | (uintptr_t)src) & ALIGNMENT_MASK))
return rte_memcpy_aligned(dst, src, n);
else
return rte_memcpy_generic(dst, src, n);
}
static __rte_always_inline void *
rte_memcpy_aligned(void *dst, const void *src, size_t n)
{
void *ret = dst;
/* Copy size < 16 bytes */
if (n < 16) {
return rte_mov15_or_less(dst, src, n);
}
/* Copy 16 <= size <= 32 bytes */
if (n <= 32) {
rte_mov16((uint8_t *)dst, (const uint8_t *)src);
rte_mov16((uint8_t *)dst - 16 + n,
(const uint8_t *)src - 16 + n);
return ret;
}
/* Copy 32 < size <= 64 bytes */
if (n <= 64) {
rte_mov32((uint8_t *)dst, (const uint8_t *)src);
rte_mov32((uint8_t *)dst - 32 + n,
(const uint8_t *)src - 32 + n);
return ret;
}
/* Copy 64 bytes blocks */
for (; n >= 64; n -= 64) {
rte_mov64((uint8_t *)dst, (const uint8_t *)src);
dst = (uint8_t *)dst + 64;
src = (const uint8_t *)src + 64;
}
/* Copy whatever left */
rte_mov64((uint8_t *)dst - 64 + n,
(const uint8_t *)src - 64 + n);
return ret;
}
static __rte_always_inline void *
rte_memcpy_generic(void *dst, const void *src, size_t n)
{
__m128i xmm0, xmm1, xmm2, xmm3, xmm4, xmm5, xmm6, xmm7, xmm8;
void *ret = dst;
size_t dstofss;
size_t srcofs;
/**
* Copy less than 16 bytes
*/
if (n < 16) {
return rte_mov15_or_less(dst, src, n);
}
/**
* Fast way when copy size doesn't exceed 512 bytes
*/
if (n <= 32) {
rte_mov16((uint8_t *)dst, (const uint8_t *)src);
rte_mov16((uint8_t *)dst - 16 + n, (const uint8_t *)src - 16 + n);
return ret;
}
if (n <= 48) {
rte_mov32((uint8_t *)dst, (const uint8_t *)src);
rte_mov16((uint8_t *)dst - 16 + n, (const uint8_t *)src - 16 + n);
return ret;
}
if (n <= 64) {
rte_mov32((uint8_t *)dst, (const uint8_t *)src);
rte_mov16((uint8_t *)dst + 32, (const uint8_t *)src + 32);
rte_mov16((uint8_t *)dst - 16 + n, (const uint8_t *)src - 16 + n);
return ret;
}
if (n <= 128) {
goto COPY_BLOCK_128_BACK15;
}
if (n <= 512) {
if (n >= 256) {
n -= 256;
rte_mov128((uint8_t *)dst, (const uint8_t *)src);
rte_mov128((uint8_t *)dst + 128, (const uint8_t *)src + 128);
src = (const uint8_t *)src + 256;
dst = (uint8_t *)dst + 256;
}
COPY_BLOCK_255_BACK15:
if (n >= 128) {
n -= 128;
rte_mov128((uint8_t *)dst, (const uint8_t *)src);
src = (const uint8_t *)src + 128;
dst = (uint8_t *)dst + 128;
}
COPY_BLOCK_128_BACK15:
if (n >= 64) {
n -= 64;
rte_mov64((uint8_t *)dst, (const uint8_t *)src);
src = (const uint8_t *)src + 64;
dst = (uint8_t *)dst + 64;
}
COPY_BLOCK_64_BACK15:
if (n >= 32) {
n -= 32;
rte_mov32((uint8_t *)dst, (const uint8_t *)src);
src = (const uint8_t *)src + 32;
dst = (uint8_t *)dst + 32;
}
if (n > 16) {
rte_mov16((uint8_t *)dst, (const uint8_t *)src);
rte_mov16((uint8_t *)dst - 16 + n, (const uint8_t *)src - 16 + n);
return ret;
}
if (n > 0) {
rte_mov16((uint8_t *)dst - 16 + n, (const uint8_t *)src - 16 + n);
}
return ret;
}
/**
* Make store aligned when copy size exceeds 512 bytes,
* and make sure the first 15 bytes are copied, because
* unaligned copy functions require up to 15 bytes
* backwards access.
*/
dstofss = (uintptr_t)dst & 0x0F;
if (dstofss > 0) {
dstofss = 16 - dstofss + 16;
n -= dstofss;
rte_mov32((uint8_t *)dst, (const uint8_t *)src);
src = (const uint8_t *)src + dstofss;
dst = (uint8_t *)dst + dstofss;
}
srcofs = ((uintptr_t)src & 0x0F);
/**
* For aligned copy
*/
if (srcofs == 0) {
/**
* Copy 256-byte blocks
*/
for (; n >= 256; n -= 256) {
rte_mov256((uint8_t *)dst, (const uint8_t *)src);
dst = (uint8_t *)dst + 256;
src = (const uint8_t *)src + 256;
}
/**
* Copy whatever left
*/
goto COPY_BLOCK_255_BACK15;
}
/**
* For copy with unaligned load
*/
MOVEUNALIGNED_LEFT47(dst, src, n, srcofs);
/**
* Copy whatever left
*/
goto COPY_BLOCK_64_BACK15;
}
更多相关的代码在rte_memcpy.h和rte_memcpy.c中,注意,它包含不同CPU架构平台的多个版本,不要搞混。
从上面的代码可以看到,影响拷贝速度的有以下几点:
1、字节对齐和数据的加载存储。
这个大家都明白,除了字节对齐速度加快外,而且DPDK中还对不同的字节对齐以及长度进行了控制,充分发挥SIMD的优势(说直白一点就是在条件允许的情况下,一次拷贝数量多【16字节:128位】,这个和平台支持有关)
2、函数和库调用开销,库函数需要调用过程,这个也浪费时间。这个库调用过程在编译选择优化的过程中,优化难度也比较大,不如在DPDK中直接调用,特别是使用
static __rte_always_inline(静态内联)时,这在网上有很多优化的比较,自己也可以试一试。
3、整体上来说,数据量越大,上面的优化越优势越大;否则优势则不明显。
上述的比较是针对库glibc以及DPDK相比而言的,至于个人优化过的则不在此范畴之内。另外,随着技术的进步,如果用高版本的glibc并开启优化后,可能效果差别也不大,这个没有进行比较。
有兴趣可以看看rte_mov256等几个函数。
需要说明的是,对于某一类函数,没有普遍最优之说。只有场景条件限制下的最合适。也就是说,DPDK的拷贝函数不代表此函数比glibc中的拷贝函数优秀,只是说明此函数在DPDK的应用场景下更合适。
最后总结一下,针对内存拷贝的优化点:
1、减少拷贝过程中的附加处理如字节对齐
2、在平台允许情况下使用最大带宽(拷贝最大数量)
3、使用平坦顺序内存并使用分支预测(减少分支跳转,如是否有范围重叠等)
4、有可能的情况下使用non-temporal访存执令
5、使用加速拷贝的一些指令(string操作指令等)。
6、处理大内存(M以上)和小内存(K以下)的不同场景(这个在一些常用框架中都会处理)
性能和效率的提升,是一个系统工程。它可能会从一个点开始,然后不断的影响别的点,然后这些点又互相影响,最后蔓延到整个系统,形成一个量变到质变的过程。计算机应用也不外乎这样。
DPDK中通过Linux内核的一些设计(如大页),通过一种工程优化的手段来提高网络通信的效率,但反过来,内核也会借鉴DPDK的一些特点来吸收到内核中去。同样,DPDK的出现对硬件本身的设计也提出了虚拟化的相关等要求。硬件水平的提高又可以提高DPDK的性能。
国内的缺少的不是后面的一系列动作,缺少的恰恰是开始那个点,那个用于爆发的创新点。