这个memcpy实现中缺少/次优的是什么?

时间:2014-10-07 22:02:29

标签: c optimization x86 simd avx

我对编写memcpy()作为教育练习感兴趣。我不会写一篇关于我所做和没想过的论文,但是这里是 some guy's implementation

__forceinline   // Since Size is usually known,
                // most useless code will be optimized out
                // if the function is inlined.

void* myMemcpy(char* Dst, const char* Src, size_t Size)
{
        void* start = Dst;
        for ( ; Size >= sizeof(__m256i); Size -= sizeof(__m256i) )
        {
                __m256i ymm = _mm256_loadu_si256(((const __m256i* &)Src)++);
                _mm256_storeu_si256(((__m256i* &)Dst)++, ymm);
        }

#define CPY_1B *((uint8_t * &)Dst)++ = *((const uint8_t * &)Src)++
#define CPY_2B *((uint16_t* &)Dst)++ = *((const uint16_t* &)Src)++
#define CPY_4B *((uint32_t* &)Dst)++ = *((const uint32_t* &)Src)++
#if defined _M_X64 || defined _M_IA64 || defined __amd64
#define CPY_8B *((uint64_t* &)Dst)++ = *((const uint64_t* &)Src)++
#else
#define CPY_8B _mm_storel_epi64((__m128i *)Dst, _mm_loadu_si128((const __m128i *)Src)), ++(const uint64_t* &)Src, ++(uint64_t* &)Dst
#endif
#define CPY16B _mm_storeu_si128((__m128i *)Dst, _mm_loadu_si128((const __m128i *)Src)), ++(const __m128i* &)Src, ++(__m128i* &)Dst

    switch (Size) {
    case 0x00:                                                      break;
    case 0x01:      CPY_1B;                                         break;
    case 0x02:              CPY_2B;                                 break;
    case 0x03:      CPY_1B; CPY_2B;                                 break;
    case 0x04:                      CPY_4B;                         break;
    case 0x05:      CPY_1B;         CPY_4B;                         break;
    case 0x06:              CPY_2B; CPY_4B;                         break;
    case 0x07:      CPY_1B; CPY_2B; CPY_4B;                         break;
    case 0x08:                              CPY_8B;                 break;
    case 0x09:      CPY_1B;                 CPY_8B;                 break;
    case 0x0A:              CPY_2B;         CPY_8B;                 break;
    case 0x0B:      CPY_1B; CPY_2B;         CPY_8B;                 break;
    case 0x0C:                      CPY_4B; CPY_8B;                 break;
    case 0x0D:      CPY_1B;         CPY_4B; CPY_8B;                 break;
    case 0x0E:              CPY_2B; CPY_4B; CPY_8B;                 break;
    case 0x0F:      CPY_1B; CPY_2B; CPY_4B; CPY_8B;                 break;
    case 0x10:                                      CPY16B;         break;
    case 0x11:      CPY_1B;                         CPY16B;         break;
    case 0x12:              CPY_2B;                 CPY16B;         break;
    case 0x13:      CPY_1B; CPY_2B;                 CPY16B;         break;
    case 0x14:                      CPY_4B;         CPY16B;         break;
    case 0x15:      CPY_1B;         CPY_4B;         CPY16B;         break;
    case 0x16:              CPY_2B; CPY_4B;         CPY16B;         break;
    case 0x17:      CPY_1B; CPY_2B; CPY_4B;         CPY16B;         break;
    case 0x18:                              CPY_8B; CPY16B;         break;
    case 0x19:      CPY_1B;                 CPY_8B; CPY16B;         break;
    case 0x1A:              CPY_2B;         CPY_8B; CPY16B;         break;
    case 0x1B:      CPY_1B; CPY_2B;         CPY_8B; CPY16B;         break;
    case 0x1C:                      CPY_4B; CPY_8B; CPY16B;         break;
    case 0x1D:      CPY_1B;         CPY_4B; CPY_8B; CPY16B;         break;
    case 0x1E:              CPY_2B; CPY_4B; CPY_8B; CPY16B;         break;
    case 0x1F:      CPY_1B; CPY_2B; CPY_4B; CPY_8B; CPY16B;         break;
    }
#undef CPY_1B
#undef CPY_2B
#undef CPY_4B
#undef CPY_8B
#undef CPY16B
        return start;
}

评论翻译为“大小通常被称为编译器可以优化代码内联最无用的”。

如果可能的话,我想改进这个实现 - 但也许没有太多改进。我看到它使用SSE / AVX用于较大的内存块,然后代替在最后一个上面的循环。 32字节相当于手动展开,并进行了一些调整。所以,这是我的问题:

  • 为什么要为最后几个字节展开循环,但不能部分展开第一个(现在是单个循环)?
  • 对齐问题怎么样?它们不重要吗?我应该以不同方式处理前几个字节到一些对齐量子,然后在对齐的字节序列上执行256位操作吗?如果是这样,我如何确定合适的对齐量?
  • 此实施中最重要的缺失功能(如果有)是什么?

