Skip to main content

hrx/execution/
mod.rs

1//! Coordinated execution for GPU and NPU kernels.
2//!
3//! Device placement is explicit. Contracts describe memory access; prepared
4//! graphs infer dependencies and retain all allocations through completion.
5//! The low-level GPU API remains available in [`crate::gpu`].
6mod buffer;
7mod completion;
8mod contract;
9mod dependencies;
10mod graph;
11mod handoff;
12mod native_session;
13mod scheduler;
14mod statistics;
15mod trace;
16use crate::{Error, Result};
17pub use buffer::{Buffer, BufferView, MemoryPlacement, ReadGuard, WriteGuard};
18use buffer::{Engine, HostState, Storage, Visibility};
19pub use completion::{Completion, CompletionProfile};
20pub use contract::{Access, BindingContract, KernelContract};
21pub use graph::{ExecutableGraph, GpuKernel, GpuLane, Graph, Node};
22pub use native_session::NativeSession;
23use scheduler::Core;
24pub use statistics::Statistics;
25use std::sync::{Arc, Mutex};
26pub use trace::{ExecutionTrace, TraceEvent};
27
28/// A buffer region and the access performed during an external GPU handoff.
29#[derive(Clone)]
30pub struct GpuAccess {
31    /// Region retained and reserved until the supplied stream completes.
32    pub view: BufferView,
33    /// Reads and writes performed by the callback.
34    pub access: Access,
35}
36
37pub(super) struct RuntimeOwner {
38    core: Arc<Core>,
39    workers: Mutex<Vec<std::thread::JoinHandle<()>>>,
40    gpu_index: i32,
41    copy_streams: [Mutex<Option<Arc<Mutex<graph::CopyStream>>>>; 3],
42    allocation_stream: Mutex<Option<crate::gpu::Stream>>,
43}
44impl Drop for RuntimeOwner {
45    fn drop(&mut self) {
46        self.core
47            .state
48            .lock()
49            .unwrap_or_else(|e| e.into_inner())
50            .shutdown = true;
51        self.core.changed.notify_all();
52        for worker in self
53            .workers
54            .get_mut()
55            .unwrap_or_else(|e| e.into_inner())
56            .drain(..)
57        {
58            if worker.thread().id() != std::thread::current().id() {
59                let _ = worker.join();
60            }
61        }
62    }
63}
64/// Runtime limits fixed before workers or prepared run slots are created.
65#[derive(Clone, Debug)]
66pub struct RuntimeOptions {
67    /// Optional shared byte ceiling for tracked allocations, including weights,
68    /// private scratch and transfer staging. Native allocations made outside
69    /// this runtime are not covered; adoption charges them at the boundary.
70    pub memory_budget: Option<crate::residency::MemoryBudget>,
71    /// Physical GPU index; zero selects the integrated GPU on the tested host.
72    pub gpu_index: i32,
73    /// Maximum simultaneous submissions. Exhaustion returns `Busy`.
74    /// This bounds queued work; it does not increase per-engine concurrency.
75    pub max_submissions: usize,
76    /// Preallocated completion slots per prepared graph.
77    pub graph_slots: usize,
78}
79impl Default for RuntimeOptions {
80    fn default() -> Self {
81        Self {
82            memory_budget: None,
83            gpu_index: 0,
84            max_submissions: 64,
85            graph_slots: 2,
86        }
87    }
88}
89/// Shared scheduler and allocation domain. Clones use the same dependency state.
90///
91/// Upload, compute, download, and NPU regions have independent scheduling lanes.
92/// Each lane runs one region at a time; conflicting memory accesses are ordered
93/// across all lanes and submissions. Actual device overlap depends on hardware.
94#[derive(Clone)]
95pub struct Runtime {
96    pub(super) inner: Arc<RuntimeOwner>,
97    options: RuntimeOptions,
98}
99/// A selected GPU and its verified compilation target.
100#[derive(Clone)]
101pub struct GpuDevice {
102    index: i32,
103    target: crate::Target,
104}
105impl GpuDevice {
106    /// Physical GPU ordinal.
107    pub fn index(&self) -> i32 {
108        self.index
109    }
110    /// Target for compiling a kernel on this GPU.
111    pub fn target(&self) -> &crate::Target {
112        &self.target
113    }
114}
115/// An explicitly selected NPU ordinal.
116#[cfg(feature = "npu")]
117#[derive(Clone)]
118pub struct NpuDevice {
119    native: crate::fabric::Device,
120    index: i32,
121    budget: Option<crate::residency::MemoryBudget>,
122}
123#[cfg(feature = "npu")]
124impl NpuDevice {
125    /// Physical NPU ordinal.
126    pub fn index(&self) -> i32 {
127        self.index
128    }
129    /// Exact XDNA deployment target for offline compilation.
130    pub fn target(&self) -> &crate::Target {
131        self.native.target()
132    }
133    /// Native device domain, independent of any loaded program.
134    pub fn native(&self) -> &crate::fabric::Device {
135        &self.native
136    }
137    /// Admit a canonical XDNA artifact and its external binding contract.
138    ///
139    /// # Safety
140    /// The native code must be trusted and satisfy the supplied access contract.
141    pub unsafe fn load_artifact(
142        &self,
143        artifact: &crate::loom::Artifact,
144        columns: u16,
145        contract: KernelContract,
146    ) -> Result<crate::npu::NpuKernel> {
147        unsafe {
148            crate::npu::NpuKernel::load(
149                self.native.clone(),
150                artifact,
151                columns,
152                contract,
153                self.budget.as_ref(),
154            )
155        }
156    }
157}
158impl Runtime {
159    /// Begin an opt-in bounded trace of host-observed native-region latencies.
160    pub fn start_trace(&self, event_capacity: usize) -> Result<()> {
161        self.inner.core.tracer.start(event_capacity)
162    }
163    /// Stop capture without waiting for pending work. Wait for its completions
164    /// first when the capture should include the entire request.
165    pub fn finish_trace(&self) -> Option<ExecutionTrace> {
166        self.inner.core.tracer.finish()
167    }
168    /// Whether a view belongs to this runtime's allocation and scheduling domain.
169    pub fn owns(&self, view: &BufferView) -> bool {
170        Arc::ptr_eq(&self.inner, &view.buffer.storage.runtime)
171    }
172
173    /// Whether two handles share the same allocation and scheduling domain.
174    pub fn same_domain(&self, other: &Self) -> bool {
175        Arc::ptr_eq(&self.inner, &other.inner)
176    }
177
178    /// Shared allocation ceiling, if selected at construction. Native clients
179    /// can attach it to their streams before loading weights or workspace.
180    pub fn memory_budget(&self) -> Option<&crate::residency::MemoryBudget> {
181        self.options.memory_budget.as_ref()
182    }
183
184    /// Create a runtime. Native devices are opened lazily when requested.
185    pub fn new() -> Result<Self> {
186        Self::with_options(RuntimeOptions::default())
187    }
188    /// Create a runtime with bounded pending submission and graph capacities.
189    pub fn with_options(options: RuntimeOptions) -> Result<Self> {
190        if options.max_submissions == 0 || options.graph_slots == 0 || options.gpu_index < 0 {
191            return Err(Error::Message(
192                "runtime capacities must be nonzero and GPU index nonnegative".into(),
193            ));
194        }
195        let core = Arc::new(Core::new(options.max_submissions));
196        let owner = Arc::new(RuntimeOwner {
197            core: core.clone(),
198            workers: Mutex::new(Vec::new()),
199            gpu_index: options.gpu_index,
200            copy_streams: std::array::from_fn(|_| Mutex::new(None)),
201            allocation_stream: Mutex::new(None),
202        });
203        for name in ["hrx-upload", "hrx-compute", "hrx-download", "hrx-npu"] {
204            let core = core.clone();
205            let worker = std::thread::Builder::new()
206                .name(name.into())
207                .spawn(move || scheduler::worker(core))?;
208            owner
209                .workers
210                .lock()
211                .unwrap_or_else(|e| e.into_inner())
212                .push(worker);
213        }
214        Ok(Self {
215            inner: owner,
216            options,
217        })
218    }
219    /// Open the selected GPU and return its actual target.
220    pub fn gpu(&self) -> Result<GpuDevice> {
221        let device = crate::gpu::Device::open(self.inner.gpu_index)?;
222        Ok(GpuDevice {
223            index: self.inner.gpu_index,
224            target: device.target().clone(),
225        })
226    }
227    /// Select an NPU. Opening its program validates actual device access.
228    #[cfg(feature = "npu")]
229    pub fn npu(&self, index: i32) -> Result<NpuDevice> {
230        if index < 0 {
231            return Err(Error::Message("NPU index must be nonnegative".into()));
232        }
233        Ok(NpuDevice {
234            native: crate::fabric::Device::open(crate::fabric::Engine::Xdna, index as usize)?,
235            index,
236            budget: self.options.memory_budget.clone(),
237        })
238    }
239    // Buffers retain their allocating stream. Creating one per allocation
240    // therefore consumes one native queue per live tensor, even after the
241    // temporary Stream handle drops. Serialize allocation/initialization on a
242    // single lazy stream; execution graphs retain their independent queues.
243    fn allocation_stream(&self) -> Result<std::sync::MutexGuard<'_, Option<crate::gpu::Stream>>> {
244        let mut stream = self
245            .inner
246            .allocation_stream
247            .lock()
248            .map_err(|_| Error::DeviceLost("allocation stream poisoned".into()))?;
249        if stream.is_none() {
250            *stream = Some(crate::gpu::Device::open(self.inner.gpu_index)?.stream()?);
251        }
252        Ok(stream)
253    }
254
255    /// Allocate initialized storage. Shared memory is exported and imported once.
256    pub fn allocate(&self, bytes: usize, placement: MemoryPlacement) -> Result<Buffer> {
257        self.allocate_inner(bytes, placement, None)
258    }
259
260    /// Transfer an initialized allocation into this runtime, optionally importing
261    /// it into an NPU program once. Only GpuLocal and Shared placement are accepted.
262    ///
263    /// # Safety
264    /// All bytes must be initialized and prior uses complete. No old pointer,
265    /// recorded graph or other external alias may access the allocation after
266    /// adoption, except through this runtime's scoped GPU handoff.
267    pub unsafe fn adopt_gpu_buffer(
268        &self,
269        buffer: crate::gpu::Buffer,
270        placement: MemoryPlacement,
271    ) -> Result<Buffer> {
272        let mut allocation = self.allocation_stream()?;
273        let stream = allocation.as_mut().unwrap();
274        if buffer.device_id() != stream.device_id() {
275            return Err(Error::Message("buffer belongs to another device".into()));
276        }
277        if matches!(placement, MemoryPlacement::HostVisible) || matches_npu_local(&placement) {
278            return Err(Error::Unsupported(
279                "adoption requires GpuLocal or Shared placement".into(),
280            ));
281        }
282        drop(allocation);
283        self.allocate_inner(buffer.bytes(), placement, Some(buffer))
284    }
285
286    fn allocate_inner(
287        &self,
288        bytes: usize,
289        placement: MemoryPlacement,
290        mut adopted: Option<crate::gpu::Buffer>,
291    ) -> Result<Buffer> {
292        if bytes == 0 || bytes > isize::MAX as usize {
293            return Err(Error::Message(
294                "allocation size must be in 1..=isize::MAX".into(),
295            ));
296        }
297        let mut storage = Storage {
298            _reservation: self
299                .options
300                .memory_budget
301                .as_ref()
302                .filter(|budget| {
303                    !adopted
304                        .as_ref()
305                        .is_some_and(|buffer| buffer.charged_to(budget))
306                })
307                .map(|budget| budget.reserve(bytes))
308                .transpose()?,
309            native: None,
310            accounted: false,
311            gpu: None,
312            pointer: std::ptr::null_mut(),
313            bytes,
314            #[cfg(test)]
315            _test_memory: None,
316            shared: false,
317            host: Mutex::new(HostState::default()),
318            visibility: Mutex::new(Visibility::new()),
319            runtime: self.inner.clone(),
320        };
321        match &placement {
322            MemoryPlacement::GpuLocal => {
323                let mut allocation = self.allocation_stream()?;
324                let stream = allocation.as_mut().unwrap();
325                let buffer = if let Some(buffer) = adopted.take() {
326                    buffer
327                } else {
328                    // Initialize before publication without a submission whose
329                    // completion would retain this allocation on the stream.
330                    stream.allocate_zeroed(bytes)?
331                }
332                .into_unpooled();
333                storage.native = Some(buffer.native.clone());
334                storage.gpu = Some(buffer);
335                storage.visibility.get_mut().unwrap().wrote(Engine::Gpu);
336            }
337            MemoryPlacement::HostVisible => {
338                let mut allocation = self.allocation_stream()?;
339                let stream = allocation.as_mut().unwrap();
340                let buffer = stream.allocate_shared(bytes)?.into_unpooled();
341                let pointer = buffer.device_ptr()?.cast::<u8>();
342                // Coherent host-local allocation, with no aliases or device uses.
343                unsafe {
344                    std::ptr::write_bytes(pointer, 0, bytes);
345                }
346                storage.pointer = pointer;
347                storage.native = Some(buffer.native.clone());
348                storage.gpu = Some(buffer);
349            }
350            #[cfg(feature = "npu")]
351            MemoryPlacement::Shared(device) | MemoryPlacement::NpuLocal(device) => {
352                let native = if matches!(placement, MemoryPlacement::Shared(_)) {
353                    let mut allocation = self.allocation_stream()?;
354                    let stream = allocation.as_mut().unwrap();
355                    let gpu = if let Some(buffer) = adopted.take() {
356                        // Register the same owned backing; its original native owner
357                        // remains retained through the shared attachment's lifetime.
358                        buffer.share_with(device.native())?
359                    } else {
360                        stream.allocate_for(
361                            bytes,
362                            &[
363                                crate::gpu::Device::open(self.inner.gpu_index)?
364                                    .native()
365                                    .clone(),
366                                device.native().clone(),
367                            ],
368                        )?
369                    };
370                    let native = gpu.native.clone();
371                    storage.gpu = Some(gpu);
372                    storage.shared = true;
373                    native
374                } else {
375                    device
376                        .native()
377                        .fabric()
378                        .allocate(bytes, std::slice::from_ref(device.native()))?
379                };
380                storage.pointer = native.host_pointer();
381                storage.native = Some(native);
382            }
383        }
384
385        use std::sync::atomic::Ordering;
386        let counters = &self.inner.core.counters;
387        counters.allocations.fetch_add(1, Ordering::Relaxed);
388        if storage.shared {
389            counters.imports.fetch_add(1, Ordering::Relaxed);
390        }
391        let live = counters
392            .live_bytes
393            .fetch_add(bytes as u64, Ordering::Relaxed)
394            + bytes as u64;
395        counters.peak_bytes.fetch_max(live, Ordering::Relaxed);
396        storage.accounted = true;
397        Ok(Buffer {
398            storage: Arc::new(storage),
399        })
400    }
401    /// Observe allocation, transfer, and execution counters.
402    pub fn statistics(&self) -> Statistics {
403        self.inner.core.counters.snapshot()
404    }
405
406    /// Start describing a reusable pipeline in this allocation domain.
407    pub fn graph(&self) -> Graph {
408        Graph::new(self.clone())
409    }
410    /// Load a fixed GPU specialization with its trusted memory contract.
411    /// # Safety
412    /// The code must obey the contract for this grid/block and every accepted
413    /// binding. Kernel memory access is not sandboxed by native binding lengths.
414    pub unsafe fn load_gpu_kernel(
415        &self,
416        path: impl AsRef<std::path::Path>,
417        symbol: &str,
418        grid: [u32; 3],
419        block: [u32; 3],
420        contract: KernelContract,
421    ) -> Result<GpuKernel> {
422        let mut allocation = self.allocation_stream()?;
423        let stream = allocation.as_mut().unwrap();
424        let raw = unsafe { stream.load(path.as_ref(), symbol) }?;
425        drop(allocation);
426        unsafe { self.adopt_gpu_kernel(raw, grid, block, contract) }
427    }
428
429    /// Retain a loaded executable without loading or copying its code again.
430    /// # Safety
431    /// The kernel must obey the declared memory contract for this launch and
432    /// every accepted binding, as for [`Self::load_gpu_kernel`].
433    pub unsafe fn adopt_gpu_kernel(
434        &self,
435        raw: crate::gpu::Kernel,
436        grid: [u32; 3],
437        block: [u32; 3],
438        contract: KernelContract,
439    ) -> Result<GpuKernel> {
440        contract.validate()?;
441        crate::runtime::validate_export_launch(raw.info(), grid, block)?;
442        let mut allocation = self.allocation_stream()?;
443        let stream = allocation.as_mut().unwrap();
444        if raw.device_id() != stream.device_id() {
445            return Err(Error::Message("kernel belongs to another device".into()));
446        }
447        if raw.info().binding_count as usize != contract.bindings.len()
448            || raw.info().constant_byte_length as usize != contract.constants.len()
449        {
450            return Err(Error::Message(
451                "GPU metadata does not match contract".into(),
452            ));
453        }
454        Ok(GpuKernel {
455            raw,
456            contract,
457            grid,
458            block,
459            runtime: self.inner.clone(),
460        })
461    }
462}
463
464fn matches_npu_local(placement: &MemoryPlacement) -> bool {
465    #[cfg(feature = "npu")]
466    {
467        matches!(placement, MemoryPlacement::NpuLocal(_))
468    }
469    #[cfg(not(feature = "npu"))]
470    {
471        let _ = placement;
472        false
473    }
474}
475
476#[cfg(test)]
477mod tests;