Skip to content

Commit 90b7a0e

Browse files
committed
Add Metal external buffer-write interop
1 parent e99f530 commit 90b7a0e

12 files changed

Lines changed: 429 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ Bottom level categories:
4242

4343
## Unreleased
4444

45+
### Added/New Features
46+
47+
#### Metal
48+
49+
- Add retained native device, queue, and buffer handles plus guarded external buffer-write initialization registration. By @jcwal1516 in [#9928](https://github.com/gfx-rs/wgpu/issues/9928).
50+
4551
## v29.0.4 (2026-07-01)
4652

4753
### New Features

Cargo.lock

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

tests/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ parking_lot = { workspace = true, features = ["deadlock_detection"] }
9292
[target.'cfg(not(any(target_arch = "wasm32", miri)))'.dependencies]
9393
nv-flip.workspace = true
9494

95+
[target.'cfg(target_vendor = "apple")'.dev-dependencies]
96+
objc2.workspace = true
97+
objc2-foundation.workspace = true
98+
objc2-metal.workspace = true
99+
95100
# Webassembly
96101
[target.'cfg(target_arch = "wasm32")'.dependencies]
97102
console_log.workspace = true

tests/tests/wgpu-gpu/main.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ mod instance;
3838
mod life_cycle;
3939
mod mem_leaks;
4040
mod mesh_shader;
41+
#[cfg(target_vendor = "apple")]
42+
mod metal_external_write;
4143
mod multiview;
4244
mod naga_capabilities;
4345
mod occlusion_query;
@@ -105,6 +107,8 @@ fn all_tests() -> Vec<wgpu_test::GpuTestInitializer> {
105107
image_atomics::all_tests(&mut tests);
106108
instance::all_tests(&mut tests);
107109
life_cycle::all_tests(&mut tests);
110+
#[cfg(target_vendor = "apple")]
111+
metal_external_write::all_tests(&mut tests);
108112
mem_leaks::all_tests(&mut tests);
109113
mesh_shader::all_tests(&mut tests);
110114
multiview::all_tests(&mut tests);
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
use objc2::{rc::Retained, runtime::ProtocolObject};
2+
use objc2_foundation::NSRange;
3+
use objc2_metal::{
4+
MTLBlitCommandEncoder, MTLBuffer, MTLCommandBuffer, MTLCommandEncoder, MTLCommandQueue,
5+
MTLDevice,
6+
};
7+
use wgpu_test::{gpu_test, FailureCase, GpuTestConfiguration, GpuTestInitializer, TestParameters};
8+
9+
pub fn all_tests(tests: &mut Vec<GpuTestInitializer>) {
10+
tests.push(METAL_EXTERNAL_BUFFER_WRITE);
11+
}
12+
13+
#[gpu_test]
14+
static METAL_EXTERNAL_BUFFER_WRITE: GpuTestConfiguration = GpuTestConfiguration::new()
15+
.parameters(TestParameters::default().skip(FailureCase::backend(!wgpu::Backends::METAL)))
16+
.run_sync(|ctx| {
17+
const SIZE: u64 = 64;
18+
const WRITTEN: core::ops::Range<usize> = 16..48;
19+
20+
let buffer = ctx.device.create_buffer(&wgpu::BufferDescriptor {
21+
label: Some("external Metal destination"),
22+
size: SIZE,
23+
usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
24+
mapped_at_creation: false,
25+
});
26+
let readback = ctx.device.create_buffer(&wgpu::BufferDescriptor {
27+
label: Some("external Metal readback"),
28+
size: SIZE,
29+
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
30+
mapped_at_creation: false,
31+
});
32+
33+
let device_ptr = unsafe {
34+
ctx.device
35+
.as_hal::<wgpu_hal::api::Metal>()
36+
.expect("Metal device")
37+
.retained_raw_handle()
38+
};
39+
let queue_ptr = unsafe {
40+
ctx.queue
41+
.as_hal::<wgpu_hal::api::Metal>()
42+
.expect("Metal queue")
43+
.retained_raw_handle()
44+
};
45+
let buffer_ptr = unsafe {
46+
buffer
47+
.as_hal::<wgpu_hal::api::Metal>()
48+
.expect("Metal buffer")
49+
.retained_raw_handle()
50+
};
51+
52+
let device = unsafe {
53+
Retained::<ProtocolObject<dyn MTLDevice>>::from_raw(device_ptr.cast().as_ptr())
54+
.expect("retained Metal device")
55+
};
56+
let queue = unsafe {
57+
Retained::<ProtocolObject<dyn MTLCommandQueue>>::from_raw(queue_ptr.cast().as_ptr())
58+
.expect("retained Metal queue")
59+
};
60+
let raw_buffer = unsafe {
61+
Retained::<ProtocolObject<dyn MTLBuffer>>::from_raw(buffer_ptr.cast().as_ptr())
62+
.expect("retained Metal buffer")
63+
};
64+
assert_eq!(queue.device(), device);
65+
66+
let command_buffer = queue.commandBuffer().expect("external command buffer");
67+
let encoder = command_buffer.blitCommandEncoder().expect("blit encoder");
68+
encoder.fillBuffer_range_value(
69+
&raw_buffer,
70+
NSRange::new(WRITTEN.start, WRITTEN.len()),
71+
0xA5,
72+
);
73+
encoder.endEncoding();
74+
command_buffer.commit();
75+
command_buffer.waitUntilCompleted();
76+
77+
unsafe {
78+
buffer
79+
.mark_external_write_initialized(WRITTEN.start as u64..WRITTEN.end as u64)
80+
.expect("register completed external write");
81+
}
82+
83+
let mut command_encoder = ctx
84+
.device
85+
.create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
86+
command_encoder.copy_buffer_to_buffer(&buffer, 0, &readback, 0, SIZE);
87+
ctx.queue.submit([command_encoder.finish()]);
88+
readback
89+
.slice(..)
90+
.map_async(wgpu::MapMode::Read, Result::unwrap);
91+
ctx.device
92+
.poll(wgpu::PollType::wait_indefinitely())
93+
.unwrap();
94+
95+
let mapped = readback.slice(..).get_mapped_range();
96+
assert!(mapped[..WRITTEN.start].iter().all(|byte| *byte == 0));
97+
assert!(mapped[WRITTEN.clone()].iter().all(|byte| *byte == 0xA5));
98+
assert!(mapped[WRITTEN.end..].iter().all(|byte| *byte == 0));
99+
drop(mapped);
100+
readback.unmap();
101+
});
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
use core::ops::Range;
2+
3+
use crate::{
4+
init_tracker::BufferInitTracker,
5+
resource::{BufferAccessError, BufferAccessResult},
6+
};
7+
8+
pub(super) fn mark_buffer_range_initialized(
9+
buffer_size: wgt::BufferAddress,
10+
initialization_status: &mut BufferInitTracker,
11+
range: Range<wgt::BufferAddress>,
12+
) -> BufferAccessResult {
13+
if range.start >= range.end {
14+
return Err(BufferAccessError::InvalidRange {
15+
start: range.start,
16+
end: range.end,
17+
});
18+
}
19+
if range.start > buffer_size {
20+
return Err(BufferAccessError::OutOfBoundsStartOffsetOverrun {
21+
index: range.start,
22+
max: buffer_size,
23+
});
24+
}
25+
if range.end > buffer_size {
26+
return Err(BufferAccessError::OutOfBoundsEndOffsetOverrun {
27+
index: range.start,
28+
size: range.end - range.start,
29+
max: buffer_size,
30+
});
31+
}
32+
33+
drop(initialization_status.drain(range));
34+
Ok(())
35+
}
36+
37+
#[cfg(test)]
38+
mod tests {
39+
use super::mark_buffer_range_initialized;
40+
use crate::init_tracker::BufferInitTracker;
41+
use crate::resource::BufferAccessError;
42+
use core::ops::Range;
43+
44+
fn rejected_range(range: Range<u64>) -> BufferAccessError {
45+
let mut tracker = BufferInitTracker::new(64);
46+
let error = mark_buffer_range_initialized(64, &mut tracker, range).unwrap_err();
47+
assert_eq!(tracker.check(0..64), Some(0..64));
48+
error
49+
}
50+
51+
#[test]
52+
fn marks_only_the_external_write_range_initialized() {
53+
let mut tracker = BufferInitTracker::new(64);
54+
55+
mark_buffer_range_initialized(64, &mut tracker, 16..48).unwrap();
56+
57+
assert_eq!(tracker.check(0..16), Some(0..16));
58+
assert_eq!(tracker.check(16..48), None);
59+
assert_eq!(tracker.check(48..64), Some(48..64));
60+
}
61+
62+
#[test]
63+
fn rejects_empty_reversed_and_out_of_bounds_ranges() {
64+
let reversed = Range { start: 32, end: 16 };
65+
assert!(matches!(
66+
rejected_range(16..16),
67+
BufferAccessError::InvalidRange { start: 16, end: 16 }
68+
));
69+
assert!(matches!(
70+
rejected_range(reversed),
71+
BufferAccessError::InvalidRange { start: 32, end: 16 }
72+
));
73+
assert!(matches!(
74+
rejected_range(65..66),
75+
BufferAccessError::OutOfBoundsStartOffsetOverrun { index: 65, max: 64 }
76+
));
77+
assert!(matches!(
78+
rejected_range(48..65),
79+
BufferAccessError::OutOfBoundsEndOffsetOverrun {
80+
index: 48,
81+
size: 17,
82+
max: 64
83+
}
84+
));
85+
}
86+
}

wgpu-core/src/device/global.rs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use alloc::{borrow::Cow, boxed::Box, string::String, sync::Arc, vec::Vec};
2-
use core::{ptr::NonNull, sync::atomic::Ordering};
2+
use core::{ops::Range, ptr::NonNull, sync::atomic::Ordering};
33

44
#[cfg(feature = "trace")]
55
use crate::device::trace::{self, IntoTrace};
@@ -11,7 +11,10 @@ use crate::{
1111
},
1212
command::{self, CommandEncoder},
1313
conv,
14-
device::{life::WaitIdleError, DeviceError, DeviceLostClosure},
14+
device::{
15+
external_write::mark_buffer_range_initialized, life::WaitIdleError, DeviceError,
16+
DeviceLostClosure,
17+
},
1518
global::Global,
1619
id::{self, AdapterId, DeviceId, QueueId, SurfaceId},
1720
instance::{self, Adapter, Surface},
@@ -34,6 +37,32 @@ use wgt::{BufferAddress, TextureFormat};
3437
use super::UserClosures;
3538

3639
impl Global {
40+
/// Record that a completed external GPU producer fully initialized a buffer range.
41+
///
42+
/// # Safety
43+
///
44+
/// The caller must own the range exclusively, resolve every prior wgpu use, and prevent
45+
/// later wgpu access until the external write completes or an equivalent GPU dependency is
46+
/// installed. Every semantically accessible byte in `range` must have been written.
47+
#[doc(hidden)]
48+
pub unsafe fn buffer_mark_external_write_initialized(
49+
&self,
50+
buffer_id: id::BufferId,
51+
range: Range<BufferAddress>,
52+
) -> BufferAccessResult {
53+
let buffer = self.hub.buffers.get(buffer_id).get()?;
54+
let snatch_guard = buffer.device.snatchable_lock.read();
55+
buffer.check_destroyed(&snatch_guard)?;
56+
drop(snatch_guard);
57+
buffer.device.check_is_valid()?;
58+
59+
let result = {
60+
let mut initialization_status = buffer.initialization_status.write();
61+
mark_buffer_range_initialized(buffer.size, &mut initialization_status, range)
62+
};
63+
result
64+
}
65+
3766
pub fn adapter_is_surface_supported(
3867
&self,
3968
adapter_id: AdapterId,

wgpu-core/src/device/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use wgt::{
2121
};
2222

2323
pub(crate) mod bgl;
24+
mod external_write;
2425
pub mod global;
2526
mod life;
2627
pub mod queue;

wgpu-core/src/resource.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,11 @@ pub enum BufferAccessError {
300300
size: wgt::BufferAddress,
301301
max: wgt::BufferAddress,
302302
},
303+
#[error("Buffer access range {start}..{end} must be non-empty and ordered")]
304+
InvalidRange {
305+
start: wgt::BufferAddress,
306+
end: wgt::BufferAddress,
307+
},
303308
#[error("Buffer map aborted")]
304309
MapAborted,
305310
#[error(transparent)]
@@ -340,6 +345,7 @@ impl WebGpuError for BufferAccessError {
340345
| Self::OutOfBoundsStartOffsetUnderrun { .. }
341346
| Self::OutOfBoundsStartOffsetOverrun { .. }
342347
| Self::OutOfBoundsEndOffsetOverrun { .. }
348+
| Self::InvalidRange { .. }
343349
| Self::MapAborted
344350
| Self::MapStartOffsetOverrun { .. }
345351
| Self::MapEndOffsetOverrun { .. } => ErrorType::Validation,

0 commit comments

Comments
 (0)