Skip to content

Parallel calibration - #48

Open
ch-ahindura wants to merge 38 commits into
sopherapps:mainfrom
ch-ahindura:parallel-calibration
Open

Parallel calibration#48
ch-ahindura wants to merge 38 commits into
sopherapps:mainfrom
ch-ahindura:parallel-calibration

Conversation

@ch-ahindura

Copy link
Copy Markdown
Collaborator

Why

For time gains on large chips

Added

  • qpi-driver/py: parallel in calibration.yml groups a routine's targets into sets that
    can be measured at once, coloured from the coupling graph — qubit_spacing,
    edge_spacing, max_group, exclude, or explicit groups. Off unless the file says so,
    and a walk with no readable coupling graph runs one target at a time rather than guessing.
  • qpi-driver/py: every routine in the graph can measure a group in one schedule, so a
    group costs one arm-and-wait cycle instead of one per target. Each target reads its own
    acquisition channel, and a group whose instruments cannot play it at once — readout clocks
    outside one LO band, amplitudes that would clip, more clocks than sequencers — is split,
    with the measured figure and the ceiling in the message.
  • qpi-driver/py: parallel.measure_penalty benchmarks each target alone as well as in
    company and reports parallel_penalty per target — the fidelity the group cost it, which
    is what a tighter qubit_spacing has to be earned with. Off by default: it doubles what
    the benchmarks cost.
  • qpi-driver/py: make bench-parallel reports what grouping saves on a 3-qubit,
    2-coupler chain — 52 acquisitions sequentially against 37 grouped. Skipped unless
    QPI_BENCH=1, so a normal run does not pay for it.
  • docs: RFC 0009 — Parallel Calibration. Why a group is a colouring of the coupling graph
    rather than a hand-written list, why concurrent submission to one cluster cannot work, and
    what licenses a tighter spacing.

Changed

  • qpi-driver/py: a two-qubit routine resets and excites both of an edge's qubits at the
    same time rather than one after the other, which halves the reset every CZ sweep waits
    through. This applies to a single edge too, not only to a group.

Fixed

  • qpi-driver/py, qpi-ui: the calibration graph draws the node a walk is on. A progress
    event only fired after a target finished, so a single-target node went straight from
    pending to done and the running style was unreachable; a node whose every target was
    blocked stayed pending for the whole run.
  • qpi-driver/py: a routine's swept setpoints belong to the target rather than to the
    routine. They were kept on the routine and read back in analyse, so a routine measuring
    several targets in one schedule would have fitted every one against whichever target built
    last — a plausible curve against the wrong axis, not an error.
  • qpi-driver/py: check schedules are compiled once per scheduler rather than once against
    both. The only such compile in the suite needed both installed, so under the per-extra CI
    matrix it ran nowhere.

Ten passages named "the August 2026 chip" and two scoped a general design
fact to "this chip". The configs those references came from —
quantify.device.yml, quantify.hardware.yml, calibration.yml — are all
gitignored, so a reader of the repo cannot see the device being appealed to
and cannot check the claim.

The rationale is worth keeping: these RFCs exist because a real bring-up
found the gaps, and 0007 and 0008 say so by design. What changes is the
referent. A run is nameable and its evidence is in this changelog; a device
nobody outside the lab can identify is not. So "the August 2026 chip"
becomes "the August 2026 bring-up", and the four places where that shifts
the grammar move to the past tense they should always have had — it
describes what one run did, not standing behaviour.

Two scoping fixes are not renames. 0005 §13's "what it is worth on this
chip" is about the simulator, three lines above "this simulator does not
model that", and now says so. 0007's decision table meant "on a given
chip", which is the point it was making about load-bearing config.

Every number stays, f01: 4735509751.238763 included: nine significant
figures for a line that was never there is the whole argument of that
passage.

The 0.4.2 changelog line is a released entry, edited only because "this
chip's qubit" has no antecedent anywhere in it.
The nav stopped at 0005, so 0006, 0007 and 0008 have been unreachable from
the documentation site since each was written — present in docs/rfcs and in
the RFC index, absent from the sidebar. mkdocs builds without complaint
because an unlisted page is legal, which is why this went unnoticed.
The walk is `for routine, for target`, so a full calibration costs
routines x targets and is linear in qubit count. Grouped it costs
routines x groups, and the group count is a property of the connectivity
graph rather than of its size: every topology in the RFC's table needs two
groups for its single-qubit routines whether it carries five qubits or five
hundred. That is the argument, and it is why the RFC quotes N/Gq rather
than a speedup figure.

Three decisions carry most of the design.

Parallelism is fusion into one schedule, never concurrent submission.
Investigating the alternative changed this argument rather than confirming
it. Two independently compiled schedules do NOT collide on sequencer
indices — those come from the hardware config's port-clock ordering and are
stable per port-clock — so an earlier draft's reason was wrong, and §2 says
so. What actually rules it out is that ClusterComponent.start() opens by
disarming every sequencer in the cluster so its no-argument
start_sequencer() fires only what it just armed; starting one group
therefore disarms another mid-flight, and wait_done and
retrieve_acquisition are cluster-wide the same way. Narrowing stop() to the
modules in the program is possible and is noted as an independent latency
aside, but it cannot help: the groups worth running at once share a
feedline and so one module, and concurrent is not simultaneous, which is
what a crosstalk-sensitive measurement needs.

