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

Multi-target gate circuit art improvement #2185

Open
wants to merge 7 commits into
base: main
Choose a base branch
from

Conversation

Morcifer
Copy link
Contributor

@Morcifer Morcifer commented Feb 15, 2025

This PR has a proposed fix for issue #2184, but it mainly includes refactoring of the circuit-art code based on comments from PR #2126.

The only real functionality change in this code is that multi-target gates get extra lines that behave in the same way as measurement lines, in order to better convey the qubits involved.

For example, a circuit like this one:

use q0 = Qubit();
use q1 = Qubit();
Rxx(1.0, q0, q1);

use q2 = Qubit();
use q3 = Qubit();
Rxx(1.0, q2, q3);

instead of rendering as

q_0    ─ rxx(1.0000) ─
q_1    ─ rxx(1.0000) ─
q_2    ─ rxx(1.0000) ─
q_3    ─ rxx(1.0000) ─

will instead render as

q_0    ─ rxx(1.0000) ─
              ┆
q_1    ─ rxx(1.0000) ─
q_2    ─ rxx(1.0000) ─
              ┆
q_3    ─ rxx(1.0000) ─

This is very similar to what we get if we measure the first qubit of each of these two-qubit gate, i.e.

q_0    ─ rxx(1.0000) ─── M ──
              ┆          ╘═══
q_1    ─ rxx(1.0000) ────────
q_2    ─ rxx(1.0000) ─── M ──
              ┆          ╘═══
q_3    ─ rxx(1.0000) ────────

On top of this change in rendering, the first few commits in this PR are actually just code refactoring. As such, it's probably best to review this PR commit by commit, for two reason - a. it's going to be much easier to follow, and b. any of these commits can be reverted if you think they make the code more complicated instead of making it easier to read.

The commits are as follows:

  1. a400655 A rework of PR Long gate in ASCII art circuits - lengthen column width when necessary #2126, which, to be honest, is how I think I should have implemented it in the first place.
  2. c141799 A cleanup based on this comment. It makes the code significantly better, in my opinion.
  3. 4012ee8 A small simplification to have the ColumnRenderer have a new method which is in charge of making sure that the column width is an odd number, and also a Default trait implementation for the case where we don't need a specific width.
  4. cfa7382 This is the actual enhancement - it makes sure there is a classical line (same as what you get from a measurement) if there are two consecutive qubits for the same operation. If you think there is a better solution to make the rendering clearer, let me know. But of the ones I could think of, I found this one to be the least disruptive one (to both the existing code, and the existing circuits).

let next_qubit = qubit + 1;

