3

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

4

3 回答 3

3

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

于 2016-09-18T17:01:59.583 回答
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 内核或其他东西中运行此代码,请查看您可以访问的普通内存中是否已经有一个副本。)

于 2016-09-18T17:45:37.823 回答
2

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

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

在 USWC 视频内存上,您可以从 MOVNTDQA 获得很大的加速。请参阅英特尔的文章,以及我对 NT 负载的一些回答:在这里,尤其是在这篇文章中,我解释了 x86 ISA 手册中关于 NT 负载不覆盖内存排序语义的说法,因此它们不是弱排序的,除非你使用它们弱有序的内存区域。


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


您应该使用 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]) 中。但是索引寻址模式至少不能在某些 SnB 系列 CPU 上进行微融合,因此您可能希望展开,add rdi, 32add rdx, 16不是使用相对于目标寻址源的技巧。

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


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

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


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

memory-dest 功能甚至通过 C/C++ 内在函数公开,从而可以方便地在 C 中编写掩码存储。(这是一个很好的变化,从pmovzx不方便使用内在函数并使编译器发出pmovzx负载的地方)。

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

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

于 2016-09-18T21:23:19.957 回答