使用 AVX2 向量化随机初始化并使用十进制数字数组打印 BigInt?

Vectorize random init and print for BigInt with decimal digit array, with AVX2?

如何将我的代码传递给 AVX2 代码并获得与以前相同的结果?

是否可以在 LongNumInit、LongNumPrint 函数中使用 __m256i 而不是 uint8_t *L 或某种类似类型的变量?

我对AVX的了解非常有限;我调查了很多,但是我不太了解如何转换我的代码,欢迎任何建议和解释。

我对 AVX2 中的这段代码非常感兴趣。

void LongNumInit(uint8_t *L, size_t N )
{
  for(size_t i = 0; i < N; ++i){
      L[i] = myRandom()%10;
  }

}
void LongNumPrint( uint8_t *L, size_t N, uint8_t *Name )
{
  printf("%s:", Name);
  for ( size_t i=N; i>0;--i )
  {
    printf("%d", L[i-1]);
  }
  printf("\n");
}
int main (int argc, char **argv)
{
  int i, sum1, sum2, sum3, N=10000, Rep=50;

  seed = 12345;

  // obtain parameters at run time
  if (argc>1) { N    = atoi(argv[1]); }
  if (argc>2) { Rep  = atoi(argv[2]); }
  
 // Create Long Nums
  unsigned char *V1= (unsigned char*) malloc( N);
  unsigned char *V2= (unsigned char*) malloc( N);
  unsigned char *V3= (unsigned char*) malloc( N);
  unsigned char *V4= (unsigned char*) malloc( N);

  LongNumInit ( V1, N ); LongNumInit ( V2, N ); LongNumInit ( V3, N );
   
//Print last 32 digits of Long Numbers
  LongNumPrint( V1, 32, "V1" );
 LongNumPrint( V2, 32, "V2" );
  LongNumPrint( V3, 32, "V3" );
  LongNumPrint( V4, 32, "V4" );

  free(V1); free(V2); free(V3); free(V4);
  return 0;
}

我在初始代码中得到的结果是这样的:

V1:59348245908804493219098067811457
V2:24890422397351614779297691741341
V3:63392771324953818089038280656869
V4:00000000000000000000000000000000

这对于 BigInteger 来说通常是一种糟糕的格式,请参阅 https://codereview.stackexchange.com/a/237764 以查看对 BigInteger 每个字节使用一位小数的设计缺陷的代码审查,以及您 could/should 的做法。

并查看 Can long integer routines benefit from SSE? @Mysticial 关于存储数据的方法的注释,这些注释使 SIMD for BigInteger 数学变得实用,特别是您的临时变量可能未被“规范化”的部分字算术,让您可以进行惰性进位处理。


但显然你只是在问 this 代码,随机初始化和打印函数,而不是如何在这种格式的两个数字之间进行数学运算.

我们可以很好地向量化这两个。我的 LongNumPrintName() 是您的替代品。

对于 LongNumInit 我只是展示了一个构建块,它存储两个 32 字节的块和 returns 一个递增的指针。循环调用它。 (它自然会在每次调用时产生 2 个向量,因此对于较小的 N,您可以制作替代版本。)

LongNumInit

What's the fastest way to generate a 1 GB text file containing random digits? 在 4GHz Skylake 上生成约 33 GB/s 处的 space 分隔的随机 ASCII 十进制数字,包括对 /dev/nullwrite() 系统调用的开销. (这高于 DRAM 带宽;128kiB 的缓存阻塞让存储命中 L2 缓存。/dev/null 的内核驱动程序甚至不读取用户-space 缓冲区。)

它可以很容易地改编成 void LongNumInit(uint8_t *L, size_t N ) 的 AVX2 版本。我在那里的回答使用了 AVX2 xorshift128+ PRNG(在 __m256i 的 64 位元素中使用 4 个独立的 PRNG 矢量化),如 AVX/SSE version of xorshift128+。这应该与您的 rand() % 10.

具有相似的随机性