// Check if the next qubit is also in this operation.
if operation.targets.iter().any(|t| t.q_id == next_qubit) {
Copy link
Contributor Author

@Morcifer Morcifer Feb 15, 2025

Choose a reason for hiding this comment

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

Technically, in terms of pure complexity, it would make more sense to make a hashsets out of these ids, inside the loop of line 336, and use it in this line (with contains(&next_qubit)).

However, given the overhead of creating hashsets, and moreover, the relatively slow performance of hashsets in Rust (even FxHashSet, from my experience), I'm almost sure that this loop is faster for gates with a small number of target qubits.

But if it's actually common for these gates to have a large number of targets, I can easily replace this bit of code and use a FxHashSet here, just let me know.

Copy link
Member

Choose a reason for hiding this comment

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

Agree with the reasoning. It would have to be a custom intrinsic in Q# that takes a very large number of qubits, and I just don't expect to see that in practice.

@Morcifer Morcifer marked this pull request as ready for review February 15, 2025 12:47
Copy link
Member

@minestarks minestarks left a comment

Choose a reason for hiding this comment

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

Thank you for the fix and additional test cases. Just a few requests for readability.

@@ -325,6 +330,26 @@ impl Display for Circuit {
// to row in the diagram
let mut register_to_row = FxHashMap::default();

// Keep track of which qubits have the qubit after them in the same multi-qubit operation.
let mut consequtive_qubits = FxHashSet::default();
Copy link
Member

Choose a reason for hiding this comment

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

Minor typo (consecutive).

But actually consider renaming this. I spent some time scratching my head over how this could possibly work, because I thought the set literally contained some consecutive qubit IDs (e.g. 2, 3, 4, 5) 😄

How about qubits_with_gap_row_below? Not a pretty name, but at least it's less likely to send the reader on a wild goose chase.

end_column: usize,
) -> std::fmt::Result {
// Temporary string so we can trim whitespace at the end
let default_renderer = ColumnRenderer::default();
Copy link
Member

Choose a reason for hiding this comment

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

The comment above this line got pushed up - it's supposed to refer to this line below this.

However I think you can just use unwrap_or_default below instead of declaring a default here. It's not going to incur an extra allocation or anything like that (since the struct is just an int anyway)

Copy link
Contributor Author

@Morcifer Morcifer Feb 19, 2025

Choose a reason for hiding this comment

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

unwrap_or_default doesn't work, because column_renderers.get(&column).unwrap() is a &ColumnRenderer, while the default part of unwrap_or_default returns an actual ColumnRenderer.

And doing something like .unwrap_or(&ColumnRenderer::default()) also doesn't work because of temporary value dropped while borrowed, because I can't reference something made inside this.

Rust is fighting against me here. Can you think of another way to do it that won't end up moving (or needing to clone) the renderer we get from column_renderers?

Copy link
Member

Choose a reason for hiding this comment

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

Oh, interesting. I would just add #[derive(Clone, Copy)] on the struct to stop "fighting" rust. It's really not a big deal cloning this object since it's simply a usize.

Copy link
Contributor Author

@Morcifer Morcifer Feb 19, 2025

Choose a reason for hiding this comment

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

The solution to the ColumnWidthsByColumn naming question (in the latest commit) ended up solving this, as well as simplifying this code quite a lot (especially with clippy's suggestion of using enumerate rather than keep the for loop in this method).

Let me know what you think.

@@ -96,7 +96,7 @@ impl Config {
}

type ObjectsByColumn = FxHashMap<usize, CircuitObject>;
type ColumnWidthsByColumn = FxHashMap<usize, usize>;
type ColumnWidthsByColumn = FxHashMap<usize, ColumnRenderer>;
Copy link
Member

Choose a reason for hiding this comment

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

I like how you organized this. I just think we can simplify the names.

Suggested change
type ColumnWidthsByColumn = FxHashMap<usize, ColumnRenderer>;
type FullColumns = FxHashMap<usize, Column>;

That's what the new type is... it's just the column! No need to overthink it imo.

And now we can think of the hashmap as containing only the columns that have objects in them, so I suggested a name that reflects that.

Copy link
Contributor Author

@Morcifer Morcifer Feb 19, 2025

Choose a reason for hiding this comment

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

I'm not 100% sure I agree with this:

My thinking is that the "ByColumn" postfix is consistent with ObjectsByColumn in the row above, because both are a hashmap with the key being the column index.

And the ColumnRenderer itself is a struct that takes circuit objects and turns them into ASCII art, so calling it a Column sounds a bit confusing to me.

Not to mention how, if you agree with my point about ByColumn but don't agree with this point, then we'll alias this hashmap as ColumnByColumn, and that's just silly. 😄

Having said that, I will of course rename both of these if you still think your suggested names are better.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Actually, I realize now that I can resolve most of this comment and the other open one pretty easily, it's an oversight on my side - this alias isn't necessary, because we don't actually need a hashmap for this. Let me try it out...

You'll still have to decide between Column and ColumnRenderer, though.

Copy link
Member

Choose a reason for hiding this comment

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

Okay, short answer: Totally agree with you on the bit where you say it would be inconsistent with ObjectsByColumn map right above this - I hadn't noticed that. Consistency trumps all, so let's stick with your version. Maybe consider ColumnWidth instead of ColumnRenderer.


Long answer: humor me for a second while I try to explain what I originally had in mind. Naming discussions tend to get pretty philosophical.

  • "column index": it's just a number and that's what the original hashmap index usize refers to.
  • "column": that's the struct that describes the "physical" column in the diagram, so to speak. It contains attributes of the column, such as its width.

Personally I'm biased against struct names like "Something-Doer" because I prefer structs to have names that describe what the thing is, not what it does.

I guess then the natural conclusion would have been to rename these to ObjectsByColumnIndex and ColumnsByColumnIndex ... and then rename all the other variables that refer to columns below to column_index ... and I mean... that is getting into silly territory.

Copy link
Contributor Author

@Morcifer Morcifer Feb 19, 2025

Choose a reason for hiding this comment

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

Yeah, that works - please look at the latest commit. Basically, since the column renderers are made by iterating over 0..end_column, they can just be put in a vector. That means there's no need for this hashmap at all.

I can still rename the ColumnRenderer to Column, though then the enumeration in lines 192 and 200 will be over (column_index, column) instead of the current (column, renderer). Not sure what's clearer in this case.

let next_qubit = qubit + 1;

// Check if the next qubit is also in this operation.
if operation.targets.iter().any(|t| t.q_id == next_qubit) {
Copy link
Member

Choose a reason for hiding this comment

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

Agree with the reasoning. It would have to be a custom intrinsic in Q# that takes a very large number of qubits, and I just don't expect to see that in practice.

@@ -335,7 +360,15 @@ impl Display for Circuit {

register_to_row.insert((q.id, None), rows.len() - 1);

for i in 0..q.num_children {
// If this qubit has no children, but it is in a multi-qubit operation with
// the next qubit, we add one classical row for clarity.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
// the next qubit, we add one classical row for clarity.
// the next qubit, we add an empty row to make room for the vertical connector.
// We can just use a classical wire type for this row since the wire won't actually be rendered.

I now see how you're exploiting the "classical" wire type to create an empty row. I think that's fine (no need to overengineer it and add a 3rd wire type) but it's subtle enough that I think it warrants an explanatory comment.

@minestarks
Copy link
Member

Heads up to @ScottCarda-MS who I think has been working near this code.

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.

2 participants