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

ein*_*ica 26 c optimization x86 simd avx

我对编写一个memcpy()教育练习感兴趣.我不会写一篇关于我做了什么和没想过的论文,但这里 有一些人的实现:

__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;
}
Run Code Online (Sandbox Code Playgroud)

注释翻译为"大小通常被称为编译器可以优化代码内联最无用".

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

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

到目前为止答案中提到的功能/原则

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

Z b*_*son 34

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

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

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

Core2-Ivy Bridge             32 bytes/cycle
Haswell                      64 bytes/cycle
Run Code Online (Sandbox Code Playgroud)

这是您应该针对小型阵列的基准.对于我的测试,我假设数组与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%
Run Code Online (Sandbox Code Playgroud)

asmlibAgner Fog的asmlib.的copy_unroll1copy_unroll8功能定义如下.

从这个表中我们可以看到GCC内置memcpy在Core2上不能很好地工作,而memcpy在EGLIBC中在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)]);
}
Run Code Online (Sandbox Code Playgroud)

}

其中VECNF().LOAD_mm_load_ps()为SSE或_mm256_load_ps()用于AVX,VECNF().STORE_mm_store_ps()为SSE或_mm256_store_ps()用于AVX,和JUMP是4 SSE或8 AVX.

对于大尺寸,通过使用非临时存储指令和使用多个线程来获得最佳结果.与许多人可能认为相反,单个线程通常不会使内存带宽饱和.

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);
    }
}
Run Code Online (Sandbox Code Playgroud)

哪里stream_mm_stream_ps()上证所或 _mm256_stream_ps()对AVX

以下是memcpy我的E5-1620@3.6 GHz上的结果,其中四个线程为1 GB,最大主内存带宽为51.2 GB/s.

                         GB/s     efficiency
    eglibc:              23.6     46%
    asmlib:              36.7     72%
    copy_stream:         36.7     72%
Run Code Online (Sandbox Code Playgroud)

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

我修改了eglibc和这些asmlib 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);
    }
}
Run Code Online (Sandbox Code Playgroud)

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

更新

根据Stephen Canon的评论我已经了解到,在Ivy Bridge和Haswell上使用它rep movsb比使用movntdqa(非临时存储指令)更有效.英特尔称之为增强型rep movsb(ERMSB).这在3.7.6增强型REP MOVSB和STOSB操作(ERMSB)一节中的英特尔优化手册中有所描述.

此外,在第17.9节" 移动数据块(所有处理器)"中的Agner Fog的装配手册中的优化子程序中,他写道:

"有几种方法可以移动大块数据.最常见的方法是:

  1. REP MOVS指令.
  2. 如果数据是对齐的:在具有最大可用寄存器大小的循环中进行读写.
  3. 如果大小不变:内联移动指令.
  4. 如果数据未对齐:首先移动所需的字节数以使目标对齐.然后读取未对齐并在具有最大可用寄存器大小的循环中进行对齐.
  5. 如果数据未对齐:读取对齐,移位以补偿未对齐并写入对齐.
  6. 如果数据大小太大而无法进行缓存,请使用非临时写入来绕过缓存.如有必要,转移以补偿不对中."

将军memcpy应该考虑这些要点.此外,对于Ivy Bridge和Haswell来说,对于大型阵列来说,点1似乎优于点6.英特尔和AMD以及每次技术迭代都需要不同的技术.我认为编写自己的通用高效memcpy函数显然非常复杂.但是在我看过的特殊情况下,我已经设法做得比GCC内置memcpy或EGLIBC更好,所以假设你不能做得比标准库更好是不正确的.

  • 是的,`rep movsb`在Ivybridge和Haswell上流式传输到内存时明显比`movntdqa`快得多(但要注意前Ivybridge它很慢!) (6认同)

Max*_*tin 5

受益于 ERMSB

\n\n

对于较大的块,还请考虑使用 REP MOVSB。

\n\n

如您所知,自 1993 年生产第一颗 Pentium CPU 以来,Intel 开始使简单命令更快,而复杂命令(如 REP MOVSB)更慢。因此,REP MOVSB 变得非常慢,并且没有更多理由使用它。2013年,英特尔决定重新审视REP MOVSB。如果 CPU 具有 CPUID ERMSB(增强型 REP MOVSB)位,则 REP MOVSB 命令的执行方式与旧处理器上不同,并且应该很快。在实践中,它仅对于 256 字节及更大的大块来说才快,并且仅当满足某些条件时:

