|
| 1 | +//! Compares the X11 grabber's MIT-SHM (`ShmGetImage`) and core `GetImage` |
| 2 | +//! backends on root-window grabs. Each sample is a full `X11Grabber::grab`, |
| 3 | +//! including the row copy into a freshly allocated ffmpeg BGRZ frame. |
| 4 | +//! |
| 5 | +//! scripts/bench-x11.sh # runs under xvfb-run at 3840x2160x24 |
| 6 | +//! scripts/bench-x11.sh --json |
| 7 | +
|
| 8 | +#[cfg(not(target_os = "linux"))] |
| 9 | +fn main() { |
| 10 | + eprintln!("x11-capture-benchmark: skipped (not Linux)"); |
| 11 | +} |
| 12 | + |
| 13 | +#[cfg(target_os = "linux")] |
| 14 | +fn main() -> anyhow::Result<()> { |
| 15 | + linux::run() |
| 16 | +} |
| 17 | + |
| 18 | +#[cfg(target_os = "linux")] |
| 19 | +mod linux { |
| 20 | + use std::time::Instant; |
| 21 | + |
| 22 | + use anyhow::{Context, anyhow, bail}; |
| 23 | + use cap_recording::screen_capture::{X11Grabber, X11InputConfig}; |
| 24 | + use clap::Parser; |
| 25 | + use x11rb::connection::{Connection, RequestConnection}; |
| 26 | + use x11rb::protocol::shm::{self, ConnectionExt as _}; |
| 27 | + use x11rb::protocol::xproto::{ |
| 28 | + ChangeGCAux, ConnectionExt as _, CreateGCAux, Rectangle, Window, |
| 29 | + }; |
| 30 | + use x11rb::rust_connection::RustConnection; |
| 31 | + |
| 32 | + const RESOLUTIONS: [(u16, u16); 4] = [(1280, 720), (1920, 1080), (2560, 1440), (3840, 2160)]; |
| 33 | + |
| 34 | + #[derive(Parser)] |
| 35 | + #[command(about = "Benchmark X11 GetImage vs ShmGetImage capture")] |
| 36 | + struct Args { |
| 37 | + #[arg(long, default_value_t = 30)] |
| 38 | + warmup: usize, |
| 39 | + #[arg(long, default_value_t = 300)] |
| 40 | + iters: usize, |
| 41 | + #[arg(long)] |
| 42 | + json: bool, |
| 43 | + } |
| 44 | + |
| 45 | + #[derive(Clone, Copy, PartialEq)] |
| 46 | + enum Backend { |
| 47 | + GetImage, |
| 48 | + ShmGetImage, |
| 49 | + } |
| 50 | + |
| 51 | + impl Backend { |
| 52 | + fn name(self) -> &'static str { |
| 53 | + match self { |
| 54 | + Backend::GetImage => "GetImage", |
| 55 | + Backend::ShmGetImage => "ShmGetImage", |
| 56 | + } |
| 57 | + } |
| 58 | + } |
| 59 | + |
| 60 | + struct Stats { |
| 61 | + mean: f64, |
| 62 | + p50: f64, |
| 63 | + p95: f64, |
| 64 | + p99: f64, |
| 65 | + mpix_per_s: f64, |
| 66 | + } |
| 67 | + |
| 68 | + impl Stats { |
| 69 | + fn from_samples(mut samples_ms: Vec<f64>, pixels: f64) -> Self { |
| 70 | + samples_ms.sort_by(f64::total_cmp); |
| 71 | + let mean = samples_ms.iter().sum::<f64>() / samples_ms.len() as f64; |
| 72 | + let percentile = |p: f64| { |
| 73 | + let rank = ((p / 100.0) * samples_ms.len() as f64).ceil() as usize; |
| 74 | + samples_ms[rank.clamp(1, samples_ms.len()) - 1] |
| 75 | + }; |
| 76 | + Self { |
| 77 | + mean, |
| 78 | + p50: percentile(50.0), |
| 79 | + p95: percentile(95.0), |
| 80 | + p99: percentile(99.0), |
| 81 | + mpix_per_s: pixels / (mean / 1000.0) / 1e6, |
| 82 | + } |
| 83 | + } |
| 84 | + |
| 85 | + fn to_json(&self) -> serde_json::Value { |
| 86 | + serde_json::json!({ |
| 87 | + "mean_ms": self.mean, |
| 88 | + "p50_ms": self.p50, |
| 89 | + "p95_ms": self.p95, |
| 90 | + "p99_ms": self.p99, |
| 91 | + "mpix_per_s": self.mpix_per_s, |
| 92 | + }) |
| 93 | + } |
| 94 | + } |
| 95 | + |
| 96 | + struct SizeResult { |
| 97 | + width: u16, |
| 98 | + height: u16, |
| 99 | + get_image: Stats, |
| 100 | + shm: Stats, |
| 101 | + } |
| 102 | + |
| 103 | + impl SizeResult { |
| 104 | + fn speedup(&self) -> f64 { |
| 105 | + self.get_image.mean / self.shm.mean |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + fn grabber(width: u16, height: u16, backend: Backend) -> anyhow::Result<X11Grabber> { |
| 110 | + let grabber = X11Grabber::with_shm( |
| 111 | + &X11InputConfig { |
| 112 | + display_name: std::env::var("DISPLAY").context("DISPLAY is not set")?, |
| 113 | + window_id: None, |
| 114 | + x: 0, |
| 115 | + y: 0, |
| 116 | + width: u32::from(width), |
| 117 | + height: u32::from(height), |
| 118 | + fps: 30, |
| 119 | + show_cursor: false, |
| 120 | + }, |
| 121 | + backend == Backend::ShmGetImage, |
| 122 | + )?; |
| 123 | + if (backend == Backend::ShmGetImage) != grabber.uses_shm() { |
| 124 | + bail!( |
| 125 | + "{width}x{height}: X11Grabber did not select {}", |
| 126 | + backend.name() |
| 127 | + ); |
| 128 | + } |
| 129 | + Ok(grabber) |
| 130 | + } |
| 131 | + |
| 132 | + fn frame_rows(frame: &ffmpeg::frame::Video) -> Vec<u8> { |
| 133 | + let row_bytes = frame.width() as usize * 4; |
| 134 | + let stride = frame.stride(0); |
| 135 | + frame |
| 136 | + .data(0) |
| 137 | + .chunks(stride) |
| 138 | + .take(frame.height() as usize) |
| 139 | + .flat_map(|row| &row[..row_bytes]) |
| 140 | + .copied() |
| 141 | + .collect() |
| 142 | + } |
| 143 | + |
| 144 | + fn paint_pattern( |
| 145 | + conn: &RustConnection, |
| 146 | + root: Window, |
| 147 | + width: u16, |
| 148 | + height: u16, |
| 149 | + ) -> anyhow::Result<()> { |
| 150 | + const CELL: u16 = 96; |
| 151 | + let gc = conn.generate_id()?; |
| 152 | + conn.create_gc(gc, root, &CreateGCAux::new())?; |
| 153 | + for cy in 0..height.div_ceil(CELL) { |
| 154 | + for cx in 0..width.div_ceil(CELL) { |
| 155 | + let seed = u32::from(cx).wrapping_mul(0x9E37_79B9) |
| 156 | + ^ u32::from(cy).wrapping_mul(0x85EB_CA6B); |
| 157 | + conn.change_gc(gc, &ChangeGCAux::new().foreground(seed & 0x00FF_FFFF))?; |
| 158 | + conn.poly_fill_rectangle( |
| 159 | + root, |
| 160 | + gc, |
| 161 | + &[Rectangle { |
| 162 | + x: (cx * CELL) as i16, |
| 163 | + y: (cy * CELL) as i16, |
| 164 | + width: CELL, |
| 165 | + height: CELL, |
| 166 | + }], |
| 167 | + )?; |
| 168 | + } |
| 169 | + } |
| 170 | + conn.free_gc(gc)?; |
| 171 | + conn.get_input_focus()?.reply()?; |
| 172 | + Ok(()) |
| 173 | + } |
| 174 | + |
| 175 | + fn cpu_model() -> String { |
| 176 | + let from_cpuinfo = std::fs::read_to_string("/proc/cpuinfo") |
| 177 | + .ok() |
| 178 | + .and_then(|info| { |
| 179 | + info.lines() |
| 180 | + .find(|line| line.starts_with("model name")) |
| 181 | + .and_then(|line| line.split_once(':')) |
| 182 | + .map(|(_, value)| value.trim().to_string()) |
| 183 | + }); |
| 184 | + let from_lscpu = || { |
| 185 | + let output = std::process::Command::new("lscpu").output().ok()?; |
| 186 | + String::from_utf8_lossy(&output.stdout) |
| 187 | + .lines() |
| 188 | + .find(|line| line.trim_start().starts_with("Model name:")) |
| 189 | + .and_then(|line| line.split_once(':')) |
| 190 | + .map(|(_, value)| value.trim().to_string()) |
| 191 | + }; |
| 192 | + let model = from_cpuinfo |
| 193 | + .or_else(from_lscpu) |
| 194 | + .unwrap_or_else(|| "unknown".to_string()); |
| 195 | + format!( |
| 196 | + "{model} ({} logical CPUs, {})", |
| 197 | + std::thread::available_parallelism().map_or(0, |n| n.get()), |
| 198 | + std::env::consts::ARCH |
| 199 | + ) |
| 200 | + } |
| 201 | + |
| 202 | + fn time_backend(grabber: &mut X11Grabber, args: &Args) -> anyhow::Result<Vec<f64>> { |
| 203 | + for _ in 0..args.warmup { |
| 204 | + grabber.grab()?; |
| 205 | + } |
| 206 | + let mut samples = Vec::with_capacity(args.iters); |
| 207 | + for _ in 0..args.iters { |
| 208 | + let start = Instant::now(); |
| 209 | + let frame = grabber.grab()?; |
| 210 | + samples.push(start.elapsed().as_secs_f64() * 1000.0); |
| 211 | + drop(frame); |
| 212 | + } |
| 213 | + Ok(samples) |
| 214 | + } |
| 215 | + |
| 216 | + pub fn run() -> anyhow::Result<()> { |
| 217 | + let args = Args::parse(); |
| 218 | + if args.iters == 0 { |
| 219 | + bail!("--iters must be at least 1"); |
| 220 | + } |
| 221 | + ffmpeg::init().context("init ffmpeg")?; |
| 222 | + |
| 223 | + let (conn, screen_num) = x11rb::connect(None).context("connect to X server")?; |
| 224 | + let setup = conn.setup(); |
| 225 | + let screen = &setup.roots[screen_num]; |
| 226 | + let root = screen.root; |
| 227 | + let (screen_w, screen_h) = (screen.width_in_pixels, screen.height_in_pixels); |
| 228 | + |
| 229 | + let format = setup |
| 230 | + .pixmap_formats |
| 231 | + .iter() |
| 232 | + .find(|f| f.depth == screen.root_depth) |
| 233 | + .ok_or_else(|| anyhow!("no pixmap format for root depth {}", screen.root_depth))?; |
| 234 | + if format.bits_per_pixel != 32 { |
| 235 | + bail!( |
| 236 | + "root depth {} uses {} bpp; only 32 bpp is supported", |
| 237 | + screen.root_depth, |
| 238 | + format.bits_per_pixel |
| 239 | + ); |
| 240 | + } |
| 241 | + |
| 242 | + if conn |
| 243 | + .extension_information(shm::X11_EXTENSION_NAME)? |
| 244 | + .is_none() |
| 245 | + { |
| 246 | + bail!("X server does not advertise MIT-SHM"); |
| 247 | + } |
| 248 | + let shm_version = conn.shm_query_version()?.reply()?; |
| 249 | + |
| 250 | + let cpu = cpu_model(); |
| 251 | + let vendor = String::from_utf8_lossy(&setup.vendor).into_owned(); |
| 252 | + let server = format!("{vendor} {}", setup.release_number); |
| 253 | + let shm_version = format!( |
| 254 | + "{}.{}", |
| 255 | + shm_version.major_version, shm_version.minor_version |
| 256 | + ); |
| 257 | + |
| 258 | + eprintln!("CPU: {cpu}"); |
| 259 | + eprintln!( |
| 260 | + "X server: {server}, screen {screen_w}x{screen_h} depth {}, MIT-SHM {shm_version}", |
| 261 | + screen.root_depth |
| 262 | + ); |
| 263 | + |
| 264 | + paint_pattern(&conn, root, screen_w, screen_h)?; |
| 265 | + |
| 266 | + let mut results = Vec::new(); |
| 267 | + let mut skipped = Vec::new(); |
| 268 | + for (width, height) in RESOLUTIONS { |
| 269 | + if width > screen_w || height > screen_h { |
| 270 | + eprintln!("skipping {width}x{height}: larger than {screen_w}x{screen_h} screen"); |
| 271 | + skipped.push(format!("{width}x{height}")); |
| 272 | + continue; |
| 273 | + } |
| 274 | + |
| 275 | + let mut get_image_grabber = grabber(width, height, Backend::GetImage)?; |
| 276 | + let mut shm_grabber = grabber(width, height, Backend::ShmGetImage)?; |
| 277 | + |
| 278 | + let reference = frame_rows(&get_image_grabber.grab()?); |
| 279 | + if frame_rows(&shm_grabber.grab()?) != reference { |
| 280 | + bail!("{width}x{height}: ShmGetImage pixels differ from GetImage"); |
| 281 | + } |
| 282 | + |
| 283 | + eprintln!("benchmarking {width}x{height} ..."); |
| 284 | + let pixels = f64::from(width) * f64::from(height); |
| 285 | + let get_image = |
| 286 | + Stats::from_samples(time_backend(&mut get_image_grabber, &args)?, pixels); |
| 287 | + let shm = Stats::from_samples(time_backend(&mut shm_grabber, &args)?, pixels); |
| 288 | + if !shm_grabber.uses_shm() { |
| 289 | + bail!("{width}x{height}: X11Grabber fell back to GetImage mid-run"); |
| 290 | + } |
| 291 | + results.push(SizeResult { |
| 292 | + width, |
| 293 | + height, |
| 294 | + get_image, |
| 295 | + shm, |
| 296 | + }); |
| 297 | + } |
| 298 | + |
| 299 | + if args.json { |
| 300 | + let rows: Vec<_> = results |
| 301 | + .iter() |
| 302 | + .map(|r| { |
| 303 | + serde_json::json!({ |
| 304 | + "width": r.width, |
| 305 | + "height": r.height, |
| 306 | + Backend::GetImage.name(): r.get_image.to_json(), |
| 307 | + Backend::ShmGetImage.name(): r.shm.to_json(), |
| 308 | + "speedup": r.speedup(), |
| 309 | + }) |
| 310 | + }) |
| 311 | + .collect(); |
| 312 | + let report = serde_json::json!({ |
| 313 | + "cpu": cpu, |
| 314 | + "x_server": server, |
| 315 | + "screen": { "width": screen_w, "height": screen_h, "depth": screen.root_depth }, |
| 316 | + "mit_shm_version": shm_version, |
| 317 | + "pixel_format": "BGRZ", |
| 318 | + "warmup": args.warmup, |
| 319 | + "iters": args.iters, |
| 320 | + "skipped": skipped, |
| 321 | + "results": rows, |
| 322 | + }); |
| 323 | + println!("{}", serde_json::to_string_pretty(&report)?); |
| 324 | + return Ok(()); |
| 325 | + } |
| 326 | + |
| 327 | + println!("# X11 capture benchmark: GetImage vs ShmGetImage\n"); |
| 328 | + println!("- CPU: {cpu}"); |
| 329 | + println!( |
| 330 | + "- X server: {server} ({screen_w}x{screen_h}, depth {})", |
| 331 | + screen.root_depth |
| 332 | + ); |
| 333 | + println!("- MIT-SHM: {shm_version}"); |
| 334 | + println!( |
| 335 | + "- {} warmup + {} timed grabs per backend, each a full `X11Grabber::grab` including the copy into a new ffmpeg BGRZ frame\n", |
| 336 | + args.warmup, args.iters |
| 337 | + ); |
| 338 | + println!( |
| 339 | + "| Resolution | Backend | Mean (ms) | p50 (ms) | p95 (ms) | p99 (ms) | Mpix/s | Speedup |" |
| 340 | + ); |
| 341 | + println!("|---|---|---:|---:|---:|---:|---:|---:|"); |
| 342 | + for r in &results { |
| 343 | + for (backend, stats) in [ |
| 344 | + (Backend::GetImage, &r.get_image), |
| 345 | + (Backend::ShmGetImage, &r.shm), |
| 346 | + ] { |
| 347 | + let speedup = if backend == Backend::ShmGetImage { |
| 348 | + format!("{:.2}x", r.speedup()) |
| 349 | + } else { |
| 350 | + "1.00x".to_string() |
| 351 | + }; |
| 352 | + println!( |
| 353 | + "| {}x{} | {} | {:.3} | {:.3} | {:.3} | {:.3} | {:.1} | {} |", |
| 354 | + r.width, |
| 355 | + r.height, |
| 356 | + backend.name(), |
| 357 | + stats.mean, |
| 358 | + stats.p50, |
| 359 | + stats.p95, |
| 360 | + stats.p99, |
| 361 | + stats.mpix_per_s, |
| 362 | + speedup |
| 363 | + ); |
| 364 | + } |
| 365 | + } |
| 366 | + Ok(()) |
| 367 | + } |
| 368 | +} |
0 commit comments