Fix divmod truncating the quotient for floats - #4108
Conversation
zcbenz
left a comment
There was a problem hiding this comment.
Can you check how numpy/pytorch behaves on this?
|
Checked, and the answer argues for a bigger change than this PR makes. Measured on int32, mlx via the CPU stream: numpy, pytorch and python all floor both halves. mlx truncates both. All four satisfy So there are two ways to go:
I did not want to pick option 2 for you inside a PR scoped to the GPU, so tell me which you want and I will redo it that way. Also pushed a fix for the metal build failure, which was mine: for the narrow int types |
|
Thanks for checking, let's do option 2 and fix all things. |
|
Nudging this one with a recommendation rather than just a ping, since the measurement came back one sided. Every reference floors both halves: numpy, pytorch and python If you want option 2, I am happy to redo this as: floor both halves in If you would rather keep truncation, this PR as it stands is the smaller change and only makes the GPU agree with the CPU. Either way is fine by me, I just did not want to pick the convention on your behalf. Happy to close this if you prefer the bigger change as a fresh PR. |
|
Ignore that last comment, I missed your reply above. Doing option 2. |
79636f8 to
42b5354
Compare
| shape, dtype, std::make_shared<Divide>(to_stream(s)), std::move(inputs)); | ||
| // Integer division truncates, so take the floored quotient from divmod | ||
| // rather than reimplementing the correction here. | ||
| return divmod(a, b, s)[0]; |
There was a problem hiding this comment.
This seems to be causing a lot of weird errors, adding a new primitive might be simpler in the end.
|
Pushed fixes for both CI failures. Both were mine. Metal build. My It now uses the same three Fedora and ASAN crash. Routing integer Worse, that path was silently wrong under vmap even where it did not crash. So auto num = subtract(a, remainder(a, b, s), s);
// ... Divide primitive on (num, b)That also drops the wasted remainder computation you would have had, so the tradeoff I flagged earlier is gone. Verified: all 216 combinations of a in [-13, 13] and b in {1, 2, 3, 5, -1, -2, -3, -5} match python for |
42b5354 to
180988e
Compare
|
Third metal fix, sorry for the churn. The build step failed again and it was the complex instantiation.
Ran the C++ suite again as well as the python one, 249 cases and 3346 assertions pass. I still cannot compile Metal or CUDA here, so the structure being identical to |
180988e to
a5353b0
Compare
|
Re-verified this on current main (bbebc8f), since it has been sitting a week and main has moved a lot in that time. It still merges cleanly, and on that merge the C++ suite passes (250 cases, 3351 assertions) along with
I have deliberately not force pushed a rebase. Fork PRs are behind the workflow approval gate at the moment, so a push would drop the 28 green checks here back to zero and need another approval to re-run. Nothing needs rebasing anyway, GitHub still reports it mergeable and clean. One thing worth knowing rather than discovering twice: #4311 was opened today and covers the same three backend files. I have left a note there too. I do not mind at all which one you take, I just did not want the same diff reviewed twice. |
|
Two things I hit running your branch merged onto main. 1. The integer path in a, b = mx.array([120, 1], mx.int8), mx.array([-27, -128], mx.int8)
(a // b).tolist() # [4, 1]
mx.divmod(a, b)[0].tolist() # [-5, -1] correct
2. a, b = mx.array([2144.0], mx.bfloat16), mx.array([358.0], mx.bfloat16)
mx.divmod(a, b) # q=6.0, r=354.0 2144/358 is 5.9888, so the floor is 5
numpy derives the quotient from the remainder instead of dividing twice: mod = fmod(a, b);
div = (a - mod) / b;
if (mod != 0 && (b < 0) != (mod < 0)) { mod += b; div -= 1; }
else if (mod == 0) { mod = copysign(0, b); }
floordiv = floor(div);
if (div - floordiv > 0.5) floordiv += 1;I checked that against Found with https://github.com/krrishapatel/arraydiff. |
|
Correction to the algorithm I posted above. I implemented it in A zero divisor needs an early out. Otherwise A zero quotient needs its own sign. mod = fmod(a, b);
if (b == 0) { return {a / b, mod}; } // was missing
div = (a - mod) / b;
if (mod != 0) { if ((b < 0) != (mod < 0)) { mod += b; div -= 1; } }
else { mod = copysign(0, b); }
if (div != 0) {
floordiv = floor(div);
if (div - floordiv > 0.5) { floordiv += 1; }
} else {
floordiv = copysign(0, a / b); // was missing
}With both branches this is bit exact against |
|
I should have checked #4003 before posting the algorithm above. It found the same half precision rounding and was closed with "we should follow what PyTorch does" and prefer speed. That is worth revisiting here, because PyTorch's floor division is not
if (C10_UNLIKELY(b == 0)) { return a / b; }
auto mod = std::fmod(a, b);
auto div = (a - mod) / b;
if ((mod != 0) && (b < 0) != (mod < 0)) { div -= scalar_t(1); }
scalar_t floordiv;
if (div != 0) {
floordiv = std::floor(div);
if (div - floordiv > scalar_t(0.5)) { floordiv += scalar_t(1.0); }
} else {
floordiv = C10_COMPAT_COPYSIGN(scalar_t(0), a / b);
}That derives the quotient from the remainder, same as numpy, and it is vectorized as It matters for this PR because of the new comment, "Both halves floor, so quotient * y + remainder == x". I applied this PR's mx.divmod(mx.array([2144.0], mx.bfloat16), mx.array([358.0], mx.bfloat16))
# q=6, r=354 q*y + r = 2496, x = 2144
mx.divmod(mx.array([1.0], mx.float64), mx.array([0.1], mx.float64))
# q=10, r=0.09999999999999995 q*y + r = 1.0999999999999999, x = 1.0float64 is the part #4003 does not reach. Its fix was to widen the division, and there is nothing above float64 to widen to, so "use higher precision" has no answer there. The error is also a whole divisor rather than a rounding difference. I have the PyTorch version building on CPU: bit exact against |
|
@krrishapatel thank you, the first one is a real regression in this PR and it should not be merged as it stands. Please hold off on this one for now. I reproduced both on this branch merged onto current main, and I want to separate them because they are not the same kind of problem. The integer overflow is mine, and it is worse than the invariant it was meant to fix. On main for The float one reproduces but predates this PR. Same numbers on unmodified main: So this branch does not change that behaviour. What it does do is add a comment asserting "Both halves floor, so quotient * y + remainder == x", and your examples show that sentence is not true for the float path. The claim is wrong even though the code is not new, and I will fix the claim. I am working on the integer fix now: derive the floored quotient from the truncating one and a correction term rather than from On your PyTorch and numpy algorithm for the float path, I agree that deriving the quotient from the remainder is the correct answer, and your point about float64 having nothing to widen to is a good one against the #4003 resolution. That is a bigger change than this PR and it is your work, so please do send it as its own PR rather than folding it in here. I will point at it from here. |
a - remainder(a, b) can overflow: for int8 it is 135 when a is 120 and b is -27, which wraps and made floor_divide disagree with divmod on the very invariant this is for. Start from the truncating quotient and step it down instead. Also drop the claim that the float halves satisfy quotient * y + remainder == x, which flooring an already rounded quotient cannot guarantee.
|
Fixed and pushed. The floored quotient now comes from the truncating one plus a correction, so nothing has to leave the dtype range: auto quotient = array(shape, dtype, std::make_shared<Divide>(to_stream(s)), inputs);
auto rem = subtract(inputs[0], multiply(quotient, inputs[1], s), s);
auto step = logical_and(
not_equal(rem, zero, s),
not_equal(less(rem, zero, s), less(inputs[1], zero, s), s),
s);
return subtract(quotient, astype(step, dtype, s), s);
Your case now agrees: Verified over all 65279 int8 pairs with a non zero divisor, skipping only
Also checked the boundary values for int16, int32, int64 and all four unsigned types, and C++ suite passes (249 cases, 3346 assertions) along with I also corrected the comment you were right about. It now says the identity holds for integers and explicitly does not claim it for floats, since flooring an already rounded quotient cannot recover the exact one. Thanks for catching this, it was a clear regression and it had my approval on it. Your float work stands on its own and I would rather it went in as your PR than folded in here. |
| not_equal(rem, zero, s), | ||
| not_equal(less(rem, zero, s), less(inputs[1], zero, s), s), | ||
| s); | ||
| return subtract(quotient, astype(step, dtype, s), s); |
There was a problem hiding this comment.
I think with this implementation we should really add a new primitive for FloorDivide, probably in another PR.
There was a problem hiding this comment.
Agreed, and I think you are right that a FloorDivide primitive is where this wants to end up.
What this PR does now is build the floored quotient out of existing primitives, a Divide plus a multiply, a subtract, two compares and a select. That is correct, and it is verified over all 65279 int8 pairs, but it is six nodes in the graph where one kernel would do, and it is why the overflow was possible in the first place: the intermediate had to fit in the dtype. A primitive computes the quotient and the correction in registers and never materializes anything that can wrap.
Happy to do it as a follow up rather than growing this PR, since a new primitive means the three backend kernels, vjp and vmap, and it should land on its own. If you would rather have that instead of this and skip the intermediate step, I am fine closing this one too, your call.
There was a problem hiding this comment.
I think it would be good to have this PR fixing divmod, and then a followup PR to fix floor_divide.
Per review, keep the change to the three DivMod kernels and move the integer floor_divide fix in ops.cpp to its own PR. The tests here now check divmod against python divmod and the q*b+r invariant, without asserting that mx.// agrees, which it will not until the followup.
|
Done, this PR is now divmod only.
Floats are unchanged and still satisfy the invariant. C++ suite passes (249 cases, 3346 assertions) with The followup for integer |
Fix #4119
Proposed changes
mx.divmodtruncated its quotient whilemx.remainderfloored, so the two disagreed with each other and with everything else:numpy, pytorch and python all floor both halves. The docstring for
divmodsays it is "equivalent to but faster than(a // b, a % b)", which was not true:a // btruncated for integers anda % bfloored, so that pair did not even satisfyq * b + r == a.On the GPU it was worse, because
DivModpaired a truncatingFloorDividewith a flooringRemainder, soquotient * y + remainder == xfailed outright on six of eight mixed-sign int32 pairs.Everything now floors:
FloorDivideon Metal and CUDA steps the quotient down when the operands have opposite signs and the division is not exact, and usesfloorrather thantruncfor floats.DivModgoes back to{FloorDivide, Remainder}, which is consistent now that both halves floor.DivMod::eval_cpuapplies the same correction, and its float path returns(floor(x / y), floored remainder).floor_dividetakes the floored quotient fromdivmodinstead of the truncatingDivideprimitive, so the two cannot drift apart again.Quotient and remainder both match python and numpy on every mixed-sign pair I tried,
q * b + r == aholds, anda // banda % bnow equal the two halves ofdivmod. Floats matchdivmodtoo. Unsigned types are untouched, since the correction is behindis_signed_v, and dtypes are preserved.One tradeoff worth calling out: routing integer
floor_dividethroughdivmodcomputes a remainder that is then dropped. I preferred that over open coding the correction a second time, since the whole point here is that the two stopped agreeing, but say the word and I will inline it instead.Checklist
Put an
xin the boxes that apply.pre-commit run --all-filesto format my code / installed pre-commit prior to committing changesCPU only build (
MLX_BUILD_METAL=OFF) at 3abd0fd.test_ops.py,test_array.py,test_autograd.py,test_compile.py,test_random.py,test_losses.py,test_nn.pyandtest_linalg.pyall pass, and the new assertions fail on main. I cannot run Metal or CUDA here, so those two kernels are reasoned rather than executed and the GPU jobs are the real check on them.Also worth noting:
mx.divmod(-6.0, 3.0)gives a remainder of-0.0where numpy gives0.0. That is unchanged behaviour and it now matchesmx.remainder, which is what pytorch does too.freshman contributor, i work through these with Claude Code. thanks for the steer on option 2, this ended up much tidier than what i had. the part i had to be careful with was unsigned integers, where the sign correction has to be compiled out rather than just evaluated to false, otherwise the always false comparison trips the warnings as errors build.