Skip to content

Commit 5b4d9ce

Browse files
committed
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.
1 parent ef46cd4 commit 5b4d9ce

11 files changed

Lines changed: 169 additions & 198 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: 48 additions & 27 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 {
8786
/// The relative weight of this heuristic. Typically this is defined relative to the
8887
/// :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.
88+
weights: Vec<f64>,
89+
/// Dynamic scaling of the heuristic weight depending on the size of the 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: 15 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,18 @@ use super::vec_map::VecMap;
2828
/// makes it more efficient to do everything in terms of physical qubits, so the conversion between
2929
/// physical and virtual qubits via the layout happens once per inserted swap and on layer
3030
/// extension, not for every swap trialled.
31-
pub struct FrontLayer {
31+
#[derive(Clone, Debug)]
32+
pub struct Layer {
3233
/// Map of the (index to the) node to the qubits it acts on.
3334
nodes: IndexMap<NodeIndex, [PhysicalQubit; 2], ::ahash::RandomState>,
3435
/// Map of each qubit to the node that acts on it and the other qubit that node acts on, if this
3536
/// qubit is active (otherwise `None`).
3637
qubits: VecMap<PhysicalQubit, Option<(NodeIndex, PhysicalQubit)>>,
3738
}
3839

39-
impl FrontLayer {
40+
impl Layer {
4041
pub fn new(num_qubits: u32) -> Self {
41-
FrontLayer {
42+
Layer {
4243
// This is the maximum capacity of the front layer, since each qubit must be one of a
4344
// pair, and can only have one gate in the layer.
4445
nodes: IndexMap::with_capacity_and_hasher(
@@ -80,6 +81,14 @@ impl FrontLayer {
8081
self.qubits[b] = None;
8182
}
8283

84+
/// Remove all nodes from the layer.
85+
pub fn clear(&mut self) {
86+
for (_, [a, b]) in self.nodes.drain(..) {
87+
self.qubits[a] = None;
88+
self.qubits[b] = None;
89+
}
90+
}
91+
8392
/// Query whether a qubit has an active node.
8493
#[inline]
8594
pub fn is_active(&self, qubit: PhysicalQubit) -> bool {
@@ -98,6 +107,9 @@ impl FrontLayer {
98107
let [a, b] = swap;
99108
let mut total = 0.0;
100109
if let Some((_, c)) = self.qubits[a] {
110+
if c == b {
111+
return 0.0;
112+
}
101113
total += dist[[b.index(), c.index()]] - dist[[a.index(), c.index()]]
102114
}
103115
if let Some((_, c)) = self.qubits[b] {
@@ -158,96 +170,3 @@ impl FrontLayer {
158170
self.nodes.values().flatten()
159171
}
160172
}
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)