Demultiplexing happens in the DAG, so analyse never learns it ran in
company. signal_of reads data_vars[0], so a fused dataset would feed one
target's trace to every fit; slicing per channel in the walk leaves
thirty-three analyse implementations and every fit untouched. The
acquisition channel is the target's position in its group, passed
explicitly — the pattern executors/quantify/conv.py already uses — because
acq_channel is 0 on every element by default and fusion must not mutate the
device to work around that.

Grouping needs the coupling graph, not chip geometry, so no layout file is
introduced here. Physical distance is the wrong metric: two qubits close
together with no coupler between them are farther apart for this purpose
than two adjacent ones. The layout work is left to its own RFC, and the one
thing recorded is that nothing here depends on it.

Phase 1 fixes a defect in 0006 found while writing this. The calibration
graph has a `running` style and a legend entry that no walk has ever
reached: progress events fire after a target finishes, so a single-target
node's first event already has Done >= Total and settles straight to
`done`. The repo's own reducer tests encode this without naming it — the
test for `running` needs a three-target node to reach the state at all —
and a bring-up calibrating one qubit at a time gives every node one target.
A node whose every target is blocked never reports at all and stays
`pending` for the rest of the run.

Grouping is a colouring of a conflict graph whose radius is a parameter set
by measurement, following Kelly on the DAG, Gambetta on simultaneous
randomized benchmarking as the acceptance test, Murali on characterise-then-
serialise, ISCA 2025 on parallel graph traversal, and Heinsoo on what makes
a shared feedline workable. The simulator can validate the crosstalk
detector for a pair, since MAX_ENTANGLED is 3, and can never license a
radius, because the coefficient would be one this project chose.

Quantities are stated per topology throughout. Nothing is derived from a
particular device: the configs that would supply such numbers are
gitignored, and the RFC names the field a value is computed from instead.
RFC 0009 phase 1. The graph has had a `running` style and a legend entry since
RFC 0006 that no walk ever reached.

A progress event fired only after a target finished, so a node with one target
had Done >= Total on its first event and settled straight to `done`. A bring-up
calibrating one qubit at a time gives every node one target, so on that run the
state was unreachable everywhere. With several targets it was merely late: the
node stayed `pending`, indistinguishable from "not its turn", for the whole of
its first target.

The driver now reports before the work as well as after, carrying the targets it
is about to measure, and the server treats an event with `running` as a start
that advances no tally. Both events carry the same running totals, because the
server reads whether a target failed from the difference against the last
event's `failed` — a start reporting zeroes would make the next finish look like
a failure.

A target skipped for an unsatisfied prerequisite now reports too. It emitted
nothing at all, so a node whose every target was blocked was drawn `pending` for
the rest of the walk. Counting a skip as `done` would claim it measured
something, so the event carries a cumulative `skipped` and the node settles to a
new `blocked` state, or `partial` for a mixture. Failure outranks a skip: a node
with one of each has something to investigate.

The in-flight targets are drawn under the node and named in the progress line,
which is what the dashboard needed to say *which* components are being
calibrated rather than only that some are.

A driver predating this sends no `running` key and reduces exactly as before.
RFC 0009 phase 2. Computed and published only — the walk still runs one target
at a time, so this is provable by unit test before anything depends on it being
right.

Conflict is proximity in the coupling graph, which the device's edges already
give; chip geometry does not come into it, since two qubits close together with
no coupler between them are farther apart for this purpose than two adjacent
ones. `parallel` in calibration.yml sets the radius — qubit_spacing,
edge_spacing, max_group, exclude — or names groups outright for a chip whose
measured crosstalk does not follow its topology. Off unless the file says so,
which is the opposite of `routines` and deliberate: defaulting it to on would
silently change how every existing chip calibrates.

Unifying a qubit with an edge as its endpoint set is what lets one distance rule
serve both, so a CZ conflicts with whatever either of its qubits conflicts with.
The search is breadth-first bounded by the spacing, because the question is never
how far apart two targets are, only whether they are far enough.

Two things measured rather than assumed. The default spacing gives two groups on
a chain, a square lattice and heavy-hex alike, at every size tested — which is
the claim the RFC rests on, that the group count follows the topology and not the
qubit count. And greedy colouring is *not* optimal at spacing 3: the five-colour
Lee tiling is the optimum for an infinite lattice and greedy reaches six or
seven. That costs runtime, never correctness, and the default is where greedy
provably cannot do worse. RFC §5.5 and D2 are corrected to say so; they had
quoted the optimum as though it were the result.

Writing the config validation found a real hole: `groups: []` was coalesced to
`{}` by a falsy default and silently accepted rather than rejected. Both list
settings are now type-checked before any coalescing.

grouping.py joins the gated coverage floor at 96% and sits at 100%. It qualifies
where the routines do not — pure functions over a config, no instrument behind
them. The one thing that reads a device is outputs_of, which reads the
connectivity as an iterable of pairs and returns nothing when it cannot, the
convention has_flux_port already uses.
RFC 0009 phase 3 — the fusion mechanism, and the routines that need only the hook.

The walk now iterates groups rather than targets. A group of one, or a routine
that has not opted in, goes through _run_one exactly as before, so every
unconverted routine behaves identically; a fused group is one build, one
arm-and-wait cycle, then one fit per target from its own channel of the returned
dataset. No routine's `analyse` changed, which is the point of demultiplexing in
the walk instead: thirty-three fits reading the wrong qubit's data is thirty-three
chances to write a plausible wrong number to a device.

