MXFP4/community docs · rev 2026.09

Home/Format/Block Scaling

Format ยท Specification

Block Scaling

One 8-bit power-of-two exponent per 32 elements. How it is chosen, what it recovers, and the outlier problem it cannot solve.

Silicon wafer held at an angle showing a repeating grid of dies
A silicon wafer of repeating dies.

Why a shared exponent at all

A 4-bit float on its own is useless for neural network weights. E2M1 spans 0.5 to 6.0 in magnitude โ€” a dynamic range of about 3.6 binades. Real weight tensors span far more than that, and the distribution is not centred anywhere convenient.

Block floating point solves this by refusing to commit to a global range. Instead of one scale for a tensor, MXFP4 uses one scale for every 32 contiguous elements along the reduction axis. Each block gets to sit in whatever binade suits it. A tensor whose columns differ by six orders of magnitude is no harder to represent than a uniform one, because no block ever has to hold both extremes.

Block scaling buys dynamic range. It does not buy precision. Inside a block you still only have eight magnitudes to choose from.

Choosing the scale

The standard rule is amax-based and conservative: take the largest absolute value in the block, find its binade, and set the shared exponent so that value lands at or below the top of the E2M1 range without saturating.

scale_select.py โ€” the amax rule
# The shared exponent is chosen so the block maximum lands at the top of the
# E2M1 range without ever saturating. floor() is what guarantees that.
def choose_scale(block):
    amax = np.max(np.abs(block))
    if amax == 0:
        return 127                        # 2^0, an all-zero block
    e = np.floor(np.log2(amax)) - 2   # 2 = max exponent in E2M1
    return int(np.clip(e, -127, 127)) + 127

# A search-based alternative: try a few exponents, keep the lowest MSE.
def choose_scale_mse(block, span=3):
    base = choose_scale(block)
    best, best_e = float("inf"), base
    for e in range(base - span, base + 1):
        err = mse(block, dequantize_block(encode(block, e), e))
        if err < best:
            best, best_e = err, e
    return best_e                        # offline only - too slow for a kernel

Because floor is used rather than rounding, the largest element is guaranteed representable and nothing in the block saturates. The cost is that you sometimes waste up to one binade of the available range: if the block maximum is just above a power of two, the top of the E2M1 grid sits well above anything actually present.

Illustrative comparison of scale-selection policies on synthetic normal blocks. These are illustrative figures from a small reference experiment, not a published benchmark.
policysaturationmean rel. errornotes
floor(log2 amax) - 2none0.081the conservative default
round(log2 amax) - 2rare0.076clips the top element occasionally
percentile 99.9 of |x|occasional0.071better average, worse tails
MSE-optimal searchnone0.0698 candidate exponents, offline only
Uncertain

Whether a non-amax policy is a net win depends heavily on the model and on whether you are quantizing weights or activations. For weights, the small average-error improvement from a search-based policy is often invisible at the task level. For activations, clipping an outlier can be catastrophic and unpredictable. Measure it; do not assume.

The outlier problem

One large value in a block drags the shared exponent upward, and every other element in that block is then quantized on a coarser grid. With 32 elements, a single outlier degrades 31 neighbours. This is the dominant failure mode of MXFP4 in practice and it is why block size and outlier handling are the two levers that matter most.

Transformer activations are notorious here: certain channels carry consistently large magnitudes, and they persist across tokens and layers rather than appearing randomly. Weight tensors are usually better behaved, which is a large part of why weight-only MXFP4 is common and full activation MXFP4 is not.

  • Smaller blocks confine an outlier to fewer neighbours, at the cost of more scale bytes โ€” block 16 costs 4.5 bits/element instead of 4.25.
  • Rotation or Hadamard transforms spread outlier energy across a channel group before quantizing, which flattens the per-block amax.
  • Mixed precision keeps a small set of identified outlier channels or whole layers in a wider format.
  • Reordering groups similar-magnitude channels into the same block, though this usually costs you a permuted matmul.

Block axis matters

Blocks must run along the reduction dimension of the matmul, because that is the axis a dot product consumes. If you quantize along the wrong axis the numbers are still valid MXFP4, but the kernel cannot apply one scale per accumulation chain and you lose the hardware path entirely. When a converted checkpoint is unexpectedly slow, this is the first thing to check.

python tools/inspect_mx.py model.safetensors --layer mlp.down_projweight shape=(11008, 4096) packed=(11008, 2048) uint8scales shape=(11008, 128) dtype=uint8 (E8M0)block_k 32 axis=-1 OK: blocks align with reduction axis
Corrections

Found an error, or a result that disagrees? This is a community reference. Corrections with a reproducible test case are the most useful thing you can send us.

Open a correction · Community