Skip to content

Commit 35f4fa3

Browse files
committed
Add C API vtable to pyext
While the `_accelerate.abi3.so` object that ships with Qiskit already exposes all the C API symbols with public `qk_*` names, this can't safely be used by distributable compiled Python extension modules, because they cannot rely even on deferred dynamic linking to the object. Instead, we define what is effectively a set of vtables, where the actual addresses of the functions are written (at runtime) at known offsets to a base pointer. These can then be accessed without the actual involvement of a linker by knowing the base pointer of the vtable, the offset of the desired function and the expected signature. In order for user builds to be forwards compatible (or in other words, for later Qiskit builds to be _backwards_ compatible) from an ABI perspective, the offsets into the vtables must be constant between Qiskit versions. This requires them to be defined statically, without being inferred from other functions; if we try to infer based on the set of functions, there is no way to keep them the same as functions are added without defining an order. The hierarchical `VTable` machinery introduced in this commit is a trade-off between two extreme approaches: 1. use a per-function annotation to set the slot and the vtable 2. use a single global list completely defining the vtable Option 1 has the negative that it's incredibly hard to tell from local information only what the available slots are, and which slot should be next assigned. Option 2 is undesirable because it completely centralises all definitions, which will likely make it very hard to add new C API functions without constantly generating merge conflicts (which is especially important to avoid breaking the merge queue), and likely leads to the functions by sequential offset being in random order (which isn't a technical problem, but is aesthetically unsatisfying!). The hierarchical approach allows C-API additions that touch completely different modules to be independent, while still permitting some locality in slot assignments for related functions, and providing an overview of where the slots are assigned. Each `leaves` node in the hierarchy over-allocates slots for itself to allow some addition of new functions in the future. There is a trade-off between having many `PyCapsule` function pointers and spreading the data across many completely separate pointers (which leaves most room for expansion), and having very few vtables (where we have to leave intermediate gaps for expansion within the hierarchy). The names and groupings are not especially important; they're mostly aesthetic, and subsequent patches will introduce header files and other automated tools that mostly mean that users will not have to worry about the internals of the vtables themselves. This is not included in `cext` itself (despite my earlier attempts to do just that) because doing so would require _all_ use of the vtable specification to involve a complete compilation of Qiskit, likely also including linking in `libpython`. In language-binding generation, we do not need to do that; we only need the string names and the separate assistance from `cbindgen` to parse out the function signatures.
1 parent 2d49283 commit 35f4fa3

11 files changed

Lines changed: 738 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ num-traits = "0.2"
4141
uuid = { version = "1.21", features = ["v4", "fast-rng"], default-features = false }
4242
anyhow = "1.0"
4343
binrw = "0.15"
44+
cbindgen = "0.29.2"
4445

4546
# Most of the crates don't need the feature `extension-module`, since only `qiskit-pyext` builds an
4647
# actual C extension (the feature disables linking in `libpython`, which is forbidden in Python
@@ -52,12 +53,13 @@ pyo3 = { version = "0.28.1", features = ["abi3-py310"] }
5253
# These are our own crates.
5354
qiskit-accelerate = { path = "crates/accelerate" }
5455
qiskit-bindgen = { path = "crates/bindgen" }
56+
qiskit-cext = { path = "crates/cext" }
57+
qiskit-cext-vtable = { path = "crates/cext-vtable" }
5558
qiskit-circuit = { path = "crates/circuit" }
5659
qiskit-circuit-library = { path = "crates/circuit_library" }
5760
qiskit-qasm2 = { path = "crates/qasm2" }
5861
qiskit-qasm3 = { path = "crates/qasm3" }
5962
qiskit-qpy = { path = "crates/qpy" }
60-
qiskit-cext = { path = "crates/cext" }
6163
qiskit-transpiler = { path = "crates/transpiler" }
6264
qiskit-quantum-info = { path = "crates/quantum_info" }
6365
qiskit-synthesis = {path = "crates/synthesis" }

crates/bindgen/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,4 @@ name = "qiskit_bindgen"
1414

1515
[dependencies]
1616
anyhow.workspace = true
17-
cbindgen = "0.29.2"
17+
cbindgen.workspace = true

crates/cext-vtable/Cargo.toml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[package]
2+
name = "qiskit-cext-vtable"
3+
version.workspace = true
4+
edition.workspace = true
5+
rust-version.workspace = true
6+
license.workspace = true
7+
8+
[lib]
9+
name = "qiskit_cext_vtable"
10+
doctest = false
11+
12+
[lints]
13+
workspace = true
14+
15+
[dependencies]
16+
qiskit-cext = { workspace = true, optional = true }
17+
18+
[features]
19+
addr = ["dep:qiskit-cext"]
20+
python_binding = ["qiskit-cext?/python_binding"]

crates/cext-vtable/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# `qiskit-cext-vtable`
2+
3+
This crate defines the machinery to specify ABI-stable vtables of function pointers, and provides
4+
concrete vtables for the functions within `cext`.
5+
6+
This vtable can be compiled into dependencies on `cext` to include the actual function-pointers and
7+
a complete vtable (when using the `addr` feature, in which case it depends on `cext`), or, if not
8+
using the `addr` feature, then the tables can be built solely in terms of the function names, which
9+
is used by build scripts to generate accessor files.
10+
11+
This exists as a separate module to `cext` because language-bindings generators typically do not
12+
want to, or _cannot_ compile against `cext` fully. For example, the build script of `pyext` cannot
13+
depend on `cext` itself, because that would trigger a complete second compilation of Qiskit and
14+
require the build script to link against `libpython` simply to run, both of which are highly
15+
problematic for the build proces.

crates/cext-vtable/src/impl_.rs

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
// This code is part of Qiskit.
2+
//
3+
// (C) Copyright IBM 2026
4+
//
5+
// This code is licensed under the Apache License, Version 2.0. You may
6+
// obtain a copy of this license in the LICENSE.txt file in the root directory
7+
// of this source tree or at https://www.apache.org/licenses/LICENSE-2.0.
8+
//
9+
// Any modifications or derivative works of this code must retain this
10+
// copyright notice, and modified files need to carry a notice indicating
11+
// that they have been altered from the originals.
12+
13+
use std::sync::LazyLock;
14+
15+
// This has to be `pub(crate)` so that the `export_fn` macro can find it, but nothing in here is
16+
// supposed to actually be used other than as internal implementation details.
17+
#[doc(hidden)]
18+
pub(crate) mod inner {
19+
#[derive(Copy, Clone, Debug)]
20+
pub struct ExportedFunctionPartial {
21+
pub name: &'static str,
22+
#[cfg(feature = "addr")]
23+
pub addr: usize,
24+
}
25+
26+
pub fn last_element(path: &str) -> &str {
27+
path.rfind(":")
28+
.map(|index| path.split_at(index + 1).1)
29+
.unwrap_or(path)
30+
}
31+
}
32+
33+
/// An exported C API function, along with a slot to place it in a function-pointer lookup table.
34+
#[derive(Copy, Clone, Debug)]
35+
pub struct ExportedFunction {
36+
/// The name of the function.
37+
pub name: &'static str,
38+
/// Which slot the function pointer should be assigned to, in its appropriate table.
39+
pub slot: usize,
40+
/// A pointer to the function, type erased to a pointer-width integer.
41+
///
42+
/// In general, these derive from a type that looks like
43+
/// ```
44+
/// unsafe extern "C" fn(T0, T1, ...) -> TRet
45+
/// ```
46+
/// for some number of arguments and some (maybe void) return type.
47+
#[cfg(feature = "addr")]
48+
pub addr: usize,
49+
}
50+
51+
/// Maximum number of children of any single `ExportedFunctions` object.
52+
///
53+
/// Upping this causes more static memory use, but it shouldn't be too onerous. You can nest
54+
/// `ExportedFunctions` objects at any depth without trouble.
55+
const MAX_CHILDREN: usize = 8;
56+
57+
/// A compile-time list of exported functions, including potential subgroups of functions.
58+
///
59+
/// When creating one of these, you almost certainly want to assign it to a `static` variable; all
60+
/// the data it is supposed to represent is static
61+
pub struct ExportedFunctions {
62+
/// The amount of space reserved for the leaves. It is a panic to reserve less space than
63+
/// required, but it's fine (and encouraged) to reserve as much space as you think you'll expand
64+
/// to, in any given set of `ExportedFunctions`.
65+
leaves_reserve: usize,
66+
/// The calculated total length of reserved space (though there may be internal gaps that aren't
67+
/// technically reserved within it). This is calculated at compile time, mostly for the
68+
/// purposes of causing compile-time errors of this crate if the requested reservations don't
69+
/// fit together properly.
70+
len: usize,
71+
/// The leaf functions owned by this set of `ExportedFunctions`. This has to constructed lazily
72+
/// because the function-pointer values can't (in general) be calculated until the compiled
73+
/// artifact is loaded into a process's memory space.
74+
///
75+
/// This shouldn't be used directly; use [`get_leaves`] to build it to ensure the `assert` code
76+
/// is called too.
77+
leaves: LazyLock<Vec<Option<inner::ExportedFunctionPartial>>>,
78+
/// The offsets and references to each child owned by this object. The funky static-sized array
79+
/// of maybe-uninitialized references is to make this all work at compile time. The array is
80+
/// guaranteed to be zero or more `Some` values and all the remainder are `None`.
81+
children: [Option<(usize, &'static ExportedFunctions)>; MAX_CHILDREN],
82+
}
83+
impl ExportedFunctions {
84+
/// Create a new (lazy) list of exported functions.
85+
///
86+
/// The first argument is how much space to reserve for the leaf nodes. It must be at least as
87+
/// large as the vector of leaves, or this will panic when trying to access the functions. The
88+
/// second is a non-capturing closure that produces a vector of items defined by [`export_fn`}
89+
/// (or `None`).
90+
///
91+
/// The second argument has to be a lazy closure because the addresses of functions generally
92+
/// aren't set until the fully compiled binary has been loaded up into a process; they can't be
93+
/// set at compile time.
94+
///
95+
/// You can then append children with [`add_child`]. If you don't need any leaf functions, use
96+
/// [`empty`].
97+
pub const fn leaves(
98+
reserve: usize,
99+
slots: fn() -> Vec<Option<inner::ExportedFunctionPartial>>,
100+
) -> Self {
101+
Self {
102+
leaves_reserve: reserve,
103+
len: reserve,
104+
leaves: LazyLock::new(slots),
105+
children: [None; MAX_CHILDREN],
106+
}
107+
}
108+
/// Create a new empty list of exported functions.
109+
///
110+
/// You can then append children with [`add_child`].
111+
pub const fn empty() -> Self {
112+
Self::leaves(0, Vec::new)
113+
}
114+
115+
/// Add a group of exported functions as a child of this set.
116+
///
117+
/// You must add children in offset order, or the compile-time checks on validity will fail.
118+
///
119+
/// # Panics
120+
///
121+
/// If there are already `MAX_CHILDREN` children attached to this set of functions, or if the
122+
/// base `offset` is less than the maximum current reservation.
123+
pub const fn add_child(mut self, offset: usize, fns: &'static ExportedFunctions) -> Self {
124+
if offset < self.len {
125+
panic!("offset is less than previously reserved space; don't fill in holes");
126+
}
127+
let mut i = 0;
128+
while self.children[i].is_some() {
129+
i += 1;
130+
if i == MAX_CHILDREN {
131+
// We'd panic even without this catch, but this just makes sure the dev sees a
132+
// clearer message about what's gone wrong.
133+
panic!("too many children; consider using deeper nesting");
134+
}
135+
}
136+
// There isn't actually a value to throw away here, but we had to do this little dance with
137+
// the iteration and `replace` to keep things safely `const`
138+
self.children[i].replace((offset, fns));
139+
self.len = offset + fns.len;
140+
self
141+
}
142+
143+
/// The total length of the reservation
144+
pub fn len(&self) -> usize {
145+
self.len
146+
}
147+
pub fn is_empty(&self) -> bool {
148+
self.len == 0
149+
}
150+
151+
#[inline]
152+
fn get_leaves(&self) -> &[Option<inner::ExportedFunctionPartial>] {
153+
let slots = &self.leaves;
154+
assert!(slots.len() <= self.leaves_reserve);
155+
slots
156+
}
157+
158+
/// Iterate through all the exported functions, filling in their complete slot information from
159+
/// a base offset.
160+
///
161+
/// The order of iteration is not defined with respect to the slots; they are not guaranteed to
162+
/// be in sorted order.
163+
///
164+
/// Requiring a `'static` lifetime on `self` is mostly just laziness in defining this (it lets
165+
/// us safely do it with iterator combinators rather than producing a custom "walker" class
166+
/// while avoiding recursive types), but one that shouldn't actually affect use of this, since
167+
/// all `ExportedFunctions` objects are expected to be defined as `static`s.
168+
pub fn exports(&'static self, offset: usize) -> Box<dyn Iterator<Item = ExportedFunction>> {
169+
Box::new(
170+
self.get_leaves()
171+
.iter()
172+
.enumerate()
173+
.filter_map(move |(i, func)| {
174+
func.as_ref().map(move |func| ExportedFunction {
175+
name: func.name,
176+
slot: offset + i,
177+
#[cfg(feature = "addr")]
178+
addr: func.addr,
179+
})
180+
})
181+
.chain(
182+
self.children
183+
.iter()
184+
.filter_map(move |funcs| {
185+
funcs
186+
.as_ref()
187+
.map(move |(inner, funcs)| funcs.exports(offset + inner))
188+
})
189+
.flatten(),
190+
),
191+
)
192+
}
193+
}
194+
195+
/// Create an entry in an `ExportedFunctions` table.
196+
///
197+
/// The first argument to the macro is the path to export, which should resolve to some object
198+
/// declared like
199+
/// ```
200+
/// #[unsafe(no_mangle)]
201+
/// pub unsafe extern "C" fn qk_my_function() {}
202+
/// ```
203+
/// (or just `pub extern` - the `unsafe` is not important).
204+
///
205+
/// If the function is only defined when certain features are active, you can follow the path with a
206+
/// comma-separated list of `feature = "my-feature"` items, such as
207+
/// ```
208+
/// export_fn!(path::to::qk_my_function, feature = "python_binding", feature = "cool_stuff");
209+
/// ```
210+
macro_rules! export_fn {
211+
($fn:path) => {
212+
Some($crate::impl_::inner::ExportedFunctionPartial {
213+
name: $crate::impl_::inner::last_element(stringify!($fn)),
214+
#[cfg(feature = "addr")]
215+
addr: ($fn as *const ()).addr(),
216+
})
217+
};
218+
($fn:path, $(feature = $feat:tt),+) => {{
219+
#[cfg(all($(feature = $feat),+))]
220+
let out = $crate::impl_::export_fn!($fn);
221+
#[cfg(not(all($(feature = $feat),+)))]
222+
let out = None::<$crate::impl_::inner::ExportedFunctionPartial>;
223+
out
224+
}};
225+
}
226+
pub(crate) use export_fn;
227+
228+
/// Helper module to made exports easier. This should contain everything that modules need to
229+
/// define their exports.
230+
pub(crate) mod prelude {
231+
pub(crate) use super::{ExportedFunctions, export_fn};
232+
}

0 commit comments

Comments
 (0)