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

164 lines
7.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```python
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`.