高效地实现大位向量的Shift-Or算法

4

我有一个大型的内存数组,表示为某些指针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,只存在可移位8的倍数(换句话说是字节移位)的_mm_slli_si128() 内嵌/指令。我需要任意位大小的移位,而不仅仅是字节移位。

如果没有SIMD,我也可以通过使用shld reg,reg,reg 指令一次性移位128位来实现,该指令允许进行128位移位。在MSVC中,它以内嵌形式__shiftleft128()实现,并生成汇编代码,可以在此处查看。

顺便说一下,我需要解决MSVC / GCC / CLang的所有问题。

此外,在单个循环迭代内,我可以通过连续操作移位4或8个字,利用CPU流水线加速并行无序执行多个指令。

如果需要,我的位向量可以对齐到内存中的任意字节数,如果这将有助于通过进行对齐读写来提高SIMD的速度。此外源和目标位向量内存是不同的(非重叠)。

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

注意,为了澄清,实际上我必须执行多个移位或运算,而不仅仅是单个移位。我有一个大的位向量X,以及若干个移位大小s0,s1,...,sN,其中每个移位大小都不同,也可能很大(例如100K位的移位),然后我想计算出结果大的位向量Y = (X << s0) | (X << s1) | ... | (X << sN)。我只是将我的问题简化为堆栈溢出以移动单个向量。但这个关于原始任务的细节可能非常重要。

根据@Jake'Alquimista'LEE的请求,我决定实现一个现成的玩具最小可重现示例,用于计算输入位向量src的移位或结果并生成dst位向量。该示例未进行任何优化,只是如何解决我的任务的简单直接变体。为了简单起见,此示例的输入向量大小很小,而不是像我的情况那样以千兆字节为单位。这是一个玩具示例,我没有检查它是否正确解决了任务,可能包含轻微的错误:在此在线尝试!
#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在所有核心上并行化shift-or操作。另外,正如评论中所说,我为每个移位大小创建了专门的模板函数,共计64个函数,并选择其中一个函数来执行实际的shift-or操作。每个C++函数都有编译时值的移位大小,因此编译器会根据编译时值进行额外的优化。


2
如果你有几个GB的数据,那么你不是已经受到内存限制了吗?除非硬件等方面有所限制,否则你应该考虑将此操作与前一个或后一个操作合并。 - Davis Herring
1
好的,是的,你需要在读取每组输入时进行即时位移,可能是4到8个一组,而不是实际将每个位对齐的临时数组存回内存(除非你很快要重复使用相同的位移计数)。 - Peter Cordes
1
@Jake'Alquimista'LEE 目前 C++ 算法很简单,我只是计算 Y = (X << s0) | (X << s1) | ... | (X << sN),其中 X 是大型输入位向量,Y 是大型输出位向量,而 s0, ..., sN 是从 0 到 1M 的一百个移位大小。我在两个循环中进行这个计算,外部循环遍历所有的 sK,内部循环执行实际的移位或操作,就像我在问题开头展示的代码中所示。除此之外没有别的了。这不是最优解决方案。因此,我正在寻求所有关于如何优化它的建议。 - Arty
2
GCC没有问题在编译时自动向量化此操作,链接如下:https://godbolt.org/z/PKbhqjdM4 - chtz
2
@Arty,你可以在GCC中使用__builtin_assume_aligned - Marc Stevens
显示剩余27条评论
5个回答

5
你可以使用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用于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的二进制兼容性)。 - Alex Guteniev
1
是的,我已经尝试了使用MSVC自动向量化上述代码。奇怪的是godbolt显示它对于msvc x86可以工作,但对于msvc x64则无法工作。在x64情况下,使用/Qvec-report:2命令会导致自动向量化失败1200。 - Marc Stevens
有趣。它还无法对 32 位类型在 x86 上进行矢量化 https://godbolt.org/z/fTo3oT5n1 。我已经报告了缺失的优化,或许有一天他们会解决这个问题 https://developercommunity.visualstudio.com/t/Missing-optimization:-shift-loop-vectori/1590948。 - Alex Guteniev
2
我可以确认,即使是使用类似于SIMD的结构化代码,MSVC也可以正确地进行自动向量化:void shiftvec(uint64_t* __restrict dst, const uint64_t* __restrict src, int size, int shift) { int i = 0; for (; i+4 < size; i+=4,dst+=4,src+=4) { for (int j = 0; j < 4; ++j) *(dst+j) = (*(src+j))<>(64-shift)); } for (; i < size; ++i,++src,++dst) { *dst = ((*src)<>(64-shift)); } } - Marc Stevens
2
@AlexGuteniev:如果你想要在编译器之间获得良好的汇编代码,可以从GCC(在这种情况下)或clang中采用良好的向量化策略,然后将其转换回内部函数。 - Peter Cordes

