One byte. Exact quantization. No allocator. No std.
f8 is an unsigned normalized 8-bit number, representing bits / 255 in
[0, 1]. It is suitable for compact colors, coverage, probabilities, masks,
weights, and normalized device data. It is not IEEE FP8, E4M3, or E5M2.
[dependencies]
f8 = "0.2"use f8::f8;
const HALF: f8 = f8::from_f32(0.5);
assert_eq!(HALF.to_bits(), 128);
assert_eq!(HALF.to_f32(), 128.0 / 255.0);
assert_eq!(HALF + HALF, f8::ONE);
assert_eq!((HALF * HALF).to_bits(), 64);
let values = [HALF, f8::ONE];
assert_eq!(values.iter().sum::<f8>(), f8::ONE);
assert_eq!(values.iter().product::<f8>(), HALF);- Raw encoding
bdenotes the exact real numberb / 255. All 256 encodings are valid; zero and one are exact endpoints. from_f32(x)clamps to[0, 1], then rounds the exact product255*xto nearest, ties to even. All NaNs become zero; infinities saturate.to_f32()returns correctly roundedb / 255. Every byte round-trips.- Quantization error is at most
1/510against the clamped input, before the finalf32decoding roundoff. This is a uniform grid, not relative precision. - Addition/subtraction saturate instead of wrapping or panicking. Multiplication and division saturate and round to nearest, ties to even.
0 / 0 = 0; positive/ 0 = 1. No arithmetic operation panics, including in debug builds. Arithmetic is not IEEE floating-point arithmetic.
The rounding rule is deliberate. (x * 255.0_f32).round() is not equivalent:
it uses different tie-breaking and can round the multiplication onto a false
midpoint. The scalar and accelerated encoders avoid both problems using integer
significand arithmetic. See Numerics for the derivation.
The byte storage interoperates with UNORM8 formats such as R8_UNORM. This is
not a claim that every GPU's float-to-UNORM conversion uses identical rounding.
| Task | API | Semantics |
|---|---|---|
| Raw storage | from_bits, to_bits, From<u8>, Into<u8> |
Lossless byte encoding; not a numeric cast. |
| Quantization | from_f32, From<f32> |
Saturating, lossy, exact nearest-even rounding. |
| Decoding | to_f32, Into<f32> |
Correctly rounded normalized value. |
| Initialization | Default, ZERO, ONE, MIN, MAX |
Default is zero. |
| Ordering and maps | Eq, Ord, Hash |
Byte equality, total numerical ordering; no NaNs to handle. |
| Arithmetic | +, -, *, /, assignment forms |
Saturating UNORM arithmetic, owned or borrowed operands. |
| Const arithmetic | saturating_add/sub/mul/div |
Same semantics as the corresponding operators. |
| Reductions | Sum, Product |
Left folds; empty identities are zero and one. |
| Formatting | Display, Debug |
Display is the normalized f32; Debug exposes the newtype and byte. |
| Serialization | Serde Serialize, Deserialize |
Newtype f8 containing a u8, never a serialized float. |
Product quantizes each step, so grouping or reordering can change results.
Accumulate in a wider representation and quantize once when accuracy matters:
use f8::f8;
let weights = [f8::from_bits(1), f8::from_bits(128), f8::from_bits(128)];
assert_eq!(weights.iter().product::<f8>().to_bits(), 1);
let result = f8::from_f32(weights.iter().map(|x| x.to_f32()).product());
assert_eq!(result, f8::ZERO);Bulk conversion accepts caller-owned storage, does not allocate, and checks lengths before writing. Inputs need only their normal Rust alignment; vector widths, tails, and dispatch are internal details.
use f8::f8;
let mut packed = [f8::ZERO; 4];
f8::from_f32_slice(&[0.0, 0.25, 0.5, 1.0], &mut packed);
assert_eq!(f8::as_bytes(&packed), &[0, 64, 128, 255]);
let mut decoded = [0.0; 4];
f8::to_f32_slice(&packed, &mut decoded);
let mut bytes = [0, 128, 255];
f8::from_bytes_mut(&mut bytes)[0] = f8::ONE;
assert_eq!(bytes, [255, 128, 255]);#[repr(transparent)] guarantees the layout/ABI of u8: one-byte size and
alignment, with no invalid bit patterns or endianness dependence. as_bytes,
as_bytes_mut, from_bytes, and from_bytes_mut provide safe, zero-copy slice
views; Rust's ordinary borrowing rules still apply.
Rust 1.85+, edition 2024. No nightly compiler is required.
| Feature | Default | Effect |
|---|---|---|
serde |
Yes | Allocation-free Serde support, with Serde's std feature disabled. |
simd |
Yes | AVX2 bulk encoding on eligible x86-64 hosts, portable Rust elsewhere. |
Disable both for a dependency-free library. Enabling either feature never
requires std or an allocator. Features of Serde selected by other dependencies
are still unified by Cargo in the usual way.
Scalar encoding, decoding, and arithmetic use integers, with no soft-float runtime calls or lookup tables. The bulk encoder uses eight-lane integer AVX2 assembly for lengths of at least 32 on supported x86-64 hosts. The cached probe checks CPUID, OSXSAVE, and XCR0; unsupported CPUs never execute AVX2. An explicitly AVX2-enabled compilation skips detection. Short slices stay in Rust, as do AVX-512-enabled builds, where LLVM can exploit the wider instruction set.
Bare-metal x86-64, UEFI, and SGX do not perform runtime detection or compile the handwritten kernel. ARM, RISC-V, WebAssembly, other architectures, and Miri also use the portable encoder. Explicit compiler target features can still enable auto-vectorization and require the environment's corresponding state management. No ARM or WASM-specific assembly is claimed or required.
Bulk decoding uses a vectorizable exact-division loop where the target advertises
SSE2, NEON, VFP2, RISC-V F, or WebAssembly floating-point operations, and integer
bit construction elsewhere. Hardware division assumes Rust's default
floating-point environment, including round-to-nearest-even. Scalar conversions
and both encoding paths are independent of floating-point control registers.
See Performance for measurements, methodology, and commands.
Do not ship binaries built with -C target-cpu=native to unknown CPUs.
cargo test --all-features
cargo test --no-default-features
cargo test --release --all-features -- --include-ignored
cargo clippy --all-targets --all-features -- -D warnings
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features
cargo +nightly miri test --all-features
cargo bench --bench convertTests cover all 65,536 operand pairs for each arithmetic operation and overload,
every byte round trip, every quantization boundary and its neighbors, NaN
payloads, all sign/exponent classes, randomized inputs, unaligned buffers,
tails, panic-before-write behavior, const evaluation, and Serde tokens.
The opt-in release test verifies 75,497,473 positive f32 bit patterns
covering the entire nontrivial quantization range against an independent f64
reference, in both scalar and bulk paths. Miri checks the portable paths and
slice casts; it does not interpret the assembly kernel.