Tier 88 (context/plans/build-site.md): the registry had no unblocked task left; what remained to an agent were criteria no task covered any more. T-518. Criteria met by earlier tasks ticked with their evidence: gpu R11's review (T-513 and Codex's 2026-10-24 pass), content-blocking R7 (the bundle's sha check against NOTICE and the lists' README, the README paragraph with cardview.jpg, round 26's licence reading), modern-web R1 (T-484 and run 2c; the README's Caveats link) and T-500's two. T-501's stays [~] for MSE (below). The template placeholders never filled (T500_KIT, T501_KIT, T501_DEVICE_RESULT in docs/MODERN-WEB.md, DEVICE_STATE_EVENING and TRACE_PICKUP in PICKUP) are filled; .cavekit/config is the owner's one line again; the README's "Where it stands" regenerated. T-519. On the build with the review fixes (engine 0058), the 10 s scripted pan on six pages under JIHAD_GPU_VERIFY=1 (docs/rounds/t519-2026-10-24/): 0 rows structurally wrong on all; 0 rows differ at all on the long page, jihad-fixed-both.html (new: an opaque header AND footer, nothing else), sticky-mix, scroll-js and Hacker News; Wikipedia 3 of 70 frames with resampling-class rows. The one miss is the verify mode's own: on jihad-anim-css.html the animated block's 160 rows differ (the reference samples the animation at its own compose time), every other row equal — R2's first criterion [~] with the engine fix named. The dumps looked at frame by frame; Hacker News at 0.955 shows its right side and bottom. R4's four ticked on the compositor's lines: the fixed-both page reads two bands with both kept, the long page one kept, Wikipedia's see-through panels as 2 columns in 3-5 chunks; sticky-mix takes the whole path as in T-503's rounds (its sticky layer changes every step). R3's table: the GPU path against NOGPU=1 on every page, in docs/RENDERING-OPTIONS.md — the GPU path ahead on every page (long static 30-33 against 11-12.5 fps, Wikipedia 18.5-26 against 2.7-2.9, Hacker News 37-48 against 6-10). The frame rates were measured again without verify after a first sweep read 5-6 fps under it (a whole reference per job): tools/device/t502/README.md says so; pan-run.sh takes ROUND_DIR. T-520. The blocker's device criteria on the Enyo card through the inject channel, the listed hosts mapped to the host PC in the device's /etc/hosts for the run (only the blocker can then stop a load): a listed host opens top-level; EasyPrivacy's ||123count.com^$third-party loads first-party (the page on that host) and is refused third-party; an @@||host^ exception (a list copy with one added, exceptions 6+1083 against 5+1079) loads the host; the master switch and hiding flip live through setpref (the observer re-applies the sheet); the settings line shows the versions and a counter that rose 10 -> 16 on a page with 6 refusals; "Update the lists now" moved the lists three times and the line followed; shouldLoad on 200 refused images is within 10 % of blocking off (6.5 against 6.0 s, the floor the engine's own per-image work); MSE reports H.264 and AAC (T-501's criterion closed). R4's cost criterion was NOT met: hiding on cost the MDN page's copy 3.7-7.5 s against 2.6-3.0 off, the real cached page 15 against 11, and a daemon's first load 8-12 s. T-522. Four variants of the cached sheet on the device named the cost: the 268 @-moz-document regexp groups (one per site-excepted generic selector, each carrying the 160 generic-hide hosts' 2.4 KB regexp again), evaluated per document; the same 13,079 keyed selectors under url-prefix scopes or bare cost nothing. The compiler (version 11) keeps those selectors apart (excSel) and the component adds them to the per-document sheet for every host the selector is not excepted on, with the generic-hide sites (hosts and address prefixes) honoured there too; the global sheet keeps its one regexp group. Measured: the MDN copy with hiding on 2868, 3104, 2876 ms against 2712, 2578, 2846 off (+8.7 %, within R4's 15 %), a daemon's first load 3.7 s where it took 8-12; generic hiding still applies under a host name, a site-excepted selector is hidden where not excepted, and a $generichide host (jetzt.de mapped to the host PC) gets neither; the compile reports 268 excepted selectors per document and 154 generic-hide sites with 4 address prefixes. New test pages: jihad-fixed-both.html (an opaque header and footer), jihad-tp-first/third.html, jihad-exception.html, jihad-hiding.html, jihad-excsel.html, jihad-mse-probe.html, jihad-plain-ok.png. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
35 KiB
Rendering options for Jihad — what the TouchPad measured (2026-10-17)
The report the owner reads is the Claude doc "Jihad rendering options: speed comparison"; this page keeps the measurements in the repository with the tools that took them. Every number is from the TouchPad (webOS CE 3.1.0 — the bench since the CE loop of 2026-10-01, 3.0.5 before — APQ8060, Adreno 220).
The software pipeline as it runs (the daemons' logs, 1958 paints since a boot)
| Stage | Cost per 1024×768 frame |
|---|---|
| Paint the document into the headless widget's buffer (cairo/skia on the CPU) | 62–73 ms per megapixel: screen-sized band median 53 ms (p90 182), full-page band median 120 ms (p90 274), worst 2.4–4.4 s |
| Copy into the YAP shared memory (3 MB) | 4–5 ms |
| The card's blit with the nearest-neighbour fit scale | 8 ms scaled, 2–4 ms at 1:1 |
| LunaSysMgr's composite (GPU) | ~4 ms |
Frame gaps while painting: 35% under 16 ms, 1% 16–23, 6% 24–31, 16% 32–39, 17% 40–55, 24% over 56.
The caps in BrowserPageGoanna.cpp: one dirty repaint per 150 ms, one pan repaint per 250 ms.
The GPU (tools/device/gpu-bench.c, vendor EGL 1.4 / OpenGL ES 2.0, two runs)
| Stage | run 1 | run 2 |
|---|---|---|
| EGL + pbuffer + shader setup | 135 ms | 82 ms |
| Upload a 1024×768 RGBA texture (3 MB) | 12.1 ms | 8.8 ms |
| Composite one opaque full-screen layer | 3.8 ms | 3.7 ms |
| Composite four layers, three blended | 13.5 ms | 12.9 ms |
Twelve 256×256 glTexSubImage2D updates (3 MB) + composite |
169 ms | 158 ms |
glReadPixels 1024×768 RGBA |
14.4 ms | 11.3 ms |
glReadPixels 640×480 / 320×240 (a Flash movie's rectangle; a third run, 2026-10-17) |
4.9 / 1.9 ms | 6.3 / 2.3 ms |
| Upload a 640×480 / 320×240 RGBA texture (a movie's own frame; the same third run) | 1.85 / 0.59 ms | 1.82 / 0.53 ms |
| Whole readback frame (12 tile updates + 2 layers + read) | 45 ms | 64 ms |
CPU memcpy 3 MB |
5.0 ms | 4.2 ms |
| CPU blend of one 1024×768 ARGB layer (plain C) | 22.5 ms | 19.7 ms |
| CPU nearest-neighbour scale 1000→768 wide | 8.2 ms | 8.1 ms |
GL_EXT_texture_format_BGRA8888 present, GL_EXT_read_format_bgra absent. The one pathological
path is the sub-image tile update; whole-texture uploads run at 250–340 MB/s.
What the other UXP browsers do (the platform source, third_party/uxp)
gfxPlatform::AccelerateLayersByDefault() (gfx/thebes/gfxPlatform.cpp:2126) is true on Windows,
macOS and GTK: Pale Moon and Basilisk composite on the GPU by default (Direct3D 11 / OpenGL / OpenGL
over GLX), rasterize content with Direct2D (Windows), CoreGraphics (macOS) or cairo (Linux), draw
canvas with Direct2D, Skia-on-GL (gfx.canvas.azure.accelerated, macOS) or Skia on the CPU
(Linux), and have WebGL on through WGL/ANGLE, CGL or GLX. The headless toolkit Jihad builds gets
gfxPlatformHeadless (cairo, software layers); its GLContextProvider is GLContextProviderEGL
(gfx/gl/GLContextProvider.h: the default when neither MOZ_X11 nor MOZ_GL_PROVIDER is set),
loading libEGL.so / libGLESv2.so by name, but AccelerateLayersByDefault() is false without
MOZ_GL_PROVIDER and the daemon runs with JIHAD_DISABLE_OMTC=1, so no GL is ever created. The application pref
files on repo.palemoon.org sit behind Anubis and were not read.
The shortlist, fastest first (the report's chart)
Every combination that adds options without adding speed in any column is cut (rows with 3, rows with 1 on top of 4), and so are the options advised against: 2 without 4 (WebGL at 11–13 fps through a readback and a software blend, below a page's WebGL-off path) and 6 (a patched LunaSysMgr, so webOS CE 3.1.0 only; a frame path beside the YAP delivery this project keeps byte-identical; 5 weeks on top of 4's 6 with the daemon-to-LunaSysMgr buffer share unmeasured; animation only, 42 to 17 ms, new content stays at the 80 ms raster). Sorted by the columns left to right. The 44 rows behind the cut follow. Flash games: 27 fps is a 30 fps 320×240 movie and 22 fps a 640×480 one, Flash's own rate on this CPU (the plugin child; the daemon's paints keep up at 4–5 ms, 30–34 fps composite, 2026-10-07); no option touches it. On the 4 rows a whole-frame readback (12.3 ms) would cap 320×240 at 23 fps; the movie's rectangle read back (1.9–6.3 ms measured) keeps 27. The report colours the cells (green ≥ 24 fps, yellow 16–23, red ≤ 13; weeks 0–1 green, 2.5–3.5 yellow, 6–7.5 orange, 8.5+ red). Every row runs on webOS 3.0.5 and webOS CE 3.1.0: the one CE-only option, 6, is cut. Rows that scroll slower than 24 fps (1 + 5, 1, 5, today) are cut too, and then the rows without 2 (4, 4 + 5).
| # | Solution (option numbers) | Animation and in-band scroll | New content | WebGL games | Flash games | Video | Weeks | webOS | Like Pale Moon / Basilisk |
|---|---|---|---|---|---|---|---|---|---|
| 1 | 2 + 4 + 5 | 42 ms, 24 fps | 106 ms, 9 fps | 42 ms, 24 fps | 27 / 22 fps with the movie's rect read back; 23 / 22 whole-frame | decode only (overlay) | 10 | 3.0.5 and CE 3.1.0 | their compositor + a readback, their WebGL |
| 2 | 2 + 4 | 42 ms, 24 fps | 106 ms, 9 fps | 42 ms, 24 fps | 27 / 22 fps with the movie's rect read back; 23 / 22 whole-frame | decode + a 42 ms frame | 7.5 | 3.0.5 and CE 3.1.0 | their compositor + a readback, their WebGL |
What tells them apart
Both share option 4's frame and option 2's WebGL: 24 fps scroll, animation and WebGL, 9 fps new content, Flash at its own rate; they differ only in video and 2.5 weeks.
| Solution | Adds | Costs | Risk | Pick it when |
|---|---|---|---|---|
| 2 + 4 | the GPU compositor (CSS animation, fixed headers, in-band scroll at 24 fps; 13 today, capped to 4–6.7 by the repaint limits) and WebGL at the same 24 fps as one more layer (nothing today); video at 24 fps, a 42 ms frame per video frame | 7.5 weeks | high: CompositorOGL over GLContextProviderEGL has never run in the headless toolkit or on an Adreno 220, every frame is read back, and the Adreno 220's GLES 2.0 driver meets WebGL 1 (Atlas turned WebGL off after ANGLE whited out its compositor, a different stack) |
video is occasional |
| 2 + 4 + 5 | video at decode speed through the MDP overlay (a 30 fps clip at 30), the compositor and CPU freed of it | 2.5 weeks more (10) | the above, plus an overlay plane: no CSS transform, opacity or clip over the video, the card cuts a hole and moves the rectangle as the page scrolls; Atlas ships the sink (gst-mdpoverlay) |
YouTube and embedded clips matter |
The pick (2026-10-17) and the engine patches it touches
Picked: 2 + 4, a one-week spike first (the OpenGL compositor up in the headless daemon on the device, one real frame read back into the YAP buffer and measured), then 4, then 2. Video stays a compositor layer at 24 fps so styled video keeps working; 5 is held back as a full-screen-only overlay if 24 fps full-screen displeases by eye.
What 2 + 4 is in the engine: off-main-thread compositing on (gfxPlatform::UsesOffMainThreadCompositing),
the offscreen widget handing out a ClientLayerManager and creating an in-process compositor
(nsBaseWidget::CreateCompositor → GPUProcessManager::CreateTopLevelCompositor), CompositorOGL
on an EGL pbuffer context (MOZ_LAYERS_PREFER_OFFSCREEN → GLContextProvider::CreateOffscreen),
and the frame fetched with CompositorBridgeParent::ForceComposeToTarget into a DrawTarget over
the shared buffer (CompositorOGL::EndFrame → CopyToTarget = the measured glReadPixels). All
of that is upstream code; what it does to our 39 patches (docs/ENGINE-SOURCE.md):
| Patch | Under 2 + 4 | Why |
|---|---|---|
| 0003 gfx init and OMTC | half retired | the JIHAD_DISABLE_OMTC override in gfxPlatform::UsesOffMainThreadCompositing is dead, 4 is OMTC on; the gfxPlatformHeadless selection and gfxPlatform::GetPlatform() in XRE_InitEmbedding2 stay |
| 0005 PuppetWidget offscreen | paint half retired | GetLayerManager's BasicLayerManager branch and jihad_offscreen_render_region (RenderDocument into the buffer) give way to the ClientLayerManager and ForceComposeToTarget into the same buffer; create/resize/release, the dirty flag and rect, and the popup composite stay (popups are their own display roots on either path) |
| 0006 headless toolkit | changes | gfxPlatformHeadless stays but must accept accelerated layers: AccelerateLayersByDefault() is false without MOZ_GL_PROVIDER, so either --with-gl-provider=EGL at configure or layers.acceleration.force-enabled; its GfxInfo answers FEATURE_STATUS_UNKNOWN and blocks nothing |
| 0020 composite without ImageBridge | no-op | every hunk sits behind !ImageBridgeChild::GetSingleton(); with OMTC the bridge exists and upstream's TextureClient path carries the plugin's bitmap to the compositor |
| 0021 windowless invalidate offscreen | changes | its widget invalidate makes the daemon re-render the document; an ImageLayer update now reaches the compositor through the ImageBridge, so the invalidate means "a composite is due", not a repaint |
| 0022 RecvShow copy + frameID | stays | the copy guards the child's double buffer and the frameID is what the ImageContainer coalesces on, both still true through the bridge |
| 0035 BasicPlanarYCbCrImage::CopyData | no-op | BasicImages belong to the BasicLayerManager; the compositor takes PlanarYCbCr frames over the ImageBridge and converts them in its YCbCr shader |
| 0024 plugin input, widget part | stays | the dirty rect feeds the readback rectangle (the Flash column's 2–5 ms reads) |
| 0026, 0028, 0029, 0031 frame pacing and pull | stay | Flash's frames are the child's; the daemon's pull drives a composite instead of a paint |
| 0027 CPU boost | stays | the clock lever |
| the other 28 | untouched | toolchain, embedding, branding, NPAPI host/input/loader/logging/semaphore, NSS, audio, video controls, add-ons: nothing to do with how pixels reach the card |
Daemon side: JIHAD_DISABLE_OMTC=1 leaves the three packaging/event.d launchers. Option 2 retires
nothing: WebGL needs the EGL provider (present) and the feature not blocked (webgl.force-enabled
until GfxInfo reports the Adreno).
T-502, the spike: what the device measured (2026-10-17, 86 rounds)
The OpenGL compositor runs in the headless daemon on the Adreno 220 (an EGL pbuffer context,
CompositorOGL, patch 0040 behind JIHAD_GPU_COMPOSITOR), draws into a renderbuffer window
target, and the daemon reads the frame back into the adapter's shared buffer. The first 30
rounds measured black or wrong frames (an un-set render bound, a window target deleted per
frame, a y-flip from the intermediate-surface path, a view the widget's size instead of the
frame's); every number below is from a frame checked by eye (docs/rounds/t502-2026-10-17/,
the spike-run*-seq.jpg contact sheets and the card screenshots).
Page (render/goanna/test/) |
Compositor path | Software path (today) | Chart row |
|---|---|---|---|
jihad-anim-css.html: four layers, a CSS transform animation, a fixed header, a half-transparent overlay |
21–27 fps (runs 59–86) | 23–30 fps (the damage-box partial paints) | 24 fps |
jihad-scroll-js.html: 300 paragraphs scrolled 6 px every 16 ms by script |
12–17 fps | 5–9.5 fps | 24 fps |
jihad-colors.html: four blocks, static |
a frame per change | a frame per change | — |
Where a frame's time goes on the device (768×942, the clock floor held, io_fraction 100):
| Stage | Measured | Note |
|---|---|---|
| Paint the layers on the main thread | 0.1–3 ms (up to 100 when content changes) | presShell->Paint(PAINT_LAYERS) in JihadComposeStart |
| Compose, CPU side | 3–6 ms | the compositor thread issuing the draws |
| Compose, GPU side | 18–32 ms for four full-screen layers | the bench: one opaque layer 12 ms, four 32; a scissor to 160 rows saves nothing (29 ms), a 160-row viewport is worse (38): the GPU is memory-bound at ~500 MB/s and resolves the whole target |
| Read the frame back | 20–45 ms whole, 4–15 ms for 160 rows once the GPU is done | glReadPixels from the renderbuffer; RGBA only, BGRA reads refused |
| RGBA→BGRA and the row flip | 5–7 ms whole, ~1 ms for 160 rows | NEON, in place, on the compositor thread |
| A content upload | 8 ms for 768×942 BGRA with the GPU idle; 18–78 ms whatever the size while GPU work is pending | any glTexImage2D waits for the queued composite; a fresh name waits too. Corrected 2026-10-18 (T-504): the wait is the driver converting a texture on its first write after its first draw (~18 ms per 256x256, once per texture); engine patch 0044 converts new tile textures at 1x1 |
| The daemon's publish | 0–2 ms | the painted message, the slot bookkeeping |
| The adapter / LunaSysMgr | ~33 ms cadence, a 2.9 MB card upload a frame | one buffer returned per painted message; three slots |
What is in the engine and the daemon now (patches 0040 and 0041, render/goanna):
- The frame in two halves: the daemon starts a job (
jihad_offscreen_compose_start: the layer paint on the main thread, the compose and the read on the compositor thread), polls it on its 8 ms tick, publishes when it is in, and starts the next on the publish tick when the compositor asked again meanwhile (the request used to be consumed by the publish, which cost a frame). Up to two jobs in flight into any slot the adapter does not hold. - Band reads: the compositor reports the rows its layer-tree diff changed; the daemon keeps, per shared-buffer slot, the rows that changed since that slot was read and asks for only those (the animation page reads 160 rows a frame); a slot composed for other geometry is read whole. The composite itself is scissored to the rows read (kept: harmless, no GPU saving).
- The window's flipped view kept on the renderbuffer target (the intermediate-surface draw cancels a flip that must exist) and the rows reversed in the readback; render bounds of the frame's size; the compositor's request counter as the daemon's "frame due" signal.
- Uploads as BGRA on EGL (
GetPreferredARGB32Format, GLES-legal types in the upload helper andBasicTextureImage::Resize): four times faster than RGBA when the GPU is idle. - Tiled content layers by default (
layers.enable-tilesin the engine's all.js; the bundle's goanna.js is read after the once-only gfx prefs are snapshotted). - The docshell active from creation and inactive while the card is frozen (it was active already through the content activation on touch; kept for the frozen card).
- Opt-in, measured as no gain: a read thread with a shared context, EGL fences and two window
targets (
JIHAD_GPU_READ_THREAD=1: equal on the animation page, a third slower on the scroll page: every GL call on the compositor context waits behind the read anyway); a GPU swizzle pass (JIHAD_GPU_PASS=1); fresh texture names per upload (JIHAD_GPU_FRESH_TEX=1); the round-5 snapshot road (JIHAD_GPU_SNAPSHOT=1).
Verdict: the animation row holds (21–27 fps against the chart's 24, the hardware floor of a
four-layer composite plus a readback on this GPU), the scroll row does not yet (12–17). The
scroll page repaints and re-uploads content on every step because the daemon scrolls the
engine's main thread per step; the displayport only makes the paint bigger, not rarer. The
remedy is the one option 4 implies: apply the scroll offset in the compositor while the scroll
stays inside the painted displayport (AsyncCompositionManager's async transform for the root
scroll layer, with AlignFixedAndStickyLayers keeping the header still), and move the engine's
scroll only when it leaves it; a scroll frame is then a compose and a 942-row read (~45 ms, 22
fps) with no upload, and a band read where the compositor can tell what moved. After it: a
fourth shared buffer (the two-deep queue mostly finds the slot after the published one still at
the adapter), a damage rectangle in the frame header so the adapter invalidates and LunaSysMgr
uploads only the band, then option 2 (WebGL through EGL). The diagnostics in the engine
([jihad-gpu] lines, the first N of each) are still in and cheap; prune them with the fold.
2026-10-18, the compositor-side scroll (patch 0042, rounds 90-98) — the corrected account:
the daemon sends the card's scroll to the compositor (jihad_offscreen_set_scroll), which
applies the difference to the engine's own scroll as an async transform on the layer carrying
the root content metrics, with AlignFixedAndStickyLayers keeping fixed layers still (the APZ
path without APZ); the engine is scrolled only when the card's scroll nears the edge of the
painted displayport (every ~840 CSS px at the fit zoom). The daemon keeps each slot's frame,
shifts its rows by the card's scroll since (on the compositor thread, overlapping the
composite), and asks only for the rows that entered and the rows fixed layers cover; a
scroll-only frame (no composite request since the last one: no transaction, no animation
step) composes those bands into a 256-row renderbuffer target and reads it back once.
Rounds 90-93 reported 17-25 fps pans "with the content right" from screenshots. That was wrong:
a scripted pan moves only the daemon's idea of the scroll (the adapter's view stays at 0), so a
screenshot shows one frame at renderedY=0, and any single frame looks right. Frame dumps
mid-pan (JIHAD_GPU_DUMP_EVERY) and the daemon's verify mode (JIHAD_GPU_VERIFY=1: the same
frame composed whole at the job's scroll, row runs that differ logged) showed the content
jumping only at the engine's anchor moves, with the entering band repeated between them. Three
causes, found in that order:
SetDisplayPortMarginsForElementhad failed on every call since the first round (rv 0x80004005, logged and never read): no displayport, nothing painted past the viewport. Replaced byjihad_layout_set_displayport(nsLayoutUtils.cpp, from the docshell: base rect plus margins on the root scroll frame's content), retried by the daemon until the document takes it.- Without APZ,
nsDisplayList::PaintRootattaches root scroll metadata only whenAsyncPanZoomEnabled, so the compositor had no layer to move (the six early hits were about:blank, which has no root scroll frame). A root scroll frame with a displayport now counts like APZ there (patch 0042). ContainerLayer::DefaultComputeEffectiveTransformsgives a container an intermediate surface when its transform "has a non-integer translation" (true for any scale) and a child has a clip: under the world zoom the root rendered its children through a surface the size of its visible region, one viewport — an extra full-frame pass every composite, nothing of the displayport beyond the viewport (white below), and fixed-layer compensation in the surface's space (the header off the frame). On the compositor's tree an axis-aligned scale+translate now clips by scissor instead (JIHAD_GPU_SURFACES=1restores the surface).
Measured after 1 and 2, before 3: 13-18 fps with the content moving and the header missing,
composes of 17-29 ms (the surface pass). The numbers after 3 are in docs/PICKUP.md.
Rounds 99-112 (2026-10-18): the driver's surface rule, then 25-31 fps. The white rows below
frame row 484 in one composite of five were the Adreno 220 binning an FBO render by the current
EGL surface's size (1400-row pbuffer, 942-row frame: the bottom 458 GL rows never rasterized;
proved with a magenta clear, raw GL state checked, a second read, one target vs two, a
frame-sized FBO). Fix: the context's pbuffer at the frame's size. Band frames on their own
768x256 pbuffer and FBO (switching into it costs 11-13 ms, so it stays current), the row shift
behind the GPU, a done-pipe that wakes the daemon's loop, the displayport only from a page's first
pan, the compositor-side scroll engaged by card pans only and settled after them. The pan:
25-31 fps (frame gaps mostly 24-31 ms), the animation page 21-25, frames verified by dumps and by
eye. Diagnostics that found it (env, off by default): JIHAD_GPU_CLEAR_MAGENTA,
JIHAD_GPU_DRAWSTATS, JIHAD_GPU_WHITE, JIHAD_GPU_READ_VERIFY, JIHAD_GPU_DUMP_TARGET.
Rounds 113-117: each frame built from the newest finished slot (one whole read after an anchor move repairs the pipeline); the diff treats the root-content layer's scroll as a shift (a page's own scroll reads the entering band) and is scaled to frame pixels (a latent wrong-rows bug on fit-zoomed animated pages); the two-deep queue made correct and kept for still pages. The pan 27-32 fps, the animation page 23.5-25, the self-scrolling page 11-14.
Rounds 118-119: the diff first, without a render: a frame whose tree changed is composed on the band surface when its rows fit (the animation page 26-31 fps, the self-scrolling page 13.5-17.4); the displayport also from a page's own first scroll.
Rounds 120-125: an anchor move during a pan was a whole frame because FrameLayerBuilder adds
the BOUNDS of what it painted to the layer's invalid region: the new strips at both ends of the
displayport spanned the frame. On the GPU path the region itself goes (AddInvalidRegion), and
changed regions are clipped to the frame before their bounds are taken; anchor moves are now
46-72-row band frames. The anchor distance is a knob (JIHAD_GPU_ANCHOR, 0.3 heights by default).
The pan 26-33 fps.
Rounds 126-128 (the first real page): en.m.wikipedia.org panned at 3.7-4.9 fps on the GPU path, worse than software (6.7-10.4): the 1.25-height displayport at a fit zoom of 0.69 was ~95 tiles of an image-heavy page, and tile uploads wait behind the GPU. Margins now 0.5 heights, none sideways unless zoomed in; only stuck sticky layers count as fixed rows. The page then pans at 15-17 fps against software's 9-12.4; a stuck 208x786 sticky container (541 rows re-read every step) is what limits it.
Rounds 129-134: the real page was being drawn wrong: the root scroll frame clipped to the engine window (764x942 CSS px) on a page laid out wider (1116x1374), so the right third and the bottom were never shown; the composition is the scroll port on the GPU path now. The displayport base and margins follow what the card shows; the fixed-row re-read widens by the shift (a sticky header doubled). The real page now pans at 13-14 fps (software 9-12.4) with every row right.
Rounds 138-150 (T-503, Cavekit Tier 87): fixed and sticky layers at minimum cost (opaque ones
kept per covered rect, others as column chunks on the band surface; header + footer two bands),
the draw culling fixed, stuck-sticky detection by probing the sticky ranges, the root composition
covering what the card shows, and a verify mode that changes nothing and tells sub-pixel
resampling from structural errors. The account and the table: docs/PICKUP.md, 2026-10-18
(evening) delta.
Rounds 162-186 (T-504, zoom): the zoom hold (the first frame at a new zoom in 52-217 ms on a
settled page), the zoomed-in settle's grey strip, the Adreno 220's first-write-after-draw texture
conversion (engine 0044), the zoom's resolution above 1.1 (sharp zoomed text), no DEAA for
axis-aligned scales (engine 0045). Desktop Wikipedia's pan at its fit zoom: 19-25 fps. The account:
docs/PICKUP.md, 2026-10-18 (night) delta.
Traps, for whoever continues: a GPU wait left unbounded wedged the driver for every process
(the system UI crash-looped on QFatal: EGL error; only a reboot recovered — the fence wait is
bounded to 1 s now); a screenshot at a fixed delay shows the load phase (the counters on the test
pages read 1–2 a second there while timers fire 26–60 times a second later: wait for content
frames before shooting); a launch into an open card relays the URL into it (close the cards
first); the device's /tmp is 40 MB (the frame dumps fill it).
Tier 87: the GPU path as shipped (2026-10-19)
Options 4 and 2 of the shortlist, measured on the bench (TouchPad, webOS CE 3.1.0). The account of each
step: docs/PICKUP.md, 2026-10-19 delta; the rounds: docs/rounds/t502-2026-10-17/.
What it does. Every interface's daemon starts on the GPU path (JIHAD_GPU_COMPOSITOR=1 JIHAD_GPU_EXPRESS=1 in the upstart jobs; JIHAD_GPU_COMPOSITOR=0 or user_pref("jihad.gpu.enabled", false) gives the software path, and a daemon whose GPU fails or stalls past 3 s falls back for its
life). The engine's own compositor (OpenGL ES 2.0 on an EGL pbuffer) composes the layer tree; the
card's scroll and zoom are applied in the compositor; a frame is read back only where it changed
(bands, the fixed layers' columns, a narrow change's one column) into the shared buffer the card
blits. Each card's commands are read on a thread of their own and an express lane composes and
publishes frames while the page's script holds the main thread. A frame may start anywhere in its
shared segment (a trailer says where), so a scroll moves the frame's window instead of its rows.
WebGL 1 contexts are made on the GPU and share textures with the compositor (no readback).
| Measured (scripted, the daemon's frames to the card) | GPU path as shipped | compared with |
|---|---|---|
| a long static page, pan | 30.5-36.4 fps | software path 2.9-3.2 fps |
| desktop Wikipedia, pan (10 s, frames published) | 23.2-24.1 fps | before engine 0055/0056 (one composite for a frame's regions, tiles outside the clip skipped): 20.0-21.7 |
| a page holding the main thread 600 ms in 700, pan | 26-32 fps | GPU path without the express lane 2.4-3.8 fps |
| zoom while the page's script runs (Wikipedia after its load) | 10/10 first frames within 160-225 ms | without the express lane: median 681 ms, 3 of 10 never framed |
| card double taps on Wikipedia, first frame at the new zoom | median 158 ms, 0-2 of 12 over 250 | before the clock hold: 4-8 of 12 over 250 |
| WebGL spinning cube, 240 / 360 / 480 px | 26.5-27 / 21.5-23 / 19.5-20.9 fps (the page draws 23.8-25.1 at 480) | software path: no WebGL |
The same 10 s scripted pan (0 → 3000 card px) on each page, the GPU path as shipped against the software
path (NOGPU=1), on the build with the review fixes (engine 0058), 2026-10-24 (T-519, the logs in
docs/rounds/t519-2026-10-24/; the 2 s windows during the pan, the pan's first window left out, the windows after it not counted; Hacker News is 1249 px tall, so the pan's last frames are past the page; Wikipedia's lowest window is its first scroll's restyle):
| Page | GPU path | Software path |
|---|---|---|
jihad-long-static.html (a fixed header) |
30.4-33.3 fps | 10.8-12.5 fps |
jihad-fixed-both.html (an opaque header and footer) |
26.4-27.9 fps | 10.9-12.7 fps |
jihad-sticky-mix.html (header, footer, a see-through sticky sidebar) |
16.5-19.0 fps | 8.6-10.0 fps |
jihad-anim-css.html (a CSS animation, panned) |
14.5-24.4 fps | 11.4-23.6 fps |
jihad-scroll-js.html (the page scrolls itself, panned) |
22.8-27.7 fps | 10.5-11.4 fps |
| news.ycombinator.com (fit zoom 0.955) | 37.5-48.0 fps | 6.3-10.2 fps |
| desktop en.wikipedia.org (12 s after the load) | 18.5-25.9 fps | 2.7-2.9 fps |
What it does not reach yet. Desktop Wikipedia at 24 fps every run: its two pinned side panels are
see-through sticky layers, composed again every frame; since they are regions of one composite the CPU
cost is down (a chunked frame 10.6 ms against 22.5) and the frame is bound by the GPU (~17 ms of compose
on the Adreno, round 357) with both cores full (the bench also runs sensord at ~18 % of a core beside
the Atlas browser's atlas-sensord). The page's own tile uploads, sent as partial sub-images, deferred,
or made ahead, and the express lane building on its own slot were each measured and changed nothing
(context/impl/dead-ends.md). The 480 px WebGL cube reaches the card at ~20 fps against the page's own
~24: its change does not fit the band surface and is composed in the frame-sized target. Option 6
(zero-copy frames to the card) is what lifts that floor.
Every combination, fastest first (the data behind the shortlist)
The frame modeled is four full-screen layers, three blended; the CPU pays three blends (20.8 ms plain, 15.4 ms NEON each), the GPU one composite (12.9 ms); "new content" adds the 53 ms CPU raster and, for the GPU rows, the 10.3 ms upload; a readback row adds 12.3 ms, the copy 5.1 ms, the card's scale 8.2 ms, LunaSysMgr's composite 3.8 ms. 3 needs 2 or 4; 6 needs 4 (webOS CE only). Weeks add per option.
| # | Solution (option numbers) | Animation and in-band scroll | New content | WebGL | Video | Weeks | Builds | Like Pale Moon / Basilisk |
|---|---|---|---|---|---|---|---|---|
| 1 | 4 + 6 | 17 ms, 60 fps | 80 ms, 12 fps | none | decode + a 17 ms frame | 11 | CE 3.1.0 only | their compositor, their presentation |
| 2 | 3 + 4 + 6 | 17 ms, 60 fps | 80 ms, 12 fps | none | decode + a 17 ms frame | 11.5 | CE 3.1.0 only | their compositor, their canvas (macOS), their presentation |
| 3 | 1 + 4 + 6 | 17 ms, 60 fps | 80 ms, 12 fps | none | decode + a 17 ms frame | 12 | CE 3.1.0 only | their compositor, their presentation |
| 4 | 2 + 4 + 6 | 17 ms, 60 fps | 80 ms, 12 fps | 17 ms, 60 fps | decode + a 17 ms frame | 12.5 | CE 3.1.0 only | their compositor, their WebGL, their presentation |
| 5 | 1 + 3 + 4 + 6 | 17 ms, 60 fps | 80 ms, 12 fps | none | decode + a 17 ms frame | 12.5 | CE 3.1.0 only | their compositor, their canvas (macOS), their presentation |
| 6 | 2 + 3 + 4 + 6 | 17 ms, 60 fps | 80 ms, 12 fps | 17 ms, 60 fps | decode + a 17 ms frame | 13 | CE 3.1.0 only | their compositor, their WebGL, their canvas (macOS), their presentation |
| 7 | 4 + 5 + 6 | 17 ms, 60 fps | 80 ms, 12 fps | none | decode only (overlay) | 13.5 | CE 3.1.0 only | their compositor, their presentation |
| 8 | 1 + 2 + 4 + 6 | 17 ms, 60 fps | 80 ms, 12 fps | 17 ms, 60 fps | decode + a 17 ms frame | 13.5 | CE 3.1.0 only | their compositor, their WebGL, their presentation |
| 9 | 3 + 4 + 5 + 6 | 17 ms, 60 fps | 80 ms, 12 fps | none | decode only (overlay) | 14 | CE 3.1.0 only | their compositor, their canvas (macOS), their presentation |
| 10 | 1 + 2 + 3 + 4 + 6 | 17 ms, 60 fps | 80 ms, 12 fps | 17 ms, 60 fps | decode + a 17 ms frame | 14 | CE 3.1.0 only | their compositor, their WebGL, their canvas (macOS), their presentation |
| 11 | 1 + 4 + 5 + 6 | 17 ms, 60 fps | 80 ms, 12 fps | none | decode only (overlay) | 14.5 | CE 3.1.0 only | their compositor, their presentation |
| 12 | 2 + 4 + 5 + 6 | 17 ms, 60 fps | 80 ms, 12 fps | 17 ms, 60 fps | decode only (overlay) | 15 | CE 3.1.0 only | their compositor, their WebGL, their presentation |
| 13 | 1 + 3 + 4 + 5 + 6 | 17 ms, 60 fps | 80 ms, 12 fps | none | decode only (overlay) | 15 | CE 3.1.0 only | their compositor, their canvas (macOS), their presentation |
| 14 | 2 + 3 + 4 + 5 + 6 | 17 ms, 60 fps | 80 ms, 12 fps | 17 ms, 60 fps | decode only (overlay) | 15.5 | CE 3.1.0 only | their compositor, their WebGL, their canvas (macOS), their presentation |
| 15 | 1 + 2 + 4 + 5 + 6 | 17 ms, 60 fps | 80 ms, 12 fps | 17 ms, 60 fps | decode only (overlay) | 16 | CE 3.1.0 only | their compositor, their WebGL, their presentation |
| 16 | 1 + 2 + 3 + 4 + 5 + 6 | 17 ms, 60 fps | 80 ms, 12 fps | 17 ms, 60 fps | decode only (overlay) | 16.5 | CE 3.1.0 only | their compositor, their WebGL, their canvas (macOS), their presentation |
| 17 | 4 | 42 ms, 24 fps | 106 ms, 9 fps | none | decode + a 42 ms frame | 6 | all | their compositor + a readback |
| 18 | 3 + 4 | 42 ms, 24 fps | 106 ms, 9 fps | none | decode + a 42 ms frame | 6.5 | all | their compositor + a readback, their canvas (macOS) |
| 19 | 1 + 4 | 42 ms, 24 fps | 106 ms, 9 fps | none | decode + a 42 ms frame | 7 | all | their compositor + a readback |
| 20 | 2 + 4 | 42 ms, 24 fps | 106 ms, 9 fps | 42 ms, 24 fps | decode + a 42 ms frame | 7.5 | all | their compositor + a readback, their WebGL |
| 21 | 1 + 3 + 4 | 42 ms, 24 fps | 106 ms, 9 fps | none | decode + a 42 ms frame | 7.5 | all | their compositor + a readback, their canvas (macOS) |
| 22 | 2 + 3 + 4 | 42 ms, 24 fps | 106 ms, 9 fps | 42 ms, 24 fps | decode + a 42 ms frame | 8 | all | their compositor + a readback, their WebGL, their canvas (macOS) |
| 23 | 4 + 5 | 42 ms, 24 fps | 106 ms, 9 fps | none | decode only (overlay) | 8.5 | all | their compositor + a readback |
| 24 | 1 + 2 + 4 | 42 ms, 24 fps | 106 ms, 9 fps | 42 ms, 24 fps | decode + a 42 ms frame | 8.5 | all | their compositor + a readback, their WebGL |
| 25 | 3 + 4 + 5 | 42 ms, 24 fps | 106 ms, 9 fps | none | decode only (overlay) | 9 | all | their compositor + a readback, their canvas (macOS) |
| 26 | 1 + 2 + 3 + 4 | 42 ms, 24 fps | 106 ms, 9 fps | 42 ms, 24 fps | decode + a 42 ms frame | 9 | all | their compositor + a readback, their WebGL, their canvas (macOS) |
| 27 | 1 + 4 + 5 | 42 ms, 24 fps | 106 ms, 9 fps | none | decode only (overlay) | 9.5 | all | their compositor + a readback |
| 28 | 2 + 4 + 5 | 42 ms, 24 fps | 106 ms, 9 fps | 42 ms, 24 fps | decode only (overlay) | 10 | all | their compositor + a readback, their WebGL |
| 29 | 1 + 3 + 4 + 5 | 42 ms, 24 fps | 106 ms, 9 fps | none | decode only (overlay) | 10 | all | their compositor + a readback, their canvas (macOS) |
| 30 | 2 + 3 + 4 + 5 | 42 ms, 24 fps | 106 ms, 9 fps | 42 ms, 24 fps | decode only (overlay) | 10.5 | all | their compositor + a readback, their WebGL, their canvas (macOS) |
| 31 | 1 + 2 + 4 + 5 | 42 ms, 24 fps | 106 ms, 9 fps | 42 ms, 24 fps | decode only (overlay) | 11 | all | their compositor + a readback, their WebGL |
| 32 | 1 + 2 + 3 + 4 + 5 | 42 ms, 24 fps | 106 ms, 9 fps | 42 ms, 24 fps | decode only (overlay) | 11.5 | all | their compositor + a readback, their WebGL, their canvas (macOS) |
| 33 | 1 | 63 ms, 16 fps | 116 ms, 9 fps | none | decode + a 63 ms frame | 1 | all | no equivalent |
| 34 | 1 + 2 | 63 ms, 16 fps | 116 ms, 9 fps | 76 ms, 13 fps | decode + a 63 ms frame | 2.5 | all | their WebGL |
| 35 | 1 + 2 + 3 | 63 ms, 16 fps | 116 ms, 9 fps | 76 ms, 13 fps | decode + a 63 ms frame | 3 | all | their WebGL, their canvas (macOS) |
| 36 | 1 + 5 | 63 ms, 16 fps | 116 ms, 9 fps | none | decode only (overlay) | 3.5 | all | no equivalent |
| 37 | 1 + 2 + 5 | 63 ms, 16 fps | 116 ms, 9 fps | 76 ms, 13 fps | decode only (overlay) | 5 | all | their WebGL |
| 38 | 1 + 2 + 3 + 5 | 63 ms, 16 fps | 116 ms, 9 fps | 76 ms, 13 fps | decode only (overlay) | 5.5 | all | their WebGL, their canvas (macOS) |
| 39 | today | 80 ms, 13 fps | 132 ms, 8 fps | none | decode + a 80 ms frame | 0 | all | their software fallback |
| 40 | 2 | 80 ms, 13 fps | 132 ms, 8 fps | 92 ms, 11 fps | decode + a 80 ms frame | 1.5 | all | their WebGL |
| 41 | 2 + 3 | 80 ms, 13 fps | 132 ms, 8 fps | 92 ms, 11 fps | decode + a 80 ms frame | 2 | all | their WebGL, their canvas (macOS) |
| 42 | 5 | 80 ms, 13 fps | 132 ms, 8 fps | none | decode only (overlay) | 2.5 | all | no equivalent |
| 43 | 2 + 5 | 80 ms, 13 fps | 132 ms, 8 fps | 92 ms, 11 fps | decode only (overlay) | 4 | all | their WebGL |
| 44 | 2 + 3 + 5 | 80 ms, 13 fps | 132 ms, 8 fps | 92 ms, 11 fps | decode only (overlay) | 4.5 | all | their WebGL, their canvas (macOS) |
The options, in short
- Software tuning — NEON blend/scale, fewer copies: 40–45 ms frames, 8–12 fps scroll; days; all builds.
- WebGL through EGL with readback — 15–20 fps for a full-screen GLES 2.0 canvas; 1–2 weeks.
- Skia GPU canvas on 2's context — days more; readback per canvas.
- OMTC on the GPU with readback (Atlas's shape) — 25–30 fps scroll/animation, 4–8 weeks, high risk.
- Video through the MDP overlay (Atlas's
gst-mdpoverlay) — decode-speed playback, 2–3 weeks. - Zero-copy GPU frames to the card — 40–60 fps ceiling, LunaSysMgr change, webOS CE only, on top of 4.