Skip to content

✨ Improve Static Qubit Handling#1569

Open
simon1hofmann wants to merge 38 commits intomainfrom
static_qubit_handling
Open

✨ Improve Static Qubit Handling#1569
simon1hofmann wants to merge 38 commits intomainfrom
static_qubit_handling

Conversation

@simon1hofmann
Copy link
Contributor

@simon1hofmann simon1hofmann commented Mar 17, 2026

Description

Add explicit isStatic flag to QC/QCO qubit types, make static qubits distinct from dynamic ones, preserve staticness through conversions, treat StaticOp as allocation, skip lowering dealloc for static qubits, emit deallocs at top-level returns for remaining dynamic qubits, and add tests and builders for static-qubit scenarios.

Checklist

  • The pull request only contains commits that are focused and relevant to this change.
  • I have added appropriate tests that cover the new/changed functionality.
  • I have updated the documentation to reflect these changes.
  • I have added entries to the changelog for any noteworthy additions, changes, fixes, or removals.
  • I have added migration instructions to the upgrade guide (if needed).
  • The changes follow the project's style guidelines and introduce no new warnings.
  • The changes are fully tested and pass the CI checks.
  • I have reviewed my own code changes.

If PR contains AI-assisted content:

  • I have disclosed the use of AI tools in the PR description as per our AI Usage Guidelines.
  • AI-assisted commits include an Assisted-by: [Model Name] via [Tool Name] footer.
  • I confirm that I have personally reviewed and understood all AI-generated content, and accept full responsibility for it.

@simon1hofmann simon1hofmann added enhancement Improvement of existing feature MLIR Anything related to MLIR labels Mar 17, 2026
@simon1hofmann simon1hofmann added this to the MLIR Support milestone Mar 17, 2026
@simon1hofmann simon1hofmann self-assigned this Mar 17, 2026
@codecov
Copy link

codecov bot commented Mar 17, 2026

@simon1hofmann simon1hofmann marked this pull request as ready for review March 17, 2026 14:23
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 17, 2026

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Distinct static vs. dynamic qubit types with explicit assembly syntax; staticness is preserved across conversions and operations.
    • Builders and modifiers now propagate concrete qubit types so block/region arguments reflect input qubit types.
  • Bug Fixes

    • Enforced pairwise qubit-type preservation for unitary ops, barriers, modifiers, measure/reset, and dealloc semantics to prevent invalid static-qubit usage.
    • Improved lowering/resynchronization to maintain correct qubit typing across regions.
  • Tests

    • Added program builders and parameterized tests covering static, dynamic, and mixed qubit scenarios.

Walkthrough

Adds explicit static vs dynamic qubit support across QC/QCO: type parameter and constraints, assembly parse/print, builders updated to preserve isStatic, verification enforcing pairwise type preservation, region-aware QC↔QCO conversions with conditional dealloc handling, mapping resynchronization, and expanded tests/program builders.

Changes

Cohort / File(s) Summary
Type declarations & assembly
mlir/include/mlir/Dialect/QC/IR/QCTypes.td, mlir/include/mlir/Dialect/QCO/IR/QCOTypes.td, mlir/include/mlir/Dialect/QCO/IR/QCOOps.td, mlir/include/mlir/Dialect/QC/IR/QCOps.td
Add isStatic bool to QubitType, introduce StaticQubit/DynamicQubit constraints, custom assembly parse/print for <static>, and update alloc/static op result types/builders.
Dialect parsing/printing & ops
mlir/lib/Dialect/QC/IR/QCOps.cpp, mlir/lib/Dialect/QCO/IR/QCOOps.cpp, mlir/lib/Dialect/QCO/IR/Modifiers/CtrlOp.cpp, mlir/lib/Dialect/QCO/IR/Modifiers/InvOp.cpp, mlir/lib/Dialect/QCO/IR/Operations/StandardGates/BarrierOp.cpp
Implement QubitType parse/print, support optional inline alias types, build region block args from operand types, and add verifier checks enforcing pairwise qubit-type preservation for modifiers and barrier.
Conversion: QC↔QCO and QCO→QC
mlir/lib/Conversion/QCToQCO/QCToQCO.cpp, mlir/lib/Conversion/QCOToQC/QCOToQC.cpp
Preserve isStatic in type conversion; QC→QCO lowered to per-region qubit maps with modifier-frame stack and new return rewriting that inserts deallocs; QCO→QC erases qco.dealloc for static qubits and emits qc::DeallocOp for dynamic.
Builders & public APIs
mlir/lib/Dialect/QCO/Builder/QCOProgramBuilder.cpp, mlir/lib/Dialect/QC/Builder/QCProgramBuilder.cpp, mlir/include/mlir/Dialect/QCO/Builder/QCOProgramBuilder.h, mlir/include/mlir/Dialect/QC/Builder/QCProgramBuilder.h
Change staticQubit index parameter to uint64_t, remove negative-index check; builders create modifier/inv region args using each operand's concrete type.
Mapping, transforms & utils
mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp, mlir/include/mlir/Dialect/QCO/Utils/ValueOrdering.h
Add resynchronization to repair stale qubit SSA types after placement, cloning utilities to rebuild Ctrl/Inv bodies with corrected types, and SSAOrder comparator for deterministic ordering.
QTensor interaction & build
mlir/lib/Dialect/QTensor/IR/Operations/AllocOp.cpp, mlir/lib/Dialect/QTensor/IR/CMakeLists.txt
Alloc builder uses explicit non-static qco::QubitType; qtensor.alloc verification rejects static-qubit element types; CMake links MLIRDialectUtils to MLIRQTensorDialect.
Tests & program builders
mlir/unittests/.../test_qco_to_qc.cpp, mlir/unittests/.../test_qc_to_qco.cpp, mlir/unittests/.../test_qc_ir.cpp, mlir/unittests/.../test_qco_ir.cpp, mlir/unittests/.../test_qir_ir.cpp, mlir/unittests/Compiler/test_compiler_pipeline.cpp, mlir/unittests/Conversion/QCToQIR/test_qc_to_qir.cpp, mlir/unittests/programs/qc_programs.*, mlir/unittests/programs/qco_programs.*, mlir/unittests/programs/qir_programs.*, mlir/unittests/programs/*_programs.h, mlir/unittests/Dialect/QCO/Transforms/Mapping/test_mapping.cpp
Add new static / mixed static-dynamic program builders and corresponding test instantiations; add mapping tests asserting type synchronization and modifier region argument types.
Docs/changelog & CI build files
CHANGELOG.md, mlir/unittests/Dialect/QCO/Transforms/Mapping/CMakeLists.txt
Add PR reference to changelog and update a CMake target link dependency for the mapping unit tests.

Sequence Diagram(s)

sequenceDiagram
  participant QC as QC IR
  participant QCToQCO as QC->QCO Lowering
  participant QCO as QCO IR
  participant QCOToQC as QCO->QC Lowering
  participant FuncReturn as Func.return Rewrite

  QC->>QCToQCO: lower ops (alloc/static/ctrl/inv/yield)
  QCToQCO->>QCToQCO: push modifier frames & assign per-region qubit maps
  QCToQCO->>QCO: emit qco ops with mapped qubit types (preserve isStatic)
  QCO->>QCOToQC: conversion back to qc ops
  QCOToQC->>QCOToQC: preserve isStatic in type conversion
  QCOToQC->>QCOToQC: decide dealloc handling
  alt qubit is static
    QCOToQC-->>QCO: erase qco.dealloc
  else qubit is dynamic
    QCOToQC->>QC: emit qc::DeallocOp
  end
  QCOToQC->>FuncReturn: on return, compute escaped qubits
  FuncReturn->>FuncReturn: insert qco.dealloc for non-escaped live qubits and replace return
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested labels

c++

Suggested reviewers

  • burgholzer
  • denialhaag

"I hopped through code with careful paws,
Static or dynamic — I sniffed the clause.
Types now travel, preserved and neat,
Builders updated, mappings complete.
A tiny rabbit cheers this quantum feat! 🐇"

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.10% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Improve Static Qubit Handling' is directly related to the primary change: adding explicit isStatic flags to QC/QCO qubit types and handling static vs dynamic qubits throughout the codebase.
Description check ✅ Passed The PR description provides a clear summary of changes and covers most required sections, but lacks entries for changelog and migration instructions, and does not confirm CI passing.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch static_qubit_handling

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@mlir/lib/Conversion/QCToQCO/QCToQCO.cpp`:
- Around line 136-163: state.qubitMap contains SSA values for return operands
too, so the current loop deallocates qubits that are about to escape via
func::ReturnOp; modify the qubit collection before sorting/Dealloc emission to
exclude any Values that appear in adaptor.getOperands() (the operands passed to
the new func::ReturnOp) so those escaping qubits are not deallocated; locate the
block that builds liveQubits from state.qubitMap, filter out
adaptor.getOperands() (or build a DenseSet of return operands and skip them when
inserting into liveQubits), then continue with the deterministic sort and
creation of qco::DeallocOp for the remaining qubits and clear state.qubitMap as
before.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 535af8d9-20fd-4c6d-b9b8-f07a948730d4

📥 Commits

Reviewing files that changed from the base of the PR and between a5cf121 and 77e00a4.

📒 Files selected for processing (16)
  • mlir/include/mlir/Dialect/QCO/IR/QCOOps.td
  • mlir/lib/Conversion/QCOToQC/QCOToQC.cpp
  • mlir/lib/Conversion/QCToQCO/QCToQCO.cpp
  • mlir/unittests/Compiler/test_compiler_pipeline.cpp
  • mlir/unittests/Conversion/QCOToQC/test_qco_to_qc.cpp
  • mlir/unittests/Conversion/QCToQCO/test_qc_to_qco.cpp
  • mlir/unittests/Conversion/QCToQIR/test_qc_to_qir.cpp
  • mlir/unittests/Dialect/QC/IR/test_qc_ir.cpp
  • mlir/unittests/Dialect/QCO/IR/test_qco_ir.cpp
  • mlir/unittests/Dialect/QIR/IR/test_qir_ir.cpp
  • mlir/unittests/programs/qc_programs.cpp
  • mlir/unittests/programs/qc_programs.h
  • mlir/unittests/programs/qco_programs.cpp
  • mlir/unittests/programs/qco_programs.h
  • mlir/unittests/programs/qir_programs.cpp
  • mlir/unittests/programs/qir_programs.h

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@mlir/lib/Conversion/QCToQCO/QCToQCO.cpp`:
- Around line 139-142: The current code builds escapedQubits from
adaptor.getOperands(), which returns stale allocation values because gate
patterns (e.g., ConvertQCOneTargetZeroParameterToQCO) call rewriter.eraseOp()
and only update state.qubitMap while ConvertQCAllocOp uses replaceOpWithNewOp()
and updates the ConversionValueMapping; fix by iterating the original return
operands but for each Value check state.qubitMap for a mapped replacement and
insert that mapped Value into escapedQubits (falling back to the original
operand only if no entry exists), so ConvertFuncReturnOp/func::ReturnOp uses the
final mapped qubit values instead of stale adaptor.getOperands() results.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0f33f39f-095a-4961-8d6e-a6cbabbdf6ca

📥 Commits

Reviewing files that changed from the base of the PR and between 77e00a4 and 71bbe39.

📒 Files selected for processing (1)
  • mlir/lib/Conversion/QCToQCO/QCToQCO.cpp

@burgholzer
Copy link
Member

Is this one ready for review? 🙂

…ly an operation across multiple program builders (QC, QCO, QIR).
… QCO, QIR) to enhance coverage and ensure functionality.
…ted test cases across various dialects and programs to reflect the new functionality of allocating multiple static qubits and applying operations.
…lated operations and conversions in QCO and QC dialects. Add tests for static qubit type propagation and ensure correct handling in various operations.
…allocation, modifying builders and operations accordingly.
@simon1hofmann simon1hofmann force-pushed the static_qubit_handling branch from 71bbe39 to 0221058 Compare March 19, 2026 08:50
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@mlir/include/mlir/Dialect/QC/IR/QCOps.td`:
- Around line 117-126: The builder currently creates a static qubit in C++ but
the ODS op signature still allows a generic QubitType result, so textual IR like
`qc.static 0 : !qc.qubit` can bypass the static-qubit invariant; update the op
definition in QCOps.td to constrain the result to a static qubit (e.g., change
the results from (outs QubitType:$qubit) to an explicit static qubit type such
as (outs QubitType<static>:$qubit) or the ODS equivalent !qc.qubit<static>), and
adjust the assemblyFormat if needed so the textual form requires the static
qubit type; keep the existing builder call that uses QubitType::get(...,
/*isStatic=*/true).

