Skip to main content

hrx/loom/
model.rs

1//! Resident Loom workloads built from reusable buffers, kernels and graphs.
2//!
3//! This module owns the bookkeeping that otherwise tends to be repeated by
4//! every model: allocation handles, loaded-kernel indices, graph dependency
5//! inference, replay, readback and graph-versus-direct timing. Model-specific
6//! shape validation and kernel construction remain with the caller.
7//!
8//! ```no_run
9//! use hrx::{
10//!     loom::Specialization,
11//!     model::{Command, Dispatch, ModelSession},
12//! };
13//!
14//! fn prepare(source: &str) -> hrx::Result<()> {
15//!     let mut model = ModelSession::open(0)?;
16//!     let input = model.allocate(4096)?;
17//!     let output = model.allocate(4096)?;
18//!     // The application owns and audits its embedded kernel source.
19//!     let kernels = unsafe {
20//!         model.compile(&[(source, Specialization::new("transform"))])?
21//!     };
22//!     let commands = [Command::Dispatch(Dispatch::indices(
23//!         kernels[0],
24//!         [1024],
25//!         [4, 1, 1],
26//!         vec![input.read(), output.write()],
27//!     ))];
28//!     // These access declarations cover every byte the kernel can touch.
29//!     unsafe { model.record(1024, &commands)? };
30//!     model.upload(input, &[0; 4096])?;
31//!     model.replay(1024)?;
32//!     model.read(output, &mut [0; 4096])
33//! }
34//! ```
35
36use crate::loom::{Compiler, CompilerOptions, Kernels, Specialization};
37use crate::{
38    Access, Buffer, Constants, Device, Error, GraphExec, Kernel, Result, Stream, Target,
39    benchmark::Distribution,
40};
41use std::{
42    collections::{BTreeMap, BTreeSet, HashMap},
43    sync::atomic::{AtomicU64, Ordering},
44    time::Instant,
45};
46
47#[path = "model/definition.rs"]
48mod definition;
49pub use definition::{ModelDefinition, ModelFragment};
50
51static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(1);
52
53/// A checked byte region in one allocation owned by a [`ModelSession`].
54///
55/// Regions are cheap handles and do not borrow the session, so every operation
56/// checks the session identity before indexing storage.
57#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
58pub struct Region {
59    session: u64,
60    allocation: usize,
61    offset: usize,
62    bytes: usize,
63}
64
65impl Region {
66    /// Length of this region in bytes.
67    #[must_use]
68    pub fn len(self) -> usize {
69        self.bytes
70    }
71
72    /// Whether this region is empty.
73    #[must_use]
74    pub fn is_empty(self) -> bool {
75        self.bytes == 0
76    }
77
78    /// Byte offset from the start of the owning allocation.
79    #[must_use]
80    pub fn offset(self) -> usize {
81        self.offset
82    }
83
84    /// Return a checked subregion relative to this one.
85    pub fn slice(self, offset: usize, bytes: usize) -> Result<Self> {
86        let end = offset
87            .checked_add(bytes)
88            .ok_or_else(|| Error::Message("model region size overflow".into()))?;
89        if end > self.bytes {
90            return Err(Error::Message("model region exceeds its parent".into()));
91        }
92        Ok(Self {
93            offset: self
94                .offset
95                .checked_add(offset)
96                .ok_or_else(|| Error::Message("model region offset overflow".into()))?,
97            bytes,
98            ..self
99        })
100    }
101
102    /// Bind this region for read-only kernel access.
103    #[must_use]
104    pub fn read(self) -> Binding {
105        Binding {
106            region: self,
107            access: Access::Read,
108        }
109    }
110
111    /// Bind this region for write-only kernel access.
112    #[must_use]
113    pub fn write(self) -> Binding {
114        Binding {
115            region: self,
116            access: Access::Write,
117        }
118    }
119
120    /// Bind this region for kernel access that reads and writes existing bytes.
121    #[must_use]
122    pub fn read_write(self) -> Binding {
123        Binding {
124            region: self,
125            access: Access::ReadWrite,
126        }
127    }
128}
129
130/// One dispatch binding and the complete access the kernel performs through it.
131#[derive(Clone, Copy, Debug, PartialEq, Eq)]
132pub struct Binding {
133    /// Allocation region passed to the kernel.
134    pub region: Region,
135    /// Reads and writes performed through this argument.
136    pub access: Access,
137}
138
139/// A loaded kernel in one [`ModelSession`].
140#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
141pub struct KernelId {
142    session: u64,
143    index: usize,
144}
145
146/// Scalar arguments for a model dispatch.
147#[derive(Clone, Debug)]
148pub enum Arguments {
149    /// Loom indices whose 32- or 64-bit width is derived from export metadata.
150    Indices(Vec<u32>),
151    /// Explicitly packed scalar values for mixed-width signatures.
152    Packed(Box<Constants>),
153}
154
155impl Arguments {
156    /// Homogeneous Loom index values in declaration order.
157    pub fn indices(values: impl IntoIterator<Item = u32>) -> Self {
158        Self::Indices(values.into_iter().collect())
159    }
160
161    /// No scalar arguments.
162    #[must_use]
163    pub fn none() -> Self {
164        Self::Packed(Box::new(Constants::new()))
165    }
166
167    /// Explicitly packed mixed-width scalar values.
168    #[must_use]
169    pub fn packed(constants: Constants) -> Self {
170        Self::Packed(Box::new(constants))
171    }
172}
173
174/// One fixed-shape kernel dispatch in a reusable model graph.
175#[derive(Clone, Debug)]
176pub struct Dispatch {
177    /// Kernel returned by [`ModelSession::compile`].
178    pub kernel: KernelId,
179    /// Scalar values in kernel declaration order.
180    pub arguments: Arguments,
181    /// Workgroup counts in x, y and z.
182    pub grid: [u32; 3],
183    /// Buffer arguments in kernel declaration order.
184    pub bindings: Vec<Binding>,
185}
186
187impl Dispatch {
188    /// Construct a dispatch whose scalar arguments are homogeneous Loom indices.
189    pub fn indices(
190        kernel: KernelId,
191        values: impl IntoIterator<Item = u32>,
192        grid: [u32; 3],
193        bindings: Vec<Binding>,
194    ) -> Self {
195        Self {
196            kernel,
197            arguments: Arguments::indices(values),
198            grid,
199            bindings,
200        }
201    }
202}
203
204/// A command recorded into a reusable model graph.
205#[derive(Clone, Debug)]
206pub enum Command {
207    /// Fill a region with a repeating byte value.
208    Fill {
209        /// Destination region.
210        region: Region,
211        /// Byte pattern.
212        value: u8,
213    },
214    /// Invoke a compiled kernel.
215    Dispatch(Dispatch),
216}
217
218#[derive(Clone)]
219enum PreparedCommand {
220    Fill {
221        region: Region,
222        value: u8,
223    },
224    Dispatch {
225        kernel: KernelId,
226        constants: Box<Constants>,
227        grid: [u32; 3],
228        bindings: Vec<Binding>,
229    },
230}
231
232struct PreparedGraph {
233    executable: GraphExec,
234    commands: Vec<PreparedCommand>,
235}
236
237#[derive(Clone, Copy, Debug, PartialEq, Eq)]
238enum Placement {
239    Device,
240    Shared,
241}
242
243struct Allocation {
244    buffer: Buffer,
245    placement: Placement,
246    immutable: bool,
247}
248
249/// A resident model's exclusive stream, storage, kernels and reusable graphs.
250///
251/// The session infers graph edges from [`Binding::access`] at byte-region
252/// granularity. Independent regions may overlap; reads follow the last writer,
253/// and writes follow both the last writer and every intervening reader.
254pub struct ModelSession {
255    id: u64,
256    stream: Stream,
257    allocations: Vec<Allocation>,
258    kernels: Vec<Kernel>,
259    cache: Kernels,
260    graphs: HashMap<usize, PreparedGraph>,
261    readback: Option<Buffer>,
262    failed: bool,
263}
264
265impl ModelSession {
266    /// Prepare on a shared context's GPU, compiler and allocation budget.
267    /// The native loading stream charges weights, scratch and staging before
268    /// allocation; freezing into the same context does not charge weights twice.
269    pub fn in_context(context: &crate::inference::ModelContext) -> Result<Self> {
270        let mut stream = Device::open(context.runtime().gpu()?.index())?.stream()?;
271        if let Some(budget) = context.runtime().memory_budget() {
272            stream = stream.with_memory_budget(budget.clone());
273        }
274        Self::with_stream(stream, context.compiler()?)
275    }
276
277    /// Open a device and its shared Loom compiler using the device's actual target.
278    pub fn open(index: i32) -> Result<Self> {
279        let device = Device::open(index)?;
280        let stream = device.stream()?;
281        let options = CompilerOptions::for_stream(&stream);
282        let compiler = Compiler::shared(None, options)?;
283        Self::with_stream(stream, compiler)
284    }
285
286    /// Open a device only when it reports the architecture validated by a model.
287    pub fn open_for(index: i32, expected_target: &str) -> Result<Self> {
288        let device = Device::open_for(index, expected_target)?;
289        let stream = device.stream()?;
290        let compiler = Compiler::for_stream(None, &stream)?;
291        Self::with_stream(stream, compiler)
292    }
293
294    /// Create a session with an explicitly selected compiler.
295    ///
296    /// The compiler profile must match the device architecture; mismatches are
297    /// rejected before any native code is loaded.
298    pub fn with_compiler(device: Device, compiler: Compiler) -> Result<Self> {
299        if compiler.target() != device.target() {
300            return Err(Error::Message(format!(
301                "compiler target {} does not match device target {}",
302                compiler.target().as_str(),
303                device.target().as_str()
304            )));
305        }
306        Self::with_stream(device.stream()?, compiler)
307    }
308
309    fn with_stream(stream: Stream, compiler: Compiler) -> Result<Self> {
310        if compiler.target() != stream.target() {
311            return Err(Error::Message(format!(
312                "compiler target {} does not match device target {}",
313                compiler.target().as_str(),
314                stream.target().as_str()
315            )));
316        }
317        Ok(Self {
318            id: NEXT_SESSION_ID.fetch_add(1, Ordering::Relaxed),
319            stream,
320            allocations: Vec::new(),
321            kernels: Vec::new(),
322            cache: Kernels::new(compiler),
323            graphs: HashMap::new(),
324            readback: None,
325            failed: false,
326        })
327    }
328
329    /// Architecture selected by the device and compiler.
330    #[must_use]
331    pub fn target(&self) -> &Target {
332        self.stream.target()
333    }
334
335    /// Allocate device-local model storage.
336    pub fn allocate(&mut self, bytes: usize) -> Result<Region> {
337        self.allocate_with(bytes, Placement::Device)
338    }
339
340    /// Allocate coherent host-local storage visible to the device.
341    pub fn allocate_shared(&mut self, bytes: usize) -> Result<Region> {
342        self.allocate_with(bytes, Placement::Shared)
343    }
344
345    fn allocate_with(&mut self, bytes: usize, placement: Placement) -> Result<Region> {
346        self.usable()?;
347        let buffer = match placement {
348            Placement::Device => self.stream.allocate(bytes)?,
349            Placement::Shared => self.stream.allocate_shared(bytes)?,
350        };
351        let allocation = self.allocations.len();
352        self.allocations.push(Allocation {
353            buffer,
354            placement,
355            immutable: false,
356        });
357        Ok(Region {
358            session: self.id,
359            allocation,
360            offset: 0,
361            bytes,
362        })
363    }
364
365    /// Allocate device-local storage and enqueue its initial contents.
366    pub fn weight(&mut self, bytes: &[u8]) -> Result<Region> {
367        let region = self.allocate(bytes.len())?;
368        self.upload(region, bytes)?;
369        self.allocations[region.allocation].immutable = true;
370        Ok(region)
371    }
372
373    /// Compile and load a batch of trusted embedded Loom kernels.
374    ///
375    /// Returned IDs follow request order and remain valid for this session.
376    ///
377    /// # Safety
378    /// Every source is compiled to native code. The caller must trust it not to
379    /// violate the buffer and scalar contracts later supplied to [`Self::record`].
380    pub unsafe fn compile(
381        &mut self,
382        specifications: &[(&str, Specialization)],
383    ) -> Result<Vec<KernelId>> {
384        self.usable()?;
385        let pending = specifications
386            .iter()
387            .map(|(source, specialization)| self.cache.request(source, specialization))
388            .collect::<Result<Vec<_>>>()?;
389        // Safety: required from this method's caller for every requested source.
390        unsafe { self.cache.build(&self.stream)? };
391        let mut loaded = Vec::with_capacity(pending.len());
392        for request in pending {
393            // Safety: the pending request is one of the sources vouched for above.
394            loaded.push(unsafe { request.resolve(&self.stream)? }.clone());
395        }
396        self.synchronize()?;
397        let first = self.kernels.len();
398        self.kernels.extend(loaded);
399        Ok((first..self.kernels.len())
400            .map(|index| KernelId {
401                session: self.id,
402                index,
403            })
404            .collect())
405    }
406
407    /// Whether a graph has already been prepared under `key`.
408    #[must_use]
409    pub fn is_recorded(&self, key: usize) -> bool {
410        self.graphs.contains_key(&key)
411    }
412
413    /// Record commands once, inferring the minimal buffer hazard dependencies.
414    ///
415    /// Keys are unique within the session; use [`Self::is_recorded`] when a
416    /// shape-dependent preparation path can run more than once.
417    ///
418    /// # Safety
419    /// Each dispatch must match its compiled kernel: scalar types, binding
420    /// order, accessible extents and declared access modes must describe every
421    /// read and write the native kernel can perform. Regions that may alias
422    /// outside this session require the caller to provide equivalent ordering.
423    pub unsafe fn record(&mut self, key: usize, commands: &[Command]) -> Result<()> {
424        self.usable()?;
425        if commands.is_empty() {
426            return Err(Error::Message("cannot record an empty model graph".into()));
427        }
428        if self.graphs.contains_key(&key) {
429            return Err(Error::Message(format!(
430                "model graph key {key} is already recorded"
431            )));
432        }
433        let prepared = self.prepare(commands)?;
434        let mut graph = self.stream.access_graph()?;
435        for command in &prepared {
436            match command {
437                PreparedCommand::Fill { region, value } => {
438                    graph.fill(self.view(*region)?, *value)?;
439                }
440                PreparedCommand::Dispatch {
441                    kernel,
442                    constants,
443                    grid,
444                    bindings,
445                } => {
446                    let kernel = self.kernel(*kernel)?;
447                    let views = bindings
448                        .iter()
449                        .map(|binding| Ok(self.view(binding.region)?.access(binding.access)))
450                        .collect::<Result<Vec<_>>>()?;
451                    // Safety: required from this method's caller for this dispatch.
452                    unsafe {
453                        graph.dispatch(
454                            kernel,
455                            *grid,
456                            kernel.info().workgroup_size,
457                            constants,
458                            &views,
459                        )?
460                    };
461                }
462            }
463        }
464        let executable = graph.finish()?;
465        self.graphs.insert(
466            key,
467            PreparedGraph {
468                executable,
469                commands: prepared,
470            },
471        );
472        Ok(())
473    }
474
475    /// Enqueue a host-to-device transfer at the start of a region.
476    pub fn upload(&mut self, region: Region, bytes: &[u8]) -> Result<()> {
477        self.usable()?;
478        if bytes.len() > region.bytes {
479            return Err(Error::Message("upload exceeds model region".into()));
480        }
481        self.validate_region(region)?;
482        let allocation = &self.allocations[region.allocation].buffer;
483        let view = allocation
484            .try_slice(region.offset, region.bytes)?
485            .slice(0, bytes.len())?;
486        let result = self.stream.upload(view, bytes);
487        if result.is_err() {
488            self.failed = true;
489        }
490        result
491    }
492
493    /// Enqueue one replay of a prepared graph.
494    pub fn replay(&mut self, key: usize) -> Result<()> {
495        self.usable()?;
496        let graph = self
497            .graphs
498            .get_mut(&key)
499            .ok_or_else(|| Error::Message(format!("model graph key {key} is not recorded")))?;
500        let result = self.stream.launch(&mut graph.executable);
501        if result.is_err() {
502            self.failed = true;
503        }
504        result
505    }
506
507    /// Wait for all queued session work.
508    pub fn synchronize(&mut self) -> Result<()> {
509        self.usable()?;
510        let result = self.stream.synchronize();
511        if result.is_err() {
512            self.failed = true;
513        }
514        result
515    }
516
517    /// Preallocate reusable coherent storage for combined readback.
518    ///
519    /// Readback also grows this storage automatically; reserving avoids an
520    /// allocation on the first inference.
521    pub fn reserve_readback(&mut self, bytes: usize) -> Result<()> {
522        self.usable()?;
523        if self
524            .readback
525            .as_ref()
526            .is_none_or(|buffer| buffer.bytes() < bytes)
527        {
528            self.readback = Some(self.stream.allocate_shared(bytes)?);
529        }
530        Ok(())
531    }
532
533    /// Wait for queued work and copy one region into caller-owned bytes.
534    pub fn read(&mut self, region: Region, bytes: &mut [u8]) -> Result<()> {
535        self.read_many(&mut [(region, bytes)])
536    }
537
538    /// Wait once and copy several regions into caller-owned byte slices.
539    pub fn read_many(&mut self, outputs: &mut [(Region, &mut [u8])]) -> Result<()> {
540        self.usable()?;
541        let mut total = 0usize;
542        let mut all_shared = true;
543        for (region, output) in outputs.iter() {
544            self.validate_region(*region)?;
545            if output.len() > region.bytes {
546                return Err(Error::Message("readback exceeds model region".into()));
547            }
548            total = total
549                .checked_add(output.len())
550                .ok_or_else(|| Error::Message("readback size overflow".into()))?;
551            all_shared &= self.allocations[region.allocation].placement == Placement::Shared;
552        }
553        if all_shared {
554            self.synchronize()?;
555            for (region, output) in outputs.iter_mut() {
556                if output.is_empty() {
557                    continue;
558                }
559                let pointer = self.allocations[region.allocation]
560                    .buffer
561                    .device_ptr()?
562                    .cast::<u8>();
563                if pointer.is_null() {
564                    self.failed = true;
565                    return Err(Error::Message("null shared model pointer".into()));
566                }
567                // The allocation is host-local and coherent, synchronization
568                // completed above, and private session storage cannot alias output.
569                unsafe {
570                    std::ptr::copy_nonoverlapping(
571                        pointer.add(region.offset),
572                        output.as_mut_ptr(),
573                        output.len(),
574                    );
575                }
576            }
577            return Ok(());
578        }
579        self.reserve_readback(total)?;
580        {
581            let readback = self.readback.as_ref().expect("reserved above");
582            let mut offset = 0;
583            for (region, output) in outputs.iter() {
584                if !output.is_empty() {
585                    let source = self.allocations[region.allocation]
586                        .buffer
587                        .try_slice(region.offset, output.len())?;
588                    if let Err(error) = self
589                        .stream
590                        .copy(readback.try_slice(offset, output.len())?, source)
591                    {
592                        self.failed = true;
593                        return Err(error);
594                    }
595                }
596                offset += output.len();
597            }
598        }
599        self.synchronize()?;
600        if total == 0 {
601            return Ok(());
602        }
603        let readback = self.readback.as_ref().expect("reserved above");
604        let pointer = readback.device_ptr()?.cast::<u8>();
605        if pointer.is_null() {
606            self.failed = true;
607            return Err(Error::Message("null model readback pointer".into()));
608        }
609        let mut offset = 0;
610        for (_, output) in outputs.iter_mut() {
611            if !output.is_empty() {
612                // The private coherent readback was populated and synchronized
613                // above, and distinct mutable slices cannot alias it.
614                unsafe {
615                    std::ptr::copy_nonoverlapping(
616                        pointer.add(offset),
617                        output.as_mut_ptr(),
618                        output.len(),
619                    );
620                }
621            }
622            offset += output.len();
623        }
624        Ok(())
625    }
626
627    /// Compare synchronized graph replay with the equivalent direct commands.
628    ///
629    /// Transfers are excluded. Ten untimed alternating warm-up pairs precede
630    /// the requested samples to limit ordering and cache bias.
631    pub fn benchmark(&mut self, key: usize, samples: usize) -> Result<ForwardTimings> {
632        if samples < 10 {
633            return Err(Error::Message(
634                "use at least ten model timing samples".into(),
635            ));
636        }
637        self.usable()?;
638        let commands = self
639            .graphs
640            .get(&key)
641            .ok_or_else(|| Error::Message(format!("model graph key {key} is not recorded")))?
642            .commands
643            .clone();
644        let mut times = [Vec::with_capacity(samples), Vec::with_capacity(samples)];
645        for iteration in 0..samples + 10 {
646            for mode in [iteration % 2, 1 - iteration % 2] {
647                let start = Instant::now();
648                if mode == 0 {
649                    self.replay(key)?;
650                } else {
651                    // Safety: these commands were accepted by unsafe `record`.
652                    let result = unsafe { self.execute_direct(&commands) };
653                    if result.is_err() {
654                        self.failed = true;
655                    }
656                    result?;
657                }
658                self.synchronize()?;
659                if iteration >= 10 {
660                    times[mode].push(start.elapsed().as_secs_f64() * 1_000.0);
661                }
662            }
663        }
664        let [graph, direct] = times;
665        Ok(ForwardTimings {
666            samples,
667            graph: Distribution::from_samples(graph)?,
668            direct: Distribution::from_samples(direct)?,
669        })
670    }
671
672    /// Profile each prepared command as an isolated synchronized submission.
673    ///
674    /// This is a diagnostic view of per-command host latency, not an estimate
675    /// of graph contribution: it includes dispatch and synchronization overhead
676    /// and removes graph overlap. One unmeasured warm-up precedes each command's
677    /// samples. Commands execute repeatedly and may change resident outputs.
678    pub fn profile_commands(&mut self, key: usize, samples: usize) -> Result<Vec<Distribution>> {
679        if samples == 0 {
680            return Err(Error::Message(
681                "use at least one command timing sample".into(),
682            ));
683        }
684        self.usable()?;
685        let commands = self
686            .graphs
687            .get(&key)
688            .ok_or_else(|| Error::Message(format!("model graph key {key} is not recorded")))?
689            .commands
690            .clone();
691        let mut distributions = Vec::with_capacity(commands.len());
692        for command in commands {
693            // Safety: this command was accepted by unsafe `record`.
694            let warmup = unsafe { self.execute_direct(std::slice::from_ref(&command)) };
695            if warmup.is_err() {
696                self.failed = true;
697            }
698            warmup?;
699            self.synchronize()?;
700            let mut times = Vec::with_capacity(samples);
701            for _ in 0..samples {
702                let start = Instant::now();
703                // Safety: this command was accepted by unsafe `record`.
704                let result = unsafe { self.execute_direct(std::slice::from_ref(&command)) };
705                if result.is_err() {
706                    self.failed = true;
707                }
708                result?;
709                self.synchronize()?;
710                times.push(start.elapsed().as_secs_f64() * 1_000.0);
711            }
712            distributions.push(Distribution::from_samples(times)?);
713        }
714        Ok(distributions)
715    }
716
717    fn usable(&self) -> Result<()> {
718        if self.failed {
719            Err(Error::Message(
720                "model session is unusable after a GPU failure".into(),
721            ))
722        } else {
723            Ok(())
724        }
725    }
726
727    fn validate_region(&self, region: Region) -> Result<()> {
728        if region.session != self.id {
729            return Err(Error::Message(
730                "model region belongs to another session".into(),
731            ));
732        }
733        let allocation = self
734            .allocations
735            .get(region.allocation)
736            .ok_or_else(|| Error::Message("invalid model allocation".into()))?;
737        let end = region
738            .offset
739            .checked_add(region.bytes)
740            .ok_or_else(|| Error::Message("model region size overflow".into()))?;
741        if end > allocation.buffer.bytes() {
742            return Err(Error::Message("model region exceeds its allocation".into()));
743        }
744        Ok(())
745    }
746
747    fn view(&self, region: Region) -> Result<crate::View<'_>> {
748        self.validate_region(region)?;
749        self.allocations[region.allocation]
750            .buffer
751            .try_slice(region.offset, region.bytes)
752    }
753
754    fn views<'a>(&'a self, bindings: &[Binding]) -> Result<Vec<crate::View<'a>>> {
755        bindings
756            .iter()
757            .map(|binding| self.view(binding.region))
758            .collect()
759    }
760
761    /// Evaluate compiler launch geometry while preparing a model graph.
762    /// Workload bits follow the kernel definition's workload signature, which
763    /// can differ from the scalar arguments supplied to the device entry.
764    pub fn launch_config(
765        &self,
766        id: KernelId,
767        workload_bits: &[u64],
768    ) -> Result<crate::loom::LaunchConfig> {
769        self.usable()?;
770        self.kernel(id)?.launch_config(workload_bits)
771    }
772
773    fn kernel(&self, id: KernelId) -> Result<&Kernel> {
774        if id.session != self.id {
775            return Err(Error::Message(
776                "model kernel belongs to another session".into(),
777            ));
778        }
779        self.kernels
780            .get(id.index)
781            .ok_or_else(|| Error::Message("invalid model kernel".into()))
782    }
783
784    fn prepare(&self, commands: &[Command]) -> Result<Vec<PreparedCommand>> {
785        commands
786            .iter()
787            .map(|command| match command {
788                Command::Fill { region, value } => {
789                    self.validate_region(*region)?;
790                    if region.is_empty() {
791                        return Err(Error::Message("cannot record an empty model fill".into()));
792                    }
793                    Ok(PreparedCommand::Fill {
794                        region: *region,
795                        value: *value,
796                    })
797                }
798                Command::Dispatch(dispatch) => {
799                    let kernel = self.kernel(dispatch.kernel)?;
800                    for binding in &dispatch.bindings {
801                        self.validate_region(binding.region)?;
802                    }
803                    let constants = match &dispatch.arguments {
804                        Arguments::Indices(values) => Box::new(Constants::indices(kernel, values)?),
805                        Arguments::Packed(constants) => constants.clone(),
806                    };
807                    Ok(PreparedCommand::Dispatch {
808                        kernel: dispatch.kernel,
809                        constants,
810                        grid: dispatch.grid,
811                        bindings: dispatch.bindings.clone(),
812                    })
813                }
814            })
815            .collect()
816    }
817
818    unsafe fn execute_direct(&self, commands: &[PreparedCommand]) -> Result<()> {
819        for command in commands {
820            match command {
821                PreparedCommand::Fill { region, value } => {
822                    self.stream.fill(self.view(*region)?, *value)?;
823                }
824                PreparedCommand::Dispatch {
825                    kernel,
826                    constants,
827                    grid,
828                    bindings,
829                } => {
830                    let kernel = self.kernel(*kernel)?;
831                    let views = self.views(bindings)?;
832                    // Safety: inherited from this method's caller.
833                    unsafe {
834                        self.stream.dispatch(
835                            kernel,
836                            *grid,
837                            kernel.info().workgroup_size,
838                            constants,
839                            &views,
840                        )?;
841                    }
842                }
843            }
844        }
845        Ok(())
846    }
847}
848
849#[derive(Clone, Copy, Debug)]
850struct ScratchLifetime {
851    bytes: usize,
852    first: usize,
853    last: usize,
854}
855
856/// Plans best-fit reuse of temporary allocations from their inclusive lifetimes.
857#[derive(Clone, Debug, Default)]
858pub struct ScratchPlanner<K> {
859    values: BTreeMap<K, ScratchLifetime>,
860}
861
862impl<K: Clone + Ord> ScratchPlanner<K> {
863    /// Create an empty planner.
864    #[must_use]
865    pub fn new() -> Self {
866        Self {
867            values: BTreeMap::new(),
868        }
869    }
870
871    /// Add a value alive from `first` through `last`, inclusive.
872    pub fn insert(&mut self, key: K, bytes: usize, first: usize, last: usize) -> Result<()> {
873        if bytes == 0 {
874            return Err(Error::Message("scratch values must be nonempty".into()));
875        }
876        if first > last {
877            return Err(Error::Message(
878                "scratch lifetime ends before it begins".into(),
879            ));
880        }
881        if self
882            .values
883            .insert(key, ScratchLifetime { bytes, first, last })
884            .is_some()
885        {
886            return Err(Error::Message("duplicate scratch value".into()));
887        }
888        Ok(())
889    }
890
891    /// Assign values to reusable slots, preferring the smallest fitting free slot.
892    pub fn finish(self) -> ScratchPlan<K> {
893        let mut values = self.values.into_iter().collect::<Vec<_>>();
894        values.sort_by(|(left_key, left), (right_key, right)| {
895            (left.first, std::cmp::Reverse(left.bytes), left_key).cmp(&(
896                right.first,
897                std::cmp::Reverse(right.bytes),
898                right_key,
899            ))
900        });
901        let mut active = Vec::<(usize, usize)>::new();
902        let mut free = BTreeSet::<usize>::new();
903        let mut slots = Vec::<usize>::new();
904        let mut assignments = BTreeMap::new();
905        for (key, lifetime) in values {
906            active.retain(|&(last, slot)| {
907                if last < lifetime.first {
908                    free.insert(slot);
909                    false
910                } else {
911                    true
912                }
913            });
914            let slot = free
915                .iter()
916                .copied()
917                .filter(|&slot| slots[slot] >= lifetime.bytes)
918                .min_by_key(|&slot| slots[slot])
919                .map_or_else(
920                    || {
921                        slots.push(lifetime.bytes);
922                        slots.len() - 1
923                    },
924                    |slot| {
925                        free.remove(&slot);
926                        slot
927                    },
928                );
929            assignments.insert(key, slot);
930            active.push((lifetime.last, slot));
931        }
932        ScratchPlan { assignments, slots }
933    }
934}
935
936/// Allocation sizes and value-to-slot assignments produced by [`ScratchPlanner`].
937#[derive(Clone, Debug)]
938pub struct ScratchPlan<K> {
939    assignments: BTreeMap<K, usize>,
940    slots: Vec<usize>,
941}
942
943impl<K: Ord> ScratchPlan<K> {
944    /// Reusable allocation sizes, indexed by slot.
945    #[must_use]
946    pub fn slots(&self) -> &[usize] {
947        &self.slots
948    }
949
950    /// Slot assigned to a value.
951    pub fn slot(&self, key: &K) -> Result<usize> {
952        self.assignments
953            .get(key)
954            .copied()
955            .ok_or_else(|| Error::Message("scratch value was not planned".into()))
956    }
957
958    /// All assignments in key order.
959    #[must_use]
960    pub fn assignments(&self) -> &BTreeMap<K, usize> {
961        &self.assignments
962    }
963}
964
965/// Synchronized host timings for a resident forward pass, excluding transfers.
966#[derive(Clone, Copy, Debug, serde::Serialize)]
967pub struct ForwardTimings {
968    /// Number of measured samples for each mode.
969    pub samples: usize,
970    /// Reusable graph replay timings.
971    pub graph: Distribution,
972    /// Equivalent direct stream dispatch timings.
973    pub direct: Distribution,
974}
975
976#[cfg(test)]
977mod tests {
978    use super::*;
979    use std::ops::Range;
980
981    fn region(allocation: usize, range: Range<usize>) -> Region {
982        Region {
983            session: 1,
984            allocation,
985            offset: range.start,
986            bytes: range.end - range.start,
987        }
988    }
989
990    #[test]
991    fn region_slices_are_relative_and_checked() {
992        let whole = region(2, 100..200);
993        let slice = whole.slice(25, 50).unwrap();
994        assert_eq!(slice.offset(), 125);
995        assert_eq!(slice.len(), 50);
996        assert!(whole.slice(101, 0).is_err());
997        assert!(whole.slice(usize::MAX, 2).is_err());
998    }
999
1000    #[test]
1001    fn distributions_use_nearest_rank_quantiles() {
1002        let distribution = Distribution::from_samples((1..=100).map(f64::from).collect()).unwrap();
1003        assert_eq!(distribution.median_ms, 50.0);
1004        assert_eq!(distribution.p95_ms, 95.0);
1005    }
1006
1007    #[test]
1008    fn scratch_slots_reuse_only_after_the_last_use() {
1009        let mut planner = ScratchPlanner::new();
1010        planner.insert("a", 8, 0, 1).unwrap();
1011        planner.insert("b", 16, 1, 2).unwrap();
1012        planner.insert("c", 4, 2, 3).unwrap();
1013        planner.insert("d", 7, 3, 3).unwrap();
1014        let plan = planner.finish();
1015        assert_ne!(plan.slot(&"a").unwrap(), plan.slot(&"b").unwrap());
1016        assert_eq!(plan.slot(&"a").unwrap(), plan.slot(&"c").unwrap());
1017        assert_eq!(plan.slot(&"b").unwrap(), plan.slot(&"d").unwrap());
1018    }
1019}