diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index e03db7ea94..265f114b34 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -310,6 +310,7 @@ export { type WorkerResult, type WorkerSizing, type WorkerSizingBound, + type WorkerSizingConfig, type LandedFrame, type ParallelProgress, type ParallelWorkerPhaseDiagnostic, diff --git a/packages/engine/src/services/parallelCoordinator.test.ts b/packages/engine/src/services/parallelCoordinator.test.ts index 646bfdfc9d..d6aa0dc331 100644 --- a/packages/engine/src/services/parallelCoordinator.test.ts +++ b/packages/engine/src/services/parallelCoordinator.test.ts @@ -167,6 +167,22 @@ describe("computeWorkerSizing", () => { expect(computeWorkerSizing(1300, undefined, { concurrency: "auto" }).workers).toBe(5); }); + it("sizes Apple-GPU disk capture by its own heap and core budget", () => { + vi.mocked(cpus).mockReturnValue( + Array.from({ length: 14 }, () => ({}) as ReturnType[number]), + ); + vi.mocked(totalmem).mockReturnValue(24 * 1024 ** 3); + vi.mocked(getHeapStatistics).mockReturnValue({ + heap_size_limit: 4192 * 1024 ** 2, + } as ReturnType); + const apple = { concurrency: "auto" as const, appleGpuDiskCapture: true }; + const sizing = computeWorkerSizing(1300, undefined, apple); + expect(sizing.workers).toBe(8); + expect(sizing.boundBy).toBe("memory"); + expect(sizing.exceedsHeapAdvisory).toBe(false); + expect(computeWorkerSizing(1300, undefined, { ...apple, coresPerWorker: 4 }).workers).toBe(3); + }); + it("matches calculateOptimalWorkers and reports every constraint", () => { const config = { concurrency: "auto" as const }; const sizing = computeWorkerSizing(900, undefined, config); diff --git a/packages/engine/src/services/parallelCoordinator.ts b/packages/engine/src/services/parallelCoordinator.ts index 17b9f083aa..98d8afee77 100644 --- a/packages/engine/src/services/parallelCoordinator.ts +++ b/packages/engine/src/services/parallelCoordinator.ts @@ -131,6 +131,8 @@ export interface WorkerSizingConfig extends Partial< * than a plain DOM screenshot. Explicit --workers requests ignore this hint. */ captureCostMultiplier?: number; + /** macOS, hardware GPU, screenshot frames written to disk (not streamed through this process). */ + appleGpuDiskCapture?: boolean; } type WorkerBrowserPoolDecision = { @@ -151,6 +153,10 @@ const HEAP_RESERVED_MB = 1024; // default heap ⇒ >~500MB/worker + base. Caps auto sizing; an explicit // `--workers N` is still the operator's call. const HEAP_PER_WORKER_MB = 640; +// Apple-GPU disk capture measured ~15-25 MB parent heap per worker; 14 cores kept gaining to 8. +const APPLE_DISK_HEAP_PER_WORKER_MB = 128; +const APPLE_DISK_CORES_PER_WORKER = 1.75; +const APPLE_DISK_MAX_WORKERS = 8; const MIN_WORKERS = 1; const MAX_WORKER_DIAGNOSTIC_LINES = 8; // Hard ceiling on explicit `--workers N` requests. Above this, the cost of @@ -321,9 +327,13 @@ export function computeWorkerSizing( // is immediately reclaimable. const totalMemoryMb = getSystemTotalMb(); const heapLimitMb = Math.round(getHeapStatistics().heap_size_limit / (1024 * 1024)); + const appleDisk = config?.appleGpuDiskCapture === true; const heapBasedWorkers = Math.max( 1, - Math.floor((heapLimitMb - HEAP_RESERVED_MB) / HEAP_PER_WORKER_MB), + Math.floor( + (heapLimitMb - HEAP_RESERVED_MB) / + (appleDisk ? APPLE_DISK_HEAP_PER_WORKER_MB : HEAP_PER_WORKER_MB), + ), ); const cpuBasedWorkers = Math.max(1, cpuCount - 2); const memoryBasedWorkers = Math.max(1, Math.floor((totalMemoryMb * 0.5) / MEMORY_PER_WORKER_MB)); @@ -362,9 +372,15 @@ export function computeWorkerSizing( if (concurrency !== "auto") { return Math.max(MIN_WORKERS, Math.min(ABSOLUTE_MAX_WORKERS, Math.floor(concurrency))); } - return defaultSafeMaxWorkers(); + return appleDisk + ? Math.max(defaultSafeMaxWorkers(), APPLE_DISK_MAX_WORKERS) + : defaultSafeMaxWorkers(); })(); - const effectiveCoresPerWorker = config?.coresPerWorker ?? DEFAULT_CONFIG.coresPerWorker; + const configuredCoresPerWorker = config?.coresPerWorker ?? DEFAULT_CONFIG.coresPerWorker; + const effectiveCoresPerWorker = + appleDisk && configuredCoresPerWorker === DEFAULT_CONFIG.coresPerWorker + ? APPLE_DISK_CORES_PER_WORKER + : configuredCoresPerWorker; const effectiveMinParallelFrames = config?.minParallelFrames ?? DEFAULT_CONFIG.minParallelFrames; const effectiveLargeRenderThreshold = config?.largeRenderThreshold ?? DEFAULT_CONFIG.largeRenderThreshold; diff --git a/packages/producer/src/services/render/captureCost.ts b/packages/producer/src/services/render/captureCost.ts index b162484095..3a89b4d589 100644 --- a/packages/producer/src/services/render/captureCost.ts +++ b/packages/producer/src/services/render/captureCost.ts @@ -17,6 +17,7 @@ import { type CaptureSession, type EngineConfig, type WorkerSizing, + type WorkerSizingConfig, calculateOptimalWorkers, captureFrameToBuffer, closeCaptureSession, @@ -138,7 +139,7 @@ export function buildHeapAdvisoryWarning( export function resolveRenderWorkerCount( totalFrames: number, requestedWorkers: number | undefined, - cfg: EngineConfig, + cfg: EngineConfig & Pick, compiled: Pick, log: ProducerLogger = defaultLogger, measuredCaptureCost?: CaptureCostEstimate, diff --git a/packages/producer/src/services/renderOrchestrator.test.ts b/packages/producer/src/services/renderOrchestrator.test.ts index d8e6a36b1c..8f28f4653b 100644 --- a/packages/producer/src/services/renderOrchestrator.test.ts +++ b/packages/producer/src/services/renderOrchestrator.test.ts @@ -64,6 +64,7 @@ import { SEGMENTED_MIN_SECONDS_AFTER_SOAK, resolveParallelCaptureMode, shouldStreamParallelCapture, + isAppleGpuDiskCapture, shouldUseStreamingEncode, resolveObservedCaptureMode, createCaptureObservabilityUpdater, @@ -3665,6 +3666,23 @@ describe("calibrationCaptureConfig", () => { }); }); +describe("isAppleGpuDiskCapture", () => { + const disk = { + platform: "darwin", + browserGpuMode: "hardware", + layered: false, + parallelStream: false, + } as const; + + it("holds only for hardware-GPU Mac renders that write frames to disk", () => { + expect(isAppleGpuDiskCapture(disk)).toBe(true); + expect(isAppleGpuDiskCapture({ ...disk, layered: true })).toBe(false); + expect(isAppleGpuDiskCapture({ ...disk, parallelStream: true })).toBe(false); + expect(isAppleGpuDiskCapture({ ...disk, browserGpuMode: "software" })).toBe(false); + expect(isAppleGpuDiskCapture({ ...disk, platform: "win32" })).toBe(false); + }); +}); + describe("shouldClampDefaultDrawElement (default-on drawElement clamp)", () => { const unverifiedParallel = { useDrawElement: true, diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index c3c7a380f7..6c0dbe44c4 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -2797,6 +2797,21 @@ export function shouldSegmentCapture(args: { return args.durationSeconds >= resolveSegmentedMinSeconds(args.env); } +/** Worker sizing's Apple-GPU budget fits only frames written to disk by screenshot workers. */ +export function isAppleGpuDiskCapture(args: { + platform: NodeJS.Platform; + browserGpuMode: "software" | "hardware"; + layered: boolean; + parallelStream: boolean; +}): boolean { + return ( + args.platform === "darwin" && + args.browserGpuMode === "hardware" && + !args.layered && + !args.parallelStream + ); +} + export function shouldStreamParallelCapture(args: { /** Router switch for this render — see isCaptureParallelStreamRouterEnabled. */ routerEnabled: boolean; @@ -4037,10 +4052,28 @@ async function executeRenderPipeline(input: { // Low-memory safe-mode's single-worker pin lives inside // resolveRenderWorkerCount so its "why workers=N" logging stays coherent. + const appleGpuDiskCapture = isAppleGpuDiskCapture({ + platform: process.platform, + browserGpuMode: resolvedBrowserGpuMode, + layered: shouldUseLayeredComposite({ + hasHdrContent, + hasShaderTransitions: compiled.hasShaderTransitions && !isGif, + isPngSequence, + }), + parallelStream: isCaptureParallelStreamRouterEnabled( + process.env, + resolveParallelCaptureMode({ + platform: process.platform, + headlessShellPath: resolveHeadlessShellPath(cfg), + forceScreenshot: captureForceScreenshot, + deviceScaleFactor: captureOptions.deviceScaleFactor, + }), + ), + }); workerCount = resolveRenderWorkerCount( totalFrames, job.config.workers, - cfg, + { ...cfg, appleGpuDiskCapture }, compiled, log, captureCalibration?.estimate,