Skip to content

Fix qubit state across dynamic branches - #3943

Open
Demetrios Chiuratto Agourakis (agourakis82) wants to merge 11 commits into
microsoft:mainfrom
agourakis82:fix/3940-branch-qubit-state
Open

Demetrios Chiuratto Agourakis (agourakis82) wants to merge 11 commits into
microsoft:mainfrom
agourakis82:fix/3940-branch-qubit-state

Conversation

@agourakis82

@agourakis82 Demetrios Chiuratto Agourakis (agourakis82) commented Oct 9, 2026 •

Copy link
Copy Markdown

Summary

Addresses the original reproducer and the loop-local array variant in #3940.

Partial evaluation was carrying qubit releases from one dynamic branch into the alternate path. The patch snapshots qubit resource state before the branch, restores it for the alternate path, and merges the live resources afterward. Liveness checks consult the active resource map rather than relying on references retained by snapshots.

The follow-up variant also exposed an unreachable cleanup suffix after an unconditional return in a loop. Return unification now stops rewriting a unit-valued block after a statement that unconditionally returns. Detection follows only straight-line nested blocks; a return inside a conditional arm or nested loop does not establish that the enclosing statement returns.

The regression tests cover the original reproducer, the exact variant, a range-loop variant, and controls without an early return, without a loop, and with a single qubit. Six existing snapshots are updated only to remove unreachable statements.

Validation of 2c666a4

Native Linux cluster checks with Rust 1.95.0:

  • cargo test --locked -p qsc --lib issue_3940_ -- --test-threads=4: 6 passed.
  • cargo test --locked -p qsc_fir_transforms --lib return_unify -- --test-threads=4: 463 passed.
  • cargo test --locked -p qsc_partial_eval --lib tests::qubits -- --test-threads=4: 13 passed, including the dynamic-branch use-after-release regression.
  • Formatting checks on the changed files and git diff --check passed.

With the same regression tests on preceding head 876554c, the maintainer variant and range-loop variant panic; the other four pass. With this follow-up, all six pass. These are focused compiler checks, not a claim that the full workspace suite or GitHub CI has passed on this head.

Prepared with Codex assistance. The return-lowering candidate also received a separate coder-next review; executable validation and final review remained part of the contribution workflow.

Additional return/cleanup regression coverage

Commit 6c50b68 adds six Q#-to-QIR compilation tests and one structural FIR test for return-operand effects, nested scalar/array cleanup, conditional fallthrough and possibly zero-iteration loops. Production code and the original early-return regressions remain unchanged by this follow-up.

All seven new tests pass. Scoped Clippy with warnings denied, formatting and whitespace checks pass. The existing early-return test filters also pass (16 qsc and 19 FIR-transform tests); these local checks are separate from hosted CI. The structural test checks operand evaluation before inner/array/outer cleanup and the return flag, without claiming a general semantic proof.

The maintainer's return-handling and snapshot-lifetime questions remain open for review. The investigation and additional compiler tests were prepared with OpenAI Codex assistance.

Signed-off-by: Demetrios Agourakis <[email protected]>
@agourakis82

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

@agourakis82

Copy link
Copy Markdown
Author

The workflows for this PR are awaiting maintainer approval. Could you approve the pending runs so the change can receive build and test validation?

@amcasey

Copy link
Copy Markdown
Member

Demetrios Chiuratto Agourakis (@agourakis82) Thanks for this! We're pausing a lot of compiler changes until this monster PR stack goes in: #3961. Thanks for your patience!

Integrate upstream main after the microsoft#3961 stack landed. Resolve the codegen
test conflict by preserving the new upstream tests and the microsoft#3940
indexed-qubit-array early-return regression.

Validation against main 3977495: the regression fails without the
production fix and passes with it; 221 codegen tests pass (one ignored)
and all 425 partial-evaluation tests pass. Formatting checks pass.
@agourakis82

