Skip to content

Commit b739944

Browse files
Convert Sabre's lookahead to layer-based tracking (#14911)
* Convert Sabre's `lookahead` to layer-based tracking The previous Sabre extended set was just the "next N" 2q gates topologically on from the front layer, where Qiskit reliably used `N = 20` ever since its introduction. For small-width circuits (as were common when the original Sabre paper was written, and when it was first implemented in Qiskit), this could mean the extended set was reliably several layers deep. This could also be the case for star-like circuits. For the wider circuits in use now, at the 100q order of magnitude, the 20-gate limit reliably means that denser circuits cannot have their entire next layer considered by the lookahead set. This commit modifies the lookahead heuristic to be based specifically on layers. This regularises much of the structure of the heuristic with respect to circuit and target topology; we reliably "look ahead" by the same "distance" as far as routing is concerned. It comes with the additional benefits: - we can use the same `Layer` structure for both the front layer and the lookahead layers, which reduces the amount of scoring code - the lookahead score of a swap can now affect at most two gates per layer, just like the front-layer scoring, and we can do this statically without loops - we no longer risk "biasing" the lookahead heuristic in either case of long chains of dependent gates (e.g. a gate that has 10 predecessors weights the score the same as a gate with only 1) or wide circuits (some qubits have their next layer counted in the score, but others don't because the extended set reached capacity). - applying a swap to the lookahead now has a time complexity that is constant per layer, regardless of the number of gates stored in it, whereas previously it was proportional to the number of gates stored (and the implementation in the parent of this commit is proportional to the number of qubits in the circuit). This change alone is mostly a set up, which enables further computational complexity improvements by modifying the lookahead layers in place after a gate routes, rather than rebuilding them from scratch, and subsequently only updating _swap scores_ based on routing changes, rather than recalculating all from scratch. * Update documentation * Add release note * Apply suggestion from @alexanderivrii Co-authored-by: Alexander Ivrii <alexi@il.ibm.com> --------- Co-authored-by: Alexander Ivrii <alexi@il.ibm.com>
1 parent f8e543f commit b739944

12 files changed

Lines changed: 192 additions & 214 deletions

File tree

crates/cext/src/transpiler/passes/sabre_layout.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,13 @@ pub unsafe extern "C" fn qk_transpiler_pass_standalone_sabre_layout(
120120
1.0,
121121
heuristic::SetScaling::Constant,
122122
)),
123-
Some(heuristic::LookaheadHeuristic::new(
124-
0.5,
125-
20,
126-
heuristic::SetScaling::Size,
127-
)),
123+
Some(
124+
heuristic::LookaheadHeuristic::new(
125+
vec![0.5 / target.num_qubits.unwrap_or(20) as f64],
126+
heuristic::SetScaling::Constant,
127+
)
128+
.expect("number of layers should be valid"),
129+
),
128130
Some(heuristic::DecayHeuristic::new(0.001, 5)),
129131
Some(10 * target.num_qubits.unwrap() as usize),
130132
1e-10,

crates/transpiler/src/passes/sabre/heuristic.rs

Lines changed: 50 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -79,43 +79,60 @@ impl BasicHeuristic {
7979
}
8080
}
8181