\n\n
    \n
  • 源地址和目标地址都必须与 16 字节边界对齐;
  • \n
  • 源区域不应与目标区域重叠;
  • \n
  • 长度必须是 64 的倍数才能产生更高的性能;
  • \n
  • 方向必须向前(CLD)。
  • \n
\n\n

请参阅英特尔优化手册,第 3.7.6 节增强型 REP MOVSB 和 STOSB 操作 (ERMSB) http://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia- 32-架构-优化-手册.pdf

\n\n

Intel 建议对小于 2048 字节的块使用 AVX。对于较大的块,英特尔建议使用 REP MOVSB。这是因为 REP MOVSB 的初始启动成本较高(约 35 个周期)。

\n\n

我做过速度测试,对于大于 2048 字节及以上的块,REP MOVSB 的性能是无与伦比的。然而,对于小于 256 字节的块,REP MOVSB 非常慢,甚至比循环中来回的普通 MOV RAX 还要慢。

\n\n

请注意,ERMSB 只影响 MOVSB,而不影响 MOVSD (MOVSQ),因此 MOVSB 比 MOVSD (MOVSQ) 快一点。

\n\n

因此,您可以使用 AVX 进行 memcpy() 实现,如果块大于 2048 字节并且满足所有条件,则调用 REP MOVSB - 因此您的 memcpy() 实现将是无与伦比的。

\n\n

利用无序执行引擎的优势

\n\n

您还可以阅读“Intel\xc2\xae 64 和 IA-32 架构优化参考手册”\n http://www.intel.com/content/dam/www/中有关乱序执行引擎的内容public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf第 2.1.2 节,并从中受益。

\n\n

例如,在Intel SkyLake处理器系列(2015年推出)中,它具有:

\n\n
    \n
  • 算术逻辑单元 (ALU) 的 4 个执行单元(add、and、cmp、or、test、xor、movzx、movsx、mov、(v)movdqu、(v)movdqa、(v)movap*、(v)movup ),
  • \n
  • Vector ALU 的 3 个执行单元( (v)pand、(v)por、(v)pxor、(v)movq、(v)movq、(v)movap*、(v)movup*、(v)andp*、 (v)orp*、(v)paddb/w/d/q、(v)blendv*、(v)blendp*、(v)pblendd)
  • \n
\n\n

因此,如果我们使用仅寄存器操作,我们可以并行占用上述单元(3+4)。我们不能并行使用3+4条指令进行内存复制。即使我们使用一级缓存,我们也可以同时使用最多两条 32 字节指令从内存加载,并使用一条 32 字节指令从内存存储。

\n\n

请再次参阅英特尔手册,了解如何实现最快的 memcpy 实现:http://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures -优化手册.pdf

\n\n

第 2.2.2 节(Haswell 微架构的乱序引擎):“调度程序控制微操作到调度端口的调度。有八个调度端口支持乱序执行核心。四个八个端口中的一个为计算操作提供了执行资源。另外 4 个端口支持在一个周期内最多进行两个 256 位加载操作和一个 256 位存储操作的内存操作。

\n\n

第 2.2.4 节(缓存和内存子系统)有以下注释:“第一级数据缓存每个周期支持两个加载微操作;每个微操作最多可以获取 32 字节的数据。”

\n\n

第 2.2.4.1 节(加载和存储操作增强)包含以下信息: L1 数据缓存每个周期可以处理两个 256 位(32 字节)加载操作和一个 256 位(32 字节)存储操作。统一的 L2 每个周期可以服务一个高速缓存行(64 字节)。此外,还有 72 个加载缓冲区和 42 个存储缓冲区可用于支持微操作的动态执行。

\n\n

其他部分(2.3等,专门讨论Sandy Bridge和其他微架构)基本上重申了上述信息。

\n\n

2.3.4 节(执行核心)提供了更多详细信息。

\n\n

调度器每个周期最多可以调度 6 个微操作,每个端口一个。下表总结了可以在哪个端口上调度哪些操作。

\n\n
    \n
  • 端口 0:ALU、Shift、Mul、STTNI、Int-Div、128b-Mov、Blend、256b-Mov
  • \n
  • 端口 1:ALU、快速 LEA、慢速 LEA、MUL、Shuf、混合、128bMov、添加、CVT
  • \n
  • 端口 2 和端口 3:Load_Addr、Store_addr
  • \n
  • 端口4:存储数据
  • \n
  • 端口 5:ALU、移位、分支、快速 LEA、Shuf、混合、128b-Mov、256b-Mov
  • \n
