Skip to content

perf(recording): add aarch64 NEON paths for screenshot pixel packers - #3102

Merged
richiemcilroy merged 1 commit into
CapSoftware:mainfrom
shubhook:perf/screenshot-neon-packers
Oct 24, 2026
Merged

richiemcilroy merged 1 commit into
CapSoftware:mainfrom
shubhook:perf/screenshot-neon-packers

Conversation

@shubhook

@shubhook shubhook commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

On Apple Silicon, screenshot pixel packing is 5.3x to 6.6x faster, and a real display capture is 2.5 to 4 ms quicker. Measured on an M2.

#2808 added an SSSE3 path for rgb_from_rgba on x86_64 and noted that Apple Silicon / aarch64 was not measured. On aarch64 both screenshot packers still ran per-pixel scalar loops on a zero-filled buffer, and release builds use opt-level = "s", so the compiler doesn't vectorize them. This PR adds NEON paths for both packers and skips the zero-fill on aarch64. x86_64 and other targets are unchanged.

How it works

  • rgb_from_rgba: reorder_rgb_row_neon loads 16 pixels with vld4q_u8, reorders channels, and stores with vst3q_u8. Leftover pixels go through the scalar code. It handles both channel orders, so Windows on ARM gets it too.
  • rgba_from_raw: reorder_rgba_row_neon does the same with vld4q_u8 and vst4q_u8, swapping R and B for BGRA input.
  • No zero-fill: pack_rows_uninit writes rows into the spare capacity of Vec::with_capacity as MaybeUninit<u8> and calls set_len only after every row is written. Unwritten memory is never exposed as &mut [u8].
  • Strides: each row is read from y * bytes_per_row, so padded rows work.
  • Safety: each kernel asserts its row length is a whole number of pixels, slicing panics on a short source row, and each of the 5 unsafe blocks has a SAFETY comment.
  • Other targets: the SSSE3 and scalar code moved, unchanged, into a #[cfg(not(target_arch = "aarch64"))] block. The scalar helpers are compiled off aarch64 or in tests only, which avoids dead_code on aarch64 release builds.

Changes

  • crates/recording/src/screenshot.rs: NEON kernels, pack_rows_uninit, cfg split, and tests.
  • crates/recording/examples/screenshot-pack-benchmark.rs (new): std-only benchmark of the old aarch64 path against the new NEON kernels in one binary.

Benchmark

Apple M2, 8 GB, macOS 27.0.1, rustc 1.88.0 with -C opt-level=s -C lto=thin -C codegen-units=1. BGRA input with bytes_per_row = width * 4 + 64. Two warmup calls, then the median of 9 single-frame calls per variant. Each call allocates its output, as the real functions do. Outputs are checked byte for byte before timing, and the benchmark exits 1 on any mismatch. Times are ms per frame.

Frame BGRA to RGB before NEON, zeroed NEON, no zero-fill Speedup BGRA to RGBA before NEON, zeroed NEON, no zero-fill Speedup
1920x1080 1.093 0.235 0.170 6.45x 1.386 0.268 0.212 6.55x
2880x1800 2.568 0.621 0.471 5.46x 3.369 0.729 0.537 6.27x
5120x2880 7.299 1.804 1.383 5.28x 9.538 2.121 1.536 6.21x

Three more runs gave 4.58x to 6.39x for RGB and 6.07x to 6.64x for RGBA. The "zeroed" column separates the two changes. Most of the gain comes from NEON, and skipping the zero-fill saves about another 25%.

Display capture

A small binary repeating the display branch of try_fast_capture (CGDisplayCreateImage, CGImage::data(), rgb_from_rgba, timed at the same points), linked against the packer code from main and from this branch. The main display was 2940x1912 with 11776 bytes per row. Medians of 25 captures, three runs:

Total capture (ms) Packing only (ms)
Before 8.9 / 9.3 / 10.7 5.6 / 5.8 / 6.3
After 6.2 / 6.5 / 6.7 2.3 / 2.3 / 2.5

Packing gets about 2.4x faster in a real capture, less than in the synthetic benchmark, probably because packing pays for the first read of the CGImage's memory.

How to test

  1. Benchmark: cargo run --release --example screenshot-pack-benchmark -p cap-recording, or build the file with plain rustc and the flags above, since it only uses std.
  2. Real capture: take display and window screenshots with RUST_LOG=cap_recording=debug and compare the Fast capture completed in line against main.

Testing

  • cargo fmt --all --check
  • screenshot_pixel_tests: 9 passed on aarch64 (6 existing, 3 new) and under Rosetta on x86_64, including rgb_ssse3_matches_original_including_scalar_tail.
    • New tests: rgb_neon_matches_scalar_including_tail, rgba_dispatch_matches_original_for_both_channel_orders, rgba_packing_rejects_undersized_input.
    • rgb_cases gained widths 32, 33, 47, 48, 49 and 2879. Coverage includes widths 0 to 9, widths not divisible by 16, row padding of 1, 7 and 32 bytes, 1x1, 1x1080, empty input, and both channel orders.
  • Mutation check: breaking the NEON RGB channel swap or the RGBA alpha lane fails the tests.
  • Clippy with -D warnings and the workspace lints passes for aarch64 and x86_64 on macOS, Windows and Linux.

Not measured

  • The Fast capture completed in log line from a real Cap build, and window capture (the RGBA path) end to end.
  • Captures with screen-recording permission granted. The display numbers above were taken without it, so the image likely held only the wallpaper and menu bar. Packing time doesn't depend on pixel values, but CGDisplayCreateImage may take longer with full content.
  • Windows on ARM compiles the NEON paths but wasn't run.

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; no actionable issues were found.

Summary

Adds aarch64 NEON paths for screenshot RGB and RGBA packing.

  • Aarch64 screenshot capture packs RGB and RGBA pixels faster.

Static review found no actionable issues. No builds, tests, or benchmarks were run during this review.

Reviews (1) · Last reviewed commit: "perf(recording): add aarch64 NEON paths ..." · Reviewed by Greptile

On aarch64, rgb_from_rgba and rgba_from_raw now pack 16 pixels per step
with vld4q_u8 and vst3q_u8/vst4q_u8, with a scalar tail, and write into
uninitialized capacity instead of a zero-filled buffer. x86_64 and other
targets keep the existing SSSE3 and scalar code.

Adds a std-only screenshot-pack-benchmark example comparing the old
aarch64 path with the new kernels.
@vercel

vercel Bot commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

@shubhook is attempting to deploy a commit to the Cap Software Inc Team on Vercel.

A member of the Team first needs to authorize it.

@shubhook

Copy link
Copy Markdown
Contributor Author

Note: the tests and clippy in the description ran on a standalone crate, not on cap-recording itself.

I copied the packer code and the screenshot_pixel_tests module into it because cap-recording doesn't build on my Mac. The cidre build script needs Xcode.app, and I only have the Command Line Tools.

CI will check the full crate once the workflows are approved.

@richiemcilroy
richiemcilroy merged commit f510acf into CapSoftware:main Oct 24, 2026
47 of 49 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants