✨ Extend Layout API for partial injections - #1956
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
@rturrado Thanks for another PR! 🙌 @MatthiasReumann care to take a first look here since this is closest to the stuff you have been working on? 😌 |
| const Layout exit = | ||
| TypeSwitch<Operation*, Layout>(op) | ||
| .Case<scf::ForOp>([&](scf::ForOp) { | ||
| // Find (insert) the epilogue SWAP sequence for (into) the child | ||
| // region using the restore strategy. | ||
|
|
||
| const auto swaps = restore(children[0].layout, parent.layout); | ||
| insertSWAPs<Mode>(swaps, children[0], stats, rewriter); | ||
| return parent.layout; | ||
| }) | ||
| .template Case<scf::WhileOp>([&](scf::WhileOp) { | ||
| // Find (insert) the epilogue SWAP sequence for (into) the after | ||
| // region using the restore strategy. | ||
|
|
||
| const auto swaps = restore(children[1].layout, parent.layout); | ||
| insertSWAPs<Mode>(swaps, children[1], stats, rewriter); | ||
|
|
||
| // The scf::YieldOp is the terminator in the before region and | ||
| // thus determines the final output layout. | ||
| return children[0].layout; | ||
| }) | ||
| .template Case<IfOp>([&](IfOp) { | ||
| // Find (insert) the epilogue SWAP sequence for (into) each child | ||
| // branch using the "converge" strategy. | ||
|
|
||
| const auto [convergedLayout, fst, snd] = | ||
| converge(children[0].layout, children[1].layout); | ||
| insertSWAPs<Mode>(fst, children[0], stats, rewriter); | ||
| insertSWAPs<Mode>(snd, children[1], stats, rewriter); | ||
| return convergedLayout; | ||
| }) | ||
| .template Case<IndexSwitchOp>([&](IndexSwitchOp) { | ||
| for (auto& child : children) { | ||
| const auto swaps = restore(child.layout, parent.layout); | ||
| insertSWAPs<Mode>(swaps, child, stats, rewriter); | ||
| } | ||
| return parent.layout; | ||
| }); |
There was a problem hiding this comment.
The converge strategy for qco.if and the restore strategy only of the "yielding" branch of the scf.while operation is definitely not a bug but a feature.
MatthiasReumann
left a comment
There was a problem hiding this comment.
@rturrado Thanks for the effort 🚀 Really appreciate it!
I've left one comment regarding the strategies for mapping SCF which probably shouldn't be reverted to "restore-all". The essential idea is that the SCF operations (besides scf.for) act like permutation networks changing the layout (which needs to be propagated to the parent). If you have any questions, feel free to reach out!
Thanks! This one touches a really beautiful area.
But I have to admit that it is also more complicated code, so human review here is essential. |
Many thanks!
Perfect, thanks! Yes, this was my main concern about this PR. That the new wire-index-equals-hardware-index invariant always has to restore to the parent layout and never converges. That |
I think the "wire-index-equals-hardware-index" is a pretty neat idea, which eventually I would have also looked into. In a previous version of the mapping pass (before SCF mapping), we implemented "wire-index-equals-program-index" which worked pretty nicely. Nonetheless, I think it would make sense to split this PR into two:
Especially since it's very likely that #1951 is merged before this one. |
|
🤖 AI text below 🤖 @MatthiasReumann Agreed on the split. Rough sketch: PR 1 (safe): PR 2 (invariant):
Assisted-by: Claude Opus 4.7 via Claude Code |
Layout: DenseMap storage and partial-injection API
Layout: DenseMap storage and partial-injection APILayout: DenseMap storage and partial-injection API
|
I think we'll try to get #1951 in asap (either tonight or tomorrow). Then PR1 can go on top 😌 |
25f536f to
b036d42
Compare
MatthiasReumann
left a comment
There was a problem hiding this comment.
Great work. Looking forward for merging this 🚀
Some notes / ideas:
We can now reasonably argue that the program and hardware qubits are a consecutive range of numbers (#1993). That is, a Layout holds [M] → [N], where M <= N. Maybe this allows us to avoid DenseMaps and use SmallVectors again. The Layout is an essential data structure in the mapping pass, so we need to make sure the implementation is very efficient.
Moreover, if we now already invest the time to improve this data structure, it could also make sense to add a generalized InjectiveMap<K, V>, where the Layout would then be a subclass of that (If that is even necessary). Using templates, we may need to keep DenseMap; or specialize on integer types using SmallVector. Just an idea - maybe a follow-up!
| const auto materializeKey = [](const Layout& layout) { | ||
| SmallVector<size_t> key(layout.nHardwareQubits()); | ||
| for (size_t prog = 0; prog < layout.nHardwareQubits(); ++prog) { | ||
| key[prog] = layout.getHardwareIndex(prog); | ||
| } | ||
| return key; | ||
| }; | ||
|
|
There was a problem hiding this comment.
Couldn't we use getProgramToHardware instead as it now returns a SmallVector<size_t>? For the mapping pass it must always hold nHardwareQubits() == nProgramQubits().
| // Owns the materialized layout snapshots that `bestDepth` keys point into. | ||
| // std::deque never invalidates pointers to existing elements on push_back, | ||
| // so ArrayRefs already in `bestDepth` stay stable as we grow it. | ||
| std::deque<SmallVector<size_t>> keyStorage; |
There was a problem hiding this comment.
| std::deque<SmallVector<size_t>> keyStorage; | |
| std::deque<SmallVector<size_t>> keys; |
Personal preference; not a must-have.
| /// Number of program qubits this layout was declared with. | ||
| size_t nProgramQubits_ = 0; | ||
| /// Number of hardware qubits this layout was declared with. | ||
| size_t nHardwareQubits_ = 0; |
There was a problem hiding this comment.
Why are these required? Shouldn't nProgramQubits (nHardwareQubits) also rather return programToHardware_.size() (hardwareToProgram_.size())?
| void Layout::swap(const size_t hwA, const size_t hwB) { | ||
| assert(hwA < nHardwareQubits_ && "hardware index out of bounds"); | ||
| assert(hwB < nHardwareQubits_ && "hardware index out of bounds"); | ||
| if (hwA == hwB) { | ||
| return; | ||
| } | ||
| // Read the current value on each side (may be empty), then write each to the | ||
| // other side. Empty is treated as a legitimate value. | ||
| const auto itA = hardwareToProgram_.find(hwA); | ||
| const auto itB = hardwareToProgram_.find(hwB); | ||
| const bool hasA = itA != hardwareToProgram_.end(); | ||
| const bool hasB = itB != hardwareToProgram_.end(); | ||
| const size_t progA = hasA ? itA->second : 0; | ||
| const size_t progB = hasB ? itB->second : 0; | ||
| hardwareToProgram_.erase(hwA); | ||
| hardwareToProgram_.erase(hwB); | ||
| if (hasA) { | ||
| hardwareToProgram_[hwB] = progA; | ||
| programToHardware_[progA] = hwB; | ||
| } | ||
| if (hasB) { | ||
| hardwareToProgram_[hwA] = progB; | ||
| programToHardware_[progB] = hwA; | ||
| } | ||
| } |
There was a problem hiding this comment.
I think this function should assume (assert) that both hardware qubits have program qubits assigned instead of inserting 0s.
There was a problem hiding this comment.
Done. swap now asserts both hardware slots have program qubits assigned.
|
@MatthiasReumann Many thanks for the thorough review and the guidance. This PR was just the rebase onto |
Layout: DenseMap storage and partial-injection API|
@MatthiasReumann @rturrado Except for the minor conflict with |
@burgholzer Thanks for flagging this. I'll have a look into that conflict! |
Extend `Layout` to support `M <= N` program-to-hardware mappings where `N - M` hardware slots stay unmapped: - Unmapped hardware entries in `hardwareToProgram_` carry an `UNMAPPED` sentinel. - Split `nqubits()` into `nProgramQubits()` and `nHardwareQubits()`. `random()` takes both sizes. - Add `hasProgramAt(hw)` to distinguish mapped from unmapped hardware slots. Adapt `Mapping.cpp`: - rename `nqubits()` call sites to `nHardwareQubits()`, - pass both sizes to `Layout::random()`. Assisted-by: Claude Opus 4.7 via Claude Code Signed-off-by: rturrado <rturrado@gmail.com>
MatthiasReumann
left a comment
There was a problem hiding this comment.
Thanks for your continued work on this! @rturrado Much appreciated🙏
The pull request looks really good already! I've only left some very nitpicky comments, which should be fairly easy to resolve. Lastly, I think a changelog entry is missing also. Otherwise, let's get this PR merged ASAP 🛫
| /// Construct a layout from a program-to-hardware mapping, | ||
| /// where mapping[prog] = hw. | ||
| /// Sets both `nProgramQubits` and `nHardwareQubits` to `mapping.size()`. |
There was a problem hiding this comment.
| /// Construct a layout from a program-to-hardware mapping, | |
| /// where mapping[prog] = hw. | |
| /// Sets both `nProgramQubits` and `nHardwareQubits` to `mapping.size()`. | |
| /// Construct a layout from a bijective program-to-hardware mapping, | |
| /// where mapping[prog] = hw. | |
| /// Sets both `nProgramQubits` and `nHardwareQubits` to `mapping.size()`. |
Another option would be to explicitly allow the caller to set some program qubits to UNMAPPED. This would require to change the semantics of mapping to mapping[hw] = prog.
There was a problem hiding this comment.
Thanks, applied the bijective addition.
On mapping[hw] = prog: agreed as a natural next step when a caller needs partial injection through fromMapping. Today no caller does; Mapping.cpp only uses it with pure permutations. Leaving it for the follow-up that will start exercising M < N from the mapping pass.
| const auto mappedCount = | ||
| std::ranges::count_if(std::views::iota(size_t{0}, nHw), | ||
| [&](size_t hw) { return layout.hasProgramAt(hw); }); |
There was a problem hiding this comment.
Nitpick: Could we do this with llvm:: utilities? For example, llvm::seq should be able to replace iota.
There was a problem hiding this comment.
Beautiful. Done, replaced both std::ranges::count_if / std::views::iota with llvm::count_if / llvm::seq.
| TEST(LayoutDeathTest, RejectDuplicateHardwareIndex) { | ||
| constexpr std::array<size_t, 3> mapping{0, 0, 2}; | ||
| EXPECT_DEATH((void)qco::Layout::fromMapping(mapping), | ||
| EXPECT_DEATH((void)Layout::fromMapping(mapping), |
There was a problem hiding this comment.
Nitpick: Any way to avoid that C-style cast here?
There was a problem hiding this comment.
Very well spotted. Done, dropped the cast. fromMapping is not [[nodiscard]], so calling it as a plain statement compiles cleanly and the cast was defensive.
In that regard, please just fold this PR number into the existing changelog entry for the mapping pass (and add your name to it) 😌 |
Done, folded |
- Doc: describe `fromMapping` as bijective. - Test: use `llvm::count_if` / `llvm::seq` instead of `std::ranges::count_if` / `std::views::iota`. - Test: drop the defensive `(void)` cast in `EXPECT_DEATH`. - Add `munich-quantum-toolkit#1956` to the `place-and-route` changelog entry. Assisted-by: Claude Opus 4.7 via Claude Code Signed-off-by: rturrado <rturrado@gmail.com>
🤖 AI text below 🤖
Description
Extend
Layoutto supportM <= Nprogram-to-hardware mappings whereN - Mhardware slots stay unmapped:hardwareToProgram_carry anUNMAPPEDsentinel.nqubits()intonProgramQubits()andnHardwareQubits().random()takes both sizes.hasProgramAt(hw)to distinguish mapped from unmapped hardware slots.A possible follow-up could exercise
nProg < nHwand the new query API from inside the mapping pass.Part of #1867
Checklist
If PR contains AI-assisted content:
🤖 *AI text below* 🤖(titles are exempt).Assisted-by: [Model Name] via [Tool Name]footer.Assisted-by: Claude Opus 4.7 via Claude Code