From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id BA46D1FF0AF for ; Thu, 22 Oct 2026 11:23:06 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 4B502215F1; Thu, 22 Oct 2026 11:23:05 +0200 (CEST) From: Elias Huhsovitz To: pve-devel@lists.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 Message-ID: <20261022092227.58871-2-e.huhsovitz@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261022092227.58871-1-e.huhsovitz@proxmox.com> References: <20261022092227.58871-1-e.huhsovitz@proxmox.com> MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1792660953313 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.511 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) KAM_SHORT 0.001 Use of a URL Shortener for very short URL RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: TLK7TUFXVKWVJUHSFYH3U7VRI53SQ7MJ X-Message-ID-Hash: TLK7TUFXVKWVJUHSFYH3U7VRI53SQ7MJ X-MailFrom: e.huhsovitz@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header CC: Elias Huhsovitz X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: 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 --- 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 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 , + rustc:native (>= 1.94) , + libstd-rust-dev , + 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 +Maintainer: Proxmox Support Team +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 +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 . 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 " +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, +} + +#[derive(Debug, Clone)] +struct BindRule { + vcpus: Vec, + cpuset: CpuSet, +} + +#[derive(Debug, Clone)] +struct NumaBindRule { + vnode: VnumaNode, + host_node: NumaNodeId, +} + +impl From for Cpu { + fn from(v: u32) -> Self { + Self(v) + } +} +impl From for Vcpu { + fn from(v: u32) -> Self { + Self(v) + } +} +impl From for NumaNodeId { + fn from(v: u32) -> Self { + Self(v) + } +} + +trait BindableRule { + fn vcpus(&self) -> &[Vcpu]; + fn resolve_cpuset(&self, topo: &Topology) -> Result; +} + +impl BindableRule for BindRule { + fn vcpus(&self) -> &[Vcpu] { + &self.vcpus + } + fn resolve_cpuset(&self, _topo: &Topology) -> Result { + Ok(self.cpuset.clone()) + } +} + +impl BindableRule for NumaBindRule { + fn vcpus(&self) -> &[Vcpu] { + &self.vnode.cpus + } + fn resolve_cpuset(&self, topo: &Topology) -> Result { + 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( + 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 { + 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, 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 = 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, 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, 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>(s: &str) -> Result, 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, 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 { + let max_node = qmp_cpuinfo + .iter() + .map(|c| c.props.node_id) + .max() + .unwrap_or(0); + let mut buckets: Vec> = 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, 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 = 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 = 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 = 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 = 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 = 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 = 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 = if binding_list.is_empty() { + topo_nodeset.iter_set().collect() + } else { + let ids: Vec = 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 = 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)>, +} + +#[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 { + 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::>()) + .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 { + 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 { + 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 + '_ { + 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 { + 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::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::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, BindError> { + let mut cpus: Vec = 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