Skip to content

Commit 0055cc0

Browse files
authored
Merge pull request #1065 from Dstack-TEE/fix/gpu-sbr-vfio-ioctls
vmm: sanitize GPUs through vfio-ioctls hot reset
2 parents cb7ea9b + c975611 commit 0055cc0

6 files changed

Lines changed: 196 additions & 63 deletions

File tree

‎dstack/Cargo.lock‎

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

‎dstack/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ dstack-verifier = { path = "verifier", default-features = false }
126126
size-parser = { path = "size-parser" }
127127
nvattest = { path = "nvattest" }
128128
wavekv = "2.1"
129+
vfio-ioctls = { git = "https://github.com/kvinwang/vfio.git", rev = "1b560c2", default-features = false }
129130

130131
# Core dependencies
131132
anyhow = { version = "1.0.97", default-features = false }

‎dstack/vmm/Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ tar.workspace = true
6868
tempfile.workspace = true
6969
wait-timeout.workspace = true
7070
listenfd.workspace = true
71+
vfio-ioctls.workspace = true
7172

7273
[dev-dependencies]
7374
insta.workspace = true

‎dstack/vmm/src/gpu_reset.rs‎

Lines changed: 129 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,49 @@
22
//
33
// SPDX-License-Identifier: Apache-2.0
44

5+
//! GPU sanitization before QEMU attach.
6+
//!
7+
//! Terminating QEMU while GSP/SPDM initialization is in progress can leave
8+
//! stale FSP/SPDM session state in a GPU. VFIO's attach-time FLR does not
9+
//! clear that state; a PCIe Secondary Bus Reset does. The next guest
10+
//! otherwise fails with an SPDM timeout followed by GSP/RmInitAdapter errors.
11+
//!
12+
//! The VMM runs as an unprivileged user, so it cannot issue the SBR by
13+
//! writing Bridge Control in the upstream bridge's sysfs config space: that
14+
//! file is writable by root only, as is /sys/bus/pci/drivers_probe. Instead
15+
//! the VFIO_DEVICE_PCI_HOT_RESET ioctl asks the kernel to perform the same
16+
//! bus reset. The ioctl is authorized by device ownership rather than
17+
//! privilege: the caller must present an fd for every VFIO group affected by
18+
//! the reset. The group nodes under /dev/vfio are the same ones QEMU opens
19+
//! to attach the GPU, so the VMM user already has access to them.
20+
//!
21+
//! A single group fd suffices only because every sanitized GPU sits alone
22+
//! behind a dedicated PCIe bridge and alone in its IOMMU group. The bridge
23+
//! topology is validated first, and the kernel-reported set of devices
24+
//! affected by the reset must all belong to the GPU's own group; anything
25+
//! else aborts the launch rather than risking disruption to other devices.
26+
//!
27+
//! A VFIO group can be opened by only one process at a time, so every fd is
28+
//! closed again before QEMU is spawned.
29+
530
use std::{
631
collections::BTreeSet,
7-
fs::{File, OpenOptions},
32+
fs::File,
833
os::unix::fs::FileExt,
934
path::{Path, PathBuf},
35+
sync::Arc,
1036
thread,
1137
time::{Duration, Instant},
1238
};
1339

1440
use anyhow::{bail, Context, Result};
1541
use tracing::info;
42+
use vfio_ioctls::{PciHotResetDevice, VfioContainer, VfioDevice};
1643

1744
use crate::{app::GpuConfig, config::GpuConfig as HostGpuConfig};
1845

1946
const PCI_SYSFS_DEVICES: &str = "/sys/bus/pci/devices";
2047
const PCI_BRIDGE_CLASS: u32 = 0x0604;
21-
const PCI_BRIDGE_CONTROL: u64 = 0x3e;
22-
const PCI_BRIDGE_CTL_BUS_RESET: u16 = 1 << 6;
23-
const SBR_ASSERT_TIME: Duration = Duration::from_millis(100);
2448
const SBR_POLL_INTERVAL: Duration = Duration::from_millis(100);
2549
const SBR_STABLE_TIME: Duration = Duration::from_millis(500);
2650

