From aa2578514a85146f07ac5f47ef47e1cd4c8e09da Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:20:08 +0100 Subject: [PATCH] Optimize turn processing and enhance performance metrics handling - Introduced batching for turn messages in ClientGameRunner to reduce the frequency of worker communication - Updated WorkerClient to manage pending turns and schedule flushes - Enhanced Worker.worker.ts to process turn batches - Added new TurnBatchMessage type for better organization of turn data sent between the main thread and worker. - Improved PerformanceOverlay to display additional metrics related to worker performance and turn processing. --- src/client/ClientGameRunner.ts | 30 ++++- .../graphics/layers/PerformanceOverlay.ts | 67 ++++++++++ src/client/graphics/layers/TerritoryLayer.ts | 73 ++++++++--- .../graphics/webgpu/core/GroundTruthData.ts | 123 +++++++++--------- src/client/graphics/webgpu/core/Pipeline.ts | 90 +++++++++++++ src/core/worker/Worker.worker.ts | 39 +++++- src/core/worker/WorkerClient.ts | 40 +++++- src/core/worker/WorkerMessages.ts | 7 + 8 files changed, 380 insertions(+), 89 deletions(-) create mode 100644 src/client/graphics/webgpu/core/Pipeline.ts diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 5e276f382..2d1e06639 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -9,6 +9,7 @@ import { PlayerCosmeticRefs, PlayerRecord, ServerMessage, + Turn, } from "../core/Schemas"; import { createPartialGameRecord, replacer } from "../core/Util"; import { ServerConfig } from "../core/configuration/Config"; @@ -435,20 +436,43 @@ export class ClientGameRunner { goToPlayer(); } - for (const turn of message.turns) { + const normalizeTurn = (turn: Turn): Turn => + this.gameView.config().isReplay() + ? { + ...turn, + intents: turn.intents.filter((i) => i.type !== "toggle_pause"), + } + : turn; + + // Firefox in particular suffers from a storm of thousands of tiny + // postMessage() calls on reconnect. Batch turns to keep the worker + // event loop responsive for render_frame and sim scheduling. + const batchSize = 256; + let batch: Turn[] = []; + const flush = () => { + if (batch.length === 0) return; + this.worker.sendTurnBatch(batch); + batch = []; + }; + + for (const rawTurn of message.turns as Turn[]) { + const turn = normalizeTurn(rawTurn); if (turn.turnNumber < this.turnsSeen) { continue; } while (turn.turnNumber - 1 > this.turnsSeen) { - this.worker.sendTurn({ + batch.push({ turnNumber: this.turnsSeen, intents: [], }); this.turnsSeen++; + if (batch.length >= batchSize) flush(); } - this.worker.sendTurn(turn); + batch.push(turn); this.turnsSeen++; + if (batch.length >= batchSize) flush(); } + flush(); } if (message.type === "desync") { if (this.lobby.gameStartInfo === undefined) { diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts index a5c0725b6..c76bd7f7c 100644 --- a/src/client/graphics/layers/PerformanceOverlay.ts +++ b/src/client/graphics/layers/PerformanceOverlay.ts @@ -538,6 +538,7 @@ export class PerformanceOverlay extends LitElement implements Layer { } private getWorkerKeyStats(metrics: WorkerMetricsMessage | null): { + intervalMs: number; loopLagAvg: number; loopLagMax: number; simDelayAvg: number; @@ -549,9 +550,18 @@ export class PerformanceOverlay extends LitElement implements Layer { rfHandlerAvg: number | null; rfHandlerMax: number | null; traceLines: string[]; + topMsgs: Array<{ + type: string; + count: number; + queueAvg: number | null; + queueMax: number | null; + handlerAvg: number | null; + handlerMax: number | null; + }>; } { if (!metrics) { return { + intervalMs: 0, loopLagAvg: 0, loopLagMax: 0, simDelayAvg: 0, @@ -563,6 +573,7 @@ export class PerformanceOverlay extends LitElement implements Layer { rfHandlerAvg: null, rfHandlerMax: null, traceLines: [], + topMsgs: [], }; } @@ -573,7 +584,32 @@ export class PerformanceOverlay extends LitElement implements Layer { const traceLines = metrics.trace && metrics.trace.length > 0 ? metrics.trace.slice(-5) : []; + const topMsgs = Object.entries(metrics.msgCounts ?? {}) + .sort((a, b) => b[1] - a[1]) + .slice(0, 6) + .map(([type, count]) => ({ + type, + count, + queueAvg: + typeof metrics.msgQueueMsAvg?.[type] === "number" + ? metrics.msgQueueMsAvg[type] + : null, + queueMax: + typeof metrics.msgQueueMsMax?.[type] === "number" + ? metrics.msgQueueMsMax[type] + : null, + handlerAvg: + typeof metrics.msgHandlerMsAvg?.[type] === "number" + ? metrics.msgHandlerMsAvg[type] + : null, + handlerMax: + typeof metrics.msgHandlerMsMax?.[type] === "number" + ? metrics.msgHandlerMsMax[type] + : null, + })); + return { + intervalMs: metrics.intervalMs, loopLagAvg: metrics.eventLoopLagMsAvg, loopLagMax: metrics.eventLoopLagMsMax, simDelayAvg: metrics.simPumpDelayMsAvg, @@ -585,6 +621,7 @@ export class PerformanceOverlay extends LitElement implements Layer { rfHandlerAvg: typeof rfHandlerAvg === "number" ? rfHandlerAvg : null, rfHandlerMax: typeof rfHandlerMax === "number" ? rfHandlerMax : null, traceLines, + topMsgs, }; } @@ -735,6 +772,12 @@ export class PerformanceOverlay extends LitElement implements Layer { >${this.formatMs(this.workerMetricsAgeMs, 0)} +
+ metrics interval (worker) + ${this.formatMs(worker.intervalMs, 0)} +
event loop lag (avg / max)
+ ${worker.topMsgs.length + ? html`
+ top msgs (count | queue avg/max | handler avg/max) +
+ ${worker.topMsgs.map( + (m) => + html`
+ ${m.type} (${m.count}) + + ${this.formatMs(m.queueAvg, 0)}/${this.formatMs( + m.queueMax, + 0, + )} + | + ${this.formatMs(m.handlerAvg, 0)}/${this.formatMs( + m.handlerMax, + 0, + )} + +
`, + )}` + : html``}
trace diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts index a7b2971ce..ef7aaeb4e 100644 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ b/src/client/graphics/layers/TerritoryLayer.ts @@ -59,6 +59,9 @@ export class TerritoryLayer implements Layer { private hoveredOwnerSmallId: number | null = null; private lastHoverUpdateMs = 0; private hoverRequestSeq = 0; + private hoverTile: TileRef | null = null; + private hoverQueryInFlight = false; + private pendingHoverTile: TileRef | null = null; constructor( private game: GameView, @@ -299,6 +302,8 @@ export class TerritoryLayer implements Layer { this.lastHoverUpdateMs = now; if (!this.lastMousePosition) { + this.hoverTile = null; + this.pendingHoverTile = null; if (this.hoveredOwnerSmallId !== null) { this.hoveredOwnerSmallId = null; this.territoryRenderer.setHighlightedOwnerId(null); @@ -311,6 +316,8 @@ export class TerritoryLayer implements Layer { this.lastMousePosition.y, ); if (!this.game.isValidCoord(cell.x, cell.y)) { + this.hoverTile = null; + this.pendingHoverTile = null; if (this.hoveredOwnerSmallId !== null) { this.hoveredOwnerSmallId = null; this.territoryRenderer.setHighlightedOwnerId(null); @@ -319,24 +326,54 @@ export class TerritoryLayer implements Layer { } const tile = this.game.ref(cell.x, cell.y); - const seq = ++this.hoverRequestSeq; - this.game.worker - .tileContext(tile) - .then((ctx) => { - if (seq !== this.hoverRequestSeq) { - return; - } - const nextOwnerSmallId = ctx.ownerSmallId; - if (nextOwnerSmallId === this.hoveredOwnerSmallId) { - return; - } - this.hoveredOwnerSmallId = nextOwnerSmallId; - this.territoryRenderer?.setHighlightedOwnerId(nextOwnerSmallId); - }) - .catch((err) => { - // Don't spam; hover is best-effort. - console.warn("tileContext hover lookup failed:", err); - }); + + // Only query on tile changes; keep at most one query in flight. + if (this.hoverTile === tile && this.pendingHoverTile === null) { + return; + } + this.hoverTile = tile; + this.pendingHoverTile = tile; + + if (this.hoverQueryInFlight) { + return; + } + + const doQuery = () => { + const nextTile = this.pendingHoverTile; + if (nextTile === null) { + this.hoverQueryInFlight = false; + return; + } + this.pendingHoverTile = null; + this.hoverQueryInFlight = true; + + const seq = ++this.hoverRequestSeq; + this.game.worker + .tileContext(nextTile) + .then((ctx) => { + if (seq !== this.hoverRequestSeq) { + return; + } + const nextOwnerSmallId = ctx.ownerSmallId; + if (nextOwnerSmallId === this.hoveredOwnerSmallId) { + return; + } + this.hoveredOwnerSmallId = nextOwnerSmallId; + this.territoryRenderer?.setHighlightedOwnerId(nextOwnerSmallId); + }) + .catch((err) => { + // Hover is best-effort; avoid spamming logs. + console.warn("tileContext hover lookup failed:", err); + }) + .finally(() => { + this.hoverQueryInFlight = false; + if (this.pendingHoverTile !== null) { + doQuery(); + } + }); + }; + + doQuery(); } private computePaletteSignature(): string { diff --git a/src/client/graphics/webgpu/core/GroundTruthData.ts b/src/client/graphics/webgpu/core/GroundTruthData.ts index 803944884..e5f9eafb0 100644 --- a/src/client/graphics/webgpu/core/GroundTruthData.ts +++ b/src/client/graphics/webgpu/core/GroundTruthData.ts @@ -58,8 +58,12 @@ export class GroundTruthData { private readonly state: Uint16Array; private readonly terrainData: Uint8Array; private needsStateUpload = true; + private stateUploadScratch: Uint32Array | null = null; + private stateUploadScratchStrideU32 = 0; private needsPaletteUpload = true; private needsTerrainDataUpload = true; + private terrainDataUploadScratch: Uint8Array | null = null; + private terrainDataUploadScratchBytesPerRow = 0; private needsTerrainParamsUpload = true; private useVisualStateTexture = false; private visualStateNeedsSync = false; @@ -514,41 +518,42 @@ export class GroundTruthData { } this.needsStateUpload = false; - // Convert 16-bit CPU state to 32-bit array - const u32State = new Uint32Array(this.state.length); - for (let i = 0; i < this.state.length; i++) { - u32State[i] = this.state[i]; - } - const bytesPerTexel = Uint32Array.BYTES_PER_ELEMENT; const fullBytesPerRow = this.mapWidth * bytesPerTexel; + const bytesPerRow = align(fullBytesPerRow, 256); + const strideU32 = bytesPerRow / 4; + const required = strideU32 * this.mapHeight; - if (fullBytesPerRow % 256 === 0) { - this.device.queue.writeTexture( - { texture: this.stateTexture }, - u32State, - { bytesPerRow: fullBytesPerRow, rowsPerImage: this.mapHeight }, - { - width: this.mapWidth, - height: this.mapHeight, - depthOrArrayLayers: 1, - }, - ); - } else { - // Fallback: upload row-by-row with padding - const paddedBytesPerRow = align(fullBytesPerRow, 256); - const scratch = new Uint32Array(paddedBytesPerRow / 4); - for (let y = 0; y < this.mapHeight; y++) { - const start = y * this.mapWidth; - scratch.set(u32State.subarray(start, start + this.mapWidth), 0); - this.device.queue.writeTexture( - { texture: this.stateTexture, origin: { x: 0, y } }, - scratch, - { bytesPerRow: paddedBytesPerRow, rowsPerImage: 1 }, - { width: this.mapWidth, height: 1, depthOrArrayLayers: 1 }, - ); + if ( + !this.stateUploadScratch || + this.stateUploadScratchStrideU32 !== strideU32 || + this.stateUploadScratch.length < required + ) { + this.stateUploadScratch = new Uint32Array(required); + this.stateUploadScratchStrideU32 = strideU32; + } + + const dst = this.stateUploadScratch; + const src = this.state; + const w = this.mapWidth; + for (let y = 0; y < this.mapHeight; y++) { + const srcBase = y * w; + const dstBase = y * strideU32; + for (let x = 0; x < w; x++) { + dst[dstBase + x] = src[srcBase + x]; } } + + this.device.queue.writeTexture( + { texture: this.stateTexture }, + dst, + { bytesPerRow, rowsPerImage: this.mapHeight }, + { + width: this.mapWidth, + height: this.mapHeight, + depthOrArrayLayers: 1, + }, + ); } /** @@ -589,36 +594,36 @@ export class GroundTruthData { } this.needsTerrainDataUpload = false; - const bytesPerRow = this.mapWidth; - const paddedBytesPerRow = align(bytesPerRow, 256); - - if (paddedBytesPerRow === bytesPerRow) { - // Direct upload if already aligned - this.device.queue.writeTexture( - { texture: this.terrainDataTexture }, - this.terrainData, - { bytesPerRow, rowsPerImage: this.mapHeight }, - { - width: this.mapWidth, - height: this.mapHeight, - depthOrArrayLayers: 1, - }, - ); - } else { - // Row-by-row upload with padding - const row = new Uint8Array(paddedBytesPerRow); - for (let y = 0; y < this.mapHeight; y++) { - row.fill(0); - const start = y * this.mapWidth; - row.set(this.terrainData.subarray(start, start + this.mapWidth), 0); - this.device.queue.writeTexture( - { texture: this.terrainDataTexture, origin: { x: 0, y } }, - row, - { bytesPerRow: paddedBytesPerRow, rowsPerImage: 1 }, - { width: this.mapWidth, height: 1, depthOrArrayLayers: 1 }, - ); - } + const bytesPerRow = align(this.mapWidth, 256); + const required = bytesPerRow * this.mapHeight; + if ( + !this.terrainDataUploadScratch || + this.terrainDataUploadScratchBytesPerRow !== bytesPerRow || + this.terrainDataUploadScratch.length < required + ) { + this.terrainDataUploadScratch = new Uint8Array(required); + this.terrainDataUploadScratchBytesPerRow = bytesPerRow; } + + const dst = this.terrainDataUploadScratch; + const src = this.terrainData; + const w = this.mapWidth; + for (let y = 0; y < this.mapHeight; y++) { + const srcStart = y * w; + const dstStart = y * bytesPerRow; + dst.set(src.subarray(srcStart, srcStart + w), dstStart); + } + + this.device.queue.writeTexture( + { texture: this.terrainDataTexture }, + dst, + { bytesPerRow, rowsPerImage: this.mapHeight }, + { + width: this.mapWidth, + height: this.mapHeight, + depthOrArrayLayers: 1, + }, + ); } uploadTerrainParams(): void { diff --git a/src/client/graphics/webgpu/core/Pipeline.ts b/src/client/graphics/webgpu/core/Pipeline.ts new file mode 100644 index 000000000..ec2b4dcc7 --- /dev/null +++ b/src/client/graphics/webgpu/core/Pipeline.ts @@ -0,0 +1,90 @@ +type AnyFn = (...args: any[]) => any; +type SlowPipelineKind = "compute" | "render"; +type SlowPipelineEvent = { + kind: SlowPipelineKind; + ms: number; + label?: string; +}; + +function getOptionalMethod( + obj: unknown, + name: string, +): T | undefined { + const anyObj = obj as any; + const value = anyObj?.[name]; + return typeof value === "function" ? (value as T) : undefined; +} + +function emitSlowPipelineEvent(event: SlowPipelineEvent): void { + const hook = (globalThis as any).__webgpuSlowPipelineHook; + if (typeof hook !== "function") { + return; + } + try { + hook(event); + } catch { + // ignore + } +} + +export async function createComputePipeline( + device: GPUDevice, + descriptor: GPUComputePipelineDescriptor, + debugLabel?: string, +): Promise { + const start = performance.now(); + + const maybeAsync = getOptionalMethod< + (desc: GPUComputePipelineDescriptor) => Promise + >(device, "createComputePipelineAsync"); + + const pipeline = maybeAsync + ? await maybeAsync.call(device, descriptor) + : device.createComputePipeline(descriptor); + + const ms = performance.now() - start; + if (ms > 250) { + console.warn("WebGPU slow compute pipeline compile", { + ms: Math.round(ms), + label: debugLabel, + }); + emitSlowPipelineEvent({ + kind: "compute", + ms, + label: debugLabel, + }); + } + + return pipeline; +} + +export async function createRenderPipeline( + device: GPUDevice, + descriptor: GPURenderPipelineDescriptor, + debugLabel?: string, +): Promise { + const start = performance.now(); + + const maybeAsync = getOptionalMethod< + (desc: GPURenderPipelineDescriptor) => Promise + >(device, "createRenderPipelineAsync"); + + const pipeline = maybeAsync + ? await maybeAsync.call(device, descriptor) + : device.createRenderPipeline(descriptor); + + const ms = performance.now() - start; + if (ms > 250) { + console.warn("WebGPU slow render pipeline compile", { + ms: Math.round(ms), + label: debugLabel, + }); + emitSlowPipelineEvent({ + kind: "render", + ms, + label: debugLabel, + }); + } + + return pipeline; +} diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index 93067cfb0..61f71cd52 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -16,7 +16,7 @@ import { } from "../game/GameUpdates"; import { createGameRunner, GameRunner } from "../GameRunner"; -import { ClientID, GameStartInfo, PlayerCosmetics } from "../Schemas"; +import { ClientID, GameStartInfo, PlayerCosmetics, Turn } from "../Schemas"; import { DirtyTileQueue } from "./DirtyTileQueue"; import { WorkerCanvas2DRenderer } from "./WorkerCanvas2DRenderer"; import { @@ -45,6 +45,7 @@ let renderer: WorkerTerritoryRenderer | WorkerCanvas2DRenderer | null = null; let dirtyTiles: DirtyTileQueue | null = null; let dirtyTilesOverflow = false; let renderTileState: Uint16Array | null = null; +const pendingTurns: Turn[] = []; type WorkerDebugConfig = { enabled: boolean; @@ -282,24 +283,36 @@ class WorkerProfiler { const profiler = new WorkerProfiler(); let simPumpScheduled = false; + function scheduleSimPump(): void { if (simPumpScheduled) { return; } simPumpScheduled = true; - const scheduledAtWallMs = Date.now(); setTimeout(async () => { simPumpScheduled = false; if (!gameRunner) { return; } + const gr = await gameRunner; profiler.recordSimDelay(Date.now() - scheduledAtWallMs); const execStart = performance.now(); + if (pendingTurns.length > 0) { + // Drain turns into GameRunner's queue in chunks so we don't block + // the worker event loop for too long (important for Firefox). + const maxDrain = 256; + for (let i = 0; i < maxDrain && pendingTurns.length > 0; i++) { + const t = pendingTurns.shift(); + if (t) { + gr.addTurn(t); + } + } + } gr.executeNextTick(); profiler.recordSimExec(performance.now() - execStart); - if (gr.hasPendingTurns()) { + if (pendingTurns.length > 0 || gr.hasPendingTurns()) { scheduleSimPump(); } }, 0); @@ -442,6 +455,9 @@ ctx.addEventListener("message", async (e: MessageEvent) => { if (!dirtyTiles) { return; } + if (dirtyTilesOverflow) { + return; + } const tile = Number(packedUpdate >> 16n) as TileRef; const state = Number(packedUpdate & 0xffffn); @@ -475,8 +491,7 @@ ctx.addEventListener("message", async (e: MessageEvent) => { } try { - const gr = await gameRunner; - gr.addTurn(message.turn); + pendingTurns.push(message.turn); scheduleSimPump(); } catch (error) { console.error("Failed to process turn:", error); @@ -484,6 +499,20 @@ ctx.addEventListener("message", async (e: MessageEvent) => { } break; + case "turn_batch": + if (!gameRunner) { + throw new Error("Game runner not initialized"); + } + + try { + pendingTurns.push(...message.turns); + scheduleSimPump(); + } catch (error) { + console.error("Failed to process turn batch:", error); + throw error; + } + break; + case "tile_context": if (!gameRunner) { throw new Error("Game runner not initialized"); diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts index f0ba0d86a..efc641dfd 100644 --- a/src/core/worker/WorkerClient.ts +++ b/src/core/worker/WorkerClient.ts @@ -25,6 +25,10 @@ export class WorkerClient { ) => void; private workerMetricsCallback?: (metrics: WorkerMetricsMessage) => void; + private pendingTurns: Turn[] = []; + private turnFlushScheduled = false; + private readonly maxTurnsPerBatch = 256; + constructor( private gameStartInfo: GameStartInfo, private clientID: ClientID, @@ -154,15 +158,43 @@ export class WorkerClient { this.gameUpdateCallback = gameUpdate; } + private scheduleTurnFlush(): void { + if (this.turnFlushScheduled) return; + this.turnFlushScheduled = true; + setTimeout(() => { + this.turnFlushScheduled = false; + this.flushTurns(); + }, 0); + } + + private flushTurns(): void { + while (this.pendingTurns.length > 0) { + const batch = this.pendingTurns.splice(0, this.maxTurnsPerBatch); + this.postMessage({ + type: "turn_batch", + turns: batch, + }); + } + } + sendTurn(turn: Turn) { if (!this.isInitialized) { throw new Error("Worker not initialized"); } - this.postMessage({ - type: "turn", - turn, - }); + this.pendingTurns.push(turn); + this.scheduleTurnFlush(); + } + + sendTurnBatch(turns: Turn[]) { + if (!this.isInitialized) { + throw new Error("Worker not initialized"); + } + if (turns.length === 0) return; + + // Preserve order with any already queued turns. + this.pendingTurns.push(...turns); + this.scheduleTurnFlush(); } sendHeartbeat() { diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts index 3cda691c7..69a0f2bbf 100644 --- a/src/core/worker/WorkerMessages.ts +++ b/src/core/worker/WorkerMessages.ts @@ -13,6 +13,7 @@ export type WorkerMessageType = | "init" | "initialized" | "turn" + | "turn_batch" | "game_update" | "tile_context" | "tile_context_result" @@ -73,6 +74,11 @@ export interface TurnMessage extends BaseWorkerMessage { turn: Turn; } +export interface TurnBatchMessage extends BaseWorkerMessage { + type: "turn_batch"; + turns: Turn[]; +} + // Messages from worker to main thread export interface InitializedMessage extends BaseWorkerMessage { type: "initialized"; @@ -337,6 +343,7 @@ export type MainThreadMessage = | HeartbeatMessage | InitMessage | TurnMessage + | TurnBatchMessage | TileContextMessage | PlayerActionsMessage | PlayerProfileMessage