fpemu: Examples#
Two programs, with different purposes. The first is the example that ships with CCCL, which shows what each operation does and is the place to start. The second is the benchmark behind the figures on the fpemu page, which shows what the emulation costs against the hardware it is replacing.
The example that ships with CCCL#
examples/cudax/fp/fpemu.cu
covers both representations with one kernel each — fpemu_packed_operations and
fpemu_unpacked_operations — over the same operations with the same inputs, so the two read
almost identically and the differences stand out: the unpacked type needs a written-out cast
wherever the packed one converts implicitly. Construction, arithmetic including the mixed-type and
+= scalar paths, sqrt and fma, the comparisons, the accuracy levels side by side, and
conversion between the two representations are all shown, each on the host and in a kernel, with
both results printed.
Its closing section is the one result that cannot be seen in a single operation. It adds 1000
terms of 1e-17 onto 1.0. One ulp of 1.0 is about 2.22e-16, so each term is some
twenty times too small to move a double at all: double and packed fp64emu both never
leave 1.0, while the unpacked form’s guard bits accumulate the terms and reach the exact
answer. That is the deferred rounding of the unpacked representation, in the smallest case that
makes it visible.
It builds with the other CCCL examples, and the
sibling programs do the same for
fpmp and the fptool types. Those examples are also where the CCCL runtime API usage worth
copying lives — device enumeration that degrades gracefully when there is no GPU, buffers that
release themselves, and a launch configuration built with cuda::make_config.
Measuring against hardware double#
The benchmark is the same program as on the fpmp examples page, which computes
by the midpoint rule, once in native double and once in a type of your choosing, and reports
for each the value, how many decimal digits of it are correct, and how long the kernel took. It is
deliberately a poor numerical method and a good benchmark: five arithmetic operations per term with
no memory traffic worth speaking of, so what it prices is arithmetic pipelines rather than
bandwidth, and both runs execute identical code. That page carries the full listing and the
measurement details — the warm-up launch, the reduction carried out in the type under test rather
than in something wider, and the double-double reference.
Nothing in it is specific to one sub-component. The kernel is written against a type parameter, and the conversions are written out because a narrowing conversion into these types has to be explicit — which is exactly what the unpacked representation requires everywhere:
template <class T>
__global__ void pi_kernel(long long n, T* partials)
{
const int tid = static_cast<int>(blockIdx.x * blockDim.x + threadIdx.x);
const int nthreads = static_cast<int>(gridDim.x * blockDim.x);
const T h = T(1.0 / static_cast<double>(n));
const T four = T(4.0);
const T one = T(1.0);
T x = (T(static_cast<double>(tid)) + T(0.5)) * h;
const T dx = T(static_cast<double>(nthreads)) * h;
T acc = T(0.0);
for (long long i = tid; i < n; i += nthreads)
{
acc += four / (one + x * x);
x += dx;
}
partials[tid] = acc;
}
The type and the header that declares it come from the build line, so measuring an fpemu type
is a rebuild rather than an edit. All three include roots have to come from one CCCL checkout,
because the toolkit ships its own copies of CUB and Thrust and their version checks reject being
paired with a libcudacxx from somewhere else:
nvcc -std=c++20 -arch=native \
-DPI_FP_T=cuda::fp64emu_unpacked -DPI_FP_HEADER='<cuda/fpemu>' \
-I <cccl>/libcudacxx/include -I <cccl>/cub -I <cccl>/thrust pi.cu -o pi
On an RTX 6000 Ada, where FP64 runs at a fraction of FP32:
pi by the midpoint rule, 67108864 terms, 131072 threads
on NVIDIA RTX 6000 Ada Generation, sm_89
type value digits time (ms)
double 3.1415926535897984 14.78 1.297
cuda::fp64emu_unpacked 3.1415926535897936 16.10 0.881
cuda::fp64emu_unpacked is 1.47x the speed of native double, for +1.3 digits
Software double precision, beating the hardware double precision on the same part on both axes —
and the accuracy it gains is the summation error that its guard bits absorb and double has to
round away. Rebuilding with -DPI_FP_T=cuda::fp64emu gives the packed form, which reproduces
double bit for bit at about parity on time, and the _mid and _low names walk the
accuracy levels. The fpemu page collects those figures
across the parts measured.
Two things to expect when running it. Absolute times move with clocks and with what else is on the
GPU, so the ratio is the stable quantity, not the milliseconds. And -DPI_LOG2_TERMS=nn is worth
a look here in particular: as terms are added the unpacked form keeps gaining on double, which
stalls near 14 digits, while packed mid sits unmoved at 11.3 and low tracks plain float
down — for the reasons given
on the fpemu page.