Multi-IR passmanager for Rust/C/Python - #81
Conversation
There was a problem hiding this comment.
Thanks Julien Gacon (@Cryoris)!
I'd like to sketch an adapter that allows a nested compiler structure. By nested, I mean the situation where an outer IR owns components which themselves are complicated enough that you want to treat them via a separate pass manager. That is, you have a pass, the "adapter", acting as Outer->Outer and that owns a task Component->Component. The job of the adapter is to search for components and apply the inner task. For example, when a quantum circuit owns a conditional instruction, there should be a generic way to run a pass manager/task on each block of every conditional instruction, and recursively. But the example I'm really interested in is a QuantumProgram that contains a bunch of QuantumCircuits: we want to run standard pass managers on those as one step of compiling the QuantumProgram.
/// A way of decomposing an outer IR into owned component units.
///
/// Example: `impl UnrollQuantumCircuitForLoop for Nesting`
pub trait Nesting {
type OuterIR;
type ComponentIR;
type Id: Clone + Eq + Hash + Debug + 'static;
// Whether extracted components might share any scope (affects possible parallelism)
const ISOLATED: bool;
fn take_components(
&self,
outer: &mut Self::OuterIR,
) -> Vec<(Self::Id, Self::ComponentIR)>;
fn put_component(
&self,
outer: &mut Self::OuterIR,
id: Self::Id,
component: Self::ComponentIR,
);
}
/// Runs `inner` over the components of an outer IR.
pub struct NestedAdaptor<N: Nesting> {
nesting: N,
/// Task is `N::ComponentIR -> N::ComponentIR` checked at construction
inner: Task
}
impl<N: Nesting> Pass for NestedAdaptor<N> {
type InputIR = N::OuterIR;
type OutputIR = N::OuterIR;
fn run(&self, mut outer: N::OuterIR, context: &mut PassContext)
-> Result<Self::OutputIR>
{ /* ... invoke take_components and put_component, possibly with parallelism */ }
}This looks like it mostly works well. However, it seems like PassContext will step on its own toes a bit.
Taking the example Outer = QuantumProgram and Inner = QuantumCircuit, and assuming that we want to pass the PassContext
from the OuterIR down to each QuantumCircuit rather than start a fresh context each time, then all of the strings will
collide. Should we introduce namespacing or scoping into the context?
I have similar questions about how this will eventually interact with debug information. The user will care just as much about
the debug information inside of components as they do about debug information in the outer scope.
| /// Which analysis does this preserve? | ||
| fn preserves(&self) -> PreservedAnalysis; |
There was a problem hiding this comment.
Pointing out something obvious in case it sparks discussion: making preserves() a static function, instead of an output of run(), forces it to be conservative. If a pass finds nothing to do on a particular input, it must declare that it did something here, despite the fact that it didn't.
There was a problem hiding this comment.
Oh that's a good point that we actually discussed previously too. It would be better to have it be an output of run, I agree. In that case we can make this decision dynamically!
|
|
||
| The pass manager is in charge of executing the tasks. | ||
| ```rust | ||
| pub struct PassManager { |
There was a problem hiding this comment.
A PassManager and a Task::Group are essentially the same thing. I wonder if we can unify them a bit by having
impl From<PassManager> for Task {
fn from(pm: PassManager) -> Task { Task::Group(pm.tasks) }
}
This ties into my main comment, where I would want the ability to set inner to a PassManager.
There was a problem hiding this comment.
Conceptually, a PassManager and Task are different in this RFC and it's a distinction we'd like to make clear compared to the current situation: a PassManager is the sole executor, a Task only provides the description of the flow.
Further, there is only a single pass manager as executor, so "nesting PM1 inside PM2" would rather amount to "appending the tasks of PM1 to PM2" using a Task::Group. What you wrote above is one option, alternatively we could be more explicit about the distinction
pm2.try_push_task(Task::Group(pm1.tasks));There was a problem hiding this comment.
"nesting PM1 inside PM2" would rather amount to "appending the tasks of PM1 to PM2" using a Task::Group. What you wrote above is one option, alternatively we could be more explicit about the distinction
I think there must be a fundamental misunderstanding somewhere between us. I don't think that concatenating list-like passes is equivalent to nesting passes.
There was a problem hiding this comment.
Right, I probably didn't write the above well enough: you can nest passes arbitrarily using the Task::Group. What I meant above is that nesting a PM inside a pass would mean to define a Task::Group(pm.tasks). I was assuming you then want to append this nested object onto another pass manager, hence the "append" above. But we can also have a chat offline!
| /// The key under which analyses results or properties are stored. | ||
| pub struct Property(String); | ||
|
|
||
| pub struct PassContext { |
There was a problem hiding this comment.
Can you remind me where a Target lives? In here?
There was a problem hiding this comment.
Right now the Target is not a runtime argument, but the idea is to keep the existing model and have it be an input argument to the pass struct. So something like
struct UnitarySynthesis {
target: Option<Target>,
...
}
impl UnitarySynthesis {
fn new(target: Option<Target>, ...) -> Self { ... }
}
impl Pass for UnitarySynthesis {
...
}There was a problem hiding this comment.
the idea is to keep the existing model
Just for my own sake: is this for the continuity, or because it's a better idea? I suppose allowing every pass to have it's own definition of a target is more flexible.
There was a problem hiding this comment.
It sounds like the right separation to me: the pass context contains program-specific information gathered at runtime, whereas the Target (and other arguments like approximation_degree etc.) specify the pass' action independent of the program. I'll mention this in the doc!
|
Prompted by Jake Lishman (@jakelishman), this is a rework of my original comment where I extend the The main deficiency I see with the proposed One of Jake Lishman (@jakelishman)'s main points is that what I'm considering here is just one of many examples (as yet unknown) Region walkerI'm going to write two alternative implementation sketches, but both of them use this /// Discovers regions and calls a function on each. Notably, provides mutable references.
pub trait RegionWalker {
type OuterIR: 'static;
type RegionIR: 'static;
fn walk(
&self,
outer: &mut Self::OuterIR,
body: &mut dyn FnMut(&mut Self::RegionIR) -> anyhow::Result<()>,
) -> anyhow::Result<()>;
}
trait AnyRegionWalker { /* type erased version of RegionWalker */ }
impl<W: RegionWalker> AnyRegionWalker for W { /* ... */ }Alternative 1: multiple traits, each with one
|
|
I guess my previous comments omits the most important thing: what needs to change in the RFC to accommodate something like this. As far as I can tell it's this:
Alternative 1 additionally needs
|
|
Thanks for working this through, Ian! Firstly:
Then a question on your snippet: I would imagine that the pass manager would decide pass-by-pass which |
I suppose so. In the current example, it's pretty straight forward, because there are only two, and built into the |
This RFC drafts a new passmanager for Qiskit that is written in Rust and will be exposed to C and to Python. It has two main goals:
This is targeting Qiskit v2.6.