Floating-Point

A floating-point type holds a binary fraction with an exponent, covering a vast range at a fixed number of significant digits. Rux has eight widths: the two IEEE 754 formats every supported machine computes in hardware, float32 and float64, and six more that the compiler computes itself.

The floating-point types

TypeBitsBytesFormatSignificant digitsLargest finite valueSuffix
float881E4M3: 4 exponent, 3 fraction bitsabout 1240f8
float16162IEEE 754 binary16about 365,504f16
float32324IEEE 754 binary32about 7≈ 3.40 × 1038f32
float64648IEEE 754 binary64about 16≈ 1.80 × 10308f64
float808016x87 extended precisionabout 19≈ 1.19 × 104932f80
float12812816IEEE 754 binary128about 34≈ 1.19 × 104932f128
float25625632IEEE 754 binary256about 71≈ 1.61 × 1078913f256
float51251264binary512: 23 exponent bitsabout 147≈ 2.07 × 101262611f512

float is a built-in alias of float64, and float64 is the type of an unsuffixed floating-point literal. Use float64 by default, and float32 where storage or bandwidth matters more than precision, such as large arrays of samples. The other widths are for data that arrives in their format and for precision beyond float64. They cost more: the compiler computes them with integer instructions rather than the machine's floating-point unit.

  • float8 is the E4M3 format: one sign bit, four exponent bits with a bias of 7, and three fraction bits. It keeps IEEE-style infinities and NaNs in the all-ones exponent, so its largest finite value is 240.
  • float80 is the x87 extended format: a 64-bit significand with its integer bit stored, and a 15-bit exponent. Its 80 bits of value are held in 16 bytes, so float80::Bits is 80 while float80::Bytes and sizeof(float80) are 16.
  • float512 continues the IEEE 754 pattern past binary256, with a 23-bit exponent and 489 bits of precision.

Associated constants

Imported from Core like the integer constants, every width has the same eight:

ConstantMeaning
BitsWidth of the value in bits (uint)
BytesStorage size in bytes (uint)
MaxLargest finite value
LowestMost negative finite value, -Max
MinPositiveSmallest positive normal value
EpsilonGap between 1.0 and the next value up
InfinityPositive infinity, which prints as Inf
NaNA quiet not-a-number, which prints as NaN
TypeMaxMinPositiveEpsilon
float82402−6 = 0.0156252−3 = 0.125
float1665,5042−14 ≈ 6.10 × 10−52−10 ≈ 9.77 × 10−4
float323.4028235e+381.1754944e-381.1920929e-07
float641.7976931348623157e+3082.2250738585072014e-3082.220446049250313e-16
float80≈ 1.19 × 1049322−16382 ≈ 3.36 × 10−49322−63 ≈ 1.08 × 10−19
float128≈ 1.19 × 1049322−16382 ≈ 3.36 × 10−49322−112 ≈ 1.93 × 10−34
float256≈ 1.61 × 10789132−262142 ≈ 2.48 × 10−789132−236 ≈ 9.06 × 10−72
float512≈ 2.07 × 1012626112−4194302 ≈ 1.94 × 10−12626112−488 ≈ 1.25 × 10−147

Core writes the limits of float8 through float64 as literals. Infinity and NaN at every width, and every limit of float80 and wider, are intrinsic constants whose values the compiler takes from the format itself.

import Core::{ float16, float64 };
import Io::PrintLine;

func Main() -> int {
    PrintLine("{} {}", float64::Max, float64::Epsilon);
    PrintLine("{} {}", float64::Infinity, -float64::Infinity);   // Inf -Inf
    PrintLine("{} {}", float16::Max, float16::Epsilon);          // 65500.0 0.000977
    return 0;
}

float16::Max prints as 65500.0 because {} writes the shortest decimal that reads back as the same value at its own width, and no other float16 is nearer to 65,500 than 65,504 is.

Printing the wide widths

float8 through float64 implement Display and Debug, so {} prints them. float80 and wider do not: the exact arithmetic that finds their digits allocates, and those protocols offer nowhere to pass an allocator. PrintLine("{}", 1.0f80) is therefore an error:

error: no matching overload for 'PrintLine' with argument types (char8[..], float80)

Render one through the Format package's Float80 to Float512 values instead, which take the allocator explicitly:

import Allocator::{ Allocator, SystemAllocator };
import Core::{ Succeeded, float128 };
import Format::{ ByteCursor, Float128 };
import Io::PrintLine;
import Text::{ FormatSpec, TextWriter };

func Main() -> int {
    let third = 1.0f128 / 3.0f128;

    var system = SystemAllocator();
    let allocator: Allocator = system;
    var storage: char8[64];
    var cursor = ByteCursor(storage[..]);
    let writer: &var TextWriter = cursor;
    if !Succeeded(Float128::FromValue(third).FormatWith(allocator, writer, FormatSpec::Plain())) {
        return 1;
    }
    PrintLine("{}", cursor.Written());   // 0.3333333333333333333333333333333333
    return 0;
}