\n\n

第 2.3.5.1 节(加载和存储操作概述)以及第 2.4.4.1 节(加载和存储)也可能有助于理解如何进行快速内存复制。

\n\n

对于其他处理器架构,同样是两个加载单元和一个存储单元。表 2-4(Skylake 微架构的缓存参数)包含以下信息:

\n\n

峰值带宽(字节/周期):

\n\n
    \n
  • 一级数据缓存:96字节(2x32B加载+1*32B存储)
  • \n
  • 二级缓存:64字节
  • \n
  • 三级缓存:32字节。
  • \n
\n\n

我还在我的 Intel Core i5 6600 CPU(Skylake,14nm,2015 年 9 月发布)和 DDR4 内存上进行了速度测试,这也证实了这个理论。例如,我的测试表明,使用通用 64 位寄存器进行内存复制,甚至并行使用许多寄存器,都会降低性能。另外,仅使用 2 个 XMM 寄存器就足够了 - 添加第 3 个并不会提高性能。

\n\n

如果您的 CPU 有 AVX CPUID 位,您可以利用大型 256 位(32 字节)YMM 寄存器来复制内存,以占用两个完整加载单元。AVX 支持首先由 Intel 在 Sandy Bridge 处理器中引入,并于 2011 年第一季度发货,随后由 AMD 在 Bulldozer 处理器中于 2011 年第三季度发货。

\n\n
// first cycle  \nvmovdqa ymm0, ymmword ptr [rcx+0]      // load 1st 32-byte part using first load unit\nvmovdqa ymm1, ymmword ptr [rcx+20h]    // load 2nd 32-byte part using second load unit\n\n// second cycle\nvmovdqa ymmword ptr [rdx+0], ymm0      // store 1st 32-byte part using the single store unit\n\n// third cycle\nvmovdqa ymmword ptr [rdx+20h], ymm1    ; store 2nd 32-byte part - using the single store unit (this instruction will require a separate cycle since there is only one store unit, and we cannot do two stores in a single cycle)\n\nadd ecx, 40h // these instructions will be used by a different unit since they don\'t invoke load or store, so they won\'t require a new cycle\nadd edx, 40h\n
Run Code Online (Sandbox Code Playgroud)\n\n

此外,如果您循环展开此代码至少 8 次,还会带来速度优势。正如我之前所写,除了 ymm0 和 ymm1 之外添加更多寄存器并不会提高性能,因为只有两个加载单元和一个存储单元。添加诸如“dec r9 jnz @@again”之类的循环会降低性能,但简单的“add ecx/edx”则不会。

\n\n

最后,如果你的CPU有AVX-512扩展,你可以使用512位(64字节)寄存器来复制内存:

\n\n
vmovdqu64   zmm0, [rcx+0]           ; load 1st 64-byte part\nvmovdqu64   zmm1, [rcx+40h]         ; load 2nd 64-byte part \n\nvmovdqu64   [rdx+0], zmm0           ; store 1st 64-byte part\nvmovdqu64   [rdx+40h], zmm1         ; store 2nd 64-byte part \n\nadd     rcx, 80h\nadd     rdx, 80h    \n
Run Code Online (Sandbox Code Playgroud)\n\n

以下处理器支持 AVX-512: Xeon Phi x200,2016 年发布;Skylake EP/EX Xeon“Purley”(Xeon E5-26xx V5) 处理器(2017 年下半年);Cannonlake 处理器(2017 年下半年)、Skylake-X 处理器 - Core i9-7\xc3\x97\xc3\x97\xc3\x97X、i7-7\xc3\x97\xc3\x97\xc3\x97X、i5-7\xc3 \x97\xc3\x97\xc3\x97X - 于 2017 年 6 月发布。

\n\n

请注意,内存必须与您正在使用的寄存器的大小对齐。如果不是,请使用“未对齐”指令:vmovdqu 和 moveups。

\n

  • @MaximMasiutin - 您尝试混合 SSE 和 64 位 `mov` 指令不起作用,因为 ALU 不执行加载。即使是最先进的 x86 CPU 也只有两个负载单元,因此每个周期最多可以发出两个负载。所有大小(8 位、16 位、32 位、...、256)的负载都会进入这些单元,因此您通常只想使用可用于大部分副本的最大负载。 (2认同)
  • @BeeOnRope,非常感谢您指出这一点。我已经重写了相关部分。再次感谢你。 (2认同)

