Skip to content

fix(visitor): pair register operands element by element when broadcasting a gate - #462

Open
ryanhill1 wants to merge 4 commits into
mainfrom
fix/register-broadcasting
Open

ryanhill1 wants to merge 4 commits into
mainfrom
fix/register-broadcasting

Conversation

@ryanhill1

@ryanhill1 ryanhill1 commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Closes #384

Problem

unroll() flattened every operand of a gate call into one list and chunked it by the gate's arity. With register operands this silently produced a different circuit:

Input Before After
cx a, b; (two qubit[2]) cx a[0], a[1]; cx b[0], b[1]; cx a[0], b[0]; cx a[1], b[1];
cx q[0:1], q[2:3]; cx q[0], q[1]; cx q[2], q[3]; cx q[0], q[2]; cx q[1], q[3];
let a = q[0:1]; let b = q[2:3]; cx a, b; same as above same as above
def f(qubit[2] a, qubit[2] b) { cx a, b; } f(r, s); cx r[0], r[1]; cx s[0], s[1]; cx r[0], s[0]; cx r[1], s[1];
ctrl @ x a, b; cx a[0], a[1]; cx a[0], b[0]; cx a[0], b[1]; cx a[0], b[0]; cx a[1], b[1];
cx q, r[0]; (qubit[3] q) ValidationError cx q[0], r[0]; cx q[1], r[0]; cx q[2], r[0];
spec g4 qr0[0], qr1, qr2[0], qr3; ValidationError three g4 calls, one per index of qr1/qr3

Change

_visit_generic_gate_operation now broadcasts first, in a new _broadcast_operands:

  • It resolves each operand on its own, keeping the operand boundaries the old path lost. Inside a subroutine it keeps the formal names, so the existing argument mapping still applies.
  • When a call has one operand per gate qubit (controls included) and at least one operand names several qubits, register operands pair by index and single qubits repeat. Each application then goes through the normal path, so modifiers, custom gates, external gates and subroutines need no other change.
  • Register operands of different lengths raise ValidationError: Cannot broadcast 'cx' over registers of different lengths [2, 3]. A slice or set counts as a register operand even when it names one qubit, so cx q[0:1], q[2:2] raises too. A bare qubit[1] register is still repeated like a single qubit, because the visitor rewrites qubit x to qubit[1] x in place and the two can't be told apart afterwards.
  • An empty operand (cx q[0:1], q[3:2]) raises ValidationError instead of IndexError.
  • Operand forms are checked syntactically first, so a call where every operand is a single indexed qubit (cx q[0], q[1]) skips the resolution. Unrolling 30k such gates takes 1.42s, against 1.37s on main.
  • Opaque gates broadcast like custom gates, since they are custom gates with empty bodies by this point. This fixes opaque g a, b; g q, r;, which paired wrongly on main.
  • Every other call keeps the existing behaviour. That includes calls with fewer operands than gate qubits (cx q2;, inv @ ccx q[0], q2;, ctrl (2) @ x a, b[0];, which existing tests rely on), the cx q[0], q[1], q[2], q[3] form, and non-constant modifier arguments. The issue left it to the maintainers whether to keep that register-fills-several-operands extension, so this PR leaves it alone.

Testing

  • New tests/qasm3/test_broadcast.py: two registers, slices, aliases, subroutine register arguments, swap, ctrl @, the mixed register/qubit form, the spec's g4 example, length mismatches (including a length-1 slice), empty operands, OpenQASM 2 registers, and duplicate qubits.
  • Full suite, after merging main: 1033 passed, 3 skipped. black, isort, pylint (10.00), headers pass. mypy reports only the typer/rich import errors that come from the tox environment lacking the CLI extra.
  • Also checked by hand: negctrl @, a parametrised custom two-qubit gate on registers, and cx kept with unroll(external_gates=["cx"]) all pair by index.

Found while testing qBraid/qBraid#1468, whose qasm3 -> qasm2 conversion currently detects register broadcasts and falls back to another path to avoid this bug.

…ting a gate

cx a, b over two registers unrolled to cx a[0], a[1]; cx b[0], b[1]
because every operand was flattened into one list and chunked by the
gate's arity. When a call has one operand per gate qubit, register
operands now pair by index and single qubits repeat, as the spec
defines. Slices, aliases, subroutine register arguments and ctrl @ take
the same path. Calls with fewer operands than gate qubits, such as
cx q2, keep filling operands from the register as before.

Closes #384
@argus-eye

argus-eye Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Argus review

Auto-review is off for this repo. Tick the box below to run a review on this PR.