4
我会尝试依赖x64能够读取未对齐地址的能力,并在星星正确(不)对齐时几乎没有可见的惩罚。只需要处理一些(shift% 8)或(shift% 16)的情况 - 所有这些都可以使用SSE2指令集完成,通过将余数修复为零并具有数据向量的不对齐偏移量,并通过memcpy寻址UB。
话虽如此,内循环将如下所示:
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(b, 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 的情况下从非对齐地址读取比首先运行此算法更快。 - Aki Suihkonen
使用 8、16、32 或 64 位 SSE 位移指令哪个更快?还是它们的速度相同? - Arty
1
@Arty:在所有CPU上,它们的速度都是相同的,请参见https://uops.info/和https://agner.org/optimize/。 - Peter Cordes
1
正如在其他地方评论的那样,英特尔根本没有8位移位;因此,如果需要,它们大约要慢2倍,因为它们需要被模拟。但在这种情况下,任何粒度都会完全相同。 - Aki Suihkonen
我认为您有一个小错误 - 应该将 b = _mm_sll_epi16(a, 16 - c); 替换为 b = _mm_sll_epi16(b, 16 - c);(用 b 替换 a)。另外,如果您要执行右移操作,则 b 应该是 ptr - 1,或者交换 _mm_srl_epi16a 的位置,并将 _mm_sll_epi16 用于 b 来执行左移操作。 - Arty
是的,你是正确的。已修正。 - Aki Suihkonen

2

换句话说,我正在寻求有关如何在不同的英特尔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 << 8) >> 8) == 5.0);如果/当ignored_bits超出一定范围时,您可能需要一个大型的memcpy()等等。
更有趣的是,滥用低级内存管理 - 使用VirtualAlloc()(Windows)或mmap()(Linux)来保留巨大的空间,然后将数组放在空间中间,然后根据需要分配/释放起始/结束的页面;这样,只需在原始位向左/向右移动数十亿个位后进行memcpy()即可。
当然,其结果是会使您的其他代码变得更加复杂 - 例如,要将2个位域进行OR运算,您必须进行棘手的“获取A;将A移动以匹配B;结果= A OR B”调整。但这并不会影响性能。

谢谢你提供的好主意!已点赞。我喜欢假装被移位的想法,实际上它可以在一定程度上(不是完全)帮助我完成任务。因为我有几个累积的Shit-Or阶段。而你的懒惰想法可能会在前两个或后两个阶段中节省一些内存。 - Arty

0
#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上最多可达八个)。

如果大小不是四的倍数,则最多还有三个剩余元素需要处理。

附注:自动向量化永远不是一个合适的解决方案。


2
呸,你为什么要从数组中跨越4个位置收集1个元素,而不是将GCC更好的自动向量化策略转换回内部函数,如_mm256_loadu_si256?在Skylake上,内存目标vpextrq mem, xmm, imm是2个融合域uops,p5 shuffle加上一个p237+p4微融合存储器,因此它与非常数数据所需的_mm256_set_epi64x洗牌竞争。此外,从YMM的上半部分进行vpextrq需要先进行vextracti128,因为vpextrq仅适用于XMM源。 - Peter Cordes
2
如果您想让HW预取器查看多个页面,应该在展开的循环中一次处理2或4个256位向量,可能仍然使用GCC的策略。我没有进行基准测试,但在不是非常昂贵的4k分割的CPU上,我预计这会慢得多。甚至在Skylake之前的CPU上,跨越4k边界的错误对齐负载的成本超过100个周期。 (避免未对齐的负载似乎是唯一的优势,但L1d缓存可以很好地吸收它们。) - Peter Cordes
执行所有这些洗牌uops很可能比使用两个不对齐的加载而不是1个读取L1d的负载执行单元消耗更多的功率。您不会多次触摸DRAM; CPU具有缓存。此外,尽快完成工作并返回深度睡眠状态可以节省更多电力。x86首先没有跨步SIMD加载,不像NEON;我并不是在建议那样做。 - Peter Cordes
1
Linus最明显地抱怨了AVX512作为“功率病毒”。我不记得他对AVX2的评论。AVX-512有更好的洗牌,但仍然没有8位移位:/ 明显仍然存在一些缺陷,但真的很不幸,AVX-512的像掩码和更好的移位这样的好特性大多限于服务器,因为它们只带有宽向量,并且Alder Lake在很大程度上不会将它们带到桌面上,而且启用E核心的CPU也不会。无论如何,不喜欢某个扩展名可能会解释为编写其次优代码,但并不意味着其他人不能做得更好。 - Peter Cordes
让我们在聊天中继续这个讨论 - Jake 'Alquimista' LEE
显示剩余3条评论

-3

不行,你不能

NEON和AVX(512)都支持最多64位元素的桶移位操作。

但是你可以使用NEON中的ext指令或AVX中的alignr指令将整个128位向量“移动”n个字节(8位)。

为了性能,应避免使用向量类,因为它只是链表,对性能不利。


@Arty SIMD可以做到这一点,特别是使用其srisli指令的NOEN。但首先要放弃向量类。 - Jake 'Alquimista' LEE
2
关于std::vector - 我只是用它作为例子,在现实中我只有两个普通的内存指针uint64_t *src,*dst,它们指向两个内存位置,源内存应该移动到目标位置。顺便说一下,在C++中,std::vector不是链表,而是一个常规的连续数组,与uint64_t arr[SIZE];在内存中以相同的方式组织。 - Arty
1
std::vector 不是一个链表。我不知道你从哪里听到这个说法,但它是错误的。在 C++ 中,向量是一块连续的内存区域,在扩展时会重新分配。 - Qix - MONICA WAS MISTREATED
如果你觉得一个 new/memcpy 看起来和一个链表一样,那么你需要换一副眼镜了。复制并不好,但是通过仔细使用 std::vector,你可以避免浪费的复制。链表在根本上是不同而且可怕的,遍历它会涉及到加载-使用延迟。我不知道你将 std::vectorstd::forward_list 混为一谈的理由。此外,即使没有 OR on the fly 部分,通过使用 128 位 SSE 或 AVX,可以比标量 shld 获得一些性能提升;这就是现代 x86-64 上 GMP 的 mpn_lshift 所做的。特别是当数据在 L2 缓存中热时。 - Peter Cordes
@Qix-MONICAWASMISTREATED 对不起,这是一个误解。我以为我没有发布它,所以我刚刚删除了评论。 - Jake 'Alquimista' LEE
显示剩余6条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接