In `@mlir/lib/Dialect/QCO/IR/Modifiers/CtrlOp.cpp`:
- Around line 129-130: The new block argument is being created with a hardcoded
non-static QubitType which loses the promoted control's concrete staticness;
instead, fetch the original promoted control's type (e.g., from the control
operand collection like op.getControls()[<promotedIndex>] or
op.getControl(<promotedIndex>).getType()) and pass that Type into
op.getBody()->addArgument(...) so the added argument preserves the exact
QubitType (including isStatic) and location rather than using
QubitType::get(..., /*isStatic=*/false).

In `@mlir/lib/Dialect/QCO/IR/QCOOps.cpp`:
- Around line 74-75: The code is hardcoding QubitType::get(...,
/*isStatic=*/false) when creating aliased region block arguments (newArg.type),
which loses staticness; update parseTargetAliasing to accept the parsed target
type list (thread the parsed target types through the custom assembly directive
inputs) and use the original parsed target's staticness when constructing
newArg.type (i.e., derive isStatic from the corresponding parsed target type
instead of always false), ensuring QubitType::get preserves static vs dynamic
qubit semantics during parsing and verification.

In `@mlir/lib/Dialect/QTensor/IR/Operations/AllocOp.cpp`:
- Around line 34-36: AllocOp::verify currently allows a static qco::QubitType
element type but the builder now always materializes qtensor.alloc with a
non-static qubit; update AllocOp::verify() to enforce the new invariant by
checking the element type of the result RankedTensorType (or the operand/result
type used by AllocOp) and emit an error if qco::QubitType::isStatic (or the
equivalent static flag) is true, ensuring parser/hand-written IR cannot claim a
static allocation and referencing AllocOp::verify, qtensor.alloc and
qco::QubitType to locate the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: a308d815-bb91-40ea-971c-273068af3b15

📥 Commits

Reviewing files that changed from the base of the PR and between 71bbe39 and 0221058.

📒 Files selected for processing (28)
  • CHANGELOG.md
  • mlir/include/mlir/Dialect/QC/IR/QCOps.td
  • mlir/include/mlir/Dialect/QC/IR/QCTypes.td
  • mlir/include/mlir/Dialect/QCO/IR/QCOOps.td
  • mlir/include/mlir/Dialect/QCO/IR/QCOTypes.td
  • mlir/lib/Conversion/JeffToQCO/JeffToQCO.cpp
  • mlir/lib/Conversion/QCOToQC/QCOToQC.cpp
  • mlir/lib/Conversion/QCToQCO/QCToQCO.cpp
  • mlir/lib/Dialect/QC/Builder/QCProgramBuilder.cpp
  • mlir/lib/Dialect/QC/IR/QCOps.cpp
  • mlir/lib/Dialect/QCO/Builder/QCOProgramBuilder.cpp
  • mlir/lib/Dialect/QCO/IR/Modifiers/CtrlOp.cpp
  • mlir/lib/Dialect/QCO/IR/Modifiers/InvOp.cpp
  • mlir/lib/Dialect/QCO/IR/QCOOps.cpp
  • mlir/lib/Dialect/QTensor/IR/Operations/AllocOp.cpp
  • mlir/unittests/Compiler/test_compiler_pipeline.cpp
  • mlir/unittests/Conversion/QCOToQC/test_qco_to_qc.cpp
  • mlir/unittests/Conversion/QCToQCO/test_qc_to_qco.cpp
  • mlir/unittests/Conversion/QCToQIR/test_qc_to_qir.cpp
  • mlir/unittests/Dialect/QC/IR/test_qc_ir.cpp
  • mlir/unittests/Dialect/QCO/IR/test_qco_ir.cpp
  • mlir/unittests/Dialect/QIR/IR/test_qir_ir.cpp
  • mlir/unittests/programs/qc_programs.cpp
  • mlir/unittests/programs/qc_programs.h
  • mlir/unittests/programs/qco_programs.cpp
  • mlir/unittests/programs/qco_programs.h
  • mlir/unittests/programs/qir_programs.cpp
  • mlir/unittests/programs/qir_programs.h

… loop constructs in CtrlOp and InvOp, and ensure proper type casting in StaticOp creation.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
mlir/lib/Dialect/QCO/IR/Modifiers/CtrlOp.cpp (1)

129-130: ⚠️ Potential issue | 🟠 Major

Preserve the promoted control's concrete qubit type.

After the segment size adjustment (lines 122-126), the first original control is now op.getInputTarget(0). Using hardcoded isStatic=false loses the static flag if the promoted control was a static qubit.

🛠️ Suggested fix
-    auto arg = op.getBody()->addArgument(
-        QubitType::get(rewriter.getContext(), /*isStatic=*/false), op.getLoc());
+    auto arg = op.getBody()->addArgument(op.getInputTarget(0).getType(), op.getLoc());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mlir/lib/Dialect/QCO/IR/Modifiers/CtrlOp.cpp` around lines 129 - 130, The new
argument creation uses a hardcoded QubitType::get(..., /*isStatic=*/false) which
discards the original control's static flag; fetch the original control's qubit
type from op.getInputTarget(0) (or its type accessor), extract its isStatic
property, and pass that boolean into QubitType::get(rewriter.getContext(),
isStatic) when calling op.getBody()->addArgument so the promoted control
preserves its concrete static/dynamic flag while keeping op.getLoc().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@mlir/lib/Dialect/QCO/IR/Modifiers/CtrlOp.cpp`:
- Around line 129-130: The new argument creation uses a hardcoded
QubitType::get(..., /*isStatic=*/false) which discards the original control's
static flag; fetch the original control's qubit type from op.getInputTarget(0)
(or its type accessor), extract its isStatic property, and pass that boolean
into QubitType::get(rewriter.getContext(), isStatic) when calling
op.getBody()->addArgument so the promoted control preserves its concrete
static/dynamic flag while keeping op.getLoc().

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8ecdc976-232b-4f31-92ae-03315e16bc8c

📥 Commits

Reviewing files that changed from the base of the PR and between 0221058 and a5ffdef.

📒 Files selected for processing (5)
  • mlir/lib/Conversion/QCOToQC/QCOToQC.cpp
  • mlir/lib/Conversion/QCToQCO/QCToQCO.cpp
  • mlir/lib/Dialect/QC/IR/QCOps.cpp
  • mlir/lib/Dialect/QCO/IR/Modifiers/CtrlOp.cpp
  • mlir/lib/Dialect/QCO/IR/Modifiers/InvOp.cpp

… verification to ensure static qubit type, and update related operations to support static qubit handling.
@mergify mergify bot added the conflict label Mar 19, 2026
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (2)
mlir/lib/Dialect/QCO/IR/Modifiers/CtrlOp.cpp (1)

118-131: ⚠️ Potential issue | 🔴 Critical

Capture the promoted control type before rewriting the segment sizes.

After Line 123 updates the operand/result segment sizes, op.getControlsIn().back() no longer refers to the promoted control; it refers to the last remaining control. With mixed static/dynamic controls, this can create the new block argument with the wrong type and make the rewritten qco.ctrl fail verification or drop isStatic.