它通过乘法逆运算将其分解为十进制数字,使用 Why does GCC use multiplication by a strange number in implementing integer division? 除以 10 并使用移位和 vpmulhuw 取模。 (实际上使用 GNU C 原生向量语法让 GCC 确定魔法常量并发出乘法和移位以获得方便的语法,如 v16u dig1 = v % ten;v /= ten;

可以使用_mm256_packus_epi16将两个16位数字的向量打包成8位元素,而不是把奇数元素变成ASCII' ',偶数元素变成ASCII'0'..'9'. (因此更改 vec_store_digit_and_space 以打包向量对,而不是使用常量进行 ORing,见下文)

使用 gcc、clang 或 ICC(或希望任何其他理解 C99 的 GNU C 方言和 Intel 的内在函数的编译器)编译它。

参见 https://gcc.gnu.org/onlinedocs/gcc/Vector-Extensions.html for the __attribute__((vector_size(32))) part, and https://software.intel.com/sites/landingpage/IntrinsicsGuide/ for the _mm256_* stuff. Also https://whosebug.com/tags/sse/info

#include <immintrin.h>

// GNU C native vectors let us get the compiler to do stuff like %10 each element
typedef unsigned short v16u __attribute__((vector_size(32)));

// returns p + size of stores.  Caller should use outpos = f(vec, outpos)
// p must be aligned
__m256i* vec_store_digits(__m256i vec, __m256i *restrict p)
{
    v16u v = (v16u)vec;
    v16u ten = (v16u)_mm256_set1_epi16(10);

    v16u divisor = (v16u)_mm256_set1_epi16(6554);  // ceil((2^16-1) / 10.0)
    v16u div6554 = v / divisor;      // Basically the entropy from the upper two decimal digits: 0..65.
    // Probably some correlation with the modulo-based values, especially dig3, but we do this instead of
    // dig4 for more ILP and fewer instructions total.

    v16u dig1 = v % ten;
    v /= ten;
    v16u dig2 = v % ten;
    v /= ten;
    v16u dig3 = v % ten;
    //  dig4 would overlap much of the randomness that div6554 gets

    // __m256i or v16u assignment is an aligned store
    v16u *vecbuf = (v16u*)p;
      // pack 16->8 bits.
    vecbuf[0] = _mm256_packus_epi16(div6554, dig1);
    vecbuf[1] = _mm256_packus_epi16(dig2, dig3)
    return p + 2;  // always a constant number of full vectors
}

random_decimal_fill_buffer 中插入换行符的逻辑可以完全删除,因为您只需要一个十进制数字的平面数组。只需循环调用上述函数,直到填满缓冲区。

处理小尺寸(小于完整矢量):

将您的 malloc 填充到下一个 32 字节的倍数会很方便,因此执行 32 字节加载而不检查是否可能跨越未映射的页面总是安全的。

并使用C11 aligned_alloc得到32字节对齐存储。例如,aligned_alloc(32, (size+31) & -32)。这让我们可以进行完整的 32 字节存储,即使 N 是奇数也是如此。从逻辑上讲,只有缓冲区的前 N ​​个字节保存我们的真实数据,但是我们可以随意填充填充很方便,以避免对 N 小于 32 或不是 32 的倍数进行任何额外的条件检查。

不幸的是,缺少 ISO C 和 glibc aligned_reallocaligned_calloc。 MSVC 实际上提供了这些: Why is there no 'aligned_realloc' on most platforms? 允许您有时在对齐缓冲区的末尾分配更多 space 而无需复制它。如果非平凡可复制对象更改地址,“try_realloc”对于可能需要 运行 复制构造函数的 C++ 来说是理想的选择。强制执行有时不必要的复制的非表达性分配器 API 是我的心头肉。


LongNumPrint

采用 uint8_t *Name arg 是糟糕的设计。如果调用者想先打印一个 "something:" 字符串,他们可以这样做。您的函数应该只做 printf "%d"int.

所做的事情

由于您以反向打印顺序存储数字,因此您需要将字节反转到 tmp 缓冲区中,并通过与 '0'。然后将该缓冲区传递给 fwrite.

具体使用alignas(32) char tmpbuf[8192];作为局部变量。

您可以在固定大小的块(如 1kiB 或 8kiB)中工作,而不是分配一个可能很大的缓冲区。您可能仍想通过 stdio(而不是直接 write() 并管理您自己的 I/O 缓冲)。使用 8kiB 缓冲区,高效的 fwrite 可能只是将其直接传递给 write() 而不是 memcpy 到 stdio 缓冲区。您可能想尝试调整它,但是让 tmp 缓冲区舒适地小于 L1d 缓存的一半将意味着它在您写入后重新读取时在缓存中仍然很热。

缓存阻塞使循环边界更加复杂,但对于非常大的 N 来说是值得的。

字节反转一次32字节:

您可以通过决定您的数字以 MSD 优先顺序存储来避免这项工作,但是如果您确实想要实现加法,则必须从末尾向后循环。

你的函数可以用 SIMD _mm_shuffle_epi8 实现来反转 16 字节的块,从你的数字数组的末尾开始写入你的 tmp 缓冲区的开头。

或者更好,加载 vmovdqu / vinserti128 16 字节加载以将 _mm256_shuffle_epi8 馈送到通道内的字节反转,为 32 字节存储设置。

在 Intel CPU 上,vinserti128 解码为 load+ALU uop,但它可以 运行 在任何矢量 ALU 端口上,而不仅仅是随机端口。所以两个 128 位加载比 256 位加载更有效 -> vpshufb -> vpermq 如果缓存中的数据很热,这可能会成为随机端口吞吐量的瓶颈。英特尔 CPU 每个时钟周期最多可以执行 2 次加载 + 1 次存储(或者在 IceLake 中,2 次加载 + 2 次存储)。如果没有内存瓶颈,我们可能会在前端出现瓶颈,因此实际上不会使加载+存储和混洗端口饱和。 (https://agner.org/optimize/ and https://uops.info/)

这个函数也被简化了,假设我们总是可以从 L 中读取 32 个字节,而不会进入未映射的页面。但是在对小 N 进行 32 字节反转之后,输入的前 N ​​个字节成为 32 字节块中的最后 N 个字节。如果我们总是可以安全地在缓冲区末尾进行 32 字节加载 ending ,那将是最方便的,但是在对象之前期望填充是不合理的。

#include <immintrin.h>
#include <stdalign.h>
#include <stddef.h>
#include <stdio.h>
#include <stdint.h>

// one vector of 32 bytes of digits, reversed and converted to ASCII
static inline
void ASCIIrev32B(void *dst, const void *src)
{
    __m128i hi = _mm_loadu_si128(1 + (const __m128i*)src);  // unaligned loads
    __m128i lo = _mm_loadu_si128(src);
    __m256i v = _mm256_set_m128i(lo, hi);    // reverse 128-bit hi/lo halves

    // compilers will hoist constants out of inline functions
    __m128i byterev_lane = _mm_set_epi8(0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15);      
    __m256i byterev = _mm256_broadcastsi128_si256(byterev_lane);  // same in each lane
    v = _mm256_shuffle_epi8(v, byterev);               // in-lane reverse

    v = _mm256_or_si256(v, _mm256_set1_epi8('0'));     // digits to ASCII
    _mm256_storeu_si256(dst, v);                       // Will usually be aligned in practice.
}

// Tested for N=32; could be bugs in the loop bounds for other N
// returns bytes written, like fwrite: N means no error, 0 means error in all fwrites
size_t LongNumPrint( uint8_t *num, size_t N)
{
    // caller can print a name if it wants

    const int revbufsize = 8192;      // 8kiB on the stack should be fine
    alignas(32) char revbuf[revbufsize];

    if (N<32) {
        // TODO: maybe use a smaller revbuf for this case to avoid touching new stack pages
        ASCIIrev32B(revbuf, num);   // the data we want is at the *end* of a 32-byte reverse
        return fwrite(revbuf+32-N, 1, N, stdout);
    }

    size_t bytes_written = 0;
    const uint8_t *inp = num+N;  // start with last 32 bytes of num[]
    do {
        size_t chunksize = (inp - num >= revbufsize) ? revbufsize : inp - num;

        const uint8_t *inp_stop = inp - chunksize + 32;   // leave one full vector for the end
        uint8_t *outp = revbuf;
        while (inp > inp_stop) {        // may run 0 times
            inp -= 32;
            ASCIIrev32B(outp, inp);
            outp += 32;
        }
        // reverse first (lowest address) 32 bytes of this chunk of num
        // into last 32 bytes of this chunk of revbuf
        // if chunksize%32 != 0 this will overlap, which is fine.
        ASCIIrev32B(revbuf + chunksize - 32, inp_stop - 32);
        bytes_written += fwrite(revbuf, 1, chunksize, stdout);
        inp = inp_stop - 32;
    } while ( inp > num );

    return bytes_written;
    // caller can putchar('\n') if it wants
}


// wrapper that prints name and newline
void LongNumPrintName(uint8_t *num, size_t N, const char *name)
{
    printf("%s:", name);
    //LongNumPrint_scalar(num, N);
    LongNumPrint(num, N);
    putchar('\n');
}

// main() included on Godbolt link that runs successfully

这会编译 运行s (on Godbolt) with gcc -O3 -march=haswell 并为 N=32 的标量循环生成相同的输出main 通过了。 (我使用 rand() 而不是 MyRandom(),因此我们可以使用相同的种子进行测试并获得相同的数字,使用您的 init 函数。)

未经测试较大的 N,但 chunksize = min(ptrdiff, 8k) 并使用它从 num[] 的末尾向下循环的一般想法应该是可靠的。

如果我们在开始主循环之前转换第一个 N%32 字节并将其传递给 fwrite,我们就可以加载(不仅仅是存储)对齐向量。但这可能会导致额外的 write() 系统调用,或者在 stdio 中进行笨拙的复制。 (除非已经有尚未打印的缓冲文本,如 Name:,在这种情况下我们已经有该惩罚。)

请注意,从技术上讲,从 num 开始后递减 inp 是 C UB。因此 inp -= 32 而不是 inp = inp_stop-32 将具有离开外循环的迭代的 UB。我实际上在这个版本中避免了这种情况,但它通常仍然有效,因为我认为 GCC 假设一个平面内存模型并且 de-factor 足够定义指针比较的行为。正常的操作系统保留零页,所以 num 绝对不能在物理内存开始的 32 字节以内(所以 inp 不能换行到高地址。)这段大部分是左 -从第一次完全未经测试的尝试开始,我认为是在内部循环中将指针递减得比实际递减得更远。