Literals

A floating-point literal has digits on both sides of a point, an exponent, or a float suffix: 3.14, 2.998e8, 1.6e-19, 0.5f32, 1f16. A suffixed literal is read exactly at its own width: 0.1f128 is the float128 nearest to one tenth, not a float64 widened. Without a suffix a literal is a float64, whatever the context:

let single: float32 = 0.75;
error: cannot assign 'float64' to 'float32'

Write 0.75f32. A literal too large for its width is an infinity: 300.0f8 is Inf. Literals gives the full grammar and every suffix.

Operations

OperatorsResultRules
+ - * /The operand typeIEEE 754, rounded to nearest with ties to even
%The operand typeRemainder of truncated division: 7.5 % 2.0 is 1.5
prefix -The operand typeFlips the sign, including of zero and infinity
== != < <= > >=boolIEEE 754 comparison

At every width, % is the exact remainder of truncating division, with the dividend's sign, as C's fmod computes it. Its non-NaN result agrees bit for bit with constant folding on every target, including large quotients. On x86-64, float32 and float64 remainder use x87 fprem; on AArch64 they use compiler-generated helpers.

Floating-point arithmetic never panics, at any width. Results follow IEEE 754:

ExpressionResult
1.0 / 0.0Inf
-1.0 / 0.0-Inf
0.0 / 0.0NaN

A NaN is unordered: every comparison with it is false except !=, so x != x is true exactly when x is a NaN. The Core functions IsNaN, IsInfinite, IsFinite, IsZero and IsNegativeZero test for special values at every width.

The bitwise and shift operators do not apply to floats: a & 1.0 is error: operator '&' requires an integer, bool, or character left operand, but found 'float64'.

Most decimal fractions have no exact binary form, so arithmetic on them rounds: 0.1 + 0.2 == 0.3 is false. Compare against a tolerance, or count in integer units such as cents.

Mixed widths

A float32 operand meets a float64 operand at float64. No other two widths mix: with half: float16 and single: float32, half + single is error: operator '+' cannot combine left operand 'float16' with right operand 'float32'. Convert one operand with as.

Because an unsuffixed literal is a float64, single * 2.0 is a float64, and half * 2.0 is an error. Write single * 2.0f32 to stay in float32, and half * 2.0f16.

How each width is computed

float32 and float64 use the machine's floating-point instructions. Every other width is computed by helper functions the compiler synthesizes from integer operations while it lowers the program. They are named __rux_float_f<bits>_<operation>, generated only for the operations a program uses, and private to it. They depend on no package, Core included, so arithmetic at every width works in a program with no dependencies.

float80 on x86-64 is the exception, because the x87 unit computes in exactly that format. +, -, *, /, %, the comparisons, conversion between float80 and float32 or float64, and conversion from a signed integer of at most 64 bits run on x87. Around each of these that rounds, the compiler sets the x87 control word to 64-bit precision with round-to-nearest and restores the caller's word afterwards, since Windows leaves the unit at 53-bit precision. --emit asm shows this code. Every other float80 conversion goes through the helpers, as all of float80 does on AArch64.

For float8, float16 and float80 through float512, every result that is not a NaN is bit-identical on every target, and identical to what the compiler computes when it folds the same expression while compiling. A NaN result is only guaranteed to be a NaN: its sign and payload may differ between targets, and between folding and running. A program that needs a NaN writes T::NaN, and tests for one with x != x or IsNaN, never by its bits.

Conversions

float32 converts to float64 implicitly; every float32 value is exactly a float64 value. That is the only implicit conversion between floating-point widths. Every other one, and every conversion between a float and another type, is written with as:

ConversionResult
To another float widthRounded to nearest with ties to even; a value beyond its range becomes an infinity
To an integer typeTruncated toward zero; beyond the range, saturated at Min or Max; NaN becomes 0
From an integer typeThe nearest representable value
To a boolean typefalse for a zero of either sign; true for anything else, NaN included
let ratio = 3.9;
let whole = ratio as int32;              // 3
let down = -3.9 as int32;                // -3
let capped = 1e10 as int32;              // 2147483647
let floor = -1.0 as uint8;               // 0
let single = 3.141592653589793 as float32;   // 3.1415927
let back = 7 as float64;                 // 7.0
let tenth = 0.1 as float16;              // 0.1, stored as 0.0999755859375
let zero = -0.0f16 as bool;              // false

Float-to-integer conversion is the same for every float width and every integer width, on every target, and whether it runs or is folded at compile time. It never wraps: 1e20 as int32 is int32::Max, not its low bits, and float512::Infinity as int512 is int512::Max. Round first, with the Math package, when rounding rather than truncation is meant.

The C boundary

C's float and double are float32 and float64, and they are the only floating-point widths the C calling convention knows. The others are passed by the compiler's own convention, which matches no C type — not _Float16, long double or __float128 — so one of them in an extern or #Abi signature, or as a C-variadic argument, is an error. See Foreign Function Interface.

See also