@@ -33,63 +57,123 @@ pub fn sanitize_on_attach(host: &HostGpuConfig, devices: &GpuConfig) -> Result<(
3357
if !host.enabled || !host.sanitize_on_attach || devices.gpus.is_empty() {
3458
return Ok(());
3559
}
36-
sanitize_at(
37-
Path::new(PCI_SYSFS_DEVICES),
38-
devices,
39-
Duration::from_millis(host.sbr_timeout_ms),
40-
)
60+
let slots = devices
61+
.gpus
62+
.iter()
63+
.map(|gpu| gpu.slot.clone())
64+
.collect::<Vec<_>>();
65+
sanitize_slots(&slots, Duration::from_millis(host.sbr_timeout_ms))
4166
}
4267

43-
fn sanitize_at(sysfs_devices: &Path, devices: &GpuConfig, timeout: Duration) -> Result<()> {
44-
let selected = devices
45-
.gpus
68+
/// Sanitizes the given GPU slots. Entry point for the `sanitize-gpu`
69+
/// subcommand; `sanitize_on_attach` funnels here as well.
70+
pub fn sanitize_slots(slots: &[String], timeout: Duration) -> Result<()> {
71+
if slots.is_empty() {
72+
bail!("no GPU slots specified");
73+
}
74+
let selected = slots
4675
.iter()
47-
.map(|gpu| normalize_slot(&gpu.slot))
76+
.map(|slot| normalize_slot(slot))
4877
.collect::<BTreeSet<_>>();
49-
let mut bridges = BTreeSet::new();
78+
sanitize_at(Path::new(PCI_SYSFS_DEVICES), &selected, timeout)
79+
}
5080

51-
for gpu in &selected {
81+
fn sanitize_at(sysfs_devices: &Path, selected: &BTreeSet<String>, timeout: Duration) -> Result<()> {
82+
for gpu in selected {
5283
let bridge = upstream_bridge(sysfs_devices, gpu)?;
5384
ensure_dedicated_bridge(&bridge, gpu)?;
54-
bridges.insert(bridge);
5585
}
5686

57-
for bridge in bridges {
58-
info!(bridge = %bridge.display(), "sanitizing GPU with PCIe Secondary Bus Reset");
59-
secondary_bus_reset(&bridge)
60-
.with_context(|| format!("failed to sanitize GPU using bridge {}", bridge.display()))?;
87+
for gpu in selected {
88+
info!(gpu = %gpu, "sanitizing GPU with VFIO PCI hot reset");
89+
hot_reset(sysfs_devices, gpu)
90+
.with_context(|| format!("failed to sanitize GPU {gpu} with VFIO hot reset"))?;
91+
}
92+
wait_for_vfio_ready(sysfs_devices, selected, timeout)?;
93+
Ok(())
94+
}
95+
96+
/// Issues a Secondary Bus Reset on the GPU's upstream bridge through VFIO.
97+
///
98+
/// All fds are dropped on return so that QEMU can open the group afterwards.
99+
fn hot_reset(sysfs_devices: &Path, slot: &str) -> Result<()> {
100+
let group_id = iommu_group_id(sysfs_devices, slot)?;
101+
let container = Arc::new(VfioContainer::new(None).context("failed to open VFIO container")?);
102+
let device = VfioDevice::new(&sysfs_devices.join(slot), container)
103+
.with_context(|| format!("failed to open VFIO device {slot}"))?;
104+
105+
let dependents = device
106+
.pci_hot_reset_info()
107+
.context("failed to query PCI hot reset scope")?;
108+
let foreign = dependents
109+
.iter()
110+
.filter(|dep| dep.group_id != group_id)
111+
.map(format_dependent)
112+
.collect::<Vec<_>>();
113+
if !foreign.is_empty() {
114+
bail!(
115+
"refusing hot reset of GPU {slot}: it would also reset devices outside \
116+
IOMMU group {group_id}: {}",
117+
foreign.join(", ")
118+
);
61119
}
62-
wait_for_vfio_ready(sysfs_devices, &selected, timeout)?;
120+
info!(
121+
gpu = %slot,
122+
affected = %dependents.iter().map(format_dependent).collect::<Vec<_>>().join(", "),
123+
"issuing VFIO PCI hot reset"
124+
);
125+
126+
device
127+
.pci_hot_reset_own_group()
128+
.context("failed to perform PCI hot reset")?;
63129
Ok(())
64130
}
65131

132+
fn format_dependent(dep: &PciHotResetDevice) -> String {
133+
format!(
134+
"{:04x}:{:02x}:{:02x}.{:x} (group {})",
135+
dep.segment,
136+
dep.bus,
137+
dep.devfn >> 3,
138+
dep.devfn & 0x7,
139+
dep.group_id
140+
)
141+
}
142+
143+
fn iommu_group_id(sysfs_devices: &Path, slot: &str) -> Result<u32> {
144+
let link = sysfs_devices.join(slot).join("iommu_group");
145+
let group = link
146+
.canonicalize()
147+
.with_context(|| format!("failed to resolve IOMMU group of {slot}"))?;
148+
group
149+
.file_name()
150+
.and_then(|name| name.to_str())
151+
.and_then(|name| name.parse().ok())
152+
.with_context(|| format!("invalid IOMMU group path {}", group.display()))
153+
}
154+
66155
fn wait_for_vfio_ready(
67156
sysfs_devices: &Path,
68157
devices: &BTreeSet<String>,
69158
timeout: Duration,
70159
) -> Result<()> {
71-
let drivers_probe = sysfs_devices
72-
.parent()
73-
.context("PCI sysfs devices directory has no parent")?
74-
.join("drivers_probe");
75-
let deadline = Instant::now() + timeout;
160+
let started = Instant::now();
161+
let deadline = started + timeout;
76162
let mut stable_since = None;
77163
loop {
78164
let not_ready = devices
79165
.iter()
80-
.filter(|slot| {
81-
let device = sysfs_devices.join(slot);
82-
if !driver_is_vfio(&device) {
83-
let _ = fs_err::write(&drivers_probe, format!("{slot}\n"));
84-
}
85-
!is_vfio_ready(&device)
86-
})
166+
.filter(|slot| !is_vfio_ready(&sysfs_devices.join(slot)))
87167
.cloned()
88168
.collect::<Vec<_>>();
89169
if not_ready.is_empty() {
90170
let since = *stable_since.get_or_insert_with(Instant::now);
91171
if since.elapsed() >= SBR_STABLE_TIME {
92-
info!(count = devices.len(), "all sanitized GPUs are VFIO-ready");
172+
info!(
173+
count = devices.len(),
174+
elapsed_ms = started.elapsed().as_millis() as u64,
175+
"all sanitized GPUs are VFIO-ready"
176+
);
93177
return Ok(());
94178
}
95179
} else {
@@ -194,39 +278,12 @@ fn is_pci_slot(name: &str) -> bool {
194278
.all(|(i, c)| matches!(i, 4 | 7 | 10) || c.is_ascii_hexdigit())
195279
}
196280

197-
fn secondary_bus_reset(bridge: &Path) -> Result<()> {
198-
let config_path = bridge.join("config");
199-
let config = OpenOptions::new()
200-
.read(true)
201-
.write(true)
202-
.open(&config_path)
203-
.with_context(|| format!("failed to open {}", config_path.display()))?;
204-
let original = read_u16(&config, PCI_BRIDGE_CONTROL)?;
205-
write_u16(
206-
&config,
207-
PCI_BRIDGE_CONTROL,
208-
original | PCI_BRIDGE_CTL_BUS_RESET,
209-
)?;
210-
thread::sleep(SBR_ASSERT_TIME);
211-
write_u16(
212-
&config,
213-
PCI_BRIDGE_CONTROL,
214-
original & !PCI_BRIDGE_CTL_BUS_RESET,
215-
)?;
216-
Ok(())
217-
}
218-
219281
fn read_u16(file: &File, offset: u64) -> Result<u16> {
220282
let mut value = [0_u8; 2];
221283
file.read_exact_at(&mut value, offset)?;
222284
Ok(u16::from_le_bytes(value))
223285
}
224286

225-
fn write_u16(file: &File, offset: u64, value: u16) -> Result<()> {
226-
file.write_all_at(&value.to_le_bytes(), offset)?;
227-
Ok(())
228-
}
229-
230287
#[cfg(test)]
231288
mod tests {
232289
use super::*;
@@ -247,6 +304,17 @@ mod tests {
247304
assert!(!is_pci_slot("0000:0g:00.0"));
248305
}
249306

307+
#[test]
308+
fn formats_dependent_devices_with_pci_slot_and_function() {
309+
let dep = PciHotResetDevice {
310+
group_id: 46,
311+
segment: 0,
312+
bus: 0x0f,
313+
devfn: 0x1,
314+
};
315+
assert_eq!(format_dependent(&dep), "0000:0f:00.1 (group 46)");
316+
}
317+
250318
#[test]
251319
fn skips_sanitization_when_gpu_passthrough_is_disabled() {
252320
let host = HostGpuConfig {

‎dstack/vmm/src/main.rs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ enum Command {
6565
CheckConfig,
6666
/// One-shot VM execution mode for debugging
6767
Run(RunArgs),
68+
/// Sanitize GPUs with a VFIO PCI hot reset (debugging/ops)
69+
SanitizeGpu(SanitizeGpuArgs),
6870
/// Run the privileged TAP and libvirt nwfilter broker.
6971
Netd(NetdArgs),
7072
/// Internal per-VM QEMU/swtpm launcher.
@@ -132,6 +134,16 @@ struct RunArgs {
132134
dry_run: bool,
133135
}
134136

137+
#[derive(ClapArgs)]
138+
struct SanitizeGpuArgs {
139+
/// PCI slots of the GPUs to reset, e.g. 0000:0f:00.0
140+
#[arg(required = true)]
141+
slots: Vec<String>,
142+
/// Maximum time in milliseconds for the GPUs to become VFIO-ready again
143+
#[arg(long, default_value_t = 10_000)]
144+
timeout_ms: u64,
145+
}
146+
135147
#[derive(ClapArgs)]
136148
struct VmLauncherArgs {
137149
/// Path to the generated VM launch specification.
@@ -327,6 +339,14 @@ async fn main() -> Result<()> {
327339
return vm_launcher::run(Path::new(&launcher_args.spec)).await;
328340
}
329341

342+
// Needs no server configuration; only /dev/vfio access.
343+
if let Some(Command::SanitizeGpu(sanitize_args)) = &args.command {
344+
return gpu_reset::sanitize_slots(
345+
&sanitize_args.slots,
346+
Duration::from_millis(sanitize_args.timeout_ms),
347+
);
348+
}
349+
330350
let figment = config::load_config_figment(args.config.as_deref());
331351
if let Some(Command::Netd(netd_args)) = &args.command {
332352
let mut netd_config: NetdConfig = figment
@@ -394,6 +414,7 @@ async fn main() -> Result<()> {
394414
return Ok(());
395415
}
396416
Command::Netd(_) => unreachable!("netd mode handled before server startup"),
417+
Command::SanitizeGpu(_) => unreachable!("sanitize-gpu handled before config loading"),
397418
Command::Run(run_args) => {
398419
// One-shot VM execution mode
399420
return one_shot::run_one_shot(

0 commit comments

Comments
 (0)