Skip to content

mx.dequantize returns garbage on CPU past 2^31 unpacked bits (int size in cpu/binary.h) #4479

Description

@builtbycnob

What happens

On a CPU stream, a contiguous elementwise binary op silently stops writing its output once one
side reaches 2^31 elements. No exception, no warning. The op returns an array that holds
whatever the output buffer held before: zeros when the buffer was freshly allocated, the
unmodified input when mlx donated the input's buffer.

I found it through mx.dequantize. Affine dequantize has no CPU kernel: affine_dequantize
builds the fast::Quantize primitive only for a GPU stream and otherwise evaluates an
op-by-op fallback (mlx/ops.cpp:5341 and :5345, v0.32.2). At a bit width that is not a power
of two, that fallback widens every packed uint32 word to one uint32 per bit and masks it, so
the full size ops in that branch run over numel * bits elements; the bitwise_and at
mlx/ops.cpp:5314 is the VectorScalar case that reads a.data_size(), so it is the likely one,
though I did not instrument which of them drops its writes. Past 2^31 such an op writes nothing.
When the input buffer is donated to the output, the raw shifted words survive into the sum and
get scaled by the group scale, which fits the magnitudes below. A 6-bit round trip of values in
[-1, 1] then comes back off by 7e7, in an array that looks perfectly ordinary.

Repro

# /// script
# requires-python = ">=3.12"
# dependencies = ["mlx==0.32.2"]
# ///
import mlx.core as mx

mx.set_default_device(mx.cpu)

# 1. Any contiguous elementwise binary op on the CPU stream. About 4 GB peak, under a second.
for cols in (2**30 - 1, 2**30):
    a = mx.full((2, cols), 3, dtype=mx.uint8)
    out = mx.add(a, mx.array(1, mx.uint8))
    mx.eval(out)
    a0, a1 = int(a[0, 0].item()), int(a[-1, -1].item())
    o0, o1 = int(out[0, 0].item()), int(out[-1, -1].item())
    print(f"add         n={2 * cols:>13,}  in={a0} {a1}  out={o0} {o1}  want out 4 4")
    del a, out
    mx.clear_cache()

# 2. What that does to mx.dequantize at a bit width that is not a power of two. ~15 GB peak.
for lead in (218, 219):
    n = lead * 640 * 2560
    w = mx.sin(mx.arange(n, dtype=mx.float32).reshape(lead, 640, 2560) * 0.017)
    w = w.astype(mx.bfloat16)
    wq, scales, biases = mx.quantize(w, group_size=64, bits=6)
    deq = mx.dequantize(wq, scales, biases, group_size=64, bits=6)
    err = mx.max(mx.abs(deq.astype(mx.float32) - w.astype(mx.float32))).item()
    print(f"dequantize  numel*bits={n * 6:>13,}  max|deq-w|={err:.3e}  want <= 0.02")
    del w, wq, scales, biases, deq
    mx.clear_cache()

Output here, on both 0.32.2 and 0.32.0, byte for byte the same:

add         n=2,147,483,646  in=3 3  out=4 4  want out 4 4
add         n=2,147,483,648  in=3 3  out=0 0  want out 4 4
dequantize  numel*bits=2,143,027,200  max|deq-w|=1.758e-02  want <= 0.02
dequantize  numel*bits=2,152,857,600  max|deq-w|=7.025e+07  want <= 0.02

11 seconds, 15.0 GiB peak RSS. The input of the add case reads back as 3 at both ends at every size, so
the array and the indexing are fine and only the output is untouched.

Observed against expected

The affine round trip of an input in [-1, 1] at 6 bits cannot be off by more than one
quantization step, 0.032. Below the boundary the repro above measures 0.0176. One row of
640x2560 further up it measures 7.0e7, which is 2.2e9 steps; the magnitude of the garbage
depends on the input (I have seen 1.2e7 to 7e7 for the same shape), the failure does not.

The add probe puts the boundary exactly at 2^31 = 2,147,483,648. The dequantize sweep below
brackets it and shows that it is numel * bits that decides, not numel and not the tensor
rank. The sweep uses a different input generator from the repro above, so its in-tolerance
figures differ slightly (0.0156 against 0.0176 on the same shape); the pass/fail split does not:

shape bits group numel * bits max abs err
(218, 640, 2560) 6 64 2,143,027,200 1.56e-02
(219, 640, 2560) 6 64 2,152,857,600 6.97e+07
(436, 640, 2560) 3 64 2,143,027,200 1.29e-01
(437, 640, 2560) 3 64 2,147,942,400 6.54e+08
(262, 640, 2560) 5 64 2,146,304,000 3.12e-02
(263, 640, 2560) 5 64 2,154,496,000 1.42e+08
(140160, 2560) 6 64 2,152,857,600 6.97e+07
(219, 640, 2560) 6 32 2,152,857,600 3.54e+07
(219, 640, 2560) 6 128 2,152,857,600 1.21e+08
(656, 640, 2560) 2 64 2,149,580,800 2.58e-01
(328, 640, 2560) 4 64 2,149,580,800 6.25e-02
(164, 640, 2560) 8 64 2,149,580,800 7.81e-03

Widths 3, 5 and 6 break above the line and are correct below it. Widths 2, 4 and 8 are correct
at a comparable bit count above the line (2,149,580,800) because they take the other branch of
the fallback, whose widest intermediate holds numel elements (537,395,200 for the 4-bit row,
far below 2^31) and whose full size ops are a broadcast multiply and add at a collapsed rank of
3 or less, where the count comes from the shape. binary_op_dispatch_dims (binary.h:115)
narrows to int as well, from a.size() at :245, :257, :269 and :281; at collapsed
rank 4 or more that count drives the loop at :138, so I would expect the same failure there,
untested. Running the same case in float32 instead of bfloat16, weights and scales alike,
changes nothing (6.96e+07). mx.quantize looks correct: it has a real CPU kernel, and
dequantizing the same packed arrays on a GPU stream is within tolerance whichever device did
the quantize (1.56e-02).

One more measurement that pins the mechanism. At 2^32 + 4 elements the same mx.add writes
exactly four values and leaves the rest untouched:

cpu  n=2^32+4  first six outputs=[4, 4, 4, 4, 0, 0]  out[-1,-1]=0

A count that wraps to 4 explains that. A clamp or an allocation limit does not.

Where it comes from

mlx/backend/cpu/binary.h, identical at tag v0.32.2 and on the default branch today. The three
contiguous functors take the element count as int:

19:  void operator()(const T* a, const T* b, U* dst, int size) {   // VectorScalar
39:  void operator()(const T* a, const T* b, U* dst, int size) {   // ScalarVector
59:  void operator()(const T* a, const T* b, U* dst, int size) {   // VectorVector

and the callers hand them a size_t:

167:    ScalarVector<Op>{}(a_ptr, b_ptr, out_ptr, b.data_size());
173:    VectorScalar<Op>{}(a_ptr, b_ptr, out_ptr, a.data_size());
179:    VectorVector<Op>{}(a_ptr, b_ptr, out_ptr, a.size());

At 2^31 the narrowing makes size negative, so while (size >= N) and while (size-- > 0)
both fail on their first test and the functor returns having written nothing. Between 2^32 and
2^32 + 2^31 the value wraps back to a small positive number and a short prefix gets written,
which is the four elements above.

The neighbouring headers do not narrow: unary.h:27 keeps auto size = a.data_size() and
ternary.h:61 declares size_t size, so binary.h looks like the one that was missed.

I have not tested a patch, and I do not know whether widening those three signatures to
size_t is enough on its own or whether the SIMD tail wants the same treatment elsewhere.

Versions and hardware

mlx 0.32.2 (newest on PyPI, uploaded 2026-08-25) and mlx 0.32.0, same numbers on both.
mlx-metal 0.32.2, numpy 2.5.3, CPython 3.12.12, installed with uv into an empty project.
Apple M3 Ultra, 256 GB, macOS 26.6.2 (25G83). mlx/backend/cpu/binary.h on the default branch
is byte identical to the v0.32.2 file as of today, so I expect main to reproduce as well.

Why it matters in practice

Quantized MoE checkpoints put whole expert stacks in one 3-D tensor of several hundred million
elements, which at 6 bits is above this line, so a CPU side round trip check on one reports a
failure that is not in the data, and code that dequantizes it on the CPU gets a plausible
looking array that is wrong by seven orders of magnitude. The workaround is easy once you know:
run it on the GPU, or chunk below 2^31 / bits elements. Finding out is the expensive part,
because nothing about the result says it failed.

Related

Same family of 32-bit count and index overflows, all on the Metal side and all closed:
#3248 (conv_general above 2^31 output
elements), #2894 (SDPA boolean mask above
2^31), #3979 (conv2d returning all zeros for
large inputs), #3836 (strided copy picking the
int32 kernel by element count), #3327 (arrays
above 2^31 elements failing materialization). I did not find an open issue covering the CPU
binary path.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions