Skip to content

[Python] Evaluate a negative literal exponent in floating point - #5168

Open
udsy19 wants to merge 1 commit into
NVIDIA:mainfrom
udsy19:fix/int-negative-exponent
Open

[Python] Evaluate a negative literal exponent in floating point#5168
udsy19 wants to merge 1 commit into
NVIDIA:mainfrom
udsy19:fix/int-negative-exponent

Conversation

@udsy19

@udsy19 udsy19 commented Aug 18, 2026

Copy link
Copy Markdown

In Python an integer raised to a negative integer power is a float: 2 ** -1
is 0.5. The Python AST bridge lowered every integer base with an integer
exponent to math.ipowi, an integer power whose lowering returns 0 for a
negative exponent unless the base is +/-1, so inside a @cudaq.kernel:

  2 ** -1      kernel = 0.0   python = 0.5
  2 ** -3      kernel = 0.0   python = 0.125
  3 ** -1      kernel = 0.0   python = 0.3333333333333333
  (-2) ** -1   kernel = 0.0   python = -0.5

No error or warning was raised. Type promotion is deliberately skipped for
Pow, so nothing moved the expression into floating point.

What this changes

When the exponent is a negative integer literal — the case Python types as a
float — the base is converted to f64 and math.fpowi is used instead of
math.ipowi. math.fpowi already handles negative exponents correctly, which
is why 2.0 ** -1 has always worked. Everything else is untouched:
2 ** 3 is still an i64 math.ipowi, and range(2 ** n) still compiles.

The exponent is read from the AST rather than from the emitted SSA value,
because the bridge does not constant-fold local variables and only materialises
a constant for the literal -1 (there is an existing special case for it in
visit_UnaryOp); -3 is emitted as arith.muli(-1, 3). Reading the literal
from the AST is also what the range() step handling in this file already does
when it needs the sign of a literal at compile time. __process_binary_op
therefore takes the right-hand AST node as an optional argument; both call
sites (visit_BinOp, visit_AugAssign) pass it.

What this deliberately does not change

An exponent whose sign is only known at run time — a kernel parameter, or a
local such as n = -1 — keeps the integer power and the integer result type:

@cudaq.kernel
def kernel(base: int, exponent: int) -> float:
    return base ** exponent      # kernel(2, -1) is still 0.0, not 0.5

The type of the expression has to be fixed at compile time, and Python's rule
makes it depend on the value of the exponent. Promoting int ** int to a
float unconditionally would cover those cases, but it would change the type of
2 ** 3, break range(2 ** n) and integer indices like 2 ** n - 1, and
change the IR checked by python/tests/mlir/qft.py — which seems worse than
the bug. Diagnosing it at run time would be the complete answer, but there is
no abort/assert facility in the kernel IR to build that on today. I'm happy to
follow up with whichever direction maintainers prefer; the issue lists the
options.

One visible consequence: x **= -1 on an integer variable now fails to compile
with "augment-assign must not change the variable type" instead of silently
storing 0. Python rebinds x to a float there, which a typed kernel variable
cannot do, so an error is the honest outcome. x **= -1 on a float variable is
unaffected (0.5), as is x **= 3 on an integer variable (8).

The C++ frontend has the analogous lowering
(cudaq/lib/Frontend/nvqpp/ConvertExpr.cpp, visitMathLibFunc): it peels off
the int-to-double conversion clang inserts for std::pow(int, int), emits
math.ipowi, and casts the integer result back to double, so
std::pow(2, -1) cannot return 0.5 there either. Since std::pow always
returns double, that frontend could simply use math.fpowi unconditionally,
with no type question to answer — but it is a separate change with its own
AST-Quake tests, and I could not build the C++ toolchain to validate it, so
this PR leaves it alone.

Testing

python/tests/kernel/test_kernel_float.py gains three tests:

  • test_negative_integer_exponent_is_float — the reported cases plus a
    negative base and a run-time base with a literal negative exponent. Fails
    before this change, passes after.
  • test_non_negative_integer_exponent_is_integer — guards the unchanged path:
    2 ** 3, 2 ** 0, 2 ** 30, a run-time base and exponent, and
    range(2 ** 3) as a loop bound, all returning int.
  • test_float_base_with_integer_exponent — guards the floating-point base for
    both signs of the exponent.

No MLIR CHECK test changes: the new lowering only fires for a negative literal
exponent, which no existing test uses.

Fixes #5167

Validation actually performed (local, on an installed 0.15.1 wheel)

The change was applied to the ast_bridge.py of an installed cudaq 0.15.1
wheel (cuda_quantum_cu13, Python 3.13, macOS 26.5.1 arm64), exercised end to end,
and the wheel restored afterwards.

