Skip to main content

g2g_core/
copyplan.rs

1//! Copy / allocation plan (M613): a static, pre-run analysis of the memory-domain
2//! path a frame takes through a negotiated graph.
3//!
4//! g2g's whole-graph negotiation already resolves, per edge, the memory domain the
5//! frame lives in (the producer's `output_memory`, see
6//! `runtime::graph_runner::negotiate_graph`). This module turns that implicit
7//! knowledge into an explicit, checkable artifact: the sequence of memory *hops* and
8//! the *transfers* between them, so a pipeline can prove a property GStreamer cannot
9//! state at construction time:
10//!
11//! > "This graph keeps every frame on the GPU end to end: zero host round-trips."
12//!
13//! A **transfer** is a node whose output domain differs from the domain it consumed
14//! (the framework's domain converters live here: a CUDA upload, an NVDEC download, a
15//! `WgpuToDmaBuf` export). Each transfer is classified by cost
16//! ([`TransferKind`]) and flagged as a real **frame copy** only when a raw heavy
17//! buffer (raw video / PCM audio / tensor, see [`Caps::is_raw_media`]) crosses the
18//! boundary on both sides. A decode (`CompressedVideo` -> `RawVideo`) or encode
19//! (`RawVideo` -> `CompressedVideo`) changes domain without copying a raw frame, so
20//! it is surfaced in the trace but not counted as a copy.
21//!
22//! [`CopyPlan::check`] enforces a [`CopyPolicy`] as a graph-level contract: a graph
23//! that exceeds its copy budget fails the check, so an accidental host round-trip in
24//! a zero-copy pipeline is caught before the pipeline runs, not measured after.
25//!
26//! The analysis is pure (no graph or element types): it works over the flat
27//! [`NodeProfile`] / [`EdgeProfile`] arrays the runner extracts from a negotiated
28//! graph, mirroring how [`crate::dot`] takes flat annotations.
29
30use alloc::format;
31use alloc::string::{String, ToString};
32use alloc::vec::Vec;
33
34use crate::caps::Caps;
35use crate::memory::MemoryDomainKind;
36
37/// One node's memory profile for the copy analysis.
38#[derive(Debug, Clone)]
39pub struct NodeProfile {
40    /// Display label (the element's log category, or the structural kind).
41    pub label: String,
42    /// The memory domain the node emits on its output (`System` if it has none).
43    pub out_domain: MemoryDomainKind,
44}
45
46/// One negotiated edge: the frame's domain and fixated caps as it leaves the
47/// producer (`src`) for the consumer (`dst`), indexed into the node array.
48#[derive(Debug, Clone)]
49pub struct EdgeProfile {
50    /// Producer node index.
51    pub src: usize,
52    /// Consumer node index.
53    pub dst: usize,
54    /// The memory domain the frame occupies on this edge (the producer's output).
55    pub domain: MemoryDomainKind,
56    /// The fixated caps on this edge.
57    pub caps: Caps,
58}
59
60/// The cost class of moving a frame from one memory domain to another.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum TransferKind {
63    /// Same domain, or both host-side: the frame is handed over by reference / a
64    /// negligible host operation. No device transfer.
65    None,
66    /// Crosses a boundary designed for zero-copy sharing (a dma-buf import/export,
67    /// or a device-to-device interop bridge). No guaranteed byte copy, but the
68    /// boundary is flagged so a strict budget can still see it.
69    Interop,
70    /// A device-to-host transfer over the system bus (a GPU download or upload of
71    /// the frame bytes). The expensive copy, and the one a zero-copy GPU pipeline
72    /// exists to avoid.
73    DeviceHost,
74    /// A copy between two distinct device domains with no known zero-copy path.
75    CrossDevice,
76}
77
78impl TransferKind {
79    /// Whether this transfer moves bytes (device-to-host or cross-device), as
80    /// opposed to a free handoff ([`None`](Self::None)) or a zero-copy
81    /// [`Interop`](Self::Interop) share.
82    pub fn copies_bytes(self) -> bool {
83        matches!(self, TransferKind::DeviceHost | TransferKind::CrossDevice)
84    }
85}
86
87/// Whether a domain is host (CPU) memory.
88fn is_host(k: MemoryDomainKind) -> bool {
89    matches!(k, MemoryDomainKind::System | MemoryDomainKind::SystemView)
90}
91
92/// Classify the cost of moving a frame from domain `from` to domain `to`.
93pub fn classify(from: MemoryDomainKind, to: MemoryDomainKind) -> TransferKind {
94    if from == to {
95        return TransferKind::None;
96    }
97    // dma-buf exists precisely for zero-copy sharing (across the CPU/GPU line or
98    // between devices), so any hop involving it is interop, not a byte copy.
99    if from == MemoryDomainKind::DmaBuf || to == MemoryDomainKind::DmaBuf {
100        return TransferKind::Interop;
101    }
102    match (is_host(from), is_host(to)) {
103        // Both host (System <-> SystemView): a view or a cheap CPU touch, no
104        // device bus transfer.
105        (true, true) => TransferKind::None,
106        // Exactly one side is host: a GPU download or upload over the bus.
107        (true, false) | (false, true) => TransferKind::DeviceHost,
108        // Two distinct device domains with no dma-buf bridge: a staging copy
109        // (may be zero-copy interop depending on the bridge element; flagged).
110        (false, false) => TransferKind::CrossDevice,
111    }
112}
113
114/// A memory-domain transition at a node: it consumed `from` and emits `to`.
115#[derive(Debug, Clone)]
116pub struct Transfer {
117    /// Node index where the domain changes.
118    pub at: usize,
119    /// The node's display label.
120    pub label: String,
121    /// The domain consumed on the input edge.
122    pub from: MemoryDomainKind,
123    /// The domain emitted on the output edge.
124    pub to: MemoryDomainKind,
125    /// The transfer's cost class.
126    pub kind: TransferKind,
127    /// Whether a raw heavy buffer (raw video / PCM audio / tensor) crosses the
128    /// boundary on both sides: a real frame copy, as opposed to a codec boundary.
129    pub frame_copy: bool,
130}
131
132impl Transfer {
133    /// Whether this transfer is a real, counted frame copy: a byte-copying
134    /// transfer of a raw heavy buffer (not a free handoff, zero-copy interop, or a
135    /// codec boundary).
136    pub fn is_counted_copy(&self) -> bool {
137        self.frame_copy && self.kind.copies_bytes()
138    }
139}
140
141/// One memory hop: the domain a frame occupies on one edge, producer to consumer.
142#[derive(Debug, Clone)]
143pub struct Hop {
144    /// Producer label.
145    pub src_label: String,
146    /// Consumer label.
147    pub dst_label: String,
148    /// The domain the frame lives in on this hop.
149    pub domain: MemoryDomainKind,
150}
151
152/// The memory-domain path through a negotiated graph: the per-edge [`Hop`]s and the
153/// [`Transfer`]s between differing domains.
154#[derive(Debug, Clone)]
155pub struct CopyPlan {
156    /// Per-edge memory hops, in edge order.
157    pub hops: Vec<Hop>,
158    /// Domain transitions (the converter / transfer points), in node order.
159    pub transfers: Vec<Transfer>,
160}
161
162/// A graph-level budget on memory-domain copies, enforced by [`CopyPlan::check`].
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum CopyPolicy {
165    /// Report only; the check always passes.
166    Allow,
167    /// Pass only if the plan has at most `n` counted frame copies.
168    AtMost(u8),
169    /// Pass only with zero frame copies: the strict zero-copy contract
170    /// (equivalent to `AtMost(0)`).
171    DenyAll,
172}
173
174/// A [`CopyPolicy`] violation: the plan had more frame copies than the budget.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct CopyBudgetError {
177    /// Counted frame copies found in the plan.
178    pub copies: usize,
179    /// The budget the policy allowed.
180    pub budget: usize,
181    /// A short description of each counted copy (`"node: From -> To"`).
182    pub offenders: Vec<String>,
183}
184
185impl core::fmt::Display for CopyBudgetError {
186    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
187        write!(
188            f,
189            "copy budget exceeded: {} frame copies, budget {} ({})",
190            self.copies,
191            self.budget,
192            self.offenders.join(", ")
193        )
194    }
195}
196
197impl CopyPlan {
198    /// Analyze a negotiated graph's flat node / edge profiles into a copy plan.
199    ///
200    /// A transfer is recorded at every node whose output domain differs from the
201    /// domain it consumed on an input edge; it is a counted frame copy when that
202    /// input edge and the node's output both carry raw media
203    /// ([`Caps::is_raw_media`]) and the transfer copies bytes.
204    pub fn analyze(nodes: &[NodeProfile], edges: &[EdgeProfile]) -> CopyPlan {
205        let label = |i: usize| nodes.get(i).map(|n| n.label.clone()).unwrap_or_default();
206
207        let hops = edges
208            .iter()
209            .map(|e| Hop {
210                src_label: label(e.src),
211                dst_label: label(e.dst),
212                domain: e.domain,
213            })
214            .collect();
215
216        let mut transfers = Vec::new();
217        for (idx, node) in nodes.iter().enumerate() {
218            let out = node.out_domain;
219            // The caps this node emits (from any of its output edges), used to tell
220            // a raw-frame transfer from a codec boundary.
221            let out_caps = edges.iter().find(|e| e.src == idx).map(|e| &e.caps);
222            // Each input edge is a potential domain transition into this node.
223            for e in edges.iter().filter(|e| e.dst == idx) {
224                let kind = classify(e.domain, out);
225                if kind == TransferKind::None {
226                    continue;
227                }
228                let frame_copy =
229                    e.caps.is_raw_media() && out_caps.is_some_and(|c| c.is_raw_media());
230                transfers.push(Transfer {
231                    at: idx,
232                    label: node.label.clone(),
233                    from: e.domain,
234                    to: out,
235                    kind,
236                    frame_copy,
237                });
238            }
239        }
240        CopyPlan { hops, transfers }
241    }
242
243    /// The number of counted frame copies: byte-copying transfers of a raw heavy
244    /// buffer. This is what [`CopyPolicy`] budgets against.
245    pub fn frame_copies(&self) -> usize {
246        self.transfers
247            .iter()
248            .filter(|t| t.is_counted_copy())
249            .count()
250    }
251
252    /// The number of device-to-host round trips of a raw frame (the PCIe copies):
253    /// a subset of [`frame_copies`](Self::frame_copies).
254    pub fn host_round_trips(&self) -> usize {
255        self.transfers
256            .iter()
257            .filter(|t| t.frame_copy && t.kind == TransferKind::DeviceHost)
258            .count()
259    }
260
261    /// Whether the graph is zero-copy: no counted frame copies.
262    pub fn is_zero_copy(&self) -> bool {
263        self.frame_copies() == 0
264    }
265
266    /// Enforce a [`CopyPolicy`] as a graph-level contract.
267    pub fn check(&self, policy: CopyPolicy) -> Result<(), CopyBudgetError> {
268        let budget = match policy {
269            CopyPolicy::Allow => return Ok(()),
270            CopyPolicy::DenyAll => 0,
271            CopyPolicy::AtMost(n) => n as usize,
272        };
273        let copies = self.frame_copies();
274        if copies <= budget {
275            return Ok(());
276        }
277        let offenders = self
278            .transfers
279            .iter()
280            .filter(|t| t.is_counted_copy())
281            .map(|t| format!("{}: {:?} -> {:?}", t.label, t.from, t.to))
282            .collect();
283        Err(CopyBudgetError {
284            copies,
285            budget,
286            offenders,
287        })
288    }
289
290    /// A human-readable report: the per-hop domain trace with each transfer marked
291    /// (`!` for a counted frame copy, `~` for a zero-copy interop / codec
292    /// boundary), and a one-line verdict.
293    pub fn to_report(&self) -> String {
294        let mut s = String::new();
295        let copies = self.frame_copies();
296        let verdict = if copies == 0 {
297            "zero-copy".to_string()
298        } else {
299            format!(
300                "{copies} frame cop{}",
301                if copies == 1 { "y" } else { "ies" }
302            )
303        };
304        s.push_str(&format!("Copy plan ({verdict}):\n"));
305        for hop in &self.hops {
306            s.push_str(&format!(
307                "  {} --{:?}--> {}\n",
308                hop.src_label, hop.domain, hop.dst_label
309            ));
310        }
311        if !self.transfers.is_empty() {
312            s.push_str("  transfers:\n");
313            for t in &self.transfers {
314                let mark = if t.is_counted_copy() { "!" } else { "~" };
315                let note = match (t.kind, t.frame_copy) {
316                    (TransferKind::DeviceHost, true) => "device<->host copy (raw frame)",
317                    (TransferKind::CrossDevice, true) => "cross-device copy (raw frame)",
318                    (TransferKind::DeviceHost, false) | (TransferKind::CrossDevice, false) => {
319                        "codec boundary (no raw-frame copy)"
320                    }
321                    (TransferKind::Interop, _) => "zero-copy interop",
322                    (TransferKind::None, _) => "",
323                };
324                s.push_str(&format!(
325                    "    {mark} {}  {:?} -> {:?}  {note}\n",
326                    t.label, t.from, t.to
327                ));
328            }
329        }
330        s
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::caps::{Caps, RawVideoFormat, VideoCodec};
338    use crate::memory::MemoryDomainKind::*;
339    use crate::{Dim, Rate};
340
341    fn raw(domain: MemoryDomainKind) -> EdgeProfile {
342        EdgeProfile {
343            src: 0,
344            dst: 0,
345            domain,
346            caps: Caps::RawVideo {
347                format: RawVideoFormat::Rgba8,
348                width: Dim::Fixed(64),
349                height: Dim::Fixed(64),
350                framerate: Rate::Fixed(30 << 16),
351                interlace: crate::Interlace::Any,
352            },
353        }
354    }
355
356    fn compressed(domain: MemoryDomainKind) -> EdgeProfile {
357        EdgeProfile {
358            src: 0,
359            dst: 0,
360            domain,
361            caps: Caps::CompressedVideo {
362                codec: VideoCodec::H264,
363                width: Dim::Fixed(64),
364                height: Dim::Fixed(64),
365                framerate: Rate::Fixed(30 << 16),
366            },
367        }
368    }
369
370    fn node(label: &str, out: MemoryDomainKind) -> NodeProfile {
371        NodeProfile {
372            label: label.to_string(),
373            out_domain: out,
374        }
375    }
376
377    /// Wire up a linear chain: nodes[i] -> nodes[i+1], each edge carrying the given
378    /// (caps-template, domain) via the provided EdgeProfile whose src/dst we fix.
379    fn chain(nodes: &[NodeProfile], mut edges: Vec<EdgeProfile>) -> CopyPlan {
380        for (i, e) in edges.iter_mut().enumerate() {
381            e.src = i;
382            e.dst = i + 1;
383        }
384        CopyPlan::analyze(nodes, &edges)
385    }
386
387    #[test]
388    fn classify_covers_the_domain_families() {
389        assert_eq!(classify(System, System), TransferKind::None);
390        assert_eq!(
391            classify(System, SystemView),
392            TransferKind::None,
393            "host<->host is free"
394        );
395        assert_eq!(classify(System, Cuda), TransferKind::DeviceHost, "upload");
396        assert_eq!(classify(Cuda, System), TransferKind::DeviceHost, "download");
397        assert_eq!(classify(Cuda, WgpuTexture), TransferKind::CrossDevice);
398        assert_eq!(
399            classify(Cuda, DmaBuf),
400            TransferKind::Interop,
401            "dma-buf is zero-copy"
402        );
403        assert_eq!(classify(DmaBuf, System), TransferKind::Interop);
404    }
405
406    #[test]
407    fn all_system_pipeline_is_zero_copy() {
408        // filesrc -> parse -> dec -> sink, all in System memory: no transfers.
409        let nodes = [
410            node("filesrc", System),
411            node("h264parse", System),
412            node("dec", System),
413            node("sink", System),
414        ];
415        let plan = chain(
416            &nodes,
417            alloc::vec![compressed(System), compressed(System), raw(System)],
418        );
419        assert!(plan.is_zero_copy());
420        assert_eq!(plan.frame_copies(), 0);
421        assert!(plan.transfers.is_empty(), "no domain changes");
422        assert!(plan.check(CopyPolicy::DenyAll).is_ok());
423    }
424
425    #[test]
426    fn gpu_resident_pipeline_stays_zero_copy_across_a_decode() {
427        // nvdec decodes compressed(System) -> raw(Cuda), then cudascale stays on
428        // Cuda, then a cuda sink. The decode changes domain (System->Cuda) but the
429        // input is compressed, so it is a codec boundary, not a counted frame copy.
430        let nodes = [
431            node("filesrc", System),
432            node("nvh264dec", Cuda),
433            node("cudascale", Cuda),
434            node("cudasink", Cuda),
435        ];
436        let plan = chain(
437            &nodes,
438            alloc::vec![compressed(System), raw(Cuda), raw(Cuda)],
439        );
440        assert_eq!(
441            plan.frame_copies(),
442            0,
443            "decode-into-device is not a raw-frame copy"
444        );
445        assert!(plan.is_zero_copy());
446        // The transition is still surfaced in the trace.
447        assert_eq!(plan.transfers.len(), 1);
448        assert_eq!(plan.transfers[0].kind, TransferKind::DeviceHost);
449        assert!(!plan.transfers[0].frame_copy);
450        assert!(plan.check(CopyPolicy::DenyAll).is_ok());
451    }
452
453    #[test]
454    fn a_host_download_of_a_raw_frame_is_a_counted_copy() {
455        // A GPU decoder that downloads: raw(Cuda) -> a videoconvert that emits
456        // raw(System). Both sides raw, device->host: a real PCIe copy.
457        let nodes = [
458            node("nvdec", Cuda),
459            node("download", System), // consumes raw Cuda, emits raw System
460            node("filesink", System),
461        ];
462        let plan = chain(&nodes, alloc::vec![raw(Cuda), raw(System)]);
463        assert_eq!(plan.frame_copies(), 1);
464        assert_eq!(plan.host_round_trips(), 1);
465        assert!(!plan.is_zero_copy());
466        let err = plan.check(CopyPolicy::DenyAll).unwrap_err();
467        assert_eq!(err.copies, 1);
468        assert_eq!(err.budget, 0);
469        assert_eq!(err.offenders.len(), 1);
470        assert!(err.offenders[0].contains("download"));
471        // A budget of one copy tolerates it.
472        assert!(plan.check(CopyPolicy::AtMost(1)).is_ok());
473    }
474
475    #[test]
476    fn encode_off_gpu_is_not_a_frame_copy() {
477        // nvenc reads raw(Cuda) and emits compressed(System): the raw frame is
478        // consumed on-device, only the small bitstream lands in System. Domain
479        // changes, but the output is not raw -> not a counted copy.
480        let nodes = [
481            node("cudasrc", Cuda),
482            node("nvenc", System),
483            node("filesink", System),
484        ];
485        let plan = chain(&nodes, alloc::vec![raw(Cuda), compressed(System)]);
486        assert_eq!(plan.frame_copies(), 0);
487        assert!(plan.is_zero_copy());
488    }
489
490    #[test]
491    fn report_marks_the_offending_copy() {
492        let nodes = [
493            node("nvdec", Cuda),
494            node("download", System),
495            node("sink", System),
496        ];
497        let plan = chain(&nodes, alloc::vec![raw(Cuda), raw(System)]);
498        let report = plan.to_report();
499        assert!(
500            report.contains("1 frame copy"),
501            "verdict counts the copy:\n{report}"
502        );
503        assert!(
504            report.contains("! download"),
505            "the copy is flagged:\n{report}"
506        );
507        assert!(
508            report.contains("Cuda"),
509            "trace shows the GPU hop:\n{report}"
510        );
511    }
512}