【问题标题】:Efficiently shift-or large bit vector高效移位或大位向量
【发布时间】:2021-12-30 18:18:13
【问题描述】:

我有一个大的内存数组作为指针uint64_t * arr(加上大小),它代表普通位。我需要非常有效地(最高性能/最快)将这些位从 0 向右移动到 63。

通过移动整个数组,我的意思是不移动每个元素(如a[i] <<= Shift),而是将其作为单个大位向量进行移动。换句话说,对于每个中间位置i(第一个和最后一个元素除外),我可以在循环中执行以下操作:

dst[i] = w | (src[i] << Shift);
w = src[i] >> (64 - Shift);

其中w 是一些临时变量,保存前一个数组元素的右移值。

上面的这个解决方案简单明了。但我需要更高效的东西,因为我有千兆字节的数据。

理想情况下会为此使用一些 SIMD 指令,因此我正在寻找专家的 SIMD 建议。我需要为所有四种流行指令集 - SSE-SSE4.2 / AVX / AVX-2 / AVX-512 实现移位代码。

但据我所知,例如对于 SSE2,仅存在 _mm_slli_si128() 内在/指令,它仅移动 8 的倍数(换句话说,字节移动)。而且我需要按任意位大小进行移位,而不仅仅是字节移位。

如果没有 SIMD,我也可以通过使用 shld reg, reg, reg 指令一次移位 128 位,该指令允许进行 128 位移位。它在 MSVC 中实现为固有的__shiftleft128(),并生成可以是seen here 的汇编代码。

顺便说一句,我需要所有 MSVC/GCC/CLang 的解决方案。

同样在单循环迭代中,我可以在顺序操作中移动 4 或 8 个字,这将使用 CPU 流水线来加速多条指令的并行乱序执行。

如果需要,我的位向量可以与内存中任意数量的字节对齐,如果这有助于例如通过对齐读/写来提高 SIMD 速度。源和目标位向量内存也不同(不重叠)。

换句话说,我正在寻找有关如何在不同的 Intel CPU 上最有效(最高效地)解决我的任务的所有建议。

注意,澄清一下,我实际上必须做几个班次,而不仅仅是一次班次。我有大位向量X,和数百个移位大小s0, s1, ..., sN,其中每个移位大小不同并且也可能很大(例如移位100K位),然后我想计算得到的大位向量@987654335 @。我只是将我对 StackOverflow 的问题简化为移动单个向量。但可能这个关于原始任务的细节非常重要。

应@Jake'Alquimista'LEE 的要求,我决定实现一个现成的玩具最小可重复示例,以计算输入位向量src 的移位或生成最终的@ 987654337@ 位向量。这个例子根本没有优化,只是我的任务如何解决的一个简单的变体。为简单起见,这个例子的输入向量很小,不像我的例子那样是千兆字节。这是一个玩具示例,我没有检查它是否正确解决了任务,它可能包含一些小错误:

Try it online!

#include <cstdint>
#include <vector>
#include <random>

#define bit_sizeof(x) (sizeof(x) * 8)

using u64 = uint64_t;
using T = u64;

