Parallel calibration - #48
Open
ch-ahindura wants to merge 38 commits into
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
For time gains on large chips
Added
qpi-driver/py:parallelincalibration.ymlgroups a routine's targets into sets thatcan be measured at once, coloured from the coupling graph —
qubit_spacing,edge_spacing,max_group,exclude, or explicitgroups. 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 agroup 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_penaltybenchmarks each target alone as well as incompany and reports
parallel_penaltyper target — the fidelity the group cost it, whichis what a tighter
qubit_spacinghas to be earned with. Off by default: it doubles whatthe benchmarks cost.
qpi-driver/py:make bench-parallelreports 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 graphrather 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 thesame 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 progressevent only fired after a target finished, so a single-target node went straight from
pendingtodoneand therunningstyle was unreachable; a node whose every target wasblocked stayed
pendingfor the whole run.qpi-driver/py: a routine's swept setpoints belong to the target rather than to theroutine. They were kept on the routine and read back in
analyse, so a routine measuringseveral 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 againstboth. The only such compile in the suite needed both installed, so under the per-extra CI
matrix it ran nowhere.