-
Notifications
You must be signed in to change notification settings - Fork 9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(quil-py): support extern call instructions #394
base: main
Are you sure you want to change the base?
Conversation
/// The name of the call instruction. This must be a valid user identifier. | ||
pub name: String, | ||
/// The arguments of the call instruction. | ||
pub arguments: CallArguments, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I considered three alternatives here:
- Just have a
Vec<CallArgument>
where eachCallArgument
has aresolution: Option<Resolution>
attribute. - Do not mutate the
CallArgument
s and instead return a struct that represents the resolution, roughly of the structure ("name", instruction_index) => Vec (we need theindex
to refer to index of theCALL
instruction within the program). - Add a type parameter to the
CALL
instructions and then to the program itself. Resolution would then return a program of a different type (eginto_call_resolved_program(self) -> Result<Program<ResolveCallArgument>, ProgramError>
).
I landed on the de facto implementation because:
- Within a given instruction, call arguments are all resolved at the same time. Either all arguments are resolved or none are.
- There are existing patterns within Quil that mutate the program, such as
resolve_placeholders
. - Tracking instruction indices seems fairly unergonomic and brittle.
- I did not want to add type complexity to the
Program
struct which is pretty easy to use. - I wanted to avoid the user resolving the program more than once for efficiency's sake.
I definitely feel like this resolution functionality belongs in quil-rs and not separately in downstream compilers. After going back and forth, I think this is the right implementation, but am open to input here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is indeed a thorny question! I agree that alternatives #2 and (to a lesser extent) #1 are inferior, but I think there's a lot to be said for #3. I've talked to you about this offline, but to expand here:
I think this raises the question of if we've hit the point where we want to separate the parsed AST from the "typechecked"/"resolved" AST. I think there's a lot of merit to doing so, as it allows for capturing invariants much more cleanly. But I'm not sure if this PR is the place to do it.
I think I might favor a design where we parameterize all our types by the stage of compilation, passing that down to where we need to make a decision, and default that type parameter to the stage that corresponds to the existing situation. Something like the following:
pub trait Stage {
type CallArgument;
}
pub enum Parsed;
impl Stage for Parsed {
type CallArgument = UnresolvedCallArgument;
}
pub enum Resolved;
impl Stage for Resolved {
type CallArgument = ResolvedCallArgument;
}
pub struct Program<S: Stage = Resolved> {
// …
instructions: Instruction<S>,
// …
}
pub enum Instruction<S: Stage = Resolved> {
// …
Call(Call<S>),
// …
}
pub struct Call<S: Stage = Resolved> {
pub name: String,
pub arguments: Vec<S::CallArgument>,
}
The upside to this is that we can bundle all the types we need to parameterize by together; the downside is the extra trait. I think the upside is likely to be worth it, but it's not obvious.
Another limitation of this approach is that it forces the ASTs to be almost identical. This can be good or bad. Another approach would be to have
pub trait Stage {
type AST;
}
even if Parsed::AST = Program<α>
and Resolved::AST = Program<β>
for now.
We can also hide some of the complexity by having the parser return a Program<Parsed>
, a function fn resolve(parsed: Program<Parsed>) -> Result<Program<Resolved>, ResolutionError>
, and then exposing at the top level a function that simply combines the two, returning a Result<Program, …>
, so the user is not confronted with this new API.
That said: this adds extra complexity! I think that may be worth it, but it's not obvious. This mutate-and-resolve approach is not a bad one, and it might even be the implementation behind the more complex version I outline above.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just spoke with Kalan about this. We landed somewhere around here:
Pragma
doesn't need to be anenum
. We can add something likeextern_definitions: HashMap<String, ExternDefinition>
toProgram
.struct Call
will only havearguments: Vec<UnresolvedCallArgument>
.Call.resolve(...)
returnsResult<Vec<ResolvedCallArgument>, ...>
. This will be a public function for the purposes of translating.
There's a bit of inefficiency here WRT resolving in get_memory_accesses
(still fallible) and then resolving for translation. I may think a bit more about that, but otherwise, this seems tenable to me.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So is the idea here that resolution doesn't need to produce a new Program
, just store the extern_definitions
? And then any processing that needs to consume a resolved Call
will just call .resolve
in situ when necessary? I think this seems fine, if a bit of kicking the can down the road wrt a new AST – but as I said above, this PR is likely not the right place for that anyway, so I don't think that's an issue.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yup, you got it. Here I'm just going for consistency with the existing implementation but I'm onboard with your general vision for generating a separate "validated and resolved" AST. We'll keep the conversation going.
fn has_return_or_parameters(&self) -> bool { | ||
self.return_type.is_some() || !self.parameters.is_empty() | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe better to make this required in the (fallible) constructor and make the fields private?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes on the fallible constructor. Private fields present a couple issues:
- The Python package uses this
py_wrap_data_struct!
macro that requires the fields to be public. - Most other instructions in quil-rs have fully public fields.
There's not really precedent to create an intermediate in quil-py
with public fields that then converts to a quil-rs
Call
, so I hesitate to introduce one here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The issue with having public fields is that you can no longer count on the constructor guaranteeing validity, because a user can simply do
let mut sig = ExternSignature::try_new(Some(cool_type), vec![cool_parameter]).unwrap();
sig.return_type = None;
sig.parameters = vec![];
and now your sig
is invalid.
/// The extern definition has a signature but it lacks a return or parameters. | ||
#[error("extern definition {0} has a signature but it lacks a return or parameters")] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What signature can it have, then?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is an odd part of the spec, which basically requires that the CALL
has at least one argument. I get that the intent is to require that statefulness is maintained in the user memory space, however, this seems a futile requirement to try to enforce because a user could easily workaround it:
DECLARE useless_bit BIT[1]
PRAGMA EXTERN foo "(ignored : BIT[1])"
CALL foo useless_bit[0] # the bit is ignored and the function mutates state
I'm partial to dropping this requirement in the implementation as well as the spec.
/// The name of the call instruction. This must be a valid user identifier. | ||
pub name: String, | ||
/// The arguments of the call instruction. | ||
pub arguments: CallArguments, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is indeed a thorny question! I agree that alternatives #2 and (to a lesser extent) #1 are inferior, but I think there's a lot to be said for #3. I've talked to you about this offline, but to expand here:
I think this raises the question of if we've hit the point where we want to separate the parsed AST from the "typechecked"/"resolved" AST. I think there's a lot of merit to doing so, as it allows for capturing invariants much more cleanly. But I'm not sure if this PR is the place to do it.
I think I might favor a design where we parameterize all our types by the stage of compilation, passing that down to where we need to make a decision, and default that type parameter to the stage that corresponds to the existing situation. Something like the following:
pub trait Stage {
type CallArgument;
}
pub enum Parsed;
impl Stage for Parsed {
type CallArgument = UnresolvedCallArgument;
}
pub enum Resolved;
impl Stage for Resolved {
type CallArgument = ResolvedCallArgument;
}
pub struct Program<S: Stage = Resolved> {
// …
instructions: Instruction<S>,
// …
}
pub enum Instruction<S: Stage = Resolved> {
// …
Call(Call<S>),
// …
}
pub struct Call<S: Stage = Resolved> {
pub name: String,
pub arguments: Vec<S::CallArgument>,
}
The upside to this is that we can bundle all the types we need to parameterize by together; the downside is the extra trait. I think the upside is likely to be worth it, but it's not obvious.
Another limitation of this approach is that it forces the ASTs to be almost identical. This can be good or bad. Another approach would be to have
pub trait Stage {
type AST;
}
even if Parsed::AST = Program<α>
and Resolved::AST = Program<β>
for now.
We can also hide some of the complexity by having the parser return a Program<Parsed>
, a function fn resolve(parsed: Program<Parsed>) -> Result<Program<Resolved>, ResolutionError>
, and then exposing at the top level a function that simply combines the two, returning a Result<Program, …>
, so the user is not confronted with this new API.
That said: this adds extra complexity! I think that may be worth it, but it's not obvious. This mutate-and-resolve approach is not a bad one, and it might even be the implementation behind the more complex version I outline above.
The one caveat here is program mutation (either mutating a parsed program or just some |
6638cde
to
957b470
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I overall really like this representation, and I think it's a big improvement. I have some questions around exactly where we perform resolution, and I think you may be able to punt some (or most?) of them away with "we'll figure this out when we figure out what broader program checking looks like". I also have various specific comments.
One general note: I wonder if we should provide a way to trim out unused PRAGMA EXTERN
s? I believe quil-rs
provides that for unused calibrations etc. from the CLI; we should add removing unused PRAGMA EXTERN
s to that, I think.
"""An argument to a ``Call`` instruction. | ||
|
||
This may be expressed as an identifier, a memory reference, or an immediate value. Memory references and identifiers require a corresponding memory region declaration by the time of | ||
compilation (at the time of call argument resolution and memory graph construction to be more precise). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
compilation (at the time of call argument resolution and memory graph construction to be more precise). | |
compilation (at the time of call argument resolution and memory graph construction, to be more precise). |
... | ||
|
||
class Call: | ||
"""An instruction to an external function declared within a `PRAGMA EXTERN` instruction. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"""An instruction to an external function declared within a `PRAGMA EXTERN` instruction. | |
"""An instruction that calls an external function declared with a `PRAGMA EXTERN` instruction. |
/// A parameter type within an extern signature. | ||
#[derive(Clone, Debug, PartialEq, Hash, Eq)] | ||
pub enum ExternParameterType { | ||
/// A scalar parameter, which may accept a memory reference or immediate value. | ||
Scalar(ScalarType), | ||
/// A fixed-length vector, which must accept a memory region name of the appropriate | ||
/// length and data type. | ||
FixedLengthVector(Vector), | ||
/// A variable-length vector, which must accept a memory region name of the appropriate | ||
/// data type. | ||
VariableLengthVector(ScalarType), | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These would be clarified if you gave examples of the syntax that corresponded to them
ExternParameterType::FixedLengthVector(value) => value.write(f, fall_back_to_debug), | ||
ExternParameterType::VariableLengthVector(value) => { | ||
value.write(f, fall_back_to_debug)?; | ||
write!(f, "[]").map_err(Into::into) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
?
calls into
(well, from
) for you, so this can just be
write!(f, "[]").map_err(Into::into) | |
write!(f, "[]")? |
/// Create a new extern parameter. This validates the parameter name as a user | ||
/// identifier. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/// Create a new extern parameter. This validates the parameter name as a user | |
/// identifier. | |
/// Create a new extern parameter. This will fail if the parameter name is not a valid user | |
/// identifier. |
/// Call instructions are of the form: | ||
/// `CALL @ms{Identifier} @rep[:min 1]{@group{@ms{Identifier} @alt @ms{Memory Reference} @alt @ms{Complex}}}` | ||
/// | ||
/// For additional detail, see the ["Call" in the Quil specification](https://github.com/quil-lang/quil/blob/7f532c7cdde9f51eae6abe7408cc868fba9f91f6/specgen/spec/sec-other.s). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/// For additional detail, see the ["Call" in the Quil specification](https://github.com/quil-lang/quil/blob/7f532c7cdde9f51eae6abe7408cc868fba9f91f6/specgen/spec/sec-other.s). | |
/// For additional detail, see ["Call" in the Quil specification](https://github.com/quil-lang/quil/blob/7f532c7cdde9f51eae6abe7408cc868fba9f91f6/specgen/spec/sec-other.s). |
))(input) | ||
} | ||
|
||
fn parse_immediate_value(input: ParserInput) -> InternalParserResult<Complex64> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The fact that this is the first time we've had to parse complex numbers makes me realize – what Quil type will accept a complex literal when used as an argument to a CALL
instruction?
@@ -92,10 +93,23 @@ macro_rules! set_from_memory_references { | |||
}; | |||
} | |||
|
|||
#[derive(thiserror::Error, Debug, PartialEq)] | |||
pub enum MemoryAccessesError { | |||
#[error("must be able to resolve call to an extern signature: {0}")] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I find this description of the error confusing. Would #[error(transparent)]
work here?
/// Note, this may fail if the program contains [`Instruction::Call`] instructions that cannot | ||
/// be resolved to the appropriate [`crate::instruction::ExternSignature`]. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/// Note, this may fail if the program contains [`Instruction::Call`] instructions that cannot | |
/// be resolved to the appropriate [`crate::instruction::ExternSignature`]. | |
/// This will fail if the program contains [`Instruction::Call`] instructions that cannot | |
/// be resolved against a signature in the provided [`ExternSignatureMap`] (either because | |
/// they call functions that don't appear in the map or because the types of the parameters | |
/// are wrong). |
Instruction::Pragma(pragma) if pragma.name == RESERVED_PRAGMA_EXTERN => { | ||
self.extern_pragma_map.0.insert( | ||
match pragma.arguments.first() { | ||
Some(PragmaArgument::Identifier(name)) => Some(name.clone()), | ||
_ => None, | ||
}, | ||
pragma, | ||
); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see – the use of ExternPragmaMap
is so that adding an instruction to the program is infallible. I'm still not convinced by the type itself, but I see the logic behind doing it this way. If we want to have ExternPragmaMap
instead of the raw IndexMap
, I'd make it a little more opaque and put all this logic into it, and I'd clarify what happens on duplicate EXTERN
names in the documentation.
Review Guidance
High-level overview
This introduces support for
CALL
andPRAGMA EXTERN
instructions. The former is supported directly as anInstruction
and I've introduced aReservedPragma
instruction to accommodate the latter (see source code comment below for discussion on this particular choice).There are three main aspects to this support:
crate::parser::command::parse_call
and parsing ofExternSignature
incrate::parser::pragma_extern
.crate::instruction::extern_call
.crate::instruction::extern_call
andcrate::program::Program::get_memory_accesses
.This functionality was also ported to Python in
quil-py/src/instruction/extern_call.rs
.Public API Changes
There are two breaking public API changes from adding EXTERN / CALL support:
TwoOne new enum variant onInstruction
:Call
and.ReservedPragma
(see source code comment below for the choice ofReservedPragma
)Instruction::get_memory_accesses
is fallible now. This reflects the fact that aCALL
instruction cannot know its memory accesses until it has been resolved to anExternSignature
(ie it has to know the mutability of its different arguments.