Files
startrail-org--pixelrag/docs/screenshot-throughput-optimization.md
wehub-resource-sync 542cfa195c
CI / Frontend build (push) Failing after 9m6s
CI / Plugin validate (push) Failing after 9m27s
CI / Python lint (push) Failing after 16m1s
CI / Tests (push) Successful in 18m0s
Deploy / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:27 +08:00

7.5 KiB
Raw Permalink Blame History

Screenshot Throughput Optimization

Batch screenshot capture of Full English Wikipedia (18.7M articles, ~30M tiles) with headless Chrome on AMD EPYC 7763 (128 cores, 995GB RAM).

1. Results

Metric Value
E2E throughput 109 t/s (JPEG written to disk)
Capture throughput 98 t/s (raw BGRA to /dev/shm)
Tile size 875 × 8192 px
Output format JPEG q50, avg 305 KB/tile
Storage (30M tiles) 8.5 TB
Processing time (30M tiles) 77 hours single machine, < 1 day on 4 machines
Correctness 100% pixel-verified against ground truth

Best config: 48 Chrome workers, work-stealing queue, JPEG file output via rawFilePath with .jpg extension (Chrome encodes JPEG in ThreadPool, writes directly to disk — no base64, no websocket transfer, no external compression).

2. Architecture

kiwix-serve (ZIM)  →  48 Chrome workers  →  JPEG files on disk
    localhost:9461       875×8192 viewport     305 KB/tile avg
                         CDP websocket         109 tiles/s

Each Chrome process: navigate → fonts.ready + rAF → Page.captureScreenshot with rawFilePath=/path/tile.jpg → Chrome encodes JPEG in async ThreadPool → writes to disk → returns immediately → next article.

48 workers share the same user-data-dir (HTTP cache). Articles distributed via asyncio work-stealing queue. Page.frameStoppedLoading event ensures navigation is complete before capture.

3. Pipeline Bottleneck Analysis