`add_together` is the whole of "aligning the schedules", and it lives inside one.
`schedule.add` appends, so without it a group's resets and pulses play one after
another and a fused schedule is exactly as long as the sequential run it replaces.
The anchor it returns is the longest operation of its stage, so a following stage
cannot start before every target has finished — which matters when a group's
targets carry different pulse or readout durations.

Groups are narrowed at run time to what the instruments can play at once, per
output, since targets behind different outputs share no LO and no DAC. The message
names the measured figure and the ceiling.

Two things measured while building it, both recorded in the RFC.

Fourteen routines override `measure`, not the eleven the RFC claimed — that count
came from a grep that truncated.

More importantly, a routine's swept setpoints are per-*routine* state:
build_schedule writes self._frequencies and analyse reads it back. Any sweep
centred on a per-qubit value has a different grid per target, so fusing it would
fit every target against the last one's grid — a confident wrong answer of exactly
the kind this graph is scarred by. So eight routines can be converted as they
stand and ten more need their setpoints moved onto the target first. That
reordering is now phase 4's real content, ahead of the escalation loop.

`allxy` and `allxy_check` are converted here; the mechanism is proven by a walk
whose three targets each have a different correct answer, so a demultiplexer wired
to channel zero reports 10, 10, 10 and fails. The test harness's schedule
stand-in now tracks when each operation starts, so alignment is asserted on the
schedule's own timing rather than on the arguments it was built with.

fusion.py joins the gated coverage floor: 98%, with grouping.py at 100%.
RFC 0009 phase 3, continued. `readout_discrimination` and `readout_fidelity` are
the same shape as the AllXY pair — a fixed preparation, no swept setpoints — so
they need only the hook. Each target keeps its own operating point, since the
frequency and amplitude a cloud was fitted at are properties of that readout and
not of the group.

Both take single shots rather than averages, so a fused group multiplexes four
distributions off one feedline instead of measuring them one at a time. They are
also the two nodes phase 6 compares fused against isolated, which is what turns
`qubit_spacing` from a default into a measurement.
Three of `TestTheHook` built their device with `SimulatedTuner`, which constructs a
`TransmonSimulator` and so imports `scqubits`. That lives in the `sim` extra, and
`test-py-driver` installs one executor extra and never `sim` — so under every leg of
that matrix these failed on the import rather than on anything they assert. Nothing in
them runs physics: they check which acquisition channel each target lands on, and that
the resets coincide.

The same breakage `TestATwoDimensionalGridIsSplitByRows` had, and the same fix: a
`FakeDevice` of `FakeElement`s and a `StubBackend`, both already used across this
suite. They now run under all four extras instead of none.

Worth knowing for the next routine converted to a group schedule: `SimulatedTuner` is
only reachable from `test_calibration_loop.py`, which is the one file `test-py-loop`
runs with `--extra sim`. Anywhere else it is an import error waiting for CI.

Verified at this HEAD, not at mine: `lint-py`, all four `test-py-driver` legs
(796/800/898/873 passed), and `test-py-loop` under quantify and qblox.
RFC 0009 phase 4, first half: the plumbing, with no behaviour change.

A routine records what it swept on itself — `self._frequencies` written in
`build_schedule`, read back in `analyse`. That is correct while a routine runs
one target at a time and latently wrong once it runs several: a sweep centred on
a per-qubit value has a different grid per target, so every fit in a fused group
would read whichever target happened to build last. Not an exception — a
plausible curve fitted against the wrong axis, which is the failure this graph is
most scarred by.

So a `Sweep` belongs to the target, and it is threaded from the build to the fit
rather than kept on the routine. Keyed by axis name because that is how
`escalating` reaches it: a guard refuses an axis by name and the retry has to
widen that one and no other.

This commit only carries the object through — every routine still writes to
`self._*`, so the walk behaves exactly as before and the suite is unchanged.
Migrating the state is the next commit, and separating them keeps a 750-line
mechanical diff away from the one that can change a measurement.

`build_group_schedule` takes one sweep per target rather than one for the group,
since that is the whole point of it.
RFC 0009 phase 4, second half. The plumbing landed in the previous commit; this
moves the state onto it.

Thirty-one setpoint attributes across the graph moved from `self._<axis>` to
`sweep["<axis>"]`, and with them the two places that read them by name from
outside: `_widened`, which stretches the axis a refusal names, and the
repetition ladder that shortens one. Both took the routine and reached into it;
both now take the target's sweep.

The bug this closes is not reachable from a sequential walk, which is why the
suite is unchanged: one target at a time, `self._frequencies` is that target's.
Fused, it is whichever target built last, and every other fit in the group is
then run against an axis that was not swept for it. A frequency sweep centred on
a per-qubit value differs per target, so the failure is a fitted number rather
than an exception.

`test_every_swept_axis_is_readable_from_outside` held the old convention — that
each axis is reachable as `_<axis>` — and is now checked against the sweep's
keys. It is gated on quantify-scheduler and so skipped locally; the same
assertion was run against the qblox tuner to confirm every routine records every
axis it sweeps.

Two smaller things this surfaced. `Ramsey._resolved_root` and
`QubitSpectroscopy._search` build and fit within one call and so needed the
sweep threaded through; and several tests were reaching into routine privates to
assert what had been swept, which now read the sweep instead.
RFC 0009 phase 4 completed. `escalating` handled one target; over a group the
common case — nothing refuses — should be one acquisition for all of them, and
the interesting case is what happens when one does.

Widening for the group is wrong: it re-sweeps the satisfied targets over a range
chosen for a different qubit, and `Rabi` already documents the cost of sweeping
further than needed. So `escalating_group` widens for the refused target alone
and measures only those again. A widening therefore splits the group, since a
fused schedule needs one grid — but targets that refuse the same axis by the same
factor are re-fused together, which is one acquisition rather than one each.

That last part needed the grouping key to be the config's contents rather than
its identity: `_widened` returns a fresh object per call, so two qubits asking for
exactly the same wider sweep were being measured one after the other. A test
caught it.

Bounded exactly as the single-target loop is, per subset: MAX_ESCALATIONS
attempts, an axis the operator named left alone, and a widening that cannot move
re-raises. Each target's outcome is its own, so one qubit that never fits does
not cost the group its results.

`measure_group` is the hook, with a behaviour-preserving default that declines a
group and delegates a group of one — the same shape as `build_group_schedule`.
`t1` is converted as the first of the eleven that measure themselves: its delays
are one grid for the group, since an idle is dead time on every port at once.
RFC 0009 §10.1 gates the modules it adds at 96%. `grouping.py` and `fusion.py`
were already in; `sweep.py` arrived with phase 4 and was not.

Writing its tests found three dead methods — `__iter__`, `__len__` and
`setdefault`. Nothing iterates a Sweep: every `for x in sweep[...]` in the graph
iterates the setpoint list an axis holds, not the axes. Deleted rather than
covered.

All three modules are now at 100%.
Phases 1-4's mechanisms are in; 5 of 33 routines are converted and phases 5-6
are open. The status line said Draft, which understated it, and $9 said nothing
about where the work had got to.
Two things installing the quantify and qblox extras together turned up.

`_span_of` in the linewidth tests still took the routine after phase 4 moved the
setpoints onto the sweep. Those five tests are gated on quantify-scheduler and so
had never run here; with the extra installed they fail. The rest of that file's
seven failures are pre-existing at 3b66dbf — quantify-scheduler 0.28 dropped
`QuantumDevice.elements` — and are untouched here.

The second is a real hole in phase 2. `groups_for` reads the coupling graph from
the device's edges and falls back to the configured ones, and with neither there
is no adjacency at all. An empty adjacency does not mean nothing is adjacent: it
means nothing is known, and the colouring read it as the former and put every
qubit in one group. That is `qubit_spacing: 1` — the setting §5.6 says a chip has
to earn — selected by accident, on the configuration most likely to have it
wrong. A walk with more than one target and no readable coupling now runs them one
at a time and logs which key to set.

That fallback is what keeps the quantify drift from mattering: `edge_names`
returns nothing under 0.28, so grouping would have been working from an empty
graph on every quantify chip.
RFC 0009 D7, which the earlier phases had not needed: every routine fused so far
sweeps the same grid on every target — a fixed gate sequence, or a linear grid off
the config — so nothing had yet asked to be split.

`t2_echo` asks. Its delay window is scaled from each qubit's measured T1, and an
idle is dead time on every port at once, so there is no per-target time axis to
give two qubits that relax at different rates. Fusing them anyway would sweep one
over the other's window and fit the result.

`compatible_groups` is the hook, defaulting to a single group. It returns
subgroups rather than isolating the outlier, so on a chip where three qubits agree
and one does not, the three are still measured together. An operator who names
`delays` puts every target back in one group, which is what naming an axis means.

Also narrows the guard added in the previous commit: an absent coupling graph only
matters when the spacing depends on adjacency. At `qubit_spacing: 1` nothing is
ever too close, so the operator has asked for the whole chip at once and there is
nothing for a missing graph to get wrong.

Suite is at the 7 failures 3b66dbf already had — quantify-scheduler 0.28 dropped
`QuantumDevice.elements` — and no others.
Two more of the eleven that measure themselves, both through
`escalating_group`, so a group that all fits is one acquisition and a target
whose fit is refused is widened alone.

`rabi` needs `compatible_groups`: its grid is half of the element's own full
scale, so two elements with different ceilings want different sweeps. `drag`
does not — its span comes from the backend, since the DRAG parameter's units
differ between the two schedulers, so it is the same on every target of a given
chip and the group never splits.

The grid-partitioning that `t2_echo` introduced is now `grouped_by_grid` in
fusion.py, since this is the third routine to want it.

Verified against the physics simulator, which fits both from integrated dynamics
rather than from synthetic curves. Suite is at 3b66dbf's 7 failures and no
others.
The phase 4 migration claimed to have moved every per-target value off the
routines and had not. It was driven by a grep for `self\._[a-z_]+ =`, which does
not match a digit, so five attributes went straight past it:

  _current_amp180  the amplitude fine_amplitude refines from
  _current_amp90   the same for fine_amplitude_90
  _current_f01     the frequency ramsey refines from
  _current_f12     the same for ramsey_12
  _f01             the centre f12_spectroscopy searches around