迄今为止答案中提到的特征/原则

  • 你应该__restrict__你的参数。 (@chux)
  • 内存带宽是一个限制因素;衡量你的实施情况。(@ Zboson)
  • 对于小型阵列,您可以接近内存带宽;对于较大的阵列 - 没有那么多。 (@Zboson)
  • 使内存带宽饱和所需的多个线程(可能是)。 (@Zboson)
  • 对于大小复制尺寸进行不同的优化可能是明智之举。 (@Zboson)
  • (Alignment 重要吗?未明确解决!)
  • 编译器应该更明确地意识到它可以用于优化的“明显事实”(例如在第一个循环之后Size< 32这一事实)。 (@chux)
  • 有解除SSE / AVX调用的参数(@BenJackson,here),以及反对这样做的论据(@PaulR)
  • non-temporal transfers(用它来告诉CPU你不需要它来缓存目标位置)对于复制更大的缓冲区应该是有用的。 (@Zboson)

2 个答案:

答案 0 :(得分:33)

我一直在研究测量具有各种操作的英特尔处理器的内存带宽,其中一个是memcpy。我在Core2,Ivy Bridge和Haswell上做过这个。我使用带内在函数的C / C ++完成了大部分测试(参见下面的代码 - 但我目前正在重写我的测试程序集)。

要编写自己的高效memcpy功能,了解可能的绝对最佳带宽非常重要。这个带宽是要复制的数组大小的函数,因此高效memcpy函数需要针对小和大(以及可能之间)进行不同的优化。为了简单起见,我已针对8192字节的小型数组和1 GB的大型数组进行了优化。

对于小型阵列,每个核心的最大读写带宽为:

Core2-Ivy Bridge             32 bytes/cycle
Haswell                      64 bytes/cycle

这是您应该针对小型阵列的基准。对于我的测试,我假设数组与64字节对齐,并且数组大小是8*sizeof(float)*unroll_factor的倍数。以下是我目前的memcpy结果,大小为8192字节(Ubuntu 14.04,GCC 4.9,EGLIBC 2.19):

                             GB/s     efficiency
    Core2 (p9600@2.66 GHz)  
        builtin               35.2    41.3%
        eglibc                39.2    46.0%
        asmlib:               76.0    89.3%
        copy_unroll1:         39.1    46.0%
        copy_unroll8:         73.6    86.5%
    Ivy Bridge (E5-1620@3.6 GHz)                        
        builtin              102.2    88.7%
        eglibc:              107.0    92.9%
        asmlib:              107.6    93.4%
        copy_unroll1:        106.9    92.8%
        copy_unroll8:        111.3    96.6%
    Haswell (i5-4250U@1.3 GHz)
        builtin:              68.4    82.2%     
        eglibc:               39.7    47.7%
        asmlib:               73.2    87.6%
        copy_unroll1:         39.6    47.6%
        copy_unroll8:         81.9    98.4%

asmlibAgner Fog's asmlibcopy_unroll1copy_unroll8函数定义如下。

从这个表中我们可以看到GCC内置memcpy在Core2上运行不正常,而EGLIBC中memcpy在Core2或Haswell上运行不正常。我最近检查了GLIBC的头版,并且Haswell的性能要好得多。在所有情况下,展开都会获得最佳结果。

void copy_unroll1(const float *x, float *y, const int n) {
    for(int i=0; i<n/JUMP; i++) {
        VECNF().LOAD(&x[JUMP*(i+0)]).STORE(&y[JUMP*(i+0)]);
    }
}

void copy_unroll8(const float *x, float *y, const int n) {
for(int i=0; i<n/JUMP; i+=8) {
    VECNF().LOAD(&x[JUMP*(i+0)]).STORE(&y[JUMP*(i+0)]);
    VECNF().LOAD(&x[JUMP*(i+1)]).STORE(&y[JUMP*(i+1)]);
    VECNF().LOAD(&x[JUMP*(i+2)]).STORE(&y[JUMP*(i+2)]);
    VECNF().LOAD(&x[JUMP*(i+3)]).STORE(&y[JUMP*(i+3)]);
    VECNF().LOAD(&x[JUMP*(i+4)]).STORE(&y[JUMP*(i+4)]);
    VECNF().LOAD(&x[JUMP*(i+5)]).STORE(&y[JUMP*(i+5)]);
    VECNF().LOAD(&x[JUMP*(i+6)]).STORE(&y[JUMP*(i+6)]);
    VECNF().LOAD(&x[JUMP*(i+7)]).STORE(&y[JUMP*(i+7)]);
}

}

对于SSE VECNF().LOAD_mm_load_ps()或AVX为_mm256_load_ps()VECNF().STORE对于SSE为_mm_store_ps(),对于AVX为_mm256_store_ps(),为JUMP SSE为4,AVX为8。

对于大尺寸,通过使用non-temporal存储指令和使用多个线程获得最佳结果。与许多人可能认为的相反a single thread does NOT usually saturate the memory bandwidth

void copy_stream(const float *x, float *y, const int n) {
    #pragma omp parallel for        
    for(int i=0; i<n/JUMP; i++) {
        VECNF v = VECNF().load_a(&x[JUMP*i]);
        stream(&y[JUMP*i], v);
    }
}

SSE的stream _mm_stream_ps()或AVX的_mm256_stream_ps()

以下是我的E5-1620@3.6 GHz上的memcpy结果,其中四个线程为1 GB且maximum main memory bandwidth of 51.2 GB/s

                         GB/s     efficiency
    eglibc:              23.6     46%
    asmlib:              36.7     72%
    copy_stream:         36.7     72%

EGLIBC再次表现不佳。这是因为它不使用非临时存储。

我修改了eglibcasmlib memcpy函数以便像这样并行运行

void COPY(const float * __restrict x, float * __restrict y, const int n) {
    #pragma omp parallel
    {
        size_t my_start, my_size;
        int id = omp_get_thread_num();
        int num = omp_get_num_threads();
        my_start = (id*n)/num;
        my_size = ((id+1)*n)/num - my_start;
        memcpy(y+my_start, x+my_start, sizeof(float)*my_size);
    }
}

一般memcpy函数需要考虑未与64字节(甚至是32或16字节)对齐的数组,并且其大小不是32字节的倍数或展开因子。另外,必须决定何时使用非临时存储。一般的经验法则是仅对大于最大缓存级别(通常为L3)的一半的大小使用非临时存储。但是论文是&#34;二阶&#34;我认为在优化大小理想情况后应该处理的细节。如果理想情况表现不佳,那么在担心纠正错位或非理想大小倍数方面没有多大意义。

<强>更新

根据Stephen Canon的评论我已经了解到,在Ivy Bridge和Haswell上使用rep movsb比使用movntdqa(非临时存储指令)更有效。英特尔称之为增强型rep movsb(ERMSB)。这在 3.7.6增强型REP MOVSB和STOSB操作(ERMSB)部分的Intel Optimization manuals中进行了描述。

此外,在Agner Fog的Optimizing Subroutines in Assembly手册的 17.9移动数据块(所有处理器)中,他写道:

&#34;有多种方法可以移动大块数据。最常见的方法是:

  1. REP MOVS指令。
  2. 如果数据是对齐的:在具有最大可用寄存器大小的循环中进行读写。
  3. 如果大小不变:内联移动说明。
  4. 如果数据未对齐:首先移动所需的字节数以生成目标 对齐。然后读取未对齐并在具有最大可用循环的循环中进行对齐 寄存器大小。
  5. 如果数据未对齐:读取对齐,移位以补偿未对齐和写入 对齐。
  6. 如果数据大小太大而无法进行缓存,请使用非临时写入来绕过缓存。 如有必要,转移以补偿错位。&#34;
  7. 将军memcpy应该考虑这些要点。此外,对于Ivy Bridge和Haswell来说,对于大型阵列来说,点1似乎优于点6。英特尔和AMD以及每次技术迭代都需要不同的技术。我认为编写自己的通用高效memcpy函数非常复杂。但是在我看到的特殊情况下,我已经设法比GCC内置memcpy或EGLIBC中的更好,所以假设你不能比标准库做得更好是不正确的。< / p>

答案 1 :(得分:4)

首先,主循环使用未对齐的AVX向量加载/存储来一次复制32个字节,直到&lt;要复制的32个字节:

    for ( ; Size >= sizeof(__m256i); Size -= sizeof(__m256i) )
    {
        __m256i ymm = _mm256_loadu_si256(((const __m256i* &)Src)++);
        _mm256_storeu_si256(((__m256i* &)Dst)++, ymm);
    }

然后,最终的switch语句以尽可能有效的方式处理剩余的0..31个字节,并适当地使用8/4/2/1字节副本的组合。请注意,这不是一个展开的循环 - 它只是32个不同的优化代码路径,它们使用最少的加载和存储来处理剩余字节。

至于为什么主要的32字节AVX循环没有手动展开 - 有几个可能的原因:

  • 大多数编译器会自动展开小循环(取决于循环大小和优化开关)
  • 过度展开会导致小循环溢出LSD缓存(通常只有28个解码的μop)
  • 在当前的Core iX CPU上,您只能在停止[*]
  • 之前发出两个并发加载/存储
  • 通常即使像这样的非展开AVX循环也会使可用的DRAM带宽饱和[*]

[*]请注意,上面的最后两条注释适用于源和/或目标不在缓存中(即写入/读取DRAM)的情况,因此加载/存储延迟很高。