12 KiB
Screenshot Throughput Optimization — Working Progress
Target: 150 t/s @ 100% correct (8192px tiles, maxi Wikipedia)
Current Best
| Config | t/s | Correct | Notes |
|---|---|---|---|
| multi-process 48w (frameStoppedLoading) | 91 | 100% ✓ | Stable, production-ready |
| multi-process 48w (frameNavigated) | 98 | 100% ✓ | Stable (igpu incompatible) |
| multi-process 48w (2000 art) | 113 | 99.8% ✓ | Steady-state |
| igpu 48w + frameStoppedLoading | 117-132 | 90-97% | Fast but 3-10% about:blank |
| igpu 48w + directClip | 128-148 | 48-90% | Fastest, worst correctness |
Production System Comparison
The wiki-screenshot production system (~/pixelrag-src/wiki-screenshot/) uses:
wait_fonts = False # for kiwix/ZIM datasource
wait_images = False # for kiwix/ZIM datasource
pre_screenshot_delay = 0.5 # fixed 500ms sleep, no fonts.ready
- Playwright-based (not CDP websocket)
- GPU-accelerated (8× L40S per machine)
- Multi-machine: 4 machines × ~70-80 t/s = ~290 t/s total
- Full Wikipedia (8.28M articles) processed in ~1 day
Our optimizations added fonts.ready + eager images + double-rAF for pixel-perfect
correctness. Production skips these waits entirely (pre_screenshot_delay=0 in
coordinator). This is safe for Kiwix because all assets (including fonts) are served
from localhost — they load before wait_until="load" fires.
Gemini Vision validation of 5000 production tiles:
- 0% BROKEN_RENDER, 0% ERROR_PAGE (rendering is correct without font wait)
- 12% BLANK/PARTIAL_BLANK (tile loop overshoots page height — separate bug)
Benchmark result: Removing font/image wait gives only +4% throughput (99 vs 96 t/s) because nav is not the bottleneck — capture IPC is. The 290 t/s production rate comes from 4 machines × GPU acceleration, not from skipping font waits.
Pipeline Bottleneck Analysis
Stage Capacity Bottleneck?
Nav 430 pg/s No (3.4x headroom)
Capture 125 t/s YES (C/T_c = 48/321ms)
Steady-state theoretical: 125-150 t/s
Actual (200 art): 98 t/s (75% utilization, 25% = nav serial)
Actual (2000 art): 113 t/s (85% utilization)
Per-capture breakdown at 48 concurrent:
- IPC roundtrip: 181ms (ForceRedraw browser→renderer→compositor, 8 async hops)
- DrawRenderPass: 62ms (composite 136 quads)
- CopyDrawnRenderPass: 46ms (memcpy 28MB)
Throughput = C / T_c(C) converges at ~125-130 t/s (USL contention curve).
Nav latency (186ms) does not affect steady-state throughput (Little's Law).
Minimum workers to saturate capture: C × (1 + T_nav/T_cap) = 72.
Chromium Patches (in custom build)
| Patch | File | Impact |
|---|---|---|
| rawFilePath | page_handler.cc + Page.pdl | Async write raw BGRA to /dev/shm (ThreadPool) |
| directClip | page_handler.cc + Page.pdl | CopyFromSurface(src_rect) without emulation change |
| skipRedraw | page_handler.cc + Page.pdl | ForceRedrawWithCallback → CopyFromSurface |
| ForceRedrawWithCallback | render_widget_host_impl.cc | Lightweight ForceRedraw with commit callback |
| directClip ForceRedraw fix | page_handler.cc | directClip also does ForceRedraw before copy |
Strategy Architecture
Strategies separated from bench framework:
pixelrag_render.strategies/— capture strategies (CDPPhased, CDPSequential, etc.)pixelrag_render.bench/— measurement harness with GT validation + experiment dumpBenchclass:bench.run(strategy)→ GT cache + capture + verify + JSON dump
CDPPhasedStrategy (best strategy)
- Work-stealing queue (asyncio.Queue, not round-robin)
- Semaphore-limited concurrent captures
wait_for_event("Page.frameStoppedLoading")filtered by main frameId- Per-tile semaphore release (fine-grained pipelining)
- Configurable: tile_height, nav_timeout, use_direct_clip, extra_chrome_args
WebsocketConnection
- Background
_recv_loopfor multiplexed CDP wait_for_event(method, timeout, filter_fn)for async event listening- Supports concurrent
cdp()calls via pending futures dict
What Was Tried
Worked
- ✅ rawFilePath: async write bypasses PNG encoding (+15%)
- ✅ directClip: parallel tile capture within viewport
- ✅ Phased strategy: semaphore-limited captures reduce contention (+15%)
- ✅ Work-stealing queue: better load balancing
- ✅ frameNavigated/frameStoppedLoading wait: fixes igpu about:blank race
- ✅ Presentation feedback ForceRedraw: 100% correct (but slower)
Partially Worked
- ⚠️ --in-process-gpu: 120+ t/s but 5-10% about:blank captures
- ⚠️ SwapPromise ForceRedraw: shot_p50 325→303ms (7% gain)
- ⚠️ directClip for all tiles: fast but correctness depends on ForceRedraw
Did Not Work
- ❌ --single-process: 168 t/s but 74% correct
- ❌ peekPixels (SkiaRenderer): headless uses SoftwareRenderer
- ❌ Immediate BeginFrame feedback flush: breaks frame pipeline
- ❌ CDPScreenshotNewSurface: RequestRepaintOnNewSurface overhead
- ❌ 2-tab pipelining: Chrome UI thread serializes ForceRedraw
- ❌ Chrome flags (disable-lcd-text etc.): ±2%
- ❌ headless_shell: slower than chrome (no shared HTTP cache)
- ❌ One-shot strategy: launch overhead 1-2s/process
- ❌ Firefox Playwright: 2.6x slower than Chrome
- ❌ Servo (servoshell 0.1.0): stub package, not ready
- ❌ CEF (cefpython3): abandoned, no modern Python wheel
- ❌ WebKitGTK snapshot: needs GPU/display access
- ❌ RequestRepaintOnNewSurface in skipRedraw: didn't fix igpu race
- ❌ Bitmap dimension retry: about:blank renders at full viewport size
- ❌ Pixel content retry: can't distinguish white page from about:blank
igpu About:blank Root Cause
Chrome --in-process-gpu has two bugs at 48 concurrent workers:
- frameNavigated event not fired: Chrome sometimes silently drops
Page.frameNavigatedCDP event under high concurrency. Fix: usePage.frameStoppedLoading(always reliable). - Compositor surface race: ForceRedraw's presentation feedback arrives before the new page's CompositorFrame is activated in viz. CopyFromSurface reads the old surface (about:blank at 875×8192, indistinguishable from real page by dimensions). No reliable Python-side detection possible.
Key Analysis Methods Used
- Pipeline bottleneck analysis (closed queueing model)
- Little's Law: steady-state throughput = C/T_c when capture-bound
- USL contention curve: C/T_c(C) convergence at ~125-130 t/s
- USE method: Utilization (79%), Saturation (semaphore queue), Errors (0)
- Per-capture breakdown: DrawRenderPass (57ms) + CopyDrawnRenderPass (18ms)
- IPC overhead (95ms) measured via Chromium instrumentation
Scale Estimate
30M tiles (18.7M articles × ~1.6 tiles/article):
- Single machine 98 t/s: 30M/98 = 85 hours = 3.5 days
- Single machine 120 t/s (igpu, 95% correct): 30M/120 = 69 hours = 2.9 days
- 4 machines × 98 t/s = 392 t/s: 30M/392 = 21 hours = < 1 day
- Production system (290 t/s, 4 machines): ~1 day (matches historical data)
Production Pipeline: fast_cdp backend
Chrome 48w (capture) → /dev/shm (raw BGRA) → ProcessPool 4w (JPEG) → disk
98 t/s 28MB/tile ~100 t/s 100KB/tile
Architecture:
render_articles()inpixelrag_render.backends.fast_cdp- Capture: CDPPhasedStrategy logic (work-stealing, semaphore, frameStoppedLoading)
- Compression:
concurrent.futures.ProcessPoolExecutor(4)— GIL-free, separate cores - Raw files in /dev/shm/pixelrag_render/ — auto-deleted after compression
- Output: JPEG tiles + tiles.json manifest per article
Key: compression never blocks capture. Chrome writes raw → returns immediately. Compression reads raw file asynchronously on different CPU cores.
128-core machine: 48 cores for Chrome, 4 cores for JPEG, 76 cores idle. JPEG compression of 875×8192 takes ~10-20ms → 4 cores handle 200-400 t/s → plenty of headroom over 98 t/s capture rate.
Storage: 30M tiles × 100KB JPEG = ~3 TB
GPU Acceleration (Brewster H200 findings)
Lab machines have 8× H200/B200 GPUs but:
/dev/dri/renderD*needsrendergroup membership (no sudo)- Docker daemon not running; rootless docker lacks nvidia-container-toolkit
- SwiftShader (CPU Vulkan) doesn't improve throughput vs software rendering
- headless Chrome ignores
--use-glflags (GPU process crashes on init) - When GPU DOES init (via Xvfb + ANGLE), missing NVIDIA userspace drivers in container
To unlock GPU: sudo usermod -aG render $USER on lab machine.
Expected impact: 4x faster DrawRenderPass based on production system data.
Backend reconciliation & SPA-render fix (2026-06-11)
The three render code paths (who actually runs what)
backends/websocket.py— the shipped general-purpose renderer. ThepixelshotCLI, thepixelbrowseskill, and thepixelrag indexpipeline (render_urls,backend="cdp"/"websocket") all go through it. Simple: per-worker queue, inline JPEG over CDP, no extra deps.backends/fast_cdp.py— high-throughput batch path (render_articles): phased-logic capture + rawFilePath to /dev/shm + ProcessPool JPEG. No in-repo caller — invoked only by an out-of-repo ops script. The 8.28M flagship Wikipedia index was built by a separate system (Playwright/GPU/4-machine, see "Production System Comparison"), not by either of these.strategies/*— the benchmarking menu; used only bybench/. Kept as research scaffolding.
Regression fixed: websocket backend rendered SPAs / tall pages wrong
backends/websocket.py had drifted from the established capture pattern — it had no
nav-completion wait (fired document.fonts.ready immediately after Page.navigate)
and no per-tile scroll, both of which fast_cdp and the production strategies have.
Consequences:
- JS/SPA pages were measured/captured mid-hydration at a transient (often much taller) layout → tiled into mostly-empty space = blank tiles (this is the "tile loop overshoots page height" blank bug noted under "Production System Comparison", here root-caused).
- At small
tile_height(the skill uses 1568) every tile past the first was blank, because content below the short device viewport is never rasterized without scrolling.
Fix (verified in bench/ against ground truth at 100% on the smoke set):
- Wait for the
loadevent before measuring/capturing (readyState==='complete'shortcut + 12s cap). SSR pages fireload~as fast asfonts.ready, so ~0 cost (measured: Wikipedia render time unchanged). - Scroll each tile into view before capture (mirrors
fast_cdp). - Optional
--wait-network-idle(JS PerformanceObserver) for pages that fetch content after load; off by default (costs a quiet window/page), on by default in the skill.
Raw vs inline-JPEG is the dominant throughput lever (measured, 48w, N=600, this box)
| config | correct | t/s | note |
|---|---|---|---|
| phased raw (fast_cdp config) | 99.7% | 306 | capture-only in bench; JPEG is decoupled/parallel |
| phased jpeg (inline) | 98.2% | 182 | Chrome encodes JPEG on the capture critical path |
| sequential raw | 99.7% | 221 | |
| sequential jpeg (inline) | 98.2% | 142 |
Takeaways: (1) inline JPEG encoding is the bottleneck — bypassing it with rawFilePath
- parallel compression is ~+56-68%. (2) phased's semaphore/work-stealing buys ~+38% over
sequential in raw mode (in jpeg mode the encoding bottleneck masks it to ~+8% — an
earlier jpeg-only comparison was misleading). So
fast_cdpis ~2x the simple inline path at batch scale and is kept. Absolute t/s here is optimistic (capture-only, short window, 128-core box) vs the ~91-113 production figure; the ratios are the point.
Design direction
Ship one simple backend (websocket.py, inline JPEG) for the CLI/skill/pixelrag index
— that scale doesn't need the raw+decoupled machinery, and the flagship index uses the
separate system anyway. Keep fast_cdp + strategies/ as batch/research code. The shared
capture-readiness logic (load wait, scroll) should eventually live in one place so the
shipped backend can't silently drift from the correct pattern again.