Running Argus review...

Estimated cost

  • Files changed: 2
  • Diff lines (±): 187
  • Historical avg: ~347.1k tokens · ~$1.28 · across last 15 review(s)

Tip: you can also comment @argus-eye review at any time.

@coderabbitai

coderabbitai Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov-commenter

codecov-commenter commented Oct 23, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.80328% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/pyqasm/visitor.py 91.80% 5 Missing ⚠️

📢 Thoughts on this report? Let us know!

@argus-eye

This comment has been minimized.

@argus-eye argus-eye Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Argus · 9/10 — Empty broadcast operands raise IndexError

Check 1 warning before you merge. Argus reviewed 3 files.

What this PR does · 9 author checks
  • Fix gate broadcasting so register operands pair element by element and single-qubit operands repeat, rather than silently unrolling to a different circuit.
  • Excludes: Rejecting the existing register-fills-several-operands extension or the four-single-qubit cx calling form.
  • Excludes: Changing existing behavior for opaque gates or non-constant modifier arguments.

Author's checks

  • _visit_generic_gate_operation broadcasts first through _broadcast_operands, resolving operands separately and preserving operand boundaries.
  • Inside subroutines, operand resolution preserves formal names so existing argument mapping still applies.
  • Calls with one operand per gate qubit, including controls, pair register operands by index and repeat single-qubit operands.
  • Registers of different lengths raise ValidationError; for cx with lengths 2 and 3, report Cannot broadcast 'cx' over registers of different lengths [2, 3].
  • Each broadcast application uses the normal path, retaining support for modifiers, custom gates, external gates and subroutines.
  • Calls with fewer operands than gate qubits and the cx q[0], q[1], q[2], q[3] form retain existing behavior.
  • Opaque gates and non-constant modifier arguments retain existing behavior.
  • tests/qasm3/test_broadcast.py covers registers, slices, aliases, subroutine arguments, swap, ctrl @, mixed operands, g4 and length mismatch.
  • The spec's mixed-operand g4 example emits three applications, pairing qr1 and qr3 by index while repeating qr0[0] and qr2[0].
usage: 282.3k tokens · 17 stages

Contract: production/full · checked: bug_hunter, security, architecture, regression, single-pass review, ponytail-review · review took 2m44s

Stage Model Tokens Cost
Intent openai/gpt-6.1-sol 6.5k $0.0135
Triage openai/gpt-6.1-sol 6.4k $0.0070
Lead agent openai/gpt-6.1-sol 1.3k $0.0067
Review specialists (10) openai/gpt-6.1-sol 237.3k $0.5146
Review openai/gpt-6.1-sol 14.2k $0.0295
Acceptance openai/gpt-6.1-sol 984 $0.0026
Simulation openai/gpt-6.1-sol 12.6k $0.0363
Scoring openai/gpt-6.1-sol 3.0k $0.0038

Dashboard → · React 👎 on an inline comment to dismiss it · Reply to an inline comment or use @argus-eye help

🧠 Learned: 1 pattern · 1 PR summary · 1 finding memory · 1 file memory · …

Cross-Repo PR Coverage

No conflict found with qBraid/qBraid#1468.

Updated at 10:26 UTC on 2026-10-24

Comment thread src/pyqasm/visitor.py Outdated

@TheGupta2012 TheGupta2012 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Changes requested — The fix is correct: register operands now pair by index, and the output matches Qiskit's importer. Every multi-qubit gate now pays about 33% more unroll time, even when it has no register operand.

Findings

All findings are posted inline on the relevant lines. Checklist:

  • M1 — Broadcast check resolves every operand of every multi-qubit gate; unroll is ~33% slower
  • L1 — A length-1 register beside a longer register is accepted instead of raising
  • L2 — Opaque branch in _gate_qubit_count is unreachable; the docstring describes it wrongly
  • L3 — No test for OpenQASM 2 registers or for duplicate-qubit broadcast

How it was tested

  • Full suite on head: 1024 passed, 3 skipped. On main (a742796): 1015 passed, 3 skipped. 9 of the 10 new tests fail on main.
  • black, isort, pylint (10.00) and mypy are clean on head.
  • 28 edge-case programs ran on head and on main: registers, slices, sets, aliases, mixed register and qubit, ccx, custom and nested custom gates, pow/inv/ctrl/ctrl(2)/negctrl/ctrl(n) with a const n, loop indices, nested subroutines, mismatched lengths, duplicates, physical qubits, and the legacy cx q2; form.
  • Unitaries compared with qiskit.qasm3.loads (Operator.equiv): all 13 programs that Qiskit can load match, including the spec's g4 example.
  • OpenQASM 2 cx q, r; and opaque gates on registers paired wrongly on main. Both pair correctly now.
  • Benchmark, 30k cx q[i], q[j] in unroll(), best of 3: 1.11 s on head against 0.83 s on main.
  • All four suggestion blocks applied together: same suite results plus the 2 new tests, and the benchmark drops to 0.86 s.

