Files
startrail-org--pixelrag/docs/internal/screenshot-optimization-notes.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

12 KiB
Raw Permalink Blame History

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 dump
  • Bench class: 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_loop for 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:

  1. frameNavigated event not fired: Chrome sometimes silently drops Page.frameNavigated CDP event under high concurrency. Fix: use Page.frameStoppedLoading (always reliable).
  2. 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() in pixelrag_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* needs render group 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-gl flags (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. The pixelshot CLI, the pixelbrowse skill, and the pixelrag index pipeline (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 by bench/. 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 load event before measuring/capturing (readyState==='complete' shortcut + 12s cap). SSR pages fire load ~as fast as fonts.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_cdp is ~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.