Yup, dividing two polynomials can often give you a better tradeoff between speed and precision than one huge polynomial. As long as there's enough work to hide the divpd
throughput. (The latest x86 CPUs have pretty decent FP divide throughput. Still bad vs. multiply, but it's only 1 uop so it doesn't stall the pipeline if you use it rarely enough, i.e. mixed with lots of multiplies. Including in the surrounding code that uses exp
)
However, _mm_cvtepi64_pd(_mm_cvttpd_epi64(px));
won't work with SSE2. Packed-conversion intrinsics to/from 64-bit integers requires AVX512DQ.
To do packed rounding to the nearest integer, ideally you'd use SSE4.1 _mm_round_pd(x, _MM_FROUND_TO_NEAREST_INT |_MM_FROUND_NO_EXC)
, (or truncation towards zero, or floor or ceil towards -+Inf).
But we don't actually need that.
The scalar code ends up with int n
and double px
both representing the same numeric value. It uses the bad/buggy floor(val+0.5)
idiom instead of rint(val)
or nearbyint(val)
to round to nearest, and then converts that already-integer double
to an int
(with C++'s truncation semantics, but that doesn't matter because the double
value's already an exact integer.)
With SIMD intrinsics, it appears to be easiest to just convert to 32-bit integer and back.
__m128i n = _mm_cvtpd_epi32( _mm_mul_pd(log2e, x) ); // round to nearest
__m128d px = _mm_cvtepi32_pd( n );
Rounding to int with the desired mode, then converting back to double, is equivalent to double->double rounding and then grabbing an int version of that like the scalar version does. (Because you don't care what happens for doubles too large to fit in an int.)
cvtsd2si and si2sd instructions are 2 uops each, and shuffle the 32-bit integers to packed in the low 64 bits of a vector. So to set up for 64-bit integer shifts to stuff the bits into a double
again, you'll need to shuffle. The top 64 bits of n
will be zeros, so we can use that to create 64-bit integer n
lined up with the doubles:
n = _mm_shuffle_epi32(n, _MM_SHUFFLE(3,1,2,0)); // 64-bit integers
But with just SSE2, there are workarounds. Converting to 32-bit integer and back is one option: you don't care about inputs too small or too large. But packed-conversion between double
and int
costs at least 2 uops on Intel CPUs each way, so a total of 4. But only 2 of those uops need the FMA units, and your code probably doesn't bottleneck on port 5 with all those multiplies and adds.
Or add a very large number and subtract it again: large enough that each double
is 1 integer apart, so normal FP rounding does what you want. (This works for inputs that won't fit in 32 bits, but not double
> 2^52. So either way that would work.) Also see How to efficiently perform double/int64 conversions with SSE/AVX? which uses that trick. I couldn't find an example on SO, though.
Related:
Then of course I need to check the whole, since I'm not quite sure I've converted it correctly.
iterating over all 2^64 double
bit-patterns is impractical, unlike for float
where there are only 4 billion, but maybe iterating over all double
s that have the low 32 bits of their mantissa all zero would be a good start. i.e. check in a loop with
bitpatterns = _mm_add_epi64(bitpatterns, _mm_set1_epi64x( 1ULL << 32 ));
doubles = _mm_castsi128_pd(bitpatterns);
https://randomascii.wordpress.com/2014/01/27/theres-only-four-billion-floatsso-test-them-all/
For those last few lines, correcting the input for out-of-range inputs:
The float
version you quote just leaves out the range-check entirely. This is obviously the fastest way, if your inputs will always be in range or if you don't care about what happens for out-of-range inputs.
Alternate cheaper range-checking (maybe only for debugging) would be to turn out-of-range values into NaN by ORing the packed-compare result into the result. (An all-ones bit-pattern represents a NaN.)
__m128d out_of_bounds = _mm_cmplt_pd( limit, abs(initial_x) ); // abs = mask off the sign bit
result = _mm_or_pd(result, out_of_bounds);
In general, you can vectorize simple condition setting of a value using branchless compare + blend. Instead of if(x) y=0;
, you have the SIMD equivalent of y = (condition) ? 0 : y;
, on a per-element basis. SIMD compares produce a mask of all-zero / all-one elements so you can use it to blend.
e.g. in this case cmppd the input and blendvpd the output if you have SSE4.1. Or with just SSE2, and/andnot/or to blend. See SSE intrinsics for comparison (_mm_cmpeq_ps) and assignment operation for a _ps
version of both, _pd
is identical.
In asm it will look like this:
; result in xmm0 (in need of fixups for out of range inputs)
; initial_x in xmm2
; constants:
; xmm5 = limit
; xmm6 = +Inf
cmpltpd xmm2, xmm5 ; xmm2 = input_x < limit ? 0xffff... : 0
andpd xmm0, xmm2 ; result = result or 0
andnpd xmm2, xmm6 ; xmm2 = 0 or +Inf (In that order because we used ANDN)
orpd xmm0, xmm2 ; result |= 0 or +Inf
; xmm0 = (input < limit) ? result : +Inf
(In an earlier version of the answer, I thought I was maybe saving a movaps to copy a register, but this is just a bog-standard blend. It destroys initial_x
, so the compiler needs to copy that register at some point while calculating result
, though.)
Optimizations for this special condition
Or in this case, 0.0
is represented by an all-zero bit-pattern, so do a compare that will produce true if in-range, and AND the output with that. (To leave it unchanged or force it to +0.0). This is better than _mm_blendv_pd
, which costs 2 uops on most Intel CPUs (and the AVX 128-bit version always costs 2 uops on Intel). And it's not worse on AMD or Skylake.
+-Inf
is represented by a bit-pattern of significand=0, exponent=all-ones. (Any other value in the significand represents +-NaN.) Since too-large inputs will presumably still leave non-zero significands, we can't just AND the compare result and OR that into the final result. I think we need to do a regular blend, or something as expensive (3 uops and a vector constant).
It adds 2 cycles of latency to the final result; both the ANDNPD and ORPD are on the critical path. The CMPPD and ANDPD aren't; they can run in parallel with whatever you do to compute the result.
Hopefully your compiler will actually use ANDPS and so on, not PD, for everything except the CMP, because it's 1 byte shorter but identical because they're both just bitwise ops. I wrote ANDPD just so I didn't have to explain this in comments.
You might be able to shorten the critical path latency by combining both fixups before applying to the result, so you only have one blend. But then I think you also need to combine the compare results.
Or since your upper and lower bounds are the same magnitude, maybe you can compare the absolute value? (mask off the sign bit of initial_x
and do _mm_cmplt_pd(abs_initial_x, _mm_set1_pd(details::EXP_LIMIT))
). But then you have to sort out whether to zero or set to +Inf.
If you had SSE4.1 for _mm_blendv_pd
, you could use initial_x
itself as the blend control for the fixup that might need applying, because blendv
only cares about the sign bit of the blend control (unlike with the AND/ANDN/OR version where all bits need to match.)
__m128d fixup = _mm_blendv_pd( _mm_setzero_pd(), _mm_set1_pd(INFINITY), initial_x ); // fixup = (initial_x signbit) ? 0 : +Inf
// see below for generating fixup with an SSE2 integer arithmetic-shift
const signbit_mask = _mm_castsi128_pd(_mm_set1_epi64x(0x7fffffffffffffff)); // ~ set1(-0.0)
__m128d abs_init_x = _mm_and_pd( initial_x, signbit_mask );
__m128d out_of_range = _mm_cmpgt_pd(abs_init_x, details::EXP_LIMIT);
// Conditionally apply the fixup to result
result = _mm_blendv_pd(result, fixup, out_of_range);
Possibly use cmplt
instead of cmpgt
and rearrange if you care what happens for initial_x
being a NaN. Choosing the compare so false applies the fixup instead of true will mean that an unordered comparison results in either 0 or +Inf for an input of -NaN or +NaN. This still doesn't do NaN propagation. You could _mm_cmpunord_pd(initial_x, initial_x)
and OR that into fixup
, if you want to make that happen.
Especially on Skylake and AMD Bulldozer/Ryzen where SSE2 blendvpd
is only 1 uop, this should be pretty nice. (The VEX encoding, vblendvpd
is 2 uops, having 3 inputs and a separate output.)
You might still be able to use some of this idea with only SSE2, maybe creating fixup
by doing a compare against zero and then _mm_and_pd
or _mm_andnot_pd
with the compare result and +Infinity.
Using an integer arithmetic shift to broadcast the sign bit to every position in the double
isn't efficient: psraq
doesn't exist, only psraw/d
. Only logical shifts come in 64-bit element size.
But you could create fixup
with just one integer shift and mask, and a bitwise invert
__m128i ix = _mm_castsi128_pd(initial_x);
__m128i ifixup = _mm_srai_epi32(ix, 11); // all 11 bits of exponent field = sign bit
ifixup = _mm_and_si128(ifixup, _mm_set1_epi64x(0x7FF0000000000000ULL) ); // clear other bits
// ix = the bit pattern for 0 (non-negative x) or +Inf (negative x)
__m128d fixup = _mm_xor_si128(ifixup, _mm_set1_epi32(-1)); // bitwise invert
Then blend fixup
into result
for out-of-range inputs as normal.
Cheaply checking abs(initial_x) > details::EXP_LIMIT
If the exp algorithm was already squaring initial_x
, you could compare against EXP_LIMIT
squared. But it's not, xx = x*x
only happens after some calculation to create x
.
If you have AVX512F/VL, VFIXUPIMMPD
might be handy here. It's designed for functions where the special case outputs are from "special" inputs like NaN and +-Inf, negative, positive, or zero, saving a compare for those cases. (e.g. for after a Newton-Raphson reciprocal(x) for x=0.)
But both of your special cases need compares. Or do they?
If you square your input and subtract, it only costs one FMA to do initial_x * initial_x - details::EXP_LIMIT * details::EXP_LIMIT
to create a result that's negative for abs(initial_x) < details::EXP_LIMIT
, and non-negative otherwise.
Agner Fog reports that vfixupimmpd
is only 1 uop on Skylake-X.
float
, notdouble
. i.e. multiplying a float by1<<n
. It doesn't implement the range-check, just thez *=
statement. You already have then = _mm_add_epi64(n, _mm_set1_epi64x(1023));
/n = _mm_slli_epi64(n, 52);
part implemented in your code. fordouble
. I didn't notice you were missing the_mm_mul_pd(result, _mm_castsi128_pd(n))
part. Is that why you were quoting that single-precision code? – Allineallis_mm_mul_ps
/_pd
line that you're missing, with the type-pun_mm_cast
intrinsic? Or are you saying that someone's float version left out the range-check? That's very possible if they decided they didn't want the overhead of range-checking. – Allineallis_mm_mul_ps(z, _mm_castsi128_ps(n))
just implements the*=
part ofz *= details::uint322sp((n + 0x7f) << 23);
. I thought that was obvious. Of course it would take several intrinsics to faithfully implement the range checking, especially if you don't have SSE4 forblendv
. It would be easy and cheaper to implement it by forcing the result to NaN, though. But still not as cheap as nothing at all. – Allineallisx = _mm_sub_pd(t, _mm_and_pd(_mm_cmplt_pd(px, t), one));
is never used; you later dox = _mmstuff(px, ... px)
. So that's weird. – Allineallisx = _mm_sub_pd(x, _mm_mul_pd(px, _mm_set1_pd(6.93145751953125E-1))); ..
. I've fixed the code. – Tricyclic