很多地方提到memcpy速度快于普通循环,但c标准库中的实现就是指针循环赋值,为什么还是说它快?


为什么需要memcpy

理由如下:

  1. 你要知道在C89之前,结构体是不能直接赋值的,必须按成员依次赋值,关于这个可以翻翻谭浩强的书,里面出现大量按结构体成员赋值的用法。这里必须用memcpy,代码才没有那么冗余
  2. 数组到现在为止,都是不能直接赋值的,必须使用memcpy
  3. 不同类型的对象,无法直接赋值,必须使用memcpy(当然,不同类型的memcpy赋值需要特别注意,小心出错)

memcpy比循环快吗?

如果不了解处理器和硬体特性,单单是凭空去测猜,往往是不靠谱的。 功能运行的快与慢,与很多因素相关,比如流水线的流畅度,是否使用了更快的指令这些因素都相关。

下面是一些例子:

第一个:完全使用赋值语句来实现一个大结构的拷贝功能

第二个:使用libc提供的memcpy来实拷贝功能

第三个:使用SSE指令实现拷贝功能

第四个:使用for语句实现拷贝功能

各位可以猜一下,这4个case,谁快谁慢,它们的运行时间排序是怎么样的。

程序1:

#include &

struct block {
int data[8192];
};

#define ASSIGN1(dst, src) dst[0] = src[0]
#define ASSIGN2(dst, src) ASSIGN1(dst, src) ; ASSIGN1((dst+1), (src+1))
#define ASSIGN4(dst, src) ASSIGN2(dst, src) ; ASSIGN2((dst+2), (src+2))
#define ASSIGN8(dst, src) ASSIGN4(dst, src) ; ASSIGN4((dst+4), (src+4))
#define ASSIGN16(dst, src) ASSIGN8(dst, src) ; ASSIGN8((dst+8), (src+8))
#define ASSIGN32(dst, src) ASSIGN16(dst, src) ; ASSIGN16((dst+16), (src+16))
#define ASSIGN64(dst, src) ASSIGN32(dst, src) ; ASSIGN32((dst+32), (src+32))
#define ASSIGN128(dst, src) ASSIGN64(dst, src) ; ASSIGN64((dst+64), (src+64))
#define ASSIGN256(dst, src) ASSIGN128(dst, src) ; ASSIGN128((dst+128), (src+128))
#define ASSIGN512(dst, src) ASSIGN256(dst, src) ; ASSIGN256((dst+256), (src+256))
#define ASSIGN1024(dst, src) ASSIGN512(dst, src) ; ASSIGN512((dst+512), (src+512))
#define ASSIGN2048(dst, src) ASSIGN1024(dst, src) ; ASSIGN1024((dst+1024), (src+1024))
#define ASSIGN4096(dst, src) ASSIGN2048(dst, src) ; ASSIGN2048((dst+2048), (src+2048))
#define ASSIGN8192(dst, src) ASSIGN4096(dst, src) ; ASSIGN4096((dst+4096), (src+4096))

void *memcpy_v1(void *dst, void *src, size_t size)
{
struct block *d = (struct block *)dst;
struct block *s = (struct block *)src;

// 使用宏来表达赋值语句,是因为*d = *s 这样的写法,被gcc优化成调用memcpy了
// 知道如何禁用gcc使用优化memcpy方法的朋友请有评论区告知,谢谢。
// 已试过-fno-builtin-function不行
ASSIGN8192((d-&>data), (s-&>data));

return dst;
}

struct block a;
struct block b;

int main()
{
int i;

for (i = 0; i &< 10000000; i++) { memcpy_v1(a, b, sizeof(a)); } return 0; }

程序2:

#include &

struct block {
int data[8192];
};

void *memcpy_v2(void *dst, void *src, size_t size)
{
return memcpy(dst, src, size);
}

struct block a;
struct block b;

int main()
{
int i;

for (i = 0; i &< 10000000; i++) { memcpy_v2(a, b, sizeof(a)); } return 0; }

程序3:

#include &

struct block {
int data[8192];
};

