Repository navigation
fix: avoid integer overflow in ParameterExpression arithmetic - #16980
Eduardo Alcantara (edubraqd) wants to merge 2 commits into
Conversation
Integer-valued `Value`s used raw `+`, `-`, `*`, unary `-` and `pow`, which
panic on overflow in debug builds and wrap silently in release builds. This
was reachable from public API, e.g. binding a parameter to a large integer:
qc.global_phase = p * 2
qc.assign_parameters({p: 2**62})
The Int/Int paths now use checked arithmetic and fall back to a floating-point
result when the exact integer value does not fit in an i64, matching the
existing Int/Real coercion in the same operators.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
Thank you for opening a new pull request. Before your PR can be merged it will first need to pass continuous integration tests and be reviewed. Sometimes the review process can be slow, so please be patient. While you're waiting, please feel free to review other open PRs. While only a subset of people are authorized to approve pull requests for merging, everyone is encouraged to review open pull requests. Doing reviews helps reduce the burden on the core team and helps make the project's code better for everyone. One or more of the following people are relevant to this code:
|
Luciano Bello (1ucian0)
left a comment
There was a problem hiding this comment.
Thanks Eduardo! This fix makes sense to me.
Julien Gacon (Cryoris)
left a comment
There was a problem hiding this comment.
Thanks for submitting a fix. What about div when we divide by something close to 0 such that the result is outside i64?
Also it would be nice to have a Python test, at least as regression test for the example in your readme.
| could overflow the internal 64-bit integer representation. Binding a | ||
| parameter to a large integer and then adding, subtracting, multiplying, | ||
| negating or exponentiating it (for example via :meth:`.QuantumCircuit.assign_parameters`, | ||
| the ``global_phase`` of a circuit, or :meth:`.ParameterExpression.numeric`) | ||
| would panic in debug builds and silently wrap around to an incorrect value | ||
| in release builds. Such operations now fall back to a floating-point result | ||
| when the exact integer result does not fit in a 64-bit integer, for example:: | ||
|
|
||
| from qiskit import QuantumCircuit | ||
| from qiskit.circuit import Parameter | ||
|
|
||
| p = Parameter("p") | ||
| qc = QuantumCircuit(1) | ||
| qc.global_phase = p * 2 | ||
| qc.assign_parameters({p: 2**62}) # previously panicked or wrapped |
There was a problem hiding this comment.
This is quite wordy, I think we can reduce this
| could overflow the internal 64-bit integer representation. Binding a | |
| parameter to a large integer and then adding, subtracting, multiplying, | |
| negating or exponentiating it (for example via :meth:`.QuantumCircuit.assign_parameters`, | |
| the ``global_phase`` of a circuit, or :meth:`.ParameterExpression.numeric`) | |
| would panic in debug builds and silently wrap around to an incorrect value | |
| in release builds. Such operations now fall back to a floating-point result | |
| when the exact integer result does not fit in a 64-bit integer, for example:: | |
| from qiskit import QuantumCircuit | |
| from qiskit.circuit import Parameter | |
| p = Parameter("p") | |
| qc = QuantumCircuit(1) | |
| qc.global_phase = p * 2 | |
| qc.assign_parameters({p: 2**62}) # previously panicked or wrapped | |
| could overflow the internal 64-bit integer representation, like:: | |
| from qiskit import QuantumCircuit | |
| from qiskit.circuit import Parameter | |
| p = Parameter("p") | |
| qc = QuantumCircuit(1) | |
| qc.global_phase = p * 2 | |
| qc.assign_parameters({p: 2**62}) # previously panicked or wrapped | |
| Such operations now fall back to a floating-point result when the exact integer result does | |
| not fit in a 64-bit integer. | |
|
It's not obvious that this actually is the correct solution for a symbolic engine. This makes it not error, but silently lifts the logic from integer to floating point, which affects the semantics of all subsequent operations. The alternative is of course to add a Lifting to float may be appropriate because Qiskit it's not a general-purpose symbolic engine, but it is something that needs to have a positive decision and probably a bit of discussion, which might have been better in a bug report / feature request. |
Yeah that also made me a bit uneasy when reading it, yet we already do have some lifting to float present, e.g. for divisions or trigonometric functions (the slightly strange thing there is that we sometimes try to cast back to int when it happens to hit an integer value). The other point is backward compat, those overflows would've worked with the Python version of parameter expressions, right? (Maybe points for a more detailed discussion) |
|
Most general-purpose symbolic engines will support arbitrary-width integers, whether in Python or not. We're less general purpose, so we don't have to do that. I'm not saying I know the right answer, or necessarily want/need to be part of the decision here, I'm just saying that there is a concrete decision that should be made, considering the options.
This is a completely different situation. Mixing |
|
Let's block this PR on the discussion resolution in #17217 |
`Value::Int / Value::Int` computed the quotient in `f64` and cast back with `as i64`, which rounds above 2**53 and saturates `i64::MIN / -1`. Divide in integers with `checked_rem`/`checked_div` instead, falling back to a float on overflow like the other operators. `Int ** Real` narrowed integral results to `Int` with a saturating cast, so `2 ** 70.0` evaluated to `i64::MAX`. Only narrow when the value fits. Add a Python regression test covering the boundary of every operator. Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
Thanks Julien Gacon (@Cryoris). On
Checking that path turned up two more cases of the same "compute in p, q = Parameter("p"), Parameter("q")
(p ** q).assign(p, 2).assign(q, 70.0).numeric() # 9223372036854775807, saturated
(p / q).assign(p, 3 * 2**60 + 3).assign(q, 3).numeric() # 2**60, should be 2**60 + 1The latest commit:
On Jake Lishman (@jakelishman)'s point: agreed, falling back to a float is a choice that should be made on purpose. I kept every operator on the same rule, so whatever #17217 decides (float, BigInt, or raising) is a mechanical change across them. I'll rework it once that's settled. |
Summary
Integer arithmetic on
ParameterExpressionoverflows the internali64, which panics in debug builds and wraps silently to a wrong value in release builds. It reproduces from the public API:Same path is hit by
(p * 2).assign(p, 2**62).numeric(),float(p + 2**62 ...),-pati64::MIN, andp ** 3with a large base.Cause
In
crates/circuit/src/parameter/symbol_expr.rs, theInt/Intbranches ofAdd,Sub,Mul,NegandValue::powuse rawl + r,l - r,l * r,-vande.pow(r as u32). Theas u32also truncates a large exponent before overflowing.Fix
Use checked arithmetic and fall back to
Value::Real(f64)when the exact integer result does not fit in ani64. This mirrors the coercion the same operators already apply when mixingIntandReal, so in-range integer math is unchanged and the boundary degrades to a finite float instead of panicking/wrapping. Values that stay in range keep their exactIntresult.One file changed plus a release note. Added a Rust unit test (
test_value_int_arithmetic_saturates_to_real) covering each operator at thei64boundary and confirming in-range results are untouched.AI/LLM disclosure