Before:

  2 ** -1        0.0    python 0.5                   wrong
  2 ** -3        0.0    python 0.125                 wrong
  3 ** -1        0.0    python 0.3333333333333333    wrong
  (-2) ** -1     0.0    python -0.5                  wrong
  b ** -2, b=3   0.0    python 0.1111111111111111    wrong
  b ** -2, b=-2  0.0    python 0.25                  wrong
  n=-1; 2 ** n   0.0    python 0.5                   wrong (run-time exponent)
  param(2,-1)    0.0    python 0.5                   wrong (run-time exponent)
  2 ** 3         8      2 ** 0  1     2 ** 30  1073741824      ok
  2.0 ** -1      0.5    2.0 ** -1.5  0.35355339059327373       ok
  range(2**3)    28     range(2**n), n=3  28                   ok

After:

  2 ** -1        0.5                          ok
  2 ** -3        0.125                        ok
  3 ** -1        0.3333333333333333           ok
  (-2) ** -1     -0.5                         ok
  b ** -2, b=3   0.1111111111111111           ok
  b ** -2, b=-2  0.25                         ok
  n=-1; 2 ** n   0.0                          unchanged (run-time exponent)
  param(2,-1)    0.0                          unchanged (run-time exponent)
  2 ** 3         8 (still int)   2 ** 0  1    2 ** 30  1073741824      ok
  2.0 ** -1      0.5    2.0 ** -1.5  0.35355339059327373              ok
  range(2**3)    28     range(2**n), n=3  28                          ok

New tests against the same wheel:

  unpatched:  1 failed, 2 passed   (test_negative_integer_exponent_is_float fails)
  patched:    3 passed

Wider regression subset (test_kernel_float.py, test_assignments.py,
test_cast_kernel.py, test_kernel_shift_operators.py, test_kernel_return.py,
test_kernel_complex.py) run both ways against the same wheel:

  unpatched:  9 failed, 89 passed
  patched:    8 failed, 90 passed

The 8 remaining failures are identical in both runs and are wheel-vs-main
skew, not regressions: test_math_* and test_float_floor_division_error
exercise main-only math support and error messages, and
test_assignments.py::test_var_scopes likewise. The only difference between
the two runs is the new test.

yapf 0.40.2 --style google (the version pinned in .pre-commit-config.yaml)
reports no diff on both changed files.

Deferred to CI (not runnable locally)

  • No from-source build of main: the C++ frontend, the lit/FileCheck suites and
    the full test matrix were not run. The change cannot alter existing CHECK
    output, since it only fires on a negative literal exponent and no existing
    test uses one (verified by grep over python/tests).

Files changed

  • python/cudaq/kernel/ast_bridge.py (+32/-4) — __integerLiteralValue helper,
    the ast.Pow integer branch, and the optional right-hand AST node parameter
    on __process_binary_op plus its two call sites.
  • python/tests/kernel/test_kernel_float.py (+86) — three tests.

Open questions for maintainers

  1. What should a run-time negative exponent do — keep integer semantics
    (this PR), diagnose at run time, or something else?
  2. Should the C++ frontend switch std::pow(int, int) to math.fpowi? Its
    result is always double, so nothing is lost, and it would make both
    frontends agree with their own source language.

In Python an integer raised to a negative integer power is a float:
`2 ** -1` is `0.5`. The Python bridge lowered any integer base with an
integer exponent to `math.ipowi`, an integer power whose lowering returns
0 for a negative exponent unless the base is +/-1, so `2 ** -1`,
`2 ** -3` and `3 ** -1` all evaluated to 0 inside a kernel with no
diagnostic.

Promote the base and use `math.fpowi` when the exponent is a negative
integer literal, which is the case Python types as a float. An exponent
whose sign is only known at run time keeps the integer power, since the
type of the expression has to be determined at compile time.

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.

@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
@schweitzpgi

Copy link
Copy Markdown
Collaborator

The C++ frontend has the analogous lowering
(cudaq/lib/Frontend/nvqpp/ConvertExpr.cpp, visitMathLibFunc): it peels off
the int-to-double conversion clang inserts for std::pow(int, int), emits
math.ipowi, and casts the integer result back to double, so
std::pow(2, -1) cannot return 0.5 there either.

I'll file an issue. It's been a while since I looked at this, but I think the conversions might be done in the C++ templates in the header files.

@schweitzpgi

Copy link
Copy Markdown
Collaborator

While I sympathize that getting back a 0 isn't what a mathematician would expect here.

@cudaq.kernel
def kernel(base: int, exponent: int) -> float:
    return base ** exponent      # kernel(2, -1) is still 0.0, not 0.5

It is certainly hard to argue that the user did not specify that this was an integer pow from the types of the arguments. As integers do not have fractional parts, 0 makes as much sense as anything else.

It's not really a compiler's job to deliberately ignore the source code, which includes the types. Within a CUDA-Q kernel, one will not have Python's arbitrary precision arithmetic, which is definitely different than plain old Python code.

As the user may be 100% cognizant that using int pow has the implementation it does and chose int types exactly for that behavior, "fixing" it so that they cannot get int pow at all seems to invite more problems than it solves. If a user wants floating point values, they need to use floating point types.

@schweitzpgi schweitzpgi added the wontfix This will not be worked on label Aug 26, 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 wontfix This will not be worked on

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Integer base with a negative exponent returns 0.0 in kernels (2 ** -1)

2 participants