Every one is written in the build and read in `analyse`, which is exactly the
shape the Sweep exists to prevent: fused, each target's fit would have used the
last target's frequency or amplitude and produced a plausible number.

None of the five is reachable from a sequential walk, and none of the affected
routines is fusable yet, so nothing was miscalibrated — but `fine_amplitude` and
`ramsey` are next in the queue and would have carried it in.

A test now walks the routine modules and fails on any `self._x =` in them, since
a grep is the only check that scales here and the narrow one is what failed.
Both reach their acquisition through `amplified` rather than `escalating`
directly, so this adds the group counterpart of it. The argument is the same one
`escalating_group` makes, running downward: how far a ladder can be amplified
depends on how large the error turns out to be, which is per target, so
shortening the group would cut the ladders that were fine — and a shorter ladder
measures a smaller error less precisely. A shortening splits the group, and
targets asking for the same ladder are measured together.

A target that runs out of ladder keeps its prior and reports, which is what
`amplified` already does and for the same reason: the finding is real and the
correction is not.

Two small helpers came out of writing the second one — `_readout` for the
per-channel measurement list, and `_add_references` for the |0> and |1> points
both fits need.

Neither routine needs `compatible_groups`: the counts come from the config or a
constant, not from the chip.
`rb` is the most expensive node in the graph, so fusing it is where the largest
single saving is. It is also the one conversion that changes what is being
measured, and in the direction §5.6 needs: the same Clifford sequences played on
every target at once *is* the simultaneous RB of Gambetta et al., so the
difference between a fused fidelity and an isolated one is the addressability.
One seed for the group, so the per-target numbers are comparable with each other
and with a sequential run.

`interleaved_rb` comes with it, since it subclasses RB — but not for free. Its
CZ needs each edge's endpoints resolved first, and the Cliffords are played on
each edge's control, so it resolves the pair per target and delegates with the
controls as the group. Within a group the controls are distinct, because
`edge_spacing` will not put two edges sharing a qubit in one group.

The group sequence player needed its own interleave hook rather than reusing the
single-target one: `schedule.schedulables` is a mapping, not a list, so taking
`[-1]` from it to find the anchor raised `KeyError: -1`. Returning the operations
instead lets them be placed at one start time, which is what a fused CZ needs
anyway.

Also moves the per-channel readout list into fusion.py as `readouts`, since this
is the third module to build one, rather than importing a private helper across
modules.
…used

Converting `ramsey`'s schedule made it `fusable`, and the dispatch then took the
fused path for it: one acquisition, one fit, and none of the loop in `measure`.
That loop refines f01 over several passes and applies to the device between them,
so fusing on the schedule alone would have silently dropped every refinement — a
worse outcome than not fusing at all, and invisible except as a slightly-off
frequency.

So a routine that measures itself groups only once it has a `measure_group`, not
merely a `build_group_schedule`. That is the honest condition: the group loop is
where the per-target escalation lives, and there is nothing else for the fused
path to run it from.

`ramsey`'s schedule is converted and is used for a group of one; its loop is left
for a later commit, so it still walks one target at a time. The guard is what
makes that a safe intermediate state rather than a regression, and it protects
every later conversion from the same mistake.
`readouts` and `grouped_by_grid` left one branch of `_duration_of` unreached — the
form where an operation states its duration as an attribute rather than in kwargs.
That branch is what makes `add_together` return the *longest* operation, so a
following stage cannot begin before every target has finished, which matters when
a group's targets have different pulse or readout lengths.

All three gated modules are at 100% again.
Fourteen routines override `measure`, not eleven. The original figure came from a
grep that truncated, and §2 has since been corrected; §6.5's heading and the
status paragraph still carried the old number, and the paragraph's list of what
remained was wrong with it.

The status now names what is left by name rather than by count, since two of them
— `coupler_anticrossing` and `qubit_spectroscopy` — are ones §6.5 argues should
stay sequential, so "four remaining" overstated the work and understated the
reasoning.
RFC 0009 phase 5, for the two nodes that write the CZ. `edge_spacing` and the
edge colouring were already in from phase 2; this is the fusion side of it.

Both are structurally the same sweep — prepare |11>, play the pulse, read the
parent — so the preparation and the per-edge readout are shared helpers. What
makes the grid shareable is that each edge's pulse is on its own hardware: a
baseband pulse on the control's own `q<n>:fl` for the chevron, a virtual-Z on the
measured qubit for the phase. Two edges cannot contend there, and that is only
guaranteed because `edge_spacing` will not group edges that share a qubit.

`conditional_phase` needed more than the shared helper: it reads the parent on one
pass and the child on the next, so the qubit measured varies within the sweep
while the acquisition channel stays the edge's. The roles are resolved per pass
and the measurement follows them.

Note for anyone re-running the suite: `uv run` re-syncs and drops the quantify,
qblox and sim extras, which silently returns 1043 tests instead of 1209 with the
scheduler-gated ones skipped. `uv run --no-sync` after an explicit `uv sync
--extra ...` is what keeps them.
RFC 0009 phase 6 — the measurement that turns `qubit_spacing` from a guess into a
setting. It is Gambetta et al.'s: benchmark each qubit alone, benchmark them
simultaneously, and the difference in average gate fidelity is the addressability.
Both halves are runs the walk can already do, so the whole of it is arithmetic on
two reports.

