Skip to content

Standard IEEE-754 Types: quarter, half, single, duble, quad, octo

cfloat can express any floating-point format you can describe. That power is also the problem: a reader who wants “just an IEEE single” has to know that it spells as

cfloat<32, 8, std::uint32_t, true, false, false>

and has to be confident about all six parameters – that true is subnormals, that the first false is supernormals, that the second false is saturating arithmetic. Get one wrong and you have silently built a format that is not the one the hardware implements, and your comparison against native float is measuring your typo.

Universal therefore predefines the IEEE-754 interchange formats under their ordinary names. These are the formats a compiler, a GPU, or an FPGA soft core is most likely to implement, so they are the ones worth reaching for first:

#include <universal/number/cfloat/cfloat.hpp>
using namespace sw::universal;
single x = 1.0f; // exactly IEEE-754 binary32
duble y = 1.0; // exactly IEEE-754 binary64
quad z = 1.0; // exactly IEEE-754 binary128, no hardware required

Each type has two spellings: a word name (single) and an fpNN name (fp32). They are the same type – using fp32 = single; – so pick whichever reads better in your code. The fpNN form is usually clearer in mixed-precision work where the bit width is the point.

type alias nbits es fbits exponent range maxpos epsilon
quarter fp8 8 2 5 1 .. 2 3.938e+00 3.125e-02
half fp16 16 5 10 -13 .. 16 6.550e+04 9.766e-04
single fp32 32 8 23 -125 .. 128 3.403e+38 1.192e-07
duble fp64 64 11 52 -1021 .. 1024 1.798e+308 2.220e-16
xtndd fp80 80 15 64 -16381 .. 16384 1.190e+4932 5.421e-20
quad fp128 128 15 112 -16381 .. 16384 1.190e+4932 1.926e-34
octo fp256 256 19 236 -262141 .. 262144 3.648e+78841 9.056e-72

All values measured from the types themselves, not from a double round-trip – quad and octo exceed double’s range, so printing them through one reports inf.

half, single, duble, quad and octo match the IEEE-754 interchange formats binary16, binary32, binary64, binary128 and binary256 exactly, field for field. All six enable subnormals and disable supernormals and saturating arithmetic, which is what IEEE-754 specifies.

double is a C++ keyword. The library needs a name for “the Universal type that is IEEE-754 binary64” that is distinct from the built-in, and duble is that name. If the spelling bothers you, fp64 is the same type.

quarter is not the 8-bit format your ML framework means. It is the IEEE-754 interchange rule extended down to 8 bits: 2 exponent bits, 5 fraction bits, dynamic range topping out below 4. That makes it a faithful small IEEE format and a poor neural-network format. The formats deep-learning toolchains actually use are in microfloat:

#include <universal/number/microfloat/microfloat.hpp>
e4m3 a; // OCP OFP8 E4M3 -- NaN on overflow
e5m2 b; // OCP OFP8 E5M2 -- wider range, less precision

xtndd matches x87’s range but carries one bit more precision. As of #1599 it is cfloat<80,15>, so its exponent range [-16381, 16384] and its decimal range [-4931, 4932] are native x87’s. It previously had es = 11, which gave it double’s range in an 80-bit container and overflowed to inf everywhere above 1.8e308 that a real x87 long double is fine.

It is still not bit-compatible, and the reason is structural rather than an oversight: x87 stores an explicit integer bit, spending one of its 80 bits on the significand’s leading 1, so it carries 64 bits of precision. cfloat uses an implicit leading bit, so the same 80 bits give 64 fraction bits plus the implicit one – 65 bits, one more than x87. Closing that would need explicit-integer-bit support in cfloat.

That extra bit also moves the maximum. x87’s maxpos is (2 - 2^-63) * 2^16383 and xtndd’s is (2 - 2^-64) * 2^16383, so xtndd’s is the larger, and there is a narrow band at the top of the range where an x87 long double overflows and xtndd does not – converting xtndd’s maxpos to a native long double gives inf. So the two agree on exponent range and on the decimal range numeric_limits reports, and differ in the last bit and at the overflow boundary.

They are ordinary Universal types, so everything the library offers applies:

#include <universal/number/cfloat/cfloat.hpp>
#include <iostream>
using namespace sw::universal;
template<typename Real>
Real dot(const std::vector<Real>& a, const std::vector<Real>& b) {
Real sum{0};
for (size_t i = 0; i < a.size(); ++i) sum += a[i] * b[i];
return sum;
}
int main() {
// the same kernel, four precisions, no code changes
std::cout << dot<half> ({1.0, 2.0}, {3.0, 4.0}) << '\n';
std::cout << dot<single>({1.0, 2.0}, {3.0, 4.0}) << '\n';
std::cout << dot<duble> ({1.0, 2.0}, {3.0, 4.0}) << '\n';
std::cout << dot<quad> ({1.0, 2.0}, {3.0, 4.0}) << '\n';
}

Bit-level inspection works the same way across all of them, which is the usual reason to reach for a Universal type over the native one:

single x = 0.1f;
std::cout << to_binary(x) << '\n'; // sign|exponent|fraction, field-separated
std::cout << color_print(x) << '\n'; // the same, coloured by field
std::cout << scale(x) << '\n'; // the unbiased exponent

Every one of these types has quire support, so you can accumulate a dot product with a single rounding at the end:

#include <universal/number/cfloat/fdp.hpp>
std::vector<single> y = { 1.0f, 1.0f, 1.0f, 1.0f };
// the same four terms, three orderings
dot({1e8f, -1e8f, 0.5f, 0.25f}, y); // 0.75 -- happens to be right
dot({1e8f, 0.5f, 0.25f, -1e8f}, y); // 0.0 -- both small terms lost
dot({1e8f, 0.5f, -1e8f, 0.25f}, y); // 0.25 -- one small term lost
fdp(...) // 0.75 for every ordering

That is the real hazard, and it is worse than a single wrong answer: the naive sum is order-dependent, so the same data summed in a different order gives a different result, and neither is flagged. The quire accumulates the products exactly and rounds once, so fdp returns 0.75 whatever order the terms arrive in.

See Exact Arithmetic for what the quire is doing.

If you want reach for
A drop-in for native float / double, with bit inspection single / duble
Precision beyond double, on any hardware quad, octo
Precision beyond double, as fast as possible dd, qd
Precision that grows until the answer converges ereal, elreal
A storage format for neural network weights microfloat, bfloat16
A format nobody has standardised yet cfloat directly

quad and octo are software, so they are far slower than dd and qd at comparable precision. Choose them when you need a standard format – an interchange file, a reference answer, a conformance test – and choose dd/qd when you need the digits and do not care about the encoding.

digits10 and max_digits10 answer two different questions, and mixing them up is the usual source of a round-trip bug:

trait question it answers
digits significand bits, including the implicit leading bit
digits10 decimal digits that survive a round trip into the format
max_digits10 decimal digits needed to round trip out of and back in without loss
type digits digits10 max_digits10
half 11 3 5
single 24 6 9
duble 53 15 17
quad 113 33 36
octo 237 71 73

These match what native float and double report for the same formats, which is the property you want when substituting single for float. If you are printing a value and expect to read it back unchanged, use max_digits10 – digits10 is the smaller number and will silently lose the last place.

std::cout << std::setprecision(std::numeric_limits<single>::max_digits10) << x;

They were computed from a digits / 3.3 approximation until #1597, which reported 7 digits for a binary32 where the standard says 6.