Bee*_*ope 5

如果没有一些额外的细节,这个问题无法准确回答,例如:

  • 什么是目标平台(CPU架构,大多数,但内存配置也起作用)?
  • 复制长度的分布和可预测性1是什么(在较小程度上,比对的分布和可预测性)?
  • 复制大小是否会在编译时静态知道?

尽管如此,我仍然可以指出一些对于上述参数的至少一些组合可能是次优的事情.

32个案例的Switch语句

32个案例的switch语句是处理0到31个字节的可爱方式,并且很可能是基准测试 - 但由于至少有两个因素,可能在现实世界中表现不佳.

代码大小

除了需要跳转到每个长度的正确位置所需的32项查找表之外,此switch语句单独需要几百字节的代码.这样做的成本不会出现在memcpy全尺寸CPU 的集中基准测试中,因为所有内容仍然适用于最快的缓存级别:但在现实世界中,您也执行其他代码并且存在争用uop缓存的问题和L1数据和指令缓存.

许多指令可能占用uop缓存3的有效大小的20%,并且uop缓存未命中(以及相应的缓存到传统编码器转换周期)可以轻松地消除这个精心设计的交换机给出的小优势.

最重要的是,交换机需要一个32项,256字节的查找表用于跳转目标4.如果你在查找中错过了DRAM,那么你正在谈论150多个周期的惩罚:你需要多少次非失误才能让它switch值得,因为它最多可能节省几个或两个?同样,这不会出现在微基准测试中.

对于它的价值,这memcpy并不罕见:即使在优化的库中,这种"详尽的案例枚举"也很常见.我可以得出结论,要么它们的开发主要是由微基准测试驱动的,要么它仍然值得为大量的通用代码,尽管有缺点.也就是说,确实有一些情况(指令和/或数据缓存压力),这是不理想的.

分支预测

switch语句依赖于单个间接分支来在备选方案中进行选择.这在分支预测器可以预测这种间接分支的程度上是有效的,这基本上意味着观察到的长度序列需要是可预测的.

因为它是间接分支,所以对分支的可预测性的限制比条件分支更多,因为存在有限数量的BTB条目.最近的CPU已经取得了长足的进步,但是可以肯定地说,如果馈送的一系列长度memcpy不遵循短周期的简单重复模式(在较旧的CPU上短至1或2),则会有一个分支 - 每次通话预测.

这个问题特别阴险,因为在微基准测试显示switch最佳的情况下,它可能会在现实世界中给您带来最大的伤害:短的长度.对于很长的长度,尾随31字节的行为不是很重要,因为它由批量复制支配.对于较短的长度,这switch是非常重要的(事实上,对于31个字节或更少的副本,它就是所有执行的)!

对于这些短的长度,可预测的一系列长度非常适用于switch间接跳跃基本上是免费的.特别是,典型的memcpy基准测试"扫描"一系列长度,每个子测试重复使用相同的长度来报告结果,以便于"时间与长度"图形的绘图.在switch这些测试确实很大,常常像报告2次或3次的几个字节的小段结果.

在现实世界中,你的长度可能很小但不可预测.在这种情况下,间接分支将经常错误预测5,在现代CPU上惩罚约20个周期.与几个周期的最佳情况相比,它更糟糕一个数量级.因此,这里的玻璃钳口可能非常严重(即,switch在这种典型情况下的行为可能比最佳情况差一个数量级,而在长距离情况下,您通常会看到不同之间的差异为50%策略).

解决方案

那你怎么能比上面做的更好,至少在switch分崩离析的条件下呢?

使用Duff的设备

代码大小问题的一个解决方案是将开关案例组合在一起,duff的设备风格.

例如,长度为1,3和7的组合代码如下所示:

长度1

    movzx   edx, BYTE PTR [rsi]
    mov     BYTE PTR [rcx], dl
    ret
Run Code Online (Sandbox Code Playgroud)

长度3

    movzx   edx, BYTE PTR [rsi]
    mov     BYTE PTR [rcx], dl
    movzx   edx, WORD PTR [rsi+1]
    mov     WORD PTR [rcx+1], dx
Run Code Online (Sandbox Code Playgroud)

长度7

    movzx   edx, BYTE PTR [rsi]
    mov     BYTE PTR [rcx], dl
    movzx   edx, WORD PTR [rsi+1]
    mov     WORD PTR [rcx+1], dx
    mov     edx, DWORD PTR [rsi+3]
    mov     DWORD PTR [rcx+3], edx
    ret
