From: Elias Huhsovitz <e.huhsovitz@proxmox.com>
To: pve-devel@lists.proxmox.com
Cc: Elias Huhsovitz <e.huhsovitz@proxmox.com>
Subject: [PATCH qemu-server-rs v3 1/3] cpu: add pve-qemu-server-cpu crate for vCPU binding
Date: Thu, 22 Oct 2026 11:22:23 +0200 [thread overview]
Message-ID: <20261022092227.58871-2-e.huhsovitz@proxmox.com> (raw)
In-Reply-To: <20261022092227.58871-1-e.huhsovitz@proxmox.com>
Add a new library crate that implements CPU binding on top of libhwloc.
Two public entry points are exposed:
* validate_binding(mode, sockets, cores, cfg) - pure pre-start
validation. Checks cfg syntax, host CPU existence, vCPU range, and
NUMA node existence against the local topology. No side effects.
* bind_vcpus(mode, cfg, cpuinfo_json) - applies the binding to a running
QEMU process. Consumes the query-cpus-fast QMP response and binds each
vCPU thread by kernel TID.
Five modes are implemented:
* none: no binding
* manual: explicit vCPU:hostCPU mapping
* one-to-one: one vCPU per physical core, SMT stripped
* numa: per-vNUMA-node set-to-set binding
* numa-manual: explicit vNUMA->host NUMA mapping
hwloc is accessed via a small FFI subset (src/bindings.rs).
Thread binding uses hwloc_linux_set_tid_cpubind, which accepts a kernel
TID directly. The TID used is reported by qemu via query-cpus-fast.
Signed-off-by: Elias Huhsovitz <e.huhsovitz@proxmox.com>
---
Cargo.toml | 2 +-
build.sh | 2 +-
pve-qemu-server-cpu/Cargo.toml | 16 +
pve-qemu-server-cpu/debian/changelog | 5 +
pve-qemu-server-cpu/debian/control | 41 ++
pve-qemu-server-cpu/debian/copyright | 18 +
pve-qemu-server-cpu/debian/debcargo.toml | 9 +
pve-qemu-server-cpu/src/bind.rs | 478 ++++++++++++++++++++++
pve-qemu-server-cpu/src/error.rs | 38 ++
pve-qemu-server-cpu/src/hwloc/bindings.rs | 171 ++++++++
pve-qemu-server-cpu/src/hwloc/mod.rs | 222 ++++++++++
pve-qemu-server-cpu/src/lib.rs | 92 +++++
pve-qemu-server-cpu/src/qmp_cpu.rs | 99 +++++
13 files changed, 1191 insertions(+), 2 deletions(-)
create mode 100644 pve-qemu-server-cpu/Cargo.toml
create mode 100644 pve-qemu-server-cpu/debian/changelog
create mode 100644 pve-qemu-server-cpu/debian/control
create mode 100644 pve-qemu-server-cpu/debian/copyright
create mode 100644 pve-qemu-server-cpu/debian/debcargo.toml
create mode 100644 pve-qemu-server-cpu/src/bind.rs
create mode 100644 pve-qemu-server-cpu/src/error.rs
create mode 100644 pve-qemu-server-cpu/src/hwloc/bindings.rs
create mode 100644 pve-qemu-server-cpu/src/hwloc/mod.rs
create mode 100644 pve-qemu-server-cpu/src/lib.rs
create mode 100644 pve-qemu-server-cpu/src/qmp_cpu.rs
diff --git a/Cargo.toml b/Cargo.toml
index 71bc8e5..c100e5a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,6 @@
[workspace]
members = [
+ "pve-qemu-server-cpu",
"pve-qemu-server-pci"
]
exclude = [
@@ -26,4 +27,3 @@ strum = { version = "0.26", features = ["derive"] }
# proxmox dependencies
pve-api-types = "8.1"
-
diff --git a/build.sh b/build.sh
index 3c2293b..c3ee7b6 100755
--- a/build.sh
+++ b/build.sh
@@ -28,7 +28,7 @@ debcargo package \
--no-overlay-write-back \
--directory "$PWD/"${BUILDDIR}"/${CRATE}" \
"${CRATE}" \
- "$(dpkg-parsechangelog -l "${CRATE}/debian/changelog" -SVersion | sed -e 's/-.*//')"
+ "$(dpkg-parsechangelog -l "${CRATE}/debian/changelog" -SVersion | sed -e 's/-.*//' -e 's/~/-/')"
cd "${BUILDDIR}/${CRATE}"
rm -f debian/source/format.debcargo.hint
diff --git a/pve-qemu-server-cpu/Cargo.toml b/pve-qemu-server-cpu/Cargo.toml
new file mode 100644
index 0000000..b236796
--- /dev/null
+++ b/pve-qemu-server-cpu/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "pve-qemu-server-cpu"
+version = "0.1.0"
+authors.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+homepage.workspace = true
+exclude.workspace = true
+rust-version.workspace = true
+
+[dependencies]
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+thiserror = "2"
+libc = "0.2"
diff --git a/pve-qemu-server-cpu/debian/changelog b/pve-qemu-server-cpu/debian/changelog
new file mode 100644
index 0000000..3731cfd
--- /dev/null
+++ b/pve-qemu-server-cpu/debian/changelog
@@ -0,0 +1,5 @@
+rust-pve-qemu-server-cpu (0.1.0) trixie; urgency=medium
+
+ * initial release
+
+ -- Proxmox Support Team <support@proxmox.com> Tue, 01 Sep 2026 14:02:25 +0200
diff --git a/pve-qemu-server-cpu/debian/control b/pve-qemu-server-cpu/debian/control
new file mode 100644
index 0000000..1298d61
--- /dev/null
+++ b/pve-qemu-server-cpu/debian/control
@@ -0,0 +1,41 @@
+Source: rust-pve-qemu-server-cpu
+Section: rust
+Priority: optional
+Build-Depends: debhelper-compat (= 13),
+ dh-sequence-cargo,
+ libhwloc-dev (>= 2.12)
+Build-Depends-Arch: cargo:native <!nocheck>,
+ rustc:native (>= 1.94) <!nocheck>,
+ libstd-rust-dev <!nocheck>,
+ librust-libc-0.2+default-dev <!nocheck>,
+ librust-serde-1+default-dev <!nocheck>,
+ librust-serde-1+derive-dev <!nocheck>,
+ librust-serde-json-1+default-dev <!nocheck>,
+ librust-thiserror-2+default-dev <!nocheck>
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Standards-Version: 4.7.2
+Vcs-Git: git://git.proxmox.com/git/pve-qemu-server-rs.git
+Vcs-Browser: https://git.proxmox.com/?p=pve-qemu-server-rs.git
+Homepage: https://proxmox.com
+X-Cargo-Crate: pve-qemu-server-cpu
+
+Package: librust-pve-qemu-server-cpu-dev
+Architecture: any
+Multi-Arch: same
+Depends:
+ ${misc:Depends},
+ librust-libc-0.2+default-dev,
+ librust-serde-1+default-dev,
+ librust-serde-1+derive-dev,
+ librust-serde-json-1+default-dev,
+ librust-thiserror-2+default-dev
+Provides:
+ librust-pve-qemu-server-cpu+default-dev (= ${binary:Version}),
+ librust-pve-qemu-server-cpu-0-dev (= ${binary:Version}),
+ librust-pve-qemu-server-cpu-0+default-dev (= ${binary:Version}),
+ librust-pve-qemu-server-cpu-0.1-dev (= ${binary:Version}),
+ librust-pve-qemu-server-cpu-0.1+default-dev (= ${binary:Version}),
+ librust-pve-qemu-server-cpu-0.1.0-dev (= ${binary:Version}),
+ librust-pve-qemu-server-cpu-0.1.0+default-dev (= ${binary:Version})
+Description: Rust crate "pve-qemu-server-cpu" - Rust source code
+ Source code for Debianized Rust crate "pve-qemu-server-cpu"
diff --git a/pve-qemu-server-cpu/debian/copyright b/pve-qemu-server-cpu/debian/copyright
new file mode 100644
index 0000000..01138fa
--- /dev/null
+++ b/pve-qemu-server-cpu/debian/copyright
@@ -0,0 +1,18 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+
+Files:
+ *
+Copyright: 2026 Proxmox Server Solutions GmbH <support@proxmox.com>
+License: AGPL-3.0-or-later
+ This program is free software: you can redistribute it and/or modify it under
+ the terms of the GNU Affero General Public License as published by the Free
+ Software Foundation, either version 3 of the License, or (at your option) any
+ later version.
+ .
+ This program is distributed in the hope that it will be useful, but WITHOUT
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+ details.
+ .
+ You should have received a copy of the GNU Affero General Public License along
+ with this program. If not, see <https://www.gnu.org/licenses/>.
diff --git a/pve-qemu-server-cpu/debian/debcargo.toml b/pve-qemu-server-cpu/debian/debcargo.toml
new file mode 100644
index 0000000..23feea4
--- /dev/null
+++ b/pve-qemu-server-cpu/debian/debcargo.toml
@@ -0,0 +1,9 @@
+overlay = "."
+crate_src_path = ".."
+maintainer = "Proxmox Support Team <support@proxmox.com>"
+allow_prerelease_deps = true
+
+[source]
+vcs_git = "git://git.proxmox.com/git/pve-qemu-server-rs.git"
+vcs_browser = "https://git.proxmox.com/?p=pve-qemu-server-rs.git"
+build_depends = ["libhwloc-dev (>= 2.12)"]
diff --git a/pve-qemu-server-cpu/src/bind.rs b/pve-qemu-server-cpu/src/bind.rs
new file mode 100644
index 0000000..4f2927d
--- /dev/null
+++ b/pve-qemu-server-cpu/src/bind.rs
@@ -0,0 +1,478 @@
+use std::collections::HashSet;
+
+use crate::hwloc::{self, CpuSet, Topology};
+use crate::qmp_cpu::QmpCpu;
+use crate::BindError;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+struct Cpu(u32);
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+struct NumaNodeId(u32);
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+struct Vcpu(u32);
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+struct VnumaNode {
+ id: NumaNodeId,
+ cpus: Vec<Vcpu>,
+}
+
+#[derive(Debug, Clone)]
+struct BindRule {
+ vcpus: Vec<Vcpu>,
+ cpuset: CpuSet,
+}
+
+#[derive(Debug, Clone)]
+struct NumaBindRule {
+ vnode: VnumaNode,
+ host_node: NumaNodeId,
+}
+
+impl From<u32> for Cpu {
+ fn from(v: u32) -> Self {
+ Self(v)
+ }
+}
+impl From<u32> for Vcpu {
+ fn from(v: u32) -> Self {
+ Self(v)
+ }
+}
+impl From<u32> for NumaNodeId {
+ fn from(v: u32) -> Self {
+ Self(v)
+ }
+}
+
+trait BindableRule {
+ fn vcpus(&self) -> &[Vcpu];
+ fn resolve_cpuset(&self, topo: &Topology) -> Result<CpuSet, BindError>;
+}
+
+impl BindableRule for BindRule {
+ fn vcpus(&self) -> &[Vcpu] {
+ &self.vcpus
+ }
+ fn resolve_cpuset(&self, _topo: &Topology) -> Result<CpuSet, BindError> {
+ Ok(self.cpuset.clone())
+ }
+}
+
+impl BindableRule for NumaBindRule {
+ fn vcpus(&self) -> &[Vcpu] {
+ &self.vnode.cpus
+ }
+ fn resolve_cpuset(&self, topo: &Topology) -> Result<CpuSet, BindError> {
+ hwloc::numa_node_cpuset(topo, self.host_node.0).ok_or(BindError::UnknownHostCpu(self.host_node.0))
+ }
+}
+
+fn bind(topo: &Topology, vcpu: &Vcpu, tid: u32, set: &CpuSet) -> Result<(), BindError> {
+ // Note: DO NOT singlify here — we want the scheduler to be free to move
+ // the vCPU within the specified set.
+ hwloc::bind_thread(topo, tid as i32, set).map_err(|message| BindError::Bind {
+ vcpu: vcpu.0,
+ message,
+ })
+}
+
+fn bind_rules<R: BindableRule>(
+ topo: &Topology,
+ rules: &[R],
+ qmp_cpuinfo: &[QmpCpu],
+) -> Result<(), BindError> {
+ for rule in rules {
+ let set = rule.resolve_cpuset(topo)?;
+ for vcpu in rule.vcpus() {
+ let tid = qmp_cpuinfo
+ .get(vcpu.0 as usize)
+ .ok_or(BindError::ThreadNotFound(vcpu.0))?
+ .thread_id;
+ bind(topo, vcpu, tid, &set)?;
+ }
+ }
+ Ok(())
+}
+
+pub enum Mode {
+ None,
+ Manual,
+ OneToOne,
+ Numa,
+ NumaManual,
+}
+
+impl std::str::FromStr for Mode {
+ type Err = BindError;
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s {
+ "none" => Ok(Mode::None),
+ "manual" => Ok(Mode::Manual),
+ "one-to-one" => Ok(Mode::OneToOne),
+ "numa" => Ok(Mode::Numa),
+ "numa-manual" => Ok(Mode::NumaManual),
+ other => Err(BindError::BadSyntax(format!("unknown cpu-bind-mode '{other}'"))),
+ }
+ }
+}
+
+fn to_cpuset(cpus: &[u32]) -> CpuSet {
+ let mut set = CpuSet::new();
+ for &c in cpus {
+ set.set(c);
+ }
+ set
+}
+
+fn parse_cpu_binding_map(map: &str) -> Result<Vec<BindRule>, BindError> {
+ if map.is_empty() {
+ return Err(BindError::BadSyntax("binding map is empty".into()));
+ }
+
+ let mut rules = Vec::new();
+ for entry in map.split(';') {
+ let (vcpu_side, host_side) = entry
+ .split_once(':')
+ .ok_or_else(|| BindError::MissingColon(entry.to_string()))?;
+
+ let vcpus: Vec<Vcpu> = parse_side(vcpu_side)?.into_iter().map(Vcpu::from).collect();
+ let cpuset = to_cpuset(&parse_side(host_side)?);
+
+ rules.push(BindRule { vcpus, cpuset });
+ }
+ Ok(rules)
+}
+
+fn parse_side(s: &str) -> Result<Vec<u32>, BindError> {
+ if let Some(inner) = s.strip_prefix('{').and_then(|r| r.strip_suffix('}')) {
+ let mut out = Vec::new();
+ for part in inner.split(',') {
+ out.extend(parse_element(part)?);
+ }
+ Ok(out)
+ } else {
+ if s.contains(',') {
+ return Err(BindError::BadSyntax(format!(
+ "unions require braces: write '{{0,1}}', not '{s}'"
+ )));
+ }
+ parse_element(s)
+ }
+}
+
+fn parse_element(s: &str) -> Result<Vec<u32>, BindError> {
+ if let Some((a, b)) = s.split_once('-') {
+ let a: u32 = a
+ .parse()
+ .map_err(|_| BindError::BadSyntax(format!("bad index in '{s}'")))?;
+ let b: u32 = b
+ .parse()
+ .map_err(|_| BindError::BadSyntax(format!("bad index in '{s}'")))?;
+ if a > b {
+ return Err(BindError::BadSyntax(format!("reversed range '{s}'")));
+ }
+ Ok((a..=b).collect())
+ } else {
+ Ok(vec![s
+ .parse()
+ .map_err(|_| BindError::BadSyntax(format!("bad index '{s}'")))?])
+ }
+}
+
+fn parse_index_list<T: From<u32>>(s: &str) -> Result<Vec<T>, BindError> {
+ if s.is_empty() {
+ return Ok(Vec::new());
+ }
+ let mut out = Vec::new();
+ for part in s.split(';') {
+ out.extend(parse_element(part)?.into_iter().map(T::from));
+ }
+ Ok(out)
+}
+
+fn parse_numa_binding_map(map: &str) -> Result<Vec<(NumaNodeId, NumaNodeId)>, BindError> {
+ if map.is_empty() {
+ return Err(BindError::BadSyntax("numa binding map is empty".into()));
+ }
+ let mut pairs = Vec::new();
+ for entry in map.split(';') {
+ let entry = entry.trim();
+ let (v, h) = entry
+ .split_once(':')
+ .ok_or_else(|| BindError::MissingColon(entry.to_string()))?;
+ let vnode: u32 = v
+ .trim()
+ .parse()
+ .map_err(|_| BindError::BadSyntax(format!("bad vNUMA node in '{entry}'")))?;
+ let hnode: u32 = h
+ .trim()
+ .parse()
+ .map_err(|_| BindError::BadSyntax(format!("bad host NUMA node in '{entry}'")))?;
+ pairs.push((NumaNodeId(vnode), NumaNodeId(hnode)));
+ }
+ Ok(pairs)
+}
+
+fn collect_vnuma_nodes(qmp_cpuinfo: &[QmpCpu]) -> Vec<VnumaNode> {
+ let max_node = qmp_cpuinfo
+ .iter()
+ .map(|c| c.props.node_id)
+ .max()
+ .unwrap_or(0);
+ let mut buckets: Vec<Vec<Vcpu>> = vec![Vec::new(); (max_node + 1) as usize];
+ for cpu in qmp_cpuinfo {
+ buckets[cpu.props.node_id as usize].push(Vcpu::from(cpu.cpu_index));
+ }
+ buckets
+ .into_iter()
+ .enumerate()
+ .map(|(id, cpus)| VnumaNode {
+ id: NumaNodeId(id as u32),
+ cpus,
+ })
+ .collect()
+}
+
+fn build_numa_rules(
+ pairs: &[(NumaNodeId, NumaNodeId)],
+ vnuma_nodes: &[VnumaNode],
+) -> Result<Vec<NumaBindRule>, BindError> {
+ let mut rules = Vec::with_capacity(pairs.len());
+ for (vnode_id, hnode_id) in pairs {
+ let vnode = vnuma_nodes
+ .get(vnode_id.0 as usize)
+ .ok_or_else(|| {
+ BindError::BadSyntax(format!(
+ "vNUMA node {} does not exist in this guest",
+ vnode_id.0
+ ))
+ })?
+ .clone();
+ rules.push(NumaBindRule {
+ vnode,
+ host_node: *hnode_id,
+ });
+ }
+ Ok(rules)
+}
+
+pub fn bind_manual(binding_map: &str, qmp_cpuinfo: &[QmpCpu]) -> Result<(), BindError> {
+ let rules = parse_cpu_binding_map(binding_map)?;
+ let topo = Topology::new()
+ .map_err(|e| BindError::TopologyInit(format!("topology init failed: {e}")))?;
+ bind_rules(&topo, &rules, qmp_cpuinfo)
+}
+
+pub fn bind_one_to_one(binding_list: &str, qmp_cpuinfo: &[QmpCpu]) -> Result<(), BindError> {
+ // TODO: use complex distribution logic like:
+ // https://github.com/open-mpi/hwloc/blob/master/include/hwloc/helper.h::hwloc_distrib()
+ // https://github.com/HadrienG2/hwlocality/blob/main/src/topology/mod.rs:distribute_items()
+ let topo = Topology::new()
+ .map_err(|e| BindError::TopologyInit(format!("topology init failed: {e}")))?;
+
+ let mut allowed: CpuSet = if binding_list.is_empty() {
+ topo.allowed_cpuset()
+ } else {
+ let cpus: Vec<Cpu> = parse_index_list(binding_list)?;
+ let mut s = CpuSet::new();
+ for c in cpus {
+ s.set(c.0);
+ }
+ s
+ };
+
+ // One vCPU per physical core: strip SMT siblings.
+ allowed.singlify_per_core(&topo);
+
+ let available: Vec<u32> = allowed.iter_set().collect();
+
+ if available.len() < qmp_cpuinfo.len() {
+ return Err(BindError::BadSyntax(format!(
+ "one-to-one mode requires {} physical cores but only {} are available",
+ qmp_cpuinfo.len(),
+ available.len(),
+ )));
+ }
+
+ let mut rules: Vec<BindRule> = Vec::with_capacity(qmp_cpuinfo.len());
+ for (i, cpu) in available.iter().take(qmp_cpuinfo.len()).enumerate() {
+ rules.push(BindRule {
+ vcpus: vec![Vcpu::from(qmp_cpuinfo[i].cpu_index)],
+ cpuset: CpuSet::single(*cpu),
+ });
+ }
+
+ bind_rules(&topo, &rules, qmp_cpuinfo)
+}
+
+pub fn bind_numa_manual(binding_map: &str, qmp_cpuinfo: &[QmpCpu]) -> Result<(), BindError> {
+ let topo = Topology::new()
+ .map_err(|e| BindError::TopologyInit(format!("topology init failed: {e}")))?;
+ let pairs = parse_numa_binding_map(binding_map)?;
+ let vnuma_nodes = collect_vnuma_nodes(qmp_cpuinfo);
+ let rules = build_numa_rules(&pairs, &vnuma_nodes)?;
+ bind_rules(&topo, &rules, qmp_cpuinfo)
+}
+
+pub fn bind_numa(binding_list: &str, qmp_cpuinfo: &[QmpCpu]) -> Result<(), BindError> {
+ let topo = Topology::new()
+ .map_err(|e| BindError::TopologyInit(format!("topology init failed: {e}")))?;
+ let vnuma_nodes = collect_vnuma_nodes(qmp_cpuinfo);
+
+ let mut configured_nodes: Vec<NumaNodeId> = parse_index_list(binding_list)?;
+ if configured_nodes.is_empty() {
+ configured_nodes = topo.allowed_nodeset().iter_set().map(NumaNodeId).collect();
+ }
+
+ if configured_nodes.len() < vnuma_nodes.len() {
+ return Err(BindError::BadSyntax(format!(
+ "VM has {} vNUMA nodes but only {} host NUMA nodes configured",
+ vnuma_nodes.len(),
+ configured_nodes.len()
+ )));
+ }
+
+ let pairs: Vec<(NumaNodeId, NumaNodeId)> = (0..vnuma_nodes.len())
+ .map(|i| (NumaNodeId(i as u32), configured_nodes[i]))
+ .collect();
+
+ let rules = build_numa_rules(&pairs, &vnuma_nodes)?;
+ bind_rules(&topo, &rules, qmp_cpuinfo)
+}
+
+pub fn validate_manual(sockets: u32, cores: u32, binding_map: &str) -> Result<(), BindError> {
+ if binding_map.is_empty() {
+ return Err(BindError::BadSyntax("cpu-bind-map is empty".into()));
+ }
+
+ let rules = parse_cpu_binding_map(binding_map)?;
+ let nr_vcpus = sockets.saturating_mul(cores);
+
+ let topo = Topology::new()
+ .map_err(|e| BindError::BadSyntax(format!("topology init failed: {e}")))?;
+ let topo_cpuset = topo.allowed_cpuset();
+
+ let mut seen_vcpu: HashSet<u32> = HashSet::new();
+
+ for rule in &rules {
+ if !rule.cpuset.is_subset_of(&topo_cpuset) {
+ let missing = rule.cpuset.difference(&topo_cpuset);
+ let first = missing.first_set().ok_or_else(|| {
+ BindError::BadSyntax("internal error: subset check failed but no CPU missing".into())
+ })?;
+ return Err(BindError::UnknownHostCpu(first));
+ }
+ for vcpu in &rule.vcpus {
+ if vcpu.0 >= nr_vcpus {
+ return Err(BindError::VcpuOutOfRange {
+ vcpu: vcpu.0,
+ max: nr_vcpus,
+ });
+ }
+ if !seen_vcpu.insert(vcpu.0) {
+ return Err(BindError::VcpuReused(vcpu.0));
+ }
+ }
+ }
+ Ok(())
+}
+
+pub fn validate_one_to_one(sockets: u32, cores: u32, binding_list: &str) -> Result<(), BindError> {
+ let nr_vcpus = sockets.saturating_mul(cores);
+
+ let topo = Topology::new()
+ .map_err(|e| BindError::BadSyntax(format!("topology init failed: {e}")))?;
+ let allowed = topo.allowed_cpuset();
+
+ let mut available: CpuSet = if binding_list.is_empty() {
+ allowed
+ } else {
+ let cpus: Vec<Cpu> = parse_index_list(binding_list)?;
+ let mut set = CpuSet::new();
+ for cpu in &cpus {
+ if !allowed.isset(cpu.0) {
+ return Err(BindError::UnknownHostCpu(cpu.0));
+ }
+ set.set(cpu.0);
+ }
+ set
+ };
+
+ available.singlify_per_core(&topo);
+
+ let core_count = available.weight();
+ if core_count < nr_vcpus as usize {
+ return Err(BindError::BadSyntax(format!(
+ "one-to-one mode requires {nr_vcpus} physical cores but only {core_count} are available",
+ )));
+ }
+ Ok(())
+}
+
+pub fn validate_numa(sockets: u32, binding_list: &str) -> Result<(), BindError> {
+ let topo = Topology::new()
+ .map_err(|e| BindError::BadSyntax(format!("topology init failed: {e}")))?;
+ let topo_nodeset = topo.allowed_nodeset();
+
+ let host_node_ids: Vec<u32> = if binding_list.is_empty() {
+ topo_nodeset.iter_set().collect()
+ } else {
+ let ids: Vec<NumaNodeId> = parse_index_list(binding_list)?;
+ for id in &ids {
+ if !topo_nodeset.isset(id.0) {
+ return Err(BindError::BadSyntax(format!(
+ "host NUMA node {} does not exist on this host",
+ id.0
+ )));
+ }
+ }
+ ids.into_iter().map(|n| n.0).collect()
+ };
+
+ if (sockets as usize) > host_node_ids.len() {
+ return Err(BindError::BadSyntax(format!(
+ "VM has {sockets} vNUMA nodes but only {} host NUMA nodes available",
+ host_node_ids.len(),
+ )));
+ }
+ Ok(())
+}
+
+pub fn validate_numa_manual(sockets: u32, binding_map: &str) -> Result<(), BindError> {
+ if binding_map.is_empty() {
+ return Err(BindError::BadSyntax("numa-binding-map is empty".into()));
+ }
+
+ let pairs = parse_numa_binding_map(binding_map)?;
+
+ let topo = Topology::new()
+ .map_err(|e| BindError::BadSyntax(format!("topology init failed: {e}")))?;
+ let topo_nodeset = topo.allowed_nodeset();
+
+ let mut seen_vnode: HashSet<u32> = HashSet::new();
+
+ for (vnode, hnode) in &pairs {
+ if !topo_nodeset.isset(hnode.0) {
+ return Err(BindError::BadSyntax(format!(
+ "host NUMA node {} does not exist on this host",
+ hnode.0
+ )));
+ }
+ if vnode.0 >= sockets {
+ return Err(BindError::BadSyntax(format!(
+ "guest vNUMA node {} is out of range (VM has {sockets} sockets)",
+ vnode.0
+ )));
+ }
+ if !seen_vnode.insert(vnode.0) {
+ return Err(BindError::BadSyntax(format!(
+ "guest vNUMA node {} appears more than once",
+ vnode.0
+ )));
+ }
+ }
+ Ok(())
+}
diff --git a/pve-qemu-server-cpu/src/error.rs b/pve-qemu-server-cpu/src/error.rs
new file mode 100644
index 0000000..73a569e
--- /dev/null
+++ b/pve-qemu-server-cpu/src/error.rs
@@ -0,0 +1,38 @@
+use thiserror::Error;
+
+#[derive(Debug, Error)]
+pub enum BindError {
+ // Parse errors
+ #[error("map entry '{0}' is missing a ':' separator")]
+ MissingColon(String),
+
+ #[error("invalid syntax in binding map: '{0}'")]
+ BadSyntax(String),
+
+ // Validation errors (pre-start)
+ #[error("host CPU {0} does not exist in this topology")]
+ UnknownHostCpu(u32),
+
+ #[error("vCPU {0} appears in more than one map entry")]
+ VcpuReused(u32),
+
+ #[error("vCPU {vcpu} is out of range (VM has vCPUs 0..{max})")]
+ VcpuOutOfRange { vcpu: u32, max: u32 },
+
+ // Runtime errors (post-start)
+ #[error("no running thread found for vCPU {0}")]
+ ThreadNotFound(u32),
+
+ #[error("invalid kernel TID {tid} for vCPU {vcpu}")]
+ BadThreadId { vcpu: u32, tid: u32 },
+
+ #[error("failed to init host topology: '{0}'")]
+ TopologyInit(String),
+
+ #[error("binding vCPU {vcpu} failed: {message}")]
+ Bind {
+ vcpu: u32,
+ /// Human-readable error from libhwloc (errno-based).
+ message: String,
+ },
+}
diff --git a/pve-qemu-server-cpu/src/hwloc/bindings.rs b/pve-qemu-server-cpu/src/hwloc/bindings.rs
new file mode 100644
index 0000000..7e9c5bc
--- /dev/null
+++ b/pve-qemu-server-cpu/src/hwloc/bindings.rs
@@ -0,0 +1,171 @@
+//! Minimal raw FFI bindings for libhwloc.
+//!
+//! Taken from https://github.com/HadrienG2/hwlocality/blob/main/hwlocality-sys/src/lib.rs
+
+#![allow(non_camel_case_types)]
+
+use std::cell::UnsafeCell;
+use std::marker::{PhantomData, PhantomPinned};
+
+use libc::{c_char, c_int, c_uint, c_void, pid_t};
+
+#[repr(C)]
+struct IncompleteType {
+ _data: [u8; 0],
+ _marker: PhantomData<(*mut u8, PhantomPinned, &'static UnsafeCell<u8>)>,
+}
+
+#[repr(C)]
+pub struct hwloc_topology(IncompleteType);
+#[repr(C)]
+pub struct hwloc_bitmap_s(IncompleteType);
+
+#[repr(C)]
+pub struct hwloc_obj_attr_u(IncompleteType);
+#[repr(C)]
+pub struct hwloc_info_s(IncompleteType);
+
+pub type hwloc_topology_t = *mut hwloc_topology;
+pub type hwloc_const_topology_t = *const hwloc_topology;
+
+pub type hwloc_bitmap_t = *mut hwloc_bitmap_s;
+pub type hwloc_const_bitmap_t = *const hwloc_bitmap_s;
+
+pub type hwloc_cpuset_t = hwloc_bitmap_t;
+pub type hwloc_const_cpuset_t = hwloc_const_bitmap_t;
+pub type hwloc_nodeset_t = hwloc_bitmap_t;
+pub type hwloc_const_nodeset_t = hwloc_const_bitmap_t;
+
+pub type hwloc_obj_type_t = c_uint;
+pub const HWLOC_OBJ_MACHINE: hwloc_obj_type_t = 0;
+pub const HWLOC_OBJ_PACKAGE: hwloc_obj_type_t = 1;
+pub const HWLOC_OBJ_CORE: hwloc_obj_type_t = 2;
+pub const HWLOC_OBJ_PU: hwloc_obj_type_t = 3;
+pub const HWLOC_OBJ_NUMANODE: hwloc_obj_type_t = 13;
+
+pub type hwloc_get_type_depth_e = c_int;
+pub const HWLOC_TYPE_DEPTH_UNKNOWN: hwloc_get_type_depth_e = -1;
+pub const HWLOC_TYPE_DEPTH_MULTIPLE: hwloc_get_type_depth_e = -2;
+pub const HWLOC_TYPE_DEPTH_NUMANODE: hwloc_get_type_depth_e = -3;
+
+pub type hwloc_cpubind_flags_t = c_int;
+pub const HWLOC_CPUBIND_THREAD: hwloc_cpubind_flags_t = 1 << 1;
+pub const HWLOC_CPUBIND_PROCESS: hwloc_cpubind_flags_t = 1 << 0;
+pub const HWLOC_CPUBIND_STRICT: hwloc_cpubind_flags_t = 1 << 2;
+pub const HWLOC_CPUBIND_NOMEMBIND: hwloc_cpubind_flags_t = 1 << 3;
+
+#[repr(C)]
+pub struct hwloc_obj {
+ pub ty: hwloc_obj_type_t,
+ pub subtype: *mut c_char,
+ pub os_index: c_uint,
+ pub name: *mut c_char,
+ pub total_memory: u64,
+ pub attr: *mut hwloc_obj_attr_u,
+ pub depth: hwloc_get_type_depth_e,
+ pub logical_index: c_uint,
+ pub next_cousin: hwloc_obj_t,
+ pub prev_cousin: hwloc_obj_t,
+ pub parent: hwloc_obj_t,
+ pub sibling_rank: c_uint,
+ pub next_sibling: hwloc_obj_t,
+ pub prev_sibling: hwloc_obj_t,
+ pub arity: c_uint,
+ pub children: *mut hwloc_obj_t,
+ pub first_child: hwloc_obj_t,
+ pub last_child: hwloc_obj_t,
+ pub symmetric_subtree: c_int,
+ pub memory_arity: c_uint,
+ pub memory_first_child: hwloc_obj_t,
+ pub io_arity: c_uint,
+ pub io_first_child: hwloc_obj_t,
+ pub misc_arity: c_uint,
+ pub misc_first_child: hwloc_obj_t,
+ pub cpuset: hwloc_cpuset_t,
+ pub complete_cpuset: hwloc_cpuset_t,
+ pub nodeset: hwloc_nodeset_t,
+ pub complete_nodeset: hwloc_nodeset_t,
+ pub infos: *mut hwloc_info_s,
+ pub infos_count: c_uint,
+ pub userdata: *mut c_void,
+ pub gp_index: u64,
+}
+pub type hwloc_obj_t = *mut hwloc_obj;
+
+#[link(name = "hwloc")]
+unsafe extern "C" {
+ pub fn hwloc_topology_init(topology: *mut hwloc_topology_t) -> c_int;
+ pub fn hwloc_topology_load(topology: hwloc_topology_t) -> c_int;
+ pub fn hwloc_topology_destroy(topology: hwloc_topology_t);
+
+ pub fn hwloc_topology_get_topology_cpuset(
+ topology: hwloc_const_topology_t,
+ ) -> hwloc_const_cpuset_t;
+ pub fn hwloc_topology_get_topology_nodeset(
+ topology: hwloc_const_topology_t,
+ ) -> hwloc_const_nodeset_t;
+
+ pub fn hwloc_topology_get_allowed_cpuset(
+ topology: hwloc_const_topology_t,
+ ) -> hwloc_const_cpuset_t;
+ pub fn hwloc_topology_get_allowed_nodeset(
+ topology: hwloc_const_topology_t,
+ ) -> hwloc_const_nodeset_t;
+
+ pub fn hwloc_get_type_depth(
+ topology: hwloc_const_topology_t,
+ object_type: hwloc_obj_type_t,
+ ) -> hwloc_get_type_depth_e;
+ pub fn hwloc_get_nbobjs_by_depth(
+ topology: hwloc_const_topology_t,
+ depth: hwloc_get_type_depth_e,
+ ) -> c_uint;
+ pub fn hwloc_get_obj_by_depth(
+ topology: hwloc_const_topology_t,
+ depth: hwloc_get_type_depth_e,
+ idx: c_uint,
+ ) -> hwloc_obj_t;
+
+ pub fn hwloc_bitmap_alloc() -> hwloc_bitmap_t;
+ pub fn hwloc_bitmap_free(bitmap: hwloc_bitmap_t);
+ pub fn hwloc_bitmap_dup(src: hwloc_const_bitmap_t) -> hwloc_bitmap_t;
+
+ pub fn hwloc_bitmap_set(bitmap: hwloc_bitmap_t, id: c_uint) -> c_int;
+ pub fn hwloc_bitmap_isset(bitmap: hwloc_const_bitmap_t, id: c_uint) -> c_int;
+ pub fn hwloc_bitmap_weight(bitmap: hwloc_const_bitmap_t) -> c_int;
+ pub fn hwloc_bitmap_first(bitmap: hwloc_const_bitmap_t) -> c_int;
+ pub fn hwloc_bitmap_next(bitmap: hwloc_const_bitmap_t, prev: c_int) -> c_int;
+
+ pub fn hwloc_bitmap_isincluded(
+ left: hwloc_const_bitmap_t,
+ right: hwloc_const_bitmap_t,
+ ) -> c_int;
+ pub fn hwloc_bitmap_and(
+ result: hwloc_bitmap_t,
+ bitmap1: hwloc_const_bitmap_t,
+ bitmap2: hwloc_const_bitmap_t,
+ ) -> c_int;
+ pub fn hwloc_bitmap_andnot(
+ result: hwloc_bitmap_t,
+ bitmap1: hwloc_const_bitmap_t,
+ bitmap2: hwloc_const_bitmap_t,
+ ) -> c_int;
+ pub fn hwloc_bitmap_not(result: hwloc_bitmap_t, bitmap: hwloc_const_bitmap_t) -> c_int;
+ pub fn hwloc_bitmap_or(
+ result: hwloc_bitmap_t,
+ bitmap1: hwloc_const_bitmap_t,
+ bitmap2: hwloc_const_bitmap_t,
+ ) -> c_int;
+
+ pub fn hwloc_bitmap_singlify_per_core(
+ topology: hwloc_const_topology_t,
+ cpuset: hwloc_cpuset_t,
+ which: c_uint,
+ ) -> c_int;
+
+ pub fn hwloc_linux_set_tid_cpubind(
+ topology: hwloc_const_topology_t,
+ tid: pid_t,
+ set: hwloc_const_cpuset_t,
+ ) -> c_int;
+}
diff --git a/pve-qemu-server-cpu/src/hwloc/mod.rs b/pve-qemu-server-cpu/src/hwloc/mod.rs
new file mode 100644
index 0000000..2e3e741
--- /dev/null
+++ b/pve-qemu-server-cpu/src/hwloc/mod.rs
@@ -0,0 +1,222 @@
+//! Safe wrappers around the libhwloc FFI bindings.
+//!
+//! Replace with https://github.com/HadrienG2/hwlocality
+//! once the crate is mature enough
+
+mod bindings;
+
+use std::io;
+use bindings::*;
+
+/// Return the current `errno` as a message. hwloc reports errors via `errno`
+/// and a `-1` return value, so this is what you feed into user-facing errors.
+fn last_error(ctx: &str) -> String {
+ format!("{ctx}: {}", io::Error::last_os_error())
+}
+
+fn check(rc: std::ffi::c_int, ctx: &str) -> Result<(), String> {
+ if rc < 0 {
+ Err(last_error(ctx))
+ } else {
+ Ok(())
+ }
+}
+
+pub struct Topology(*mut hwloc_topology);
+
+impl Topology {
+ pub fn new() -> Result<Self, String> {
+ let mut raw: hwloc_topology_t = std::ptr::null_mut();
+ unsafe { check(hwloc_topology_init(&mut raw), "hwloc_topology_init")? };
+ let rc = unsafe { hwloc_topology_load(raw) };
+ if rc < 0 {
+ unsafe { hwloc_topology_destroy(raw) };
+ return Err(last_error("hwloc_topology_load"));
+ }
+ Ok(Topology(raw))
+ }
+
+ pub fn cpuset(&self) -> CpuSet {
+ unsafe {
+ let raw = hwloc_topology_get_topology_cpuset(self.0);
+ CpuSet::dup_from(raw)
+ }
+ }
+
+ pub fn nodeset(&self) -> CpuSet {
+ unsafe {
+ let raw = hwloc_topology_get_topology_nodeset(self.0);
+ CpuSet::dup_from(raw)
+ }
+ }
+
+ pub fn allowed_cpuset(&self) -> CpuSet {
+ unsafe {
+ let raw = hwloc_topology_get_allowed_cpuset(self.0);
+ CpuSet::dup_from(raw)
+ }
+ }
+
+ pub fn allowed_nodeset(&self) -> CpuSet {
+ unsafe {
+ let raw = hwloc_topology_get_allowed_nodeset(self.0);
+ CpuSet::dup_from(raw)
+ }
+ }
+
+ pub(crate) fn as_raw(&self) -> hwloc_const_topology_t {
+ self.0
+ }
+}
+
+impl Drop for Topology {
+ fn drop(&mut self) {
+ unsafe { hwloc_topology_destroy(self.0) };
+ }
+}
+
+pub struct CpuSet(*mut hwloc_bitmap_s);
+
+impl Clone for CpuSet {
+ fn clone(&self) -> Self {
+ unsafe {
+ let owned = hwloc_bitmap_dup(self.0);
+ assert!(!owned.is_null(), "hwloc_bitmap_dup failed");
+ CpuSet(owned)
+ }
+ }
+}
+
+impl std::fmt::Debug for CpuSet {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_tuple("CpuSet")
+ .field(&self.iter_set().collect::<Vec<_>>())
+ .finish()
+ }
+}
+
+impl CpuSet {
+ pub fn new() -> Self {
+ let raw = unsafe { hwloc_bitmap_alloc() };
+ assert!(!raw.is_null(), "hwloc_bitmap_alloc failed");
+ CpuSet(raw)
+ }
+
+ /// Allocate a bitmap containing exactly one CPU.
+ pub fn single(idx: u32) -> Self {
+ let mut set = Self::new();
+ set.set(idx);
+ set
+ }
+
+ /// Clone a borrowed bitmap returned by the topology.
+ fn dup_from(raw: hwloc_const_bitmap_t) -> Self {
+ let owned = unsafe { hwloc_bitmap_dup(raw) };
+ assert!(!owned.is_null(), "hwloc_bitmap_dup failed");
+ CpuSet(owned)
+ }
+
+ pub fn difference(&self, other: &CpuSet) -> CpuSet {
+ let out = CpuSet::new();
+ let rc = unsafe { hwloc_bitmap_andnot(out.0, self.0, other.0) };
+ assert!(rc >= 0, "hwloc_bitmap_andnot failed");
+ out
+ }
+
+ pub fn intersect(&mut self, other: &CpuSet) {
+ let rc = unsafe { hwloc_bitmap_and(self.0, self.0, other.0) };
+ assert!(rc >= 0, "hwloc_bitmap_and failed");
+ }
+
+ pub fn set(&mut self, idx: u32) {
+ let rc = unsafe { hwloc_bitmap_set(self.0, idx) };
+ assert!(rc >= 0, "hwloc_bitmap_set failed");
+ }
+
+ pub fn isset(&self, idx: u32) -> bool {
+ unsafe { hwloc_bitmap_isset(self.0, idx) != 0 }
+ }
+
+ pub fn weight(&self) -> usize {
+ let w = unsafe { hwloc_bitmap_weight(self.0) };
+ w.max(0) as usize
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.first_set().is_none()
+ }
+
+ pub fn first_set(&self) -> Option<u32> {
+ let i = unsafe { hwloc_bitmap_first(self.0) };
+ if i < 0 { None } else { Some(i as u32) }
+ }
+
+ fn next_set_after(&self, prev: u32) -> Option<u32> {
+ let i = unsafe { hwloc_bitmap_next(self.0, prev as std::ffi::c_int) };
+ if i < 0 { None } else { Some(i as u32) }
+ }
+
+ pub fn iter_set(&self) -> impl Iterator<Item = u32> + '_ {
+ std::iter::successors(self.first_set(), move |&cur| self.next_set_after(cur))
+ }
+
+ pub fn is_subset_of(&self, other: &CpuSet) -> bool {
+ unsafe { hwloc_bitmap_isincluded(self.0, other.0) != 0 }
+ }
+
+ /// Remove SMT siblings from each core (requires hwloc 2.2+).
+ pub fn singlify_per_core(&mut self, topo: &Topology) {
+ let rc = unsafe { hwloc_bitmap_singlify_per_core(topo.as_raw(), self.0, 0) };
+ assert!(rc >= 0, "hwloc_bitmap_singlify_per_core failed");
+ }
+}
+
+impl Drop for CpuSet {
+ fn drop(&mut self) {
+ unsafe { hwloc_bitmap_free(self.0) };
+ }
+}
+
+/// Return the CPU set belonging to the host NUMA node with the given OS index.
+///
+/// The returned set is intersected with the topology's allowed CPU set.
+/// Returns `None` if the node does not exist,or if it has no allowed
+/// CPUs (e.g., a CPU-less memory node).
+pub fn numa_node_cpuset(topo: &Topology, node_id: u32) -> Option<CpuSet> {
+ let depth = unsafe { hwloc_get_type_depth(topo.as_raw(), HWLOC_OBJ_NUMANODE) };
+ if depth == HWLOC_TYPE_DEPTH_UNKNOWN {
+ return None;
+ }
+
+ let count = unsafe { hwloc_get_nbobjs_by_depth(topo.as_raw(), depth) };
+ for i in 0..count {
+ let obj = unsafe { hwloc_get_obj_by_depth(topo.as_raw(), depth, i) };
+ if obj.is_null() {
+ continue;
+ }
+
+ if unsafe { (*obj).os_index } == node_id {
+ let raw = unsafe { (*obj).cpuset };
+
+ // hwloc guarantees cpuset != NULL for NUMA nodes.
+ let mut set = CpuSet::dup_from(raw);
+
+ let allowed = topo.allowed_cpuset();
+ set.intersect(&allowed);
+
+ if set.is_empty() {
+ return None;
+ }
+
+ return Some(set);
+ }
+ }
+
+ None
+}
+
+#[cfg(target_os = "linux")]
+pub fn bind_thread(topo: &Topology, tid: i32, set: &CpuSet) -> Result<(), String> {
+ let rc = unsafe { hwloc_linux_set_tid_cpubind(topo.as_raw(), tid, set.0) };
+ check(rc, "hwloc_linux_set_tid_cpubind")
+}
diff --git a/pve-qemu-server-cpu/src/lib.rs b/pve-qemu-server-cpu/src/lib.rs
new file mode 100644
index 0000000..e297bd9
--- /dev/null
+++ b/pve-qemu-server-cpu/src/lib.rs
@@ -0,0 +1,92 @@
+mod bind;
+mod hwloc;
+mod qmp_cpu;
+mod error;
+
+pub use error::BindError;
+
+pub fn validate_binding(
+ mode: &str,
+ sockets: u32,
+ cores: u32,
+ binding_cfg: &str,
+) -> Result<(), BindError> {
+ match mode.parse::<bind::Mode>()? {
+ bind::Mode::None => Ok(()),
+ bind::Mode::Manual => bind::validate_manual(sockets, cores, binding_cfg),
+ bind::Mode::OneToOne => bind::validate_one_to_one(sockets, cores, binding_cfg),
+ bind::Mode::Numa => bind::validate_numa(sockets, binding_cfg),
+ bind::Mode::NumaManual => bind::validate_numa_manual(sockets, binding_cfg),
+ }
+}
+
+/// Apply CPU pinning to a running VM.
+///
+/// The function parses the QMP `query-cpus-fast` response and binds each
+/// vCPU thread according to the selected mode.
+///
+/// # Arguments
+///
+/// * `mode` - Pinning mode: `none`, `manual`, `one-to-one`, `numa`, or
+/// `numa-manual`.
+/// * `binding_info` - Mode-specific CPU or NUMA mapping. Ignored for `none`.
+/// * `cpuinfo_json` - JSON output from QMP `query-cpus-fast`.
+///
+/// # Modes
+///
+/// | Mode | Meaning |
+/// |---|---|
+/// | `none` | Do not pin vCPUs. |
+/// | `manual` | Explicitly map vCPUs to host CPUs. |
+/// | `one-to-one` | Assign each vCPU to its own physical core. |
+/// | `numa` | Map guest NUMA nodes to host NUMA nodes in order. |
+/// | `numa-manual` | Explicitly map guest NUMA nodes to host NUMA nodes. |
+///
+/// # Examples
+///
+/// ```ignore
+///
+/// // Manual mode:
+/// // vCPU 0 -> host CPUs 2-3
+/// // vCPU 1 -> host CPU 4
+/// bind_vcpus("manual", "0:2-3;1:4", &cpuinfo_json)?;
+///
+/// // One-to-one mode:
+/// // Distribute vCPUs over host CPUs 0-7.
+/// bind_vcpus("one-to-one", "0-7", &cpuinfo_json)?;
+///
+/// // One-to-one mode over all allowed host CPUs.
+/// bind_vcpus("one-to-one", "", &cpuinfo_json)?;
+///
+/// // NUMA mode:
+/// // Guest NUMA node 0 -> host NUMA node 0
+/// // Guest NUMA node 1 -> host NUMA node 1
+/// bind_vcpus("numa", "0;1", &cpuinfo_json)?;
+///
+/// // NUMA mode over all allowed host NUMA nodes.
+/// bind_vcpus("numa", "", &cpuinfo_json)?;
+///
+/// // Manual NUMA mode:
+/// // Guest NUMA node 0 -> host NUMA node 1
+/// // Guest NUMA node 1 -> host NUMA node 0
+/// bind_vcpus("numa-manual", "0:1;1:0", &cpuinfo_json)?;
+/// ```
+///
+/// # Errors
+///
+/// Returns [`BindError`] if the mode or binding syntax is invalid, if the
+/// requested host CPUs or NUMA nodes do not exist, or if binding fails.
+pub fn bind_vcpus(
+ mode: &str,
+ binding_cfg: &str,
+ cpuinfo_json: &str,
+) -> Result<(), BindError> {
+ let qmp_cpuinfo = qmp_cpu::parse_cpuinfo(cpuinfo_json)?;
+ match mode.parse::<bind::Mode>()? {
+ bind::Mode::None => Ok(()),
+ bind::Mode::Manual => bind::bind_manual(binding_cfg, &qmp_cpuinfo),
+ bind::Mode::OneToOne => bind::bind_one_to_one(binding_cfg, &qmp_cpuinfo),
+ bind::Mode::Numa => bind::bind_numa(binding_cfg, &qmp_cpuinfo),
+ bind::Mode::NumaManual => bind::bind_numa_manual(binding_cfg, &qmp_cpuinfo),
+ }
+}
diff --git a/pve-qemu-server-cpu/src/qmp_cpu.rs b/pve-qemu-server-cpu/src/qmp_cpu.rs
new file mode 100644
index 0000000..00d88c5
--- /dev/null
+++ b/pve-qemu-server-cpu/src/qmp_cpu.rs
@@ -0,0 +1,99 @@
+//! Parsing of the QEMU `query-cpus-fast` QMP response.
+//!
+//! The response is a JSON array; each element describes one guest vCPU
+//! and carries both its guest-visible topology (socket/core/NUMA node)
+//! and its host kernel TID. Every binding mode consumes this shape, so
+//! the parsing lives here rather than inside any individual mode.
+//!
+//! The full QEMU schema is modeled, including fields not currently used
+//! by any binding mode. Serde ignores fields it doesn't know about, but
+//! keeping them explicit documents the wire format and makes future
+//! modes cheaper to implement.
+
+use serde::Deserialize;
+
+use crate::BindError;
+
+/// One entry of the `query-cpus-fast` array.
+#[derive(Debug, Clone, Deserialize)]
+pub struct QmpCpu {
+ /// Guest-visible vCPU index (0 .. sockets*cores).
+ #[serde(rename = "cpu-index")]
+ pub cpu_index: u32,
+
+ /// Host kernel TID of the QEMU vCPU thread.
+ #[serde(rename = "thread-id")]
+ pub thread_id: u32,
+
+ /// QOM path of the CPU object, e.g. `/machine/unattached/device[0]`.
+ #[expect(dead_code)]
+ #[serde(rename = "qom-path")]
+ pub qom_path: String,
+
+ /// QOM type of the CPU object, e.g. `host-x86_64-cpu`.
+ #[expect(dead_code)]
+ #[serde(rename = "qom-type")]
+ pub qom_type: String,
+
+ /// Target architecture, e.g. `x86_64`.
+ #[expect(dead_code)]
+ pub target: String,
+
+ /// Guest-visible topology properties of the vCPU.
+ pub props: QmpCpuProps,
+}
+
+/// Guest-visible topology properties of a vCPU.
+#[derive(Debug, Clone, Deserialize)]
+pub struct QmpCpuProps {
+ /// Guest socket the vCPU belongs to.
+ #[expect(dead_code)]
+ #[serde(rename = "socket-id")]
+ pub socket_id: u32,
+
+ /// Guest core within that socket.
+ #[expect(dead_code)]
+ #[serde(rename = "core-id")]
+ pub core_id: u32,
+
+ /// Guest NUMA node the vCPU belongs to.
+ ///
+ /// QEMU reports this even when `numa` is disabled in the VM config,
+ /// in which case every vCPU is on node 0.
+ #[serde(rename = "node-id")]
+ pub node_id: u32,
+
+ /// Guest thread within the core.
+ ///
+ /// Always 0 today: QEMU's CPU object model carries this field for
+ /// SMT support in the guest, but PVE does not expose SMT to guests,
+ /// so it is always zero in practice. Modeled for completeness.
+ /// TODO: PVE should expose SMT to the guests
+ #[expect(dead_code)]
+ #[serde(rename = "thread-id")]
+ pub thread_id: u32,
+}
+
+// returned vec is sorted by vCPU id (0,1,2,3...)
+// Guarantees vec[id] == vec[id].cpu_index
+pub fn parse_cpuinfo(cpuinfo_json: &str) -> Result<Vec<QmpCpu>, BindError> {
+ let mut cpus: Vec<QmpCpu> = serde_json::from_str(cpuinfo_json)
+ .map_err(|e| BindError::BadSyntax(format!("invalid query-cpus-fast JSON: {e}")))?;
+
+ // QEMU assigns cpu-index sequentially from 0. Sort by it so
+ // `vec[i]` indexes vCPU i, then verify the values really are
+ // 0..N-1 so that assumption holds.
+ cpus.sort_unstable_by_key(|c| c.cpu_index);
+
+ for (expected, cpu) in cpus.iter().enumerate() {
+ if cpu.cpu_index != expected as u32 {
+ return Err(BindError::BadSyntax(format!(
+ "query-cpus-fast returned non-contiguous cpu-index: \
+ expected {expected}, got {}",
+ cpu.cpu_index
+ )));
+ }
+ }
+
+ Ok(cpus)
+}
--
2.47.3
next prev parent reply other threads:[~2026-10-22 9:23 UTC|newest]
Thread overview: 4+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-10-22 9:22 [PATCH proxmox-perl-rs/qemu-server/qemu-server-rs v3 0/3] vCPU binding for qemu guests Elias Huhsovitz
2026-10-22 9:22 ` Elias Huhsovitz [this message]
2026-10-22 9:22 ` [PATCH proxmox-perl-rs v3 2/3] pve: cpu: add PVE::RS::CPU::Bind perl bindings Elias Huhsovitz
2026-10-22 9:22 ` [PATCH qemu-server v3 3/3] cpu: add CPU binding Elias Huhsovitz
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20261022092227.58871-2-e.huhsovitz@proxmox.com \
--to=e.huhsovitz@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.