【问题标题】:SSE mov instruction that can skip every 2nd byte?可以跳过每第二个字节的 SSE mov 指令?
【发布时间】:2017-01-26 08:20:12
【问题描述】:

我需要将所有奇数字节从一个内存位置复制到另一个。即复制第一个、第三个、第五个等。具体来说,我从包含 2000 个字符/属性词的文本区域 0xB8000 复制。我想跳过属性字节并以字符结尾。以下代码工作正常:

      mov eax, ecx                       ; eax = number of bytes (1 to 2000)
      mov rsi, rdi                       ; rsi = source
      mov rdi, CMD_BLOCK                 ; rdi = destination
@@:   movsb                              ; copy 1 byte
      inc rsi                            ; skip the next source byte
      dec eax
      jnz @b    

要复制的数字或字符介于 1 到 2000 之间。我最近开始玩 sse2、sse3 sse4.2 但找不到可以减少循环的指令。理想情况下,我希望将循环从 2000 减少到 250,如果有一条指令可以在一次加载 128 位后跳过每 2 个字节,这将是可能的。

【问题讨论】:

    标签: assembly 64-bit sse2 sse sse4


    【解决方案1】:

    我会做这样的事情,每个处理 32 个输入字节到 16 个输出字节 循环迭代:

    const __m128i vmask = _mm_set1_epi16(0x00ff);
    
    for (i = 0; i < n; i += 16)
    {
        __m128i v0 = _mm_loadu_si128(&a[2 * i]);      // load 2 x 16 input bytes (MOVDQU)
        __m128i v1 = _mm_loadu_si128(&a[2 * i + 16]);
        v0 = _mm_and_si128(v0, vmask);                // mask unwanted bytes     (PAND)
        v1 = _mm_and_si128(v1, vmask);
        __m128 v = _mm_packus_epi16(v0, v1);          // pack low bytes          (PACKUSWB)
        _mm_storeu_si128(v, &b[i];                    // store 16 output bytes   (MOVDQU)
    }
    

    当然,这是带有内在函数的 C - 如果您真的想在汇编程序中执行此操作,那么您可以将上面的每个内在函数转换为相应的指令。

    【讨论】:

    • 是的,正是我的想法。看起来比任何 PSHUFB 组合都好,因为每个结果向量只有一次 shuffle,而且shuffle 的吞吐量低于布尔按位运算。
    • 我认为做打包步骤就足够了。
    • 这正是我希望找到的。非常感谢。
    • @FUZxxl:我认为你需要屏蔽,除非你知道高字节总是零,因为打包操作已经饱和。
    【解决方案2】:

    我根本不会使用 SIMD 指令。我怀疑您能否显着超越 64 位负载的性能,因为视频内存未缓存并且总线不太可能支持更广泛的事务。

    我会使用这样的东西:

         lea rdi, [rdi + rcx * 2 - 8]
    loop:
         mov rax, [rdi]
         mov [CMD_BLOCK + rcx - 4], al
         shr rax, 16
         mov [CMD_BLOCK + rcx - 4 + 1], al
         shr rax, 16
         mov [CMD_BLOCK + rcx - 4 + 2], al
         shr rax, 16
         mov [CMD_BLOCK + rcx - 4 + 3], al
         sub rdi, 8
         sub rcx, 4
         jnz loop
    

    它看起来效率低下,但由于负载 (mov rax,[rdi]) 存在巨大的停滞,其他一切都可以同时发生。

    或者在 C 中:

    void copy_text(void *dest, void *src, int len) {
        unsigned long long *sp = src;
        unsigned char *dp = dest;
        int i;
    
        for(i = 0; i < len; i += 4) {
            unsigned long long a = *sp++;
            *dp++ = (unsigned char) a;
            a >>= 16;
            *dp++ = (unsigned char) a;
            a >>= 16;
            *dp++ = (unsigned char) a;
            a >>= 16;
            *dp++ = (unsigned char) a;
        }
    }      
    

    无论您做什么,代码的性能都将取决于未缓存视频内存读取的成本。这确实是您需要优化的唯一部分。

    此外,如果您执行大量此类读取操作,因此代码的性能实际上很重要,您应该查看是否无法将文本副本保存在正常的缓存内存中。视频内存不是为读取而设计的,所以这应该是最后的手段。 (或者,如果您在 Linux 内核或其他地方运行此代码,请查看普通内存中是否已经存在可以访问的副本。)

    【讨论】:

    • 在(强排序)UC 内存中,您无法像从(弱排序)USWC 中那样获得具有 NT 负载的完整缓存行,但您仍然可以在一次负载中获得 16B,对吧?英特尔有一篇关于使用来自视频内存的 MOVNTDQA 加载的文章:software.intel.com/en-us/articles/…。 (他们使用 NT 存储到 WB 内存,还有一个额外的技巧是使用一个保持缓存的反弹缓冲区将 NT 加载与 NT 存储分开,从而减少部分行填充)。
    • @PeterCordes 嗯...我不知道 MOVNTDQA 指令。它似乎允许处理器忽略内存的 USWC 属性,并立即执行整个高速缓存行加载。对于实际上在系统 RAM 中的视频内存应该是一个胜利(对 DRAM 的一次突发事务),但我不知道它是否会通过 PCI-Express 总线读取有很大的改进。我不确定是否普遍支持由 CPU 发起的大于 64 位的读取。
    • MOVNTDQA 确实 not 覆盖内存排序语义,顺便说一句。 See my answer here。在强序 (WB) 内存上,它仍然是一个强序负载。不过,CPU 可能能够使用 NT 提示做一些事情(例如避免缓存污染),因此它可能仍然有用。我只是猜测,而不是尝试测试,它是如何在具有大型 inclusive L3 缓存标签的现代 Intel 上实现的。
    • @PeterCordes 我不确定您为什么要提出内存排序语义,但我指的是您链接的文章的这一部分:“普通加载指令以单位从 USWC 内存中提取数据与指令请求的大小相同。相比之下,像 MOVNTDQA 这样的流式加载指令通常会将完整的缓存行数据拉到 CPU 中的特殊“填充缓冲区”中。随后的流式加载将从该填充缓冲区中读取,从而产生很多减少延迟”。
    • 您说“忽略 USWC 属性”,我只是想明确说明(对于可能没有看过这篇文章的未来读者)内存类型属性确实会影响MOVNTDQA 可以。但是,是的,它确实会触发数据缓存在“不可缓存”的内存中。 IIRC,它不能从 UC 内存中执行此操作,因为它不是弱排序的,只能来自 USWC 内存,这确实意味着弱排序。在我之前的评论中,我应该将 UC 作为强有序内存的示例。
    【解决方案3】:

    您真的在 x86-64 模式下的 VGA 文本模式视频内存上使用 SIMD 吗?这很有趣,但在现实生活中实际上是合理的,并且可以作为一些 SIMD 数据操作的用例。

    但是,如果您真的是从视频内存中读取数据,那么您可能会执行未缓存的加载,这很糟糕,并且意味着您应该重新设计您的系统,这样您就不必这样做了。 (有关建议,请参阅罗斯的回答)

    在 USWC 视频内存上,您可以从 MOVNTDQA 获得很大的加速。请参阅Intel's article,以及我对 NT 负载的一些回答:here,尤其是this one,我在其中解释了 x86 ISA 手册中关于 NT 负载不会覆盖内存排序语义的内容,因此它们不是弱排序的,除非你可以在弱序内存区域使用它们。


    正如您所怀疑的,您不会在 SIMD 指令集中找到复制指令;您必须在加载和存储之间的寄存器中自己进行数据处理。甚至没有一条 SSE/AVX 指令可以为您执行此操作。 (不过,ARM NEON 的 unzip instruction 确实解决了整个问题)。


    您应该使用 SSE2 PACKUSWB,将(有符号的)int16_t 的两个向量压缩成 uint8_t 的一个向量。将每个字元素的高字节归零后,饱和到 0..255 根本不会修改您的数据。

    这是一个真实的(未经测试的)循环,它对齐源指针以最大限度地减少跨越高速缓存行边界的惩罚,并使用一些寻址模式技巧来保存循环中的指令

    未对齐的负载对 Nehalem 及以后的负载几乎没有影响,主要是当它们越过缓存线边界时会产生额外的延迟。因此,如果您想使用来自视频内存的 NT 加载,这将非常有用。或者,如果您要在大副本末尾阅读超出 src 末尾的内容,这可能会很有用。

    我们执行的加载次数是存储的两倍,因此如果加载/存储吞吐量是一个问题,那么对齐加载(而不是对齐存储)可能是最佳选择。但是,有太多的 ALU 工作会导致缓存加载/存储吞吐量饱和,因此使用未对齐的加载(如 Paul R 的循环)保持简单应该在大多数 CPU 和用例上都能很好地工作

      mov       edx, CMD_BUFFER    ; or RIP-relative LEA, or hopefully this isn't even static in the first place and this instruction is something else
    
      ;; rdi = source   ; yes this is "backwards", but if you already have the src pointer in rdi, don't waste instructions
      ;; rcx = count
      ;; rdx = dest
    
      pcmpeqw   xmm7, xmm7         ; all ones (0xFF repeating)
      psrlw     xmm7, 8            ; 0x00FF repeating: mask for zeroing the high bytes
    
      ;cmp       ecx, 16
      ;jb        fallback_loop     ; just make CMD_BUFFER big enough that it's ok to copy 16 bytes when you only wanted 1.  Assuming the src is also padded at the end so you can read without faulting.
    
      ;; First potentially-unaligned 32B of source data
      ;; After this, we only read 32B chunks of 32B-aligned source that contain at least one valid byte, and thus can't segfault at the end.
      movdqu    xmm0, [rdi]             ; only diff from loop body: addressing mode and unaligned loads
      movdqu    xmm1, [rdi + 16]
      pand      xmm0, xmm7
      pand      xmm1, xmm7
      packuswb  xmm0, xmm1
      movdqu    [rdx], xmm0
    
      ;; advance pointers just to the next src alignment boundary.  src may have different alignment than dst, so we can't just AND both of them
      ;; We can only use aligned loads for the src if it was at least word-aligned on entry, but that should be safe to assume.
      ;; There's probably a way to do this in fewer instructions.
      mov       eax, edi
      add       rdi, 32                ; advance 32B
      and       rdi, -32               ; and round back to an alignment boundary
      sub       eax, edi               ; how far rdi actually advanced
      shr       eax, 1
      add       rdx, rax               ; advance dst by half that.
    
      ;; if rdi was aligned on entry, the it advances by 32 and rdx advances by 16.  If it's guaranteed to always be aligned by 32, then simplify the code by removing this peeled unaligned iteration!
      ;; if not, the first aligned loop iteration will overlap some of the unaligned loads/store, but that's fine.
    
      ;; TODO: fold the above calculations into this other loop setup
    
      lea       rax, [rdx + rdx]
      sub       rdi, rax           ; source = [rdi + 2*rdx], so we can just increment our dst pointer.
    
      lea       rax, [rdx + rcx]   ; rax = end pointer.  Assumes ecx was already zero-extended to 64-bit
    
    
    
      ; jmp      .loop_entry       ; another way to check if we're already done
      ; Without it, we don't check for loop exit until we've already copied 64B of input to 32B of output.
      ; If small inputs are common, checking after the first unaligned vectors does make sense, unless leaving it out makes the branch more predictable.  (All sizes up to 32B have identical branch-not-taken behaviour).
    
    ALIGN 16
    .pack_loop:
    
      ; Use SSE4.1  movntdqa  if reading from video RAM or other UCSW memory region
      movdqa    xmm0, [rdi + 2*rdx]         ; indexed addressing mode is ok: doesn't need to micro-fuse because loads are already a single uop
      movdqa    xmm1, [rdi + 2*rdx + 16]    ; these could optionally be movntdqa loads, since we got any unaligned source data out of the way.
      pand      xmm0, xmm7
      pand      xmm1, xmm7
      packuswb  xmm0, xmm1
      movdqa    [rdx], xmm0        ; non-indexed addressing mode: can micro-fuse
      add       rdx, 16
    .loop_entry:
      cmp       rdx, rax
      jb        .pack_loop         ; exactly 8 uops: should run at 1 iteration per 2 clocks
    
      ;; copies up to 15 bytes beyond the requested amount, depending on source alignment.
    
      ret
    

    使用 AVX 的非破坏性第三操作数编码,负载可以折叠到 PAND 中 (vpand xmm0, xmm7, [rdi + 2*rdx])。但是indexed addressing modes can't micro-fuse on at least some SnB-family CPUs,所以您可能想要展开和add rdi, 32 以及add rdx, 16,而不是使用相对于目标来寻址源的技巧。

    对于 2xload+and/pack/store,AVX 会将循环体减少到 4 个融合域微指令,加上循环开销。通过展开,我们可以开始接近 Intel Haswell 的理论最大吞吐量,即每个时钟 2 个负载 + 1 个存储(尽管它无法维持这一点;存储地址 uops 有时会窃取 p23 周期而不是使用 p7。英特尔的优化手册提供了一个真实的- 假设所有 L1 缓存命中率低于 96B 峰值吞吐量,每个时钟加载和存储约 84B 的世界可持续吞吐量数(使用 32 字节向量)。)


    您还可以使用字节洗牌 (SSSE3 PSHUFB) 将向量的偶数字节打包到低 64 位中。 (然后为每个 128 位加载执行一个 64 位 MOVQ 存储,或将两个下半部分与 PUNPCKLQDQ 组合)。但这很糟糕,因为(每个 128 位源数据向量)它是 2 次随机播放 + 2 次存储,或 3 次随机播放 + 1 次存储。您可以通过使用不同的 shuffle 掩码来降低合并成本,例如将偶数字节洗牌到一个向量的低半部分和另一个向量的上半部分。由于 PSHUFB 还可以免费将任何字节归零,因此您可以与 POR 结合使用(而不是稍微昂贵的 PBLENDW 或 AVX2 VPBLENDD)。这是 2 个 shuffle + 1 boolean + 1 store,仍然是 shuffle 的瓶颈。

    PACKUSWB 方法是 2 个布尔运算 + 1 个随机播放 + 1 个存储(瓶颈较小,因为 PAND 可以在更多执行端口上运行;例如,每个时钟 3 个,而随机播放每个时钟 1 个)。


    AVX512BW(在Skylake-avx512 but not on KNL 上可用)提供
    VPMOVWB ymm1/m256 {k1}{z}, zmm2 (__m256i _mm512_cvtepi16_epi8 (__m512i a)),它使用截断而不是饱和。与 SSE 打包指令不同,它只需要 1 个输入并产生更窄的结果(可以是内存目标)。 (vpmovswbvpmovuswb 是相似的,并且包装有符号或无符号饱和度。所有与pmovzx 相同大小的组合都可用,例如vpmovqb xmm1/m64 {k1}{z}, zmm2,因此您不需要多个步骤。Q 和 D 源尺寸为 AVX512F)。

    memory-dest 功能甚至通过 C/C++ 内部函数公开,从而可以方便地在 C 中编写掩码存储。(这是对 pmovzx where it's inconvenient to use intrinsics and get the compiler to emit a pmovzx load 的一个很好的更改。

    AVX512VBMI(预计在 Intel Cannonlake 中)可以使用一个 VPERMT2B 对一个 512b 输出进行两个输入,给定一个随机掩码,该掩码从两个输入中获取偶数字节向量并生成单个结果向量。

    如果 VPERM2TB 比 VPMOVWB 慢,则一次对一个向量使用 VPMOVWB 可能是最好的。即使它们具有相同的吞吐量/延迟/uop-count,增益也可能非常小,以至于不值得制作另一个版本并检测 AVX512VBMI 而不是 AVX512BW。 (CPU 不可能有 AVX512VBMI 而没有 AVX512BW,尽管这是可能的)。

    【讨论】:

    • @poby:酷。我也不喜欢低效的代码。但由于循环的性能并不重要,在这种情况下,整体性能的最佳选择可能是保持代码大小较小,以减少指令缓存驱逐。所以也许总是使用未对齐的加载/存储,特别是如果您不需要避免阅读结束。或者甚至像罗斯建议的那样做标量。 (不过,可能会在寄存器中组合一些字节以获得更广泛的存储。)
    • @poby:回复:视频内存。 IDK,但如果它在视频卡上;数百或数千倍的延迟,因为它不能只在 L1 缓存中命中。我认为吞吐量可以如果您进行广泛读取,特别是如果您使用 MOVNTDQA 来获得完整的缓存行传输。如果它在主内存中(即使用物理连接到 CPU 的内存的集成显卡),那么它可能仍被标记为不可缓存。如果您使用 SSE4.1 NT 负载进行读取,则延迟比普通 WriteBack 内存区域差数百倍,但吞吐量与普通内存相同。
    • @poby:见Agner Fog's microarch pdf。我链接的微融合 SO 问题确实间接链接到那里。当具有内存源操作数的 ALU 指令在 Intel 硬件上解码为单个微融合微指令时,而不是即使在保留站之外(未融合域微指令调度程序)也不是两个单独的微指令。
    • IMO 如果您没有专门针对现有 CPU 进行调优,那么在汇编中编写是没有意义的。否则,不妨使用 C(如果它不自动矢量化,则使用 SIMD 内在函数)并让编译器来做。但显然你必须从某个地方开始学习,这就是为什么我在这个答案中评论高级的东西以及基本的东西。但是,一旦您知道如何进行基本操作,这就是您在手动选择指令时应该考虑的事情。
    • @poby:我强烈建议您通读 Agner Fog 的优化装配指南。你会学到一个关于什么是真正有效的知识,以及做很多事情的好习惯。它写得很好,清晰,易于阅读,有很好的例子。不过,该 PDF 中的一些建议有些过时,并不严格适用于英特尔 Sandybridge 或特别是 Haswell 和更高版本的 CPU。 (例如,部分寄存器写入不会在 Haswell 及更高版本上产生任何合并停顿。)Agner 大多只花时间为新 CPU 更新 microarch pdf,而不是在其他指南中重写太多。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2011-02-07
    • 1970-01-01
    • 2015-06-03
    • 1970-01-01
    • 2013-04-24
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多