可以跳过每 2 个字节的 SSE mov 指令?

SSE mov instruction that can skip every 2nd byte?

我需要将所有奇数字节从一个内存位置复制到另一个内存位置。即复制第一、第三、第五等。具体来说,我是从包含 2000 character/attribute 个单词的文本区域 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 个字节,这将是可能的。

我会做这样的事情,将 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 - 如果您真的想在汇编程序中执行此操作,那么您只需将上面的每个内在函数转换为相应的指令即可。

我根本不会使用 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 内核或其他东西中,请查看普通内存中是否已经有您可以访问的副本。)

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

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

在 USWC 视频内存上,您可以从 MOVNTDQA 获得很大的加速。请参阅 Intel's article, and a couple of my answers about NT loads: here and especially 我在其中解释了 x86 ISA 手册关于 NT 加载不覆盖内存排序语义的内容,因此除非您在弱排序的内存区域上使用它们,否则它们不是弱排序的。


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


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

这是一个真实的(未经测试的)循环,它对齐源指针以最小化跨越缓存行边界的惩罚,并使用一些寻址模式技巧在循环中保存指令 .

未对齐的加载对 Nehalem 和更高版本的影响很小,主要是当它们跨越缓存行边界时的额外延迟。所以如果你想从视频内存中使用 NT 加载,这将非常有用。或者,如果您在大副本末尾阅读超出 src 末尾的内容,这可能很有用。

我们的加载量是存储的两倍,因此如果 load/store 吞吐量是一个问题,对齐加载(而不是对齐存储)可能是最佳的。然而,有太多的 ALU 工作使缓存 load/store 吞吐量饱和,因此 保持简单的未对齐负载(如 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,而不是使用相对于目标寻址源的技巧。

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


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

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)), which packs with truncation instead of saturation. Unlike the SSE pack instructions, it takes only 1 input and produces a narrower result (which can be a memory destination). (vpmovswb and vpmovuswb are similar, and pack with signed or unsigned saturation. All the same size combos as pmovzx are available, e.g. vpmovqb xmm1/m64 {k1}{z}, zmm2,所以你不需要多个步骤。 Q 和 D 源大小在 AVX512F 中)。

memory-dest 功能甚至通过 C/C++ 内在函数公开,从而可以方便地在 C 中编写屏蔽存储代码。(这是对 的一个很好的更改)。

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

如果 VPERM2TB 比 VPMOVWB 慢,一次对一个向量使用 VPMOVWB 可能是最好的。即使它们具有相同的 throughput/latency/uop-count,增益也可能很小,以至于不值得制作另一个版本并检测 AVX512VBMI 而不是 AVX512BW。 (CPU 不太可能在没有 AVX512BW 的情况下拥有 AVX512VBMI,尽管这是可能的)。