From dda07d797265feba4f645f7b088bf7828c4e1100 Mon Sep 17 00:00:00 2001 From: Ruben Nogueira <40404708+rubnogueira@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:18:18 +0100 Subject: [PATCH 1/2] perf(native): shrink Bun's footprint when the idle collector runs A full collection alone leaves the freed pages and the caches the engine rebuilds on demand where they were, about 45 MB on a long chat thread. After the collection the default collector now calls `Bun.shrink()`, which hands them back. Node's `--expose-gc` path is unchanged. --- .changeset/collect-garbage-when-idle.md | 2 +- README.md | 8 ++++--- packages/native/js/__tests__/idle-gc.test.ts | 24 ++++++++++++++++++++ packages/native/js/idle-gc.ts | 17 +++++++++++--- 4 files changed, 44 insertions(+), 7 deletions(-) diff --git a/.changeset/collect-garbage-when-idle.md b/.changeset/collect-garbage-when-idle.md index af8758df..444165e2 100644 --- a/.changeset/collect-garbage-when-idle.md +++ b/.changeset/collect-garbage-when-idle.md @@ -3,4 +3,4 @@ '@gpuix/react': patch --- -`collectGarbageWhenIdle()` runs one full garbage collection once the app has stopped committing for a while, so the heap of a long session comes back down after a burst of work. It watches the new `appliedBatchCount()` and the process's CPU, and returns a function that stops it. +`collectGarbageWhenIdle()` runs one full garbage collection (and, under Bun, `Bun.shrink()` to release the freed pages and rebuildable engine caches) once the app has stopped committing for a while, so the heap of a long session comes back down after a burst of work. It watches the new `appliedBatchCount()` and the process's CPU, and returns a function that stops it. diff --git a/README.md b/README.md index f1577fd5..01feb1b9 100644 --- a/README.md +++ b/README.md @@ -1848,9 +1848,11 @@ import { collectGarbageWhenIdle } from '@gpuix/react' const stop = collectGarbageWhenIdle() // checks every 5s, collects after two quiet checks ``` -It uses `Bun.gc(true)`, or `gc()` where Node runs with `--expose-gc`, and does -nothing in a runtime that has neither. Quiet means no mutation batch reached a -renderer and the process is under half a core. The batch count decides, not CPU +It uses `Bun.gc(true)` followed by `Bun.shrink()`, which also hands the freed pages +and rebuildable engine caches back (about 45 MB on a long chat thread), or `gc()` +where Node runs with `--expose-gc`, and does nothing in a runtime that has neither. +Quiet means no mutation batch reached a renderer and the process is under half a +core. The batch count decides, not CPU alone, because a focused text field blinks its caret in native code and redraws twice a second. Pass `activity`, `collect`, `checkMs`, `quietChecks` or `busyCpu` to change what it watches or how often. diff --git a/packages/native/js/__tests__/idle-gc.test.ts b/packages/native/js/__tests__/idle-gc.test.ts index 5b80d538..5b02e5d3 100644 --- a/packages/native/js/__tests__/idle-gc.test.ts +++ b/packages/native/js/__tests__/idle-gc.test.ts @@ -56,6 +56,30 @@ describe("collectGarbageWhenIdle", () => { expect(collect).not.toHaveBeenCalled() }) + it("collects under Bun with a full collection and then a shrink, which releases the freed pages", () => { + const calls: string[] = [] + vi.stubGlobal("Bun", { gc: (force: boolean) => calls.push(`gc:${force}`), shrink: () => calls.push("shrink") }) + try { + collectGarbageWhenIdle({ activity: () => 0, checkMs: 1000, quietChecks: 1 }) + vi.advanceTimersByTime(1000) + expect(calls).toEqual(["gc:true", "shrink"]) + } finally { + vi.unstubAllGlobals() + } + }) + + it("still collects under a Bun that has no shrink", () => { + const calls: string[] = [] + vi.stubGlobal("Bun", { gc: (force: boolean) => calls.push(`gc:${force}`) }) + try { + collectGarbageWhenIdle({ activity: () => 0, checkMs: 1000, quietChecks: 1 }) + vi.advanceTimersByTime(1000) + expect(calls).toEqual(["gc:true"]) + } finally { + vi.unstubAllGlobals() + } + }) + it("counts applied mutation batches by default", () => { const renderer = { applyBatch: () => [] as number[] } const queue = createMutationQueue(renderer) diff --git a/packages/native/js/idle-gc.ts b/packages/native/js/idle-gc.ts index 2fcde38b..4805ec9d 100644 --- a/packages/native/js/idle-gc.ts +++ b/packages/native/js/idle-gc.ts @@ -7,7 +7,10 @@ export interface IdleGcOptions { * moves on every commit, scroll-driven render and typed character. */ activity?: () => number - /** Runs the collection. Defaults to `Bun.gc(true)`, then `globalThis.gc` (Node's `--expose-gc`). */ + /** + * Runs the collection. Defaults to `Bun.gc(true)` followed by `Bun.shrink()`, or else + * `globalThis.gc` (Node's `--expose-gc`). + */ collect?: () => void /** How often to look, in ms. Default 5000. */ checkMs?: number @@ -28,10 +31,18 @@ function cpuMs(): number { function defaultCollector(): Collector | undefined { const runtime = globalThis as unknown as { - Bun?: { gc(force: boolean): void } + Bun?: { gc(force: boolean): void; shrink?(): void } gc?: () => void } - if (runtime.Bun) return () => runtime.Bun!.gc(true) + if (runtime.Bun) { + const bun = runtime.Bun + // A collection alone leaves the freed pages and the caches the engine rebuilds on demand + // in place; shrinking releases them (about 45 MB on a long chat thread). + return () => { + bun.gc(true) + bun.shrink?.() + } + } if (runtime.gc) return () => runtime.gc!() return undefined } From 1c55a3fcb14b936e23781e28eba4e935c525c1b2 Mon Sep 17 00:00:00 2001 From: Ruben Nogueira <40404708+rubnogueira@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:18:20 +0100 Subject: [PATCH 2/2] perf(native): pin zed to the lazily resident profiler journal The 4 MiB foreground profiler journal ring is allocated zeroed, so it is resident only as far as entries are recorded. Nothing outside the host's hang detection reads it, so an app that does not use that holds about 4 MB less. --- .changeset/profiler-journal-lazy-residency.md | 5 +++++ zed | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/profiler-journal-lazy-residency.md diff --git a/.changeset/profiler-journal-lazy-residency.md b/.changeset/profiler-journal-lazy-residency.md new file mode 100644 index 00000000..03ff0958 --- /dev/null +++ b/.changeset/profiler-journal-lazy-residency.md @@ -0,0 +1,5 @@ +--- +'@gpuix/native': patch +--- + +The foreground profiler journal's 4 MiB ring is allocated zeroed, so it is resident only as far as entries are recorded rather than written up front when the app starts. Nothing reads the journal outside the host's hang detection, so an app that does not use that holds about 4 MB less. diff --git a/zed b/zed index c2e9f882..74bdb018 160000 --- a/zed +++ b/zed @@ -1 +1 @@ -Subproject commit c2e9f882030ca95cee55d206512a0a3ee6cd72b0 +Subproject commit 74bdb0185063002bacb40585b293c8ae8525ef32