在运行时,我有2个范围,它们的边界分别为uint32_t
和a..b
。第一个范围往往比第二个范围大得多:c..d
。
确切限制:8 < (b - a) / (d - c) < 64
,a >= 0
,b <= 2^31 - 1
,c >= 0
。
我需要一个例程,该例程执行从第一个范围到第二个范围的整数的线性映射:d <= 2^20 - 1
。
当f(uint32_t x) -> round_to_uint32_t((float)(x - a) / (b - a) * (d - c) + c)
时,保持该比例尽可能接近理想值很重要,否则,如果b - a >= d - c
中的元素可以映射到[a; b]
中的多个整数上,则可以返回这些整数中的任何一个。
听起来像一个简单的比例问题,并且已经在许多问题中得到了回答,例如
Convert a number range to another range, maintaining ratio
但是在这里,我需要一个非常快速的解决方案。
此例程是专用排序算法的关键部分,将对已排序数组的每个元素至少调用一次。
如果不降低整体性能,SIMD解决方案也是可以接受的。
答案 0 :(得分:3)
实际运行时划分(FP和整数)非常慢,因此您一定要避免这种情况。您编写该表达式的方式可能会编译为包含除法,因为FP数学不是关联的(没有-ffast-math
);编译器无法为您将x / foo * bar
转换为x * (bar/foo)
,尽管对于循环不变的bar/foo
来说,这非常好。您确实需要浮点数或64位整数来避免乘法中的溢出,但是只有FP允许您重用非整数的循环不变除法结果。
_mm256_fmadd_ps
看起来很明显,乘数(d - c) / (b - a)
具有预先计算的循环不变值。如果严格按照顺序进行float
舍入不是一个问题(先乘后除),则可以在循环外首先进行这种不精确的除法。喜欢
_mm256_set1_ps((d - c) / (double)(b - a))
。使用double
进行此计算可避免除法操作数转换为FP时的舍入误差。
您要为许多x
重用相同的a,b,c,d,大概是来自连续内存。您将结果用作存储器地址的一部分,因此不幸的是,您最终确实需要将结果从SIMD返回到整数寄存器。 (对于AVX512散点存储,可以避免这种情况。)
现代的x86 CPU具有2个时钟的负载吞吐量,因此,将8x uint32_t返回整数寄存器的最佳选择可能是向量存储/整数重载,而不是每个元素花费2 uop进行ALU随机播放。这有一些延迟,因此我建议在循环遍历该标量之前,将其转换为可能为16或32 ints(64或128字节)的tmp缓冲区,即2x或4x __m256i
。
或者交替转换并存储一个向量,然后遍历先前转换的另一个的8个元素。即软件流水线。乱序执行可以隐藏延迟,但是您已经要扩展其延迟隐藏功能,无论您使用内存如何进行操作,都可以缓存未命中。
根据您的CPU(例如Haswell或某些Skylake),使用256位矢量指令可能会使您的最大涡轮加速的上限略低于其他方式。您可能考虑一次只做4个向量,但是每个元素要花费更多的uops。
如果不是SIMD,那么对于fma()
来说,即使是标量C ++ vfmadd213sd
仍然很好,但是使用内在函数是从float-> int({ {1}},而不是vcvtps2dq
。
请注意,直到AVX512,vcvttps2dq
<-> uint32_t
转换才直接可用。对于标量,您可以将无符号的下半部分截断/零扩展与int64_t进行相互转换。
(如注释中所述)非常方便的输入是范围限制的,因此,如果将它们解释为有符号整数,则它们具有相同的值(有符号非负数)。 float
和x
(以及x-a
)都是正数,且<= INT32_MAX即b-a
。 (或者至少是非负数。零是可以的。)
浮点四舍五入
对于SIMD,单精度0x7FFFFFFF
对于SIMD吞吐率非常好。到有符号的int32_t的高效打包转换。但是not every int32_t
can be exactly represented as a float
。较大的值将四舍五入为最接近的偶数,最接近的2 ^ 2、2 ^ 3倍数,或者更远的值大于2 ^ 24。
可以使用SIMD float
,但需要进行一些改组。
对于用double
编写的公式,我不认为float
通常不是问题。如果(float)(x-a)
输入范围很大,则意味着两个范围都很大,并且舍入误差不会将所有可能的b-a
值映射到同一输出中。取决于乘数,输入舍入误差可能比输出舍入误差更严重,可能会为较高的x
值保留一些可表示的输出浮点数。
但是,如果我们想排除x-a
部分并将其与-a * (d - c) / (b - a)
结合在一起,那么
+c
。如果(float)x
很大而a
很小,即在可能的输入范围的顶部附近有一个小的范围,则舍入误差可以将所有可能的b-a
值映射到同一个浮点数。x
,如果+c
的输出范围很小,但是d-c
的话,这又冒着输出舍入误差的风险。很大。就您而言,这不是问题;借助c
<= 2 ^ 20 -1,我们知道d
可以精确表示该c..d范围内的每个输出整数值。如果没有输入范围约束,则可以在缩放之前通过在输入上使用整数float
在输出上使用(x-a)+0x80000000U
(在四舍五入到{ {1}})。但这会为较小的...+c+0x80000000U
输入(接近0)引入巨大的int32_t
舍入误差,这些输入会发生范围偏移而接近float
。
我们不需要对uint32_t
或INT_MIN
进行范围转换,因为与b-a
的+或-或XOR将在减法中抵消。
内联之后,编译器应将d-c
向量从循环中吊起,
或者您可以手动执行。
这需要AVX1 + FMA(例如,AMD Piledriver或Intel Haswell或更高版本)。未经测试,对不起,我什至没有把它扔到Godbolt上看它是否可以编译。
0x80000000U
或更安全的版本,使用整数进行输入范围转换:如果出于便携性的考虑(如果是AVX1),可以在此处轻松避免使用FMA,也可以对输出使用整数加法。但是我们知道输出范围足够小,可以始终准确地表示任何整数
const
没有FMA,您仍然可以使用// fastest but not safe if b-a is small and a > 2^24
static inline
__m256i range_scale_fast_fma(__m256i data, uint32_t a, uint32_t b, uint32_t c, uint32_t d)
{
// avoid rounding errors when computing the scale factor, but convert double->float on the final result
double scale_scalar = (d - c) / (double)(b - a);
const __m256 scale = _mm256_set1_ps(scale_scalar);
const __m256 add = _m256_set1_ps(-a*scale_scalar + c);
// (x-a) * scale + c
// = x * scale + (-a*scale + c) but with different rounding error from doing -a*scale + c
__m256 in = _mm256_cvtepi32_ps(data);
__m256 out = _mm256_fmadd_ps(in, scale, add);
return _mm256_cvtps_epi32(out); // convert back with round to nearest-even
// _mm256_cvttps_epi32 truncates, matching C rounding; maybe good for scalar testing
}
。尽管这样做static inline
__m256i range_scale_safe_fma(__m256i data, uint32_t a, uint32_t b, uint32_t c, uint32_t d)
{
// avoid rounding errors when computing the scale factor, but convert double->float on the final result
const __m256 scale = _mm256_set1_ps((d - c) / (double)(b - a));
const __m256 cvec = _m256_set1_ps(c);
__m256i in_offset = _mm256_add_epi32(data, _mm256_set1_epi32(-a)); // add can more easily fold a load of a memory operand than sub because it's commutative. Only some compilers will do this for you.
__m256 in_fp = _mm256_cvtepi32_ps(in_offset);
__m256 out = _mm256_fmadd_ps(in_fp, scale, _mm256_set1_ps(c)); // in*scale + c
return _mm256_cvtps_epi32(out);
}
是安全的,但您最好在添加vmulps
之前先转换回整数。
您可以像这样在循环中使用它
c
这些变量名听起来很愚蠢,但我想不出更好的方法。
此软件流水线是一种优化;您可以立即使它正常工作/一次使用单个向量进行尝试。 (如果需要,可以使用vaddps
优化从重载到void foo(uint32_t *arr, ptrdiff_t len)
{
if (len < 24) special case;
alignas(32) uint32_t tmpbuf[16];
// peel half of first iteration for software pipelining / loop rotation
__m256i arrdata = _mm256_loadu_si256((const __m256i*)&arr[0]);
__m256i outrange = range_scale_safe_fma(arrdata);
_mm256_store_si256((__m256i*)tmpbuf, outrange);
// could have used an unsigned loop counter
// since we probably just need an if() special case handler anyway for small len which could give len-23 < 0
for (ptrdiff_t i = 0 ; i < len-(15+8) ; i+=16 ) {
// prep next 8 elements
arrdata = _mm256_loadu_si256((const __m256i*)&arr[i+8]);
outrange = range_scale_safe_fma(arrdata);
_mm256_store_si256((__m256i*)&tmpbuf[8], outrange);
// use first 8 elements
for (int j=0 ; j<8 ; j++) {
use tmpbuf[j] which corresponds to arr[i+j]
}
// prep 8 more for next iteration
arrdata = _mm256_loadu_si256((const __m256i*)&arr[i+16]);
outrange = range_scale_safe_fma(arrdata);
_mm256_store_si256((__m256i*)&tmpbuf[0], outrange);
// use 2nd 8 elements
for (int j=8 ; j<16 ; j++) {
use tmpbuf[j] which corresponds to arr[i+j]
}
}
// use tmpbuf[0..7]
// then cleanup: one vector at a time until < 8 or < 4 with 128-bit vectors, then scalar
}
的第一个元素的重载。)
如果在某些情况下您知道vmovd
是2的幂,则可以用_mm_cvtsi128_si32(_mm256_castsi256_si128(outrange))
或(b - a)
进行位扫描,然后相乘。 (有一些内在函数,例如GNU C tzcnt
来计算尾随零。)
还是可以确保 bsf
始终是2的幂?
或者更好,如果__builtin_ctz()
的精确乘方为2,则整个操作可以为sub / right shift / add。
如果您不能始终确保自己有时仍然需要一般情况,但也许可以有效地做到这一点。