static inline void
mov128(void *dst, void *src)
{
asm volatile ("movdqu (%[src]), %%xmm0
"
"movdqu 16(%[src]), %%xmm1
"
"movdqu 32(%[src]), %%xmm2
"
"movdqu 48(%[src]), %%xmm3
"
"movdqu 64(%[src]), %%xmm4
"
"movdqu 80(%[src]), %%xmm5
"
"movdqu 96(%[src]), %%xmm6
"
"movdqu 112(%[src]), %%xmm7
"
"movdqu %%xmm0, (%[dst])
"
"movdqu %%xmm1, 16(%[dst])
"
"movdqu %%xmm2, 32(%[dst])
"
"movdqu %%xmm3, 48(%[dst])
"
"movdqu %%xmm4, 64(%[dst])
"
"movdqu %%xmm5, 80(%[dst])
"
"movdqu %%xmm6, 96(%[dst])
"
"movdqu %%xmm7, 112(%[dst])"
:
: [src] "r" (src),
[dst] "r"(dst)
: "xmm0", "xmm1", "xmm2", "xmm3",
"xmm4", "xmm5", "xmm6", "xmm7", "memory");
}

void *memcpy_v3(void *dst, void *src, size_t size)
{
size_t times = size / 128;
int i;
void *d = dst;
void *s = src;

for (i = 0; i &< times; i++) { mov128(d, s); d += 128; s += 128; } return dst; } struct block a; struct block b; int main() { int i; for (i = 0; i &< 10000000; i++) { memcpy_v3(a, b, sizeof(a)); } return 0; }

程序4:

#include &

struct block {
int data[8192];
};

void *memcpy_v4(void *dst, void *src, size_t size)
{
int *d = (int *)dst;
int *s = (int *)src;
int times = size / sizeof(int);
int i;

for (i = 0; i &< times; i++) { d[i] = s[i]; } return dst; } struct block a; struct block b; int main() { int i; for (i = 0; i &< 10000000; i++) { memcpy_v4(a, b, sizeof(a)); } return 0; }

Linux X86_64上编译优化参数为:-O2

运行时间分别是:

$time ./case1

real 1m20.858s
user 1m20.773s
sys 0m0.000s

$time ./case2

real 0m14.735s
user 0m14.718s
sys 0m0.001s

$time ./case3

real 0m14.633s
user 0m14.616s
sys 0m0.001s

time ./case4

real 0m46.436s
user 0m46.387s
sys 0m0.000s

程序2和程序3时间相同,是因为glibc的memcpy就是采用sse指令实现的。

而程序1是按成员顺序赋值的,而程序4是有循环转跳的,两者时间应该差不多,但实际相差快一倍了。用perf来看一下数据。

$perf stat ./case1

Performance counter stats for ./case1:

82052.987282 task-clock (msec) # 0.995 CPUs utilized
8,272 context-switches # 0.101 K/sec
6 cpu-migrations # 0.000 K/sec
153 page-faults # 0.002 K/sec
186,933,548,239 cycles # 2.278 GHz (83.37%)
124,470,983,373 stalled-cycles-frontend # 66.59% frontend cycles idle (83.30%)
45,569,813,101 stalled-cycles-backend # 24.38% backend cycles idle (66.67%)
164,449,638,500 instructions # 0.88 insns per cycle
# 0.76 stalled cycles per insn (83.30%)
172,628,761 branches # 2.104 M/sec (83.37%)
1,445,740 branch-misses # 0.84% of all branches (83.30%)

82.432626366 seconds time elapsed

$perf stat ./case4

Performance counter stats for ./case4:

46647.277189 task-clock (msec) # 0.997 CPUs utilized
4,738 context-switches # 0.102 K/sec
14 cpu-migrations # 0.000 K/sec
129 page-faults # 0.003 K/sec
106,328,461,475 cycles # 2.279 GHz (83.34%)
23,740,934,687 stalled-cycles-frontend # 22.33% frontend cycles idle (83.33%)
1,606,171,699 stalled-cycles-backend # 1.51% backend cycles idle (66.67%)
409,606,222,273 instructions # 3.85 insns per cycle
# 0.06 stalled cycles per insn (83.33%)
82,052,257,319 branches # 1758.993 M/sec (83.34%)
10,580,326 branch-misses # 0.01% of all branches (83.32%)

46.777898329 seconds time elapsed

两个实现比较大的差异:case1的前后端停顿(stalled-cycles-frontend和stalled-cycles-backend)都比较严重。 但至于为什么有这么严重的停顿,我了解不多,我对微架构流水线这些知识还没有学到家,请相关专家指教,谢谢!

