从基于源的索引转换为基于目标的索引
Posted
技术标签:
【中文标题】从基于源的索引转换为基于目标的索引【英文标题】:Converting from Source-based Indices to Destination-based Indices 【发布时间】:2016-08-31 23:35:07 【问题描述】:我在一些 C 代码中使用 AVX2 指令。
VPERMD 指令采用两个 8 整数向量 a
和 idx
,并通过基于 idx
置换 a
生成第三个向量 dst
。这似乎等同于dst[i] = a[idx[i]] for i in 0..7
。我称此为基于源,因为移动是基于源的索引。
但是,我的计算索引采用基于目的地的形式。这对于设置数组是很自然的,相当于dst[idx[i]] = a[i] for i in 0..7
。
如何从基于源的表单转换为基于目标的表单?一个示例测试用例是:
2 1 0 5 3 4 6 7 source-based form.
2 1 0 4 5 3 6 7 destination-based equivalent
对于此转换,我将保留在 ymm 寄存器中,这意味着基于目标的解决方案不起作用。即使我要单独插入每个,因为它只对常量索引进行操作,你不能只设置它们。
【问题讨论】:
这就是经典的“排列反转”,dst[src[i]] = i
对。但是您的代码需要能够以基于目标的方式进行设置。因为我在 AVX2 寄存器中操作。我不能那样做。我有工作的 C 代码,几乎完全按照你说的那样做,但是我需要能够转换索引,而不能像你建议的那样进行基于目标的排列。
您的a[i] = a[idx[i]] for i in 0..7
没有正确描述VPERMD 的操作,因为它暗示对a
的更改将反馈到a[idx[i]]
以供以后的元素使用。例如原始的a[0]
总是会被立即销毁,除非idx[0] = 0
。我认为您的示例在我修改以纠正该错误(或一直假设该行为)之后仍然是理智的。
感谢您的编辑。我确实理解这种行为,但我没有正确描述它。
【参考方案1】:
我猜你是在暗示你不能修改你的代码来计算基于源的索引?除了采用基于 dst 的索引的 AVX512 分散指令之外,我想不出你可以用 x86 SIMD 做什么。 (但这些在当前 CPU 上并不是很快,即使与收集负载相比也是如此。https://uops.info/)
存储到内存、反转和重新加载向量实际上可能是最好的。 (或者直接传输到整数寄存器,而不是通过内存,可能在 vextracti128 / packusdw 之后,所以你只需要从向量到整数寄存器的两个 64 位传输:movq 和 pextrq)。
但是无论如何,然后将它们用作索引以将计数器存储到内存中的数组中,然后将其重新加载为向量。这仍然是缓慢而丑陋的,并且包括存储转发失败延迟。因此,如果可能的话,更改索引生成代码以生成基于源的随机播放向量可能是值得的。
【讨论】:
感谢分散操作的想法。我添加了一个示例作为答案。【参考方案2】:为了对解决方案进行基准测试,我修改了其他答案中的代码,以比较分散指令(USE_SCATTER
已定义)与存储和顺序置换(USE_SCATTER
未定义)的性能。我不得不将结果复制回排列模式perm
,以防止编译器优化循环体:
#ifdef TEST_SCATTER
#define REPEATS 1000000001
#define USE_SCATTER
__m512i ident = _mm512_set_epi32(15,14,13,12,11,10,9,8,7,6,5,4,3,2,1,0);
__m512i perm = _mm512_set_epi32(7,9,3,0,5,8,13,11,4,2,15,1,12,6,10,14);
uint32_t outA[16] __attribute__ ((aligned(64)));
uint32_t id[16], in[16];
_mm512_storeu_si512(id, ident);
for (int i = 0; i < 16; i++) printf("%2d ", id[i]); puts("");
_mm512_storeu_si512(in, perm);
for (int i = 0; i < 16; i++) printf("%2d ", in[i]); puts("");
#ifdef USE_SCATTER
puts("scatter");
for (long t = 0; t < REPEATS; t++)
_mm512_i32scatter_epi32(outA, perm, ident, 4);
perm = _mm512_load_si512(outA);
#else
puts("store & permute");
uint32_t permA[16] __attribute__ ((aligned(64)));
for (long t = 0; t < REPEATS; t++)
_mm512_store_si512(permA, perm);
for (int i = 0; i < 16; i++) outA[permA[i]] = i;
perm = _mm512_load_si512(outA);
#endif
for (int i = 0; i < 16; i++) printf("%2d ", outA[i]); puts("");
#endif
这是两种情况的输出(使用 tcsh
的内置命令 time
,u
输出是用户空间时间,以秒为单位):
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
14 10 6 12 1 15 2 4 11 13 8 5 0 3 9 7
store & permute
12 4 6 13 7 11 2 15 10 14 1 8 3 9 0 5
10.765u 0.001s 0:11.22 95.9% 0+0k 0+0io 0pf+0w
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
14 10 6 12 1 15 2 4 11 13 8 5 0 3 9 7
scatter
12 4 6 13 7 11 2 15 10 14 1 8 3 9 0 5
10.740u 0.000s 0:11.19 95.9% 0+0k 40+0io 0pf+0w
运行时大致相同(Intel(R) Xeon(R) W-2125 CPU @ 4.00GHz,clang++-6.0,-O3 -funroll-loops -march=native
)。我检查了生成的汇编代码。定义 USE_SCATTER
后,编译器生成 vpscatterdd
指令,而不使用 vpextrd
、vpextrq
和 vpextracti32x4
生成复杂代码。
编辑:我担心编译器可能已经为我使用的固定排列模式找到了特定的解决方案。所以我用std::random_shuffe()
中随机生成的模式替换了它,但时间测量值大致相同。
编辑:根据 Peter Cordes 的评论,我编写了一个修改后的基准测试,希望能测量吞吐量之类的东西:
#define REPEATS 1000000
#define ARRAYSIZE 1000
#define USE_SCATTER
std::srand(unsigned(std::time(0)));
// build array with random permutations
uint32_t permA[ARRAYSIZE][16] __attribute__ ((aligned(64)));
for (int i = 0; i < ARRAYSIZE; i++)
_mm512_store_si512(permA[i], randPermZMM());
// vector register
__m512i perm;
#ifdef USE_SCATTER
puts("scatter");
__m512i ident = _mm512_set_epi32(15,14,13,12,11,10,9,8,7,6,5,4,3,2,1,0);
for (long t = 0; t < REPEATS; t++)
for (long i = 0; i < ARRAYSIZE; i++)
perm = _mm512_load_si512(permA[i]);
_mm512_i32scatter_epi32(permA[i], perm, ident, 4);
#else
uint32_t permAsingle[16] __attribute__ ((aligned(64)));
puts("store & permute");
for (long t = 0; t < REPEATS; t++)
for (long i = 0; i < ARRAYSIZE; i++)
perm = _mm512_load_si512(permA[i]);
_mm512_store_si512(permAsingle, perm);
uint32_t *permAVec = permA[i];
for (int e = 0; e < 16; e++)
permAVec[permAsingle[e]] = e;
#endif
FILE *f = fopen("testperm.dat", "w");
fwrite(permA, ARRAYSIZE, 64, f);
fclose(f);
我使用了一个排列模式数组,这些排列模式是按顺序修改的,没有依赖关系。
这些是结果:
scatter
4.241u 0.002s 0:04.26 99.5% 0+0k 80+128io 0pf+0w
store & permute
5.956u 0.002s 0:05.97 99.6% 0+0k 80+128io 0pf+0w
所以使用 scatter 命令时吞吐量会更好。
【讨论】:
将输出的重新加载作为下一次迭代的排列,您测量的是延迟,而不是吞吐量。包括立即重新加载的存储转发停止。这可能反映了一些用例,但测量吞吐量也可能很有趣。 那是 10.765u = 用户空间秒,对吧?不是 10.765u = 微秒。 10 秒足以隐藏任何热身效果和其他开销,所以没关系。 @PeterCordes:测量吞吐量的代码修改是什么?将此应用于(随机生成的)排列模式数组? /时间测量来自tcsh
内置time
命令,u
时间是以秒为单位的用户空间时间。
在 asm 中简单:运行指令而不将结果作为下一次迭代的依赖项返回。所以乱序执行可以发挥它的魔力并重叠多次迭代。让 C 编译器发出这样的 asm 可能需要小心使用 volatile
vars 来强制加载,或者 asm("" : "+x"(vector))
忘记它知道的有关向量变量值的任何信息。例如像Benchmark::DoNotOptimize
或其他各种技巧。然后检查生成的 asm 以确保它对于您要测量的内容看起来很真实。
@PeterCordes:我在回答中包含了一个修改后的基准。您认为这与吞吐量有关吗?【参考方案3】:
我遇到了同样的问题,但方向相反:目标索引很容易计算,但应用 SIMD 置换指令需要源索引。这是使用 Peter Cordes 建议的分散指令的 AVX-512 解决方案;它也应该适用于相反的方向:
__m512i ident = _mm512_set_epi32(15,14,13,12,11,10,9,8,7,6,5,4,3,2,1,0);
__m512i perm = _mm512_set_epi32(7,9,3,0,5,8,13,11,4,2,15,1,12,6,10,14);
uint32_t id[16], in[16], out[16];
_mm512_storeu_si512(id, ident);
for (int i = 0; i < 16; i++) printf("%2d ", id[i]); puts("");
_mm512_storeu_si512(in, perm);
for (int i = 0; i < 16; i++) printf("%2d ", in[i]); puts("");
_mm512_i32scatter_epi32(out, perm, ident, 4);
for (int i = 0; i < 16; i++) printf("%2d ", out[i]); puts("");
一个恒等映射ident
根据索引模式perm
分配到out
数组。这个想法与inverting a permutation 描述的想法基本相同。这是输出:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
14 10 6 12 1 15 2 4 11 13 8 5 0 3 9 7
12 4 6 13 7 11 2 15 10 14 1 8 3 9 0 5
请注意,我有数学意义上的排列(没有重复)。对于重复项,out
存储需要初始化,因为某些元素可能仍未写入。
我也看不出在寄存器中实现这一点的简单方法。我考虑过通过重复应用置换指令来循环遍历给定的排列。一旦达到恒等模式,前面的就是逆排列(这可以追溯到 EOF 在unzip operations 上的想法)。但是,周期可能很长。可能需要的最大循环数由Landau's function 给出,对于 16 个元素,为 140,请参见table。我可以证明,如果单个排列子循环与标识元素重合时立即冻结,则可以将其缩短到最多 16 个。这将随机排列模式测试的平均排列指令从 28 条缩短到 9 条。但是,它仍然不是一个有效的解决方案(比我在另一个答案中描述的吞吐量基准测试中的分散指令慢得多)。
【讨论】:
你有没有针对任何东西进行过基准测试?分散指令不是很快; IDK 如果这是我 4 年前提出的一个好建议。但它们并不可怕,这可能比任何其他选择都好。如果您确实拥有正确的索引,那肯定比洗牌更糟糕。 @PeterCordes:我添加了另一个带有基准的答案。显然,使用 scatter 并没有性能优势。以上是关于从基于源的索引转换为基于目标的索引的主要内容,如果未能解决你的问题,请参考以下文章