From d22b2d0c570e557732ea157b8eca8dfb91aa9588 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 16 Jun 2026 08:35:20 +0000 Subject: [PATCH 1/4] Bun.Transpiler: don't mi_heap_new() per instance The JSTranspiler constructor was creating a dedicated MimallocArena (mi_heap_new()) per instance and passing it to the inner Transpiler. The reference implementation passes bun.default_allocator instead and only uses a thin bump arena for config parsing. Use Arena::borrowing_default() (wraps mi_heap_main()) so the inner transpiler allocates from the process-global heap, matching the VM transpiler in jsc_hooks.rs. Every parse operation already swaps in a fresh per-call Arena::new() via TranspilerStateGuard, so the stored arena is only the resting-state handle plus configure_defines' bump. 5000 new Bun.Transpiler({loader:'ts'}) instances in release: before: 232 MB retained, 5000 live mi_heaps after: 205 MB retained, 0 new mi_heaps --- src/runtime/api/JSTranspiler.rs | 12 +++++- .../transpiler/transpiler-arena-heap.test.ts | 38 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 test/js/bun/transpiler/transpiler-arena-heap.test.ts diff --git a/src/runtime/api/JSTranspiler.rs b/src/runtime/api/JSTranspiler.rs index f1de7db502af..fe8a0493eacf 100644 --- a/src/runtime/api/JSTranspiler.rs +++ b/src/runtime/api/JSTranspiler.rs @@ -5,7 +5,7 @@ use bun_options_types::TargetExt as _; use std::io::Write as _; use crate::node::{Encoding, StringOrBuffer}; -use bun_alloc::{Arena, ArenaVec}; // bumpalo::Bump / bumpalo::collections::Vec re-exports +use bun_alloc::{Arena, ArenaVec}; use bun_ast::Expr; use bun_ast::Loader; use bun_ast::{ImportRecord, ImportRecordFlags}; @@ -987,7 +987,15 @@ impl JSTranspiler { log: bun_ast::Log::init(), ..Default::default() }; - let arena = Box::new(Arena::new()); + // `borrowing_default()` wraps `mi_heap_main()` so the inner `Transpiler` + // allocates from the process-global heap, matching + // `Transpiler.Transpiler.init(bun.default_allocator, ...)`. Every parse + // (`scan` / `transformSync` / `scanImports` / async `transform`) swaps in + // a fresh per-call `Arena::new()` via `TranspilerStateGuard`, so this + // arena is only the resting-state handle and `configure_defines`' bump. + // An owned `mi_heap_new()` here would pin a dedicated mimalloc heap per + // `new Bun.Transpiler()` for the instance's lifetime. + let arena = Box::new(Arena::borrowing_default()); // SAFETY: `arena` is heap-allocated and moved (as a Box) into `Box` below; // its address is stable for the lifetime of the JSTranspiler. `Transpiler<'static>` forces // the borrow to 'static, so launder through a raw ptr. diff --git a/test/js/bun/transpiler/transpiler-arena-heap.test.ts b/test/js/bun/transpiler/transpiler-arena-heap.test.ts new file mode 100644 index 000000000000..34ddf9c8ee1d --- /dev/null +++ b/test/js/bun/transpiler/transpiler-arena-heap.test.ts @@ -0,0 +1,38 @@ +import { heapStats } from "bun:jsc"; +import { expect, test } from "bun:test"; + +// The inner `Transpiler` is constructed with the process-global allocator +// (`bun.default_allocator` in the reference implementation). Constructing a +// `Bun.Transpiler` must not `mi_heap_new()` a dedicated mimalloc heap per +// instance; each owned heap pins tens of KB of segment metadata for the +// object's lifetime. +test("new Bun.Transpiler() does not create a per-instance mimalloc heap", () => { + // Warm up: first construction may lazily initialize shared state. + new Bun.Transpiler({ loader: "ts" }); + Bun.gc(true); + + const before = heapStats({ dump: true }).mimallocDump.heaps.length; + + const instances: Bun.Transpiler[] = []; + const N = 64; + for (let i = 0; i < N; i++) { + instances.push(new Bun.Transpiler({ loader: "ts" })); + } + + const after = heapStats({ dump: true }).mimallocDump.heaps.length; + const delta = after - before; + + // Allow a small slack for unrelated lazy heap creation, but nowhere near + // one heap per instance. + expect({ before, after, delta, instances: instances.length }).toEqual({ + before: expect.any(Number), + after: expect.any(Number), + delta: expect.any(Number), + instances: N, + }); + expect(delta).toBeLessThan(N / 4); + + // Keep `instances` live across the measurement and verify they still work. + expect(instances[0].transformSync("export const x: number = 1;")).toContain("const x = 1"); + expect(instances[N - 1].transformSync("export const y: number = 2;")).toContain("const y = 2"); +}); From db1d93fabbd0ebd553da159425173142c9df3b7b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 16 Jun 2026 09:18:50 +0000 Subject: [PATCH 2/4] own exports.replace string backing storage on Config With the resting-state arena now wrapping mi_heap_main(), its Drop is a no-op, so arena.alloc_slice_copy() for exports.replace string values would strand those bytes for the process lifetime. The reference implementation frees them via the per-instance bump arena on finalize. Store each string value as a Box<[u8]> in Config.replace_exports_bufs instead and borrow from there; they drop with the JSTranspiler. Drops the arena parameter from Config::from_js since nothing else used it, and updates the stale arena field comment. --- src/runtime/api/JSTranspiler.rs | 53 ++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/src/runtime/api/JSTranspiler.rs b/src/runtime/api/JSTranspiler.rs index fe8a0493eacf..98ae631a969c 100644 --- a/src/runtime/api/JSTranspiler.rs +++ b/src/runtime/api/JSTranspiler.rs @@ -47,9 +47,14 @@ pub struct JSTranspiler { pub scan_pass_result: JsCell, pub buffer_writer: JsCell>, pub log_level: bun_ast::Level, - // Arena bulk-frees the config strings. Boxed so its - // address is stable across the move into `Box` — - // `transpiler.arena` holds a `&'static Arena` pointing into it. + // Resting-state arena handle for the inner `Transpiler` between host-fn + // calls (every parse swaps in a fresh per-call `Arena::new()` via + // `TranspilerStateGuard`). Wraps `mi_heap_main()` via + // `Arena::borrowing_default()` so no per-instance mi_heap is created; + // Drop is a no-op. Boxed so its address is stable across the move into + // `Box` since `transpiler.arena` holds a `&'static Arena` + // pointing into it. Config strings (`exports.replace`) are owned by + // `Config.replace_exports_bufs` instead so they drop with the instance. pub arena: Box, // Intrusive refcount field for `bun_ptr::IntrusiveRc`: // single-thread intrusive `bun.ptr.RefCount` because `*JSTranspiler` @@ -74,6 +79,12 @@ pub struct Config { pub macros_buf: Box<[u8]>, pub log: bun_ast::Log, pub runtime: Runtime::Features, + // Owned backing storage for `runtime.replace_exports` string-valued + // entries (`E::EString.data` borrows a `Box` in this vec via an erased + // `'static`). Declared after `runtime` so the borrowing `Expr`s drop + // first. Takes the place of the per-instance bump arena the reference + // implementation uses for `config.fromJS`. + pub replace_exports_bufs: Vec>, pub tree_shaking: bool, pub trim_unused_imports: Option, pub inlining: bool, @@ -100,6 +111,7 @@ impl Default for Config { top_level_await: true, ..Default::default() }, + replace_exports_bufs: Vec::new(), tree_shaking: false, trim_unused_imports: None, inlining: false, @@ -159,12 +171,7 @@ const PROP_ITER_OPTS: JSPropertyIteratorOptions = JSPropertyIteratorOptions { impl Config { // NOTE: out-param constructor kept as `&mut self` because `self` is a pre-initialized // field on `JSTranspiler` (in-place mutation), not a fresh value to return. - pub fn from_js( - &mut self, - global: &JSGlobalObject, - object: JSValue, - arena: &Arena, - ) -> JsResult<()> { + pub fn from_js(&mut self, global: &JSGlobalObject, object: JSValue) -> JsResult<()> { if object.is_undefined_or_null() { return Ok(()); } @@ -584,7 +591,9 @@ impl Config { // `V: Default` upstream and `ReplaceableExport` has no Default. Compute // the value first, then `put` (which upserts without needing a default // slot). - if let Some(expr) = export_replacement_value(value, global, arena)? { + if let Some(expr) = + export_replacement_value(value, global, &mut self.replace_exports_bufs)? + { replacements .put(&key, bun_ast::runtime::ReplaceableExport::Replace(expr)) .map_err(|_| bun_jsc::JsError::OutOfMemory)?; @@ -593,9 +602,11 @@ impl Config { if value.is_object() && value.get_length(global)? == 2 { let replacement_value = value.get_index(global, 1)?; - if let Some(to_replace) = - export_replacement_value(replacement_value, global, arena)? - { + if let Some(to_replace) = export_replacement_value( + replacement_value, + global, + &mut self.replace_exports_bufs, + )? { let replacement_key = value.get_index(global, 0)?; let slice = OwnedString::new(replacement_key.to_bun_string(global)?); @@ -911,7 +922,7 @@ impl<'a> Drop for TransformTask<'a> { fn export_replacement_value( value: JSValue, global: &JSGlobalObject, - arena: &Arena, + bufs: &mut Vec>, ) -> JsResult> { if value.is_boolean() { return Ok(Some(Expr { @@ -949,11 +960,13 @@ fn export_replacement_value( let zig_str = value.get_zig_string(global)?; let mut buf = Vec::new(); write!(&mut buf, "{}", zig_str).expect("unreachable"); - // Bump-allocate so the bytes - // live as long as the JSTranspiler arena that owns the resulting Expr; - // `E::EString::init` erases the borrow to `'static` per the AST - // crate's `Str` convention (see ast/E.rs). - let data = arena.alloc_slice_copy(&buf); + // Stash the bytes in a `Box` owned by `Config` so they are freed when + // the `JSTranspiler` drops. `E::EString::init` erases the borrow to + // `'static` per the AST crate's `Str` convention (see ast/E.rs); the + // `Box` heap allocation is address-stable across moves of `Config` + // and across reallocation of the outer `Vec`. + bufs.push(buf.into_boxed_slice()); + let data: &[u8] = bufs.last().expect("just pushed"); return Ok(Some(Expr::init( bun_ast::E::EString::init(data), bun_ast::Loc::EMPTY, @@ -1011,7 +1024,7 @@ impl JSTranspiler { } else { JSValue::UNDEFINED }; - config.from_js(global, config_arg, arena_ref)?; + config.from_js(global, config_arg)?; if global.has_exception() { return Err(bun_jsc::JsError::Thrown); From 4eaecf8983f64bb9ae348df560865fc50541534c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 16 Jun 2026 09:52:21 +0000 Subject: [PATCH 3/4] review: document configure_defines trade-off; drop vacuous toEqual The configure_defines bump is intentionally routed through the borrowing_default arena to match the reference (Transpiler.Transpiler.init(bun.default_allocator, ...)) and the VM transpiler in jsc_hooks.rs. Auto-injected defaults hit the const_default_define_value fast path and never touch the arena. Drop the toEqual block whose sub-checks are tautological; the toBeLessThan assertion carries the delta in its failure output. --- src/runtime/api/JSTranspiler.rs | 17 +++++++++++------ .../transpiler/transpiler-arena-heap.test.ts | 9 +-------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/runtime/api/JSTranspiler.rs b/src/runtime/api/JSTranspiler.rs index 98ae631a969c..7c612f31905e 100644 --- a/src/runtime/api/JSTranspiler.rs +++ b/src/runtime/api/JSTranspiler.rs @@ -1002,12 +1002,17 @@ impl JSTranspiler { }; // `borrowing_default()` wraps `mi_heap_main()` so the inner `Transpiler` // allocates from the process-global heap, matching - // `Transpiler.Transpiler.init(bun.default_allocator, ...)`. Every parse - // (`scan` / `transformSync` / `scanImports` / async `transform`) swaps in - // a fresh per-call `Arena::new()` via `TranspilerStateGuard`, so this - // arena is only the resting-state handle and `configure_defines`' bump. - // An owned `mi_heap_new()` here would pin a dedicated mimalloc heap per - // `new Bun.Transpiler()` for the instance's lifetime. + // `Transpiler.Transpiler.init(bun.default_allocator, ...)` and the VM + // transpiler in `jsc_hooks.rs`. Every parse (`scan` / `transformSync` / + // `scanImports` / async `transform`) swaps in a fresh per-call + // `Arena::new()` via `TranspilerStateGuard`, so this arena is only the + // resting-state handle. `configure_defines` bump-allocates user-supplied + // `define:` values here (defines.rs slow path), which then live for the + // process lifetime; that is the reference behavior (Zig routes + // `configureDefines` through `bun.default_allocator` and never frees the + // inner transpiler) and the auto-injected defaults hit the fast path + // that never touches this arena. An owned `mi_heap_new()` here would + // instead pin a dedicated mimalloc heap per `new Bun.Transpiler()`. let arena = Box::new(Arena::borrowing_default()); // SAFETY: `arena` is heap-allocated and moved (as a Box) into `Box` below; // its address is stable for the lifetime of the JSTranspiler. `Transpiler<'static>` forces diff --git a/test/js/bun/transpiler/transpiler-arena-heap.test.ts b/test/js/bun/transpiler/transpiler-arena-heap.test.ts index 34ddf9c8ee1d..f8f3eb6ed877 100644 --- a/test/js/bun/transpiler/transpiler-arena-heap.test.ts +++ b/test/js/bun/transpiler/transpiler-arena-heap.test.ts @@ -20,17 +20,10 @@ test("new Bun.Transpiler() does not create a per-instance mimalloc heap", () => } const after = heapStats({ dump: true }).mimallocDump.heaps.length; - const delta = after - before; // Allow a small slack for unrelated lazy heap creation, but nowhere near // one heap per instance. - expect({ before, after, delta, instances: instances.length }).toEqual({ - before: expect.any(Number), - after: expect.any(Number), - delta: expect.any(Number), - instances: N, - }); - expect(delta).toBeLessThan(N / 4); + expect(after - before).toBeLessThan(N / 4); // Keep `instances` live across the measurement and verify they still work. expect(instances[0].transformSync("export const x: number = 1;")).toContain("const x = 1"); From f08a2d32069f504fd327c5c0f0616869b456ce8e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:41:31 +0000 Subject: [PATCH 4/4] ci: retrigger