Run Code Online (Sandbox Code Playgroud)

这可以组合成一个单独的案例,有各种跳转:

    len7:
    mov     edx, DWORD PTR [rsi-6]
    mov     DWORD PTR [rcx-6], edx
    len3:
    movzx   edx, WORD PTR [rsi-2]
    mov     WORD PTR [rcx-2], dx
    len1:
    movzx   edx, BYTE PTR [rsi]
    mov     BYTE PTR [rcx], dl
    ret
Run Code Online (Sandbox Code Playgroud)

标签不需要任何费用,它们将这些案例组合在一起并删除3 ret条指令中的两条.请注意,这里的基础rsircx更改:它们指向要复制的最后一个字节,而不是第一个.根据跳转前的代码,这种变化是免费的或非常便宜的.

您可以将其延长更长的长度(例如,您可以将长度15和31连接到上面的链),并使用其他链来查找缺失的长度.完整的练习留给读者.你可以通过这种方法单独减少50%的尺寸,如果你把它与其他东西结合起来可以更好地将尺寸从16 - 31缩小.

这种方法只对代码大小(以及可能的跳转表大小有帮助,如果你缩小4中描述的大小,你得到256字节以下,允许一个字节大小的查找表.它没有任何可预测性.

重叠商店

有助于代码大小和可预测性的一个技巧是使用重叠存储.也就是说,memcpy8到15个字节可以以无分支方式实现,具有两个8字节存储,第二个存储部分地与第一个存储重叠.例如,要复制11个字节,你会在相对位置做一个8个字节的副本011 - 8 == 3.中间的一些字节将被"复制两次",但实际上这很好,因为8字节的复制速度与1,2或4字节的速度相同.

C代码看起来像:

  if (Size >= 8) {
    *((uint64_t*)Dst) = *((const uint64_t*)Src);
    size_t offset = Size & 0x7;
    *(uint64_t *)(Dst + offset) = *(const uint64_t *)(Src + offset);
  }
Run Code Online (Sandbox Code Playgroud)

......并且相应的组件没有问题:

    cmp     rdx, 7
    jbe     .L8
    mov     rcx, QWORD PTR [rsi]
    and     edx, 7
    mov     QWORD PTR [rdi], rcx
    mov     rcx, QWORD PTR [rsi+rdx]
    mov     QWORD PTR [rdi+rdx], rcx
Run Code Online (Sandbox Code Playgroud)

特别要注意的是,你得到完全两个负载,两家店和一个and(除了cmpjmp它的存在取决于你如何组织周围的代码).这已经与大多数编译器生成的8-15字节方法相关或更好,这可能最多使用4个加载/存储对.

较旧的处理器在这种"重叠商店"中遭受了一些惩罚,但是较新的架构(至少在过去十年左右)似乎处理它们而没有受到惩罚6.这有两个主要优点:

  1. 对于各种大小,该行为是无分支的.实际上,这会对分支进行量化,以便许多值采用相同的路径.所有尺寸从8到15(如果你想要的话,8到16)采用相同的路径,不会产生错误的预测压力.

  2. 将至少8或9个不同的情况switch包含在单个案例中,其中包含总代码大小的一小部分.

这种方法可以与switch方法结合使用,但只使用少数几种情况,或者可以通过条件移动扩展到更大的大小,例如,所有移动都可以从8到31字节不带分支.

最佳效果取决于分支分布,但总体而言,这种"重叠"技术非常有效.

对准

现有代码不涉及对齐.

事实上,它通常不是合法的或C或C++,因为char *指针只是被转换为更大的类型并且被解除引用,这是不合法的 - 尽管在实践中它生成的代码可以在今天的x86编译器上运行(但事实上对于具有更严格对齐要求的平台会失败).

除此之外,通常更好地专门处理对齐.主要有三种情况:

  1. 源和目标已经对齐.即使是原始算法也可以正常工作.
  2. 源和目标相对对齐,但绝对未对齐.也就是说,有一个值A可以添加到源和目标,以便两者都对齐.
  3. 源和目标完全未对齐(即,它们实际上没有对齐,情况(2)不适用).

在案例(1)中,现有算法可以正常工作.在(2)的情况下可能缺少大的优化,因为小的介绍循环可以将未对齐的副本转换为对齐的副本.

在情况(3)中它也可能表现不佳,因为通常在完全未对准的情况下,您可以选择对齐目的地或源,然后继续"半对齐".

对齐惩罚随着时间的推移变得越来越小,并且最新的芯片对于通用代码而言是适度的,但对于具有许多负载和存储的代码而言仍然是严重的.对于大型副本,它可能并不重要,因为您将最终限制DRAM带宽,但对于较小的副本,未对准可能会使吞吐量降低50%或更多.

如果使用NT存储,则对齐也很重要,因为许多NT存储指令在未对齐的参数中表现不佳.

没有展开

默认情况下,代码未展开,编译器以不同的数量展开.显然这不是最理想的,因为在两个具有不同展开策略的编译器中,最多只有一个是最好的.

最佳方法(至少对于已知平台目标)确定哪个展开因子最佳,然后将其应用于代码中.

此外,展开通常可以通过"介绍"我们的"outro"代码以智能方式组合,比编译器做得更好.

已知尺寸

memcpy使用现代编译器很难击败"内置" 例程的主要原因是编译器不只是在源中出现memcpy时调用库memcpy.他们知道合同memcpy并且可以在一个内联指令中自由地实现它,在正确的场景中甚至可以更少7个.

对于已知的长度,这尤其明显memcpy.在这种情况下,如果长度很小,编译器将只插入一些指令来有效地和就地执行复制.这不仅避免了函数调用的开销,而且还避免了所有关于大小等的检查 - 并且还在编译时生成了复制的高效代码,就像switch上面实现中的大部分一样- 但是没有成本switch.

类似地,编译器知道很多关于调用代码中结构的对齐,并且可以创建有效处理对齐的代码.

如果你只是实现一个memcpy2库函数,那很难复制.你可以得到的方式出现我的拆分方法到部分大的部分:部分出现在头文件中,并做了一些大小检查,并可能只是调用现有memcpy如果尺寸小或委托给库例程如果它很大 通过内联的魔力,你可能会进入内置的同一个地方memcpy.

最后,您还可以尝试使用__builtin_constant_p等效技巧来有效地处理小型的已知案例.


1请注意,我在这里区分大小的"分布" - 例如,您可能会说_在8到24个字节之间均匀分布 - 以及实际大小序列的"可预测性"(例如,大小是否具有可预测的模式)?可预测性的问题有些微妙,因为它取决于实现,因为如上所述某些实现本质上更可预测.

2特别是,仅在主体上有大约750字节的指令clang和大约600 字节的指令,gcc在交换机主体的256字节跳转查找表的顶部,其具有180-250指令(gccclang分别).Godbolt链接.

3基本上200个融合的uop,有效的uop缓存大小为1000条指令.虽然最近x86的uop缓存大小约为1500微秒,但由于代码到缓存的分配规则限制,你不能在代码库的极其专用的填充之外使用它.

4开关盒具有不同的编译长度,因此无法直接计算跳转.对于它的价值,它可能是以不同的方式完成的:它们可能在查找表中使用了16位值,代价是不使用内存源jmp,将其大小减小了75%.

5与条件分支预测不同,条件分支预测具有~50%的典型最坏情况预测率(对于完全随机分支),难以预测的间接分支可以轻松接近100%,因为您没有翻转硬币,您是选择几乎无限的分支目标.这种情况发生在现实世界中:如果memcpy用于复制长度均匀分布在0到30之间的小字符串,switch代码将错误地预测~97%的时间.

6当然,对于未对齐的商店可能会受到处罚,但这些商店通常也很小并且变得越来越小.

7例如,一个memcpy堆栈,然后是一些操作和其他地方的副本可能完全消除,直接将原始数据移动到其最终位置.即使是malloc跟随的事情memcpy也可以完全消除.

  • @einpoklum - 最近的英特尔芯片可以从一个内核驱动大约30 GB/s,许多芯片的BW大概都是这么多.具有四通道内存的较大部件肯定需要多个内核.基本上,你可以从一个核心击中你的完整BW,你绝对想要NT商店.如果你不能,你可能会发现正常的商店更快(但只有一个核心,一旦你去更多核心,NT最终会赢,因为它节省了带宽). (3认同)
  • @MaximMasiutin - 你的"跳跃链"它可能比间接跳跃方法更糟糕.基本上你必须查看每个序列的_predictability_.一般来说,当序列不可预测时,你的序列将是不可预测的,否则就好了 - 就像间接跳转一样.错误预测的分支大致同样差,无论是否是间接分支,因此通常不会通过将其更改为一系列条件分支来获得预测.你失去了一堆:更多的指令,一次复制一个字节,消耗更多的分支预测资源等. (2认同)