gpu-trace-perf 1.9.0

Plays a collection of GPU traces under different environments to evaluate driver changes on performance
Documentation
//! Deserialization structs for the traces TOML config format.

use std::{collections::HashMap, path::Path};

use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct TracesConfig {
    pub traces_db: TracesDb,
    /// Per-device declarations (memory capacity, etc).  Devices referenced by
    /// trace entries but not present here are treated as having zero capacity.
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub devices: HashMap<String, DeviceEntry>,
    pub traces: Vec<TraceEntry>,
}

impl TracesConfig {
    pub fn load(path: &Path) -> Result<TracesConfig> {
        let contents = std::fs::read_to_string(path)
            .with_context(|| format!("reading config file {}", path.display()))?;
        let config: TracesConfig = toml::from_str(&contents)
            .with_context(|| format!("parsing TOML config {}", path.display()))?;
        config
            .validate()
            .with_context(|| format!("validating TOML config {}", path.display()))?;
        Ok(config)
    }

    /// Checks that each trace's declared resource usage fits within every
    /// referenced device's declared capacity.  Devices missing from the
    /// top-level `[devices]` table are treated as having zero capacity, so a
    /// trace can't declare non-zero resources for an undeclared device — that
    /// would deadlock at replay time on a zero-permit semaphore.
    fn validate(&self) -> Result<()> {
        for trace in &self.traces {
            for (dev_name, dev_entry) in &trace.devices {
                if dev_entry.skip {
                    continue;
                }

                let (cap_vram, cap_sysmem) = self
                    .devices
                    .get(dev_name)
                    .map(|d| (d.vram, d.sysmem))
                    .unwrap_or((0, 0));
                if trace.vram > cap_vram {
                    bail!(
                        "trace {} requires vram={}MB but device {} only declares {}MB",
                        trace.path,
                        trace.vram,
                        dev_name,
                        cap_vram
                    );
                }
                if trace.sysmem > cap_sysmem {
                    bail!(
                        "trace {} requires sysmem={}MB but device {} only declares {}MB",
                        trace.path,
                        trace.sysmem,
                        dev_name,
                        cap_sysmem
                    );
                }
            }
        }
        Ok(())
    }

    /// Iterates over traces that have an entry for `device` and are not marked skip.
    pub fn active_for_device<'a>(
        &'a self,
        device: &'a str,
    ) -> impl Iterator<Item = (&'a TraceEntry, &'a TraceDeviceEntry)> {
        self.traces.iter().filter_map(move |trace| {
            let device_entry = trace.device(device)?;
            if device_entry.skip {
                return None;
            }
            Some((trace, device_entry))
        })
    }
}

#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct TracesDb {
    pub download_url: String,
}

fn is_zero(v: &usize) -> bool {
    *v == 0
}

/// Top-level declaration of a target device's resource capacity, in MB.
#[derive(Debug, Deserialize, Serialize, Default)]
#[serde(deny_unknown_fields)]
pub struct DeviceEntry {
    #[serde(default, skip_serializing_if = "is_zero")]
    pub vram: usize,
    #[serde(default, skip_serializing_if = "is_zero")]
    pub sysmem: usize,
}

#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct TraceEntry {
    pub path: String,
    /// When true, we shouldn't use apitrace's internal frame looping support
    /// due to instability (not all sequences of API calls can just be
    /// replayed).  Instead, do the replay as many times as we are capturing
    /// frames.
    #[serde(default)]
    pub nonloopable: bool,
    /// Extra arguments appended to the replay command for this trace,
    /// regardless of device.  Concatenated with any per-device replay_args.
    #[serde(default)]
    pub replay_args: Vec<String>,
    /// VRAM the trace requires while replaying, in MB.  Validated against the
    /// target device's declared capacity at config load.
    #[serde(default, skip_serializing_if = "is_zero")]
    pub vram: usize,
    /// System memory the trace requires while replaying, in MB.  Validated
    /// against the target device's declared capacity at config load.
    #[serde(default, skip_serializing_if = "is_zero")]
    pub sysmem: usize,
    #[serde(default)]
    pub devices: HashMap<String, TraceDeviceEntry>,
}

impl TraceEntry {
    pub fn device(&self, name: &str) -> Option<&TraceDeviceEntry> {
        self.devices.get(name)
    }

    /// Returns the effective replay args for this trace on `device`: trace-level
    /// args followed by device-level args.
    pub fn combined_replay_args(&self, device: &TraceDeviceEntry) -> Vec<String> {
        let mut args = self.replay_args.clone();
        args.extend(device.replay_args.iter().cloned());
        args
    }
}

#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct TraceDeviceEntry {
    pub checksum: String,
    /// Don't run any other traces in parallel with this one.
    #[serde(default)]
    pub singlethread: bool,
    /// Skip this trace on this device entirely.
    #[serde(default)]
    pub skip: bool,
    /// Extra arguments appended to the replay command for this trace on this
    /// device.  Concatenated after any per-trace replay_args.
    #[serde(default)]
    pub replay_args: Vec<String>,
}

#[cfg(test)]
mod tests {
    use super::*;

    const MINIMAL_TOML: &str = r#"
[traces_db]
download_url = "https://s3.freedesktop.org/mesa-tracie-public/"

[[traces]]
path = "valve/half-life-2-v2.trace"
devices = {
    freedreno-a306 = {
        checksum = "8f5929c82e7d990e8c3d2bea14688224aabbccdd8f5929c82e7d990e8c3d2bea",
        skip = true,
    },
    freedreno-a530 = {
        checksum = "c7b816feafeae42eef3ccd5357db4cd7c7b816feafeae42eef3ccd5357db4cd7",
    },
}

[[traces]]
path = "valve/portal-2-v2.trace"
devices = {
    freedreno-a530 = {
        checksum = "102a09ce76092436173fd09a6a2bd941102a09ce76092436173fd09a6a2bd941"
    },
}
"#;

    #[test]
    fn parse_minimal_toml() {
        let config: TracesConfig = toml::from_str(MINIMAL_TOML).expect("parsing TOML");

        assert_eq!(
            config.traces_db.download_url,
            "https://s3.freedesktop.org/mesa-tracie-public/"
        );
        assert_eq!(config.traces.len(), 2);

        let hl2 = &config.traces[0];
        assert_eq!(hl2.path, "valve/half-life-2-v2.trace");
        assert_eq!(hl2.devices.len(), 2);

        let a306 = &hl2.device("freedreno-a306").unwrap();
        assert_eq!(
            a306.checksum,
            "8f5929c82e7d990e8c3d2bea14688224aabbccdd8f5929c82e7d990e8c3d2bea"
        );
        assert!(a306.skip);

        let a530 = &hl2.device("freedreno-a530").unwrap();
        assert!(!a530.skip);
    }

    #[test]
    fn device_lookup() {
        let config: TracesConfig = toml::from_str(MINIMAL_TOML).expect("parsing TOML");
        let hl2 = &config.traces[0];

        assert!(hl2.device("freedreno-a306").is_some());
        assert!(hl2.device("freedreno-a530").is_some());
        assert!(hl2.device("unknown-device").is_none());
    }

    #[test]
    fn skip_detection() {
        let config: TracesConfig = toml::from_str(MINIMAL_TOML).expect("parsing TOML");
        let hl2 = &config.traces[0];

        let a306 = hl2.device("freedreno-a306").unwrap();
        assert!(a306.skip);

        let a530 = hl2.device("freedreno-a530").unwrap();
        assert!(!a530.skip);
    }

    #[test]
    fn trace_with_no_device_entry() {
        let config: TracesConfig = toml::from_str(MINIMAL_TOML).expect("parsing TOML");
        let portal2 = &config.traces[1];

        assert_eq!(portal2.path, "valve/portal-2-v2.trace");
        assert!(portal2.device("freedreno-a306").is_none());
        assert!(portal2.device("freedreno-a530").is_some());
    }