The system is a two-stage pipeline analyzed via closed queueing model (Little's Law + USL contention curve).

Stage         Workers  Per-op    Capacity     Bottleneck?
────────────────────────────────────────────────────────
Nav           48       186ms     258 pg/s     No
Capture       48       321ms     150 t/s      ← YES
Compress      async    ~10ms     ∞            No (ThreadPool)
Disk write    async    ~1ms      ∞            No

Steady-state: C/T_c(C) = 48/321ms ≈ 150 t/s theoretical
Actual (200 articles): 95 t/s (pipeline startup/drain bubble)
Actual (500 articles): 109 t/s (less bubble)

Capture is the bottleneck. Nav latency does not affect steady-state throughput — verified by reducing nav from 186ms to 92ms with no throughput change.

Per-capture breakdown (48 concurrent, measured via Chromium instrumentation):

Component 1 worker 48 concurrent Notes
ForceRedraw IPC 95ms 181ms 8-hop async roundtrip
DrawRenderPass 57ms 62ms Composite 136 quads
CopyDrawnRenderPass 18ms 46ms memcpy 28MB
JPEG encode 0 ~10ms ThreadPool, async
Total 170ms 321ms

IPC dominates at 48c (56% of capture time). This is OS scheduling overhead: 48 Chrome processes × 5 threads = 240 threads on 128 cores.

Contention curve C/T_c(C) converges at ~125-130 t/s regardless of C:

Concurrent T_c C/T_c
24 200ms 120
32 260ms 123
48 321ms 150
64 500ms 128

4. Optimizations and Ablation

Each optimization measured on 200 articles, 100% correct, same hardware.

# Optimization Throughput Δ Key insight
0 Baseline (Playwright, sleep 30ms) 20 t/s Node.js IPC layer
1 Direct CDP websocket 23 t/s +14% Bypass Playwright
2 + fonts.ready + eager images 28 t/s +22% Event-driven, no polling
3 + rawFilePath (Chromium patch) 33 t/s +18% Bypass PNG/JPEG encode mutex
4 + Multi-worker (48w sequential) 79 t/s +140% Linear scaling to ~48w
5 + Phased strategy (semaphore) 96 t/s +22% Reduce capture contention
6 + Work-stealing queue 98 t/s +2% Better load balancing
7 + .jpg rawFilePath (JPEG in ThreadPool) 95 t/s 3% E2E with compression
8 + 500 articles (steady-state) 109 t/s +15% Amortize pipeline bubble

Cumulative: 20 → 109 t/s = 5.5× improvement.

Chromium patches (5 files, 285 lines)

Patch Impact Description
rawFilePath +18% Async raw BGRA write to /dev/shm via ThreadPool
.jpg auto-detect e2e JPEG JPEG encode in ThreadPool when path ends with .jpg
directClip per-tile parallel CopyFromSurface(src_rect) without emulation change
skipRedraw 5ms latency ForceRedrawWithCallback → CopyFromSurface

5. Approaches That Did Not Work

Approach Result Why it failed
--in-process-gpu 120 t/s, 90% correct Compositor surface race: about:blank captured instead of real page. ForceRedraw callback fires before compositor activates new frame.
--single-process 168 t/s, 74% correct Renderer thread contention across tabs in shared process
Two-tab pipelining 8 t/s Chrome UI thread serializes ForceRedraw across tabs in same process
directClip without ForceRedraw 93% correct Compositor frame stale without explicit redraw
Per-user-data-dir Chrome 8 t/s Each process starts with cold HTTP cache → thundering herd on kiwix
SwiftShader GPU compositor 17% CPU-based Vulkan slower than Chrome's software rasterizer
GPU on lab machines (H200/B200) Blocked /dev/dri permissions, no nvidia-container-toolkit
External ProcessPoolExecutor JPEG 40 t/s Cross-process IPC overhead; pool workers starved during capture
Firefox (Playwright) 2.6× slower Same IPC overhead, different engine
CEF OSR ~12 t/s Xvfb + 2.6s/page overhead
Servo N/A Not production-ready (stub package)
Skip fonts.ready +4% only Nav is not the bottleneck
4096px tiles Higher t/s but lower mpix/s Fixed ForceRedraw overhead per tile

--in-process-gpu deep dive

Eliminates GPU process IPC → per-capture drops from 321ms to 175ms → 120 t/s. But 5-10% of captures get about:blank content (correct dimensions, wrong pixels).

Root cause: ForceRedraw's presentation feedback fires after SubmitCompositorFrame but before viz activates the new surface. CopyFromSurface reads the old surface. About:blank renders at 875×8192 (same as real page due to persistent viewport emulation), making detection impossible from dimensions alone.

Tried: SwapPromise (fires earlier), RequestRepaintOnNewSurface (new LocalSurfaceId), bitmap dimension retry, pixel content check, Page.frameNavigated (reliable but times out at 48w igpu), Page.frameStoppedLoading (reliable but fires for sub-frames). None achieved 100% correct at 48 workers.

Reproducing

from pixelrag_render.strategies.cdp_phased import CDPPhasedStrategy
from pixelrag_render.bench import Bench

# Benchmark (with GT pixel verification)
bench = Bench(zim_path="...", chrome_path="...", output_dir="./results",
              kiwix_url="http://localhost:9461")
strategy = CDPPhasedStrategy(chrome_path="...", n_workers=48, capture_limit=48, fmt="raw")
result = await bench.run(strategy)  # {"tiles_per_s": 98, "correct_pct": 100, ...}

# Production (JPEG files on disk)
# Use rawFilePath with .jpg extension for Chrome-side JPEG encode:
await conn.cdp("Page.captureScreenshot", {
    "rawFilePath": "/output/tile.jpg",  # .jpg → JPEG encode in ThreadPool
    "fromSurface": True, "optimizeForSpeed": True,
    "clip": {"x": 0, "y": 0, "width": 875, "height": 8192, "scale": 1}
})

Requires custom Chromium build. Patch + build instructions: chromium/README.md.