Next steps

Address M1 before merge. L1–L3 are optional.

Comment thread src/pyqasm/visitor.py Outdated
Comment on lines +1629 to +1630
or self._scope_manager.in_gate_scope()
or len(operation.qubits) != self._gate_qubit_count(operation)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[M1] Broadcast check resolves every multi-qubit gate — Implementation · Medium

Rationale: _broadcast_operands runs for every gate with two or more operands. It calls _gate_qubit_count (a second map_qasm_op_to_callable). Then it runs _get_op_bits 1 + n times before it can tell that cx q[0], q[1] has nothing to broadcast. On 30k cx q[i], q[j], unroll() takes 1.11 s against 0.83 s on main, and validate() slows by about the same amount. Only a few operand forms can name several qubits: a bare identifier that is not $n, a set index, or a range index. One indexed expression always names one qubit. A syntactic check placed before the count check skips the resolution in the common case. With the change below, the suite, the edge-case programs and the Qiskit unitary comparisons give the same results, and the benchmark drops to 0.86 s. Declared single qubits (qubit x; cx x, y;) still take the slow path, because telling them apart from registers needs a scope lookup.

Change requested:

Suggested change
or self._scope_manager.in_gate_scope()
or len(operation.qubits) != self._gate_qubit_count(operation)
or self._scope_manager.in_gate_scope()
or not any(
(
isinstance(operand, qasm3_ast.Identifier)
and not operand.name.startswith(PHYSICAL_QUBIT_PREFIX)
)
or (
isinstance(operand, qasm3_ast.IndexedIdentifier)
and (
isinstance(operand.indices[0], qasm3_ast.DiscreteSet)
or isinstance(operand.indices[0][0], qasm3_ast.RangeDefinition)
)
)
for operand in operation.qubits
)
or len(operation.qubits) != self._gate_qubit_count(operation)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Applied in a6defcf, with your syntactic check moved into a small _is_register_operand helper, which L1 reuses. Every operand form is checked before _gate_qubit_count and before anything is resolved. A call whose operands are all single indexed qubits or physical qubits returns straight away.

Same benchmark, 30k cx q[i], q[j] in unroll(), best of 5 on this machine:

Version Time
main (dc1b320) 1.37s
previous head (840aed0) 1.86s
now 1.42s

Comment thread src/pyqasm/visitor.py Outdated
resolved.append(
self._get_op_bits(single, qubits=True, function_qubit_sizes=function_sizes)
)
lengths = {len(bits) for bits in resolved if len(bits) > 1}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[L1] A length-1 register is treated as a single qubit — Implementation · Low

Rationale: len(bits) > 1 decides from the resolved length whether an operand is a register, not from the operand itself. So qubit[3] q; qubit[1] r; cx q, r; gives cx q[0], r[0]; cx q[1], r[0]; cx q[2], r[0];, and cx q[0:1], q[2:2]; repeats q[2]. Neither raises. The spec requires broadcast registers to have the same length, and Qiskit's importer rejects the first program with mismatched lengths in gate broadcast. This is the one mismatched-length case the new check misses. Main also gave a wrong circuit here.

Change requested: decide "register operand" from the operand's form (a bare register name, alias, slice or set) and its declared type, not from its resolved length. Then apply the length check to every register operand. If this stays as a design choice, add one line saying so to the _broadcast_operands docstring.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Partly applied in a6defcf.

Now applied: slices and sets are register operands by their form. cx q[0:1], q[2:2] now raises different lengths [1, 2], and test_broadcast_length_one_slice_is_a_register covers it.

Kept as a documented choice: a bare qubit[1] r register. The register's declared type can't be relied on, because _visit_quantum_register rewrites qubit x; to qubit[1] x; in place on the caller's AST. After validate(), dumps() already prints qubit[1] x; cx x, q;, so a strict check would reject the reload of a valid program.

I tried two ways around that, and both broke existing behaviour:

  • Copying the declaration: remove_idle_qubits relies on the unrolled declaration being the same object as the module's.
  • Skipping the rewrite under check_only: this changes the echoed source in five declaration-error tests, and breaks populate_idle_qubits.