82-
/// Define the characteristics of the lookahead heuristic. This is a sum of the physical distances
83-
/// of every gate in the lookahead set, which is gates immediately after the front layer.
82+
/// Define the characteristics of the lookahead heuristic.
8483
#[pyclass(module = "qiskit._accelerate.sabre", frozen, from_py_object)]
85-
#[derive(Clone, Copy, PartialEq, Debug)]
84+
#[derive(Clone, PartialEq, Debug)]
8685
pub struct LookaheadHeuristic {
87-
/// The relative weight of this heuristic. Typically this is defined relative to the
88-
/// :class:`.BasicHeuristic`, which generally has its weight set to 1.0.
89-
pub weight: f64,
90-
/// Number of gates to consider in the heuristic.
91-
pub size: usize,
92-
/// Dynamic scaling of the heuristic weight depending on the lookahead set.
86+
/// The relative weights of each sequential layer in this heuristic. Typically each of these is
87+
/// defined relative to the :class:`.BasicHeuristic`, which generally has its weight set to 1.0.
88+
weights: Vec<f64>,
89+
/// Dynamic scaling of the heuristic weight depending on the size of each layer.
9390
pub scale: SetScaling,
9491
}
95-
impl_intopyobject_for_copy_pyclass!(LookaheadHeuristic);
92+
impl LookaheadHeuristic {
93+
/// Construct a new lookahead heuristic.
94+
///
95+
/// Fails if `weights` has $2^{16}$ or more elements (these are layers - 65,536 is too many!).
96+
pub fn new(weights: Vec<f64>, scale: SetScaling) -> Option<Self> {
97+
let _: u16 = weights.len().try_into().ok()?;
98+
Some(Self { weights, scale })
99+
}
100+
101+
pub fn num_layers(&self) -> u16 {
102+
self.weights
103+
.len()
104+
.try_into()
105+
.expect("constructor enforces sufficiently few layers")
106+
}
107+
108+
#[inline]
109+
pub fn weights(&self) -> &[f64] {
110+
&self.weights
111+
}
112+
}
96113
#[pymethods]
97114
impl LookaheadHeuristic {
98115
#[new]
99-
pub fn new(weight: f64, size: usize, scale: SetScaling) -> Self {
100-
Self {
101-
weight,
102-
size,
103-
scale,
104-
}
116+
pub fn py_new(weights: Vec<f64>, scale: SetScaling) -> PyResult<Self> {
117+
Self::new(weights, scale)
118+
.ok_or_else(|| PyValueError::new_err("must have fewer than 65,536 layers"))
105119
}
106120

107121
pub fn __getnewargs__(&self, py: Python) -> PyResult<Py<PyAny>> {
108-
(self.weight, self.size, self.scale).into_py_any(py)
122+
(self.weights.as_slice().into_pyobject(py)?, self.scale).into_py_any(py)
109123
}
110124

111125
pub fn __eq__(&self, py: Python, other: Py<PyAny>) -> bool {
112126
other.extract::<Self>(py).is_ok_and(|other| self == &other)
113127
}
114128

115129
pub fn __repr__(&self, py: Python) -> PyResult<Py<PyAny>> {
116-
let fmt = "LookaheadHeuristic(weight={!r}, size={!r}, scale={!r})";
130+
let fmt = "LookaheadHeuristic(weights={!r}, scale={!r})";
117131
PyString::new(py, fmt)
118-
.call_method1("format", (self.weight, self.size, self.scale))?
132+
.call_method1(
133+
"format",
134+
(self.weights.as_slice().into_pyobject(py)?, self.scale),
135+
)?
119136
.into_py_any(py)
120137
}
121138
}
@@ -159,6 +176,14 @@ impl DecayHeuristic {
159176

160177
/// A complete description of the heuristic that Sabre will use. See the individual elements for a
161178
/// greater description.
179+
///
180+
/// .. note::
181+
///
182+
/// This is an internal Qiskit object, not a formal part of the API. You can use this for
183+
/// fine-grained control over the Sabre heuristic, including for research, but beware that the
184+
/// available options and configuration of it may change without warning in minor versions of
185+
/// Qiskit. If you are doing research using this, be sure to pin the version of Qiskit in your
186+
/// requirements.
162187
#[pyclass(module = "qiskit._accelerate.sabre", frozen, eq, skip_from_py_object)]
163188
#[derive(Clone, Debug, PartialEq)]
164189
pub struct Heuristic {
@@ -205,7 +230,7 @@ impl Heuristic {
205230
pub fn __getnewargs__(&self, py: Python) -> PyResult<Py<PyAny>> {
206231
(
207232
self.basic,
208-
self.lookahead,
233+
self.lookahead.clone(),
209234
self.decay,
210235
self.attempt_limit,
211236
self.best_epsilon,
@@ -223,15 +248,11 @@ impl Heuristic {
223248
}
224249
}
225250

226-
/// Set the weight and extended-set size of the ``lookahead`` heuristic. The weight here
227-
/// should typically be less than that of ``basic``.
228-
pub fn with_lookahead(&self, weight: f64, size: usize, scale: SetScaling) -> Self {
251+
/// Set the layer weights of the ``lookahead`` heuristic. The weight here should typically be
252+
/// less than that of ``basic``. The number of weights dictates the number of layers.
253+
pub fn with_lookahead(&self, weights: Vec<f64>, scale: SetScaling) -> Self {
229254
Self {
230-
lookahead: Some(LookaheadHeuristic {
231-
weight,
232-
size,
233-
scale,
234-
}),
255+
lookahead: Some(LookaheadHeuristic { weights, scale }),
235256
..self.clone()
236257
}
237258
}
@@ -256,7 +277,7 @@ impl Heuristic {
256277
"format",
257278
(
258279
self.basic,
259-
self.lookahead,
280+
self.lookahead.clone(),
260281
self.decay,
261282
self.attempt_limit,
262283
self.best_epsilon,

crates/transpiler/src/passes/sabre/layer.rs

Lines changed: 28 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -18,29 +18,32 @@ use qiskit_circuit::PhysicalQubit;
1818

1919
use super::vec_map::VecMap;
2020

21-
/// A container for the current non-routable parts of the front layer. This only ever holds
22-
/// two-qubit gates; the only reason a 0q- or 1q operation can be unroutable is because it has an
23-
/// unsatisfied 2q predecessor, which disqualifies it from being in the front layer.
21+
/// A container for 2q gates in a layer that are yet to be routed.
22+
///
23+
/// The graph nodes in this structure always refer to `TwoQ` entries, since `Synchronize` nodes do
24+
/// not impact the scoring; they only affect when nodes are eligible to move forwards a layer, or to
25+
/// become routed.
2426
///
2527
/// It would be more algorithmically natural for this struct to work in terms of virtual qubits,
2628
/// because then a swap insertion would not change the data contained. However, for each swap we
2729
/// insert, we score tens or hundreds, yet the subsequent update only affects two qubits. This
2830
/// makes it more efficient to do everything in terms of physical qubits, so the conversion between
2931
/// physical and virtual qubits via the layout happens once per inserted swap and on layer
3032
/// extension, not for every swap trialled.
31-
pub struct FrontLayer {
33+
#[derive(Clone, Debug)]
34+
pub struct Layer {
3235
/// Map of the (index to the) node to the qubits it acts on.
3336
nodes: IndexMap<NodeIndex, [PhysicalQubit; 2], ::ahash::RandomState>,
3437
/// Map of each qubit to the node that acts on it and the other qubit that node acts on, if this
3538
/// qubit is active (otherwise `None`).
3639
qubits: VecMap<PhysicalQubit, Option<(NodeIndex, PhysicalQubit)>>,
3740
}
3841

39-
impl FrontLayer {
42+
impl Layer {
4043
pub fn new(num_qubits: u32) -> Self {
41-
FrontLayer {
42-
// This is the maximum capacity of the front layer, since each qubit must be one of a
43-
// pair, and can only have one gate in the layer.
44+
Layer {
45+
// This is the maximum capacity of the layer, since each qubit must be one of a pair,
46+
// and can only have one gate in the layer.
4447
nodes: IndexMap::with_capacity_and_hasher(
4548
num_qubits as usize / 2,
4649
::ahash::RandomState::default(),
@@ -60,15 +63,15 @@ impl FrontLayer {
6063
&self.qubits
6164
}
6265

63-
/// Add a node into the front layer, with the two qubits it operates on.
66+
/// Add a node into the layer, with the two qubits it operates on.
6467
pub fn insert(&mut self, index: NodeIndex, qubits: [PhysicalQubit; 2]) {
6568
let [a, b] = qubits;
6669
self.qubits[a] = Some((index, b));
6770
self.qubits[b] = Some((index, a));
6871
self.nodes.insert(index, qubits);
6972
}
7073

71-
/// Remove a node from the front layer.
74+
/// Remove a node from the layer.
7275
pub fn remove(&mut self, index: &NodeIndex) {
7376
// The actual order in the indexmap doesn't matter as long as it's reproducible.
7477
// Swap-remove is more efficient than a full shift-remove.
@@ -80,6 +83,14 @@ impl FrontLayer {
8083
self.qubits[b] = None;
8184
}
8285

86+
/// Remove all nodes from the layer.
87+
pub fn clear(&mut self) {
88+
for (_, [a, b]) in self.nodes.drain(..) {
89+
self.qubits[a] = None;
90+
self.qubits[b] = None;
91+
}
92+
}
93+
8394
/// Query whether a qubit has an active node.
8495
#[inline]
8596
pub fn is_active(&self, qubit: PhysicalQubit) -> bool {
@@ -89,15 +100,15 @@ impl FrontLayer {
89100
/// Calculate the score _difference_ caused by this swap, compared to not making the swap.
90101
#[inline(always)]
91102
pub fn score(&self, swap: [PhysicalQubit; 2], dist: &ArrayView2<f64>) -> f64 {
92-
// At most there can be two affected gates in the front layer (one on each qubit in the
93-
// swap), since any gate whose closest path passes through the swapped qubit link has its
94-
// "virtual-qubit path" order changed, but not the total weight. In theory, we should
95-
// never consider the same gate in both `if let` branches, because if we did, the gate would
96-
// already be routable. It doesn't matter, though, because the two distances would be
97-
// equal anyway, so not affect the score.
103+
// At most there can be two affected gates in the layer (one on each qubit in the swap),
104+
// since any gate whose closest path passes through the swapped qubit link has its
105+
// "virtual-qubit path" order changed, but not the total weight.
98106
let [a, b] = swap;
99107
let mut total = 0.0;
100108
if let Some((_, c)) = self.qubits[a] {
109+
if c == b {
110+
return 0.0;
111+
}
101112
total += dist[[b.index(), c.index()]] - dist[[a.index(), c.index()]]
102113
}
103114
if let Some((_, c)) = self.qubits[b] {
@@ -106,7 +117,7 @@ impl FrontLayer {
106117
total
107118
}
108119

109-
/// Calculate the total absolute of the current front layer on the given layer.
120+
/// Calculate the total absolute score of the layer for the set layout.
110121
pub fn total_score(&self, dist: &ArrayView2<f64>) -> f64 {
111122
self.iter()
112123
.map(|(_, &[a, b])| dist[[a.index(), b.index()]])
@@ -158,96 +169,3 @@ impl FrontLayer {
158169
self.nodes.values().flatten()
159170
}
160171
}
161-
162-
/// This structure is currently reconstructed after each gate is routed, so there's no need to
163-
/// worry about tracking gate indices or anything like that. We track length manually just to
164-
/// avoid a summation.
165-
pub struct ExtendedSet {
166-
qubits: Vec<Vec<PhysicalQubit>>,
167-
len: usize,
168-
}
169-
170-
impl ExtendedSet {
171-
pub fn new(num_qubits: u32) -> Self {
172-
ExtendedSet {
173-
qubits: vec![Vec::new(); num_qubits as usize],
174-
len: 0,
175-
}
176-
}
177-
178-
/// Add a node and its active qubits to the extended set.
179-
pub fn push(&mut self, qubits: [PhysicalQubit; 2]) {
180-
let [a, b] = qubits;
181-
self.qubits[a.index()].push(b);
182-
self.qubits[b.index()].push(a);
183-
self.len += 1;
184-
}
185-
186-
/// Calculate the score of applying the given swap, relative to not applying it.
187-
#[inline(always)]
188-
pub fn score(&self, swap: [PhysicalQubit; 2], dist: &ArrayView2<f64>) -> f64 {
189-
let [a, b] = swap;
190-
let mut total = 0.0;
191-
for other in self.qubits[a.index()].iter() {
192-
// If the other qubit is also active then the score won't have changed, but since the
193-
// distance is absolute, we'd double count rather than ignore if we didn't skip it.
194-
if *other == b {
195-
continue;
196-
}
197-
total += dist[[b.index(), other.index()]] - dist[[a.index(), other.index()]];
198-
}
199-
for other in self.qubits[b.index()].iter() {
200-
if *other == a {
201-
continue;
202-
}
203-
total += dist[[a.index(), other.index()]] - dist[[b.index(), other.index()]];
204-
}
205-
total
206-
}
207-
208-
/// Calculate the total absolute score of this set of nodes over the given layout.
209-
pub fn total_score(&self, dist: &ArrayView2<f64>) -> f64 {
210-
// Factor of two is to remove double-counting of each gate.
211-
self.qubits
212-
.iter()
213-
.enumerate()
214-
.flat_map(move |(a_index, others)| {
215-
others.iter().map(move |b| dist[[a_index, b.index()]])
216-
})
217-
.sum::<f64>()
218-
* 0.5
219-
}
220-
221-
/// Clear all nodes from the extended set.
222-
pub fn clear(&mut self) {
223-
for others in self.qubits.iter_mut() {
224-
others.clear()
225-
}
226-
self.len = 0;
227-
}
228-
229-
/// Number of nodes in the set.
230-
pub fn len(&self) -> usize {
231-
self.len
232-
}
233-
234-
pub fn is_empty(&self) -> bool {
235-
self.len == 0
236-
}
237-
238-
/// Apply a physical swap to the current layout data structure.
239-
pub fn apply_swap(&mut self, swap: [PhysicalQubit; 2]) {
240-
let [a, b] = swap;
241-
for other in self.qubits[a.index()].iter_mut() {
242-
if *other == b {
243-
*other = a
244-
}
245-
}
246-
for other in self.qubits[b.index()].iter_mut() {
247-
if *other == a {
248-
*other = b
249-
}
250-
}
251-
self.qubits.swap(a.index(), b.index());
252-
}
253-
}

0 commit comments

Comments
 (0)