Skip to main content

hrx/
runtime.rs

1use std::ffi::{CStr, CString, c_void};
2use std::path::Path;
3
4/// Native export metadata used to validate dispatch shapes and arguments.
5pub use crate::sys::ExportInfo;
6use crate::{Error, Result, TARGET_FAMILY, Target, sys};
7
8/// Turns a status into a `Result`, taking ownership of its message. A null status is success.
9pub(crate) fn check(status: sys::Status, what: impl std::fmt::Display) -> Result<()> {
10    if sys::is_ok(status) {
11        return Ok(());
12    }
13    let code = unsafe { sys::hrx_status_code(status) };
14    let mut message: *mut std::ffi::c_char = std::ptr::null_mut();
15    let mut length: usize = 0;
16    let text = unsafe {
17        let to_string = sys::hrx_status_to_string(status, &mut message, &mut length);
18        let text = if sys::is_ok(to_string) && !message.is_null() {
19            let owned = CStr::from_ptr(message).to_string_lossy().into_owned();
20            sys::hrx_status_free_message(message);
21            owned
22        } else {
23            sys::hrx_status_ignore(to_string);
24            "unknown error".to_string()
25        };
26        sys::hrx_status_ignore(status);
27        text
28    };
29    Err(Error::Runtime {
30        context: what.to_string(),
31        code,
32        message: text,
33    })
34}
35
36/// Shared ownership of a stream and its borrowed device registry entry.
37/// Buffers, kernels and events retain this handle through their native uses.
38struct Inner {
39    device: sys::Device,
40    target: Target,
41    stream: sys::Stream,
42}
43
44impl Drop for Inner {
45    fn drop(&mut self) {
46        // hrx_stream_create returns an owned reference. hrx_gpu_device_get
47        // borrows the global registry entry without retaining it; do not release it.
48        unsafe { sys::hrx_stream_release(self.stream) };
49    }
50}
51
52// Safety: native handle reference counts are atomic. Stream mutation requires
53// exclusive Stream access. Shared events query or wait on their semaphore;
54// they do not mutate the stream.
55unsafe impl Send for Inner {}
56unsafe impl Sync for Inner {}
57
58/// Initialize the process-wide runtime once, serializing access to native global
59/// state. ALREADY_EXISTS permits reuse across model libraries; failures are retryable.
60pub(crate) fn initialize_runtime() -> Result<()> {
61    sys::load()?;
62    static READY: std::sync::Mutex<Option<()>> = std::sync::Mutex::new(None);
63    crate::cached_init(&READY, || {
64        let _lock = sys::runtime_lock()?;
65        unsafe {
66            let status = sys::hrx_gpu_initialize(0);
67            if sys::hrx_status_code(status) == sys::STATUS_ALREADY_EXISTS {
68                sys::hrx_status_ignore(status);
69            } else {
70                check(status, "hrx_gpu_initialize")?;
71            }
72        }
73        Ok(())
74    })
75}
76
77pub(crate) fn open_device(index: i32) -> Result<(sys::Device, Target)> {
78    initialize_runtime()?;
79    unsafe {
80        let mut count = 0;
81        check(
82            sys::hrx_gpu_device_count(&mut count),
83            "hrx_gpu_device_count",
84        )?;
85        if index < 0 || index >= count {
86            return Err(Error::Message(
87                "no GPU device (check GPU access and the README provisioning instructions)".into(),
88            ));
89        }
90        let mut device = std::ptr::null_mut();
91        check(
92            sys::hrx_gpu_device_get(index, &mut device),
93            "hrx_gpu_device_get",
94        )?;
95        let mut architecture = [0u8; 64];
96        check(
97            sys::hrx_device_get_property(
98                device,
99                sys::DEVICE_PROPERTY_ARCHITECTURE,
100                architecture.as_mut_ptr().cast(),
101                architecture.len(),
102            ),
103            "device architecture",
104        )?;
105        let key = std::str::from_utf8(architecture.split(|b| *b == 0).next().unwrap())
106            .map_err(|_| Error::Message("invalid device architecture".into()))?;
107        Ok((device, Target::from_device_architecture(key)?))
108    }
109}
110
111impl Stream {
112    fn new(index: i32) -> Result<Self> {
113        let (device, target) = open_device(index)?;
114        unsafe {
115            let mut stream = std::ptr::null_mut();
116            check(
117                sys::hrx_stream_create(device, 0, &mut stream),
118                "hrx_stream_create",
119            )?;
120            Ok(Self {
121                inner: std::sync::Arc::new(Inner {
122                    device,
123                    target,
124                    stream,
125                }),
126                _not_sync: std::marker::PhantomData,
127                staging: Vec::new(),
128                staging_pool: Vec::new(),
129                scratch: std::collections::BTreeMap::new(),
130                scratch_bytes: 0,
131                scratch_limit: 256 * 1024 * 1024,
132                budget: None,
133                budget_uses: std::cell::RefCell::new(BudgetUses::default()),
134            })
135        }
136    }
137
138    fn owns(&self, buffer: &Buffer) -> Result<()> {
139        owns(&self.inner, buffer)
140    }
141
142    fn synchronize_native(&self) -> Result<()> {
143        unsafe {
144            check(
145                sys::hrx_stream_synchronize(self.inner.stream),
146                "hrx_stream_synchronize",
147            )
148        }
149    }
150
151    unsafe fn loaded_export(&self, executable: sys::Executable, symbol: &str) -> Result<Kernel> {
152        let kernel = (|| {
153            let symbol_c =
154                CString::new(symbol).map_err(|_| Error::Message("export contains a NUL".into()))?;
155            let mut ordinal = 0;
156            let mut info = sys::ExportInfo::default();
157            unsafe {
158                check(
159                    sys::hrx_executable_lookup_export_by_name(
160                        executable,
161                        symbol_c.as_ptr(),
162                        &mut ordinal,
163                    ),
164                    "looking up export",
165                )?;
166                check(
167                    sys::hrx_executable_export_info(executable, ordinal, &mut info),
168                    "export metadata",
169                )?;
170            }
171            Ok(Kernel {
172                executable: std::sync::Arc::new(Executable {
173                    raw: executable,
174                    device: self.inner.clone(),
175                }),
176                ordinal,
177                info,
178                symbol: symbol.into(),
179            })
180        })();
181        if kernel.is_err() {
182            unsafe { sys::hrx_executable_release(executable) }
183        }
184        kernel
185    }
186}
187
188/// An owned device allocation. Use [`Buffer::binding`] or [`Buffer::try_slice`]
189/// to borrow a binding; this API does not expose host pointers.
190/// A handle is bound to its device. Any stream on that device may use it;
191/// ordering conflicting access is the caller's, with events.
192pub struct Buffer {
193    raw: sys::Buffer,
194    bytes: usize,
195    /// Keeps the device alive: releasing a buffer after its device is gone would be a use-after-free.
196    _device: std::sync::Arc<Inner>,
197    reservation: Option<std::sync::Arc<crate::residency::MemoryReservation>>,
198}
199
200#[derive(Default)]
201struct BudgetUses(
202    std::collections::HashMap<usize, std::sync::Arc<crate::residency::MemoryReservation>>,
203);
204impl BudgetUses {
205    fn retain(&mut self, views: &[View<'_>]) {
206        for view in views {
207            if let Some(reservation) = &view.owner.reservation {
208                self.0
209                    .entry(std::sync::Arc::as_ptr(reservation) as usize)
210                    .or_insert_with(|| reservation.clone());
211            }
212        }
213    }
214    fn clear(&mut self) {
215        self.0.clear();
216    }
217}
218
219// Safety: moving/releasing an allocation uses native atomic reference counts.
220// Every operation checks its owning Inner before touching native buffer state.
221// Streams are !Sync, and a buffer used from two streams needs events between
222// conflicting accesses; unordered use yields stale bytes, never invalid memory.
223unsafe impl Send for Buffer {}
224unsafe impl Sync for Buffer {}
225
226/// A binding into a device allocation, borrowed from it.
227///
228/// The borrow keeps the buffer alive and identifies the stream allowed to use
229/// this view. Any stream on the same device may use it, ordered by the caller.
230#[derive(Clone, Copy, Debug)]
231pub struct View<'a> {
232    raw: sys::BufferRef,
233    owner: &'a Buffer,
234}
235
236impl<'a> View<'a> {
237    fn new(raw: sys::BufferRef, owner: &'a Buffer) -> Self {
238        Self { raw, owner }
239    }
240    /// Borrow a region relative to this view, rejecting overflow and overruns.
241    /// An empty region at the end of the view is valid.
242    pub fn slice(self, offset: usize, length: usize) -> Result<Self> {
243        checked_span(offset, length, self.len())?;
244        Ok(Self::new(
245            sys::BufferRef {
246                offset: self.raw.offset + offset,
247                length,
248                ..self.raw
249            },
250            self.owner,
251        ))
252    }
253    /// Byte offset from the start of the owning allocation.
254    #[must_use]
255    pub fn offset(self) -> usize {
256        self.raw.offset
257    }
258    /// The buffer handle from which this view was borrowed.
259    #[must_use]
260    pub fn owner(self) -> &'a Buffer {
261        self.owner
262    }
263    /// The bytes this view covers.
264    #[must_use]
265    pub fn len(self) -> usize {
266        self.raw.length
267    }
268    #[must_use]
269    /// Whether this view covers no bytes.
270    pub fn is_empty(self) -> bool {
271        self.raw.length == 0
272    }
273}
274
275impl Buffer {
276    pub(crate) fn charged_to(&self, budget: &crate::residency::MemoryBudget) -> bool {
277        self.reservation
278            .as_ref()
279            .is_some_and(|r| budget.contains(r))
280    }
281    pub(crate) fn device_id(&self) -> usize {
282        self._device.device as usize
283    }
284    /// The actual allocation size, including rounding of empty allocations.
285    pub fn bytes(&self) -> usize {
286        self.bytes
287    }
288
289    /// Borrow a span, rejecting overflow and allocation overruns.
290    pub fn try_slice(&self, offset: usize, length: usize) -> Result<View<'_>> {
291        self.binding().slice(offset, length)
292    }
293
294    /// The device address backing this buffer.
295    ///
296    /// Needed to hand the allocation to another driver -- exporting it as a dma-buf for the
297    /// NPU, say -- rather than copying its contents out.
298    pub fn device_ptr(&self) -> Result<*mut std::ffi::c_void> {
299        let mut pointer = std::ptr::null_mut();
300        unsafe {
301            check(
302                sys::hrx_buffer_get_device_ptr(self.raw, &mut pointer),
303                "hrx_buffer_get_device_ptr",
304            )?;
305        }
306        Ok(pointer)
307    }
308
309    /// The whole allocation.
310    pub(crate) fn allocation_address(&self) -> Result<u64> {
311        let address = sys::interop()?.allocation_address;
312        let mut value = 0;
313        unsafe {
314            check(
315                address(self.raw, &mut value),
316                "querying GPU allocation address",
317            )?;
318        }
319        Ok(value)
320    }
321
322    #[cfg(feature = "npu")]
323    pub(crate) fn export_dmabuf(&self) -> Result<(std::os::fd::OwnedFd, u64)> {
324        use std::os::fd::FromRawFd;
325        let export = sys::interop()?.export_dmabuf;
326        let mut descriptor = -1;
327        let mut offset = 0;
328        unsafe {
329            check(
330                export(self.raw, &mut descriptor, &mut offset),
331                "exporting GPU allocation",
332            )?;
333        }
334        if descriptor < 0 {
335            return Err(Error::Message(
336                "native export returned invalid descriptor".into(),
337            ));
338        }
339        Ok((
340            unsafe { std::os::fd::OwnedFd::from_raw_fd(descriptor) },
341            offset,
342        ))
343    }
344
345    /// Borrow the whole allocation as a device binding.
346    pub fn binding(&self) -> View<'_> {
347        View::new(
348            sys::BufferRef {
349                buffer: self.raw,
350                offset: 0,
351                length: self.bytes,
352            },
353            self,
354        )
355    }
356
357    /// Borrow a span within the allocation.
358    ///
359    /// # Panics
360    /// Panics if offset arithmetic overflows or the span exceeds the allocation.
361    /// Use [`Buffer::try_slice`] to return an error instead.
362    pub fn slice(&self, offset: usize, length: usize) -> View<'_> {
363        self.try_slice(offset, length)
364            .expect("slice past the allocation or overflow")
365    }
366}
367
368impl Drop for Buffer {
369    fn drop(&mut self) {
370        unsafe { sys::hrx_buffer_release(self.raw) }
371    }
372}
373
374/// A loaded executable export with immutable dispatch metadata.
375/// Kernels can be dispatched on any stream on their device; buffer handles belong
376/// to individual streams.
377pub struct Kernel {
378    /// Shared by every clone, so cloning is two atomics rather than a call
379    /// across the FFI boundary. A cache that hands a kernel out per dispatch
380    /// clones it on every launch, so `Kernel: Clone` is only worth advertising
381    /// over `Arc<Kernel>` if it is no more expensive.
382    executable: std::sync::Arc<Executable>,
383    ordinal: u32,
384    info: sys::ExportInfo,
385    symbol: std::sync::Arc<str>,
386}
387
388/// The native executable, released once the last kernel naming it is dropped.
389struct Executable {
390    raw: sys::Executable,
391    /// As for a buffer: the executable names its device.
392    device: std::sync::Arc<Inner>,
393}
394
395// Safety: the same assertion `Kernel` carries, moved to the field that actually
396// holds the native handle. Executable metadata is immutable and the native
397// reference count is atomic, so sharing one across threads is sound; dispatch
398// still requires serialized access to a command stream.
399unsafe impl Send for Executable {}
400unsafe impl Sync for Executable {}
401
402impl Drop for Executable {
403    fn drop(&mut self) {
404        unsafe { sys::hrx_executable_release(self.raw) }
405    }
406}
407
408// Safety: executable metadata is immutable and native reference counts are
409// atomic. Dispatch requires serialized access to a command stream.
410unsafe impl Send for Kernel {}
411unsafe impl Sync for Kernel {}
412
413impl Kernel {
414    pub(crate) fn device_id(&self) -> usize {
415        self.executable.device.device as usize
416    }
417    /// Native export metadata, including argument counts and workgroup dimensions.
418    pub fn info(&self) -> &ExportInfo {
419        &self.info
420    }
421    /// The loaded export name.
422    pub fn symbol(&self) -> &str {
423        &self.symbol
424    }
425}
426
427/// Cloning shares the native executable, so a kernel can be cached and handed
428/// out by value instead of behind an `Arc`. Export metadata is immutable, and
429/// the borrowed `ExportInfo::name` stays valid while any clone is alive.
430impl Clone for Kernel {
431    fn clone(&self) -> Self {
432        Self {
433            executable: self.executable.clone(),
434            ordinal: self.ordinal,
435            info: self.info,
436            symbol: self.symbol.clone(),
437        }
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    #[test]
444    fn compiled_workgroup_dimensions_are_enforced() {
445        let info = super::ExportInfo {
446            workgroup_size: [64, 2, 1],
447            ..Default::default()
448        };
449        assert!(super::validate_export_launch(&info, [1; 3], [64, 2, 1]).is_ok());
450        assert!(super::validate_export_launch(&info, [1; 3], [128, 1, 1]).is_err());
451        assert!(super::validate_export_launch(&info, [0, 1, 1], [64, 2, 1]).is_err());
452        let dynamic = super::ExportInfo {
453            workgroup_size: [0, 2, 1],
454            ..Default::default()
455        };
456        assert!(super::validate_export_launch(&dynamic, [1; 3], [32, 2, 1]).is_ok());
457        assert!(super::validate_export_launch(&dynamic, [1; 3], [32, 1, 1]).is_err());
458    }
459    /// The bounds arithmetic, without a device: `offset + length` must not wrap into a pass.
460    #[test]
461    fn a_slice_past_the_allocation_is_refused_even_when_the_sum_wraps() {
462        let checked = |offset, length, bytes| super::checked_span(offset, length, bytes).is_ok();
463        assert!(checked(0, 8, 8));
464        assert!(checked(4, 4, 8));
465        assert!(!checked(4, 5, 8));
466        // the wrapping case: 8 bytes must not accept an offset near the top of the address space
467        assert!(!checked(usize::MAX, 2, 8));
468        assert!(!checked(usize::MAX - 1, 4, 8));
469    }
470}
471
472/// Buffers are bound to their device, not to the stream that allocated them.
473///
474/// The allocator is asked for `queue_affinity: u64::MAX` and executables are
475/// already device-scoped, so a stricter check here would be conservatism rather
476/// than a constraint. Ordering conflicting access across streams is the caller's
477/// job, and [`Stream::record_event`] / [`Stream::wait_event`] are the tools for
478/// it: a buffer written by one stream and read by another with no event between
479/// them yields whichever bytes the device happened to hold.
480fn owns(inner: &std::sync::Arc<Inner>, buffer: &Buffer) -> Result<()> {
481    if inner.device == buffer._device.device {
482        Ok(())
483    } else {
484        Err(Error::Message("buffer belongs to another device".into()))
485    }
486}
487
488// Keep common dispatches on the stack without imposing a new binding limit.
489fn raw_bindings<'a>(
490    views: &[View<'_>],
491    stack: &'a mut [std::mem::MaybeUninit<sys::BufferRef>; 32],
492) -> std::borrow::Cow<'a, [sys::BufferRef]> {
493    if views.len() <= stack.len() {
494        for (out, view) in stack.iter_mut().zip(views) {
495            out.write(view.raw);
496        }
497        // Only the prefix written above is exposed, and BufferRef is Copy.
498        std::borrow::Cow::Borrowed(unsafe {
499            std::slice::from_raw_parts(stack.as_ptr().cast(), views.len())
500        })
501    } else {
502        std::borrow::Cow::Owned(views.iter().map(|v| v.raw).collect())
503    }
504}
505pub(crate) fn checked_span(offset: usize, length: usize, capacity: usize) -> Result<()> {
506    if offset.checked_add(length).is_none_or(|end| end > capacity) {
507        Err(Error::Message(format!(
508            "span {offset}+{length} exceeds {capacity} bytes"
509        )))
510    } else {
511        Ok(())
512    }
513}
514pub(crate) fn validate_launch(grid: [u32; 3], block: [u32; 3]) -> Result<()> {
515    if grid.contains(&0)
516        || block.contains(&0)
517        || block
518            .iter()
519            .try_fold(1u32, |n, b| n.checked_mul(*b))
520            .is_none_or(|n| n > 1024)
521    {
522        Err(Error::Message("invalid GPU launch dimensions".into()))
523    } else {
524        Ok(())
525    }
526}
527
528pub(crate) fn validate_export_launch(
529    info: &sys::ExportInfo,
530    grid: [u32; 3],
531    block: [u32; 3],
532) -> Result<()> {
533    validate_launch(grid, block)?;
534    if info
535        .workgroup_size
536        .iter()
537        .zip(block)
538        .any(|(&expected, actual)| expected != 0 && expected != actual)
539    {
540        return Err(Error::Message(format!(
541            "workgroup size {block:?} disagrees with compiled size {:?}",
542            info.workgroup_size
543        )));
544    }
545    Ok(())
546}
547
548/// A borrowed device registry entry. Opening a stream never acquires ownership
549/// of the native device, and dropping a model never shuts down HRX globally.
550#[derive(Clone, Debug)]
551pub struct Device {
552    index: i32,
553    target: Target,
554}
555impl Device {
556    /// Whether this native library exposes shared-allocation interop ABI 1.
557    /// This checks the native API, not whether a particular NPU driver can import.
558    pub fn supports_shared_interop(&self) -> Result<bool> {
559        match sys::interop() {
560            Ok(_) => Ok(true),
561            Err(Error::Unsupported(_)) => Ok(false),
562            Err(error) => Err(error),
563        }
564    }
565
566    /// Validate a device index without creating a native stream.
567    pub fn open(index: i32) -> Result<Self> {
568        let (_, target) = open_device(index)?;
569        Ok(Self { index, target })
570    }
571    /// Open a device only when it reports the architecture required by a workload.
572    pub fn open_for(index: i32, expected: &str) -> Result<Self> {
573        let device = Self::open(index)?;
574        if device.target.as_str() != expected {
575            return Err(Error::Message(format!(
576                "workload requires {expected}, found {}",
577                device.target.as_str()
578            )));
579        }
580        Ok(device)
581    }
582    /// Architecture reported by the selected device.
583    pub fn target(&self) -> &Target {
584        &self.target
585    }
586    /// Create an independent ordered stream on this device.
587    pub fn stream(&self) -> Result<Stream> {
588        Stream::new(self.index)
589    }
590}
591
592/// An ordered command stream. Mutating operations require exclusive access.
593/// Prepared kernels and allocations can be reused without a compiler/cache lookup.
594pub struct Stream {
595    inner: std::sync::Arc<Inner>,
596    // Native stream fields are unsynchronized. Stream is Send, but not Sync.
597    _not_sync: std::marker::PhantomData<std::cell::Cell<()>>,
598    // Native command buffers retain HAL storage, not the hrx_buffer wrapper.
599    // Releasing a mapped wrapper unmaps it (buffer.c::hrx_buffer_release).
600    // Upload staging must therefore retain its wrapper through completion.
601    // Readbacks are unmapped until wait; ordinary/scratch buffers are never
602    // mapped by this API, so native retention suffices for their early release.
603    staging: Vec<Buffer>,
604    staging_pool: Vec<Buffer>,
605    scratch: std::collections::BTreeMap<usize, Vec<Buffer>>,
606    scratch_bytes: usize,
607    scratch_limit: usize,
608    budget: Option<crate::residency::MemoryBudget>,
609    // Native commands may retain storage after the Rust buffer is dropped.
610    // Their charges survive until this stream is drained, including uses of
611    // buffers allocated by another stream or imported into a tracked runtime.
612    budget_uses: std::cell::RefCell<BudgetUses>,
613}
614impl Stream {
615    /// Charge subsequent allocations (including native upload/readback staging)
616    /// against a shared residency ceiling. Existing allocations are unchanged.
617    /// Charges remain with buffers, queued uses and recorded graphs, and leak
618    /// with quarantined native storage after an uncertain failure. The ceiling
619    /// covers requested extents, not native allocator rounding or code objects.
620    pub fn with_memory_budget(mut self, budget: crate::residency::MemoryBudget) -> Self {
621        self.budget = Some(budget);
622        self
623    }
624
625    /// Allocation ceiling for this stream, including transfer staging.
626    pub fn memory_budget(&self) -> Option<&crate::residency::MemoryBudget> {
627        self.budget.as_ref()
628    }
629
630    fn reserve(
631        &self,
632        bytes: usize,
633    ) -> Result<Option<std::sync::Arc<crate::residency::MemoryReservation>>> {
634        self.budget
635            .as_ref()
636            .map(|budget| budget.reserve(bytes.max(1)).map(std::sync::Arc::new))
637            .transpose()
638    }
639    /// Architecture reported by this stream's device.
640    pub fn target(&self) -> &Target {
641        &self.inner.target
642    }
643
644    /// Submit preceding work and record a single immutable completion event.
645    /// Recording does not wait on the host and does not reclaim upload staging.
646    pub fn record_event(&mut self) -> Result<Event> {
647        let mut raw = std::ptr::null_mut();
648        unsafe {
649            check(
650                sys::hrx_event_create(self.inner.device, sys::EVENT_FLAG_DISABLE_TIMING, &mut raw),
651                "create event",
652            )?;
653            let event = Event {
654                raw,
655                inner: self.inner.clone(),
656            };
657            check(
658                sys::hrx_event_record(raw, self.inner.stream),
659                "record event",
660            )?;
661            Ok(event)
662        }
663    }
664
665    /// Queue a device-side dependency before subsequent work on this stream.
666    /// The event must come from the same device. This call does not wait on the host.
667    pub fn wait_event(&mut self, event: &Event) -> Result<()> {
668        if self.inner.device != event.inner.device {
669            return Err(Error::Message("event belongs to another device".into()));
670        }
671        unsafe {
672            check(
673                sys::hrx_stream_wait_event(self.inner.stream, event.raw),
674                "wait event",
675            )
676        }
677    }
678    /// Open an ordered stream on device zero.
679    pub fn open() -> Result<Self> {
680        Device::open(0)?.stream()
681    }
682    /// Identifies the device this stream runs on, for callers that keep
683    /// per-device state. Kernels are device-scoped, so a cache of loaded
684    /// executables is only valid for the device that loaded them.
685    pub fn device_id(&self) -> usize {
686        self.inner.device as usize
687    }
688    /// Identifies this stream, for callers that keep per-stream state.
689    ///
690    /// Buffers are device-scoped, so nothing here rejects one used on a sibling
691    /// stream — correct, because events can order that. What events cannot fix
692    /// is *reuse*: a pool handing a buffer out again relies on the queue that
693    /// used it last running in order, which holds within a stream and not
694    /// across them. A caller pooling allocations needs to say which stream a
695    /// block came from, and this is how. Unique among live streams; a value may
696    /// repeat once its stream is dropped.
697    pub fn id(&self) -> usize {
698        std::sync::Arc::as_ptr(&self.inner) as usize
699    }
700    /// Allocate storage owned by this stream; zero bytes is rounded to one.
701    pub fn allocate(&self, bytes: usize) -> Result<Buffer> {
702        let reservation = self.reserve(bytes)?;
703        let mut buffer = std::ptr::null_mut();
704        unsafe {
705            check(
706                sys::hrx_allocator_allocate_buffer(
707                    sys::hrx_device_allocator(self.inner.device),
708                    sys::BufferParams {
709                        memory_type: sys::MEMORY_TYPE_DEVICE_LOCAL,
710                        access: sys::MEMORY_ACCESS_ALL,
711                        usage: sys::BUFFER_USAGE_DEFAULT,
712                        queue_affinity: u64::MAX,
713                    },
714                    bytes.max(1),
715                    &mut buffer,
716                ),
717                "hrx_buffer_allocate",
718            )?;
719        }
720        Ok(Buffer {
721            raw: buffer,
722            bytes: bytes.max(1),
723            _device: self.inner.clone(),
724            reservation,
725        })
726    }
727    /// Allocate device-local storage and enqueue a zero fill over its requested size.
728    pub fn allocate_zeroed(&self, bytes: usize) -> Result<Buffer> {
729        let buffer = self.allocate(bytes)?;
730        if bytes != 0 {
731            self.fill(buffer.try_slice(0, bytes)?, 0)?;
732        }
733        Ok(buffer)
734    }
735    /// Import host memory the caller owns as a device-visible buffer, without copying.
736    ///
737    /// On an APU the GPU and the NPU address the same physical pages, so importing one
738    /// host allocation into both runtimes is the basis of zero-copy handoff between them:
739    /// the same pages can simultaneously back an XRT BO driving the NPU.
740    ///
741    /// # Safety
742    ///
743    /// `pointer` must be page-aligned, cover at least `bytes`, and stay allocated and
744    /// unmoved for the whole life of the returned buffer -- the buffer borrows the memory
745    /// and never frees it. The host must not read or write those bytes while device work
746    /// touching them is in flight.
747    pub unsafe fn import_host(
748        &self,
749        pointer: *mut std::ffi::c_void,
750        bytes: usize,
751    ) -> Result<Buffer> {
752        let reservation = self.reserve(bytes)?;
753        let mut buffer = std::ptr::null_mut();
754        unsafe {
755            check(
756                sys::hrx_allocator_import_buffer(
757                    sys::hrx_device_allocator(self.inner.device),
758                    sys::BufferParams {
759                        // Host-resident pages the device reads in place, rather than a
760                        // device-local allocation the runtime would have to copy into.
761                        memory_type: sys::MEMORY_TYPE_HOST_LOCAL
762                            | sys::MEMORY_TYPE_HOST_COHERENT
763                            | sys::MEMORY_TYPE_DEVICE_VISIBLE,
764                        access: sys::MEMORY_ACCESS_ALL,
765                        usage: sys::BUFFER_USAGE_DEFAULT,
766                        queue_affinity: u64::MAX,
767                    },
768                    pointer,
769                    bytes,
770                    &mut buffer,
771                ),
772                "hrx_allocator_import_buffer",
773            )?;
774        }
775        Ok(Buffer {
776            raw: buffer,
777            bytes,
778            _device: self.inner.clone(),
779            reservation,
780        })
781    }
782
783    /// Submit and wait for all work, then reclaim completed upload staging.
784    pub fn synchronize(&mut self) -> Result<()> {
785        self.synchronize_native()?;
786        self.reclaim_staging();
787        self.budget_uses.get_mut().clear();
788        Ok(())
789    }
790    /// Drain pending work, then upload bytes at the start of a view and wait for completion.
791    /// The input must fit within the view. Completed staging is reclaimed.
792    pub fn upload_blocking(&mut self, dst: View<'_>, bytes: &[u8]) -> Result<()> {
793        self.owns(dst.owner)?;
794        checked_span(0, bytes.len(), dst.len())?;
795        self.synchronize()?;
796        if bytes.is_empty() {
797            return Ok(());
798        }
799        unsafe {
800            check(
801                sys::hrx_synchronous_h2d(
802                    self.inner.device,
803                    bytes.as_ptr().cast(),
804                    dst.raw.buffer,
805                    dst.raw.offset,
806                    bytes.len(),
807                ),
808                "hrx_synchronous_h2d",
809            )
810        }
811    }
812    /// Synchronously upload at a checked byte offset into an allocation.
813    pub fn upload_blocking_at(&mut self, dst: &Buffer, offset: usize, bytes: &[u8]) -> Result<()> {
814        self.upload_blocking(dst.try_slice(offset, bytes.len())?, bytes)
815    }
816    /// Drain all pending work before a synchronous read and reclaim staging.
817    pub fn read_blocking(&mut self, src: View<'_>, bytes: &mut [u8]) -> Result<()> {
818        self.owns(src.owner)?;
819        checked_span(0, bytes.len(), src.len())?;
820        self.synchronize()?;
821        if bytes.is_empty() {
822            return Ok(());
823        }
824        unsafe {
825            check(
826                sys::hrx_synchronous_d2h(
827                    self.inner.device,
828                    src.raw.buffer,
829                    src.raw.offset,
830                    bytes.as_mut_ptr().cast(),
831                    bytes.len(),
832                ),
833                "hrx_synchronous_d2h",
834            )
835        }
836    }
837    /// Synchronously read at a checked byte offset from an allocation.
838    pub fn read_blocking_at(
839        &mut self,
840        src: &Buffer,
841        offset: usize,
842        bytes: &mut [u8],
843    ) -> Result<()> {
844        self.read_blocking(src.try_slice(offset, bytes.len())?, bytes)
845    }
846    /// Queue a byte-pattern fill over a nonempty view owned by this stream.
847    pub fn fill(&self, dst: View<'_>, value: u8) -> Result<()> {
848        self.owns(dst.owner)?;
849        if dst.is_empty() {
850            return Err(Error::Message("empty stream fill".into()));
851        }
852        self.budget_uses.borrow_mut().retain(&[dst]);
853        unsafe {
854            check(
855                sys::hrx_stream_fill_buffer(
856                    self.inner.stream,
857                    dst.raw.buffer,
858                    dst.raw.offset,
859                    dst.len(),
860                    &value as *const u8 as *const c_void,
861                    1,
862                ),
863                "hrx_stream_fill_buffer",
864            )
865        }
866    }
867    /// Queue a copy between equal, nonempty views owned by this stream.
868    pub fn copy(&self, dst: View<'_>, src: View<'_>) -> Result<()> {
869        self.owns(dst.owner)?;
870        self.owns(src.owner)?;
871        if dst.len() != src.len() || dst.is_empty() {
872            return Err(Error::Message(
873                "stream copy requires equal nonempty spans".into(),
874            ));
875        }
876        self.budget_uses.borrow_mut().retain(&[dst, src]);
877        unsafe {
878            check(
879                sys::hrx_stream_copy_buffer(
880                    self.inner.stream,
881                    src.raw.buffer,
882                    src.raw.offset,
883                    dst.raw.buffer,
884                    dst.raw.offset,
885                    src.len(),
886                ),
887                "hrx_stream_copy_buffer",
888            )
889        }
890    }
891    /// Copy the input into runtime-owned staging, then enqueue an actual GPU
892    /// buffer copy. The caller's slice is no longer referenced when this returns.
893    /// Bytes are written at the start of the view and must fit within it.
894    /// Staging pressure may submit or wait for prior work to bound memory use.
895    pub fn upload(&mut self, dst: View<'_>, bytes: &[u8]) -> Result<()> {
896        self.owns(dst.owner)?;
897        checked_span(0, bytes.len(), dst.len())?;
898        if bytes.is_empty() {
899            return Ok(());
900        }
901        // Submit/query only under pressure, allowing uploads to batch.
902        let next_capacity = self
903            .staging_pool
904            .iter()
905            .filter(|b| b.bytes >= bytes.len())
906            .map(|b| b.bytes)
907            .min()
908            .unwrap_or(bytes.len());
909        if !self.staging.is_empty()
910            && (self.staging.len() >= 8
911                || self
912                    .staging
913                    .iter()
914                    .map(|b| b.bytes)
915                    .sum::<usize>()
916                    .saturating_add(next_capacity)
917                    > STAGING_LIMIT)
918            && !self.submit()?.is_complete()?
919        {
920            self.synchronize()?;
921        }
922        let staging = if let Some(i) = self
923            .staging_pool
924            .iter()
925            .enumerate()
926            .filter(|(_, b)| b.bytes >= bytes.len())
927            .min_by_key(|(_, b)| b.bytes)
928            .map(|(i, _)| i)
929        {
930            self.staging_pool.swap_remove(i)
931        } else {
932            self.allocate_host(bytes.len())?
933        };
934        let raw = staging.raw;
935        self.budget_uses.get_mut().retain(&[dst, staging.binding()]);
936        unsafe {
937            let mut pointer = std::ptr::null_mut();
938            check(
939                sys::hrx_buffer_get_device_ptr(raw, &mut pointer),
940                "map staging",
941            )?;
942            std::ptr::copy_nonoverlapping(bytes.as_ptr(), pointer.cast::<u8>(), bytes.len());
943            // Retain before enqueue: even a partially recorded command on failure
944            // must not outlive the wrapper or its mapping.
945            self.staging.push(staging);
946            check(
947                sys::hrx_stream_copy_buffer(
948                    self.inner.stream,
949                    raw,
950                    0,
951                    dst.raw.buffer,
952                    dst.raw.offset,
953                    bytes.len(),
954                ),
955                "enqueue upload",
956            )
957        }
958    }
959    /// Enqueue an upload at a checked byte offset into an allocation.
960    pub fn upload_at(&mut self, dst: &Buffer, offset: usize, bytes: &[u8]) -> Result<()> {
961        self.upload(dst.try_slice(offset, bytes.len())?, bytes)
962    }
963    /// Allocate a host-local, device-visible buffer.
964    ///
965    /// Unlike [`Stream::allocate`], the runtime hands out a device pointer for these, so
966    /// the allocation can be exported to another driver -- which is what lets one buffer
967    /// serve both the GPU and the NPU. Device-local memory is faster for GPU-only work.
968    pub fn allocate_shared(&self, bytes: usize) -> Result<Buffer> {
969        self.allocate_host(bytes)
970    }
971
972    fn allocate_host(&self, bytes: usize) -> Result<Buffer> {
973        let reservation = self.reserve(bytes)?;
974        let mut raw = std::ptr::null_mut();
975        unsafe {
976            check(
977                sys::hrx_allocator_allocate_buffer(
978                    sys::hrx_device_allocator(self.inner.device),
979                    sys::BufferParams {
980                        memory_type: sys::MEMORY_TYPE_HOST_LOCAL
981                            | sys::MEMORY_TYPE_DEVICE_VISIBLE
982                            | sys::MEMORY_TYPE_HOST_COHERENT,
983                        access: sys::MEMORY_ACCESS_ALL,
984                        usage: sys::BUFFER_USAGE_DEFAULT | sys::BUFFER_USAGE_MAPPING_SCOPED,
985                        queue_affinity: u64::MAX,
986                    },
987                    bytes.max(1),
988                    &mut raw,
989                ),
990                "allocate staging",
991            )?;
992        }
993        Ok(Buffer {
994            raw,
995            bytes,
996            _device: self.inner.clone(),
997            reservation,
998        })
999    }
1000    fn reclaim_staging(&mut self) {
1001        let mut cached = self.staging_pool.iter().map(|b| b.bytes).sum::<usize>();
1002        for buffer in self.staging.drain(..) {
1003            if self.staging_pool.len() < 8 && buffer.bytes <= STAGING_LIMIT.saturating_sub(cached) {
1004                cached += buffer.bytes;
1005                self.staging_pool.push(buffer);
1006            }
1007        }
1008    }
1009    /// Submit pending commands before querying completion; native query alone
1010    /// does not include the unsubmitted command buffer.
1011    pub fn submit(&mut self) -> Result<Submission<'_>> {
1012        unsafe {
1013            check(sys::hrx_stream_flush(self.inner.stream), "submit")?;
1014        }
1015        Ok(Submission { stream: self })
1016    }
1017    /// Load a native code object and select one export by name.
1018    ///
1019    /// # Safety
1020    /// The code object must be trusted native machine code.
1021    pub unsafe fn load(&self, path: &Path, symbol: &str) -> Result<Kernel> {
1022        let c_path = CString::new(path.as_os_str().as_encoded_bytes())
1023            .map_err(|_| Error::Message(format!("{} contains a NUL", path.display())))?;
1024        let c_family = TARGET_FAMILY;
1025        let c_key = self.inner.target.as_c_str();
1026        unsafe {
1027            let mut executable = std::ptr::null_mut();
1028            check(
1029                sys::hrx_executable_load_file(
1030                    self.inner.device,
1031                    c_path.as_ptr(),
1032                    c_family.as_ptr(),
1033                    c_key.as_ptr(),
1034                    &mut executable,
1035                ),
1036                format_args!("loading {}", path.display()),
1037            )?;
1038            self.loaded_export(executable, symbol)
1039        }
1040    }
1041    /// Load a compiled artifact directly, without a filesystem round trip.
1042    ///
1043    /// # Safety
1044    /// The artifact must be trusted native code, as for [`Stream::load`].
1045    pub unsafe fn load_artifact(&self, artifact: &crate::loom::Artifact) -> Result<Kernel> {
1046        if artifact.target() != self.inner.target.as_str() {
1047            return Err(Error::Message(
1048                "artifact target does not match this runtime".into(),
1049            ));
1050        }
1051        let bytes = artifact.bytes();
1052        let mut executable = std::ptr::null_mut();
1053        unsafe {
1054            check(
1055                sys::hrx_executable_load_data(
1056                    self.inner.device,
1057                    bytes.as_ptr().cast(),
1058                    bytes.len(),
1059                    TARGET_FAMILY.as_ptr(),
1060                    self.inner.target.as_c_str().as_ptr(),
1061                    &mut executable,
1062                ),
1063                "loading compiled artifact",
1064            )?;
1065            self.loaded_export(executable, artifact.symbol())
1066        }
1067    }
1068
1069    /// Queue a kernel invocation with explicitly packed constants and borrowed bindings.
1070    ///
1071    /// # Safety
1072    /// Kernel, dimensions, constants and binding spans must agree. GPU addressing
1073    /// is not sandboxed by a binding's length.
1074    pub unsafe fn dispatch(
1075        &self,
1076        kernel: &Kernel,
1077        grid: [u32; 3],
1078        block: [u32; 3],
1079        constants: &Constants,
1080        bindings: &[View<'_>],
1081    ) -> Result<()> {
1082        if kernel.executable.device.device != self.inner.device {
1083            return Err(Error::Message("kernel belongs to another device".into()));
1084        }
1085        for view in bindings {
1086            owns(&self.inner, view.owner)?;
1087        }
1088        let mut binding_storage = [std::mem::MaybeUninit::uninit(); 32];
1089        let raw_bindings = raw_bindings(bindings, &mut binding_storage);
1090        validate_export_launch(&kernel.info, grid, block)?;
1091        if kernel.info.binding_count as usize != bindings.len()
1092            || kernel.info.constant_byte_length as usize != constants.len
1093        {
1094            return Err(Error::Message(
1095                "kernel binding or constant byte count mismatch".into(),
1096            ));
1097        }
1098        let config = sys::DispatchConfig {
1099            workgroup_count: grid,
1100            workgroup_size: block,
1101            subgroup_size: sys::SUBGROUP_SIZE_FROM_EXECUTABLE,
1102        };
1103        self.budget_uses.borrow_mut().retain(bindings);
1104        unsafe {
1105            check(
1106                sys::hrx_stream_dispatch(
1107                    self.inner.stream,
1108                    kernel.executable.raw,
1109                    kernel.ordinal,
1110                    &config,
1111                    constants.bytes.as_ptr().cast(),
1112                    constants.len,
1113                    raw_bindings.as_ptr(),
1114                    bindings.len(),
1115                    0,
1116                ),
1117                "dispatch",
1118            )
1119        }
1120    }
1121
1122    /// Reuse a cached allocation or allocate new scratch storage. Reuse follows
1123    /// the stream's command order and requires no host wait.
1124    /// Return it explicitly with [`Stream::recycle`] after recording its last use.
1125    /// Dropping a buffer releases it rather than returning it to this pool.
1126    pub fn scratch(&mut self, bytes: usize) -> Result<Buffer> {
1127        let choice = self
1128            .scratch
1129            .range(bytes.max(1)..=bytes.saturating_mul(2).max(1))
1130            .next()
1131            .map(|(&size, _)| size);
1132        if let Some(size) = choice {
1133            let buffers = self.scratch.get_mut(&size).unwrap();
1134            let buffer = buffers.pop().unwrap();
1135            if buffers.is_empty() {
1136                self.scratch.remove(&size);
1137            }
1138            self.scratch_bytes -= buffer.bytes;
1139            Ok(buffer)
1140        } else {
1141            self.allocate(bytes)
1142        }
1143    }
1144    /// Return an allocation to this stream's bounded scratch pool after recording
1145    /// its final use. A later scratch request may reuse its storage.
1146    /// Returns an error for a buffer another stream allocated: a scratch pool is
1147    /// one stream's private free list, even though the buffer itself is usable
1148    /// from any stream on the device.
1149    /// Recycling needs mutable stream access; there is no automatic return on drop.
1150    pub fn recycle(&mut self, buffer: Buffer) -> Result<()> {
1151        if !std::sync::Arc::ptr_eq(&buffer._device, &self.inner) {
1152            return Err(Error::Message("scratch belongs to another stream".into()));
1153        }
1154        if buffer.bytes <= self.scratch_limit.saturating_sub(self.scratch_bytes) {
1155            self.scratch_bytes += buffer.bytes;
1156            self.scratch.entry(buffer.bytes).or_default().push(buffer);
1157        }
1158        Ok(())
1159    }
1160    /// Set the scratch byte budget, evicting the largest size classes as needed.
1161    pub fn set_scratch_limit(&mut self, bytes: usize) {
1162        self.scratch_limit = bytes;
1163        while self.scratch_bytes > bytes {
1164            // Evict the largest size class first, preserving smaller reusable buffers.
1165            if let Some((_, buffers)) = self.scratch.pop_last() {
1166                self.scratch_bytes -= buffers.iter().map(|b| b.bytes).sum::<usize>();
1167            } else {
1168                break;
1169            }
1170        }
1171    }
1172}
1173
1174/// An immutable stream completion point. Native queues retain its semaphore
1175/// after a wait is enqueued, so dropping the event never cancels a dependency.
1176/// This is a synchronization primitive; the pinned runtime has no GPU timestamps.
1177pub struct Event {
1178    raw: sys::Event,
1179    inner: std::sync::Arc<Inner>,
1180}
1181// The recorded event is immutable and retains its native device and semaphore.
1182unsafe impl Send for Event {}
1183unsafe impl Sync for Event {}
1184impl Event {
1185    /// Query completion of the work preceding this event.
1186    pub fn is_complete(&self) -> Result<bool> {
1187        let mut complete = false;
1188        unsafe {
1189            check(sys::hrx_event_query(self.raw, &mut complete), "query event")?;
1190        }
1191        Ok(complete)
1192    }
1193    /// Wait on the host for the work preceding this event.
1194    pub fn synchronize(&self) -> Result<()> {
1195        unsafe { check(sys::hrx_event_synchronize(self.raw), "synchronize event") }
1196    }
1197}
1198impl Drop for Event {
1199    fn drop(&mut self) {
1200        unsafe { sys::hrx_event_release(self.raw) }
1201    }
1202}
1203
1204/// A borrowed submission fence. Waiting also releases completed staging. Drop
1205/// does not wait; the stream continues to own everything needed by queued work.
1206#[must_use]
1207pub struct Submission<'a> {
1208    stream: &'a mut Stream,
1209}
1210impl Submission<'_> {
1211    /// Query submitted work; successful completion reclaims upload staging.
1212    pub fn is_complete(&mut self) -> Result<bool> {
1213        let mut done = false;
1214        unsafe {
1215            check(
1216                sys::hrx_stream_query(self.stream.inner.stream, &mut done),
1217                "query submission",
1218            )?;
1219        }
1220        if done {
1221            self.stream.reclaim_staging();
1222            self.stream.budget_uses.get_mut().clear();
1223        }
1224        Ok(done)
1225    }
1226    /// Wait for this submission and reclaim completed upload staging.
1227    pub fn wait(self) -> Result<()> {
1228        self.stream.synchronize()
1229    }
1230}
1231
1232/// Explicit scalar widths, packed in declaration order for HRX binding dispatch.
1233/// Unlike direct kernargs this format has no implicit alignment or pointer slots.
1234/// Export metadata gives only the total byte count. Mixed scalar widths must
1235/// come from the kernel's argument contract.
1236#[derive(Clone, Debug)]
1237pub struct Constants {
1238    bytes: [u8; 256],
1239    len: usize,
1240}
1241impl Default for Constants {
1242    fn default() -> Self {
1243        Self {
1244            bytes: [0; 256],
1245            len: 0,
1246        }
1247    }
1248}
1249impl Constants {
1250    /// Pack an entry point whose scalar arguments are all unsigned Loom indices.
1251    ///
1252    /// Loom lowers a homogeneous index list to 32- or 64-bit slots depending on
1253    /// its range analysis. This adapter is only for that model contract; mixed
1254    /// scalar types must use `push` with their explicit native widths.
1255    ///
1256    /// Neither [`ExportInfo`] nor the compiler's resource report carries per-slot
1257    /// scalar types — only `constant_byte_length` and `parameter_count` — so no
1258    /// API here can build a mixed-width constant block by construction. A width
1259    /// inferred by dividing the byte count is wrong for a mixed `u32`/`u64`
1260    /// signature, and wrong quietly. Take each width from the source that
1261    /// declared it.
1262    pub fn indices(kernel: &Kernel, values: &[u32]) -> Result<Self> {
1263        let size = kernel.info.constant_byte_length as usize;
1264        let mut constants = Self::new();
1265        if values.is_empty() {
1266            if size != 0 {
1267                return Err(Error::Message("missing index constants".into()));
1268            }
1269            return Ok(constants);
1270        }
1271        if size == values.len() * 4 {
1272            for &value in values {
1273                constants.push(value)?;
1274            }
1275        } else if size == values.len() * 8 {
1276            for &value in values {
1277                constants.push(u64::from(value))?;
1278            }
1279        } else {
1280            return Err(Error::Message(
1281                "export is not a homogeneous 32/64-bit index list".into(),
1282            ));
1283        }
1284        Ok(constants)
1285    }
1286    /// Create an empty constant block.
1287    pub fn new() -> Self {
1288        Self::default()
1289    }
1290    #[must_use = "constant packing can fail when capacity is exceeded"]
1291    /// Append a scalar at its explicit width; fails if the 256-byte capacity is exceeded.
1292    pub fn push<T: Scalar>(&mut self, value: T) -> Result<()> {
1293        let bytes = value.bytes();
1294        checked_span(self.len, bytes.as_ref().len(), self.bytes.len())?;
1295        self.bytes[self.len..self.len + bytes.as_ref().len()].copy_from_slice(bytes.as_ref());
1296        self.len += bytes.as_ref().len();
1297        Ok(())
1298    }
1299    /// Construct an explicitly contracted scalar block from packed bytes.
1300    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
1301        let mut result = Self::new();
1302        checked_span(0, bytes.len(), result.bytes.len())?;
1303        result.bytes[..bytes.len()].copy_from_slice(bytes);
1304        result.len = bytes.len();
1305        Ok(result)
1306    }
1307    /// The packed constant bytes in declaration order.
1308    pub fn as_bytes(&self) -> &[u8] {
1309        &self.bytes[..self.len]
1310    }
1311}
1312mod sealed {
1313    pub trait Sealed {}
1314}
1315/// A supported fixed-width scalar for little-endian binding constants.
1316pub trait Scalar: sealed::Sealed {
1317    /// The scalar's fixed-size byte representation.
1318    type Bytes: AsRef<[u8]>;
1319    /// Encode this value in little-endian order.
1320    fn bytes(self) -> Self::Bytes;
1321}
1322macro_rules! scalars {
1323    ($($ty:ty => $n:literal),*) => {
1324        $(
1325            impl sealed::Sealed for $ty {}
1326            impl Scalar for $ty {
1327                type Bytes = [u8; $n];
1328                fn bytes(self) -> Self::Bytes { self.to_le_bytes() }
1329            }
1330        )*
1331    };
1332}
1333scalars!(u32 => 4, i32 => 4, f32 => 4, u64 => 8, i64 => 8, f64 => 8);
1334impl Drop for Stream {
1335    fn drop(&mut self) {
1336        if (!self.staging.is_empty() || !self.budget_uses.get_mut().0.is_empty())
1337            && self.synchronize_native().is_err()
1338        {
1339            // A failed wait provides no proof that mapped staging is idle.
1340            std::mem::forget(std::mem::take(&mut self.staging));
1341            std::mem::forget(std::mem::take(self.budget_uses.get_mut()));
1342        }
1343    }
1344}
1345
1346/// A recorded operation, used to declare what later operations depend on.
1347///
1348/// Copy and cheap: it is an index into its own graph, not a native handle, so it
1349/// never borrows the graph and can be held across recording calls. A node from
1350/// another graph is rejected rather than silently indexing the wrong recording.
1351#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1352pub struct Node {
1353    graph: u64,
1354    index: u32,
1355}
1356
1357static NEXT_GRAPH_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1358
1359/// A recording of GPU work as a dependency graph, replayed as a unit.
1360///
1361/// Every operation states what it comes after. Nothing is implicit: `&[]` records
1362/// work that may run as soon as the graph starts, and `&[a, b]` records work that
1363/// waits for both. The runtime schedules the result — `graph_analysis.c` does a
1364/// topological sort, partitions it, and detects independent workstreams, running
1365/// up to eight concurrently — so declaring only the edges that exist is what lets
1366/// it overlap anything.
1367///
1368/// Dependencies affect barriers and partitioning; there is no fixed cost per
1369/// edge. The pinned scheduler considers additional workstreams only after the
1370/// first 16 recordable nodes of a partition, and an empty join node ends that
1371/// partition. Declaring branches permits overlap but does not guarantee it.
1372///
1373/// A dependency can only name an already-recorded node, so a recording is
1374/// acyclic by construction and every edge points forward. That also keeps
1375/// instantiation on the runtime's linear fast path. Naming the same node twice
1376/// in one list is collapsed rather than rejected, so `after` may be assembled
1377/// from overlapping stage outputs.
1378///
1379/// Addresses, constants and shapes are fixed at record time. Native capture and
1380/// graph-exec update are unimplemented in the pinned revision rather than merely
1381/// unwrapped: `hrx_graph_exec_update` is a 17-byte stub and
1382/// `hrx_stream_capture_status` is 3 bytes, so changing a recording means
1383/// recording a new one.
1384pub struct Graph<'a> {
1385    raw: sys::Graph,
1386    // Brands this graph's nodes so another graph's cannot be resolved here.
1387    id: u64,
1388    nodes: Vec<sys::GraphNode>,
1389    inner: std::sync::Arc<Inner>,
1390    _resources: std::marker::PhantomData<(&'a Buffer, &'a Kernel)>,
1391    budget_uses: BudgetUses,
1392}
1393impl Stream {
1394    /// Begin recording while borrowing this stream and the recorded resources.
1395    /// `finish` instantiates an owned executable and ends those borrows.
1396    ///
1397    /// ```compile_fail
1398    /// fn escape() -> hrx::Result<hrx::Graph<'static>> {
1399    ///     let stream = hrx::Stream::open()?;
1400    ///     stream.graph()
1401    /// }
1402    /// ```
1403    pub fn graph(&self) -> Result<Graph<'_>> {
1404        let mut raw = std::ptr::null_mut();
1405        unsafe {
1406            check(
1407                sys::hrx_graph_create(self.inner.device, 0, &mut raw),
1408                "create graph",
1409            )?;
1410        }
1411        Ok(Graph {
1412            raw,
1413            id: NEXT_GRAPH_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
1414            nodes: Vec::new(),
1415            inner: self.inner.clone(),
1416            _resources: std::marker::PhantomData,
1417            budget_uses: BudgetUses::default(),
1418        })
1419    }
1420    /// Replay an instantiated graph on its original stream.
1421    ///
1422    /// A native submission failure may leave partial work in flight. Such a
1423    /// graph cannot be replayed again and its native resources are retained on
1424    /// drop because completion is unknown. Rejecting a foreign stream does not
1425    /// invalidate the graph.
1426    pub fn launch(&mut self, graph: &mut GraphExec) -> Result<()> {
1427        if !std::sync::Arc::ptr_eq(&graph.inner, &self.inner) {
1428            return Err(Error::Message("graph belongs to another stream".into()));
1429        }
1430        if matches!(graph.completion, GraphCompletion::Failed) {
1431            return Err(Error::Message(
1432                "graph has an untracked failed launch".into(),
1433            ));
1434        }
1435        // A native launch can fail after submitting only part of its work. In
1436        // that case the stream timeline does not cover everything submitted:
1437        // neither an older fence nor a later successful launch makes release safe.
1438        graph.completion = GraphCompletion::Failed;
1439        unsafe {
1440            check(
1441                sys::hrx_graph_exec_launch(graph.raw, self.inner.stream),
1442                "launch graph",
1443            )?;
1444            let mut point = sys::TimelinePoint::default();
1445            check(
1446                sys::hrx_stream_get_timeline_position(self.inner.stream, &mut point),
1447                "snapshot graph completion",
1448            )?;
1449            graph.completion = GraphCompletion::Submitted(point);
1450            Ok(())
1451        }
1452    }
1453}
1454impl<'a> Graph<'a> {
1455    /// Translate caller-facing nodes into native handles, rejecting foreign ones.
1456    /// Small fan-in stays on the stack, as dispatch bindings do.
1457    fn resolve<'s>(
1458        &self,
1459        after: &[Node],
1460        stack: &'s mut [std::mem::MaybeUninit<sys::GraphNode>; 16],
1461    ) -> Result<std::borrow::Cow<'s, [sys::GraphNode]>> {
1462        let handle = |node: &Node| -> Result<sys::GraphNode> {
1463            if node.graph != self.id || node.index as usize >= self.nodes.len() {
1464                return Err(Error::Message(
1465                    "dependency node belongs to another graph".into(),
1466                ));
1467            }
1468            Ok(self.nodes[node.index as usize])
1469        };
1470        // The native sort counts in-degree per entry but clears it once, so a
1471        // repeated dependency would strand a node. Collapsing here keeps the
1472        // caller free to assemble `after` from overlapping stage outputs.
1473        if after.len() <= stack.len() {
1474            // Small fan-in is the common case and never allocates: dedupe runs
1475            // against indices already on the stack, beside the handles themselves.
1476            let mut seen = [0u32; 16];
1477            let mut count = 0;
1478            for node in after {
1479                let raw = handle(node)?;
1480                if seen[..count].contains(&node.index) {
1481                    continue;
1482                }
1483                seen[count] = node.index;
1484                stack[count].write(raw);
1485                count += 1;
1486            }
1487            // Only the prefix written above is exposed, and a node handle is Copy.
1488            Ok(std::borrow::Cow::Borrowed(unsafe {
1489                std::slice::from_raw_parts(stack.as_ptr().cast(), count)
1490            }))
1491        } else {
1492            let mut unique: Vec<sys::GraphNode> = Vec::with_capacity(after.len());
1493            for node in after {
1494                let raw = handle(node)?;
1495                if !unique.contains(&raw) {
1496                    unique.push(raw);
1497                }
1498            }
1499            // Native in-degree is 16-bit; only a heap-sized fan-in can reach it.
1500            if unique.len() > u16::MAX as usize {
1501                return Err(Error::Message("too many graph dependencies".into()));
1502            }
1503            Ok(std::borrow::Cow::Owned(unique))
1504        }
1505    }
1506    fn record(&mut self, raw: sys::GraphNode) -> Node {
1507        let index = self.nodes.len() as u32;
1508        self.nodes.push(raw);
1509        Node {
1510            graph: self.id,
1511            index,
1512        }
1513    }
1514    /// Record a byte-pattern fill over a nonempty span on this device.
1515    pub fn fill(&mut self, after: &[Node], dst: View<'a>, pattern: u8) -> Result<Node> {
1516        owns(&self.inner, dst.owner)?;
1517        if dst.is_empty() {
1518            return Err(Error::Message("empty graph fill".into()));
1519        }
1520        self.budget_uses.retain(&[dst]);
1521        let attrs = sys::GraphFill {
1522            dst: dst.raw,
1523            pattern: pattern.into(),
1524            pattern_size: 1,
1525        };
1526        let mut storage = [std::mem::MaybeUninit::uninit(); 16];
1527        let deps = self.resolve(after, &mut storage)?;
1528        let mut next = std::ptr::null_mut();
1529        unsafe {
1530            check(
1531                sys::hrx_graph_add_fill_buffer_node(
1532                    self.raw,
1533                    deps.as_ptr(),
1534                    deps.len(),
1535                    &attrs,
1536                    &mut next,
1537                ),
1538                "record fill",
1539            )?;
1540        }
1541        Ok(self.record(next))
1542    }
1543    /// Record a copy between equal, nonempty spans on this device.
1544    pub fn copy(&mut self, after: &[Node], dst: View<'a>, src: View<'a>) -> Result<Node> {
1545        owns(&self.inner, dst.owner)?;
1546        owns(&self.inner, src.owner)?;
1547        if dst.len() != src.len() || dst.is_empty() {
1548            return Err(Error::Message(
1549                "graph copy requires equal nonempty spans".into(),
1550            ));
1551        }
1552        self.budget_uses.retain(&[dst, src]);
1553        let attrs = sys::GraphCopy {
1554            src: src.raw,
1555            dst: dst.raw,
1556        };
1557        let mut storage = [std::mem::MaybeUninit::uninit(); 16];
1558        let deps = self.resolve(after, &mut storage)?;
1559        let mut next = std::ptr::null_mut();
1560        unsafe {
1561            check(
1562                sys::hrx_graph_add_copy_buffer_node(
1563                    self.raw,
1564                    deps.as_ptr(),
1565                    deps.len(),
1566                    &attrs,
1567                    &mut next,
1568                ),
1569                "record copy",
1570            )?;
1571        }
1572        Ok(self.record(next))
1573    }
1574    /// Record a kernel invocation.
1575    ///
1576    /// # Safety
1577    /// As [`Stream::dispatch`]. Constants, addresses and grid are fixed for every
1578    /// replay. Nodes that access overlapping spans, with at least one write,
1579    /// must be ordered through `after`. Shared read-only weights need no edge.
1580    pub unsafe fn dispatch(
1581        &mut self,
1582        after: &[Node],
1583        kernel: &'a Kernel,
1584        grid: [u32; 3],
1585        block: [u32; 3],
1586        constants: &Constants,
1587        bindings: &[View<'a>],
1588    ) -> Result<Node> {
1589        if kernel.executable.device.device != self.inner.device {
1590            return Err(Error::Message("kernel belongs to another device".into()));
1591        }
1592        for view in bindings {
1593            owns(&self.inner, view.owner)?;
1594        }
1595        let mut binding_storage = [std::mem::MaybeUninit::uninit(); 32];
1596        let raw_bindings = raw_bindings(bindings, &mut binding_storage);
1597        validate_export_launch(&kernel.info, grid, block)?;
1598        if kernel.info.binding_count as usize != bindings.len()
1599            || kernel.info.constant_byte_length as usize != constants.len
1600        {
1601            return Err(Error::Message(
1602                "graph binding or constant byte count mismatch".into(),
1603            ));
1604        }
1605        // graph.c copies constants and binding descriptors into its arena, but
1606        // those descriptors only borrow HAL resources until instantiation. Thus
1607        // buffers/kernels borrow for recording, while constants borrow for this call.
1608        self.budget_uses.retain(bindings);
1609        let attrs = sys::GraphKernel {
1610            executable: kernel.executable.raw,
1611            ordinal: kernel.ordinal,
1612            config: sys::DispatchConfig {
1613                workgroup_count: grid,
1614                workgroup_size: block,
1615                subgroup_size: sys::SUBGROUP_SIZE_FROM_EXECUTABLE,
1616            },
1617            constants: constants.bytes.as_ptr().cast(),
1618            constants_size: constants.len,
1619            bindings: raw_bindings.as_ptr(),
1620            binding_count: bindings.len(),
1621            flags: 0,
1622        };
1623        let mut storage = [std::mem::MaybeUninit::uninit(); 16];
1624        let deps = self.resolve(after, &mut storage)?;
1625        let mut next = std::ptr::null_mut();
1626        unsafe {
1627            check(
1628                sys::hrx_graph_add_kernel_node(
1629                    self.raw,
1630                    deps.as_ptr(),
1631                    deps.len(),
1632                    &attrs,
1633                    &mut next,
1634                ),
1635                "record kernel",
1636            )?;
1637        }
1638        Ok(self.record(next))
1639    }
1640    /// Record a node that does no work and exists only to collect dependencies.
1641    ///
1642    /// The pinned runtime gives this node its own partition and queue barrier.
1643    /// For one consumer, pass the producer nodes directly to that operation.
1644    /// A join can express a shared dependency, but is not necessarily cheaper.
1645    pub fn join(&mut self, after: &[Node]) -> Result<Node> {
1646        let mut storage = [std::mem::MaybeUninit::uninit(); 16];
1647        let deps = self.resolve(after, &mut storage)?;
1648        let mut next = std::ptr::null_mut();
1649        unsafe {
1650            check(
1651                sys::hrx_graph_add_empty_node(self.raw, deps.as_ptr(), deps.len(), &mut next),
1652                "record join",
1653            )?;
1654        }
1655        Ok(self.record(next))
1656    }
1657    /// Instantiate the recording, retaining native resources independently of its
1658    /// borrows. A cyclic graph is rejected here.
1659    pub fn finish(mut self) -> Result<GraphExec> {
1660        let mut raw = std::ptr::null_mut();
1661        unsafe {
1662            check(
1663                sys::hrx_graph_instantiate(self.raw, 0, &mut raw),
1664                "instantiate graph",
1665            )?;
1666        }
1667        Ok(GraphExec {
1668            raw,
1669            inner: self.inner.clone(),
1670            completion: GraphCompletion::Idle,
1671            budget_uses: std::mem::take(&mut self.budget_uses),
1672        })
1673    }
1674}
1675impl Drop for Graph<'_> {
1676    fn drop(&mut self) {
1677        unsafe {
1678            sys::hrx_graph_release(self.raw);
1679        }
1680    }
1681}
1682/// An instantiated graph. Native instantiation retains HAL allocations and
1683/// executables; the Arc keeps their device and originating stream alive.
1684/// Recording borrows its inputs until finish; replay no longer borrows them.
1685/// Drop waits for its last replay, without submitting or waiting for later
1686/// stream work. A graph that has never been launched is released immediately.
1687pub struct GraphExec {
1688    raw: sys::GraphExec,
1689    inner: std::sync::Arc<Inner>,
1690    completion: GraphCompletion,
1691    budget_uses: BudgetUses,
1692}
1693
1694enum GraphCompletion {
1695    Idle,
1696    // The semaphore is borrowed from the stream retained by GraphExec::inner.
1697    Submitted(sys::TimelinePoint),
1698    Failed,
1699}
1700// Send rests on native behaviour, not on anything the compiler checks. What is
1701// asserted: an instantiated graph owns its recorded HAL resources and semaphore
1702// state, so moving the handle between threads and launching from the receiving
1703// one is sound. Rust contributes only exclusivity — launch takes `&mut` — and
1704// `Arc::ptr_eq` in `Stream::launch`, which rejects a foreign stream but says
1705// nothing about threads. `independent_streams_move_between_threads` exercises
1706// this and is evidence, not proof; a native revision that made replay
1707// thread-affine would invalidate the impl without failing to compile.
1708// Drop waits only on a captured semaphore/value pair, never on mutable stream
1709// state: the original Stream may be recording on another thread by then.
1710unsafe impl Send for GraphExec {}
1711impl Drop for GraphExec {
1712    fn drop(&mut self) {
1713        // Unlike a buffer, whose storage the command buffer retains, releasing
1714        // an executable graph that is still replaying frees native structures
1715        // the device is reading: the observed failure is an AMDGPU memory
1716        // access fault, not an error a caller could handle. Wait for the last
1717        // replay's immutable completion point, without flushing or reading the
1718        // stream's current position. A failed launch or wait is no proof the
1719        // replay is idle, so the native graph leaks rather than freeing early.
1720        let drained = match self.completion {
1721            GraphCompletion::Idle => true,
1722            GraphCompletion::Submitted(point) => check(
1723                unsafe { sys::hrx_semaphore_wait(point.semaphore, point.value, u64::MAX) },
1724                "wait for graph completion",
1725            )
1726            .is_ok(),
1727            GraphCompletion::Failed => false,
1728        };
1729        if drained {
1730            unsafe { sys::hrx_graph_exec_release(self.raw) };
1731        } else {
1732            std::mem::forget(std::mem::take(&mut self.budget_uses));
1733        }
1734    }
1735}
1736
1737/// Owned host-visible destination of a queued download. Dropping it before
1738/// completion is safe: the native command buffer retains its unmapped storage.
1739#[must_use]
1740pub struct Readback {
1741    buffer: Buffer,
1742}
1743impl Stream {
1744    /// Queue a download into owned, initially unmapped host-visible storage.
1745    pub fn read(&mut self, source: View<'_>) -> Result<Readback> {
1746        self.owns(source.owner)?;
1747        let buffer = self.allocate_host(source.len())?;
1748        let raw = buffer.raw;
1749        self.budget_uses
1750            .get_mut()
1751            .retain(&[source, buffer.binding()]);
1752        unsafe {
1753            if !source.is_empty() {
1754                check(
1755                    sys::hrx_stream_copy_buffer(
1756                        self.inner.stream,
1757                        source.raw.buffer,
1758                        source.raw.offset,
1759                        raw,
1760                        0,
1761                        source.len(),
1762                    ),
1763                    "enqueue readback",
1764                )?;
1765            }
1766            Ok(Readback { buffer })
1767        }
1768    }
1769}
1770impl Readback {
1771    /// Wait on the originating stream and return initialized download bytes.
1772    pub fn wait(self, stream: &mut Stream) -> Result<Vec<u8>> {
1773        if !std::sync::Arc::ptr_eq(&self.buffer._device, &stream.inner) {
1774            return Err(Error::Message("readback belongs to another stream".into()));
1775        }
1776        stream.synchronize()?;
1777        let mut bytes = Vec::with_capacity(self.buffer.bytes);
1778        if self.buffer.bytes != 0 {
1779            let mut pointer = std::ptr::null_mut();
1780            unsafe {
1781                check(
1782                    sys::hrx_buffer_get_device_ptr(self.buffer.raw, &mut pointer),
1783                    "map completed readback",
1784                )?;
1785                std::ptr::copy_nonoverlapping(
1786                    pointer.cast::<u8>(),
1787                    bytes.as_mut_ptr(),
1788                    self.buffer.bytes,
1789                );
1790                // The copy initialized exactly this many bytes of spare capacity.
1791                bytes.set_len(self.buffer.bytes);
1792            }
1793        }
1794        Ok(bytes)
1795    }
1796}
1797
1798impl std::fmt::Debug for Buffer {
1799    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1800        f.debug_struct("Buffer")
1801            .field("bytes", &self.bytes)
1802            .finish_non_exhaustive()
1803    }
1804}
1805
1806impl std::fmt::Debug for Kernel {
1807    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1808        f.debug_struct("Kernel")
1809            .field("symbol", &self.symbol)
1810            .finish_non_exhaustive()
1811    }
1812}
1813
1814impl std::fmt::Debug for Stream {
1815    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1816        f.debug_struct("Stream")
1817            .field("scratch_bytes", &self.scratch_bytes)
1818            .finish_non_exhaustive()
1819    }
1820}
1821
1822impl std::fmt::Debug for Graph<'_> {
1823    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1824        f.debug_struct("Graph")
1825            .field("nodes", &self.nodes.len())
1826            .finish_non_exhaustive()
1827    }
1828}
1829
1830impl std::fmt::Debug for GraphExec {
1831    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1832        f.debug_struct("GraphExec").finish_non_exhaustive()
1833    }
1834}
1835
1836impl std::fmt::Debug for Readback {
1837    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1838        f.debug_struct("Readback").finish_non_exhaustive()
1839    }
1840}
1841
1842impl std::fmt::Debug for Submission<'_> {
1843    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1844        f.debug_struct("Submission").finish_non_exhaustive()
1845    }
1846}
1847
1848const STAGING_LIMIT: usize = 64 * 1024 * 1024;
1849
1850#[cfg(test)]
1851mod graph_completion_tests {
1852    use super::*;
1853
1854    #[test]
1855    #[ignore = "requires gfx1151"]
1856    fn dropping_a_graph_on_another_thread_does_not_submit_pending_work() -> Result<()> {
1857        let mut stream = Stream::open()?;
1858        let buffer = stream.allocate(4096)?;
1859        let mut graph = stream.graph()?;
1860        graph.fill(&[], buffer.binding(), 1)?;
1861        let mut replay = graph.finish()?;
1862        // A later replay must replace the earlier completion point.
1863        stream.launch(&mut replay)?;
1864        stream.launch(&mut replay)?;
1865        let mut before = sys::TimelinePoint::default();
1866        unsafe {
1867            check(
1868                sys::hrx_stream_get_timeline_position(stream.inner.stream, &mut before),
1869                "snapshot timeline before drop",
1870            )?;
1871        }
1872        stream.fill(buffer.binding(), 2)?;
1873        std::thread::scope(|scope| {
1874            let dropper = scope.spawn(move || drop(replay));
1875            for _ in 0..64 {
1876                stream.fill(buffer.binding(), 3).unwrap();
1877            }
1878            dropper.join().unwrap();
1879        });
1880        let mut after = sys::TimelinePoint::default();
1881        unsafe {
1882            check(
1883                sys::hrx_stream_get_timeline_position(stream.inner.stream, &mut after),
1884                "snapshot timeline after drop",
1885            )?;
1886        }
1887        // This is deterministic even if the threads never overlap: the old
1888        // destructor flushes the pending fill and advances this timeline.
1889        assert_eq!(after.value, before.value, "drop submitted unrelated work");
1890        let mut actual = [0; 4096];
1891        stream.read_blocking(buffer.binding(), &mut actual)?;
1892        assert_eq!(actual, [3; 4096]);
1893        Ok(())
1894    }
1895}
1896
1897#[cfg(test)]
1898mod staging_tests {
1899    use super::*;
1900
1901    #[test]
1902    #[ignore = "requires gfx1151"]
1903    fn a_scratch_pool_only_accepts_the_stream_that_allocated_the_buffer() -> Result<()> {
1904        let device = Device::open(0)?;
1905        let mut owner = device.stream()?;
1906        let mut other = device.stream()?;
1907        let private = other.scratch(4096)?;
1908        let private_raw = private.raw;
1909        other.recycle(private)?;
1910
1911        // Buffers are device-scoped, so another stream may read and write this one.
1912        let buffer = owner.allocate(4096)?;
1913        owner.fill(buffer.binding(), 0x5a)?;
1914        let ready = owner.record_event()?;
1915        other.wait_event(&ready)?;
1916        let mut seen = [0u8; 16];
1917        other.read_blocking(buffer.binding().slice(0, 16)?, &mut seen)?;
1918        assert_eq!(seen, [0x5a; 16], "a foreign stream can use the allocation");
1919
1920        // A scratch pool is still one stream's private free list, not shared state.
1921        assert!(
1922            other
1923                .recycle(buffer)
1924                .unwrap_err()
1925                .to_string()
1926                .contains("another stream")
1927        );
1928        assert!(owner.scratch.is_empty());
1929        assert_eq!(other.scratch_bytes, 4096);
1930        assert_eq!(other.scratch(4096)?.raw, private_raw);
1931        Ok(())
1932    }
1933
1934    #[test]
1935    #[ignore = "requires gfx1151"]
1936    fn completed_staging_is_reclaimed_and_reused_on_every_completion_path() -> Result<()> {
1937        let mut stream = Stream::open()?;
1938        let buffer = stream.allocate(1024)?;
1939        stream.upload(buffer.binding(), &[7; 1024])?;
1940        let original = stream.staging[0].raw;
1941        let mut output = [0; 1024];
1942        stream.read_blocking(buffer.binding(), &mut output)?;
1943        assert_eq!(output, [7; 1024]);
1944        assert!(stream.staging.is_empty());
1945        stream.upload(buffer.binding(), &[9; 1024])?;
1946        assert_eq!(stream.staging[0].raw, original);
1947        stream.upload_blocking(buffer.binding(), &[11; 1024])?;
1948        assert!(stream.staging.is_empty());
1949        stream.upload(buffer.binding(), &[12; 512])?;
1950        assert_eq!(stream.staging[0].raw, original);
1951        stream.synchronize()?;
1952        stream.upload(buffer.binding(), &[13; 1024])?;
1953        stream.synchronize_native()?; // Establish completion without the cleanup being tested.
1954        assert!(stream.submit()?.is_complete()?);
1955        assert!(stream.staging.is_empty());
1956        for _ in 0..32 {
1957            stream.upload(buffer.binding(), &[1; 1024])?;
1958            assert!(stream.staging.len() <= 8);
1959        }
1960        stream.synchronize()?;
1961        assert!(stream.staging_pool.len() <= 8);
1962        assert!(stream.staging_pool.iter().map(|b| b.bytes).sum::<usize>() <= STAGING_LIMIT);
1963        Ok(())
1964    }
1965
1966    #[test]
1967    #[ignore = "requires gfx1151"]
1968    fn uploads_batch_until_pressure_and_reuse_the_smallest_capacity() -> Result<()> {
1969        let mut stream = Stream::open()?;
1970        let buffer = stream.allocate(4096)?;
1971        for size in [1024, 2048, 4096] {
1972            stream.upload(buffer.binding(), &vec![7; size])?;
1973        }
1974        assert_eq!(stream.staging.len(), 3);
1975        let medium = stream.staging[1].raw;
1976        stream.synchronize()?;
1977        stream.upload(buffer.binding(), &[9; 1500])?;
1978        assert_eq!(stream.staging[0].raw, medium);
1979        for _ in 0..7 {
1980            stream.upload(buffer.binding(), &[11; 1024])?;
1981        }
1982        assert_eq!(stream.staging.len(), 8);
1983        stream.upload(buffer.binding(), &[13; 1024])?;
1984        assert_eq!(stream.staging.len(), 1);
1985        stream.synchronize()?;
1986        Ok(())
1987    }
1988}
1989
1990#[cfg(test)]
1991mod dag_probe {
1992    use super::*;
1993
1994    /// Declared edges cost scheduling time, so the graph API must be able to omit
1995    /// the ones a workload does not need. Latency-bound by construction: 64 tiny
1996    /// fills, so bandwidth cannot confound the comparison.
1997    #[test]
1998    #[ignore = "requires gfx1151"]
1999    fn independent_nodes_beat_a_serial_chain() -> Result<()> {
2000        const NODES: usize = 64;
2001        let mut stream = Stream::open()?;
2002        let buffers: Vec<Buffer> = (0..NODES)
2003            .map(|_| stream.allocate(4096))
2004            .collect::<Result<_>>()?;
2005        let mut timings = Vec::new();
2006        for chained in [true, false] {
2007            let mut graph = stream.graph()?;
2008            let mut previous: Option<Node> = None;
2009            for buffer in &buffers {
2010                let after: &[Node] = match (chained, &previous) {
2011                    (true, Some(node)) => std::slice::from_ref(node),
2012                    _ => &[],
2013                };
2014                previous = Some(graph.fill(after, buffer.binding(), 0x3c)?);
2015            }
2016            let mut exec = graph.finish()?;
2017            for _ in 0..3 {
2018                stream.launch(&mut exec)?;
2019                stream.synchronize()?;
2020            }
2021            let mut samples = Vec::new();
2022            for _ in 0..9 {
2023                let start = std::time::Instant::now();
2024                stream.launch(&mut exec)?;
2025                stream.synchronize()?;
2026                samples.push(start.elapsed().as_secs_f64() * 1e6);
2027            }
2028            samples.sort_by(f64::total_cmp);
2029            println!(
2030                "{:<12} {NODES} nodes: {:8.1} us total, {:6.2} us/node",
2031                if chained { "serial" } else { "independent" },
2032                samples[4],
2033                samples[4] / NODES as f64
2034            );
2035            timings.push(samples[4]);
2036            let mut seen = [0u8; 4096];
2037            stream.read_blocking(buffers[NODES - 1].binding(), &mut seen)?;
2038            assert_eq!(seen, [0x3c; 4096], "every node ran");
2039        }
2040        // A margin rather than a strict inequality: the property under test is
2041        // that omitting edges still reaches the runtime, not that a shared CI
2042        // runner is quiet. The measured gap is ~1.7x, so 1.2x is a wide floor.
2043        assert!(
2044            timings[1] * 1.2 < timings[0],
2045            "omitting edges should be materially cheaper: {timings:?}"
2046        );
2047        Ok(())
2048    }
2049}