c - 此memcpy实现中缺少什么/欠佳?

标签 c optimization x86 simd avx

我对编写memcpy()作为一种教育 Activity 感兴趣。我不会写关于我所做和未曾思考过的全部论文,但这是
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位操作?如果是这样,我如何确定合适的取 vector 子?
  • 此实现中最重要的缺失功能是什么(如果有)?


  • 到目前为止答案中提到的功能/原理
  • 您应该使用__restrict__参数。 (@chux)
  • 内存带宽是一个限制因素。衡量针对它的实现。(@ Zboson)
  • 对于小型阵列,您可以期望接近内存带宽。对于更大的阵列-不那么多。 (@Zboson)
  • 要饱和内存带宽,必须有多个线程(可能是|)。 (@Zboson)
  • 对大和小的副本尺寸进行不同的优化可能是明智的。 (@Zboson)
  • (对齐很重要?未明确解决!)
  • 应该使编译器更明确地意识到可以用于优化的“明显事实”(例如,在第一个循环之后Size <32)。 (@chux)
  • 有用于展开SSE / AVX调用的参数(@ BenJackson,here),以及反对这样做的参数(@PaulR)
  • non-temporal transfers(通过它告诉CPU不需要它来缓存目标位置)对于复制较大的缓冲区应该很有用。 (@Zboson)
  • 最佳答案

    我一直在研究测量具有各种操作的英特尔处理器的内存带宽,其中之一是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的倍数。这是我当前的819t字节大小的memcpy结果(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)]);
    }
    

    }

    其中VECNF().LOAD是SSE的_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);
        }
    }
    

    其中stream是SSE的_mm_stream_ps()或AVX的_mm256_stream_ps()
    这是我的E5-1620@3.6 GHz在maximum main memory bandwidth of 51.2 GB/s上具有1 GB的四个线程的4个线程上的memcpy结果。
                             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)一半的大小使用非临时存储。但是这些都是“二阶”细节,我认为应该针对理想的大小案例进行优化后再处理。如果理想情况下的性能也很差,则不必担心校正未对准或不理想的尺寸倍数。

    更新

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

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

    “有几种移动大型数据块的方法。最常见的方法是:
  • REP MOVS指令。
  • 如果数据对齐:在具有最大可用寄存器大小的循环中进行读写。
  • 如果大小恒定:内联移动指令。
  • 如果数据未对齐:首先将所需的字节数移至目标位置
    对齐。然后以最大的可用循环读取未对齐并写入对齐
    寄存器大小。
  • 如果数据未对齐:读取对齐,移位以补偿未对齐并写入
    对齐。
  • 如果数据大小太大而无法进行缓存,请使用非临时写入绕过缓存。
    进行移位以补偿未对准。”

  • 一般的memcpy应该考虑所有这些点。此外,对于Ivy Bridge和Haswell,大型阵列的点1优于点6。对于Intel和AMD以及技术的每次迭代,都必须使用不同的技术。我认为很明显,编写自己的通用有效memcpy函数可能非常复杂。但是在特殊情况下,我已经看过比GCC内置的memcpy或EGLIBC中的代码更好,因此认为您不能比标准库做得更好的假设是不正确的。

    关于c - 此memcpy实现中缺少什么/欠佳?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/26246040/

    相关文章:

    c - 由于 malloc 导致地址越界和内存泄漏

    c - 将时间戳编码为最少的字符

    c - 访问 double 组的指针

    针对 REGEXP 的 Mysql 优化

    x86 - AVX512 比较和交换

    c++ - C 函数可以运行,C++ 版本不行

    mySQL - 如何解释我的 EXPLAIN 结果并优化此查询?

    c++ - 让 g++ 使用 SHLD/SHRD 指令

    linux - 32 位 LINUX 2.6 可执行文件能否在 LINUX 3.2 机器上可靠运行?

    assembly - LEA 或 ADD 指令?