_is_register_operand's docstring now says this, and test_broadcast_single_qubit_repeats pins the qubit x behaviour. Stopping that in-place rewrite seems like its own change, so I can open an issue for it if you agree.

Comment thread src/pyqasm/visitor.py Outdated
Comment on lines +1659 to +1667
Returns None when it cannot be known up front (an opaque gate, or a non-constant
modifier argument), which leaves broadcasting to the existing path.
"""
name = operation.name.name
if name in self._custom_gates:
count = len(self._custom_gates[name].qubits)
elif name in self._opaque_gates:
return None
else:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[L2] Opaque branch is unreachable — Maintenance · Low

Rationale: rewrite_opaque_declarations (preprocess.py) rewrites each opaque into a gate definition with an empty body, so its name is always in self._custom_gates first. elif name in self._opaque_gates never runs. Opaque gates broadcast like custom gates (opaque g a, b; g q, r; gives g q[0], r[0]; g q[1], r[1];), which is correct. But the docstring and the PR description both say opaque gates keep the old path.

Change requested:

Suggested change
Returns None when it cannot be known up front (an opaque gate, or a non-constant
modifier argument), which leaves broadcasting to the existing path.
"""
name = operation.name.name
if name in self._custom_gates:
count = len(self._custom_gates[name].qubits)
elif name in self._opaque_gates:
return None
else:
Returns None when it cannot be known up front (a non-constant modifier argument),
which leaves broadcasting to the existing path. An ``opaque`` gate is a custom gate
with an empty body by now, so it broadcasts like one.
"""
name = operation.name.name
if name in self._custom_gates:
count = len(self._custom_gates[name].qubits)
else:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Applied in a6defcf: I removed the branch and used your docstring. The PR description now says that opaque gates broadcast like custom gates, which fixes the opaque g a, b; g q, r; pairing.

Comment on lines +93 to +94
with pytest.raises(ValidationError, match="different lengths"):
loads(qasm3_string).unroll()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[L3] Missing OpenQASM 2 and duplicate-qubit tests — Implementation · Low

Rationale: OpenQASM 2 cx q, r; gave cx q[0], q[1]; cx r[0], r[1]; on main, and this PR fixes it, but no test covers it. cx q, q; now raises Duplicate qubit through the broadcast path, and nothing tests that either. Both tests below pass on head.

Change requested:

Suggested change
with pytest.raises(ValidationError, match="different lengths"):
loads(qasm3_string).unroll()
with pytest.raises(ValidationError, match="different lengths"):
loads(qasm3_string).unroll()
def test_broadcast_qasm2_registers():
"""OpenQASM 2 pairs register operands the same way."""
result = loads('OPENQASM 2.0;\ninclude "qelib1.inc";\nqreg q[2];\nqreg r[2];\ncx q, r;\n')
result.unroll()
gates = [line for line in dumps(result).splitlines() if line.startswith("cx")]
assert gates == ["cx q[0], r[0];", "cx q[1], r[1];"]
def test_broadcast_duplicate_qubit_raises():
"""Broadcasting a register against itself repeats a qubit within one gate."""
qasm3_string = 'OPENQASM 3.0;\ninclude "stdgates.inc";\nqubit[2] q;\ncx q, q;\n'
with pytest.raises(ValidationError, match="Duplicate qubit"):
loads(qasm3_string).unroll()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added both in a6defcf, plus three more:

  • test_broadcast_length_one_slice_is_a_register
  • test_broadcast_empty_operand_raises
  • test_broadcast_single_qubit_repeats

After merging main, the suite is at 1033 passed, 3 skipped, and format-check is clean.

…eat slices as registers

Address review on #462:

- Check operand forms before counting gate qubits or resolving operands. An
  indexed operand without a slice or set names one qubit, so plain
  `cx q[0], q[1]` no longer resolves every operand twice (30k-gate unroll:
  1.86s -> 1.42s; main 1.37s).
- A slice or set is a register operand even when it names one qubit, so
  `cx q[0:1], q[2:2]` raises a length mismatch instead of repeating `q[2]`.
  A bare length-1 register is still repeated, because the visitor rewrites
  `qubit x` to `qubit[1] x` in place.
- Raise a QASM error for an empty broadcast operand instead of an IndexError.
- Drop the unreachable opaque branch in `_gate_qubit_count`; opaque gates are
  custom gates with empty bodies by then.
- Test OpenQASM 2 registers, duplicate qubits, length-1 slices, empty
  operands and single-qubit repetition.

This branch has not been deployed

No deployments
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.

Gate broadcasting over multiple registers unrolls to the wrong circuit

3 participants