`parallel.measure_penalty` turns it on. The group runs first, then the same
targets one at a time into a throwaway report, and the difference lands on the
grouped benchmark as `parallel_penalty`. The isolated pass is a control and its
rows are discarded: the fused numbers are the ones describing how the chip will
actually be driven. Off by default, because it doubles what the benchmarks cost
and it is not something every run needs.

No new node and no change to the graph's shape, which §11 asked for. Only a
benchmark gets it — there is no fidelity to difference otherwise.

The simulator half is `CoupledTransmons.zz_mhz`, zero by default: a diagonal term
shifting each qubit's frequency in proportion to the other's excitation, so a
phase calibrated with the neighbour in |0> is wrong with it in |1>. Crosstalk that
costs phase rather than population, which is why a routine measuring one qubit at
a time cannot see it at all. The tests assert it is absent by default, present and
proportional when set, and diagonal — and assert nothing about which spacing is
safe, because that coefficient is one this project chose and `MAX_ENTANGLED` caps
a joint register at three. That line is D10's.
Finishes phase 5. Every edge routine but `coupler_anticrossing` now groups, and
that one should not: its loop sets a DC bias between acquisitions, which §6.5 gives
as one of the two cases that stay sequential.

`cz_spectroscopy` needed a looser rule than D7 as it was implemented. Its band is
centred on each edge's own CZ clock, so no two edges sweep the same frequencies —
and the strict grid match would have split every group of them, which is most of
the value of fusing a spectroscopy at all. But the axis is per-target hardware:
each edge has its own clock, its own port and its own NCO, so at setpoint i each
can sit at its own frequency. Only the point count has to agree, because the
acquisition index is shared. `grouped_by_size` is that rule.

`cz_parametrization` keeps the strict one, and the contrast is the useful part: it
sweeps pulse *duration*, which is the schedule's own timeline rather than anything
per-target. A 300 ns pulse occupies 300 ns for every edge in the group.

Both bring their own clock resource per edge, since an edge's CZ clock is declared
inside the CZ gate's subschedule rather than by the device.
Phases 1, 2, 3, 5 and 6 are implemented. Phase 4's mechanism is too, and every
later phase is built on it; what remains is its tail — nineteen routines that do
not group, of which three are deliberate (`qubit_spectroscopy` and
`coupler_anticrossing` by §6.5, `ramsey` because its loop is unconverted) and
sixteen are plain build/analyse pairs needing nothing new.

Also corrects the RFC's own arithmetic. It said thirty-three routines throughout,
which was RFC 0005's count; the graph is now thirty-six, so §5.5's `Rq` moves from
27 to 30. The figures move as nodes are added, which is the reason §5.5 argues
about `N/Gq` and not about a total — now said out loud rather than implied.

D7 gained the distinction phase 5 forced: `grouped_by_grid` for a shared axis and
`grouped_by_size` for a per-target one.
The three resonator spectroscopies differ only in how the qubit is prepared before
it is read — not at all, an X, or an X and an EF pulse — so the sweep itself is one
shared helper taking a `prepare` callback. Each resonator keeps its own band on its
own readout clock, which `grouped_by_size` allows: a readout clock is per-target
hardware and every one of these sweeps is centred on its own resonance, so only the
point count has to agree.

`resonator_spectroscopy` is the graph's root and runs its own escalation, so it
gains `measure_group` as well.

One test stub needed widening: it returned a schedule whose `add` took only the
operation, and the fused path places operations against an anchor.

Note on the suite: `test_quantify_executor_execute_dummy` failed once under the
full run and passed both in isolation and on a re-run. It is an order-dependent
flake in the executor tests, not from this change — the traceback is the same
`QuantumDevice.elements` drift behind the seven pre-existing failures.
`readout_operating_point`, `three_state_operating_point` and
`three_state_discrimination`. All three read in single-shot, which is the reason
they cannot use the shared readout helper: the measurement carries a per-target
keyword — a drive amplitude for the operating points, the three-state readout
settings for discrimination — so each target's `Measure` is built individually and
placed against the group's anchor.

The two operating points sweep a (frequency, amplitude) grid derived from each
qubit's own resonance and pulse amplitude, so they group by grid size rather than
by value. `three_state_discrimination` has no sweep at all — three prepared levels,
the same three on every chip — so it never splits.

22 of 36 routines now group.
…ulses

`rabi_12` and `ef_ladder` group, off the amplitude grid they already share —
factored out as `ef_amplitude_grid`, since the point of `ef_ladder` is that the two
are sampled identically.

Converting them exposed a bug in the four EF routines fused earlier in this branch.
`add_ef_pulse` *appends*: it is a raw pulse on a per-target clock, not a gate, so
`for target in targets: add_ef_pulse(...)` queued them instead of playing them
together. That is not merely slower. The readout was anchored to whatever preceded
the pulses, so it stayed there while the pulses moved out behind it — and for the
later targets in a group the schedule read the qubit before driving it. Each
target's own sequence was still in order, so nothing raised; only the timings show
it.

`add_ef_pulse` now takes a reference and returns its schedulable, and
`add_ef_pulses` places one per target at the same start, returning the longest as
the anchor the readout follows. The four already-converted routines —
`resonator_spectroscopy_second_excited`, `three_state_operating_point`,
`three_state_discrimination` and `rabi_12` — use it.

