Skip to content
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: add source mapping for calibration expansion #370

Merged
merged 41 commits into from
Oct 12, 2024
Merged

Conversation

kalzoo
Copy link
Contributor

@kalzoo kalzoo commented May 4, 2024

This PR allows the (Rust package) user to do the following:

  • Expand a single instruction in a self-descriptive, recursive way. That is, expand an instruction using its calibrations, and alongside the newly expanded instructions, receive an object describing the calibration decisions made and which of the new instructions are a result of which calibration decisions.
  • Expand an entire program in the same way, receiving not only the expanded program but a source mapping explaining how every instruction in the new program was derived from the first one: either it was expanded (and it will show the expanded instruction as well as the DEFCAL [MEASURE]s used to expand it) or it was not (and it will yield the instruction index in both source and target program which contains it)

Reviewer:

  • Please consider the two comments suggesting a breaking change to the MeasureCalibrationDefinition and Calibration structs. This would make them somewhat more similar to DEFFRAME in that they would have identifiers that could be used to index them without respect to their instruction bodies. We could also take the chance to rename Calibration to CalibrationDefinition for consistency.
    • I have made this change, but in a way that should not break some python consumers.
    • All of the original @propertys are there, so this will only break users who construct and modify calibrations and measure calibrations. I could add additional setters to help migrate, but there's only one __new__ and it doesn't feel right to leave those behind.
  • Please take a look at the new tests and consider the SourceMap format. Do you see any shortcomings?
  • Please look at the CalibrationSet#_expand method and those nearby.
    • I'd like to have this only construct a CalibrationExpansion when that is returned to the user; I first tried an expansion: Option<&mut CalibrationExpansion> argument, but that doesn't work well here because that doesn't have a sensible default.
    • So instead I went with build_source_map which skips most of the work involved in calibration expansion source mapping while still technically constructing one which is then discarded prior to return from CalibrationSet.expand.
    • Can you suggest a more elegant solution? I want to share logic between expand-with-source-map and expand-without-source-map while not doing any source mapping work if one isn't going to be returned.

TODOs:

  • Fix the bug in program calibration expansion source mapping
  • Restore the "fast path" to instruction calibration expansion when no source map is desired. In the current implementation, it's built in all cases and then thrown away in some in order to maintain the existing API.
  • (maybe, at request) An ergonomic reverse-mapping to allow looking up the source instruction that resulted in a given target instruction. That can always be built by the user, but it might be nice to have one here since the source mapping structure takes a moment to grok even for an experienced Quil dev.
  • (maybe) implement source mapping for parsing. This could be done on an experimental branch just to validate that this PR has the right API to support that.

Closes #366

Copy link

github-actions bot commented May 4, 2024

PR Preview Action v1.4.8
🚀 Deployed preview to https://rigetti.github.io/quil-rs/pr-preview/pr-370/
on branch quil-py-docs at 2024-10-12 00:42 UTC

@mhodson-rigetti
Copy link

As much as I can read Rust, this is making sense to me. On the first point about a lurking bug, I wonder if impl SourceMapRange for CalibrationExpansion contains() is incomplete as containment is only by the index being valid, not that it also came from the same calibration? On the third point about reverse-mapping help, if I could see the pattern for this implemented using only the Python bindings as a recipe I think I'd be fine, but if doing that means you might as well codify on the Rust side, happy for that also!

@kalzoo
Copy link
Contributor Author

kalzoo commented May 9, 2024

As much as I can read Rust, this is making sense to me. On the first point about a lurking bug, I wonder if impl SourceMapRange for CalibrationExpansion contains() is incomplete as containment is only by the index being valid, not that it also came from the same calibration? On the third point about reverse-mapping help, if I could see the pattern for this implemented using only the Python bindings as a recipe I think I'd be fine, but if doing that means you might as well codify on the Rust side, happy for that also!

Hey, thanks @mhodson-rigetti . I've added python bindings and docs but no example (yet) - I'll have to think about how best to expose those direct mappings from source to target. Right now (following our chat a week ago) I made the structure recursive to model the recursive nature of calibration expansion, and that's the least intuitive part in practical use, because each level of expansion has its own index basis. There's an example describing that problem in the python docstrings now.

You were right about SourceMapRange for exactly that reason - two ranges might not share the same basis and the SourceMapRange doesn't have enough information to make that clear. I just removed SourceMapRange altogether in this iteration, I don't think this functionality needs to be so general-purpose just yet.

The lingering bug though was actually fixed in this commit and was something prompted by our chat a week ago - certain instructions being yoinked out of the instruction body and thus (necessarily) out the source map.

})
}
}

impl CalibrationSignature for Calibration {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

From @MarquessV : this should not be needed anymore if the CalibrationIdentifier can serve as the signature

Copy link
Contributor Author

Choose a reason for hiding this comment

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

On second thought here, CalibrationSignature still seems necessary for CalibrationSet to be generic, and it seems to make sense for impl CalibrationSignature for Calibration to defer to impl CalibrationSignature for CalibrationIdentifier. I have a feeling I'm being dense about this so lmk if you see otherwise

Copy link
Contributor

Choose a reason for hiding this comment

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

That is true, I don't know of a good way to make the generics work with just a CalibrationIdentifier struct. I agree that it makes sense to formalize the concept of a calibration identifier w/ a struct and defer to it to implement the signature logic. They serve different purposes, so it's not entirely redundant, and the core logic doesn't need to be duplicated between either, so I'm good with this.

quil-rs/src/instruction/calibration.rs Show resolved Hide resolved
Comment on lines +329 to +331
expansion_output
.detail
.remove_target_index(relative_target_index);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This feels very clumsy and likely inefficient, but no more clever way occurred to me. Suggestions welcome.

Basically, we determine whether an instruction is a "header" or "body" instruction by whether or not it made the instruction body longer, and then based on that we excise it from the source mapping so that the target_index ranges stay accurate and don't map to a missing instruction

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't necessarily have a better idea for this, but an observation that the distinction between "header" and "body" instruction comes up enough that maybe we should have a more concrete definition of it in the library? Like a method in Instruction maybe? I know it's not a part of the spec, but it is a practical one that this library makes decisions around.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think @MarquessV is exactly right; this seems like it's going to be much cleaner if it never gets added than if it gets added and deleted. In particular, if the calibration expansion can check if something is a "header" instruction, then it can refrain from adding it to the expanded source, and then we don't need to do this fixup.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

if the calibration expansion can check if something is a "header" instruction, then it can refrain from adding it to the expanded source, and then we don't need to do this fixup.

I don't see how this is possible. When the instruction is expanded, it does not have the scope of a program, and so it is just expanded into a Vec<Instruction>. However, when we add that Vec to a program here, then some of its members are "hoisted" into data structures like memory region declarations and calibration declarations and such. Expanding the calibration in such a way that "predicted" how the program would handle it, without the use of an actual program, is something that could fall out of sync and requires extra care and maintenance.

Copy link
Contributor

Choose a reason for hiding this comment

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

It's statically knowable which instructions get hoisted and which ones don't: Instruction::CalibrationDefinition does, Instruction::Gate doesn't, etc. Expansion could return two vectors: Vec<HoistedInstruction> and Vec<ComputationInstruction> (new types optional, but perhaps clearer). Then anything working on the results of expansion wouldn't have to do its own classification.

quil-py/src/program/source_map.rs Outdated Show resolved Hide resolved
quil-py/src/program/source_map.rs Show resolved Hide resolved
quil-rs/src/program/calibration.rs Outdated Show resolved Hide resolved
Comment on lines +329 to +331
expansion_output
.detail
.remove_target_index(relative_target_index);
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't necessarily have a better idea for this, but an observation that the distinction between "header" and "body" instruction comes up enough that maybe we should have a more concrete definition of it in the library? Like a method in Instruction maybe? I know it's not a part of the spec, but it is a practical one that this library makes decisions around.

Copy link
Contributor

@antalsz antalsz left a comment

Choose a reason for hiding this comment

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

Looks overall good to me, but I think some details need to be hashed out before we merge this

quil-rs/src/program/source_map.rs Outdated Show resolved Hide resolved
quil-rs/src/program/source_map.rs Show resolved Hide resolved
quil-rs/src/program/mod.rs Outdated Show resolved Hide resolved
quil-rs/src/program/mod.rs Outdated Show resolved Hide resolved
quil-rs/src/program/mod.rs Show resolved Hide resolved
quil-rs/src/program/mod.rs Outdated Show resolved Hide resolved
quil-rs/src/program/mod.rs Outdated Show resolved Hide resolved
quil-rs/src/program/mod.rs Outdated Show resolved Hide resolved
Comment on lines +329 to +331
expansion_output
.detail
.remove_target_index(relative_target_index);
Copy link
Contributor

Choose a reason for hiding this comment

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

I think @MarquessV is exactly right; this seems like it's going to be much cleaner if it never gets added than if it gets added and deleted. In particular, if the calibration expansion can check if something is a "header" instruction, then it can refrain from adding it to the expanded source, and then we don't need to do this fixup.

@@ -693,14 +773,38 @@ impl ops::AddAssign<Program> for Program {
}
}

