Skip to content

Commit 7c4f019

Browse files
Merge pull request #2551 from shubhook/x11-shm-capture
perf(recording): capture X11 frames via MIT-SHM with GetImage fallback
2 parents 28d6e1f + bc86c02 commit 7c4f019

8 files changed

Lines changed: 1279 additions & 48 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ jobs:
281281
cargo test --locked -p cap-utils -p cap-cli-install --lib
282282
cargo test --locked -p cap --bin cap record::tests
283283
cargo test --locked -p cap-recording --lib -- --test-threads=1
284+
cargo test --locked -p cap-recording --test x11_shm_capture -- --test-threads=1
284285
cargo test --locked -p cap-enc-ffmpeg
285286
286287
build-gpui:

‎crates/recording/Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ scap-cpal = { path = "../scap-cpal" }
104104
[target.'cfg(target_os = "linux")'.dependencies]
105105
ashpd = { version = "0.11.0", default-features = false, features = ["tokio"] }
106106
pipewire = "0.10.0"
107-
x11rb = { version = "0.13.2", features = ["composite", "xfixes"] }
107+
x11rb = { version = "0.13.2", features = ["composite", "shm", "xfixes"] }
108108

109109
[dev-dependencies]
110110
tempfile = "3.20.0"
Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
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

Comments
 (0)