有朋友在评论区指出,case4使用-O3优化级别时,gcc会将loop优化成向量运算。下面是使用-O3后,各case的运行时间:

$time ./case1

real 1m23.093s
user 1m22.998s
sys 0m0.001s

$time ./case2

real 0m14.968s
user 0m14.952s
sys 0m0.000s

$time ./case3

real 0m14.775s
user 0m14.758s
sys 0m0.001s

$time ./case4

real 0m14.732s
user 0m14.714s
sys 0m0.000s

从最新的-O3 测试结果来看,loop的方式结果跟memcpy性能相同,说明编译器还是很聪明的,将loop上无依赖的内存操作直接优化成向量操作,向伟大的编译器至敬!!!

一个memcpy方案是如何实现的

有朋友在评论区指区,针对特定的数据结构,它的copy操作,一定可以做到比通用类型的memcpy还要优,这是肯定的。但这里,我想讨论一下通用类型的memcpy需要考虑哪些事件。

主要是以下3个事情:

  1. 利用大内存指令:尽可能的一条指令拷贝最宽的位元组数。典型的使用SIMD指令,在X86处理器,sse可以一次load/store 128bit(即16位元组),而AVX可以load/store 256 bit(32这节),这比原来的4/8位元组有了不错的飞跃。
  2. 对齐操作:1)带来拷贝大位元组的内存指令,但往往也附带要求拷贝地址是16/32 byte对齐的,而memcpy是以位元组为单位的,没有对齐要求的拷贝功能。它的内存实现需要对内存空间中,前段和尾段不对齐的两块空间用较小的内存load/store指令来操作
  3. 保持流水线的顺畅:这个跟x86每代的微架构相关,怎么让流水线跑处更顺畅,也尽可能让OoO后端的各个port资源分配得更合理,不出现资源等待,对性能也会很大的帮助(比如SSE的资源用完了,可以用ALU资源同步进行处理,而不是每个都是SSE寄存器)

&


现在的回答都是什么鬼,为什么都没有提到simd?

明确回答:

  1. memcpy(一般情况下)就是比循环赋值快,因为libc中它的实现一般都会使用simd指令。你可以想像成,你的循环赋值每次拷贝一个值,而它每次可能拷贝4,8,16或者更多个值。
  2. 然而,(一般情况下),在开启了足够高等级的优化之后(例如GCC可能需要开启-O3优化),编译器会自动把循环赋值优化成memcpy,只要它可以证明这二者效果是等价的。

关于什么叫「等价」,可以给两个简单的例子:

// 不等价
void copy1(char *a, const char *b, size_t len) {
for (size_t i = 0; i &< len; ++i) { a[i] = b[i]; } } // 等价 void copy2(char * __restrict__ a, const char * __restrict__ b, size_t len) { for (size_t i = 0; i &< len; ++i) { a[i] = b[i]; } }


除了各位提到的simd, 在支持dma的平台上可以用dma优化. cpu需要做的只是告诉dma控制器, 源地址, 目的地址, 需要copy多少个、多少位宽的数据, 开始传输,等著完事。

单论传输时间不一定比直接赋值快(可能慢不少),但是在等待dma控制器那边说「传完了」的这段时间, cpu可以并行去干别的事了,总的来说还是快的。

ARM支持内存到内存的DMA传输(比内存到设备还是要慢一些),x86以前不支持,但是现在也有办法了,详细见链接:

Linux伺服器(x86架构),想用DMA做memcpy,就是用DMA做内存到内存的拷贝,可以实现吗??

www.zhihu.com图标

还有这个:

怎样写出一个更快的 memset/memcpy ??

www.zhihu.com图标

你看的是哪个标准库实现?现实现在都有针对不同cpu架构优化的,比如gcc吧,sysdeps目录下面有几十种针对不同cpu架构的优化。具体要依赖不同cpu的提供的硬体指令。


你说的标准库实现是不是从&里面看到的,这只是为了教学目的,提供一个清晰易懂的实现。实际的memcpy实现都会根据硬体构架,作各种极端的优化,诸如SIMD的并行指令。

所以,对于这些基础库函数,除非你的水平真的极高,否则应该总是认为它们的实现会比你自己的快。就像有些人会去折腾spinlock的实现,真往往都没有系统提供的快一样。


推荐阅读:
相关文章