您真的在 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 个输入并产生更窄的结果(可以是内存目标)。 (vpmovswb 和vpmovuswb 是相似的,并且包装有符号或无符号饱和度。所有与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,尽管这是可能的)。