type InstructionIndex = usize;
Copy link
Contributor

Choose a reason for hiding this comment

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

Are we sure we want this to be a type synonym and not a newtype? It's probably fine but I figure it's worth being explicit about that choice

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 realize I had missed this comment on the last round, but it's a good idea. Changed that now.

Copy link
Contributor Author

@kalzoo kalzoo left a comment

Choose a reason for hiding this comment

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

Thanks for the review, @antalsz

quil-py/src/program/source_map.rs Show resolved Hide resolved
})
}
}

impl CalibrationSignature for Calibration {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

On second thought here, CalibrationSignature still seems necessary for CalibrationSet to be generic, and it seems to make sense for impl CalibrationSignature for Calibration to defer to impl CalibrationSignature for CalibrationIdentifier. I have a feeling I'm being dense about this so lmk if you see otherwise

quil-rs/src/instruction/calibration.rs Show resolved Hide resolved
quil-rs/src/instruction/calibration.rs Outdated Show resolved Hide resolved
quil-rs/src/program/calibration.rs Outdated Show resolved Hide resolved
quil-rs/src/program/calibration.rs Show resolved Hide resolved
quil-rs/src/program/calibration.rs Outdated Show resolved Hide resolved
quil-rs/src/program/calibration.rs Show resolved Hide resolved
quil-rs/src/program/calibration.rs Outdated Show resolved Hide resolved
Copy link
Contributor

@antalsz antalsz left a comment

Choose a reason for hiding this comment

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

These changes look good, thanks! I have a few open threads that weren't addressed, but none I'd consider blockers (except for this one new comment). However, the discussion around header/body instruction distinction should wrap up before we merge this.

quil-rs/src/instruction/calibration.rs Show resolved Hide resolved
pub(crate) fn remove_target_index(&mut self, target_index: usize) {
eprintln!("Removing target index {} from\n{self:#?}", target_index);

// Adjust the start of the range if the target index is within the range
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
// Adjust the start of the range if the target index is within the range
// Adjust the start of the range if the target index is before the range

quil-rs/src/instruction/calibration.rs Show resolved Hide resolved
quil-rs/src/program/calibration.rs Outdated Show resolved Hide resolved
quil-rs/src/program/calibration.rs Show resolved Hide resolved
Comment on lines +329 to +331
expansion_output
.detail
.remove_target_index(relative_target_index);
Copy link
Contributor

Choose a reason for hiding this comment

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

It's statically knowable which instructions get hoisted and which ones don't: Instruction::CalibrationDefinition does, Instruction::Gate doesn't, etc. Expansion could return two vectors: Vec<HoistedInstruction> and Vec<ComputationInstruction> (new types optional, but perhaps clearer). Then anything working on the results of expansion wouldn't have to do its own classification.

Copy link
Contributor

@antalsz antalsz left a comment

Choose a reason for hiding this comment

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

As I said in my last review – now that I understand the negation change, I would not consider any of my remaining comments blocking. Approved!

Copy link
Contributor

@antalsz antalsz left a comment

Choose a reason for hiding this comment

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

Reviewed 5d31908; a couple minor thoughts about the explanation, but the overall concept is great.

Comment on lines 29 to 41
# This is what we expect the expanded program to be. X and Y have been replaced by Z
expected_program_text = inspect.cleandoc(
\"\"\"
DEFCAL X 0:
Y 0

DEFCAL Y 0:
Z 0

Z 0 # This instruction is index 0
Z 0 # This instruction is index 1
\"\"\"
)
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this ought to have an assert. That raises questions of the precise whitespace, I know. Could we parse them both to Quil and assert the parsed programs are equal?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

the test did, I left it out here for readability but have restored it now

Comment on lines 43 to 44
# In order to discover _which_ calibration led to each Z in the resulting program, we
# can interrogate the expansion source mapping:
Copy link
Contributor

Choose a reason for hiding this comment

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

I like this section a lot, but I found the specific comments a bit confusing. Perhaps we could revise this first comment to something like

"…can interrogate the expansion source mapping. For instance, The X at index 0 should have been replaced with a Z at index 0. Here's how we could confirm that: First, we…".

As it stands, the comment on line 46 made me think that we were also going to check the Y at index 1. (I think adding that would probably be redundant.)

Another possibility would be to drop the ellipses on lines 51 and 63; they're not following on from a previous sentence.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yep, fixed

quil-py/quil/program/__init__.pyi Outdated Show resolved Hide resolved

## Source Mapping for Calibration Expansion

```py
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this snippet run and tested? It would be nice if it were but I don't know enough about Python to tell if that's happening/how easy it would be to have that happen.

Copy link
Contributor Author

@kalzoo kalzoo Jul 29, 2024

Choose a reason for hiding this comment

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

yes, it's a test case, although not tested in situ unfortunately

Copy link
Contributor

Choose a reason for hiding this comment

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

Can we add a pointer to the test case it corresponds to?

@bramathon
Copy link
Collaborator

Just wanted to check on the status of this - it's important for pulse plotting to work.

@bramathon
Copy link
Collaborator

I'm a little surprised by the extent of the changes in this PR. I was expecting something more along the lines of this:

from quil.program import Program
from typing import Tuple, List

def expand_with_source_mapping(program: Program) -> Tuple[Program, List[int]]:
    """
    Expand any instructions in the program which have a matching calibration, leaving the others
    unchanged. 

    :param program: A quil.Program.
    :return: A quil.Program with the instructions expanded and a source map.
    The source map is a list with the length of the expanded instructions, and indicates the index
    of the logical instruction in the original program from which the expanded instruction originated.
    """
    instructions = program.body_instructions
    calibrations = program.calibrations
    expanded_program = program.clone_without_body_instructions()

    source_map = []
    expanded_instructions = []
    for logical_index, inst in enumerate(instructions):
        expanded_instruction = calibrations.expand(inst, [])
        source_map += [logical_index]*len(expanded_instruction)
        expanded_instructions += expanded_instruction
    expanded_program.add_instructions(expanded_instructions)
    return expanded_program, source_map

expanded_program, source_map = expand_with_source_mapping(quil_program)
default_expanded_program = quil_program.expand_calibrations()
assert expanded_program == default_expanded_program

@kalzoo
Copy link
Contributor Author

kalzoo commented Oct 11, 2024

I'm a little surprised by the extent of the changes in this PR. I was expecting something more along the lines of this:

from quil.program import Program
from typing import Tuple, List

def expand_with_source_mapping(program: Program) -> Tuple[Program, List[int]]:
    """
    Expand any instructions in the program which have a matching calibration, leaving the others
    unchanged. 

    :param program: A quil.Program.
    :return: A quil.Program with the instructions expanded and a source map.
    The source map is a list with the length of the expanded instructions, and indicates the index
    of the logical instruction in the original program from which the expanded instruction originated.
    """
    instructions = program.body_instructions
    calibrations = program.calibrations
    expanded_program = program.clone_without_body_instructions()

    source_map = []
    expanded_instructions = []
    for logical_index, inst in enumerate(instructions):
        expanded_instruction = calibrations.expand(inst, [])
        source_map += [logical_index]*len(expanded_instruction)
        expanded_instructions += expanded_instruction
    expanded_program.add_instructions(expanded_instructions)
    return expanded_program, source_map

expanded_program, source_map = expand_with_source_mapping(quil_program)
default_expanded_program = quil_program.expand_calibrations()
assert expanded_program == default_expanded_program

@bramathon that snippet doesn't:

  • break out recursive calibrations
  • capture which calibration was used to do the expansion

Some of the diff, also, improves how calibrations are handled and matched

@bramathon
Copy link
Collaborator

bramathon commented Oct 11, 2024 via email

@kalzoo kalzoo merged commit 9256b95 into main Oct 12, 2024
15 checks passed
@kalzoo kalzoo deleted the 366-source-mapping branch October 12, 2024 01:18
@kalzoo
Copy link
Contributor Author

kalzoo commented Oct 12, 2024

Over to you now @bramathon

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Expand calibrations with source mapping
5 participants