将为@PeterCordes 的精彩回答添加更多信息:https://stackoverflow.com/a/36951611/5021064。
我用它为整数类型实现了std::remove from C++ standard。该算法一旦可以进行压缩,就相对简单:加载寄存器,压缩,存储。首先,我将展示变化,然后展示基准。
我最终对提议的解决方案提出了两个有意义的变化:
-
__m128i 寄存器,任何元素类型,使用_mm_shuffle_epi8 指令
-
__m256i寄存器,元素类型至少4字节,使用_mm256_permutevar8x32_epi32
当 256 位寄存器的类型小于 4 字节时,我将它们分成两个 128 位寄存器并分别压缩/存储。
链接到编译器资源管理器,您可以在其中查看完整的程序集(底部有 using type 和 width(每个包中的元素),您可以插入它们以获得不同的变体):https://gcc.godbolt.org/z/yQFR2t
注意:我的代码使用 C++17 并使用自定义 simd 包装器,所以我不知道它的可读性如何。如果您想阅读我的代码-> 大部分代码都在顶部的链接后面,包括在 Godbolt 上。或者,所有代码都在github。
两种情况下@PeterCordes 答案的实现
注意:与掩码一起,我还使用 popcount 计算剩余元素的数量。也许有一种情况是不需要的,但我还没有看到。
_mm_shuffle_epi8 的掩码
- 将每个字节的索引写入半字节:
0xfedcba9876543210
- 将索引对放入打包到
__m128i 中的 8 个短裤中
- 使用
x << 4 | x & 0x0f0f 将它们展开
传播索引的示例。假设选择了第 7 个和第 6 个元素。
这意味着相应的短路将是:0x00fe。在<< 4 和| 之后,我们会得到0x0ffe。然后我们清除第二个f。
完整的掩码代码:
// helper namespace
namespace _compress_mask {
// mmask - result of `_mm_movemask_epi8`,
// `uint16_t` - there are at most 16 bits with values for __m128i.
inline std::pair<__m128i, std::uint8_t> mask128(std::uint16_t mmask) {
const std::uint64_t mmask_expanded = _pdep_u64(mmask, 0x1111111111111111) * 0xf;
const std::uint8_t offset =
static_cast<std::uint8_t>(_mm_popcnt_u32(mmask)); // To compute how many elements were selected
const std::uint64_t compressed_idxes =
_pext_u64(0xfedcba9876543210, mmask_expanded); // Do the @PeterCordes answer
const __m128i as_lower_8byte = _mm_cvtsi64_si128(compressed_idxes); // 0...0|compressed_indexes
const __m128i as_16bit = _mm_cvtepu8_epi16(as_lower_8byte); // From bytes to shorts over the whole register
const __m128i shift_by_4 = _mm_slli_epi16(as_16bit, 4); // x << 4
const __m128i combined = _mm_or_si128(shift_by_4, as_16bit); // | x
const __m128i filter = _mm_set1_epi16(0x0f0f); // 0x0f0f
const __m128i res = _mm_and_si128(combined, filter); // & 0x0f0f
return {res, offset};
}
} // namespace _compress_mask
template <typename T>
std::pair<__m128i, std::uint8_t> compress_mask_for_shuffle_epi8(std::uint32_t mmask) {
auto res = _compress_mask::mask128(mmask);
res.second /= sizeof(T); // bit count to element count
return res;
}
_mm256_permutevar8x32_epi32 的掩码
这几乎是一对一的 @PeterCordes 解决方案 - 唯一的区别是 _pdep_u64 位(他建议将此作为注释)。
我选择的掩码是0x5555'5555'5555'5555。这个想法是 - 我有 32 位 mmask,8 个整数中的每一个都有 4 位。我想要得到 64 位 => 我需要将 32 位的每一位转换为 2 => 因此 0101b = 5。乘数也从 0xff 变为 3,因为我将得到每个整数的 0x55,而不是 1。
完整的掩码代码:
// helper namespace
namespace _compress_mask {
// mmask - result of _mm256_movemask_epi8
inline std::pair<__m256i, std::uint8_t> mask256_epi32(std::uint32_t mmask) {
const std::uint64_t mmask_expanded = _pdep_u64(mmask, 0x5555'5555'5555'5555) * 3;
const std::uint8_t offset = static_cast<std::uint8_t(_mm_popcnt_u32(mmask)); // To compute how many elements were selected
const std::uint64_t compressed_idxes = _pext_u64(0x0706050403020100, mmask_expanded); // Do the @PeterCordes answer
// Every index was one byte => we need to make them into 4 bytes
const __m128i as_lower_8byte = _mm_cvtsi64_si128(compressed_idxes); // 0000|compressed indexes
const __m256i expanded = _mm256_cvtepu8_epi32(as_lower_8byte); // spread them out
return {expanded, offset};
}
} // namespace _compress_mask
template <typename T>
std::pair<__m256i, std::uint8_t> compress_mask_for_permutevar8x32(std::uint32_t mmask) {
static_assert(sizeof(T) >= 4); // You cannot permute shorts/chars with this.
auto res = _compress_mask::mask256_epi32(mmask);
res.second /= sizeof(T); // bit count to element count
return res;
}
基准测试
处理器:Intel Core i7 9700K(现代消费级 CPU,不支持 AVX-512)
编译器:clang,从 10 版附近的主干构建
编译器选项:--std=c++17 --stdlib=libc++ -g -Werror -Wall -Wextra -Wpedantic -O3 -march=native -mllvm -align-all-functions=7
微基准库:google benchmark
控制代码对齐:
如果您不熟悉这个概念,请阅读 this 或观看 this
基准二进制文件中的所有函数都与 128 字节边界对齐。每个基准测试函数重复 64 次,在函数的开头(进入循环之前)使用不同的 noop 幻灯片。我显示的主要数字是每次测量的最小值。我认为这是有效的,因为算法是内联的。我也得到了非常不同的结果这一事实验证了我。在答案的最底部,我展示了代码对齐的影响。
注意:benchmarking code。 BENCH_DECL_ATTRIBUTES 只是内联
Benchmark 从数组中删除一定百分比的 0。我用 {0, 5, 20, 50, 80, 95, 100}% 的零来测试数组。
我测试了 3 种大小:40 字节(看看这是否适用于非常小的数组)、1000 字节和 10'000 字节。我按大小分组,因为 SIMD 取决于数据的大小而不是元素的数量。元素计数可以从元素大小(1000 字节是 1000 个字符,但 500 个短字节和 250 个整数)得出。由于非 simd 代码所花费的时间主要取决于元素数量,因此对于 char 而言,胜利应该更大。
绘图:x - 零的百分比,y - 以纳秒为单位的时间。 padding : min 表示这是所有对齐中的最小值。
40 字节的数据,40 个字符
对于 40 字节,即使对于字符也没有意义 - 当在非 simd 代码上使用 128 位寄存器时,我的实现会慢 8-10 倍。因此,例如,编译器应该小心执行此操作。
1000 字节的数据,1000 个字符
显然,非 simd 版本以分支预测为主:当我们得到少量零时,我们得到的加速较小:对于没有 0 - 大约 3 倍,对于 5% 零 - 大约 5-6 倍加速。当分支预测器无法帮助非 simd 版本时 - 大约有 27 倍的加速。 simd 代码的一个有趣特性是它的性能往往不太依赖于数据。使用 128 与 256 寄存器几乎没有区别,因为大部分工作仍分为 2 128 个寄存器。
1000 字节数据,500 条短裤
短裤的结果相似,但增益要小得多 - 最多 2 倍。
我不知道为什么对于非 simd 代码来说,shorts 比 chars 做得更好:我希望 shorts 快两倍,因为只有 500 条短裤,但实际上差异高达 10 倍。
1000 字节的数据,250 个整数
对于 1000,只有 256 位版本是有意义的 - 20-30% 的胜利,不包括没有 0 来删除以往的东西(完美的分支预测,不删除非 simd 代码)。
10'000 字节的数据,10'000 个字符
与 1000 个字符相同的数量级获胜:从分支预测器有帮助时快 2-6 倍到没有帮助时快 27 倍。
相同的情节,只有 simd 版本:
在这里,我们可以看到使用 256 位寄存器并将它们分成 2 128 位寄存器大约可以提高 10%:快了大约 10%。它的大小从 88 条指令增加到 129 条指令,数量不多,因此根据您的用例可能有意义。对于基线 - 非 simd 版本是 79 条指令(据我所知 - 这些比 SIMD 更小)。
10000 字节的数据,5000 条短裤
从 20% 到 9 次获胜,具体取决于数据分布。没有显示 256 位和 128 位寄存器之间的比较 - 它与 chars 的程序集几乎相同,而 256 位寄存器的结果相同,约为 10%。
10'000 字节的数据,2'500 个整数
使用 256 位寄存器似乎很有意义,这个版本比 128 位寄存器快大约 2 倍。与非 simd 代码进行比较时 - 从完美分支预测的 20% 获胜到不是时的 3.5 - 4 倍。
结论:当您有足够的数据量(至少 1000 字节)时,对于没有 AVX-512 的现代处理器来说,这可能是非常值得的优化
PS:
关于要移除的元素的百分比
一方面,过滤一半的元素并不常见。另一方面,在排序期间可以在分区中使用类似的算法 => 实际上预计会有大约 50% 的分支选择。
代码对齐影响
问题是:如果代码恰好对齐不佳,它值多少钱
(一般来说 - 对此几乎无能为力)。
我只显示 10'000 个字节。
对于每个百分比,这些图有两条线,分别代表最小值和最大值(意思是 - 这不是一个最佳/最差代码对齐方式 - 它是给定百分比的最佳代码对齐方式)。
代码对齐影响 - 非 simd
字符:
从差的分支预测的 15-20% 到分支预测有很大帮助的 2-3 倍。 (已知分支预测器会受到代码对齐的影响)。
短裤:
出于某种原因 - 0% 根本不受影响。可以解释为std::remove 首先进行线性搜索以找到要删除的第一个元素。显然,对短裤的线性搜索不受影响。
除此之外 - 从 10% 到 1.6-1.8 倍的价值
整数:
与短裤相同 - 无 0 不受影响。一旦我们进入删除部分,它的价值就会从 1.3 倍增加到 5 倍,然后是最佳大小写对齐。
代码对齐影响 - simd 版本
不显示 short 和 ints 128,因为它与 chars 几乎相同的程序集
字符 - 128 位寄存器
大约慢 1.2 倍
字符 - 256 位寄存器
大约慢 1.1 - 1.24 倍
整数 - 256 位寄存器
慢 1.25 - 1.35 倍
我们可以看到,对于算法的 simd 版本,与非 simd 版本相比,代码对齐的影响要小得多。我怀疑这是因为实际上没有分支。