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}