fpemu: IEEE-754 Double Precision in Software#
fpemu provides IEEE-754 double-precision arithmetic built from 32-bit integer and float
operations rather than from native FP64 instructions. It is for hardware where FP64 throughput is
rationed: the emulation runs on the units the GPU has in abundance, so a double workload can be
faster in software than in the hardware path it would otherwise take.
At its default accuracy the packed form reproduces double bit for bit. The trade is therefore
throughput against the FP64 pipeline, not accuracy against double. Accuracy enters only if you
step down a level deliberately, or step up to the unpacked representation.
On parts that ration FP64 at a small fraction of FP32 — the three measured
below do — the packed form runs at parity
with hardware double while producing its exact results, and the unpacked form runs about 1.5×
hardware double at the same accuracy. None of this applies where FP64 already runs at full
rate: on a part whose FP64 pipe is half of FP32, hardware double is the fast path and
emulating it only slows the mix down. That is the first thing to establish about your target.
The two representations#
Type |
Stored as |
Size |
Construction |
|---|---|---|---|
|
The 64-bit IEEE bit pattern, exactly as a |
8 bytes |
Implicit from the built-in arithmetic types |
|
Separate sign, exponent and mantissa fields |
16 bytes |
Explicit — the cast is written out |
The packed form is the drop-in one: the size of a double, the bit pattern of a double,
converts implicitly, and at the default accuracy produces identical results. Its advantage over
double is purely where the work happens.
The unpacked form exists to avoid re-packing between operations, and that buys accuracy as well as speed. Its mantissa field extends the 53-bit binary64 significand by 9 guard bits, so intermediate computations on values that stay unpacked run on 62 significand bits and round to the storage format once, at the pack, rather than after every operation.
Two consequences follow, and both matter:
A chain of unpacked operations is not bit-identical to
double, even atfpemu_accuracy::high, whose individual operations are. The behavior is that of computing in x87 double-extended precision and storing at the end. Code that has to reproducedoubleexactly should use the packed form.16 bytes per value against 8 can cost occupancy in kernels with many live values.
Accuracy levels#
Selected per type, and available for both representations — fp64emu_high, fp64emu_mid,
fp64emu_low, and the same three names with _unpacked_:
Type |
Level |
Mantissa |
Range |
|---|---|---|---|
|
|
Correctly rounded |
Full IEEE-754: infinities, NaNs, subnormals |
|
|
1–2 ulp error |
Normal range only |
|
|
Up to half the mantissa bits lost |
Normal range only |
|
|
The default selector, equal to |
Full IEEE-754 |
The default is the IEEE-correct level, so the type is safe to reach for without knowing this table;
the lower levels are opt-in. This is the opposite of the fpmp convention, where the default is
the middle level — worth knowing if you use both. Stepping down costs special values as well as
precision: in the packed form mid and low handle the normal range only, and do not carry
the subnormal, NaN and infinity handling that high does. The unpacked form is better behaved
here, because the pack and unpack routines it shares across every operation are full-range at
every accuracy level — denormals normalized, infinities and NaNs encoded — so stepping down there
reduces the precision the core produces without discarding the range handling at the boundary.
What stepping down does not cost is exponent range, and that is what makes low more useful
than its precision alone suggests. The format is still binary64, so a low value reaches to
about ±10308 where a float stops at ±1038, while its arithmetic works on the
top 24 bits of the significand — float’s width. That combination, float precision over
double’s range, is what the level is for: a power series, say, whose terms are perfectly well
served by 24 bits of significand but whose intermediate products or factorials leave float’s
exponent behind long before the sum has converged. Running the series in fp64emu_low keeps those
intermediates in range at the cheapest arithmetic the component offers — up to 2.3× native
double, the fastest rows in the table below — rather than paying for double precision that the
computation was never going to use. Its error is biased rather than scattered, however, which is
what limits it in a long accumulation; the measurements
below show the effect.
The four rounding modes rn (nearest), rz (toward zero), ru (toward +∞) and rd
(toward −∞) are available at every accuracy level through the intrinsic spellings listed under
Operations — but for the packed form only.
The unpacked form offers _rn and nothing else, so a computation that needs directed rounding
needs the packed representation. The operators themselves round to nearest in both, as they do for
double.
Using the types#
#include <cuda/fpemu>
The component lives in the cuda namespace. Type names carry the namespace; the
standard-named math functions are left unqualified and found by
argument-dependent lookup, so a body of double code keeps its call sites when the type
underneath is swapped:
cuda::fp64emu x = 2.0; // implicit, as it would be to double
auto r = sqrt(x); // unqualified: ADL finds the fpemu overload
Construction and conversion#
Conversion |
into |
into |
|---|---|---|
from |
implicit |
cast required |
from |
implicit |
cast required |
from any standard integer type |
implicit, at every width |
cast required |
to |
implicit |
implicit |
to |
implicit |
implicit |
to a standard integer type |
implicit, truncating |
implicit, truncating |
The packed form is deliberately as permissive as double itself, which is what makes it a
drop-in — including the integer constructors, which are implicit at 64 bits as well as 32, on the
same principle that makes long to double implicit despite its potential loss. The unpacked
form asks for the cast on the way in, since entering it is a change of representation rather
than only of type; the practical consequence is that acc += 0.5 becomes
acc += cuda::fp64emu_unpacked{0.5}.
Conversions out are those of double: float and the integer types are reachable without
a cast from either form. The unpacked form rounds to double as it goes, which is the deferred
rounding the representation exists for.
fpemu therefore mirrors the conversion rules of the built-in types rather than the C++23 ones
for extended floating-point types that
fpmp follows. The packed form holds what
binary64 holds, neither more nor less, so a conversion to or from double loses nothing either
way. The unpacked form carries more, but as an intermediate representation rather than a storage
format — which is why it is the one asking for the cast on the way in.
__int128 and __uint128 are = deleted in both directions, and __float128 on the way
in, rather than being absent — so the diagnostic names the rule instead of failing obscurely. Note
also that only the default constructors are constexpr: the converting constructors call into the
emulation, so a value cannot be built at compile time.
Operations#
Operation |
C++ |
Intrinsic spelling |
|---|---|---|
Add, subtract, multiply, divide |
|
|
Fused multiply-add |
|
|
Square root |
|
|
Multiply-add |
|
|
Dot product |
|
|
Complex multiply |
|
All six comparisons follow IEEE-754 semantics, for both representations. Mixed-type arithmetic
works directly, so an fpemu value combines with a built-in scalar without a cast on the scalar
side. Note that cmul() returns void and writes its real and imaginary results through two
reference parameters, so it is not an expression-style call like the others.
The CUDA-style intrinsic spellings in the third column take these types as operands and deduce the
accuracy level from them, so existing intrinsic call sites port across unchanged. Their <rm>
suffix is where a rounding mode other than nearest is asked for, as described
above. mad is the exception with no such
suffix — __mad_rn is its only spelling, for both representations.
There are no transcendental math functions for fpemu — no exp, log or
trigonometry. Arithmetic, fma and sqrt are the surface. That is the main functional
difference from the fpmp types, whose header carries a full math API.
Objects of both representations may be declared volatile, for the legacy CUDA pattern of
holding shared-memory scalars in volatile variables. As for fpmp2, support is limited to
storage — loads, stores and copies, with a bit-preserving round-trip and trivial copyability
retained. Arithmetic and comparison take const fpemu& and will not bind a volatile lvalue, so
compute on a non-volatile copy and store the result back.
Measured performance#
The figures below come from a midpoint-rule π integration — 226 terms, five arithmetic
operations per term, no memory traffic worth speaking of — so they price arithmetic pipelines
rather than bandwidth, and every type runs identical code. “Correct digits” means correct decimal
digits of the computed integral, with every step carried out in the type being measured, the
sequential reduction of the 131072 per-thread partials included: what is reported is therefore what
a program written in that type costs end to end, not the loop in isolation. Native double is
measured in the same run, as the baseline each row is reported against, and accuracy is a property
of the arithmetic rather than of the GPU, so it is one column for all three parts:
See also
fpemu examples — the program behind these figures, which selects the type it measures from the build line.
Type |
Correct digits |
RTX 6000 Ada |
RTX PRO 6000 Blackwell |
B300 |
|---|---|---|---|---|
|
14.8 |
1.00× |
1.00× |
1.00× |
|
14.8 |
1.02× |
1.12× |
0.98× |
|
16.1 |
1.47× |
1.65× |
1.46× |
|
11.3 |
1.43× |
1.61× |
1.46× |
|
14.0 |
1.78× |
1.96× |
1.81× |
|
3.1 |
1.72× |
1.95× |
1.76× |
|
3.1 |
2.12× |
2.30× |
2.05× |
Two results to read off it. The packed form at the default accuracy reproduces double bit for
bit — the two rows are not merely equal to the digit shown, they return the same value — while
running at parity with it, 0.98× to 1.12× across the three, so exact FP64 semantics are available
without using the FP64 pipe at all. And the unpacked form runs about 1.5× hardware double and
comes out 1.3 digits ahead of it, which is the deferred rounding
described above showing up twice over:
the pack/unpack tax is paid once at the boundary rather than once per operation, and the guard bits
that survive between operations absorb error that double has to round away — including the
error of the long final summation, which is where most of it is.
The emulation executes on the integer and FP32 pipes and never touches an FP64 unit, which has an
interesting consequence on parts like these: the FP64 hardware sits idle while the emulation runs,
and would still deliver its own rate if asked. Splitting a reduction across both — part of the
range on native double, the rest on fp64emu_unpacked — is therefore possible in principle,
and the unpacked form is the natural one for it, its pack boundary being where the handover
belongs. No such kernel has been built and measured here, so treat that as a direction rather than
as a result.
What this benchmark can and cannot separate#
The accuracy column above is one integration at one term count, and the rows do not all respond the same way as the chain of operations gets longer. Rerunning it from 222 to 226 terms, which lengthens each thread’s accumulation from 32 iterations to 512:
lowis indistinguishable fromfloat— 3.3, 2.9 and 3.1 digits at the three term counts, the same figuresfloatreports, and at some of them the same value bit for bit. That is what the level is: its fast path reaches 24 bits by truncating each operand’s significand, so once a computation carries its own summation rather than handing it to something wider,lowisfloatarithmetic with a wider exponent, and it degrades with term count for the same reasonfloatdoes. Packed and unpacked agree exactly here too, the guard bits having nothing left to protect.Packed
midis pinned at 11.3 digits at every term count, its error sitting at −1.7e−11 throughout. An error that does not move as the number of operations grows is a relative bias: each term comes out short by about the same fraction, so their sum is short by that fraction however many there are. A rounding error scattered about zero would instead grow with the chain. The 11.3 above is therefore a property of the level, not of this chain length.Unpacked
midis likewise flat, at 14.0, because its 1–2 ulp error lands in the guard bits below the stored significand rather than in the value that survives. Flat is enough to win here:doublewanders between 13.7 and 14.8 over the same sweep, so unpackedmidis level with it at 226 and slightly ahead beyond that.At the default accuracy, packed
fp64emureturns the same value asdoubleat every term count, while unpacked improves as terms are added — 14.8, 15.9, 16.1 — pulling further ahead ofdoublethe longer the summation gets. The guard bits are absorbing summation error thatdoublehas to round away at every step, and there is more of it to absorb at every term count.
Which is the general caution: measure the computation you have, in the way you have it. These
figures carry the reduction, so they describe a program written end to end in one type. Reduce in
something wider instead — as a program would if it kept a more accurate accumulator — and most of
what separates the rows goes with it: the summation error disappears from both sides, low
parts company with float, and the unpacked form comes back level with double rather than
ahead of it. Neither measurement is the truthful one in general. The one that applies is whichever
matches where your accumulator lives.