int main() {
    std::mt19937_64 rng{123};

    // Random generate source bit vector
    std::vector<T> src(100'000);
    for (size_t i = 0; i < src.size(); ++i)
        src[i] = rng();

    size_t const src_bitsize = src.size() * bit_sizeof(T);

    // Destination bit vector, for example twice bigger in size
    std::vector<T> dst(src.size() * 2);

    // Random generate shifts
    std::vector<u64> shifts(200);
    for (size_t i = 0; i < shifts.size(); ++i)
        shifts[i] = rng() % src_bitsize;

    // Right-shift that handles overflow
    auto Shr = [](auto x, size_t s) {
        return s >= bit_sizeof(x) ? 0 : (x >> s);
    };

    // Do actual Shift-Ors
    for (auto orig_shift: shifts) {
        size_t const
            word_off = orig_shift / bit_sizeof(T),
            bit_off = orig_shift % bit_sizeof(T);

        if (word_off >= dst.size())
            continue;
        
        size_t const
            lim = std::min(src.size(), dst.size() - word_off);

        T w = 0;
        
        for (size_t i = 0; i < lim; ++i) {
            dst[word_off + i] |= w | (src[i] << bit_off);
            w = Shr(src[i], bit_sizeof(T) - bit_off);
        }

        // Special case of handling for last word
        if (word_off + lim < dst.size())
            dst[word_off + lim] |= w;
    }
}

我真实项目的当前代码与上面的玩具示例不同。该项目已经正确解决了现实世界的任务。我只需要做额外的优化。我已经做了一些优化,比如使用OpenMP 在所有内核上并行化移位或操作。同样如 cmets 中所说,我为每个班次大小创建了专门的模板函数,总共 64 个函数,并选择 64 个函数中的一个来执行实际的班次或。每个 C++ 函数都有移位大小的编译时间值,因此编译器会根据编译时间值进行额外的优化。

【问题讨论】:

  • 如果你有千兆字节的数据,你不是内存受限吗?您是否应该考虑将此操作与上一个或下一个操作合并,除非这两者都受到硬件或其他方面的限制?
  • 看看 GMP 的低级 mpn lshift / rshift 函数:gmplib.org/repo/gmp/file/tip/mpn/x86_64/fastsse/… - 你可以使用 GMP(GPL 许可证)或者将它们的循环转换为 intrinsincs。
  • 好的,是的,当您的读取输入可能以 4 到 8 个为一组时,您需要即时移动,而不是将每个位对齐的临时数组存储回内存(除非您是很快就会重复使用相同的班次计数)。
  • GCC 自动向量化这个以编译时移位计数没有问题:godbolt.org/z/PKbhqjdM4
  • @Arty 你可以使用__builtin_assume_aligned 用于 GCC

标签: c++ performance simd sse avx


【解决方案1】:

您可以,甚至可能不需要明确使用 SIMD 指令。 目标编译器 GCC、CLANG 和 MSVC 以及 ICC 等其他编译器都支持自动矢量化。 虽然手动优化的汇编可以胜过编译器生成的矢量化指令,但通常更难实现,并且您可能需要针对不同架构的多个版本。 生成高效自动向量化指令的通用代码是一种可以跨多个平台移植的解决方案。

例如一个简单的 shiftvec 版本

void shiftvec(uint64_t* dst, uint64_t* src, int size, int shift)
{
    for (int i = 0; i < size; ++i,++src,++dst)
    {
        *dst = ((*src)<<shift) | (*(src+1)>>(64-shift));
    }
}

使用最近的 GCC(或 CLANG 也可以)编译,-O3 -std=c++11 -mavx2 导致程序集核心循环中的 SIMD 指令

.L5:
  vmovdqu ymm4, YMMWORD PTR [rsi+rax]
  vmovdqu ymm5, YMMWORD PTR [rsi+8+rax]
  vpsllq ymm0, ymm4, xmm2
  vpsrlq ymm1, ymm5, xmm3
  vpor ymm0, ymm0, ymm1
  vmovdqu YMMWORD PTR [rdi+rax], ymm0
  add rax, 32
  cmp rax, rdx
  jne .L5

参见 godbolt.org:https://godbolt.org/z/5TxhqMhnK

这也概括了如果您想在 dst 中组合多个班次:

void shiftvec2(uint64_t* dst, uint64_t* src1, uint64_t* src2, int size1, int size2, int shift1, int shift2)
{
    int size = size1<size2 ? size1 : size2;
    for (int i = 0; i < size; ++i,++src1,++src2,++dst)
    {
        *dst = ((*src1)<<shift1) | (*(src1+1)>>(64-shift1));
        *dst |= ((*src2)<<shift2) | (*(src2+1)>>(64-shift2)); 
    }
    for (int i = size; i < size1; ++i,++src1,++dst)
    {
        *dst = ((*src1)<<shift1) | (*(src1+1)>>(64-shift1));        
    }
    for (int i = size; i < size2; ++i,++src2,++dst)
    {
        *dst = ((*src2)<<shift2) | (*(src2+1)>>(64-shift2));
    }
}

编译成核心循环:

.L38:
  vmovdqu ymm7, YMMWORD PTR [rsi+rcx]
  vpsllq ymm1, ymm7, xmm4
  vmovdqu ymm7, YMMWORD PTR [rsi+8+rcx]
  vpsrlq ymm0, ymm7, xmm6
  vpor ymm1, ymm1, ymm0
  vmovdqu YMMWORD PTR [rax+rcx], ymm1
  vmovdqu ymm7, YMMWORD PTR [rdx+rcx]
  vpsllq ymm0, ymm7, xmm3
  vmovdqu ymm7, YMMWORD PTR [rdx+8+rcx]
  vpsrlq ymm2, ymm7, xmm5
  vpor ymm0, ymm0, ymm2
  vpor ymm0, ymm0, ymm1
  vmovdqu YMMWORD PTR [rax+rcx], ymm0
  add rcx, 32
  cmp r10, rcx
  jne .L38

在一个循环中组合多个源将减少用于加载/写入目标的内存带宽总量。您可以组合的数量当然受到可用寄存器的限制。请注意,xmm2xmm3 for shiftvec 包含移位值,因此编译时已知移位值的不同版本可能会释放这些寄存器。

另外对每个指针使用__restrict(GCC、CLANG、MSVC 支持)将告诉编译器范围不重叠。

我最初在 MSVC 提供适当的自动矢量化代码时遇到问题,但似乎添加更多类似 SIMD 的结构将使其适用于所有三个所需的编译器 GCC、CLANG 和 MSVC:

void shiftvec(uint64_t* __restrict dst, const uint64_t* __restrict src, int size, int shift)
{
    int i = 0;
    // MSVC: use steps of 2 for SSE, 4 for AVX2, 8 for AVX512
    for (; i+4 < size; i+=4,dst+=4,src+=4)
    {
        for (int j = 0; j < 4; ++j)
            *(dst+j) = (*(src+j))<<shift;
        for (int j = 0; j < 4; ++j)
            *(dst+j) |= (*(src+1)>>(64-shift));
    }
    for (; i < size; ++i,++src,++dst)
    {
        *dst = ((*src)<<shift) | (*(src+1)>>(64-shift));
    }    
}

【讨论】:

  • 不幸的是,虽然 MSVC 进行了自动矢量化,但可以告诉指针没有别名,甚至可以生成别名检查并生成两个循环版本(别名和矢量化),矢量化功能本身非常有限,它不能向量化这种转变。 (可以选择使用clang-cl 编译此函数,并利用clang-clcl 的二进制兼容性)。
  • 是的,我试图让 MSVC 自动矢量化上面的代码。奇怪的天螺栓向我展示了它适用于 msvc x86,但不适用于 msvc x64。在 x64 情况下,使用 /Qvec-report:2 会导致自动矢量化失败 1200。
  • 有趣。它也无法在 x86 上对 32 位类型 godbolt.org/z/fTo3oT5n1 进行矢量化。我报告了缺少优化,也许他们有一天会对此做点什么developercommunity.visualstudio.com/t/…
  • 我可以确认,即使是 MSVC 也可以使用更类似于 SIMD 的结构化代码正确地自动矢量化它:``` void shiftvec(uint64_t* __restrict dst, const uint64_t* __restrict src, int size, int shift) { int i = 0; for (; i+4 >(64-shift)); } for (; i >(64-shift)); } } ```
  • @AlexGuteniev:如果您想要跨编译器的良好 asm,请从 GCC(在这种情况下)或 clang 中采用良好的矢量化策略,然后将其转换回内在函数。
【解决方案2】:

我会尝试依靠 x64 读取未对齐地址的能力,并且在星星正确(未)对齐时几乎没有明显的损失。只需要处理 (shift % 8) 或 (shift % 16) 的少数情况——所有这些都可以使用 SSE2 指令集,用零固定余数,对数据向量有一个未对齐的偏移量,并通过 @ 寻址 UB 987654323@.

也就是说,内部循环看起来像:

uint16_t const *ptr;
auto a = _mm_loadu_si128((__m128i*)ptr);
auto b = _mm_loadu_si128((__m128i*)(ptr + 1);
a = _mm_srl_epi16(a, c);
b = _mm_sll_epi16(a, 16 - c);
_mm_storeu_si128((__m128i*)ptr, mm_or_si128(a,b));
ptr += 8;

展开这个循环几次,也许可以在 SSE3+ 上使用_mm_alignr_epi8 来放松内存带宽(以及那些需要组合来自未对齐内存访问的结果的流水线阶段):

auto a0 = w; 
auto a1 = _mm_load_si128(m128ptr + 1);
auto a2 = _mm_load_si128(m128ptr + 2);
auto a3 = _mm_load_si128(m128ptr + 3);
auto a4 = _mm_load_si128(m128ptr + 4);
auto b0 = _mm_alignr_epi8(a1, a0, 2);
auto b1 = _mm_alignr_epi8(a2, a1, 2);
auto b2 = _mm_alignr_epi8(a3, a2, 2);
auto b3 = _mm_alignr_epi8(a4, a3, 2);
// ... do the computation as above ...
w = a4;   // rotate the context

【讨论】:

  • 当然也可以使用_mm_sll_epi64 来获得您所需要的;无论如何,我的假设是,在shift %8 ==0 时从未对齐的地址读取比首先运行此算法要快。
  • 执行 8 或 16 或 32 或 64 SSE 位移指令是否更快?还是速度一样?
  • @Arty:它们在所有 CPU 上的速度都相同,请参阅 uops.infoagner.org/optimize
  • 正如其他地方所评论的那样,英特尔根本没有 8 位移位;因此,如果需要,它们的速度大约是需要模拟的 2 倍。但在这种情况下,任何粒度都将完全相同。
【解决方案3】:

换句话说,我正在寻找有关如何在不同的 Intel CPU 上最有效(最高效地)解决我的任务的所有建议。

效率的关键是懒惰。懒惰的关键是撒谎——假装你改变了,但实际上没有做任何改变。

对于一个初始示例(仅用于说明概念),请考虑:

struct Thingy {
    int ignored_bits;
    uint64_t data[];
}

void shift_right(struct Thingy * thing, int count) {
    thing->ignored_bits += count;
}

void shift_left(struct Thingy * thing, int count) {
    thing->ignored_bits -= count;
}

int get_bit(struct Thingy * thing, int bit_number) {
    bit_number += thing->ignored_bits;
    return !!(thing->data[bit_number / 64] & (1 << bit_number % 64));
}

对于实用代码,您需要关心各种细节——您可能希望从数组开头的备用位(非零ignored_bits)开始,这样您就可以假装右移;对于每个小班次,您可能需要清除“移入”位(否则它将表现得像浮点数 - 例如(5.0 &lt;&lt; 8) &gt;&gt; 8) == 5.0);如果/当ignored_bits 超出某个范围时,您可能需要一个大的memcpy();等等

为了更多的乐趣;滥用低级内存管理 - 使用VirtualAlloc() (Windows) 或mmap() (Linux) 保留一个巨大的空间,然后将你的数组放在空间的中间,然后在数组的开头/结尾分配/释放页面为需要;这样您只需要在原始位向左/右“移动”数十亿位后memcpy()

当然,结果是它会使代码的其他部分复杂化 - 例如将 2 个位域组合在一起,您必须进行棘手的“获取 A;将 A 移到匹配 B;结果 = A OR B”调整。这不会影响性能。

【讨论】:

  • 感谢您的好主意!赞成。我喜欢假装被转移的想法。实际上,它可以部分(不完全)帮助我完成任务。因为我有几个累积的 Shit-Or 阶段。您的懒惰想法可能会在前两个或后两个阶段帮助节省一些内存。
【解决方案4】:
#include <cstdint>
#include <immintrin.h>

template<unsigned Shift>
void foo(uint64_t* __restrict pDst, const uint64_t* __restrict pSrc, intptr_t size)
{
    uint64_t* pSrc0, * pSrc1, * pSrc2, * pSrc3, * pDst0, * pDst1, * pDst2, * pDst3;
    __m256i prev, current;
    intptr_t i, stride;

    stride = size >> 2;
    i = stride;

    pSrc0 = pSrc;
    pSrc1 = pSrc + stride;
    pSrc2 = pSrc + 2 * stride;
    pSrc2 = pSrc + 3 * stride;

    pDst0 = pDst;
    pDst1 = pDst + stride;
    pDst2 = pDst + 2 * stride;
    pDst3 = pDst + 3 * stride;

    prev = _mm256_set_epi64x(0, pSrc1[-1], pSrc2[-1], pSrc3[-1]);

    while (i--)
    {
        current = _mm256_set_epi64x(*pSrc0++, *pSrc1++, *pSrc2++, *pSrc3++);
        prev = _mm256_srli_epi64(prev, 64 - Shift);
        prev = _mm256_or_si256(prev, _mm256_slli_epi64(current, Shift));
        *pDst0++ = _mm256_extract_epi64(prev, 3);
        *pDst1++ = _mm256_extract_epi64(prev, 2);
        *pDst2++ = _mm256_extract_epi64(prev, 1);
        *pDst3++ = _mm256_extract_epi64(prev, 0);

        prev = current;
    }
}

在 AVX2 上一次最多可以对四个 64 位元素进行操作(在 AVX512 上最多八个)

如果 size 不是 4 的倍数,则最多需要处理 3 个剩余的。

PS:自动矢量化从来都不是一个合适的解决方案。

【讨论】:

  • 哎呀,你为什么要从跨越数组的 4 个位置中的每个位置收集 1 个元素,而不是将 GCC 更好的自动矢量化策略转换回像 _mm256_loadu_si256 这样的内在函数? Memory-destination vpextrq mem, xmm, imm 是 Skylake 上的 2 个融合域 uop,p5 shuffle 加上一个 p237+p4 微融合存储,因此它与 _mm256_set_epi64x 在非常量数据上所需的 shuffle 竞争。此外,来自 YMM 上半部分的 vpextrq 首先需要 vextracti128,因为 vpextrq 仅适用于 XMM 源。
  • 如果你想让硬件预取器查看多个页面,你应该在展开循环中一次执行 2 个或 4 个 256 位向量,可能仍然使用 GCC 的策略。我还没有进行基准测试,但是在 4k 拆分不是非常昂贵的 CPU 上,我预计这会明显变慢。甚至在 Skylake 之前的 CPU 上甚至可能更糟糕的是,一个未对齐的负载跨越 4k 边界,成本超过 100 个周期。 (避免未对齐的负载似乎是这里唯一的优势,但 L1d 缓存很好地吸收了这些。)
  • @PeterCordes 我不希望相同的内存被读取两次。它消耗功率(你知道我的 ARM 背景)而且由于大小不固定,因此不能保证步幅是四的倍数。最重要的是,我不太了解英特尔架构:上面的内容几乎正是我在 NEON 上所做的。 (除了sri sli 而不是or
  • 执行所有这些 shuffle uops 很可能比负载执行单元在读取 L1d 时读取两个未对齐负载而不是 1 消耗更多的功率。您不会多次接触 DRAM; CPU有缓存。此外,尽快完成工作并返回深度睡眠状态可以节省更多电量。与 NEON 不同,x86 首先没有跨步 SIMD 加载;我不是这么建议的。
  • Linus 最引人注目地抱怨 AVX512 是一种“电源病毒”。我不记得他在 AVX2 上的 cmets。 AVX-512 有更好的随机播放,但仍然没有 8 位移位:/ 肯定还有一些缺点,但非常不幸的是,AVX-512 的屏蔽和更好的移位等不错的功能仍然主要限于服务器,因为它们只带有宽向量,并且 Alder Lake 在大多数情况下不会将它们带到桌面,而不是在启用了 E-cores 的 CPU 上。无论如何,不​​喜欢扩展可能会解释为它编写次优代码,但并不意味着其他人不能做得更好。
【解决方案5】:

不,你不能

NEON 和 AVX(512) 都支持高达 64 位元素的桶形移位操作。

但是,您可以使用 NEON 上的指令 ext 和 AVX 上的 alignr 指令将整个 128 位向量“移位”n 个字节(8 位)。

而且你应该避免使用向量类来提高性能,因为它只不过是链表,这对性能很不利。

【讨论】:

  • 因此,如果 SIMD 无法做到这一点,那么至少我正在寻找有关如何通过任何可以进行的改进来加速该算法的解决方案。
  • @Arty SIMD 可以做到这一点,尤其是 NOEN 及其 srisli 指令。但首先放弃向量类。
  • 关于std::vector - 我只是以它为例,实际上我只有两个普通内存指针uint64_t *src, *dst 指向两个内存位置,源内存应该转移到目标。顺便说一句,在 C++ 中,std::vector 不是一个链表,而是一个规则的连续数组,在内存中的组织方式与 uint64_t arr[SIZE]; 相同。
  • 我只是删除了我的问题中的std::vector 引用。你可能认为我只有一个指向应该被移动的内存区域的普通指针。
  • std::vector 不是链表。 Idk 你在哪里听到的,但这不是真的。 C++ 中的向量是在扩展时重新分配的连续内存区域。
猜你喜欢
  • 2015-10-13
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2014-12-04
  • 1970-01-01
  • 1970-01-01
  • 2011-06-03
  • 2019-08-15
相关资源
最近更新 更多