A test asserts the invariant directly, on the compiled timings rather than on the
calls: every readout starts no earlier than the pulse it measures ends, and a
group's pulses coincide. Verified by neutering the alignment, which fails it.

24 of 36 routines now group.
`ramsey_12`, `drag_12` and `fine_amplitude_12`. `add_ef_pulses` gained shared
`phase_deg` and `drag`, since where those vary they are the swept axis and so the
same value on every target of a group. 27 of 36 routines now group.

Two things to record honestly.

**The two-qubit routines are not behaviour-preserving for a group of one, and my
earlier commits implied they were.** `prepare_11` aligns *both qubits of an edge*,
so a single edge's two resets and two X pulses now coincide where they used to
queue. That is an improvement — a chip resets both qubits at once, and the old
schedule waited through two 400 us resets for no reason — but it changes the
sequential walk's timing, not only a grouped walk's, and the phase 5 commit did not
say so.

**The simulated suite is ~50% slower**: 6m25s to 9m44s. Same cause. Simultaneous
pulses on a coupled pair put the simulator in the 9-dimensional joint register for
windows where it had been treating the two qubits independently, and
`MAX_ENTANGLED`'s own note explains what that costs. It is the simulator paying for
a more faithful schedule, not the chip, so it is worth the runtime — but a slower
pre-commit suite is a real cost and better named than discovered.

Verified against the physics tests that would catch a broken CZ: the conditional
phase, the Bell state's concurrence and leakage, and the chevron's crossing all
still hold. Suite is at 3b66dbf's 7 failures and no others.
…king

`time_of_flight`, `resonator_relaxation` (both through their shared trace base),
`resonator_punchout`, `flux_spectroscopy` and `f12_spectroscopy`.

Converting the 2-D ones exposed a bug this branch had already shipped. The fused
path called `build_group_schedule` and `run` directly, so it went straight past
`acquire` — which is where a sweep too large for one program is split. Four
already-fused routines were affected, and `rb` is the serious one: it chunks on the
shipped defaults, since ten circuits over the shipped depths is 1270 Cliffords
against the 1000 one schedule holds. Every grouped RB would have built a program
too long to assemble, which is the exact failure the chunking exists to prevent.

So the group path gets the same seam the single-target path has. `acquire_group`
defaults to build-and-run; `acquire_group_in_row_chunks` is the group counterpart
of the row splitter, and RB overrides `acquire_group` to split circuits. The budget
is unchanged because it was never per-schedule: the ceiling is a *sequencer's*
instruction count, and a fused schedule gives each target its own sequencer running
its own copy of the sweep, so how much one program holds does not depend on how many
targets are in it.

And a guard, so the next one cannot repeat it: a routine overriding `acquire`
without `acquire_group` is not fused. The same shape as the `measure_group` guard,
one seam down. A test asserts both — that a grouped RB still splits, and that an
unconverted chunker declines to group.
`readout_integration_time` groups, at one window for the whole group. Its axis is
the one axis that cannot be swept inside a schedule at all — every square
acquisition in a Qblox program shares an integration length — and that is also why
its grouping is strict: the length is a property of the program, not of a target,
so two targets wanting different windows are split apart exactly as two windows are
split into two schedules. `acquire_group` loops the windows for the group, which is
its single-target loop one level out.

That leaves 33 of 36 routines grouping. The three that do not are reasons rather
than gaps: `coupler_anticrossing` sets a DC bias between acquisitions,
`qubit_spectroscopy`'s next window depends on what the last one found — §6.5 gives
both as the cases that stay sequential — and `ramsey`'s schedule is converted while
its multi-pass loop is not, which the `measure_group` guard keeps safe.

Also makes the "declines a group" test define its own unconverted routine instead of
naming a real one. It had pointed at `t1`, then `resonator_relaxation`, then
`readout_integration_time`, breaking each time the named routine was converted —
which is the test being about the wrong thing.

RFC 0009 moves to Implemented, with the honest qualifier: it has not run on
hardware. §5.6's measurement is what would close that, and D10 says why simulation
cannot.
§6.5 listed it as one of two routines that "stay sequential", on the grounds that
its next window depends on what the last one found. The dependency is real and the
conclusion does not follow: it is within one qubit, across passes — sweep the
configured window, and if no line is there, search wide and confirm around the line
that search found. Two qubits are independent of each other throughout.

That is the shape `escalating_group` already handles — run the stage for the group,
then the next stage for the subset that needs it — and a confirm window centred on
each qubit's own found line is a per-target frequency axis, so those still fuse
under `grouped_by_size`. The section had conflated "cannot be one schedule" with
"cannot be one group".

`coupler_anticrossing` is the one that genuinely stays sequential, and even there
the limit is the bias source rather than the schedule.

Raised in review. The status paragraph said the same thing and is corrected with it.
Follows the review point that corrected §6.5: `qubit_spectroscopy`'s passes depend
on each other, its qubits do not, and I had confused the two.

The three stages fuse and only the membership changes — the configured window for
the whole group, a wide search for the qubits whose line was not there, then a
confirming sweep for those. Stage 3 still fuses because a frequency axis is
per-target hardware, and each qubit's confirm centre rides on its own `Sweep`: a
`RoutineConfig` is one object for the group, so a shared `centre_frequency` could
only ever describe one of them, and that is precisely what would have forced the
confirm stage to run a qubit at a time.

