Wado

WEP: Half-Precision Primitives (f16 / bf16)

Context

A machine learning checkpoint is mostly half precision, and most of the ones published today are bf16. Loam reads such a checkpoint and emits a module whose input and output tensors carry its element type, so a graph declaring float16 or bfloat16 stops the build naming a type Wado does not have. core:cbor wants the same type for a different reason: RFC 8949 §4.2.1 asks a canonical encoder to emit the shortest float that preserves the value, and without an f16 the ladder stops at binary32.

Neither caller computes in half precision. On WebGPU a shader-f16 device takes f16 in a storage buffer as is, any device unpacks it with unpack2x16float, and bf16 is the top half of an f32, so a shift in the shader is its whole conversion. A canonical CBOR encoder narrows, checks the round trip, and writes bytes. Both need a type that carries bits, and neither needs arithmetic.

Wasm 3.0 has no half precision type, so nothing below the compiler supplies one either. Whatever Wado declares, the engine sees 16 bits.

Two ways to declare such a type are already in use, and neither fits. i128 and u128 are prelude structs, so a List of one is an array of references, and a checkpoint of a million weights becomes a million GC objects. A newtype over u16 inherits u16's operators, so a + b compiles to an integer addition of two bit patterns.

The third way fits. v128 is a primitive whose representation the compiler knows, and the Component Model has no type for it. A half precision type needs both of those properties, with u16 as the representation, so that a List of it is a packed 16-bit array.

Decision

The types are storage-only primitives

f16 and bf16 are declared as every primitive is, in core:prelude, and the compiler knows their representation as it knows v128's: a u16 in every position. A List<f16> is the same packed array as a List<u16>, and a struct field of either packs the same way.

They carry no arithmetic today. + and the bitwise operators are rejected, on the same path that rejects them for v128. Neither caller computes in half precision, and Wasm has no instruction to lower an operator to. Whether Wado grows half precision arithmetic later is open; nothing decided here forecloses it.

The comparisons are the exception, and they are not arithmetic. Eq and Ord are written in core:prelude/half.wado, and each hands the widened value to f32's. Widening is exact and order-preserving, so a half never has an answer of its own to get wrong. Default is there too, and is the zero.

What a comparison answers is the same for a half as for f32, and WEP: One Order per Type decides it.

bf16 is kept rather than converted to f16 on load. That conversion is the one lossy direction, because bf16 has f32's exponent range and f16 does not, and it would touch every byte of a payload the loader otherwise passes through unchanged.

Neither type crosses a component boundary

The Component Model has no half precision type. Its defvaltype runs from bool through the integer widths to f32 and f64, and nothing narrower is proposed. Lowering one as a u16 would put a bit pattern on the wire under a name that says integer, and no other language's bindings would read it back as a float.

So an export fn naming either type is a compile error, as it is for v128. Every export fn is checked, not only the one the world names: each one lands on the component's surface. A component that wants to carry those bytes declares a list<u16>.

The representation does not wait on the FP16 proposal

Core Wasm's half precision proposal is at phase 2. It adds an f16 SIMD lane type, and it states that f16, like i8 and i16, is not a first class value type. No version of it gives Wado an f16 local, parameter or global. Wasm 3.0's packed storage types are i8 and i16, and the proposal adds none, so a GC array of half precision values is an array of i16 whatever ships.

So u16 in every position is settled rather than provisional, and so are the refusal at the component boundary and the whole API above it. What the proposal would reach is the body of the widening and of from_f32, one prelude function each. Its conversions run between f16x8 and f32x4, and its scalar f32.load_f16 reads linear memory, which is not where a List<f16> lives.

What the compiler knows

The representation, that there is no arithmetic, which casts are rejected, the bit reinterpretations, and that neither type crosses a component boundary. Everything else is written in Wado in the prelude: the widening to f32, Display, Inspect, the conversion traits, and serialization. None of it needs a compiler that knows more.

Below the Wasm-shaped IR the types are u16, so a dump of that IR reads u16, as it does for a newtype. The IR cannot act on the distinction, so it does not carry it.

There is no as for either type

f32 as u32 converts a value: 1.0 becomes 1. A f16 as u16 would reinterpret bits: 1.0 becomes 15360. One syntax cannot carry both meanings, and as already carries the first one everywhere else.

So neither type takes part in as at all. The one cast that still works is the one every type has, between a newtype and the type it wraps, which shares a representation and converts nothing. Every other as naming f16 or bf16 on either side is a compile error, and the error names what to write instead: to_bits, f32::from, f16::from_f32, or, between the two half types, nothing.

Bits go through to_bits, values through From

impl f16 {
    pub fn to_bits(&self) -> u16;
    pub fn from_bits(bits: u16) -> f16;
    pub fn from_f32(v: f32) -> f16;
    pub fn from_f64(v: f64) -> f16;
}

impl From<f16> for f32 { }
impl From<f16> for f64 { }
impl TryFrom<f32> for f16 { type Err = ConvertError; }
impl TryFrom<f64> for f16 { type Err = ConvertError; }

bf16 carries the same set. Both also carry the limits f32 and f64 do, under Rust's names: MAX, MIN, MIN_POSITIVE, EPSILON, INFINITY, NAN, MANTISSA_DIGITS and the rest.

