Skip to content
8 changes: 7 additions & 1 deletion scripts/build/deps/mimalloc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import type { Dependency, DirectBuild } from "../source.ts";

const MIMALLOC_COMMIT = "6a64e1ba7f5b2130d4efccb67ec87fd0003f0f6a";
const MIMALLOC_COMMIT = "ab13501334a8da2b64ab2e5f4f552c3a39b96350";

export const mimalloc: Dependency = {
name: "mimalloc",
Expand Down Expand Up @@ -55,6 +55,12 @@ export const mimalloc: Dependency = {
// free(). MI_SKIP_COLLECT_ON_EXIT only skips the heap walk inside it.
MI_NO_PROCESS_DETACH: 1,

// mi_free finds a block's page through the page map. Without this, page
// meta data sits at 256 MiB boundaries, every arena must start on one, and
// mi_manage_os_memory_ex refuses JSC's structure heap once its reservation
// is 256 MiB or less (JSC halves it under `ulimit -v`): abort on startup.
MI_FREE_USE_PAGEMAP: 1,
Comment thread
robobun marked this conversation as resolved.

...(cfg.release && { MI_BUILD_RELEASE: true }),
};

Expand Down
45 changes: 45 additions & 0 deletions test/js/bun/gc/gc-controller-cadence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,3 +288,48 @@ test.skipIf(!isLinux || isASAN)("Bun.gc(true) returns what it freed to the OS be
expect(JSON.parse(stdout).released).toBeGreaterThan(200);
expect(exitCode).toBe(0);
});

// One thread at a time hands the allocator's free ranges back, and its own purge thread is often the one: it starts on what
// was freed once the purge delay has passed, and a few hundred MB keep it busy for tens of milliseconds. Bun.gc(true) in the
// middle of that found the purge taken, skipped its own, and returned with all of it still resident.
test.skipIf(!isLinux || isASAN)(
"Bun.gc(true) returns what is free to the OS while the purge thread is at work",
async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const rss = () => process.memoryUsage.rss() / 1048576;
// The allocator starts its purge thread the first time a thread blocks.
await Bun.sleep(1);
const rounds = [];
for (let round = 0; round < 3; round++) {
const arrays = [];
for (let i = 0; i < 48; i++) arrays.push(new Uint8Array(8 * 1024 * 1024).fill(1));
const held = rss();
// transfer(0) frees the 8 MB here and now, no collection involved. They stay resident until they are purged.
for (const array of arrays) array.buffer.transfer(0);
// Wait for the purge thread to start on them, which it does once the purge delay (100 ms) has passed. A round in
// which it was not seen at work says nothing about the two at once, so it does not count as passed.
const deadline = performance.now() + 1000;
let started = false;
while (!(started = rss() <= held - 32) && performance.now() < deadline);
Bun.gc(true);
rounds.push({ held, started, released: held - rss() });
}
console.log(JSON.stringify(rounds));
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "inherit",
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
for (const { started, released } of JSON.parse(stdout)) {
expect(started, stdout).toBe(true);
expect(released, stdout).toBeGreaterThan(300);
}
expect(exitCode).toBe(0);
},
);
68 changes: 67 additions & 1 deletion test/js/bun/jsc/heapStats-mimalloc.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { heapStats } from "bun:jsc";
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, isMacOS } from "harness";
import { bunEnv, bunExe, isASAN, isLinux, isMacOS } from "harness";

describe("heapStats() mimalloc integration", () => {
test("mimalloc aggregate stats are present", () => {
Expand Down Expand Up @@ -110,4 +110,70 @@ describe("heapStats() mimalloc integration", () => {
expect(appTag).toBeGreaterThan(64);
expect(exitCode).toBe(0);
});

// JSC hands its structure heap to mimalloc as an arena of its own (`mi_manage_os_memory_ex`), and it halves that
// reservation when address space is short (`ulimit -v`). mimalloc has to take a small one as well: when it refused
// 256 MiB and less (page meta data at 256 MiB boundaries, without MI_FREE_USE_PAGEMAP), bun aborted on startup.
test("starts with a small structure heap reservation", async () => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", "console.log(typeof {})"],
env: { ...bunEnv, BUN_JSC_structureHeapSizeInKB: "131072" },
stdout: "pipe",
stderr: "inherit",
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
expect(stdout).toBe("object\n");
expect(exitCode).toBe(0);
});

// The allocator's purge thread takes back what was freed 100 ms after the free. What a thread freed while the purge thread
// was in the middle of a pass was left out for good: it stayed resident until the event loop went idle or something forced
// a collection. A script that keeps its thread busy does neither. It took a pass in which each of the allocator's arenas
// had something to hand back. JSC's structure heap is an arena of its own that rarely has, so Malloc=1 here: JSC then
// allocates through malloc (mimalloc as well) and there is one arena. Linux only: the wait reads RSS. Not ASAN: malloc is
// not mimalloc there.
test.skipIf(!isLinux || isASAN)(
"memory freed while the purge thread is at work is purged without an idle event loop",
async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
import { heapStats } from "bun:jsc";
const rss = () => process.memoryUsage.rss() / 1048576;
// the bytes the allocator handed back to the OS so far
const purged = () => heapStats().mimalloc.purged / 1048576;
// The allocator starts its purge thread the first time a thread blocks.
await Bun.sleep(1);
const first = [], second = [];
for (let i = 0; i < 32; i++) first.push(new Uint8Array(8 * 1024 * 1024).fill(1));
for (let i = 0; i < 16; i++) second.push(new Uint8Array(8 * 1024 * 1024).fill(1));
const held = rss(), purgedBefore = purged();
// transfer(0) frees the 8 MB here and now, no collection involved. No await from here on.
for (const array of first) array.buffer.transfer(0);
// Spin until the purge thread is in the middle of its pass over them (heapStats() would run that pass itself).
let deadline = performance.now() + 1000;
while (rss() > held - 64 && performance.now() < deadline);
for (const array of second) array.buffer.transfer(0);
// The next pass comes 100 ms later. What RSS fell by is taken before heapStats() runs again: that call polls the
// allocator, and a poll runs a pass that is due by itself.
deadline = performance.now() + 2000;
let released;
while ((released = held - rss()) < 336 && performance.now() < deadline);
console.log(JSON.stringify({ released, purged: purged() - purgedBefore }));
`,
],
env: { ...bunEnv, Malloc: "1" },
stdout: "pipe",
stderr: "inherit",
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
// 384 MB were freed. The first pass alone takes the first 256 MB, and up to 32 MB of the rest.
const { released, purged } = JSON.parse(stdout);
expect(released, stdout).toBeGreaterThanOrEqual(336);
expect(purged, stdout).toBeGreaterThanOrEqual(336);
expect(exitCode).toBe(0);
},
);
});
Loading