164 lines
7.5 KiB
Markdown
164 lines
7.5 KiB
Markdown
# 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`.
|