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    native_lifetime: crate::fabric::NativeLifetime,
42    copy_streams: [Mutex<Option<Arc<Mutex<graph::CopyStream>>>>; 3],
43    allocation_stream: Mutex<Option<crate::gpu::Stream>>,
44    aql_queues: [Mutex<Option<crate::fabric::AqlQueue>>; 3],
45    sdma_queues: [Mutex<Option<crate::fabric::Queue>>; 3],
46}
47impl RuntimeOwner {
48    fn device(&self) -> Result<crate::Device> {
49        crate::Device::open_with_lifetime(self.gpu_index, self.native_lifetime)
50    }
51}
52impl Drop for RuntimeOwner {
53    fn drop(&mut self) {
54        self.core
55            .state
56            .lock()
57            .unwrap_or_else(|e| e.into_inner())
58            .shutdown = true;
59        self.core.changed.notify_all();
60        for worker in self
61            .workers
62            .get_mut()
63            .unwrap_or_else(|e| e.into_inner())
64            .drain(..)
65        {
66            if worker.thread().id() != std::thread::current().id() {
67                let _ = worker.join();
68            }
69        }
70    }
71}
72/// Engine used for graph regions containing only copies.
73#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
74pub enum CopyEngine {
75    /// Compiled copy kernels on the existing compute queue.
76    #[default]
77    Compute,
78    /// Native SDMA packets. Preparation requires SDMA-qualified memory backing.
79    Sdma,
80}
81/// Queue protocol for explicit GPU kernel nodes.
82#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
83pub enum ComputeEngine {
84    /// Native PM4 command graphs.
85    #[default]
86    Pm4,
87    /// AQL v1 dispatch with a fixed per-workitem scratch limit.
88    /// Cross-region dependencies use checked execution completion on the host.
89    Aql {
90        /// Maximum private bytes accepted by every kernel on this runtime.
91        maximum_private_bytes: u32,
92    },
93}
94/// Runtime limits fixed before workers or prepared run slots are created.
95#[derive(Clone, Debug)]
96pub struct RuntimeOptions {
97    /// Optional shared byte ceiling for tracked allocations, including weights,
98    /// private scratch and transfer staging. Native allocations made outside
99    /// this runtime are not covered; adoption charges them at the boundary.
100    pub memory_budget: Option<crate::residency::MemoryBudget>,
101    /// Physical GPU index; zero selects the integrated GPU on the tested host.
102    pub gpu_index: i32,
103    /// Native ownership domain. Process lifetime enables registered storage pages.
104    pub native_lifetime: crate::fabric::NativeLifetime,
105    /// Maximum simultaneous submissions. Exhaustion returns `Busy`.
106    /// This bounds queued work; it does not increase per-engine concurrency.
107    pub max_submissions: usize,
108    /// Preallocated completion slots per prepared graph.
109    pub graph_slots: usize,
110    /// Native engine for copy-only regions; selected before graph preparation.
111    pub copy_engine: CopyEngine,
112    /// Queue protocol selected before preparing explicit GPU kernel nodes.
113    pub compute_engine: ComputeEngine,
114}
115impl Default for RuntimeOptions {
116    fn default() -> Self {
117        Self {
118            memory_budget: None,
119            gpu_index: 0,
120            native_lifetime: Default::default(),
121            max_submissions: 64,
122            graph_slots: 2,
123            copy_engine: CopyEngine::Compute,
124            compute_engine: ComputeEngine::Pm4,
125        }
126    }
127}
128/// Shared scheduler and allocation domain. Clones use the same dependency state.
129///
130/// Upload, compute, download, and NPU regions have independent scheduling lanes.
131/// Each lane runs one region at a time; conflicting memory accesses are ordered
132/// across all lanes and submissions. Actual device overlap depends on hardware.
133#[derive(Clone)]
134pub struct Runtime {
135    pub(super) inner: Arc<RuntimeOwner>,
136    options: RuntimeOptions,
137}
138/// A selected GPU and its verified compilation target.
139#[derive(Clone)]
140pub struct GpuDevice {
141    index: i32,
142    target: crate::Target,
143}
144impl GpuDevice {
145    /// Physical GPU ordinal.
146    pub fn index(&self) -> i32 {
147        self.index
148    }
149    /// Target for compiling a kernel on this GPU.
150    pub fn target(&self) -> &crate::Target {
151        &self.target
152    }
153}
154/// An explicitly selected NPU ordinal.
155#[cfg(feature = "npu")]
156#[derive(Clone)]
157pub struct NpuDevice {
158    native: crate::fabric::Device,
159    index: i32,
160    budget: Option<crate::residency::MemoryBudget>,
161}
162#[cfg(feature = "npu")]
163impl NpuDevice {
164    /// Physical NPU ordinal.
165    pub fn index(&self) -> i32 {
166        self.index
167    }
168    /// Exact XDNA deployment target for offline compilation.
169    pub fn target(&self) -> &crate::Target {
170        self.native.target()
171    }
172    /// Native device domain, independent of any loaded program.
173    pub fn native(&self) -> &crate::fabric::Device {
174        &self.native
175    }
176    /// Admit a canonical XDNA artifact and its external binding contract.
177    ///
178    /// # Safety
179    /// The native code must be trusted and satisfy the supplied access contract.
180    pub unsafe fn load_artifact(
181        &self,
182        artifact: &crate::loom::Artifact,
183        columns: u16,
184        contract: KernelContract,
185    ) -> Result<crate::npu::NpuKernel> {
186        unsafe {
187            crate::npu::NpuKernel::load(
188                self.native.clone(),
189                artifact,
190                columns,
191                contract,
192                self.budget.as_ref(),
193            )
194        }
195    }
196}
197impl Runtime {
198    /// Begin an opt-in bounded trace of host-observed native-region latencies.
199    pub fn start_trace(&self, event_capacity: usize) -> Result<()> {
200        self.inner.core.tracer.start(event_capacity)
201    }
202    /// Stop capture without waiting for pending work. Wait for its completions
203    /// first when the capture should include the entire request.
204    pub fn finish_trace(&self) -> Option<ExecutionTrace> {
205        self.inner.core.tracer.finish()
206    }
207    /// Whether a view belongs to this runtime's allocation and scheduling domain.
208    pub fn owns(&self, view: &BufferView) -> bool {
209        Arc::ptr_eq(&self.inner, &view.buffer.storage.runtime)
210    }
211
212    /// Whether two handles share the same allocation and scheduling domain.
213    pub fn same_domain(&self, other: &Self) -> bool {
214        Arc::ptr_eq(&self.inner, &other.inner)
215    }
216
217    /// Native ownership domain selected before device activation.
218    pub fn native_lifetime(&self) -> crate::fabric::NativeLifetime {
219        self.options.native_lifetime
220    }
221
222    /// Shared allocation ceiling selected at construction.
223    pub fn memory_budget(&self) -> Option<&crate::residency::MemoryBudget> {
224        self.options.memory_budget.as_ref()
225    }
226
227    /// Create a runtime. Native devices are opened lazily when requested.
228    pub fn new() -> Result<Self> {
229        Self::with_options(RuntimeOptions::default())
230    }
231    /// Create a runtime with bounded pending submission and graph capacities.
232    pub fn with_options(options: RuntimeOptions) -> Result<Self> {
233        if options.max_submissions == 0 || options.graph_slots == 0 || options.gpu_index < 0 {
234            return Err(Error::Message(
235                "runtime capacities must be nonzero and GPU index nonnegative".into(),
236            ));
237        }
238        let core = Arc::new(Core::new(options.max_submissions));
239        let owner = Arc::new(RuntimeOwner {
240            core: core.clone(),
241            workers: Mutex::new(Vec::new()),
242            gpu_index: options.gpu_index,
243            native_lifetime: options.native_lifetime,
244            copy_streams: std::array::from_fn(|_| Mutex::new(None)),
245            allocation_stream: Mutex::new(None),
246            aql_queues: std::array::from_fn(|_| Mutex::new(None)),
247            sdma_queues: std::array::from_fn(|_| Mutex::new(None)),
248        });
249        for name in ["hrx-upload", "hrx-compute", "hrx-download", "hrx-npu"] {
250            let core = core.clone();
251            let worker = std::thread::Builder::new()
252                .name(name.into())
253                .spawn(move || scheduler::worker(core))?;
254            owner
255                .workers
256                .lock()
257                .unwrap_or_else(|e| e.into_inner())
258                .push(worker);
259        }
260        Ok(Self {
261            inner: owner,
262            options,
263        })
264    }
265    /// Open the selected GPU and return its actual target.
266    pub fn gpu(&self) -> Result<GpuDevice> {
267        let device = self.inner.device()?;
268        Ok(GpuDevice {
269            index: self.inner.gpu_index,
270            target: device.target().clone(),
271        })
272    }
273    /// Select an NPU. Opening its program validates actual device access.
274    #[cfg(feature = "npu")]
275    pub fn npu(&self, index: i32) -> Result<NpuDevice> {
276        if index < 0 {
277            return Err(Error::Message("NPU index must be nonnegative".into()));
278        }
279        Ok(NpuDevice {
280            native: crate::fabric::Device::open_with_lifetime(
281                crate::fabric::Engine::Xdna,
282                index as usize,
283                self.options.native_lifetime,
284            )?,
285            index,
286            budget: self.options.memory_budget.clone(),
287        })
288    }
289    // Buffers retain their allocating stream. Creating one per allocation
290    // therefore consumes one native queue per live tensor, even after the
291    // temporary Stream handle drops. Serialize allocation/initialization on a
292    // single lazy stream; execution graphs retain their independent queues.
293    fn allocation_stream(&self) -> Result<std::sync::MutexGuard<'_, Option<crate::gpu::Stream>>> {
294        let mut stream = self
295            .inner
296            .allocation_stream
297            .lock()
298            .map_err(|_| Error::DeviceLost("allocation stream poisoned".into()))?;
299        if stream.is_none() {
300            *stream = Some(self.inner.device()?.stream()?);
301        }
302        Ok(stream)
303    }
304
305    /// Allocate initialized storage. Shared memory is exported and imported once.
306    pub fn allocate(&self, bytes: usize, placement: MemoryPlacement) -> Result<Buffer> {
307        self.allocate_inner(bytes, placement, None)
308    }
309
310    /// Transfer an initialized allocation into this runtime, optionally importing
311    /// it into an NPU program once. Only GpuLocal and Shared placement are accepted.
312    ///
313    /// # Safety
314    /// All bytes must be initialized and prior uses complete. No old pointer,
315    /// recorded graph or other external alias may access the allocation after
316    /// adoption, except through this runtime's scoped GPU handoff.
317    pub unsafe fn adopt_gpu_buffer(
318        &self,
319        buffer: crate::gpu::Buffer,
320        placement: MemoryPlacement,
321    ) -> Result<Buffer> {
322        let mut allocation = self.allocation_stream()?;
323        let stream = allocation.as_mut().unwrap();
324        if buffer.device_id() != stream.device_id() {
325            return Err(Error::Message("buffer belongs to another device".into()));
326        }
327        if matches!(placement, MemoryPlacement::HostVisible) || matches_npu_local(&placement) {
328            return Err(Error::Unsupported(
329                "adoption requires GpuLocal or Shared placement".into(),
330            ));
331        }
332        drop(allocation);
333        self.allocate_inner(buffer.bytes(), placement, Some(buffer))
334    }
335
336    fn allocate_inner(
337        &self,
338        bytes: usize,
339        placement: MemoryPlacement,
340        mut adopted: Option<crate::gpu::Buffer>,
341    ) -> Result<Buffer> {
342        if bytes == 0 || bytes > isize::MAX as usize {
343            return Err(Error::Message(
344                "allocation size must be in 1..=isize::MAX".into(),
345            ));
346        }
347        let mut storage = Storage {
348            _reservation: self
349                .options
350                .memory_budget
351                .as_ref()
352                .filter(|budget| {
353                    !adopted
354                        .as_ref()
355                        .is_some_and(|buffer| buffer.charged_to(budget))
356                })
357                .map(|budget| budget.reserve(bytes))
358                .transpose()?,
359            native: None,
360            accounted: false,
361            gpu: None,
362            pointer: std::ptr::null_mut(),
363            bytes,
364            #[cfg(test)]
365            _test_memory: None,
366            shared: false,
367            host: Mutex::new(HostState::default()),
368            visibility: Mutex::new(Visibility::new()),
369            runtime: self.inner.clone(),
370        };
371        match &placement {
372            MemoryPlacement::GpuLocal => {
373                let mut allocation = self.allocation_stream()?;
374                let stream = allocation.as_mut().unwrap();
375                let buffer = if let Some(buffer) = adopted.take() {
376                    buffer
377                } else {
378                    // Initialize before publication without a submission whose
379                    // completion would retain this allocation on the stream.
380                    stream.allocate_zeroed(bytes)?
381                }
382                .into_unpooled();
383                storage.native = Some(buffer.native.clone());
384                storage.gpu = Some(buffer);
385                storage.visibility.get_mut().unwrap().wrote(Engine::Gpu);
386            }
387            MemoryPlacement::HostVisible => {
388                let mut allocation = self.allocation_stream()?;
389                let stream = allocation.as_mut().unwrap();
390                let buffer = stream.allocate_shared(bytes)?.into_unpooled();
391                let pointer = buffer.device_ptr()?.cast::<u8>();
392                // Coherent host-local allocation, with no aliases or device uses.
393                unsafe {
394                    std::ptr::write_bytes(pointer, 0, bytes);
395                }
396                storage.pointer = pointer;
397                storage.native = Some(buffer.native.clone());
398                storage.gpu = Some(buffer);
399            }
400            #[cfg(feature = "npu")]
401            MemoryPlacement::Shared(device) | MemoryPlacement::NpuLocal(device) => {
402                let native = if matches!(placement, MemoryPlacement::Shared(_)) {
403                    let mut allocation = self.allocation_stream()?;
404                    let stream = allocation.as_mut().unwrap();
405                    let gpu = if let Some(buffer) = adopted.take() {
406                        // Register the same owned backing; its original native owner
407                        // remains retained through the shared attachment's lifetime.
408                        buffer.share_with(device.native())?
409                    } else {
410                        stream.allocate_for(
411                            bytes,
412                            &[
413                                self.inner.device()?.native().clone(),
414                                device.native().clone(),
415                            ],
416                        )?
417                    };
418                    let native = gpu.native.clone();
419                    storage.gpu = Some(gpu);
420                    storage.shared = true;
421                    native
422                } else {
423                    device
424                        .native()
425                        .fabric()
426                        .allocate(bytes, std::slice::from_ref(device.native()))?
427                };
428                storage.pointer = native.host_pointer();
429                storage.native = Some(native);
430            }
431        }
432
433        use std::sync::atomic::Ordering;
434        let counters = &self.inner.core.counters;
435        counters.allocations.fetch_add(1, Ordering::Relaxed);
436        if storage.shared {
437            counters.imports.fetch_add(1, Ordering::Relaxed);
438        }
439        let live = counters
440            .live_bytes
441            .fetch_add(bytes as u64, Ordering::Relaxed)
442            + bytes as u64;
443        counters.peak_bytes.fetch_max(live, Ordering::Relaxed);
444        storage.accounted = true;
445        Ok(Buffer {
446            storage: Arc::new(storage),
447        })
448    }
449    /// Create a native stream with this runtime's GPU, engines and allocation budget.
450    /// Callers coordinate its work through NativeSession or explicit completion boundaries.
451    pub fn stream(
452        &self,
453        sanitizer: Option<crate::fabric::SanitizerRuntimeOptions>,
454    ) -> Result<crate::Stream> {
455        self.inner
456            .device()?
457            .stream_with_options(crate::StreamOptions {
458                compute_engine: self.options.compute_engine,
459                copy_engine: self.options.copy_engine,
460                memory_budget: self.options.memory_budget.clone(),
461                sanitizer,
462            })
463    }
464
465    /// Observe allocation, transfer, and execution counters.
466    pub fn statistics(&self) -> Statistics {
467        self.inner.core.counters.snapshot()
468    }
469
470    /// Start describing a reusable pipeline in this allocation domain.
471    pub fn graph(&self) -> Graph {
472        Graph::new(self.clone())
473    }
474    /// Load a fixed GPU specialization with its trusted memory contract.
475    /// # Safety
476    /// The code must obey the contract for this grid/block and every accepted
477    /// binding. Kernel memory access is not sandboxed by native binding lengths.
478    pub unsafe fn load_gpu_kernel(
479        &self,
480        path: impl AsRef<std::path::Path>,
481        symbol: &str,
482        grid: [u32; 3],
483        block: [u32; 3],
484        contract: KernelContract,
485    ) -> Result<GpuKernel> {
486        let mut allocation = self.allocation_stream()?;
487        let stream = allocation.as_mut().unwrap();
488        let raw = unsafe { stream.load(path.as_ref(), symbol) }?;
489        drop(allocation);
490        unsafe { self.adopt_gpu_kernel(raw, grid, block, contract) }
491    }
492
493    /// Load a GPU artifact using its compiler-authored launch configuration.
494    /// Memory access declarations remain the caller's explicit contract.
495    /// # Safety
496    /// The kernel must obey `contract` for this workload and all accepted bindings.
497    pub unsafe fn load_gpu_artifact(
498        &self,
499        artifact: &crate::loom::Artifact,
500        workload_argument_bits: &[u64],
501        contract: KernelContract,
502    ) -> Result<GpuKernel> {
503        let mut allocation = self.allocation_stream()?;
504        let stream = allocation.as_mut().unwrap();
505        let (raw, launch) =
506            unsafe { stream.load_artifact_for_workload(artifact, workload_argument_bits) }?;
507        drop(allocation);
508        unsafe {
509            self.adopt_gpu_kernel(raw, launch.workgroup_count, launch.workgroup_size, contract)
510        }
511    }
512
513    /// Load a sanitized GPU artifact with bounded feedback and shadow storage.
514    /// Requires the AQL compute engine. Storage is charged to this runtime's
515    /// memory budget and retained through every prepared and pending invocation.
516    /// Collect diagnostics with [`GpuKernel::sanitizer_reports`] after completion.
517    /// Combined address/race shadow is limited to 64 MiB per prepared dispatch and
518    /// participates in the runtime memory budget. Address checks cover complete
519    /// bound allocations, including when the graph binds a slice.
520    /// # Safety
521    /// The kernel must obey `contract` for this workload and all accepted bindings.
522    /// Use report-only instrumentation if execution must continue after failure.
523    pub unsafe fn load_sanitized_gpu_artifact(
524        &self,
525        artifact: &crate::loom::Artifact,
526        workload_argument_bits: &[u64],
527        contract: KernelContract,
528        report_capacity_bytes: usize,
529    ) -> Result<GpuKernel> {
530        if !matches!(self.options.compute_engine, ComputeEngine::Aql { .. }) {
531            return Err(Error::Unsupported(
532                "sanitizer reports require the AQL compute engine".into(),
533            ));
534        }
535        let mut allocation = self.allocation_stream()?;
536        let stream = allocation.as_mut().unwrap();
537        let raw = unsafe {
538            stream.load_sanitized_artifact(
539                artifact,
540                &crate::fabric::SanitizerRuntimeOptions {
541                    capacity_bytes: report_capacity_bytes,
542                    memory_budget: self.options.memory_budget.clone(),
543                    ..Default::default()
544                },
545            )
546        }?;
547        let launch = raw.launch_config(workload_argument_bits)?;
548        drop(allocation);
549        unsafe {
550            self.adopt_gpu_kernel(raw, launch.workgroup_count, launch.workgroup_size, contract)
551        }
552    }
553
554    /// Retain a loaded executable without loading or copying its code again.
555    /// # Safety
556    /// The kernel must obey the declared memory contract for this launch and
557    /// every accepted binding, as for [`Self::load_gpu_kernel`].
558    pub unsafe fn adopt_gpu_kernel(
559        &self,
560        raw: crate::gpu::Kernel,
561        grid: [u32; 3],
562        block: [u32; 3],
563        contract: KernelContract,
564    ) -> Result<GpuKernel> {
565        contract.validate()?;
566        crate::runtime::validate_export_launch(raw.info(), grid, block)?;
567        let mut allocation = self.allocation_stream()?;
568        let stream = allocation.as_mut().unwrap();
569        if raw.device_id() != stream.device_id() {
570            return Err(Error::Message("kernel belongs to another device".into()));
571        }
572        if raw.info().binding_count as usize != contract.bindings.len()
573            || raw.info().constant_byte_length as usize != contract.constants.len()
574        {
575            return Err(Error::Message(
576                "GPU metadata does not match contract".into(),
577            ));
578        }
579        Ok(GpuKernel {
580            raw,
581            contract,
582            grid,
583            block,
584            runtime: self.inner.clone(),
585        })
586    }
587}
588
589fn matches_npu_local(placement: &MemoryPlacement) -> bool {
590    #[cfg(feature = "npu")]
591    {
592        matches!(placement, MemoryPlacement::NpuLocal(_))
593    }
594    #[cfg(not(feature = "npu"))]
595    {
596        let _ = placement;
597        false
598    }
599}
600
601#[cfg(test)]
602mod tests;