Everywhere else in the prelude From preserves a value, as From<u8> for u128 does. So bit reinterpretation is not written as From. An impl From<u16> for f16 would say that 15360 becomes 1.0, which repeats the as confusion in a second spelling. Bits go through to_bits and from_bits, the names f32 and f64 already use.

From widens, and widening either type into either float is exact. TryFrom narrows, and answers Ok only where the value survives the round trip, which is the question a canonical encoder's float ladder asks. from_f32 and from_f64 narrow unconditionally, rounding to nearest even and saturating to an infinity. They are named methods rather than casts, so a lossy step is never silent.

from_f64 rounds once. Narrowing to f32 first and then to the half would round twice, and the two disagree where the f32 lands exactly on a half's midpoint that the f64 was not on.

The bit accessors are unsigned across the family

f32::to_bits answers u32, f64::to_bits answers u64, and from_bits takes the same. The half types follow that rule instead of being an exception to it.

The signed forms mirrored the Wasm opcode names, i32.reinterpret_f32 and its siblings. Almost every caller in the corpus then undid the signedness on the next token, casting a result straight back to u32, or casting an unsigned bit pattern to i64 to pass it in. A bit pattern is not a negative number.

The half types would have to work around it too. Every negative f16 pattern is above 0x7FFF, so a signed parameter would refuse f16::from_bits(0xBC00).

The builtin:: layer keeps the opcode names and their signedness, because it is the Wasm-shaped layer. The conversion lives in the one prelude wrapper above it.

Display widens, Inspect uses exponent notation

${x} widens to f32 and prints as f32 does, so every format specifier works unchanged. ${x:?} prints in exponent notation, which is what a weight's magnitude reads best in. A tensor that cannot be printed cannot be debugged.

Serialization writes an f32 and reads a half

Serialize widens to f32, which is exact, so the Serializer trait gains no methods. The Deserializer trait gains deserialize_f16 and deserialize_bf16. Each format rounds what it read once, straight into the half. JSON and the text formats round the decimal, and CBOR rounds its float or integer item. Reading an f32 and narrowing it would round twice.

Reading rounds rather than using TryFrom. A serialized number is decimal text or a wider binary float, and almost none of those land exactly on a half precision value, so an exact-or-fail read would reject ordinary documents. Writing is exact, so a round trip through a format that preserves f32 preserves the value.

A literal is rounded once, from its decimal text

let x: f16 = 1.5; and let w: List<bf16> = [0.5, -1.25]; take a float literal as f32 does. A tensor written out as source is a list of such literals, and one spelled as bits cannot be read.

The literal is rounded once, from its exact decimal value to the nearest half, ties to even. Reading it as an f64 first and narrowing that is two roundings, which disagree where the f64 lands exactly on a midpoint the decimal was not on. Every float literal is rounded this way, f32 included.

The compiler's rounding and the prelude's from_f32 start from different values, a decimal and an f32, so they need not agree where the f32 is itself a rounding. Where it is exact they must, and a fixture pins that, so neither copy drifts silently.

A literal past the type's largest finite value is a compile error rather than an infinity, as an integer literal past its type's range is.

Parsing rounds once too

f16::from_str and bf16::from_str round decimal text straight to the half, as a literal does, so parsing agrees with the compiler. f32::from_str follows the same rule, and every length of mantissa is read exactly. Text past the largest finite value parses to an infinity, as it does for f32 and f64. from_str_lenient takes the same text with LenientFromStr's spellings.

What is deliberately absent

Conversion between f16 and bf16. Each direction loses something the other keeps: f16 has the shorter exponent range, bf16 the shorter mantissa.

Roadmap

Nothing outstanding. What the types do not yet reach is in Known gaps, and none of it is committed work.

Known gaps

No arithmetic, so every computation widens to f32 and narrows again to store. A generic body bounded on Add cannot be instantiated at either type.

A comparison widens both operands and calls cmp, where f32's lowers to instructions. Nothing measured has asked for a faster one.

A half precision tensor cannot be part of a component's public API, so a Loam module that exports one has to widen it or hand out its bytes.

core:cbor still stops its canonical float ladder at binary32, so to_bytes_canonical may differ from a reference deterministic encoder on float-bearing values. The type it needed now exists; the encoder does not use it.

core:simd has no f16x8, so nothing widens eight weights at once. The FP16 proposal's lane operations are where that would come from, and Loam's CPU arm is the caller that would ask for it.

A List<f16> cannot be viewed as bytes. AsByteSlice covers the byte-element sequences and String, and WasmGC offers no way to read an array i16 as an array i8, so anything that takes bytes takes a copy. wasi:webgpu writes a buffer from a List<u8>, which puts that copy between a tensor and the GPU.

WGSL's f16 is behind the shader-f16 feature, which requires 16-bit access in uniform and storage buffers. Qualcomm devices do not offer that access, so the feature is unavailable there however well the hardware computes in f16. unpack2x16float is core WGSL and needs no feature.

Neither WGSL nor the FP16 proposal has a bf16 type, so widening bf16 is a shift wherever it runs.

The narrower dtypes stay out of the type system altogether. int4 packs two elements to a byte with a scale per block, and f8 ships with its scales too; WGSL has a type for neither and unpacks both from u32 with shifts. They remain bytes in a checkpoint and a tag inside a generator.