🛠️ Minimal fix
-    auto newSegments = DenseI32ArrayAttr::get(
-        rewriter.getContext(), {segmentsAttr[0] - 1, segmentsAttr[1] + 1});
+    auto promotedControlType = op.getControlsIn().back().getType();
+    auto newSegments = DenseI32ArrayAttr::get(
+        rewriter.getContext(), {segmentsAttr[0] - 1, segmentsAttr[1] + 1});
     op->setAttr(opSegmentsAttrName, newSegments);
     const auto opResultSegmentsAttrName = CtrlOp::getResultSegmentSizeAttr();
     op->setAttr(opResultSegmentsAttrName, newSegments);

     // Add a block argument for the promoted target qubit, preserving the
     // control's type (including isStatic)
-    auto promotedControlType = op.getControlsIn().back().getType();
     auto arg = op.getBody()->addArgument(promotedControlType, op.getLoc());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mlir/lib/Dialect/QCO/IR/Modifiers/CtrlOp.cpp` around lines 118 - 131, The
code adjusts operand/result segment sizes before capturing the promoted control
type, so op.getControlsIn().back() points to the wrong control after the
rewrite; fix it by reading and storing the promoted control type into
promotedControlType (from op.getControlsIn().back().getType()) before calling
CtrlOp::getOperandSegmentSizeAttr/getResultSegmentSizeAttr and updating
segments, then use that stored promotedControlType when calling
op.getBody()->addArgument so the new block arg preserves the original control's
type (including isStatic).
mlir/lib/Dialect/QCO/IR/QCOOps.cpp (1)

71-75: ⚠️ Potential issue | 🟠 Major

Aliased region arguments still downcast static qubits to dynamic.

parseTargetAliasing() is shared by qco.ctrl, qco.inv, and qco.if, so hardcoding !qco.qubit here strips isStatic from textual IR block arguments. Static-target programs will either fail verification later or lose the static bit during parsing. Please thread the corresponding operand types into this helper, or reorder the assembly so those types are available when the block arguments are created.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mlir/lib/Dialect/QCO/IR/QCOOps.cpp` around lines 71 - 75, parseTargetAliasing
currently hardcodes newArg.type to QubitType::get(..., isStatic=false), which
strips the static bit for aliased region arguments used by
qco.ctrl/qco.inv/qco.if; update parseTargetAliasing to accept or compute the
intended operand type (preserve isStatic) instead of hardcoding — e.g., add a
parameter (or retrieve the operand type before creating block args) and set
newArg.type to that operand type rather than calling QubitType::get(...,
/*isStatic=*/false); ensure callers in qco.ctrl, qco.inv and qco.if pass the
correct static/dynamic qubit type so the static bit is preserved during parsing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@mlir/lib/Dialect/QCO/IR/Modifiers/CtrlOp.cpp`:
- Around line 118-131: The code adjusts operand/result segment sizes before
capturing the promoted control type, so op.getControlsIn().back() points to the
wrong control after the rewrite; fix it by reading and storing the promoted
control type into promotedControlType (from op.getControlsIn().back().getType())
before calling CtrlOp::getOperandSegmentSizeAttr/getResultSegmentSizeAttr and
updating segments, then use that stored promotedControlType when calling
op.getBody()->addArgument so the new block arg preserves the original control's
type (including isStatic).

In `@mlir/lib/Dialect/QCO/IR/QCOOps.cpp`:
- Around line 71-75: parseTargetAliasing currently hardcodes newArg.type to
QubitType::get(..., isStatic=false), which strips the static bit for aliased
region arguments used by qco.ctrl/qco.inv/qco.if; update parseTargetAliasing to
accept or compute the intended operand type (preserve isStatic) instead of
hardcoding — e.g., add a parameter (or retrieve the operand type before creating
block args) and set newArg.type to that operand type rather than calling
QubitType::get(..., /*isStatic=*/false); ensure callers in qco.ctrl, qco.inv and
qco.if pass the correct static/dynamic qubit type so the static bit is preserved
during parsing.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: f92294df-f746-4c7d-844f-c93aef76d90e

📥 Commits

Reviewing files that changed from the base of the PR and between a5ffdef and 6af63db.

📒 Files selected for processing (6)
  • mlir/include/mlir/Dialect/QC/IR/QCOps.td
  • mlir/include/mlir/Dialect/QCO/IR/QCOOps.td
  • mlir/lib/Dialect/QC/IR/QCOps.cpp
  • mlir/lib/Dialect/QCO/IR/Modifiers/CtrlOp.cpp
  • mlir/lib/Dialect/QCO/IR/QCOOps.cpp
  • mlir/lib/Dialect/QTensor/IR/Operations/AllocOp.cpp

simon1hofmann and others added 2 commits March 19, 2026 10:49
…nce QubitType handling in parser and printer for static qubits, and ensure proper type preservation during control operation adjustments.
Signed-off-by: simon1hofmann <119581649+simon1hofmann@users.noreply.github.com>
@mergify mergify bot removed the conflict label Mar 19, 2026
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
mlir/include/mlir/Dialect/QC/Builder/QCProgramBuilder.h (1)

111-125: ⚠️ Potential issue | 🟡 Minor

Update the example to the new static type.

The snippet still shows qc.static ... : !qc.qubit, but staticQubit() now builds !qc.qubit<static>. Leaving the old form in the public builder docs makes the new type split easy to miss.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mlir/include/mlir/Dialect/QC/Builder/QCProgramBuilder.h` around lines 111 -
125, Update the documentation example for staticQubit(uint64_t) to use the new
static qubit MLIR type; replace the MLIR snippet that shows "%q0 = qc.static 0 :
!qc.qubit" with the new form "%q0 = qc.static 0 : !qc.qubit<static>" so the
builder docs reflect that staticQubit() constructs a !qc.qubit<static> type.
mlir/include/mlir/Dialect/QC/IR/QCOps.td (1)

72-90: ⚠️ Potential issue | 🟠 Major

Constrain qc.alloc to DynamicQubit too.

These builder changes only fix the C++ call sites. The op definition still advertises a generic QubitType result, so textual IR can still spell qc.alloc : !qc.qubit<static>, which weakens the new static/dynamic split at the ODS level.

Suggested fix
-  let results = (outs QubitType:$result);
+  let results = (outs DynamicQubit:$result);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mlir/include/mlir/Dialect/QC/IR/QCOps.td` around lines 72 - 90, The op
definition for qc.alloc in QCOps.td still declares a generic QubitType result;
change the result constraint to the dynamic qubit type (e.g. replace "let
results = (outs QubitType:$result);" with "let results = (outs
DynamicQubitType:$result);"), update the assemblyFormat type($result) if
necessary to reflect DynamicQubit, and update the builders block so the builder
calls use DynamicQubitType::get(...) instead of QubitType::get(...) (the
build(...) invocations in the builders array should be updated accordingly) so
the ODS-level op signature enforces DynamicQubit.
♻️ Duplicate comments (2)
mlir/include/mlir/Dialect/QCO/IR/QCOOps.td (2)

117-137: ⚠️ Potential issue | 🟠 Major

Don’t leave qco.static as Pure.

Line 117 still marks qco.static as side-effect free, so generic MLIR CSE/hoisting may merge identical qco.static 0 ops even though each result is meant to start its own linear SSA ownership chain. That also leaves this file out of sync with the PR objective to treat static qubits as allocation-like.

🔧 Minimal fix
-def StaticOp : QCOOp<"static", [Pure]> {
+def StaticOp : QCOOp<"static", [MemoryEffects<[MemAlloc]>]> {
In MLIR ODS, does marking an operation `Pure` allow CSE/hoisting of identical ops, and should an operation that creates a fresh linear SSA ownership chain be modeled with `MemoryEffects<[MemAlloc]>` instead?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mlir/include/mlir/Dialect/QCO/IR/QCOOps.td` around lines 117 - 137, The
StaticOp (qco.static) should not be marked Pure because identical ops must not
be CSE/hoisted; remove the Pure trait from the op definition and instead declare
memory effects to model allocation-like behavior (e.g., add
MemoryEffects<[MemAlloc]> to the op traits or effects list), keeping the
existing result type (StaticQubit) and builder (build(..., /*isStatic=*/true,
index)) unchanged so the op is treated as allocation-like and won't be merged by
generic CSE/hoisting.

101-104: ⚠️ Potential issue | 🟡 Minor

Use static-origin here, not just static.

Lines 101-104 are broader than the actual conversion rule. A dynamic-origin qubit can later have type !qco.qubit<static>, but only qubits originating from qco.static may have their qco.dealloc dropped during QCO→QC conversion.

✏️ Suggested wording
-        corresponding to static qubits may be erased.
+        corresponding to qubits originating from `qco.static` may be erased.
Based on learnings: `ConvertQCODeallocOp` erases `qco.dealloc` only for static-origin qubits.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mlir/include/mlir/Dialect/QCO/IR/QCOOps.td` around lines 101 - 104, The
comment says the doc text is too broad; update the wording to mention
static-origin specifically: change the description around QCO dealloc conversion
to state that only deallocs for qubits with static-origin (i.e., those produced
by qco.static) may be erased when converting back to QC; reference
ConvertQCODeallocOp and qco.dealloc so readers know this is the conversion rule
that only removes deallocs for static-origin qubits rather than any qubit typed
!qco.qubit<static>.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@mlir/lib/Conversion/QCToQCO/QCToQCO.cpp`:
- Around line 77-83: The nested-region qubit map (qubitMap) is not being
populated for cloned modifier bodies, so when adding block arguments and storing
aliases only in targetsIn the inner regions have empty maps and asserts fail;
update the code paths that create modifier clones (e.g., the block-argument
handling in ConvertQCInvOp and the corresponding modifier conversion block in
QCToQCO.cpp) to seed qubitMap[clonedBodyRegion] with the same Value->Value alias
entries you place in targetsIn right after you add the block arguments, ensuring
nested qc.ctrl/qc.inv lookups find the captured qubit aliases in the
region-keyed map.

