~/blog
Understanding NVFP4: From Decimal Values to 4-Bit Representation
As Large Language Models (LLMs) grow to hundreds of billions of parameters, memory bandwidth becomes the primary bottleneck in AI inference. To fit these massive networks onto available hardware, the industry has aggressively pushed down the precision ladder: from FP16 to FP8, and now to 4-bit representations.
Historically, 4-bit quantization meant converting numbers to integers (INT4). However, with the introduction of the NVIDIA Blackwell architecture, a new contender has emerged: NVFP4.
Rather than writing an entire book on the low-precision landscape, this article answers one core question: How does a high-precision decimal value become an NVFP4 value, and how do we convert it back?
We will strip away the architectural jargon and walk through the exact mathematical logic, manual bit-by-bit calculations, and a complete, educational PyTorch implementation.
Why FP4 Instead of INT4?
When squeezing information into just 4 bits, you only have 16 distinct states to represent the infinity of real numbers. How you distribute those 16 states matters immensely.
Floating Point vs. Integer Distributions
An integer format (INT4) distributes its values uniformly (linearly) along the number line (e.g., -8, -7, ..., 0, ..., 7).
However, neural network weights and activations do not look like uniform grids; they typically follow a bell-shaped Gaussian or Laplacian distribution centered around zero. They contain millions of tiny numbers and a few crucial, large outliers.
Floating-point representations (FP4) allocate values logarithmically. This means they provide:
- High precision near zero: More states are tightly clustered around zero to capture the fine nuances of small weights.
- Wide dynamic range: Exponent bits allow the format to stretch out and capture larger magnitudes (outliers) that would otherwise overflow or saturate an integer format.
Why NVIDIA Introduced NVFP4
Standard 4-bit floating point formats alone have too narrow a range to hold network values safely. NVIDIA introduced NVFP4 to pair ultra-low-precision 4-bit values with a sophisticated two-level hardware scaling mechanism.
By using microscopic 16-element blocks that share an FP8 scaling factor, NVFP4 preserves the accuracy of deep networks at a fraction of the memory footprint, achieving a 3.5x memory reduction over FP16.
Understanding the NVFP4 Number Format
At the individual element level, NVFP4 implements an E2M1 format. This means each number is built from 4 bits arranged in the following layout:
+-------+-----------+-----------+
| Sign | Exponent | Mantissa |
| 1 bit | 2 bits | 1 bit |
+-------+-----------+-----------+The Mathematical Formula
A normalized floating-point number is computed as:
For NVFP4 (E2M1):
- Sign ():
0for positive,1for negative. - Exponent (): 2 bits, yielding raw values from
0b00() to0b11(). - Exponent Bias: Calculated as , where is the number of exponent bits. For 2 bits, .
- Mantissa (): 1 bit, representing the fractional part. The implicit leading bit is
1for normal numbers. Since there is only 1 fraction bit, its weight is .
All Representable Values of NVFP4
Because the format is so tiny, we can compute every single non-negative value it can natively represent.
Note on OCP vs. NVFP4 Specs: Under standard OCP micro-float specifications for E2M1, subnormals () drop the leading implicit 1. The maximum representable value occurs at , yielding a value of .
Here is the exact positive codebook for standard E2M1:
| Binary (S, EE, M) | Exponent Type | Formula / Math | Decoded Value |
|---|---|---|---|
| 0 00 0 | Subnormal | 0.0 | |
| 0 00 1 | Subnormal | 0.5 | |
| 0 01 0 | Normal | 1.0 | |
| 0 01 1 | Normal | 1.5 | |
| 0 10 0 | Normal | 2.0 | |
| 0 10 1 | Normal | 3.0 | |
| 0 11 0 | Normal | 4.0 | |
| 0 11 1 | Normal | 6.0 |
The full range spans just 8 positive values, 8 negative values, and zero. Its maximum native value is 6.0.
Manual Encoding: Decimal NVFP4
Let's take a raw decimal value, , and manually encode it into the 4-bit NVFP4 representation using Round-to-Nearest-Even (RTNE).
Step 1: Identify the Sign
Since is positive, our sign bit is:
Step 2: Locate the Value in the Codebook
We look at our representable scale options: ..., 3.0, 4.0, 6.0.
The target value sits squarely between (0b0110) and (0b0111).
Step 3: Determine the Closest Representable Point
Calculate the absolute distance to both surrounding points:
- Distance to :
- Distance to :
Because , the value is mathematically closer to .
Step 4: Extract the Bits
The codebook value for is constructed from:
- Exponent = (
0b11) - Mantissa = (
0b1)
Putting it all together:
5.75
↓ (Round to Nearest)
6.0
↓
[Sign=0] [Exp=11] [Mant=1]
↓
0111Manual Decoding: NVFP4 Decimal
Now let's reverse the process. Imagine the GPU encounters the 4-bit binary layout 1011. Let's decode it back into a standard decimal value.
Step 1: Unpack the Bits
Splitting the 4 bits according to the layout rules:
1Sign bit ()01Exponent bits ()1Mantissa bit ()
Step 2: Evaluate the Components
- Sign: , which means the final output will be negative.
- Raw Exponent: . Since , this is a standard normal number.
- De-biased Exponent: .
- Mantissa Fraction: .
Step 3: Plug into the Formula
Using the normal floating-point calculation structure:
The binary string 1011 decodes exactly to .
Why Scaling Is Necessary
Looking at our codebook, the absolute smallest non-zero normal value we can represent is , and the absolute largest is .
This presents an obvious problem for deep learning workloads:
- How do we represent a tiny gradient like ? It underflows to .
- How do we represent an outlier activation like ? It overflows to .
Block Scaling to the Rescue
To handle real-world data ranges, NVFP4 relies on micro-block scaling. Tensors are carved into small blocks of 16 elements. Each block calculates a shared scaling factor based on its maximum absolute value (). This shared scale factor is itself quantized into an 8-bit FP8 (E4M3) value.
The operational transformation pipeline functions as follows:
Tensor Block (16 elements)
│
▼
Find Max Absolute Value
│
▼
Compute Scale Factor
│
▼
Normalize by Scaling Factor
│
▼
Quantize Normalized Values to FP4Quantizing a Tensor Step by Step
Let's trace this block-scaling framework using a small 4-element toy tensor (in production, this occurs over 16 elements):
Step 1: Compute the Absolute Maximum
Step 2: Compute the Scale Factor
We must scale our maximum value () so that it aligns perfectly with the maximum possible value our FP4 format can natively hold ().
Step 3: Normalize the Tensor
Multiply every element in the original high-precision tensor by our computed scale factor:
Our normalized tensor values are now bounded within the native range .
Step 4: Quantize to the FP4 Codebook
We map each normalized float to its nearest native codebook match:
- Closes to (
0b0100) - Closest to (
0b0110) - Closest to (
0b0111) - Matches (
0b0111)
The packed 4-bit representation stored in memory is: [0b0100, 0b0110, 0b0111, 0b0111].
Step 5: Reconstruct (Dequantize)
To read these numbers back during a matrix operation, we pull the 4-bit integers back out to their codebook floats and divide by the stored scale factor ():
- (Original: 2.1)
- (Original: 3.8)
- (Original: 4.5)
- (Original: 5.2)
Implementing NVFP4 in PyTorch
Let's translate this step-by-step logic into an educational PyTorch implementation. This script explicitly builds the underlying codebook mapping and runs the block quantization pipeline.
import torch
# Explicit NVFP4 Codebook mapping (E2M1 standard values)
# Array index corresponds to the unsigned integer bit pattern (0 to 7)
FP4_CODEBOOK = torch.tensor([0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0], dtype=torch.float32)
FP4_MAX = 6.0
def encode_fp4(normalized_tensor):
"""
Encodes a normalized tensor into 4-bit signed integer representations
by mapping values to the nearest entry in the E2M1 codebook.
"""
# Isolate sign and working magnitudes
signs = torch.sign(normalized_tensor)
abs_values = torch.abs(normalized_tensor)
# Broadcast subtraction to find the closest codebook entry for each value
# Shape: (tensor_elements, codebook_elements)
distances = torch.abs(abs_values.unsqueeze(-1) - FP4_CODEBOOK)
codebook_indices = torch.argmin(distances, dim=-1)
# Store the sign inside the integer layout:
# Positive values maintain index, negative values add bit offset (8 = 0b1000)
encoded_bits = codebook_indices.clone()
encoded_bits[signs < 0] += 8
return encoded_bits
def decode_fp4(encoded_bits):
"""
Decodes the 4-bit integer representation back to standard floating-point values
using the E2M1 codebook.
"""
# Extract sign: bits >= 8 mean the sign bit (MSB) is active
signs = torch.where(encoded_bits >= 8, -1.0, 1.0)
# Extract codebook index: mask off the sign bit (modulo 8)
codebook_indices = encoded_bits % 8
# Map index back to real float values via codebook lookup
decoded_floats = FP4_CODEBOOK[codebook_indices]
return signs * decoded_floats
def quantize_tensor_blockwise(x, block_size=16):
"""
Quantizes an arbitrary float tensor into block-scaled NVFP4 format.
"""
orig_shape = x.shape
x_flat = x.flatten()
# Pad tensor if elements aren't a clean multiple of block_size
remainder = x_flat.numel() % block_size
if remainder != 0:
padding = block_size - remainder
x_flat = torch.cat([x_flat, torch.zeros(padding, dtype=x.dtype)])
# Reshape into distinct operational blocks
blocks = x_flat.view(-1, block_size)
# Step 1 & 2: Calculate scale per block
block_max = torch.max(torch.abs(blocks), dim=-1, keepdim=True).values
block_max = torch.clamp(block_max, min=1e-5) # Prevent divide-by-zero
scales = FP4_MAX / block_max
# Step 3: Normalize
normalized_blocks = blocks * scales
# Step 4: Quantize elements
quantized_bits = encode_fp4(normalized_blocks).view(-1, block_size)
return quantized_bits, scales, orig_shape
def dequantize_tensor_blockwise(quantized_bits, scales, orig_shape, block_size=16):
"""
Dequantizes an NVFP4 tensor back to standard high-precision float representation.
"""
# Decode 4-bit configurations back to native codebook values
decoded_normalized = decode_fp4(quantized_bits)
# Reverse scaling transformation
dequantized_flat = (decoded_normalized / scales).flatten()
# Truncate padding and restore original spatial dimensions
total_elements = torch.prod(torch.tensor(orig_shape)).item()
return dequantized_flat[:total_elements].view(orig_shape)
# --- Verification Run ---
if __name__ == "__main__":
raw_data = torch.tensor([2.1, 3.8, 4.5, 5.2, -1.2, -5.8, 0.1, 0.9])
print(f"Original Data:\n{raw_data}\n")
# Quantize using a miniature block size of 4 for demonstration purposes
q_bits, block_scales, original_dim = quantize_tensor_blockwise(raw_data, block_size=4)
print(f"Quantized Bit Patterns:\n{q_bits}")
print(f"Calculated Block Scales:\n{block_scales.flatten()}\n")
# Recover original numbers
recovered_data = dequantize_tensor_blockwise(q_bits, block_scales, original_dim, block_size=4)
print(f"Recovered Output Data:\n{recovered_data}")Analyzing Quantization Error
Because 4-bit precision rounds inputs aggressively to the closest grid point, it inherently introduces noise. Let's look at the deviation metrics between what went into our pipeline vs. what came out.
Error Analysis of the Sample Run
Let's analyze the first element from our manual walkthrough:
- Original Value ():
- Recovered Value ():
We measure the truncation precision using two core metrics:
- Absolute Error: Measures the raw distance delta.
- Relative Error: Scales the raw delta against the size of the target asset.
Why Neural Networks Tolerate the Noise
A precision variance on an individual parameter sounds catastrophic for traditional engineering applications. However, modern deep learning architectures handle this noise well for two main reasons:
- High-Precision Accumulation: While weights and activations are stored in ultra-precise 4-bit layouts to save memory bandwidth, Blackwell GPUs perform matrix multiplication accumulations in higher precision (like FP16). This helps prevent errors from compounding uncontrollably during calculations.
- Statistical Error Cancellation: A standard layer inside an LLM computes dot-products across thousands of parameters simultaneously. Because the quantization errors are zero-centered and randomized via round-to-nearest logic, the positive deviations tend to cancel out the negative deviations across massive matrix dimensions.
Where This Leaves Us
NVFP4 is not a standalone format — it's a system. The 4-bit E2M1 element provides the density, and the FP8 block scaling provides the range. Neither works without the other, and that pairing is the real innovation. Blackwell didn't just shrink the numbers; it changed the contract between storage and computation.
What's interesting is how much this mirrors the broader shift in inference hardware. The industry is moving away from uniform precision toward mixed-precision micro-architectures where the format itself becomes a lever for efficiency. Formats like NVFP4, MXFP4, and others are less about saving bits and more about reshaping where precision lives — concentrating it around the dynamic range of real activations rather than scattering it uniformly across all values.
The open question is where this bottoms out. If 4-bit works with block scaling, does 2-bit with coarser blocks work too? At what point does the block overhead eat the memory savings? The next generation of hardware will have to answer that — and the answer probably isn't a single format, but a hierarchy of them selected per-tensor by the compiler.