Cut per-paragraph GC stalls: reuse static paper base, cap lookahead to 1
Profiling the per-paragraph playback stutter showed the JS heap sawtoothing (37<->71MB) with 0.4-2.2s long tasks once per block — GC pauses from large (24-48MB) per-block canvas/ImageBitmap allocations, not pagination (buildPages was ~29ms). These pauses freeze the flip/reveal animation, which is also why the title flip looked un-animated. - The reveal "base" layer is the plain paper background, identical for every page of a side. The worker now sends its bitmap once per side+size; the renderer caches the canvas and reuses it for all reveals, removing a large per-block bitmap+canvas allocation. - WEBGL_BOOK_PREFETCH_LOOKAHEAD 2 -> 1 so only the next block's page render is prepared, instead of letting multiple large rasterizations overlap. Verified live: per-paragraph long tasks roughly halved (10 -> 5 over the same window) and worst case 2159ms -> 1431ms. Residual ~1.4s stall remains from the per-block page bitmap + prepared- page snapshot clone + texture upload; further reduction needs reworking those to reuse buffers. Suite 181. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -336,10 +336,17 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
ctx.clearRect(0, 0, this.canvases[side].width, this.canvases[side].height);
|
ctx.clearRect(0, 0, this.canvases[side].width, this.canvases[side].height);
|
||||||
ctx.drawImage(result.pageBitmap, 0, 0);
|
ctx.drawImage(result.pageBitmap, 0, 0);
|
||||||
result.pageBitmap.close?.();
|
result.pageBitmap.close?.();
|
||||||
if (hasReveal && result.baseBitmap) {
|
// The paper base is identical for every page of a side; the worker sends its bitmap
|
||||||
this.revealBaseCanvases[side] = this.canvasFromBitmap(result.baseBitmap);
|
// only once, and we cache the canvas and reuse it for all reveals. This removes a
|
||||||
|
// large per-block canvas/bitmap allocation that was driving GC stalls.
|
||||||
|
if (result.baseBitmap) {
|
||||||
|
if (!this.cachedBaseCanvas) this.cachedBaseCanvas = {};
|
||||||
|
this.cachedBaseCanvas[side] = this.canvasFromBitmap(result.baseBitmap);
|
||||||
|
result.baseBitmap.close?.();
|
||||||
|
}
|
||||||
|
if (hasReveal) {
|
||||||
|
this.revealBaseCanvases[side] = this.cachedBaseCanvas?.[side] || null;
|
||||||
}
|
}
|
||||||
result.baseBitmap?.close?.();
|
|
||||||
});
|
});
|
||||||
const published = this.publishSpread(sidesToDraw, options);
|
const published = this.publishSpread(sidesToDraw, options);
|
||||||
this.markPipelineTiming('drawSpread:end', { sides: sidesToDraw, phase });
|
this.markPipelineTiming('drawSpread:end', { sides: sidesToDraw, phase });
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
let fontsReady = null;
|
let fontsReady = null;
|
||||||
const imageCache = new Map(); // src -> ImageBitmap | null
|
const imageCache = new Map(); // src -> ImageBitmap | null
|
||||||
const surfaces = {}; // side -> { canvas, ctx }
|
const surfaces = {}; // side -> { canvas, ctx }
|
||||||
|
// The reveal "base" layer is the plain paper background (drawPageBase) — identical for every
|
||||||
|
// page of a side at a given size. Send its bitmap only once per side+size; the main thread
|
||||||
|
// caches and reuses it, avoiding a large per-block ImageBitmap allocation (GC churn).
|
||||||
|
const sentBaseKeys = new Set();
|
||||||
|
|
||||||
function resolveImageSource(metadata = {}) {
|
function resolveImageSource(metadata = {}) {
|
||||||
const explicit = String(metadata.url || metadata.src || '').trim();
|
const explicit = String(metadata.url || metadata.src || '').trim();
|
||||||
@@ -319,7 +323,11 @@ async function renderSide(job, side) {
|
|||||||
|
|
||||||
drawPageBase(ctx, side, width, height);
|
drawPageBase(ctx, side, width, height);
|
||||||
let baseBitmap = null;
|
let baseBitmap = null;
|
||||||
if (job.hasReveal) baseBitmap = await createImageBitmap(surface.canvas);
|
const baseKey = `${side}:${width}x${height}`;
|
||||||
|
if (job.hasReveal && !sentBaseKeys.has(baseKey)) {
|
||||||
|
baseBitmap = await createImageBitmap(surface.canvas);
|
||||||
|
sentBaseKeys.add(baseKey);
|
||||||
|
}
|
||||||
if (meta?.kind === 'title') drawTitlePage(ctx, metrics, side, job.titleData);
|
if (meta?.kind === 'title') drawTitlePage(ctx, metrics, side, job.titleData);
|
||||||
drawPageLines(ctx, metrics, side, job.spreads?.[side] || []);
|
drawPageLines(ctx, metrics, side, job.spreads?.[side] || []);
|
||||||
drawPageNumber(ctx, metrics, side, meta);
|
drawPageNumber(ctx, metrics, side, meta);
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { BaseModule } from './base-module.js';
|
|||||||
const TTS_GENERATION_TIMEOUT_MS = 60000;
|
const TTS_GENERATION_TIMEOUT_MS = 60000;
|
||||||
const ASSET_PRELOAD_TIMEOUT_MS = 60000;
|
const ASSET_PRELOAD_TIMEOUT_MS = 60000;
|
||||||
const USER_CANCEL_BLOCKING_WAIT_MIN_MS = 5000;
|
const USER_CANCEL_BLOCKING_WAIT_MIN_MS = 5000;
|
||||||
const WEBGL_BOOK_PREFETCH_LOOKAHEAD = 2;
|
// Prepare only the next block's page render ahead of playback. Higher values let multiple
|
||||||
|
// large page rasterizations overlap, spiking allocation into multi-second GC stalls.
|
||||||
|
const WEBGL_BOOK_PREFETCH_LOOKAHEAD = 1;
|
||||||
|
|
||||||
class SentenceQueueModule extends BaseModule {
|
class SentenceQueueModule extends BaseModule {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -223,14 +225,7 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
sentence.playbackStartedAt = performance.now();
|
sentence.playbackStartedAt = performance.now();
|
||||||
this.onSentenceReadyCallback(sentence, resolve);
|
this.onSentenceReadyCallback(sentence, resolve);
|
||||||
});
|
});
|
||||||
// Start lookahead only after the current sentence has entered the display
|
this.scheduleLookaheadAfterDisplay(item, queueGeneration);
|
||||||
// pipeline. This keeps future WebGL book preparation out of the first
|
|
||||||
// flip/reveal critical path while still overlapping it with playback.
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
if (this.isCurrentQueueItem(item, queueGeneration)) {
|
|
||||||
this.prefetchAhead(6, queueGeneration);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await playbackFinished;
|
await playbackFinished;
|
||||||
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
||||||
} else {
|
} else {
|
||||||
@@ -978,6 +973,18 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
return queueGeneration === this.queueGeneration && this.sentenceQueue[0] === item;
|
return queueGeneration === this.queueGeneration && this.sentenceQueue[0] === item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scheduleLookaheadAfterDisplay(item, queueGeneration = this.queueGeneration) {
|
||||||
|
const run = () => {
|
||||||
|
if (this.isCurrentQueueItem(item, queueGeneration)) {
|
||||||
|
this.prefetchAhead(6, queueGeneration);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
const scheduleIdle = window.requestIdleCallback || ((callback) => window.setTimeout(callback, 180));
|
||||||
|
scheduleIdle(run, { timeout: 260 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
prefetchAhead(maxLookahead = 6, queueGeneration = this.queueGeneration) {
|
prefetchAhead(maxLookahead = 6, queueGeneration = this.queueGeneration) {
|
||||||
if (this.sentenceQueue.length <= 1) {
|
if (this.sentenceQueue.length <= 1) {
|
||||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||||
|
|||||||
@@ -145,7 +145,8 @@ const checks = [
|
|||||||
['sentence queue starts lookahead when items arrive during playback', /else \{\s*this\.prefetchAhead\(6, this\.queueGeneration\);/.test(sentenceQueueSource)],
|
['sentence queue starts lookahead when items arrive during playback', /else \{\s*this\.prefetchAhead\(6, this\.queueGeneration\);/.test(sentenceQueueSource)],
|
||||||
['sentence queue keeps current 3D page prep immediate while future lookahead yields cooperatively', /if \(!options\.immediate\) \{[\s\S]*requestIdleCallback[\s\S]*timeout: 80/.test(sentenceQueueSource) && /prefetchAhead\(maxLookahead = 6/.test(sentenceQueueSource)],
|
['sentence queue keeps current 3D page prep immediate while future lookahead yields cooperatively', /if \(!options\.immediate\) \{[\s\S]*requestIdleCallback[\s\S]*timeout: 80/.test(sentenceQueueSource) && /prefetchAhead\(maxLookahead = 6/.test(sentenceQueueSource)],
|
||||||
['sentence queue serializes heavy WebGL book preparation separately from speech prefetch', /prefetchingWebGLBook = new Map/.test(sentenceQueueSource) && /webglBookPrepareChain = Promise\.resolve\(\)/.test(sentenceQueueSource) && /this\.webglBookPrepareChain[\s\S]*\.then\(\(\) => this\.runWebGLBookPresentationPrepare/.test(sentenceQueueSource)],
|
['sentence queue serializes heavy WebGL book preparation separately from speech prefetch', /prefetchingWebGLBook = new Map/.test(sentenceQueueSource) && /webglBookPrepareChain = Promise\.resolve\(\)/.test(sentenceQueueSource) && /this\.webglBookPrepareChain[\s\S]*\.then\(\(\) => this\.runWebGLBookPresentationPrepare/.test(sentenceQueueSource)],
|
||||||
['sentence queue caps WebGL book lookahead without capping TTS lookahead window', /const WEBGL_BOOK_PREFETCH_LOOKAHEAD = 2/.test(sentenceQueueSource) && /webglBookLookahead < WEBGL_BOOK_PREFETCH_LOOKAHEAD/.test(sentenceQueueSource) && !/spokenPrepared >= 1 && started >= 2/.test(sentenceQueueSource)],
|
['sentence queue caps WebGL book lookahead without capping TTS lookahead window', /const WEBGL_BOOK_PREFETCH_LOOKAHEAD = 1/.test(sentenceQueueSource) && /webglBookLookahead < WEBGL_BOOK_PREFETCH_LOOKAHEAD/.test(sentenceQueueSource) && !/spokenPrepared >= 1 && started >= 2/.test(sentenceQueueSource)],
|
||||||
|
['texture worker sends the static paper base bitmap once per side and the renderer reuses it', /sentBaseKeys/.test(textureWorkerSource) && /const baseKey = `\$\{side\}:\$\{width\}x\$\{height\}`/.test(textureWorkerSource) && /this\.cachedBaseCanvas\[side\] = this\.canvasFromBitmap/.test(textureRendererSource) && /this\.revealBaseCanvases\[side\] = this\.cachedBaseCanvas\?\.\[side\]/.test(textureRendererSource)],
|
||||||
['sentence queue gates WebGL book lookahead to active 3D playback only', /const allowWebGLBookPrefetch = document\.documentElement\.dataset\.webglBookPlaybackActive === 'true'/.test(sentenceQueueSource) && /const shouldPrepareWebGLBook = allowWebGLBookPrefetch[\s\S]*&& webglBookCandidate[\s\S]*&& webglBookLookahead < WEBGL_BOOK_PREFETCH_LOOKAHEAD/.test(sentenceQueueSource)],
|
['sentence queue gates WebGL book lookahead to active 3D playback only', /const allowWebGLBookPrefetch = document\.documentElement\.dataset\.webglBookPlaybackActive === 'true'/.test(sentenceQueueSource) && /const shouldPrepareWebGLBook = allowWebGLBookPrefetch[\s\S]*&& webglBookCandidate[\s\S]*&& webglBookLookahead < WEBGL_BOOK_PREFETCH_LOOKAHEAD/.test(sentenceQueueSource)],
|
||||||
['pagination can prepare future spreads without activating visible spread', /preparePendingBlock\(block = \{\}, options = \{\}\)/.test(bookPaginationSource) && /options\.activate !== false/.test(bookPaginationSource) && /includeUnrenderedHistory/.test(bookPaginationSource)],
|
['pagination can prepare future spreads without activating visible spread', /preparePendingBlock\(block = \{\}, options = \{\}\)/.test(bookPaginationSource) && /options\.activate !== false/.test(bookPaginationSource) && /includeUnrenderedHistory/.test(bookPaginationSource)],
|
||||||
['pagination preserves active inline style tags for texture lines', /getActiveStyleTags/.test(bookPaginationSource) && /activeStyleTags/.test(bookPaginationSource) && /updateStyleTagStack/.test(bookPaginationSource)],
|
['pagination preserves active inline style tags for texture lines', /getActiveStyleTags/.test(bookPaginationSource) && /activeStyleTags/.test(bookPaginationSource) && /updateStyleTagStack/.test(bookPaginationSource)],
|
||||||
|
|||||||
Reference in New Issue
Block a user