In `@mlir/lib/Dialect/QC/Builder/QCProgramBuilder.cpp`:
- Around line 82-86: QCProgramBuilder::staticQubit widened the parameter to
uint64_t but still builds a StaticOp that expects an I64Attr; add a fast-fail
bounds check in QCProgramBuilder::staticQubit (after checkFinalized() and before
calling StaticOp::create) that verifies index <=
std::numeric_limits<int64_t>::max() and fails immediately (e.g., throw, return
an error, or call llvm::report_fatal_error consistent with project conventions)
instead of allowing wraparound into negative values; reference
QCProgramBuilder::staticQubit and StaticOp::create when locating where to add
the check.

---

Outside diff comments:
In `@mlir/include/mlir/Dialect/QC/Builder/QCProgramBuilder.h`:
- Around line 111-125: Update the documentation example for
staticQubit(uint64_t) to use the new static qubit MLIR type; replace the MLIR
snippet that shows "%q0 = qc.static 0 : !qc.qubit" with the new form "%q0 =
qc.static 0 : !qc.qubit<static>" so the builder docs reflect that staticQubit()
constructs a !qc.qubit<static> type.

In `@mlir/include/mlir/Dialect/QC/IR/QCOps.td`:
- Around line 72-90: The op definition for qc.alloc in QCOps.td still declares a
generic QubitType result; change the result constraint to the dynamic qubit type
(e.g. replace "let results = (outs QubitType:$result);" with "let results =
(outs DynamicQubitType:$result);"), update the assemblyFormat type($result) if
necessary to reflect DynamicQubit, and update the builders block so the builder
calls use DynamicQubitType::get(...) instead of QubitType::get(...) (the
build(...) invocations in the builders array should be updated accordingly) so
the ODS-level op signature enforces DynamicQubit.

---

Duplicate comments:
In `@mlir/include/mlir/Dialect/QCO/IR/QCOOps.td`:
- Around line 117-137: The StaticOp (qco.static) should not be marked Pure
because identical ops must not be CSE/hoisted; remove the Pure trait from the op
definition and instead declare memory effects to model allocation-like behavior
(e.g., add MemoryEffects<[MemAlloc]> to the op traits or effects list), keeping
the existing result type (StaticQubit) and builder (build(...,
/*isStatic=*/true, index)) unchanged so the op is treated as allocation-like and
won't be merged by generic CSE/hoisting.
- Around line 101-104: The comment says the doc text is too broad; update the
wording to mention static-origin specifically: change the description around QCO
dealloc conversion to state that only deallocs for qubits with static-origin
(i.e., those produced by qco.static) may be erased when converting back to QC;
reference ConvertQCODeallocOp and qco.dealloc so readers know this is the
conversion rule that only removes deallocs for static-origin qubits rather than
any qubit typed !qco.qubit<static>.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 108b6e8a-64ba-4ecc-a1af-e4da8861d049

📥 Commits

Reviewing files that changed from the base of the PR and between acdc0cb and d6ad7c4.

📒 Files selected for processing (13)
  • mlir/include/mlir/Dialect/QC/Builder/QCProgramBuilder.h
  • mlir/include/mlir/Dialect/QC/IR/QCOps.td
  • mlir/include/mlir/Dialect/QCO/Builder/QCOProgramBuilder.h
  • mlir/include/mlir/Dialect/QCO/IR/QCOOps.td
  • mlir/include/mlir/Dialect/QCO/Utils/ValueOrdering.h
  • mlir/lib/Conversion/QCToQCO/QCToQCO.cpp
  • mlir/lib/Dialect/QC/Builder/QCProgramBuilder.cpp
  • mlir/lib/Dialect/QCO/Builder/QCOProgramBuilder.cpp
  • mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp
  • mlir/lib/Dialect/QTensor/IR/CMakeLists.txt
  • mlir/unittests/programs/qc_programs.cpp
  • mlir/unittests/programs/qco_programs.cpp
  • mlir/unittests/programs/qir_programs.cpp

Signed-off-by: burgholzer <burgholzer@me.com>
Copy link
Member

@denialhaag denialhaag left a comment

Choose a reason for hiding this comment

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

I very briefly scrolled through the changes. As my requested changes have been implemented and to not block this PR from being merged, I'll go ahead and approve this now. Thanks a lot, @simon1hofmann and @burgholzer!

Signed-off-by: burgholzer <burgholzer@me.com>
Signed-off-by: burgholzer <burgholzer@me.com>
Signed-off-by: burgholzer <burgholzer@me.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@mlir/lib/Conversion/QCToQCO/QCToQCO.cpp`:
- Around line 138-143: The free helper function lookupMappedQubit has external
linkage but per project convention should use internal linkage; change its
declaration to static (i.e., make lookupMappedQubit static) and apply the same
change to the other free helper functions in this translation unit that are
defined outside an anonymous namespace so they use static linkage as well;
locate these helpers by finding free functions in QCToQCO.cpp (those not in a
class or namespace) and prefix their declarations with static.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 591a03fa-e78e-4ffc-a729-0f3606655aa0

📥 Commits

Reviewing files that changed from the base of the PR and between 57e150a and e3281e5.

📒 Files selected for processing (2)
  • mlir/lib/Conversion/QCOToQC/QCOToQC.cpp
  • mlir/lib/Conversion/QCToQCO/QCToQCO.cpp

Signed-off-by: burgholzer <burgholzer@me.com>
@burgholzer burgholzer force-pushed the static_qubit_handling branch from e3281e5 to 2b6f808 Compare March 20, 2026 23:24
Copy link
Member

@burgholzer burgholzer left a comment

Choose a reason for hiding this comment

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

Okay, I think I am through with this now.

Cleaned up the conversion code from QC to QCO a little bit to make it less repetitive.

Fixed one bug in the Mapping related code where it did not cover all cases of operations. Would still be interested in an opinion from @MatthiasReumann whether walking the entire program is really the best option here; probably not.

After that, this should be good to go.

Copy link
Collaborator

@MatthiasReumann MatthiasReumann left a comment

Choose a reason for hiding this comment

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

Great work @simon1hofmann 🚀

It's really interesting to see how a seemingly small change (such as adding an attribute to a type) can have significant consequences for some tasks.

I have one small and one bigger remark - the latter being the more important one.

rewriter.setInsertionPoint(q.getDefiningOp());
auto op = rewriter.replaceOpWithNewOp<StaticOp>(q.getDefiningOp(), hw);
statics[hw] = op.getQubit();
statics[hw] = llvm::cast<TypedValue<QubitType>>(op.getQubit());
Copy link
Collaborator

Choose a reason for hiding this comment

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

IntelliSense tells me that the return type here is already ::mlir::TypedValue< ::mlir::qco::QubitType>. Why the cast?

Copy link
Member

Choose a reason for hiding this comment

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

Have you regenerated your build folder? I was wondering the same, but apparently something changed by adding the additional attribute so that the getter is no longer returning the actual type, but rather a generic type.
Let's look into this a little bit more.

Copy link
Collaborator

Choose a reason for hiding this comment

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

We just had a look at this. Apparently, this is because qco.static returns the StaticQubit type which exists in Tablegen but doesn't exist as class in C++ (In other words: Tablegen doesn't generate a class for it). Consequently, the getQubit uses the generic ::mlir::Type class. If we update the qco.static operation to return the QubitType (which exists as generated C++ class) it correctly types the return value.

Copy link
Collaborator

@MatthiasReumann MatthiasReumann Mar 23, 2026

Choose a reason for hiding this comment

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

Thus, there are two options:

  1. Use the QubitType as return type for the qco.static operation (Instead of StaticQubit)
  2. Cast to QubitType in the passes

Copy link
Member

Choose a reason for hiding this comment

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

Hm. The first one loses a bit of type information, but leads to cleaner pass code. I think I would prefer that.
Maybe as a third option: Find a solution so that different C++ types are emitted for the different qubit types. I remember playing with this for a bit while working on the PR, but did not find a solution that I was really happy with.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think the only option to generate two different types is to define two different types. Then the Unitary operations had to do something like

def HOp : Op<"h", [ ... ]> {
  let arguments = (ins AnyTypeOf<[StaticQubitType, DynamicQubitType]>:$qubit);
}

Copy link
Member

Choose a reason for hiding this comment

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

Which is not particularly pretty as well..
Hm.. I am starting to dislike the complexity this distinction introduces..

Copy link
Collaborator

Choose a reason for hiding this comment

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

I was thinking the same. I discussed this a little bit with @simon1hofmann, but there is no big benefit of adding another type besides less "management" logic in the conversion passes, right?

const auto qIn = dyn_cast<QubitType>(input.getType());
const auto qOut = dyn_cast<QubitType>(output.getType());
if (qIn && qOut && qIn != qOut) {
output.setType(qIn);
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think the best practice would be to replace each unitary op (which consumes and produces !qco.qubit types) with the same one but different typing. Generally speaking, any IR rewriting should be done via the IRRewriter (or PatternRewriter if you work on the conversions).

From the documentation:

Note that this [.setType()] is an extremely dangerous operation which can create completely invalid IR very easily. It is strongly recommended that you recreate IR objects with the right types instead of mutating them in place.

That being said, I don't think there is a cleaner option than traversing the IR another time. Alternatively, one could do it in the commitTrial function - but things will get messy then. Gemini's suggestion was to use the Conversion framework and the TypeConverter but that's a lot of boilerplate code for little functionality.

Copy link
Member

Choose a reason for hiding this comment

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

Thanks for the hint with the IR rewriter 👍🏼
The conversion framework seems a little overkill here.
I was hoping that we could do this "on the fly" as part of the commitTrial function.
Let's entertain that idea for a bit. I am not sure whether I will have the capacity to look at this today; maybe @simon1hofmann or @MatthiasReumann you got some time to take a look?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah. I think it is possible to do this in the commitTrial function, the question is how ugly does that get. Maybe it's not even that bad. @simon1hofmann Added you on Discord. Maybe let's discuss this in person.

Maybe something else entirely that could help this process: Have you ever thought about implementing the InferTypeOpInterface interface? @burgholzer @denialhaag

Copy link
Member

Choose a reason for hiding this comment

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

fun fact: before this PR, most operations in this PR automatically got that interface, simply by including the respective header, which we found out a couple months back. Now, with this PR, we apparently lost some of that. It's a nice exercise to compare the generated dialect files in the build folder before and after this PR.

while working on this PR a couple days back, I had already implemented the interface for the modifier and SCF operations, where the benefit was most apparent. However, I reverted those again as there was little to no perceived benefit for quite some boilerplate.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp (1)

577-580: ⚠️ Potential issue | 🔴 Critical

The pass still breaks when a function already contains qco.static wires.

collectDynamicQubits() only seeds routing from qco.alloc, but place() later materializes a fresh qco.static bank for every hardware slot. That means mixed static/dynamic two-qubit gates never enter the layering logic, dyn.size() undercounts already-occupied hardware, and this can emit duplicate qco.static N values or assign a dynamic wire to an already-fixed hardware index. Even a pure-static circuit is no longer a no-op here. Please either reject pre-existing static inputs up front or reserve/seed those hardware indices during layering and placement.

Also applies to: 692-740

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp` around lines 577 - 580,
The logic undercounts occupied hardware because collectDynamicQubits() only
seeds routing from qco.alloc and ignores pre-existing qco.static wires, causing
dyn.size() checks and place() to produce duplicate qco.static indices or assign
dynamics into already-fixed slots; update the pass to either (A) reject
functions that already contain qco.static inputs up front by detecting any
qco.static op in the function body and emitting an error from the pass, or (B)
incorporate existing qco.static indices into the layering/placement seed by
extending collectDynamicQubits() (and any routing/placement state used by
place()) to also scan for qco.static ops and reserve those hardware indices
before layering so that dyn.size() and place() account for already-occupied
slots; modify the code paths around collectDynamicQubits(), the dyn.size()
check, and place() to use the extended reservation model or the early rejection.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp`:
- Around line 95-105: Add Doxygen-style comments for the new helper functions to
document their intent and invariants: for ctrlNeedsQubitTypeResync(CtrlOp),
invNeedsQubitTypeResync(InvOp), replaceCtrlPreservingBody(...), and
replaceInvPreservingBody(...). For each, add a brief description of what the
function does, list parameters and their roles (e.g., CtrlOp/InvOp and which
operands/regions are examined), state the return value semantics (true/false
meaning), and document any important invariants or side effects (e.g., that they
preserve the op body, resync qubit types, or rely on
modifierBodyArgsMismatchTargetOperands/qubitOperandAndResultTypesDiffer). Ensure
the comments follow Doxygen format (/// or /** */) consistent with nearby
helpers and include any preconditions callers must satisfy.

---

Outside diff comments:
In `@mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp`:
- Around line 577-580: The logic undercounts occupied hardware because
collectDynamicQubits() only seeds routing from qco.alloc and ignores
pre-existing qco.static wires, causing dyn.size() checks and place() to produce
duplicate qco.static indices or assign dynamics into already-fixed slots; update
the pass to either (A) reject functions that already contain qco.static inputs
up front by detecting any qco.static op in the function body and emitting an
error from the pass, or (B) incorporate existing qco.static indices into the
layering/placement seed by extending collectDynamicQubits() (and any
routing/placement state used by place()) to also scan for qco.static ops and
reserve those hardware indices before layering so that dyn.size() and place()
account for already-occupied slots; modify the code paths around
collectDynamicQubits(), the dyn.size() check, and place() to use the extended
reservation model or the early rejection.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: f49cc0d7-479b-4714-920f-01f5ef263f80

📥 Commits

Reviewing files that changed from the base of the PR and between 20a7e6a and 3166aa0.

📒 Files selected for processing (1)
  • mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp`:
- Around line 665-666: The synchronizeMappedQubitTypes call currently runs after
commitTrial finishes, which allows SWAP rewiring (replaceAllUsesExcept) to
create uses mismatched with CtrlOp/InvOp block-argument types; move the call
into commitTrial so that immediately after place() completes (where dynamic
allocations are replaced via replaceOpWithNewOp) you call
synchronizeMappedQubitTypes to update block-argument and operand types before
any SWAP insertion loop runs, and remove the redundant
synchronizeMappedQubitTypes call that follows commitTrial.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 93f7d809-4788-406e-93a1-7c84032b3877

📥 Commits

Reviewing files that changed from the base of the PR and between 3166aa0 and 01e9861.

📒 Files selected for processing (1)
  • mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

Comment on lines 665 to +666
commitTrial(*best, dyn, func.getFunctionBody(), rewriter);
synchronizeMappedQubitTypes(func.getFunctionBody(), rewriter);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== runOnOperation ordering =="
sed -n '659,667p' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

echo
echo "== commitTrial placement and SWAP rewiring =="
sed -n '1013,1045p' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

echo
echo "== SWAP result accessors / result order =="
rg -n -C3 'def .*SWAP|Qubit0Out|Qubit1Out|getQubit0Out|getQubit1Out' \
  mlir/include/mlir/Dialect/QCO/IR/QCOOps.td \
  mlir/include/mlir/Dialect/QCO/IR \
  mlir/lib/Dialect/QCO/IR 2>/dev/null || true

Repository: munich-quantum-toolkit/core

Length of output: 3883


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== place() function signature and implementation =="
rg -n -A20 '^[^/]*place\(' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp | head -50

echo
echo "== synchronizeMappedQubitTypes() implementation =="
rg -n -A15 'synchronizeMappedQubitTypes' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp | head -50

echo
echo "== Full SWAP operation definition (qubit0_out, qubit1_out) =="
sed -n '781,810p' mlir/include/mlir/Dialect/QCO/IR/QCOOps.td

Repository: munich-quantum-toolkit/core

Length of output: 4654


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Full commitTrial() implementation =="
sed -n '1007,1100p' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

echo
echo "== toWires() function =="
rg -n -A10 'toWires' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp | head -30

echo
echo "== ctrlNeedsQubitTypeResync / invNeedsQubitTypeResync =="
rg -n -B5 -A10 'ctrlNeedsQubitTypeResync\|invNeedsQubitTypeResync' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp | head -60

Repository: munich-quantum-toolkit/core

Length of output: 3581


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Search for ctrlNeedsQubitTypeResync definition =="
rg -n 'ctrlNeedsQubitTypeResync|invNeedsQubitTypeResync|measureNeedsQubitTypeResync' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

echo
echo "== Lines around synchronizeMappedQubitTypes to understand the type resync =="
sed -n '305,360p' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

echo
echo "== Check if these helper functions are in a header or same file =="
find mlir/lib/Dialect/QCO/Transforms -name "*.cpp" -o -name "*.h" | xargs grep -l 'ctrlNeedsQubitTypeResync' 2>/dev/null || echo "Not found in Transforms directory"

echo
echo "== Search wider in QCO directory =="
find mlir -path "*/QCO/*" -name "*.cpp" -o -name "*.h" | xargs grep -l 'ctrlNeedsQubitTypeResync' 2>/dev/null || echo "Pattern not found"

Repository: munich-quantum-toolkit/core

Length of output: 2767


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Full ctrlNeedsQubitTypeResync and invNeedsQubitTypeResync implementations =="
sed -n '107,135p' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

echo
echo "== Full replaceCtrlPreservingBody and replaceInvPreservingBody implementations =="
sed -n '275,300p' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

Repository: munich-quantum-toolkit/core

Length of output: 2422


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== WireIterator class definition =="
rg -n -B5 -A20 'class WireIterator|struct WireIterator' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

echo
echo "== modifierBodyArgsMismatchTargetOperands definition =="
sed -n '60,90p' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

echo
echo "== qubitOperandAndResultTypesDiffer definition =="
rg -n -A8 'qubitOperandAndResultTypesDiffer' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp | head -20

Repository: munich-quantum-toolkit/core

Length of output: 106


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Search for WireIterator =="
rg -n 'WireIterator' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp | head -20

echo
echo "== Search for modifierBodyArgsMismatchTargetOperands =="
rg -n -B3 -A12 'modifierBodyArgsMismatchTargetOperands' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp | head -50

echo
echo "== Search for qubitOperandAndResultTypesDiffer =="
rg -n -B2 -A8 'qubitOperandAndResultTypesDiffer' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

Repository: munich-quantum-toolkit/core

Length of output: 5581


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== WireIterator header file =="
cat mlir/include/mlir/Dialect/QCO/Utils/WireIterator.h

echo
echo "== Check if place() is indeed updating qubits or just in dynQubits map =="
sed -n '769,800p' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

Repository: munich-quantum-toolkit/core

Length of output: 3810


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Full commitTrial function signature and start =="
sed -n '1007,1020p' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

echo
echo "== Check what operations might have bodies after place() =="
echo "Looking for cloned operations and region handling..."
rg -n 'clone|Region|getBody|Body' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp | grep -E 'clone|Region.*Body' | head -15

echo
echo "== Check if CtrlOp/InvOp can appear as consumers during SWAP rewiring =="
rg -n 'CtrlOp|InvOp' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

echo
echo "== Examine the full runOnOperation to understand complete context =="
sed -n '650,670p' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

Repository: munich-quantum-toolkit/core

Length of output: 3155


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Check if operations with regions (CtrlOp, InvOp, etc) can be in the IR when commitTrial is called =="
echo "These would be consumers that have body qubits not yet replaced by place()"

echo
echo "== Understand the specific scenario: do CtrlOp/InvOp bodies consume dynamic qubits? =="
rg -n 'getBody\|getQubitsIn\|getTargetsIn' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp | head -20

echo
echo "== Check modifierBodyArgsMismatchTargetOperands more carefully =="
sed -n '82,90p' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

echo
echo "== The key question: when place() replaces dyn with static, are body args still pointing to old dyn? =="
echo "Looking at what place() actually modifies..."
sed -n '773,779p' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

echo
echo "== Check if the body operations themselves need updating after place() =="
echo "Looking for calls to cloneModifierBodyAndResyncUnitary or similar during SWAP rewiring..."
sed -n '1015,1070p' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

Repository: munich-quantum-toolkit/core

Length of output: 337


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Specific search for getBody calls in Mapping.cpp =="
rg -n 'getBody()' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

echo
echo "== Search for how place() affects CtrlOp/InvOp =="
rg -n -B2 -A5 'replaceCtrlPreservingBody\|replaceInvPreservingBody' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp | head -40

echo
echo "== Look at lines 1015-1070 in smaller chunks =="
sed -n '1015,1040p' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

echo
echo "== Look for any synchronization or body handling during SWAP loop =="
sed -n '1040,1070p' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

Repository: munich-quantum-toolkit/core

Length of output: 529


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Check the actual structure of replaceCtrlPreservingBody =="
sed -n '239,255p' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

echo
echo "== Check the actual structure of replaceInvPreservingBody =="
sed -n '261,272p' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

echo
echo "== Understand cloneModifierBodyAndResyncUnitary =="
sed -n '199,225p' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

echo
echo "== The key: does modifierBodyArgsMismatchTargetOperands detect type changes? =="
sed -n '82,90p' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

Repository: munich-quantum-toolkit/core

Length of output: 2970


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Check if there are tests or examples with CtrlOp/InvOp in Mapping context =="
find mlir -name "*test*" -o -name "*Test*" | xargs grep -l "CtrlOp\|InvOp" 2>/dev/null | xargs grep -l "Mapping\|mapping" 2>/dev/null | head -5

echo
echo "== Check if dynamic qubits can flow through CtrlOp/InvOp =="
rg -n 'ctrl.*dynamic\|inv.*dynamic\|dynamic.*ctrl\|dynamic.*inv' mlir/lib/Dialect/QCO/ 2>/dev/null | head -10

echo
echo "== Verify: does replaceAllUsesExcept perform type checking? =="
echo "MLIR doesn't type-check during op creation, only during verify() or when explicitly requested"
echo "So SWAP rewiring could create invalid types that are caught later"

echo
echo "== Check the actual line numbers and context more carefully =="
sed -n '660,670p' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

Repository: munich-quantum-toolkit/core

Length of output: 215


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Examine the mapping test file =="
wc -l mlir/unittests/Dialect/QCO/Transforms/Mapping/test_mapping.cpp

echo
echo "== Search for tests with CtrlOp or InvOp =="
rg -n 'CtrlOp\|InvOp\|qco.ctrl\|qco.inv' mlir/unittests/Dialect/QCO/Transforms/Mapping/test_mapping.cpp | head -20

echo
echo "== Check test structure and dynamic qubits =="
rg -n 'dynamic\|alloc' mlir/unittests/Dialect/QCO/Transforms/Mapping/test_mapping.cpp | head -10

Repository: munich-quantum-toolkit/core

Length of output: 218


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Check what's in the mapping test file =="
head -50 mlir/unittests/Dialect/QCO/Transforms/Mapping/test_mapping.cpp

echo
echo "== Search for test patterns =="
rg -n 'TEST\|qco\.' mlir/unittests/Dialect/QCO/Transforms/Mapping/test_mapping.cpp | head -20

echo
echo "== Check if Mapping pass is only applied to leaf unitaries (no modifiers) =="
echo "Looking at what quantum operations are typically in mapping scope..."
rg -n 'UnitaryOpInterface|dyn_cast<.*Op>' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp | head -20

Repository: munich-quantum-toolkit/core

Length of output: 1548


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Check what operations the Mapping pass handles in its legality =="
rg -n 'isLegal\|addDynamicallyLegalOp\|addIllegalOp' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

echo
echo "== Check if there's a pass definition or operation filtering =="
rg -n 'class.*Pass\|struct.*Pass\|runOnOperation\|getOperationName' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp | head -15

echo
echo "== Look at the learning notes about nested regions and qubit tracking =="
echo "From learnings: nested modifier bodies (qc.ctrl/qc.inv) do NOT use state.qubitMap"
echo "This suggests they may be present in IR but handled differently"

echo
echo "== Check if synchronizeMappedQubitTypes is called in other places =="
rg -n 'synchronizeMappedQubitTypes' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

Repository: munich-quantum-toolkit/core

Length of output: 139


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Get the full MappingPass class definition =="
sed -n '337,370p' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

echo
echo "== Check runOnOperation signature =="
rg -n 'void runOnOperation' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

echo
echo "== View the complete runOnOperation function =="
sed -n '625,670p' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

echo
echo "== Understand: are CtrlOp/InvOp with dynamic qubits even expected in this pass? =="
echo "Looking at what dyn contains..."
sed -n '640,655p' mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp

Repository: munich-quantum-toolkit/core

Length of output: 3708


Resync must occur between place() and SWAP rewiring, not after both operations.

place() replaces dynamic allocations with static qubits via replaceOpWithNewOp, updating operand uses in the main computation. However, block arguments in CtrlOp/InvOp bodies bound to old dynamic qubits remain unchanged. When SWAP rewiring then executes via replaceAllUsesExcept, it can connect fresh static outputs to operations (especially modifiers) whose body arguments still expect dynamic types, creating type mismatches before synchronizeMappedQubitTypes() runs.

Move synchronizeMappedQubitTypes() into commitTrial() immediately after place() completes, before the SWAP insertion loop. This ensures all operand and body-argument types align before rewiring introduces new uses.

Suggested fix
    auto wires = toWires(place(dynQubits, result.layout, funcBody, rewriter));
+   synchronizeMappedQubitTypes(funcBody, rewriter);

    DenseMap<Operation*, WireIterator*> seen;

And remove the call at line 666:

      commitTrial(*best, dyn, func.getFunctionBody(), rewriter);
-     synchronizeMappedQubitTypes(func.getFunctionBody(), rewriter);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@mlir/lib/Dialect/QCO/Transforms/Mapping/Mapping.cpp` around lines 665 - 666,
The synchronizeMappedQubitTypes call currently runs after commitTrial finishes,
which allows SWAP rewiring (replaceAllUsesExcept) to create uses mismatched with
CtrlOp/InvOp block-argument types; move the call into commitTrial so that
immediately after place() completes (where dynamic allocations are replaced via
replaceOpWithNewOp) you call synchronizeMappedQubitTypes to update
block-argument and operand types before any SWAP insertion loop runs, and remove
the redundant synchronizeMappedQubitTypes call that follows commitTrial.

Copy link
Member

@burgholzer burgholzer left a comment

Choose a reason for hiding this comment

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

Hm. Sorry to open up the box again, but I somewhat do not really feel comfortable with the degree of changes this necessitates, especially in the passes.
Can we take one step back once more and think about less invasive solutions to our overall problem?

Am I correct in assuming that our problems would be entirely gone if we would simply deallocate all the qubits, no matter if they are dynamic or static?
The only downside we identified from that was that the semantics in QC for static qubits are not "perfect" as the deallocation of a static qubit really does not make too much sense.
However, in the grand scheme of things, this might be the easiest solution.
@DRovara @simon1hofmann @MatthiasReumann @denialhaag opinions?

And sorry for the back and forth on this. Feels like some things only become apparent if one tries them out. I am really glad that we have the mapping pass implemented by now because it revealed the kind of complexity that changes like these may cause.

Comment on lines +130 to +135
/** @brief Copies op results into `SmallVector<Value>` (OpResult → Value). */
[[nodiscard]] static llvm::SmallVector<Value> opResultsAsValues(Operation* op) {
llvm::SmallVector<Value> vals;
llvm::append_range(vals, op->getResults());
return vals;
}
Copy link
Member

Choose a reason for hiding this comment

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

This is basically llvm::to_vector, right?

Comment on lines +92 to +106
/**
* @brief True if @p ctrl must be rebuilt so qubit types match after placement.
*
* @param ctrl Operation examined: sole region (targets + body args), operands
* (`getControlsIn()`, `getTargetsIn()`), and qubit results
* (`getOutputQubits()`).
* @return True when either (1) a body block argument type differs from the
* matching `getTargetsIn()` operand, or (2) any qubit result type
* differs from the paired input qubit type (via
* @ref modifierBodyArgsMismatchTargetOperands and
* @ref qubitOperandAndResultTypesDiffer).
*
* @details Pure predicate: no IR changes. Callers use this to skip redundant
* `replaceOpWithNewOp` work.
*/
Copy link
Member

Choose a reason for hiding this comment

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

Can't really help it. These docstrings feel very AI generated and somehow more complicated than they need to be. Also holds for other docstrings generated through this file as part of the PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not really a fan of the changes in Mapping.cpp anyways and first wanted to get feedback from @MatthiasReumann. I added the docstrings for context, but I guess they just make it even more confusing.

@denialhaag denialhaag mentioned this pull request Mar 24, 2026
8 tasks
@MatthiasReumann
Copy link
Collaborator

@burgholzer @simon1hofmann

Am I correct in assuming that our problems would be entirely gone if we would simply deallocate all the qubits, no matter if they are dynamic or static? The only downside we identified from that was that the semantics in QC for static qubits are not "perfect" as the deallocation of a static qubit really does not make too much sense.
However, in the grand scheme of things, this might be the easiest solution.

I think you could even keep the "The QC dialect doesn't dealloc static qubits" with just one qubit type. It just requires one more datastructure in the conversion passes that remembers which static qubits it still has to deallocate (QC -> QCO) and which deallocs are to be removed (QCO -> QC). (Even though I still have the strong opinion that qco.dealloc should then be renamed 🙈)

I am not even sure if that much more logic in the conversions would be required:

  • In the QC dialect we could simply iterate over all qc.static ops (which in the QC dialect don't have an associated qc.dealloc) and utilize the references semantics to add the missing qc.deallocs.
  • In the QCO dialect it requires a LoweringState (analogously to the QC-to-QCO conversion) which tracks the current SSA values of static qubits and removes the qco.dealloc operations for these static qubits.

Overall this is probably less code than the one currently added to Mapping.cpp.

@burgholzer
Copy link
Member

@burgholzer @simon1hofmann

Am I correct in assuming that our problems would be entirely gone if we would simply deallocate all the qubits, no matter if they are dynamic or static? The only downside we identified from that was that the semantics in QC for static qubits are not "perfect" as the deallocation of a static qubit really does not make too much sense.

However, in the grand scheme of things, this might be the easiest solution.

I think you could even keep the "The QC dialect doesn't dealloc static qubits" with just one qubit type. It just requires one more datastructure in the conversion passes that remembers which static qubits it still has to deallocate (QC -> QCO) and which deallocs are to be removed (QCO -> QC). (Even though I still have the strong opinion that qco.dealloc should then be renamed 🙈)

I am not even sure if that much more logic in the conversions would be required:

  • In the QC dialect we could simply iterate over all qc.static ops (which in the QC dialect don't have an associated qc.dealloc) and utilize the references semantics to add the missing qc.deallocs.

  • In the QCO dialect it requires a LoweringState (analogously to the QC-to-QCO conversion) which tracks the current SSA values of static qubits and removes the qco.dealloc operations for these static qubits.

Overall this is probably less code than the one currently added to Mapping.cpp.

This feels like a very reasonable proposal. The only concern that I have is the one that @DRovara brought up elsewhere: can we really always track which qubits are static; especially in the presence of control flow that depends on runtime values?

@MatthiasReumann
Copy link
Collaborator

The only concern that I have is the one that @DRovara brought up elsewhere: can we really always track which qubits are static; especially in the presence of control flow that depends on runtime values?

Ah. I remember. For later reference:

q1 = get_static_qubit(0)
q2 = alloc_qubit()
my_list = [q1, q2] 

if random() < 0.5 else [q2, q1]
my_list[0].h()
return 0

This would cause some troubles.

Or: We constrain qubit-tensors to either have all static or all dynamic qubits. Otherwise, it's invalid IR1. Then the above shouldn't be a problem. Even if we were to SWAP the qubit states.

q1 = get_static_qubit(0)
q2 = alloc_qubit()

if measurement() < 0.5 else swap(q1, q2)
q1.h()
return 0

Maybe @DRovara can comment on this! 🙋🏻

Footnotes

  1. Implementing a verifier achieving this is a totally different story.

@simon1hofmann
Copy link
Contributor Author

@burgholzer @simon1hofmann

Am I correct in assuming that our problems would be entirely gone if we would simply deallocate all the qubits, no matter if they are dynamic or static? The only downside we identified from that was that the semantics in QC for static qubits are not "perfect" as the deallocation of a static qubit really does not make too much sense.

However, in the grand scheme of things, this might be the easiest solution.
I think you could even keep the "The QC dialect doesn't dealloc static qubits" with just one qubit type. It just requires one more datastructure in the conversion passes that remembers which static qubits it still has to deallocate (QC -> QCO) and which deallocs are to be removed (QCO -> QC). (Even though I still have the strong opinion that qco.dealloc should then be renamed 🙈)
I am not even sure if that much more logic in the conversions would be required:

  • In the QC dialect we could simply iterate over all qc.static ops (which in the QC dialect don't have an associated qc.dealloc) and utilize the references semantics to add the missing qc.deallocs.
  • In the QCO dialect it requires a LoweringState (analogously to the QC-to-QCO conversion) which tracks the current SSA values of static qubits and removes the qco.dealloc operations for these static qubits.

Overall this is probably less code than the one currently added to Mapping.cpp.

This feels like a very reasonable proposal. The only concern that I have is the one that @DRovara brought up elsewhere: can we really always track which qubits are static; especially in the presence of control flow that depends on runtime values?

Currently, QCBuilder adds qd.dealloc for every allocated dynamic qubit, so don't we have the issue with tracking dynamic qubits in the presence of control flow that depends on runtime values here already?

@burgholzer
Copy link
Member

@burgholzer @simon1hofmann

Am I correct in assuming that our problems would be entirely gone if we would simply deallocate all the qubits, no matter if they are dynamic or static? The only downside we identified from that was that the semantics in QC for static qubits are not "perfect" as the deallocation of a static qubit really does not make too much sense.

However, in the grand scheme of things, this might be the easiest solution.
I think you could even keep the "The QC dialect doesn't dealloc static qubits" with just one qubit type. It just requires one more datastructure in the conversion passes that remembers which static qubits it still has to deallocate (QC -> QCO) and which deallocs are to be removed (QCO -> QC). (Even though I still have the strong opinion that qco.dealloc should then be renamed 🙈)
I am not even sure if that much more logic in the conversions would be required:

  • In the QC dialect we could simply iterate over all qc.static ops (which in the QC dialect don't have an associated qc.dealloc) and utilize the references semantics to add the missing qc.deallocs.
  • In the QCO dialect it requires a LoweringState (analogously to the QC-to-QCO conversion) which tracks the current SSA values of static qubits and removes the qco.dealloc operations for these static qubits.

Overall this is probably less code than the one currently added to Mapping.cpp.

This feels like a very reasonable proposal. The only concern that I have is the one that @DRovara brought up elsewhere: can we really always track which qubits are static; especially in the presence of control flow that depends on runtime values?

Currently, QCBuilder adds qd.dealloc for every allocated dynamic qubit, so don't we have the issue with tracking dynamic qubits in the presence of control flow that depends on runtime values here already?

yeah, we kind of do. we would potentially deallocate wrong qubits in the example from above.

Another thought that came to mind: what if deallocation in QC is always implicit, when a qubit or register goes out of scope. and in QCO every qubit, no matter which type, is deallocated before the value leaves the scope. does that somehow make sense?

@DRovara
Copy link
Collaborator

DRovara commented Mar 24, 2026

@MatthiasReumann

Or: We constrain qubit-tensors to either have all static or all dynamic qubits. Otherwise, it's invalid IR. Then the above shouldn't be a problem.

I don't really hate this solution - that's more or less what the individual types would have done. Of course, verifying that is another issue. The first thing I immediately think of here is:

What if I have a function that takes a qubit as input and adds the qubit to a tensor inside the function body?

Once again, no simple way to verify that (without iterating over the entire program and analyzing all calls) - whereas once again, this is literally what data types and type checking would be made for (and this verification would be in constant time then).

@simon1hofmann

Currently, QCBuilder adds qd.dealloc for every allocated dynamic qubit, so don't we have the issue with tracking dynamic qubits in the presence of control flow that depends on runtime values here already?

I don't have a super deep overview on how the QCBuilder works. If we have a program like @MatthiasReumann's example:

q1 = get_static_qubit(0)
q2 = alloc_qubit()

if measurement() < 0.5 else swap(q1, q2)
q1.h()
return 0

I feel like this should not be that hard to handle for the translation though.
I'm not up-to-date on the state of the qco.if (or similar) structured control flow, but those regions would make the dataflow explicit, so in pseudo-MLIR it would look like:

q1_0 = get_static_qubit(0)
q2_0 = alloc_qubit()

q1_1, q2_1 = qco.if(measurement() < 0.5) (t1 = q1_0, t2 = q2_0) {
  t1_1, t2_1 = swap t1, t2
  qco.yield t1_1, t2_1
} else {
  qco.yield t1, t2
}

q1_2 = h q1_1
dealloc q2_1

Unless I'm missing something here, the dataflow is still unique in the context of SSA and linear types, so the dealloc shouldn't be an issue here. The only situations where the dataflow is no longer unique that I can think of is working on tensors at dynamic indices and inside function bodies.

@burgholzer

Another thought that came to mind: what if deallocation in QC is always implicit, when a qubit or register goes out of scope. and in QCO every qubit, no matter which type, is deallocated before the value leaves the scope. does that somehow make sense?

That would mean, however, that when translating from QCO to QC, we potentially lose the information about qubits that are deallocated in the middle of a block (essentially, every dealloc is moved to the end of the block when translating QCO->QC in that strategy). We also lose the ability of having a block generate qubits for its user block (e.g. a loop that allocs 100 qubits and then stores them in a list, or a function that allocs a qubit, prepares it in a state and then returns it). Of course in those cases we could also implement a sort of forwarding logic, but that would likely evolve to a state where we have a Quantum Gargabe Collector and need to count references at some point. Seems a bit fishy to me...

@burgholzer
Copy link
Member

@MatthiasReumann

Or: We constrain qubit-tensors to either have all static or all dynamic qubits. Otherwise, it's invalid IR. Then the above shouldn't be a problem.

I don't really hate this solution - that's more or less what the individual types would have done. Of course, verifying that is another issue. The first thing I immediately think of here is:

What if I have a function that takes a qubit as input and adds the qubit to a tensor inside the function body?

Once again, no simple way to verify that (without iterating over the entire program and analyzing all calls) - whereas once again, this is literally what data types and type checking would be made for (and this verification would be in constant time then).

If we were to go the separate type route (in the sense of actually different C++ types), then we would run into the same/similar problems as we already see in the PR here that it is pretty awkward to convert from dynamic to static qubits, e.g., as part of placement and routing.

Two observations:

  • If we assume that static qubits are solely used to represent hardware qubits, then there will only be a select few paces (placement) that need to deal with the annoying situation of having to convert gates.
  • I would argue that there aren't many use cases for programs mixing both types of qubits as part of one program. I would expect the transition to be "fully dynamic" to "fully static" somewhere along the pipeline.

I don't yet know what to make of that, but I wanted to bring it up.

@simon1hofmann

Currently, QCBuilder adds qd.dealloc for every allocated dynamic qubit, so don't we have the issue with tracking dynamic qubits in the presence of control flow that depends on runtime values here already?

I don't have a super deep overview on how the QCBuilder works. If we have a program like @MatthiasReumann's example:

q1 = get_static_qubit(0)
q2 = alloc_qubit()

if measurement() < 0.5 else swap(q1, q2)
q1.h()
return 0

I feel like this should not be that hard to handle for the translation though. I'm not up-to-date on the state of the qco.if (or similar) structured control flow, but those regions would make the dataflow explicit, so in pseudo-MLIR it would look like:

q1_0 = get_static_qubit(0)
q2_0 = alloc_qubit()

q1_1, q2_1 = qco.if(measurement() < 0.5) (t1 = q1_0, t2 = q2_0) {
  t1_1, t2_1 = swap t1, t2
  qco.yield t1_1, t2_1
} else {
  qco.yield t1, t2
}

q1_2 = h q1_1
dealloc q2_1

Unless I'm missing something here, the dataflow is still unique in the context of SSA and linear types, so the dealloc shouldn't be an issue here. The only situations where the dataflow is no longer unique that I can think of is working on tensors at dynamic indices and inside function bodies.

I am pretty sure that the original example that we had, which we identified as problematic, was slightly different. A qco.swap or qc.swap should never be a problem, this is a standard quantum operation. It does not change the qubits themselves.
The problem might come in if we try to elide the SWAP, which would lead to a permutation of the qubits in one of the branches but not the other.
But, as stated above, I believe this scenario is highly hypothetical anyway as I would have assumed static qubits only to be used for hardware qubits and never really mixed in programs with dynamic qubits. Maybe I am missing something here though.

@burgholzer

Another thought that came to mind: what if deallocation in QC is always implicit, when a qubit or register goes out of scope. and in QCO every qubit, no matter which type, is deallocated before the value leaves the scope. does that somehow make sense?

That would mean, however, that when translating from QCO to QC, we potentially lose the information about qubits that are deallocated in the middle of a block (essentially, every dealloc is moved to the end of the block when translating QCO->QC in that strategy). We also lose the ability of having a block generate qubits for its user block (e.g. a loop that allocs 100 qubits and then stores them in a list, or a function that allocs a qubit, prepares it in a state and then returns it). Of course in those cases we could also implement a sort of forwarding logic, but that would likely evolve to a state where we have a Quantum Gargabe Collector and need to count references at some point. Seems a bit fishy to me...

"Deallocation in the middle of the block" does not really make sense to me. After a deallocation, the qubit is dead and its value is consumed. It does not make a difference whether that dealloc is moved to the end of the block in the sequence of operations or not as it does not interact with any other SSA value than the qubit value it consumed.
On the second point: Any qubits that are part of function parameters or return values would not be deallocated as they are still "live". Part of the code in this PR already does something very similar. Reference counting should not really be needed in QCO as every qubit value will be used exactly once. As part of a function or the body block of a function, the return statement consumes the values that are still supposed to be "live", so they would no longer be in the qubits being tracked for deallocation. This seems pretty simple to me; but maybe my sleep deprived brain is really overlooking something here.

@DRovara
Copy link
Collaborator

DRovara commented Mar 25, 2026

I would argue that there aren't many use cases for programs mixing both types of qubits as part of one program. I would expect the transition to be "fully dynamic" to "fully static" somewhere along the pipeline.

I agree and that's a really good point. At the end of the day, no matter how much we argue from the "that's what the classical world would do", situations like these don't really exist in the classical world. So I don't really want to say "we need to have different types for these concepts", I just wanted to make sure we have a good explanation if we are ever asked why we don't.

It does not make a difference whether that dealloc is moved to the end of the block in the sequence of operations or not as it does not interact with any other SSA value than the qubit value it consumed.

Right, I didn't think about that. That's a non-issue then, agreed.

Reference counting should not really be needed in QCO as every qubit value will be used exactly once.

That's true. Pretty strong arguments, at least I can't think of anything against it. Although then I'd just add that I wouldn't be super opposed to @MatthiasReumann's suggestion of using a different term that's not dealloc for this type of operation (I don't particularly hate sink).

Final question though:

Any qubits that are part of function parameters or return values would not be deallocated as they are still "live"

Once again in the context of dynamic indices to arrays/tensors, if we have the following code:

func my_func(p: qc.qubit)  {
  q = qc.alloc
  my_array = [p, q] if random() < 0.5 else [q, p]
  return my_array[0]
}

Would it be possible to translate from QC to QCO in this situation? Because if random() < 0.5 was true, then the qubit q would have to be deallocated in the QCO translation as it goes out of scope here. Meanwhile, if random() < 0.5 was false, then there would be no dealloc required.
I guess this depends on the rules and semantics of the qtensor data structure, which I didn't look into enough yet but it could be an issue potentially.
Of course, requiring the deallocations in QC as well would not magically solve this issue, but it would make a program like above invalid already in QC, shifting the issue from the QC->QCO conversion phase to the initial code writing phase.

@burgholzer
Copy link
Member

I would argue that there aren't many use cases for programs mixing both types of qubits as part of one program. I would expect the transition to be "fully dynamic" to "fully static" somewhere along the pipeline.

I agree and that's a really good point. At the end of the day, no matter how much we argue from the "that's what the classical world would do", situations like these don't really exist in the classical world. So I don't really want to say "we need to have different types for these concepts", I just wanted to make sure we have a good explanation if we are ever asked why we don't.

The last point is very important. Maybe, at the end of all of this, we need an LLM to go through the discussions on this issue and summarize the points so that we could have something like an RFC that describes how we reached the consensus.

Reference counting should not really be needed in QCO as every qubit value will be used exactly once.

That's true. Pretty strong arguments, at least I can't think of anything against it. Although then I'd just add that I wouldn't be super opposed to @MatthiasReumann's suggestion of using a different term that's not dealloc for this type of operation (I don't particularly hate sink).

I am also fine with renaming in QCO. I'd also be fine with qco.sink.
Just has the slight downside of loosing the alloc/dealloc pairing.
But tbh alloc / sink also works in my mind.

Final question though:

Any qubits that are part of function parameters or return values would not be deallocated as they are still "live"

Once again in the context of dynamic indices to arrays/tensors, if we have the following code:

func my_func(p: qc.qubit)  {
  q = qc.alloc
  my_array = [p, q] if random() < 0.5 else [q, p]
  return my_array[0]
}

Would it be possible to translate from QC to QCO in this situation? Because if random() < 0.5 was true, then the qubit q would have to be deallocated in the QCO translation as it goes out of scope here. Meanwhile, if random() < 0.5 was false, then there would be no dealloc required. I guess this depends on the rules and semantics of the qtensor data structure, which I didn't look into enough yet but it could be an issue potentially. Of course, requiring the deallocations in QC as well would not magically solve this issue, but it would make a program like above invalid already in QC, shifting the issue from the QC->QCO conversion phase to the initial code writing phase.

Generally, I would think this feels a bit artificial. The problem itself would be solved by writing the program as

func my_func(p: qc.qubit)  {
  q = qc.alloc
  if random() < 0.5
    return p
  else
    return q
}

At which point it is clear which qubit needs to be deallocated.
I think, in general, a lot comes down to the programs that we expect to come in.
These won't be arbitrary. Even in the presence of more sophisticated classical code.
What I believe would be important for us is that we fairly quickly build up more sophisticated examples beyond simple quantum circuits so that we can really stress test the infrastructure.

@DRovara
Copy link
Collaborator

DRovara commented Mar 25, 2026

I think, in general, a lot comes down to the programs that we expect to come in.
These won't be arbitrary. Even in the presence of more sophisticated classical code.

I guess that's the point of contention I still have. At least intuitively, it feels like we should support all variations of code that a user might come up with. Otherwise, a user might end up providing a code that they view as valid but is then compiled incorrectly.

In classical compiler development, "Compiler Fuzzing" is a testing strategy where a very large number of random programs are created and used as input for the compiler, simply to make sure that it produces correct results. So at least in the classical world, it seems like they don't care how realistic an example is.

Now, the one argument I see against that is: MLIR is an IR, so we expect to get the code from some other front-end (e.g. a higher-level language) that would never generate code like above. However, I only designed that example to be a minimally reproducible unit. More realistic situations where something like this may happen are when we have a for loop and access each qubit inside the loop. This will be dynamic indexing, which obfuscates data dependencies. After a for loop that accesses a tensor consisting of "temporary" and "parameter" qubits, we won't know anymore which of the elements was temporary and which was not.

@burgholzer
Copy link
Member

I think, in general, a lot comes down to the programs that we expect to come in.

These won't be arbitrary. Even in the presence of more sophisticated classical code.

I guess that's the point of contention I still have. At least intuitively, it feels like we should support all variations of code that a user might come up with. Otherwise, a user might end up providing a code that they view as valid but is then compiled incorrectly.

In classical compiler development, "Compiler Fuzzing" is a testing strategy where a very large number of random programs are created and used as input for the compiler, simply to make sure that it produces correct results. So at least in the classical world, it seems like they don't care how realistic an example is.

Now, the one argument I see against that is: MLIR is an IR, so we expect to get the code from some other front-end (e.g. a higher-level language) that would never generate code like above. However, I only designed that example to be a minimally reproducible unit. More realistic situations where something like this may happen are when we have a for loop and access each qubit inside the loop. This will be dynamic indexing, which obfuscates data dependencies. After a for loop that accesses a tensor consisting of "temporary" and "parameter" qubits, we won't know anymore which of the elements was temporary and which was not.

I see this as a very valid counter argument. QC will probably never be what people write. People will write Guppy, Qiskit, PennyLane, Qrisp, etc.

But even if QC is the starting point, the validity of your example very much depends on the semantics of qubit tensors.
I would argue that one cannot simply extract two qubit values from a tensor and insert them in the other position.
But maybe I am also missing something here.
I don't foresee any problem for any of the structured benchmark that we currently have in mind. These would all work.

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

Labels

enhancement Improvement of existing feature MLIR Anything related to MLIR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants