Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .agent/plans/export-grouped-measurements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Export independently scheduled measurements

Status: in progress. Validate measurement-store normalization on the export
clone.

## Goal and scope

Export valid mapped QC programs whose measurement stores are separated by
independent measurements or control flow. This change is stacked on the routing
fix in PR [#2351](https://github.com/munich-quantum-toolkit/core/pull/2351). It
adds no scheduling constraint to the mapper. The implementation belongs in
`bindings/mlir/qiskit/QiskitExport.cpp`, with semantic regressions in
`test/python/test_mlir_qiskit_translation.py`.

## Decisions

- Keep the measurement at its original quantum position and fuse its unique,
static destination only when intervening operations cannot access that bit.
CBit registers are non-aliasing; distinct static indices are disjoint.
- Inspect nested effects, while retaining the verified QC unitary contract for
operations with intentionally conservative quantum memory effects.
- Move each supported store immediately after its measurement on the exporter's
existing clone before indexing writes. Materialize a late constant destination
index before the measurement. Quantum operations retain their order, and the
caller's program stays unchanged.
- Snapshot analysis uses the actual normalized order. Do not maintain synthetic
writes at measurement positions or model other measurements' future stores.
Retain the parent's indexed lookup and scalar snapshot support.
- Do not add scratch classical bits: Qiskit exposes them in the public result.
Conflicting destination accesses, unknown effects, and unsupported stale
snapshots remain diagnosed rather than silently changing results.
- Snapshot checks remain conservative at register granularity. Reuse the
parent's scalar snapshot support, including reads consumed across a fused
write. Stale register snapshots wider than 64 bits remain unsupported.
- Benchpress's temporary textual event-order guard cannot prove equivalence and
rejects legal independent scheduling. Retire it only with the tested Core
snapshot and deterministic semantic regressions; retain input-profile
restrictions and validate native export and target compliance separately.

## Validation

Current validation on parent `aa1b13cf8`, with rebuilt Python 3.13 bindings and
Qiskit 2.5.2:

- `pytest test/python/test_mlir_qiskit_translation.py test/python/test_mlir_loops.py`:
all 398 tests pass. Added cases cover direct measured-bit control before a
delayed store, disjoint-bit snapshots without redundant scalar variables, late
destination indices, and ordered writes to a shared destination. Reverse
writes and conflicting effects remain rejected. Export leaves the input
program unchanged.
- The nested Qiskit control program that aborted before the parent's fixes now
compiles through the target pipeline.
- `uvx nox -s stubs`: passes without generated API changes.
- `uvx nox -s cpp-lint -- aa1b13cf8`: no whole-file C++ lint findings.
- `uvx nox -s lint`: passes.

Historical validation at source revision `7dad9e19e`, with Qiskit 2.5.0:

- `pytest test/python/test_mlir_qiskit_translation.py`: 330 tests, including
deterministic QC/QCO native-export round trips and unsafe-fusion rejections.
The preceding build fails 12 of the new positive regressions.
- All 31 guarded Benchpress feed-forward profiles, ten previously enabled
profiles, and BV100: 42 native-export checks, with 4,621 conditionals and
recursive Qiskit basis/connectivity validation. No OpenQASM fallback is used;
BV100 retains exactly 99 measurements and classical bits.
- All 80 Benchpress integration tests, including deterministic output checks and
an explicit rejection regression for unsupported snapshot capture.
- `uvx nox -s stubs`, `uvx nox -s lint`, and `uvx nox -s cpp-lint -- 8936bc2ab`:
no whole-file C++ lint findings. Stub generation changes no public API files.

The historical Benchpress update pins that snapshot and retains input
restrictions. Its old snapshot-rejection result predates the parent's scalar
snapshot support and has not been revalidated. Those integration checks and the
full benchmark suite have not been rerun for this update; structural condition
counts are not a general semantic equivalence proof.
131 changes: 106 additions & 25 deletions bindings/mlir/qiskit/QiskitExport.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
#include <llvm/ADT/SmallPtrSet.h>
#include <llvm/ADT/SmallVector.h>
#include <llvm/ADT/StringSet.h>
#include <llvm/ADT/TypeSwitch.h>
#include <llvm/Support/Casting.h>
#include <mlir/Analysis/CallGraph.h>
#include <mlir/Dialect/Arith/IR/Arith.h>
Expand All @@ -49,6 +50,7 @@
#include <mlir/IR/Region.h>
#include <mlir/IR/Value.h>
#include <mlir/IR/ValueRange.h>
#include <mlir/Interfaces/SideEffectInterfaces.h>
#include <mlir/Support/WalkResult.h>
#include <mlir/Transforms/GreedyPatternRewriteDriver.h>
#include <nanobind/nanobind.h>
Expand Down Expand Up @@ -1687,6 +1689,25 @@ exportExpression(mlir::Value value, ExportState& state,
return result;
}

[[nodiscard]] static mlir::cbit::StoreOp
measurementDestination(mlir::qc::MeasureOp measure) {
mlir::cbit::StoreOp destination;
for (auto* user : measure.getResult().getUsers()) {
if (auto store = llvm::dyn_cast<mlir::cbit::StoreOp>(user)) {
if (destination) {
throw std::runtime_error(
"QC measurement has more than one classical destination");
}
Comment thread
burgholzer marked this conversation as resolved.
destination = store;
}
}
if (!destination) {
throw std::runtime_error(
"QC measurement is missing a static classical destination");
}
return destination;
}

/// Index writes in block order, including effects of nested operations.
static void indexWrites(mlir::Block& block, ExportState::WriteIndex& index) {
index.try_emplace(&block);
Expand Down Expand Up @@ -2053,6 +2074,63 @@ static void validateControlFlowDepth(const size_t controlFlowDepth) {
}
}

[[nodiscard]] static bool
disjointClassicalBit(mlir::Value reg, mlir::Value index,
mlir::cbit::StoreOp destination) {
if (reg != destination.getReg()) {
return true;
}
const auto bit = mlir::getConstantIntValue(index);
const auto destinationBit = mlir::getConstantIntValue(destination.getIndex());
return bit && destinationBit && *bit != *destinationBit;
}

[[nodiscard]] static bool
canFuseMeasurementAcross(mlir::Operation& operation,
mlir::cbit::StoreOp destination) {
const auto result = operation.walk<mlir::WalkOrder::PreOrder>(
[&](mlir::Operation* candidate) {
return llvm::TypeSwitch<mlir::Operation*, mlir::WalkResult>(candidate)
.Case([](mlir::qc::UnitaryOpInterface) {
// Verified unitary regions cannot access classical
// memory. Their global phase and call effects are
// deliberately broad.
return mlir::WalkResult::skip();
})
.Case([&](mlir::MemoryEffectOpInterface mem) {
llvm::SmallVector<mlir::MemoryEffects::EffectInstance> effects;
mem.getEffects(effects);
// CBit registers do not alias. Same-register bit accesses
// still need static-index disambiguation.
const bool conflicts =
llvm::any_of(effects, [&](const auto& effect) {
if (!effect.getValue()) {
return true;
}
if (effect.getValue() != destination.getReg()) {
return false;
}
return llvm::TypeSwitch<mlir::Operation*, bool>(candidate)
.Case<mlir::cbit::LoadOp, mlir::cbit::StoreOp>(
[&](auto access) {
return !disjointClassicalBit(access.getReg(),
access.getIndex(),
destination);
})
.Default(true);
});
return conflicts ? mlir::WalkResult::interrupt()
: mlir::WalkResult::advance();
})
.Default([](mlir::Operation* op) {
return op->hasTrait<mlir::OpTrait::HasRecursiveMemoryEffects>()
? mlir::WalkResult::advance()
: mlir::WalkResult::interrupt();
});
});
return !result.wasInterrupted();
}

[[nodiscard]] static bool isFusableMeasurementStore(mlir::qc::MeasureOp measure,
mlir::cbit::StoreOp store) {
if (store.getValue() != measure.getResult() ||
Expand All @@ -2061,17 +2139,38 @@ static void validateControlFlowDepth(const size_t controlFlowDepth) {
}
for (auto* operation = measure->getNextNode(); operation != store;
operation = operation->getNextNode()) {
// Fusion writes the destination at the measurement. Quantum gates and
// resets cannot observe that earlier classical write and stay in place.
if (operation == nullptr ||
!llvm::isa<mlir::arith::ConstantOp, mlir::qc::UnitaryOpInterface,
mlir::qc::ResetOp>(operation)) {
if (operation == nullptr || !canFuseMeasurementAcross(*operation, store)) {
return false;
}
}
return true;
}

/// Put each supported measurement store at its emitted position before
/// snapshot analysis. Quantum operations keep their order.
static void prepareMeasurementStores(mlir::func::FuncOp function) {
function.walk([&](mlir::qc::MeasureOp measure) {
auto destination = measurementDestination(measure);
const auto index = mlir::getConstantIntValue(destination.getIndex());
if (!index) {
throw std::runtime_error(
"QC measurement uses a dynamic classical destination");
}
if (!isFusableMeasurementStore(measure, destination)) {
throw std::runtime_error("QC measurement destination must follow the "
"measurement in the same block");
}
if (auto* definition = destination.getIndex().getDefiningOp();
definition && definition->getBlock() == measure->getBlock() &&
measure->isBeforeInBlock(definition)) {
mlir::OpBuilder builder(measure);
destination.getIndexMutable().assign(mlir::arith::ConstantIndexOp::create(
builder, measure.getLoc(), *index));
}
destination->moveAfter(measure);
});
}

[[nodiscard]] static ClassicalVariable
declareLocal(mlir::Type type, ExportedCircuit& circuit, ExportState& state) {
Expression expression;
Expand Down Expand Up @@ -2570,21 +2669,7 @@ collectSwitch(mlir::scf::IndexSwitchOp switchOp, ExportedCircuit& containing,
continue;
}
if (auto measure = llvm::dyn_cast<mlir::qc::MeasureOp>(operation)) {
mlir::cbit::StoreOp destination;
for (auto& use : measure.getResult().getUses()) {
if (auto store =
llvm::dyn_cast<mlir::cbit::StoreOp>(use.getOwner())) {
if (destination) {
throw std::runtime_error(
"QC measurement has more than one classical destination");
}
destination = store;
}
}
if (!destination) {
throw std::runtime_error(
"QC measurement is missing a static classical destination");
}
auto destination = measurementDestination(measure);
const auto info =
state.classicalRegisterInfo.find(destination.getReg());
const auto index = mlir::getConstantIntValue(destination.getIndex());
Expand All @@ -2596,11 +2681,6 @@ collectSwitch(mlir::scf::IndexSwitchOp switchOp, ExportedCircuit& containing,
throw std::runtime_error(
"QC measurement uses a dynamic classical destination");
}
if (!isFusableMeasurementStore(measure, destination)) {
throw std::runtime_error(
"QC measurement destination must follow the measurement in the "
"same block");
}
const auto checked = checkedIndex(*index, "classical-bit");
if (checked >= info->second.size) {
throw std::runtime_error(
Expand Down Expand Up @@ -2935,6 +3015,7 @@ nb::object exportCircuit(const mlir::QCProgram& program,
"target qubit count");
}
collectResources(function, state, target);
prepareMeasurementStores(function);
indexWrites(function.getBody().front(), state.writes);
auto circuit = collectBlock(function.getBody().front(), state, 0U);
for (const auto& [reg, info] : state.classicalRegisterInfo) {
Expand Down
Loading
Loading