Skip to content

[Python] Use Python floor-modulo semantics for % on integers - #5166

Open
udsy19 wants to merge 1 commit into
NVIDIA:mainfrom
udsy19:fix/5159-signed-modulo
Open

[Python] Use Python floor-modulo semantics for % on integers#5166
udsy19 wants to merge 1 commit into
NVIDIA:mainfrom
udsy19:fix/5159-signed-modulo

Conversation

@udsy19

@udsy19 udsy19 commented Aug 18, 2026

Copy link
Copy Markdown

The Python AST bridge lowered % on integers to arith.remui, an unsigned
remainder, so any expression with a negative operand silently produced a wrong
result inside a @cudaq.kernel:

    a   b   kernel   python
   -1   3      0        2     <- wrong
   -5   3      2        1     <- wrong
    5  -3      5       -1     <- wrong
    7   3      1        1     ok

This also broke the identity a == (a // b) * b + a % b, because // in the
same bridge already lowers to the correctly signed arith.floordivsi.

What this changes

% on integers now emits arith.remsi plus the floor correction that Python's
% requires:

%r    = arith.remsi %a, %b
%nz   = arith.cmpi ne, %r, %c0
%x    = arith.xori %r, %b          // sign bit set <=> signs differ
%sd   = arith.cmpi slt, %x, %c0
%need = arith.andi %nz, %sd
%adj  = arith.addi %r, %b
%res  = arith.select %need, %adj, %r

arith.remsi alone would not be enough: it truncates towards zero, so it fixes
the negative-dividend cases but still disagrees with Python whenever the divisor
is negative (5 % -3 would give 2, not -1), and it would leave the
(a // b) * b + a % b identity broken against arith.floordivsi. Adding the
divisor when the remainder is non-zero and its sign differs from the divisor's
gives the remainder the sign of the divisor, which is exactly Python's rule.

The correction is branch-free and only costs a compare/xor/compare/and/add/select
around the existing division; arith canonicalization folds it away when both
operands are known constants.

Notes:

  • No unsigned path is regressed. The Python bridge only ever builds signless
    integer types (IntegerType.get_signless) for Python int, bool, and the
    numpy.int8/16/32/64 annotations — all signed. There is no unsigned integer
    annotation in python/cudaq/kernel/utils.py, so nothing relied on remui.
  • The C++ bridge is unaffected and stays correct. In
    cudaq/lib/Frontend/nvqpp/ConvertExpr.cpp (BO_Rem) it already dispatches on
    signedness — arith::RemUIOp for unsigned C++ integer types, arith::RemSIOp
    for signed ones — which is the right lowering for C++'s truncating %. No
    change is needed or wanted there; the two frontends now each match their own
    source language instead of the Python one accidentally using an unsigned
    lowering for signed types.
  • The undefined-behaviour edges are unchanged in kind. arith.remsi by zero
    and arith.remsi(INT_MIN, -1) are UB in LLVM, exactly as the arith.divsi
    underlying the arith.floordivsi already emitted for // is. So a kernel
    computing a // b is already exposed to both today, and this change does not
    add a new class of exposure — but it does move % onto the signed division
    instruction, so on targets where signed division traps (x86 idiv),
    a % b with b == 0 or (a, b) == (INT_MIN, -1) can now trap where the old
    remui silently returned a wrong value (INT_MIN % -1 returned INT_MIN;
    Python says 0). Guarding it was deliberately not done: it would cost every
    modulo an extra compare and select, and would be inconsistent with the
    unguarded // right above it. Happy to guard both together if maintainers
    prefer.
  • The correction itself cannot overflow. arith.remsi gives |r| < |b|, and
    the r + b is only selected when r != 0 and r and b have opposite
    signs, so the sum lies strictly between 0 and b and is always
    representable — including for b == INT_MIN.
  • The floating-point % path is left alone in this PR. arith.remf also
    truncates towards zero, so -1.0 % 3.0 in a kernel returns -1.0 where
    Python returns 2.0. Matching CPython there needs the same correction plus
    its copysign(0.0, divisor) handling of a zero remainder, and it has to be
    thought through for inf/NaN operands, so it deserves its own change. Happy to
    follow up with it if maintainers want it in the same PR.

Testing

  • python/tests/kernel/test_kernel_float.py: adds
    test_integer_modulo_matches_python (the reported cases, for int and
    np.int32) and test_integer_floor_division_modulo_identity (checks
    a == (a // b) * b + a % b over a sweep of signed pairs), next to the
    existing test_integer_floor_division_matches_python.
  • python/tests/mlir/{ast_break,ast_continue,ast_elif,ast_iterate_loop_init}.py:
    CHECK lines updated for the new i % 4 expansion. No new constant appears in
    the entry block — the 0 constant CSEs with the one already there, so the
    existing CHECK-DAG constant block is untouched. The trailing VAL_* capture
    names in each file are renumbered so they stay unique and increasing, matching
    how these generated files are written; FileCheck itself would accept the old
    numbers, but leaving them would make two different values share a name inside
    one function's CHECK block.

Fixes #5159

The Python AST bridge lowered `%` on integers to `arith.remui`, an
unsigned remainder, so any expression with a negative operand silently
produced a wrong result: inside a kernel `-1 % 3` evaluated to 0 instead
of 2, `-5 % 3` to 2 instead of 1, and `5 % -3` to 5 instead of -1.

Emit `arith.remsi` instead and correct the truncated remainder by adding
the divisor when the remainder is non-zero and its sign differs from the
divisor's. That gives the remainder the sign of the divisor, as Python
does, and matches the `arith.floordivsi` already emitted for `//`, so
that `a == (a // b) * b + a % b` holds again.

The bridge only ever builds signless integer types for Python `int`,
`bool`, and the `numpy.intN` annotations, all of which are signed, so no
unsigned operand type is affected.

Fixes NVIDIA#5159

Signed-off-by: Udaya Tejas <udayatejas2004@gmail.com>
@copy-pr-bot

copy-pr-bot Bot commented Aug 18, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@udsy19

udsy19 commented Aug 18, 2026

Copy link
Copy Markdown
Author

Two scoping questions for maintainers, kept out of the description so the issue link reads cleanly:

  1. Float % is still truncating. arith.remf gives -1.0 % 3.0 == -1.0 where Python gives 2.0. Matching CPython there needs the same correction plus its copysign(0.0, divisor) zero-remainder rule and a decision on inf/NaN, so I left it for a separate change. Happy to follow up if you want it.

  2. Floor-mod vs plain remsi. I chose full floor-mod because remsi alone still disagrees with Python for negative divisors (5 % -32, not -1) and would leave a == (a // b) * b + a % b broken against the arith.floordivsi that // already emits. If you would rather have the smaller remsi-only change, that is a one-line edit.

Note the C++ bridge is correct as-is: it dispatches on signedness, which matches C++'s truncating %. Only the Python frontend needed to change.

@github-actions github-actions Bot added python-lang Anything related to the Python CUDA Quantum language implementation python bridge Involves the python bridge to quake labels Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python bridge Involves the python bridge to quake python-lang Anything related to the Python CUDA Quantum language implementation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python kernel % lowers to unsigned remainder, giving wrong results for negative operands

1 participant