项目文件夹

文件
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

240 行
12 KiB
Markdown

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# 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:
```python
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.