    const RESOURCES_TOML: &str = r#"
[traces_db]
download_url = ""

[devices.freedreno-a530]
vram = 4096
sysmem = 8192

[[traces]]
path = "big.trace"
vram = 2048
sysmem = 1024
devices = {
    freedreno-a530 = { checksum = "abc" },
}

[[traces]]
path = "small.trace"
devices = {
    freedreno-a530 = { checksum = "def" },
}
"#;

    #[test]
    fn parse_resources() {
        let config: TracesConfig = toml::from_str(RESOURCES_TOML).expect("parsing TOML");
        let dev = config.devices.get("freedreno-a530").unwrap();
        assert_eq!(dev.vram, 4096);
        assert_eq!(dev.sysmem, 8192);

        let big = &config.traces[0];
        assert_eq!(big.vram, 2048);
        assert_eq!(big.sysmem, 1024);

        let small = &config.traces[1];
        assert_eq!(small.vram, 0);
        assert_eq!(small.sysmem, 0);
    }

    #[test]
    fn validate_accepts_fitting_resources() {
        let config: TracesConfig = toml::from_str(RESOURCES_TOML).expect("parsing TOML");
        config.validate().expect("should validate");
    }

    #[test]
    fn validate_rejects_oversized_vram() {
        const TOML: &str = r#"
[traces_db]
download_url = ""

[devices.dev]
vram = 1024

[[traces]]
path = "too-big.trace"
vram = 4096
devices = { dev = { checksum = "x" } }
"#;
        let config: TracesConfig = toml::from_str(TOML).expect("parsing TOML");
        let err = config.validate().expect_err("should fail");
        let msg = format!("{err}");
        assert!(msg.contains("vram"), "expected vram error, got: {msg}");
        assert!(msg.contains("too-big.trace"), "got: {msg}");
    }

    #[test]
    fn validate_rejects_oversized_sysmem() {
        const TOML: &str = r#"
[traces_db]
download_url = ""

[devices.dev]
sysmem = 512

[[traces]]
path = "too-big.trace"
sysmem = 1024
devices = { dev = { checksum = "x" } }
"#;
        let config: TracesConfig = toml::from_str(TOML).expect("parsing TOML");
        let err = config.validate().expect_err("should fail");
        let msg = format!("{err}");
        assert!(msg.contains("sysmem"), "expected sysmem error, got: {msg}");
    }

    #[test]
    fn validate_rejects_resources_on_undeclared_device() {
        const TOML: &str = r#"
[traces_db]
download_url = ""

[[traces]]
path = "any.trace"
vram = 9999
devices = { unknown-dev = { checksum = "x" } }
"#;
        let config: TracesConfig = toml::from_str(TOML).expect("parsing TOML");
        let err = config.validate().expect_err("should fail");
        let msg = format!("{err}");
        assert!(msg.contains("vram"), "got: {msg}");
        assert!(msg.contains("unknown-dev"), "got: {msg}");
    }

    #[test]
    fn validate_accepts_skipped_oversize() {
        const TOML: &str = r#"
[traces_db]
download_url = ""

[devices.dev]
sysmem = 4096
vram = 2048

[[traces]]
path = "any.trace"
sysmem= 5000
vram = 3000
devices = { dev = { checksum = "x", skip = true } }
"#;
        let config: TracesConfig = toml::from_str(TOML).expect("parsing TOML");
        config
            .validate()
            .expect("skipped traces can exceed declared resources");
    }

    #[test]
    fn validate_accepts_zero_resources_on_undeclared_device() {
        const TOML: &str = r#"
[traces_db]
download_url = ""

[[traces]]
path = "any.trace"
devices = { unknown-dev = { checksum = "x" } }
"#;
        let config: TracesConfig = toml::from_str(TOML).expect("parsing TOML");
        config
            .validate()
            .expect("zero-resource traces don't need a declared device");
    }
}