Skip to content

Control variants of kernels with measured uncomputation (fixes #5257) - #5630

Closed
wsttiger wants to merge 2 commits into
NVIDIA:mainfrom
wsttiger:controlled-measurement-fix
Closed

wsttiger wants to merge 2 commits into
NVIDIA:mainfrom
wsttiger:controlled-measurement-fix

Conversation

@wsttiger

@wsttiger wsttiger commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Closes #5257.

Problem

cudaq.control / cudaq::control over a kernel containing a mid-circuit measurement was silently
accepted and produced trajectory-dependent, non-physical states. createControlVariantOf adds the
new control to every op carrying the QuantumGate trait and skips measurements (which carry no such
trait). For the measured-uncomputation (MBU) idiom

AND-compute -> payload -> X-measure(anc) -> outcome-conditioned feedforward + reset

that conditions the wrong things: the outcome-conditioned reset x(anc) acquires the control, so
the ancilla is left in |m⟩ when the control is off, and (in the h; mz spelling) the basis change
acquires it too, so the measurement is taken in a control-dependent basis. The outcome distribution
then depends on the control, each outcome partially collapses it, and every get_state call follows
one random trajectory. Measured, 12 seeded trajectories, max pairwise distance with global phase
removed: 0.000 uncontrolled, 1.3 under cudaq.control, 2e-16 with this change.

Semantics

A measurement cannot take a control and is left unconditioned. For the controlled kernel to be
correct, everything the idiom needs to execute identically in both control branches must stay
unconditioned too, and the unconditioned measurement must reveal nothing about the control. The
pass accepts exactly the shape where that holds, and controls it:

  • In-kernel ancilla. The measured qubit comes from a quake.alloca in the kernel (directly or
    through a constant-index quake.extract_ref). With the control off nothing has acted on it, so
    it is |0⟩ when measured. Measuring a qubit the caller passed in is rejected: the uncontrolled
    measurement would collapse caller state the control-off branch must preserve.
  • X- or Y-basis measurement. quake.mx / quake.my only. Of |0⟩ these are fair coins, so the
    outcome is independent of the control. quake.mz of |0⟩ is deterministic and would reveal the
    control, so it is rejected (spell the erase as mx, or my with the matching feedforward).
  • Reset exempt, feedforward controlled. The outcome feeds only a cc.if in the measurement's
    block. Inside it, a gate whose only target is the measured qubit (its reset) stays unconditioned;
    every other gate receives the control, i.e. (outcome AND control).

Anything else is rejected with a diagnostic naming the reason, never guessed: a measurement
inside a conditional or loop, of a register, of a dynamically indexed qubit, or of a qubit the
kernel did not allocate; an outcome used other than as that cc.if condition; nested control flow,
calls or measurements inside its body. Straight-line cc.scope nesting (as emitted by the Python
frontend) is fine. The adjoint-controlled variant of a measuring kernel remains rejected (the
adjoint of a measurement is undefined); generated variants carry the source kernel's location so
diagnostics point at the user's kernel.

This makes control of MBU blocks correct, not gate-optimal; constructions that fold the control
into the compute remain cheaper and are unaffected.

Commits

  1. Reject control-variant generation for kernels that measure — the floor: mirror the guard
    createAdjointVariantOf already has (hasMeasureOp), returning FailureOr<func::FuncOp> and
    threading the failure through step1 and the adjoint-control path. The runtime trap message that
    surfaces specialization failures no longer claims the adjoint was at fault. Independently landable.
  2. Control variants of kernels with measured uncomputation — the semantics above.

Tests

  • test/Optimizer/apply-measurement.qke: the accepted shape in both bases (mx on a quake.alloca
    ref, my on a constant extract of a local veq<1>): compute and payload controlled, measurement
    and reset unconditioned, feedforward controlled.
  • test/Optimizer/apply-measurement-reject.qke: Z-basis, argument qubit, register, escaping
    outcome, dynamic index, adjoint-controlled.
  • test/Frontend/control_measurement.cpp: the C++ bridge's shape (cudaq::qubit member, handle
    spilled through cc.alloca) through memtoreg{quantum=0},canonicalize, as the default target
    pipelines run before apply-op-specialization.
  • python/tests/kernel/test_control_measurement.py (13 tests): the controlled MBU block equals the
    controlled unitary reference (CCZ payload; and, with no payload, the two-Toffoli identity) to
    1e-10 on every seeded trajectory at control |0⟩, |1⟩ and ry(0.7), for mx and my; Z-basis,
    argument-qubit (bare erase, and a flip-measure-reset on a |+⟩ argument) and adjoint cases raise.

@github-actions github-actions Bot added runtime CUDA quantum runtime python-lang Anything related to the Python CUDA Quantum language implementation simulation trouble getting code to run on one of the simulators core compiler labels Oct 15, 2026
@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

CI Summary (push) — ✅ passed

Run #38838706502 · ✅ 8 · ⏩ 10 · ❌ 0 · ⛔ 0

Top-level jobs (18)
Job Result
binaries ⏩ skipped
build_and_test ✅ success
changes ✅ success
config_devdeps ✅ success
config_source_build ⏩ skipped
config_wheeldeps ✅ success
devdeps ✅ success
docker_image ⏩ skipped
documentation ✅ success
gen_code_coverage ⏩ skipped
metadata ✅ success
python_devel_wheel ⏩ skipped
python_metapackages ⏩ skipped
python_wheels ⏩ skipped
python_wheels_cu12_validation ⏩ skipped
python_wheels_cu13 ⏩ skipped
source_build ⏩ skipped
wheeldeps ✅ success
⏩ Skipped jobs (10) — intentionally skipped on PR builds; run on merge_group / workflow_dispatch
Job
binaries
config_source_build
docker_image
gen_code_coverage
python_devel_wheel
python_metapackages
python_wheels_cu12_validation
python_wheels_cu13
python_wheels
source_build
All sub-jobs (47) — every matrix leg, with links
Job Status Link
Build and test (amd64, gcc12, openmpi) / Dev environment (Debug) ✅ success view
Build and test (amd64, gcc12, openmpi) / Dev environment (Python) ✅ success view
Build and test (amd64, llvm, openmpi) / Dev environment (Debug) ✅ success view
Build and test (amd64, llvm, openmpi) / Dev environment (Python) ✅ success view
Build and test (arm64, llvm, openmpi) / Dev environment (Debug) ✅ success view
Build and test (arm64, llvm, openmpi) / Dev environment (Python) ✅ success view
CI Summary ❔ in_progress view
Check for stable CUDA-Q changes ✅ success view
Configure build (devdeps) ✅ success view
Configure build (source_build) ⏩ skipped view
Configure build (wheeldeps) ✅ success view
Create CUDA 12 Python wheels ⏩ skipped view
Create CUDA 13 Python wheels ⏩ skipped view
Create CUDA Quantum installer ⏩ skipped view
Create Docker images ⏩ skipped view
Create Python metapackages ⏩ skipped view
Create shared Python development wheel ⏩ skipped view
Documentation / Build and check docs ✅ success view
Gen code coverage ⏩ skipped view
Load dependencies (amd64, gcc12) / Caching ✅ success view
Load dependencies (amd64, gcc12) / Finalize ✅ success view
Load dependencies (amd64, gcc12) / Metadata ✅ success view
Load dependencies (amd64, llvm) / Caching ✅ success view
Load dependencies (amd64, llvm) / Finalize ✅ success view
Load dependencies (amd64, llvm) / Metadata ✅ success view
Load dependencies (arm64, gcc12) / Caching ✅ success view
Load dependencies (arm64, gcc12) / Finalize ✅ success view
Load dependencies (arm64, gcc12) / Metadata ✅ success view
Load dependencies (arm64, llvm) / Caching ✅ success view
Load dependencies (arm64, llvm) / Finalize ✅ success view
Load dependencies (arm64, llvm) / Metadata ✅ success view
Load source build cache ⏩ skipped view
Load wheel dependencies (amd64, 12.6) / Caching ✅ success view
Load wheel dependencies (amd64, 12.6) / Finalize ✅ success view
Load wheel dependencies (amd64, 12.6) / Metadata ✅ success view
Load wheel dependencies (amd64, 13.0) / Caching ✅ success view
Load wheel dependencies (amd64, 13.0) / Finalize ✅ success view
Load wheel dependencies (amd64, 13.0) / Metadata ✅ success view
Load wheel dependencies (arm64, 12.6) / Caching ✅ success view
Load wheel dependencies (arm64, 12.6) / Finalize ✅ success view
Load wheel dependencies (arm64, 12.6) / Metadata ✅ success view
Load wheel dependencies (arm64, 13.0) / Caching ✅ success view
Load wheel dependencies (arm64, 13.0) / Finalize ✅ success view
Load wheel dependencies (arm64, 13.0) / Metadata ✅ success view
Prepare cache clean-up ❔ in_progress view
Retrieve PR info ✅ success view
Validate CUDA 12 Python wheels ⏩ skipped view
✅ Required checks (7/7) — declared in .github/required-checks.yml for push
Required check Status Link
Build and test (amd64, llvm, openmpi) / Dev environment (Debug) ✅ success view
Build and test (amd64, llvm, openmpi) / Dev environment (Python) ✅ success view
Build and test (arm64, llvm, openmpi) / Dev environment (Debug) ✅ success view
Build and test (arm64, llvm, openmpi) / Dev environment (Python) ✅ success view
Build and test (amd64, gcc12, openmpi) / Dev environment (Debug) ✅ success view
Build and test (amd64, gcc12, openmpi) / Dev environment (Python) ✅ success view
Documentation / Build and check docs ✅ success view

@wsttiger
wsttiger force-pushed the controlled-measurement-fix branch from 9285e23 to b23d223 Compare October 15, 2026 22:09
@github-actions github-actions Bot added the testing Relates to testing label Oct 15, 2026

@sacpis sacpis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Thanks @wsttiger. Left a comment.

Comment on lines +1653 to +1667
bool computed = false;
for (Operation *op = m->getPrevNode(); op && !computed;
op = op->getPrevNode()) {
if (!op->hasTrait<cudaq::QuantumGate>())
continue;
auto gate = dyn_cast<cudaq::quake::OperatorInterface>(op);
if (!gate)
continue;
for (Value t : gate.getTargets())
if (auto k = wireKeyOf(t); k && *k == *key) {
computed = true;
break;
}
}
if (!computed) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the whole method assumes the measured qubit enters the kernel in |0>. The check only asks whether any earlier gate in the kernel targets that qubit. If the qubit is a kernel argument holding real user state, the check passes anyway. Then, with the control off, the measurement (never controlled) collapses the user's qubit and the reset sets it to |0>.

This will fail if a kernel takes a qubit, applies x, measures it, and resets on 1. Run it under a control qubit that stays |0>, with the target in |+>. The result should be unchanged, but the target will come back as |0>.

Should we accept only measured qubits that come from a quake.alloca inside the kernel? If the qview-ancilla style in the tests must keep working, let's state the "caller passes the ancilla in |0>" rule in the the docs?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, that case was unsound, thanks. The "earlier gate writes it" test is replaced by the rule you suggested: the measured ref must trace to a quake.alloca inside the kernel (directly or via a constant-index extract, through relax_size). A measured kernel argument is now rejected with "a measurement of a qubit the kernel does not allocate itself". Two tests pin it: the bare erase on a qview element, and your scenario verbatim (test_measured_argument_with_user_state_is_rejected: argument in |+⟩, x; mx; reset-on-1, control off). The tests' qview-ancilla style is gone; they allocate the ancilla in the kernel.

@schweitzpgi schweitzpgi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like there is a general fold being added as a rando side-effect.

@wsttiger
wsttiger force-pushed the controlled-measurement-fix branch from b23d223 to 5c3ecf1 Compare October 16, 2026 01:11
createControlVariantOf added the new control to every op carrying the
QuantumGate trait and silently skipped measurements. For a kernel that
measures, the result is wrong rather than merely unsupported: the basis
change feeding the measurement and the outcome-conditioned reset of the
measured qubit acquire the control, so the measurement outcome distribution
depends on the control and each trajectory returns a different,
non-physical state (issue NVIDIA#5257).

Mirror the guard createAdjointVariantOf already has: fail with a
diagnostic (or plain failure under legacy-classical) when the kernel
contains a measurement, and thread the failure through step1 and the
adjoint-control path by returning FailureOr<func::FuncOp>. The runtime trap
message that surfaces such failures no longer claims the adjoint was at
fault.

Tests: a verify-diagnostics case for the controlled and adjoint-controlled
variants, and a Python test asserting cudaq.control over a measuring kernel
raises instead of running.

Signed-off-by: Scott Thornton <wsttiger@gmail.com>
@wsttiger
wsttiger force-pushed the controlled-measurement-fix branch from 5c3ecf1 to f4b32da Compare October 16, 2026 02:04
A measurement cannot take a control and is left unconditioned; for the
controlled kernel to be correct, everything the measurement idiom needs
to execute identically in both control branches must stay unconditioned
too. Instead of rejecting every measuring kernel, recognize the
measured-uncomputation shape and control it correctly:

- The measured qubit is allocated by the kernel itself (`quake.alloca`,
  directly or through a constant-index extract), so with the control off
  it is still |0> when measured.
- It is measured in the X or Y basis (`quake.mx`/`quake.my`): of |0>
  that outcome is a fair coin that reveals nothing about the control.
  A Z-basis measurement of |0> is deterministic, so its outcome would
  reveal the control; `quake.mz` is rejected.
- The outcome feeds only a `cc.if` in the same block. Inside it, a gate
  whose only target is the measured qubit (its reset) stays
  unconditioned; every other gate receives the control as usual, giving
  (outcome AND control).

Any other shape -- a measurement inside a conditional or a loop, of a
register, of a dynamically indexed qubit, or of a qubit the kernel did
not allocate, an outcome used other than as that `cc.if` condition, or
nested control flow, calls or measurements inside its body -- is still
rejected with a diagnostic naming the reason. Straight-line `cc.scope`
nesting is allowed. Generated variants carry the source kernel's
location so diagnostics on them (e.g. the adjoint of a measuring kernel,
which remains undefined) point at the user's kernel.

Tests: FileCheck for the accepted shape in both bases (compute and
payload controlled, measurement and reset not) and for each rejected
shape; a C++ frontend test through memtoreg; Python tests comparing the
controlled block against the controlled unitary reference over seeded
trajectories at control |0>, |1> and superposed, plus the rejections.

Closes NVIDIA#5257.

Signed-off-by: Scott Thornton <wsttiger@gmail.com>
@wsttiger
wsttiger force-pushed the controlled-measurement-fix branch from f4b32da to 1b3aec4 Compare October 16, 2026 02:06
@wsttiger
wsttiger requested a review from schweitzpgi October 16, 2026 22:39
@schweitzpgi

schweitzpgi commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

According to the CUDA-Q Spec, § 7, ¶ 4 there aren't really any defined limitations or correctness considerations specified for cudaq::control transformation. This is seeking to add or at least define some. As a matter of process, we probably need to run this past the arch guys.

I do think cudaq::control, cudaq::adjoint, ... need to come with big flashing signals that they don't work with all CUDA-Q kernels (and are not intended to either).

@boschmitt

@wsttiger

Copy link
Copy Markdown
Contributor Author

Decided to go in a different direction (for now)

github-actions Bot pushed a commit that referenced this pull request Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core compiler python-lang Anything related to the Python CUDA Quantum language implementation runtime CUDA quantum runtime simulation trouble getting code to run on one of the simulators testing Relates to testing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cudaq.control on a kernel containing mid-circuit measurement is silently accepted; result is trajectory-dependent

3 participants