From 4caaa27411d5d8ad55a3d067554a2ef5e291feb1 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 28 Sep 2026 13:24:17 +0800 Subject: [PATCH] mem: Set M_MMAP_THRESHOLD to 128K on linux builds Following some experimentation, this sets M_MMAP_THRESHOLD to 128K on linux builds IF it isn't explicit set, e.g. via the `MALLOC_MMAP_THRESHOLD_` env. This allows users explicitly set it if they want. I've been looking at this for a while, but my understanding is still basic. The simplistic explanation is that glibc's allocator internally uses arenas. Allocations above THRESHOLD skip these arenas and are allocated directly from and freed directly to the OS. Allocations under this threshold are released to the arena where they can be re-used. Everything works OK, EXCEPT for when it comes time to release the memory from these arenas back to the OS. If free arena memory is pinned under live memory, it cannot be released. Various online posts / comments reference glibc as suffering from significant fragmentation and "holding" onto memory. That's the behavior that we've seen. Further, calls to malloc_trim reclaim the lost memory, so what we're seeing aren't leaks. --- src/lightpanda.zig | 1 + src/main.zig | 1 + src/malloc_tuning.zig | 83 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+) create mode 100644 src/malloc_tuning.zig diff --git a/src/lightpanda.zig b/src/lightpanda.zig index e51039aff..e2e58524a 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -27,6 +27,7 @@ pub const Config = @import("Config.zig"); pub const cookies = @import("cookies.zig"); pub const datetime = @import("datetime.zig"); pub const core_dump = @import("core_dump.zig"); +pub const malloc_tuning = @import("malloc_tuning.zig"); pub const ArenaPool = @import("ArenaPool.zig"); pub const build_config = @import("build_config"); pub const String = @import("string.zig").String; diff --git a/src/main.zig b/src/main.zig index a669571de..d5e5a7fba 100644 --- a/src/main.zig +++ b/src/main.zig @@ -61,6 +61,7 @@ pub fn main(init: std.process.Init) !void { fn run(allocator: Allocator, main_arena: Allocator, proc_args: std.process.Args) !void { lp.core_dump.disableIfRequested(); + lp.malloc_tuning.apply(); lp.crash_handler.attachSignalHandlers(); const args = Config.parseArgs(main_arena, proc_args) catch |err| switch (err) { diff --git a/src/malloc_tuning.zig b/src/malloc_tuning.zig new file mode 100644 index 000000000..8dffa6622 --- /dev/null +++ b/src/malloc_tuning.zig @@ -0,0 +1,83 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! glibc malloc tuning applied at startup. +//! +//! glibc raises its mmap threshold dynamically (up to 32MB) as large blocks +//! are freed, so the MB-sized bursts a page can produce (response bodies, +//! script sources) end up on the brk heap. Once freed, they stay stranded +//! under live chunks and the process holds that memory until it exits. +//! Pinning the threshold at 128KB keeps those blocks mmapped, and freeing them +//! returns them to the OS. +//! +//! glibc's own `MALLOC_MMAP_THRESHOLD_` / `GLIBC_TUNABLES` remain the override: +//! when either sets the threshold, it is left alone. + +const std = @import("std"); +const builtin = @import("builtin"); +const lp = @import("lightpanda.zig"); + +const log = lp.log; + +const MMAP_THRESHOLD = 128 * 1024; + +// malloc.h +const M_MMAP_THRESHOLD: c_int = -3; +extern "c" fn mallopt(param: c_int, value: c_int) c_int; + +pub fn apply() void { + if (comptime (builtin.os.tag != .linux or builtin.abi.isGnu() == false)) { + return; + } + if (userConfigured()) { + return; + } + if (mallopt(M_MMAP_THRESHOLD, MMAP_THRESHOLD) == 0) { + log.warn(.app, "mallopt mmap_threshold failed", .{}); + } +} + +fn userConfigured() bool { + if (std.c.getenv("MALLOC_MMAP_THRESHOLD_") != null) { + return true; + } + const tunables = std.c.getenv("GLIBC_TUNABLES") orelse return false; + return std.mem.indexOf(u8, std.mem.span(tunables), "glibc.malloc.mmap_threshold") != null; +} + +const testing = @import("testing.zig"); +extern fn setenv(name: [*:0]u8, value: [*:0]u8, override: c_int) c_int; +extern fn unsetenv(name: [*:0]u8) c_int; + +test "malloc_tuning: glibc env overrides" { + _ = unsetenv(@constCast("MALLOC_MMAP_THRESHOLD_")); + _ = unsetenv(@constCast("GLIBC_TUNABLES")); + try testing.expectEqual(false, userConfigured()); + + _ = setenv(@constCast("GLIBC_TUNABLES"), @constCast("glibc.malloc.arena_max=2"), 1); + defer _ = unsetenv(@constCast("GLIBC_TUNABLES")); + try testing.expectEqual(false, userConfigured()); + + _ = setenv(@constCast("GLIBC_TUNABLES"), @constCast("glibc.malloc.arena_max=2:glibc.malloc.mmap_threshold=65536"), 1); + try testing.expectEqual(true, userConfigured()); + + _ = unsetenv(@constCast("GLIBC_TUNABLES")); + _ = setenv(@constCast("MALLOC_MMAP_THRESHOLD_"), @constCast("65536"), 1); + defer _ = unsetenv(@constCast("MALLOC_MMAP_THRESHOLD_")); + try testing.expectEqual(true, userConfigured()); +}