Stage 2 stays per qubit. It is one acquisition each and only runs when a qubit is
not where the config says it is, so there is little to win and a per-target band to
keep simple. Said so in the docstring rather than left to be inferred.

Also closes an ordering hazard in the dispatch found while checking this one.
`measures_group` was tested before `chunks_acquisition`, so a routine with a group
loop but no `acquire_group` would have reached `_fused_pass` and through it the
unchunked default, with the guard never consulted. No routine is in that state —
every `acquire` override has a matching `acquire_group` — but the order made it
reachable, and a test now asserts it is not.

34 of 36 routines group. `coupler_anticrossing` should not; `ramsey`'s loop is the
one piece left.
… saves

Three things the review asked for, and two bugs found doing them.

**`ramsey` groups.** Every branch of its loop reads one qubit — its own residual,
its own floor from its own window, its own fringe sign, its own write-back. What
differs between qubits is only how many passes each needs, which is
`escalating_group`'s shape. A `settled` set does what `break` does in the
single-target loop, so a qubit whose detuning stopped falling is not measured again.
The sign resolution stays per target: it applies to the device between its own two
sweeps. That leaves only `coupler_anticrossing` sequential, and it must be.

**A benchmark, off by default.** `make bench-parallel` walks the whole graph twice
on a 3-qubit, 2-coupler chain and reports acquisitions — one arm-and-wait cycle
each, which is the quantity that matters, since a fused schedule's pulses are one
target's. 52 against 37, so 1.41x. It says in its own output why that is a floor:
only 20 of the graph's 102 routine-targets complete against the simulator, most of
the rest having no physics here. Skipped unless QPI_BENCH=1 so it costs a normal run
nothing.

**Two bugs it found, which no unit test had.**

The group row-chunker called `self.acquire_group` for the unchunked case — the very
override that had called it — and recursed until the stack gave out. Four routines
failed with `RecursionError` the moment a group reached them. The single-target
version has always run that case directly; this now does too.

And the simulated backend answered every schedule with one `y0` variable, ignoring
`acq_channel` entirely, so a fused walk silently lost every target but the first —
6 results where 20 were expected. It now splits a fused schedule back into one per
target and answers each as before, which leaves every acquisition's physics
untouched and is what makes a grouped routine checkable against real dynamics at
all. Both walks now produce the same 20 results, which is what makes the comparison
one measurement of two methods rather than two different runs.

Neither was reachable from the unit tests: they use stub backends, and the only
whole-graph simulator test targets a single qubit, so grouping never engaged.
RFC 0009 is implemented with 35 of 36 routines grouping; only
`coupler_anticrossing` runs one target at a time, and §6.5 says why it must.

§9 now carries the measured figure — 52 acquisitions sequentially against 37
grouped on a 3-qubit, 2-coupler chain — with what it is a floor of, and does not
present it as §5.5's arithmetic.

It also keeps both wrong claims visible rather than quietly deleting them. Twice I
argued a routine could not be grouped because each of its passes depends on the
last, which is true within one qubit and says nothing about two. Recording the shape
of the mistake is worth more than a corrected list, since the next adaptive routine
will look the same.

The benchmark gets a `workflow_dispatch` job so it can be asked for from the Actions
tab, gated on an input so a normal dispatch does not run it.
…exclusion

Every one of the graph's 36 routines now groups. Nothing is excluded on principle.

The claim that `coupler_anticrossing` could not be grouped rested on a wrong
premise about the hardware: "a chip has one bias source". It has one *rack*. An S4g
has four current outputs, a cluster has many baseband outputs, and each edge already
names its own — `bias.spi_module`/`bias.spi_output`, or the `qcm` pair. Setting a
group's currents is therefore one quick write per edge over the same serial port and
then a single acquisition, not one acquisition per coupler. What is sequential is one
coupler across its own current setpoints, which is where every other routine's
dependency lives too.

Both delivery mechanisms were already built and tested before RFC 0009 —
`SpiRackBias` driving an S4g through qcodes, `QcmBias` holding the same offset on a
baseband module output, chosen by `bias.source`, with per-edge wiring on the element
and coverage in tests/test_coupler_bias.py. So nothing needed implementing to group
this, and no new RFC: the gap was in my reasoning, not in the driver.

The refusal when no source can hold a current is now worded once and shared by both
paths — it is the case the node exists for, since a recorder makes the sweep flat and
the fit confident.

This is the third time I read a per-target sequential dependency as a group-wide one,
after `qubit_spectroscopy` and `ramsey`. §6.5 keeps all three, because the shape of
the mistake is more use than a corrected list.
Twenty-four entries had accumulated, most of them paragraphs, and together they
narrated the order I did the work in rather than its effect — five successive
entries counting 32, 33, 34, 35 and 36 of 36 routines converted. Nine entries now,
two sentences each, which is what the file's own entries are.

Most of what went was net-zero for a reader: bugs introduced and fixed inside this
same unreleased block — the grouped row-chunker's recursion, the queued EF pulses,
the chunking the fused path skipped, and the guards added against each. An operator
upgrading never sees any of them, and the reasoning is in the commits where it
belongs. The twelve per-routine conversion entries collapse into one, because "every
routine in the graph can measure a group" is the fact, and which order they arrived
in is not.

What stayed is the three pre-existing defects this branch actually fixes, the four
things it adds, and the one change to the sequential walk.
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.

1 participant