From c96d8e3b5acd4f9c5ffad794684e7fe12afb3cfd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:40:55 -0700 Subject: [PATCH] perf(emulator): return MJPEG frames as views instead of per-frame copies (#6707) Co-authored-by: Orca --- .../mjpeg-frame-parser-alloc-benchmark.mjs | 176 ++++++++++++++++++ src/main/emulator/mjpeg-frame-parser.test.ts | 15 ++ src/main/emulator/mjpeg-frame-parser.ts | 11 +- 3 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 config/scripts/mjpeg-frame-parser-alloc-benchmark.mjs diff --git a/config/scripts/mjpeg-frame-parser-alloc-benchmark.mjs b/config/scripts/mjpeg-frame-parser-alloc-benchmark.mjs new file mode 100644 index 000000000..86791745c --- /dev/null +++ b/config/scripts/mjpeg-frame-parser-alloc-benchmark.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node +// Benchmark: main-process heap allocation of the emulator MJPEG frame parser +// across a realistic stream burst. +// +// extractJpegFrames() splits an MJPEG byte stream into JPEG frames. The old +// implementation copied every extracted frame via Buffer.from(subarray(...)) +// and copied the incoming chunk via Buffer.from(chunk) when no bytes were +// pending. Both copies are unnecessary: each frame is consumed synchronously +// by the IPC layer (which copies into a transferable ArrayBuffer), and the only +// state retained across calls (`pending`) is independently copied out. The fix +// returns frame *views* and reads the chunk directly, so the parser allocates +// ~0 frame-sized buffers per second instead of one copy per frame. +// +// This script runs both implementations over the same synthetic 30fps stream +// and reports total bytes allocated (via process.memoryUsage / gc deltas). +import { performance, PerformanceObserver } from 'node:perf_hooks' + +// Count bytes copied by Buffer.from inside the parser, deterministically. Each +// implementation calls trackedCopy() wherever it would allocate a copy, so the +// reported "bytes copied" is exact and independent of GC timing. +let copiedBytes = 0 +function trackedCopy(buf) { + copiedBytes += buf.length + return Buffer.from(buf) +} + +const FPS = Number.parseInt(process.env.ORCA_MJPEG_BENCH_FPS ?? '30', 10) +const SECONDS = Number.parseInt(process.env.ORCA_MJPEG_BENCH_SECONDS ?? '30', 10) +const FRAME_BYTES = Number.parseInt(process.env.ORCA_MJPEG_BENCH_FRAME_BYTES ?? '184320', 10) // ~180 KiB + +for (const [name, value] of [ + ['ORCA_MJPEG_BENCH_FPS', FPS], + ['ORCA_MJPEG_BENCH_SECONDS', SECONDS], + ['ORCA_MJPEG_BENCH_FRAME_BYTES', FRAME_BYTES] +]) { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${value}`) + } +} + +const JPEG_START = Buffer.from([0xff, 0xd8]) +const JPEG_END = Buffer.from([0xff, 0xd9]) + +function makeFrame(seed) { + const body = Buffer.alloc(FRAME_BYTES - 4) + // Avoid an accidental 0xff 0xd9 inside the body so the frame boundary is clean. + body.fill(seed % 251 || 1) + return Buffer.concat([JPEG_START, body, JPEG_END]) +} + +// --- old implementation: copy chunk + copy every frame ----------------------- +function extractOld(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) { + let cursor = pending.length > 0 ? Buffer.concat([pending, chunk]) : trackedCopy(chunk) + const frames = [] + while (cursor.length > 0) { + const frameStart = cursor.indexOf(JPEG_START) + if (frameStart < 0) { + const keepLastByte = cursor.at(-1) === 0xff + return { frames, pending: keepLastByte ? Buffer.from([0xff]) : Buffer.alloc(0) } + } + if (frameStart > 0) { + cursor = cursor.subarray(frameStart) + } + const frameEnd = cursor.indexOf(JPEG_END, JPEG_START.length) + if (frameEnd < 0) { + const tail = cursor.length <= maxPendingBytes ? cursor : cursor.subarray(-maxPendingBytes) + return { frames, pending: trackedCopy(tail) } + } + const nextOffset = frameEnd + JPEG_END.length + frames.push(trackedCopy(cursor.subarray(0, nextOffset))) + cursor = cursor.subarray(nextOffset) + } + return { frames, pending: Buffer.alloc(0) } +} + +// --- new implementation: views, no chunk copy -------------------------------- +function extractNew(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) { + let cursor = pending.length > 0 ? Buffer.concat([pending, chunk]) : chunk + const frames = [] + while (cursor.length > 0) { + const frameStart = cursor.indexOf(JPEG_START) + if (frameStart < 0) { + const keepLastByte = cursor.at(-1) === 0xff + return { frames, pending: keepLastByte ? Buffer.from([0xff]) : Buffer.alloc(0) } + } + if (frameStart > 0) { + cursor = cursor.subarray(frameStart) + } + const frameEnd = cursor.indexOf(JPEG_END, JPEG_START.length) + if (frameEnd < 0) { + const tail = cursor.length <= maxPendingBytes ? cursor : cursor.subarray(-maxPendingBytes) + return { frames, pending: trackedCopy(tail) } + } + const nextOffset = frameEnd + JPEG_END.length + frames.push(cursor.subarray(0, nextOffset)) + cursor = cursor.subarray(nextOffset) + } + return { frames, pending: Buffer.alloc(0) } +} + +function runStream(extract, frames) { + // Simulate one network chunk per frame (the common MJPEG transport shape): + // each chunk delivers exactly one whole JPEG, so `pending` is empty per call. + let totalFrameBytes = 0 + for (const frame of frames) { + const result = extract(Buffer.alloc(0), frame) + for (const f of result.frames) { + totalFrameBytes += f.length + } + } + return totalFrameBytes +} + +function measure(label, extract, frames) { + let gcCount = 0 + let gcPauseMs = 0 + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + gcCount += 1 + gcPauseMs += entry.duration + } + }) + observer.observe({ entryTypes: ['gc'] }) + + global.gc?.() + copiedBytes = 0 + const before = process.memoryUsage() + const start = performance.now() + const consumed = runStream(extract, frames) + const elapsed = performance.now() - start + const after = process.memoryUsage() + observer.disconnect() + return { + label, + elapsedMs: elapsed, + consumedFrameBytes: consumed, + copiedMb: copiedBytes / (1024 * 1024), + rssDeltaMb: (after.rss - before.rss) / (1024 * 1024), + gcCount, + gcPauseMs + } +} + +const totalFrames = FPS * SECONDS +const frames = Array.from({ length: totalFrames }, (_, i) => makeFrame(i)) +const streamMb = (totalFrames * FRAME_BYTES) / (1024 * 1024) + +console.log( + `MJPEG parser alloc benchmark: ${FPS}fps × ${SECONDS}s = ${totalFrames} frames, ` + + `${(FRAME_BYTES / 1024).toFixed(0)} KiB/frame (${streamMb.toFixed(1)} MiB streamed)\n` +) + +if (!global.gc) { + console.log('(run with `node --expose-gc` for accurate heap deltas)\n') +} + +const oldResult = measure('before (Buffer.from copies)', extractOld, frames) +const newResult = measure('after (subarray views) ', extractNew, frames) + +for (const r of [oldResult, newResult]) { + console.log( + `${r.label} copied=${r.copiedMb.toFixed(1)} MiB ` + + `rssΔ=${r.rssDeltaMb.toFixed(1)} MiB gc=${r.gcCount} (${r.gcPauseMs.toFixed(1)}ms) ` + + `time=${r.elapsedMs.toFixed(1)}ms` + ) +} + +const savedMb = oldResult.copiedMb - newResult.copiedMb +console.log( + `\nEliminated ${savedMb.toFixed(1)} MiB of transient frame copies over ${SECONDS}s ` + + `(~${(savedMb / SECONDS).toFixed(2)} MiB/s of avoided allocation + GC pressure on the main process).` +) +console.log( + `Same frame bytes delivered (${(oldResult.consumedFrameBytes / (1024 * 1024)).toFixed(1)} MiB) ` + + `with ${newResult.copiedMb.toFixed(1)} MiB copied instead of ${oldResult.copiedMb.toFixed(1)} MiB.` +) diff --git a/src/main/emulator/mjpeg-frame-parser.test.ts b/src/main/emulator/mjpeg-frame-parser.test.ts index 629f265d4..40af8ba01 100644 --- a/src/main/emulator/mjpeg-frame-parser.test.ts +++ b/src/main/emulator/mjpeg-frame-parser.test.ts @@ -27,4 +27,19 @@ describe('extractJpegFrames', () => { expect(second.frames).toEqual([JPEG_A]) }) + + // Regression: frames are returned as views into the chunk (no per-frame copy), + // so the retained `pending` must still be an independent copy — otherwise a + // later mutation of the source chunk could corrupt buffered partial frames. + it('does not retain a view aliased to the input chunk', () => { + const chunk = Buffer.concat([JPEG_A, Buffer.from([0xff, 0xd8, 0x09])]) // A + partial B + const result = extractJpegFrames(Buffer.alloc(0), chunk) + + expect(result.frames).toEqual([JPEG_A]) + expect(result.pending).toEqual(Buffer.from([0xff, 0xd8, 0x09])) + + // Mutating the original chunk after parsing must not change pending. + chunk.fill(0) + expect(result.pending).toEqual(Buffer.from([0xff, 0xd8, 0x09])) + }) }) diff --git a/src/main/emulator/mjpeg-frame-parser.ts b/src/main/emulator/mjpeg-frame-parser.ts index 0f6a59873..841bc6a8c 100644 --- a/src/main/emulator/mjpeg-frame-parser.ts +++ b/src/main/emulator/mjpeg-frame-parser.ts @@ -22,7 +22,12 @@ export function extractJpegFrames( chunk: Buffer, maxPendingBytes = DEFAULT_MAX_PENDING_BYTES ): MjpegFrameParseResult { - let cursor = pending.length > 0 ? Buffer.concat([pending, chunk]) : Buffer.from(chunk) + // Why: when there are no leftover bytes the chunk already holds whole frames, + // so read it directly instead of copying — frames below are views consumed + // synchronously (the IPC layer copies into a transferable ArrayBuffer), and + // any retained `pending` is copied out via trimPendingBuffer, so no chunk + // memory is held across calls. At ~30fps this avoids a full-frame copy/frame. + let cursor = pending.length > 0 ? Buffer.concat([pending, chunk]) : chunk const frames: Buffer[] = [] while (cursor.length > 0) { @@ -41,7 +46,9 @@ export function extractJpegFrames( } const nextOffset = frameEnd + JPEG_END.length - frames.push(Buffer.from(cursor.subarray(0, nextOffset))) + // A view, not a copy: the caller consumes each frame synchronously before + // the next chunk arrives, and `cursor` is only ever re-sliced (never mutated). + frames.push(cursor.subarray(0, nextOffset)) cursor = cursor.subarray(nextOffset) }