#4479·mlx

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

Author: builtbycnobCreated Sep 8, 2026Updated Sep 16, 2026
Labelsbuglow priority

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.