Copy link
Copy Markdown
Author

Andrew Casey (@amcasey) Thanks for the update. #3961 is now merged, and this branch includes the subsequent main update at 3feb87cd3fbd.

The current CI Build and Test run is awaiting approval (action_required). Could you approve it and resume review when convenient?

Prepared with Codex assistance.

@amcasey

Copy link
Copy Markdown
Member

Does this address the variant repro of #3940? Maybe that's a related but distinct issue?

@agourakis82

Copy link
Copy Markdown
Author

No — I checked your exact variant on 3feb87cd3fbd, and it still panics with qubit should still be alive at qsc_eval/src/val.rs:151.

With the same native Q#-to-QIR test harness, the original indexed-array reproducer passes on the PR while your variant fails. Against base 3977495f819c, both fail.

The variant's backtrace reaches ResourceManager::release_qubit through array release and a generated dynamic branch inside the lowered loop. So I cannot yet conclude that it is an unrelated issue. I have removed the automatic closing reference from the description: this PR addresses the original reproducer, but does not cover all of #3940.

Investigation and reply prepared with Codex assistance.

pub(crate) struct QubitState {
qubits_in_use: Vec<bool>,
qubit_id_map: IndexMap<usize, usize>,
qubit_tracker: FxHashSet<Rc<Qubit>>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this going to keep Qubits alive longer than they used to be? I think there's behavior associated with their deallocation.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Via copilot

#[test]
fn released_qubit_inside_dynamic_branch_reports_error() {
    let error = get_partial_evaluation_error(indoc! {
        r#"
        namespace Test {
            operation Op(q : Qubit) : Unit { body intrinsic; }

            @EntryPoint()
            operation Main() : Unit {
                use control = Qubit();
                let q = QIR.Runtime.__quantum__rt__qubit_allocate();

                if MResetZ(control) == One {
                    QIR.Runtime.__quantum__rt__qubit_release(q);
                    Op(q);
                }
            }
        }
        "#,
    });

    assert!(matches!(
        error,
        crate::Error::EvaluationFailed(message, _)
            if message == "qubit used after release"
    ));
}

Choose a reason for hiding this comment

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

The snapshot does extend the Rc payload lifetime while the alternate path is evaluated. Qubit has no custom Drop implementation; logical resource release is explicit in free_qubit_id. A Weak reference can therefore still upgrade after logical release.

The map-only guard needed one more condition when an ID is recycled. In 015eb55, the guard requires both an active ID and Rc::ptr_eq with the allocation in qubit_tracker. On 2c666a4, regressions that release q, allocate fresh with the recycled ID, and then use q are accepted in both the true and else branches. With this change they report qubit used after release, while the fresh-allocation control remains valid.

Native Q#-to-IR checks passed: 17 qubit tests, 31 branching tests, and six #3940 cases including the original reproducer, your loop-local variant and four controls. The source hashes match the isolated compiler build. This validates these liveness cases; the snapshot still retains strong Rc ownership.

Does this address the lifetime concern, or is there another deallocation-dependent path you would like covered?

Investigation, patch and reply prepared with Codex assistance.

Choose a reason for hiding this comment

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

Merged main 0eb69e16 in 9ecb0a7. Snapshots and restores now carry physical capacity, both free heaps, logical-to-physical mappings and allocation identities. The callable QIR test also supplies the new LanguageFeatures argument.

Review found a compatibility issue in the initial integration candidate. Two mutually exclusive branches can explicitly allocate distinct Rc identities with the same logical ID. The published head compiles that input in Adaptive, while the initial candidate's new join assertion panicked. The merge restores the published overlap selection and adds both a component capacity regression and a full Adaptive QIR regression. The strict allocation-identity check at use time is preserved.

Native compiler-only validation with Rust 1.96 passed all 491 partial-evaluation tests, 12 allocator lifecycle controls, 322 selected structural return-unification tests, 254 code-generator tests and 133 RIR tests. The six cleanup cases, 85 Adaptive codegen cases and 26 base-profile cases also pass, with one existing base-profile test ignored. Formatting and Clippy for the affected compiler packages pass with -D warnings. The macOS deep-recursion test uses RUST_MIN_STACK=16777216.

I also reproduced an existing liveness gap on the exact previously published head 6c50b68d. After partial evaluation, this pattern can still be accepted despite the one-sided release.

let q = QIR.Runtime.__quantum__rt__qubit_allocate();
if MResetZ(control) == One {
    QIR.Runtime.__quantum__rt__qubit_release(q);
}
Op(q);

That behavior predates this integration. A separate hardening experiment detected it but rejected valid guarded cleanup across a loop exit, so the continuation/path-correlation work remains an open design follow-up. Is separating the allocator compatibility update from that semantic follow-up an acceptable boundary for this PR?

Prepared with OpenAI Codex assistance, including independent source review.

Choose a reason for hiding this comment

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

Implemented the liveness follow-up in 074da93.

Snapshots and release-history entries retain Rc<Qubit> payloads for allocation-identity comparisons. Logical release is now recorded separately from physical-slot reservation. For statically tracked allocations, a later use or release checks the exact allocation identity against every resource path reaching that operation. A surviving snapshot or recycled numeric ID therefore cannot revive a released allocation.

The resource analysis follows Boolean binding identities and assignment/copy semantics, including the return guards produced by lowering. It filters resource paths at ordinary branches and short-circuit edges while preserving the emitted runtime guards. Caller-visible release effects are joined when the callee completes. Callables with transitive release effects use the existing inline path for static qubit management, so their actual argument identities remain available to that analysis.

The loop check records use/release events on the emitted CFG and checks entry allocations across reachable backedges, without evaluating the source effects again. The loop exit also includes the zero-iteration state. Loop-entry alternatives retain their release and Boolean facts, including identities that are live on only some incoming paths. Delayed physical reuse creates a fresh allocation identity while preserving the slot and capacity accounting.

The three previously demonstrated invalid programs now produce the required diagnostics: one-sided release followed by unconditional use, use after release on a subsequent iteration, and double release on a subsequent iteration. The valid cleanup continuations still compile, including both partially returning arms and an outer array whose loop body always returns but whose zero-iteration path continues.

Native compiler-only validation on the frozen candidate passed all 507 partial-evaluation tests, 26 cleanup/liveness QIR tests, 51 Adaptive profile tests, 15 Adaptive RI tests, 13 Adaptive RIF tests, 26 base-profile tests (one existing test ignored), 254 code-generator tests and 133 RIR tests. Strict Clippy for the affected compiler libraries and tests, formatting and diff checks also passed. An independent rebuild in a separate target also passed all 507 partial-evaluation tests and all 26 cleanup/liveness QIR tests. The six source hashes were verified before and after that replay.

One existing allocator test had equated an occupied slot with unconditional liveness after one branch released its input. Its two liveness assertions now require that identity to be invalid, while retaining the slot, capacity and numeric-ID checks. The selected profile tests passed against their checked-in expectations.

Prepared and validated with OpenAI Codex assistance, including separate implementation, source-review and compiler-test lanes.

The pushed head is awaiting upstream workflow results and maintainer re-review.

Stop flag lowering of unit-valued blocks after a straight-line statement that unconditionally returns. This avoids evaluating duplicate array cleanup in the loop-local qubit variant of microsoft#3940. Preserve conditional and loop-containing suffixes unless a separate unconditional return establishes termination.

Add native QIR regressions and update six snapshots to omit unreachable statements.

Signed-off-by: Demetrios Agourakis <[email protected]>
@agourakis82

Copy link
Copy Markdown
Author

The variant is now covered in 2c666a4.

It exposed an unreachable cleanup suffix left after an unconditional return in the loop body. Partial evaluation visited that suffix and released the same array twice. The follow-up removes such suffixes from unit-valued blocks, following only straight-line nested blocks. It preserves qubit lifetime checks and the changes in 876554c.

On 876554c, your exact variant and a range-loop variant both panic. With the follow-up, all six focused cases pass. The return-unification suite passes all 463 tests, and all 13 qubit-management tests pass. Six snapshots changed only to remove unreachable statements. GitHub CI still needs to run on the new head.

Prepared with Codex assistance.

@agourakis82

Copy link
Copy Markdown
Author

Andrew Casey (@amcasey) Thanks for the careful review. I’ve pushed two focused follow-ups to address the comments and the loop-local variant of #3940.

In 876554ca9:

  • Renamed QubitState to QubitStateSnapshot.
  • Kept the qubit snapshot save/restore adjacent to the static var-map save/restore. The snapshot is restored immediately after the cached var map, before evaluating the otherwise branch.
  • Kept the resource-state merge after the if/else: the implicit false path still participates in the resource-state join when there is no otherwise branch, so moving the merge inside otherwise would discard state from the true path.
  • Kept Rc<Qubit> ownership only in the short-lived snapshot, since restoring the alternate path requires restoring the same qubit identity. Logical liveness is now checked against the current qubit_id_map, so a released qubit cannot be made usable merely because the snapshot still owns its Rc. The merge consumes the snapshot to avoid an additional tracker clone.

The regression test released_qubit_inside_dynamic_branch_reports_error now reports qubit used after release instead of panicking. The follow-up 2c666a480 adds six native QIR regressions for the loop-local qubit-array variant and removes unreachable cleanup suffixes after unconditional returns.

Validation on the current branch head (2c666a480):

  • cargo +1.96.0 test -p qsc --lib issue_3940_: 6 passed
  • cargo +1.96.0 test -p qsc_partial_eval --lib released_qubit_inside_dynamic_branch_reports_error: passed
  • cargo +1.96.0 fmt --all -- --check: passed

Please take another look when convenient.

@amcasey

Copy link
Copy Markdown
Member

Demetrios Chiuratto Agourakis (@agourakis82) Thanks for the updates! Just a heads up - I'll be out for a few days. Hopefully, someone will be able to pick it up while I'm gone, but I can't promise anything.

Require the active tracker allocation as well as its numeric ID when
checking liveness across branch snapshots. Cover stale references in
both branches, valid ID reuse, and the implicit-false-path peak count.
Keep the resource-state join outside otherwise to preserve that peak.

Co-Authored-By: Codex <[email protected]>
@amcasey

Copy link
Copy Markdown
Member

Demetrios Chiuratto Agourakis (@agourakis82) Both CI failures are qubit used after release, so possibly related to your change. Please have a look.

@agourakis82

Copy link
Copy Markdown
Author

Thanks for flagging this. I've checked the logs: both failing tests exercise early returns in dynamic branches and report qubit used after release. I'll trace where the qubit state diverges in those cases before changing the implementation. There is also a separate Clippy failure in return_unify/lower.rs requesting let … else.

Document the live-input contract for caller-supplied qubits and keep input registration separate from subsequent allocation-aware liveness checks. Add codegen coverage that retains an external owner across release and numeric-ID reuse.
@agourakis82

Copy link
Copy Markdown
Author

I've pushed the fix and integrated current main. Both failures came from live qubits supplied through closure captures: the new partial evaluator had not registered those inputs before checking their liveness.

Input registration now happens before evaluating the body. The stricter checks remain in place afterward, and a new regression confirms that an external strong reference cannot hide a release followed by numeric-ID reuse.

Both reported tests pass locally, along with 541 qsc tests, 439 qsc_partial_eval tests, 471 return_unify tests, and Clippy for the three affected packages. The new CI run is awaiting approval.

@amcasey

Copy link
Copy Markdown
Member

I've pushed the fix and integrated current main. Both failures came from live qubits supplied through closure captures: the new partial evaluator had not registered those inputs before checking their liveness.

Input registration now happens before evaluating the body. The stricter checks remain in place afterward, and a new regression confirms that an external strong reference cannot hide a release followed by numeric-ID reuse.

Both reported tests pass locally, along with 541 qsc tests, 439 qsc_partial_eval tests, 471 return_unify tests, and Clippy for the three affected packages. The new CI run is awaiting approval.

I'm probably missing something obvious, but I only see tests and comments in your last commit.

@agourakis82

Copy link
Copy Markdown
Author

You're right — bbb6cb9 adds the regression test and explanatory comments. The implementation change is in the preceding merge commit, ed566fd, where I also integrated main.

Specifically, PartialEvaluator::invoke registers qubits in the supplied arguments before evaluating the callable, and ResourceManager::register_input_qubit registers their identities and slots. The existing liveness checks still apply after that. Sorry for not making the split between the two commits clear.

@amcasey Andrew Casey (amcasey) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm a bit nervous about the new return handling. I'm pretty sure there's a reason we weren't already doing that. I'll try to confirm

@agourakis82

Copy link
Copy Markdown
Author

I traced the suffix rule through allocation lowering and published seven additional compiler tests in 6c50b68 for operand effects, nested scalar/array cleanup, conditional fallthrough, and a possibly zero-iteration loop. Production code and the original early-return regressions are unchanged by this follow-up.

Allocation lowering captures the operand and releases active scopes before the Return. The suffix rule removes the later duplicate suffix only in Unit blocks, following direct returns and straight-line blocks. The structural FIR test checks Value(inner) before inner/array/outer cleanup and the return flag.

Disabling only that trimming rule reproduces qubit should still be alive in the existing loop-local array regression; restoring it passes. These checks do not prove general correctness or settle the return-handling and lifetime concerns. All seven new tests, scoped Clippy and formatting pass locally; hosted CI remains a separate approval gate.

Investigation and additional tests prepared with OpenAI Codex assistance.

@agourakis82

Copy link
Copy Markdown
Author

Andrew Casey (@amcasey) Could you re-review head 6c50b68 and approve the pending workflows when convenient? The current-head runs remain at action_required, including Build and Test.

The latest follow-up adds targeted return/cleanup tests; the implementation and the earlier release/reuse regressions remain in the preceding commits. The source analysis and validation limits are recorded above. Your concern about the return transformation and qubit lifetime remains open for review; the additional tests are not a claim of general correctness.

Prepared with OpenAI Codex assistance.

@amcasey

Copy link
Copy Markdown
Member

Demetrios Chiuratto Agourakis (@agourakis82) We haven't forgotten about you! We're just trying to get somethings straightened out before our next release (some of which overlap with this change).

So that it doesn't come as a surprise later, I'll mention that while the first part of your change (state snapshots) seemed pretty straightforward, the early return change makes me much more nervous since it's a change to our historical approach (possibly an improvement, but nevertheless a change), so we're going to have to think about it in the context of a larger change (which almost certainly won't meet the bar for the current release).

Thanks for your patience!

Carry capacity and both free heaps through snapshots while retaining the
published overlap selection for mutually exclusive branch allocations.
Update the caller-qubit codegen test for the LanguageFeatures argument.

Assisted-by: OpenAI Codex
Separate allocation validity from physical slot reservation and retain
release facts by allocation identity. Track Boolean facts through guards,
copies and writes so return cleanup paths stay distinct from continuation.

Validate loop-entry identities over the emitted CFG, including repeatable
backedges and zero-iteration exits. Evaluate transitive release effects
with concrete arguments during static qubit management, and give delayed
slot reuse a fresh allocation identity.

Add diagnostic and valid-continuation controls for branches, loops,
caller-owned qubits, arrays, Boolean updates and delayed release.

Assisted-by: OpenAI Codex

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants