Skip to content

mem: HG_MALLOC - a lock-free, elastic memory allocator - #4327

Open
Lt-Flash wants to merge 4 commits into
OpenSIPS:masterfrom
Lt-Flash:feature/hg-malloc-v3-final
Open

Lt-Flash wants to merge 4 commits into
OpenSIPS:masterfrom
Lt-Flash:feature/hg-malloc-v3-final

Conversation

@Lt-Flash

@Lt-Flash Lt-Flash commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

HG_MALLOC is a new allocator for OpenSIPS shared and private memory. With today's allocators every shm_malloc() and shm_free() takes a lock - the one global shared-memory lock with F_MALLOC and Q_MALLOC, one of several with F_PARALLEL_MALLOC and HP_MALLOC - so at high call rates the workers queue behind each other. HG_MALLOC's common allocation and free take no lock at all. It is opt-in: nothing changes unless -a HG_MALLOC is given or the build sets PREFERRED_MALLOC=HG_MALLOC.

  • Less CPU: at 60,000 calls/s, 18% fewer cores than F_MALLOC on the same 4 KB pages, 24% fewer with huge pages; at the same relayed-call rate, 23% fewer than F_PARALLEL_MALLOC.
  • More calls per server: 40,000 relayed calls/s, against 28,000 for F_PARALLEL_MALLOC, 16,000 for F_MALLOC and 8,000 for HP_MALLOC, all on the same 4 KB pages.
  • Low latency: p99 under 10 ms at 60,000 calls/s on 4 KB pages, under 2 ms with huge pages, against 0.5-1 s for F_MALLOC and F_PARALLEL_MALLOC and over 1 s for HP_MALLOC.
  • Elastic memory (optional): -m 128:1024 -M 16:64 - the shared arena and every process's private arena start small, grow with the traffic and give memory back when it goes quiet; growth stops at a host free-memory floor and the memory cgroup's limit, not at the OOM killer.
  • Auto-scaling (optional): shm_auto_scaling_profile and pkg_auto_scaling_profile, in the grammar of the worker autoscaler's auto_scaling_profile, grow an arena before an allocation fails and shrink it after a quiet spell. -m/-M then only set the starting size: the profile's scale down to may take the arena below it and its scale up to above it, as far as the cap - -m 128:1024 with scale up to 1024 and scale down to 64 starts at 128 MB, grows to 1 GB under load and drops to 64 MB when quiet.
  • Runs on any host: memory comes from the best of four tiers the host offers - a hugetlb pool, transparent huge pages by advice or by collapse, or plain 4 KB pages - each verified from the kernel's own counters; x86 and ARM, 32 and 64 bits.
  • Safe: a double free, a damaged header or a corrupted free list is refused and counted, never followed; -a HG_MALLOC_DBG adds red zones and a quarantine for freed memory.
  • Observable: core:hg_stats, core:hg_advise (what -m/-M should be), hgmem: statistics and two events.
  • In production: since August 2026. Since 2026-11-05 all 49 OpenSIPS nodes of three sites (Australia, UK, US) run the HG_MALLOC code of this PR - 37 production nodes (SBCs, registrars, load balancers and gateways) and 12 test and staging nodes.

Details

Both tests ran on one bare-metal server (2 x Xeon E5-2690 v2) with 8 UDP workers, the default socket buffer and a fixed 64 GB shared arena on 4 KB pages for every allocator - only the allocator changes - and sipp on four VMs. Two 30 s runs per rate, the allocators taking turns; no run logged an error in OpenSIPS.

INVITEs answered by tm - sipp sends an INVITE, tm answers 486 Busy Here, sipp ACKs - at 60,000 calls/s; in brackets the difference from F_MALLOC:

allocator CPU, cores UDP drops p99
F_MALLOC 3.89 56,831 0.5-1 s
F_PARALLEL_MALLOC 3.60 (-7%) 33,041 0.5-1 s
HP_MALLOC 5.59 (+44%) 883,550 over 1 s
HG_MALLOC 3.17 (-18%) 19,581 under 10 ms
HG_MALLOC, huge pages 2.95 (-24%) 11,796 under 2 ms

HG_MALLOC on huge pages used a fixed 12 GB arena on the host's hugetlb pool. At 120,000 calls/s, the highest rate tried, HG_MALLOC still held on both page sizes (2 failed calls in 14.4 million); HP_MALLOC fails from 75,000, F_MALLOC from 90,000 and F_PARALLEL_MALLOC in one of two runs at 120,000.

Calls relayed by OpenSIPS as a record-routing proxy (sipp -> OpenSIPS -> sipp: INVITE, 180, 200, ACK, BYE, 200) - the highest rate at which at most 0.1% of the calls failed:

allocator calls/s cores at that rate
HP_MALLOC 8,000 2.20
F_MALLOC 16,000 3.97
F_PARALLEL_MALLOC 28,000 6.55
HG_MALLOC 40,000 6.88

The same relayed calls over TLS and over WSS, carried to the server by an OpenSIPS gateway on each sipp VM - the highest rate held in both runs, with no error in the OpenSIPS or gateway logs:

allocator TLS, calls/s WSS, calls/s
HP_MALLOC 8,000 8,000
F_MALLOC 8,000 8,000
F_PARALLEL_MALLOC 12,000 12,000
HG_MALLOC 14,000 12,000

Where both held, HG_MALLOC used 14-23% fewer cores than the other allocator, 30-38% less in its TCP workers. At 16,000 calls/s, held by neither, it failed 0.07-0.19% of the calls over TLS and 6.6-9.2% over WSS, F_PARALLEL_MALLOC 8.8-9.6% and 42.9-47.8%.

How each number was measured, and every run:

Solution

  • No lock on the common path: each process has its own free list per size class (21 classes, 64 B to 64 KB).
  • Batches between processes: cells move through a shared pool in batches of 32 to 128, so the arena lock is taken once per batch - usually a pointer swap - not once per call.
  • Memory is reused, not stranded: blocks come from a buddy allocator, and an empty block can be re-cut for any size.
  • tm and dialog, one small commit each: tm's timer set and a no-value dialog profile's bucket now hash the object's address, so timer_partitions and those profiles spread their load under HG_MALLOC.

Design, configuration and a guide for reviewers: mem/README.hg_malloc.md; docs and all measurements: https://github.com/Lt-Flash/opensips/tree/hg_malloc

Compatibility

  • Opt-in: compiled in, used only with -a HG_MALLOC, or as the default of a build with PREFERRED_MALLOC=HG_MALLOC.
  • -m/-M also accept k/m/g suffixes and INIT:CAP; only HG_MALLOC uses the cap.
  • Restart-persistent memory stays on F_MALLOC (or Q_MALLOC) when HG_MALLOC is selected.
  • LimitMEMLOCK=infinity is recommended, so the arena stays pinned.
  • Two small fixes to existing code: add_stats_group() gave a group's first statistic a 1-byte array, and the unit-test runner allocated the global context before mod_init(), so a module test that sets a module's context slot wrote past it (the first commit; the dialog test aborts on arm32). The other two fixes this PR carried, the default allocator of a build without DBG_MALLOC and the F_PARALEL_MALLOC typo in shm_mem.c, are now in master on their own.

Closing issues

None.

@Lt-Flash
Lt-Flash force-pushed the feature/hg-malloc-v3-final branch 11 times, most recently from dd92ab9 to 9f140e2 Compare October 11, 2026 08:26
@Lt-Flash
Lt-Flash force-pushed the feature/hg-malloc-v3-final branch 5 times, most recently from 5223184 to 6bd3352 Compare October 11, 2026 12:42
@Lt-Flash Lt-Flash changed the title mem: HG_MALLOC - a lock-free, elastic, huge-page allocator mem: HG_MALLOC - a lock-free, elastic memory allocator Oct 11, 2026
@Lt-Flash
Lt-Flash force-pushed the feature/hg-malloc-v3-final branch 2 times, most recently from 6642c73 to 0c21c59 Compare October 11, 2026 13:36
@Lt-Flash

Lt-Flash commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor Author

Hi,

This PR is ready for review. It is a single commit on top of current master, and the description has the measurements and the design summary; full documentation and benchmarks are in mem/README.hg_malloc.md and at https://github.com/Lt-Flash/opensips/tree/hg_malloc. The matching documentation update is OpenSIPS/opensips-docs#30.

HG_MALLOC is opt-in (-a HG_MALLOC), so nothing changes for existing configurations. I'm happy to adjust anything you'd like changed.

Thank you for your time!

@Lt-Flash
Lt-Flash force-pushed the feature/hg-malloc-v3-final branch 2 times, most recently from 9e02cc0 to 8de237b Compare October 16, 2026 12:57
@Lt-Flash

Copy link
Copy Markdown
Contributor Author

Two fixes folded into the commit, both in paths the earlier rounds did not exercise:

  • HG_MALLOC_DBG / realloc of pre-fork pkg memory. A child freeing memory the parent allocated before fork (every cachedb module's child_init() does this with its URL list) aborted under HG_MALLOC_DBG: the debug free checked the bounds of the child's own arena before the owner lookup that the non-DBG path already does. hg_realloc() had the same check and returned NULL for such pointers in all builds. Both now resolve the owning arena; a pointer from no arena is still rejected.
  • Restart persistent memory. With -a HG_MALLOC[_DBG] the rpm pool inherited HG, which cannot be placed in rpm's file-backed pool, so configs using restart persistency failed at startup. rpm now uses F_MALLOC (F_MALLOC_DBG) in that case; an explicit -r HG_MALLOC is refused with a clear message.

Checked with the default build and with -DSHM_EXTRA_STATS, each under HG_MALLOC and HG_MALLOC_DBG: mem:pkg_dump, mem:shm_dump (per-site lines in the DBG runs), shm_memlog_size, memdump at shutdown, mem-group statistics, and cachedb_local restart persistency. No errors in any run.

@Lt-Flash
Lt-Flash force-pushed the feature/hg-malloc-v3-final branch from 8de237b to e749a5a Compare October 18, 2026 01:08
@Lt-Flash

Copy link
Copy Markdown
Contributor Author

Pushed a small portability fix, squashed into the HG_MALLOC commit.

HG_MALLOC is enabled by default in Makefile.conf.template, so mem/hg_malloc.c is compiled on every platform. The huge-page ladder and its fallback constants were already confined to __OS_linux, but the two memory-release sites - hg_mem_release() and the start-up hand-back of the pre-faulted part of the first huge page - used MADV_REMOVE unguarded. It is Linux-only, so the default build would fail on FreeBSD, macOS or Solaris.

There is no portable way to free shared pages, so off Linux hg_mem_release() now refuses the way it already does when the kernel rejects a release: shrink_unsupported is set, one NOTICE says why, and the arena grows but does not shrink. The start-up hand-back is skipped. On Linux nothing changes - the compiled mem/hg_malloc.o is instruction-for-instruction identical before and after.

Tested by compiling the HG sources the way a non-Linux build sees them (Linux-only mman.h names removed): before, 'MADV_REMOVE' undeclared at both sites; after, clean. Then at runtime, with those objects linked into a Linux build, a 60 MB grow/release cycle: past the scale-down cooldown the Linux build shrank from 74 to 16 MB, while the non-Linux build logged the NOTICE once and kept its 72 MB, with no errors.

@Lt-Flash

Lt-Flash commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor Author

Updated the description with call-rate data over TLS and WSS (plus UDP) from a second setup. There one unit is a call relayed by a stateful proxy (2 transactions, 13 datagrams), not an INVITE answered by tm as in the existing tables, so its rates do not compare with theirs. 8 workers per transport, Debian 13 / Linux 6.12, no hugetlb pool, both allocators' shared memory on plain 4 KB pages (the host's THP shmem_enabled is never), same binary and load for both allocators.

At the same rate of relayed calls, F_MALLOC -> HG_MALLOC:

  • TLS, 6,000/s: 9-11% less CPU per call (18-23% less in the TCP workers), 54-69% fewer sched_yield calls
  • WSS, 5,000/s: 7-8% less CPU per call (16-21% less in the TCP workers), 59-64% fewer sched_yield calls
  • UDP, 12,000/s: 25-32% less CPU per call, 69-72% fewer syscalls, failed calls 0.2-0.8% -> 0

Over TLS and WSS the saving is in the TCP workers. TCP main, which does the TLS I/O, is within 3% with either allocator and sets the ceiling, so there HG_MALLOC lowers the cost per call rather than raising capacity. Over UDP with the default 512 KB socket buffer the highest sustained rate goes from 10,000 to 20,000 relayed calls/s (22,000 with auto from #4365). With an 8 MB socket buffer HG_MALLOC drops nothing up to 16,000 relayed calls/s and starts to drop at 18,000; F_MALLOC already drops at 12,000.

Full tables: https://github.com/Lt-Flash/opensips/blob/udp-rx-mode-measurements/README.md#6-hg_malloc-vs-f_malloc-data-for-the-hg_malloc-pull-request-4327

Edited 2026-10-19: on that host HG_MALLOC's shared arena ran on 4 KB pages, not on THP as first stated; added the units and the 8 MB socket-buffer result.

@Lt-Flash
Lt-Flash force-pushed the feature/hg-malloc-v3-final branch from e749a5a to 31d1c97 Compare October 21, 2026 14:28
@Lt-Flash

Lt-Flash commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor Author

While going through the allocator again for this round, I found a number of issues and fixed them. I have kept the fixes as a separate commit on top of the original one, so what changed is easy to review on its own; the tests and the reviewer section of the README follow as two more commits.

Fixes (mem: HG_MALLOC review fixes), each found by the new tests or that review pass:

  • A process leaving for good (auto-scaling down) hands back the cells it held in the shared arenas; they stayed counted as live until restart.
  • TCP main's I/O threads flush their caches after a sweep; nothing called that flush before.
  • A cell freed twice in a row, and a large fragment freed twice, are refused and counted instead of being handed out twice; HG_MALLOC_DBG checks the whole private list.
  • HG_MALLOC_DBG: the status dump at shutdown crashed on cells carved from recycled memory.
  • A worker that ran out of memory while the arena was shrinking could fail its allocation instead of growing.
  • Process groups check their auto-scaling profile where they take it: a profile with 1000 or more processes, or in k/m/g units, stops the startup again, as before this PR.
  • -m/-M: a negative size is refused; INIT:CAP with another allocator warns that only INIT is used.
  • Smaller: the free-list rebuild skips pages being released; a cell off the page grid is logged and counted; a refused free no longer moves the statistics; the rpm error names -e.

Tests (mem: HG_MALLOC unit tests, run in CI). make test takes UT_ALLOC and UT_MEM. The unit-test workflow runs the core suites, the allocator stress suite (disabled upstream since 2021, enabled here when HG is selected) and new HG cases with HG_MALLOC and HG_MALLOC_DBG on every job of its matrix. The fork-ownership case fails on the code before the 2026-10-16 fix; the exit and double-free cases fail on the code before this push.

For reviewers: a guide to the code (mem/README.hg_malloc.md, section 11) - reading order, the invariants and where they are enforced, known limits, and which test shows which property.

What changes for users who do not select HG_MALLOC:

  • -DHG_MALLOC is in Makefile.conf.template, so a fresh build compiles it in; nothing uses it without -a HG_MALLOC.
  • -m/-M accept k/m/g suffixes and INIT:CAP; other allocators use INIT and warn; a bare number still means MB.
  • New core parameters, and k/m/g sizes in auto_scaling_profile; existing syntax is unchanged.
  • E_CORE_SHM_GROW_BLOCKED and E_CORE_HG_LOCK_STALL are registered in every build; core:hg_stats and core:hg_advise exist when HG is compiled in and report that HG is not the active allocator otherwise.
  • Two fixes to existing code: master's default allocator, Q_MALLOC_DBG, leaves a build without DBG_MALLOC unable to start unless -a is given; an #elif define F_PARALEL_MALLOC typo in shm_mem.c.

Questions:

  1. Would you prefer the parser change (size suffixes, INIT:CAP) and the default-allocator fix as separate PRs, and the rest as a few commits (allocator, integration, observability, tests)?
  2. Should HG stay compiled in by default, or become opt-in in Makefile.conf.template?
  3. mem/mem_arena.h (module arenas) has no user in this PR; cachedb_perf (cachedb_perf: high-performance local memory cache built on modern kernel features #4118) uses it. Keep it here, or move it to cachedb_perf: high-performance local memory cache built on modern kernel features #4118?

@Lt-Flash

Copy link
Copy Markdown
Contributor Author

For reviewers: a guide to the code (mem/README.hg_malloc.md, section 11) - reading order, the invariants and where they are enforced, known limits, and which test shows which property.

@Lt-Flash

Lt-Flash commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor Author

I re-ran the benchmarks from the description against the current head (63046ceca0, with both rounds of review fixes), on the same hosts with the same drivers and method. HG_MALLOC still leads in every test: the least CPU, no UDP drops or failed requests where the other allocators drop or fail, and the highest sustained call rates (equal to F_MALLOC's in two of the three TLS modes). In the tables below, figures outside brackets are from today's re-run; figures in brackets are the ones in the PR description, measured before the review fixes.

Setup A, INVITEs answered by tm, mean of two runs:

INVITEs/s F_MALLOC F_PARALLEL_MALLOC HP_MALLOC HG_MALLOC
20,000 cores 2.35 (2.40) 2.28 (2.30) 2.20 (2.25) 1.99 (2.04)
45,000 cores 4.55 (4.41) 4.41 (4.14) 4.38 (4.22) 3.68 (3.62)
UDP drops 36,622 (31,030) 368 (167) 43,062 (19,342) 0 (0)
60,000 cores 8.65 (8.57) 7.24 (8.13) 6.82 (5.40) 4.65 (4.69)
UDP drops 8.3 M (7.7 M) 1.8 M (3.6 M) 883,200 (247,051) 0 (0)
probe p99 39.1 ms (36.2 ms) 20.4 ms (37.7 ms) 14.9 ms (8.4 ms) 4.1 ms (3.3 ms)
failed INVITEs 834,048 (508,165) 37,726 (122,206) 1,387 (0) 0 (0)
60,000, -m 4096 cores 6.24 (5.98) 5.63 (5.43) 6.57 (6.31) 4.66 (4.59)
UDP drops 1.0 M (1.1 M) 2,380 (4,554) 756,258 (363,419) 0 (0)

Registrar, 1.2 M REGISTERs at 60k/s: HG_MALLOC again registers all of them with a p99 of 4 ms on 1.9 cores, against 15-24 s on 4 cores for F_MALLOC and F_PARALLEL_MALLOC and 0.53 M registrations for HP_MALLOC. The binary behind the description's tables, run alongside, is within 3.2% of the current head in CPU, in both directions.

Setup B, calls relayed by a stateful proxy, F_MALLOC vs HG_MALLOC in the three receive modes of #4365:

highest sustained calls/s (shared / auto / exclusive) F_MALLOC HG_MALLOC
UDP, 512 KB socket buffer 10k / 10k / 10k (10k / 10k / 10k) 20k / 22k / 20k (20k / 22k / 18k)
UDP, 8 MB socket buffer 13k / 14k / 12k (10k / 13k / 12k) 22k / 22k / 22k (-)
TLS 6k / 6k / 6k (6k / 6k / 6k) 6k / 8k / 6k (6k / 8k / 6k)
WSS 5k / 5k / 6k (6k / 5k / 6k) 6k / 6k / 6k (5k / 5k / 5k)
same rate, F_MALLOC -> HG_MALLOC UDP 12,000/s TLS 6,000/s WSS 5,000/s
CPU per call -21 to -25% (-25 to -32%) -7 to -11% (-9 to -11%) -6 to -9% (-7 to -8%)
TCP workers' CPU - -16 to -24% (-18 to -23%) -15 to -21% (-16 to -21%)
syscalls per call -67 to -70% (-69 to -72%) -12 to -14% (-14 to -20%) -7 to -8% (-10 to -12%)
sched_yield per call - -53 to -58% (-54 to -69%) -48 to -57% (-59 to -64%)
failed calls 0.2-0.5% -> 0 (0.2-0.8% -> 0) 0 -> 0 0 -> 0

All tables, the method and what differed between the runs: https://github.com/Lt-Flash/opensips/blob/hg_malloc/docs/benchmarks-after-review.md

@Lt-Flash

Copy link
Copy Markdown
Contributor Author

I pushed six more commits. They come from an internal review of the allocator that I did just before opening this PR; they were still being tested then, so I left them out. They have been running in production since 17 October, on SBCs, registrars and load balancers, and I'm adding them so that this PR is the code that runs there:

  • Lock contract made visible: the four functions that drop and re-take the arena lock in the middle are renamed hg_buddy_*_unlocks, and the lock is checked on entry and on return; a breach counts as corruption.internal.
  • A stats slot per thread: TCP main's IO-pool threads shared their process's statistics slot and lost updates under WSS load (+264 B and +3 fragments over 54,016 handshakes); each thread now claims its own slot.
  • Drained blocks kept per class by demand: a drained block no longer goes back to the buddy on the free path. Each size class keeps a demand-sized reserve, and the maintenance process returns the rest, at most 32 blocks per second (on the exhaustion rig: GC passes 108 -> 37, refill lock hold max 301 -> 92 us).
  • Sweep skips idle processes: a process whose caches took nothing since its last flush is not sent a flush job, and every 4th interval sweeps all of them (sweep jobs 20 -> 6 over four idle intervals).
  • Private-cache budget from the IO pool: the divisor follows tcp_threads (or the online CPUs), clamped to 4-64, instead of a constant 16.
  • Sweep spread by a hash: each process's sweep second is a hash of its index, so the busy processes no longer cluster in the first part of the interval.

Each commit message has the details and the measurements. New head: 16a143d1d7; the same six commits are on the 4.0 branch (6ba0d6feae).

@Lt-Flash

Copy link
Copy Markdown
Contributor Author

I trimmed the comments, fixed one bug and split the large files into smaller ones to make the code easier to read and review. The moved code is unchanged, function by function; six static helpers that other files now call gained an hg_ prefix.

  • Comments: about a third shorter. They keep the lock, thread and ownership rules, invariants and units; comments that no longer matched the code are corrected.
  • Fix: struct hg_pstat's padding counted three fields instead of four, so on 64-bit builds each stats slot took two cache lines. It is one line again, with a static assert.
  • Files:
file what it holds
hg_malloc.h the interface to the core; includes hg_cell.h (cell header, size classes) and hg_block.h (struct hg_block and the other structures)
hg_arena.c per-thread caches, cell alloc and free, carving, arena init
hg_pool.c the shared per-class pool, drained-block reclaim
hg_cache.c cache flushes, the reserve floor
hg_stats.c statistics, lock instrumentation
hg_buddy.c the page grid and the buddy allocator
hg_grow.c growth, re-growth into holes, deferred collapse
hg_shrink.c trim, interior release, the drain
hg_backing.c backing memory: the tier ladder, commit, release
hg_capacity.c host and cgroup memory room, capacity checks
hg_malloc.c init, the arena registry, auto-scaling profiles, status
hg_arena_int.h, hg_buddy_int.h, hg_backing.h helpers shared within each group

The full map is in mem/README.hg_malloc.md, section 11.

@Lt-Flash

Lt-Flash commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor Author

In 52ac3f3 I added an optional integrity level, hg_mem_check, so that the checks Q_MALLOC_DBG makes are available on the normal HG_MALLOC build, without switching to HG_MALLOC_DBG:

  • 0 (default): the existing checks only (header checksum and state, double free, pointer outside the arenas); a failed check is logged and counted, and the cell is leaked.
  • 1: every new allocation also gets a guard. The size asked for is kept in a word the header leaves spare, and up to 8 bytes after the request hold a fixed pattern, checked at free and at realloc, so a write of even one byte past the request is caught - small cells, large fragments, pkg and shm, realloc in place.
  • 2: as 1, and the process aborts on the first corruption any check finds, after that check's message is logged.

A guarded cell is marked by its own state value, which the header checksum covers, so the level can change while cells of both kinds are live: the shared arena starts guarding as soon as the config is read. The header layout is unchanged on every ABI; a 32-bit build with only one of DBG_MALLOC and SHM_EXTRA_STATS has no spare word and keeps the size in the cell's last 4 bytes instead. core:hg_stats shows each arena's guard flag. A realloc of a pointer from no arena, or of a cell with an invalid class, is now counted in corruption as well (it was only logged). hgstress gains overrun faults to exercise the guard, and fill/max_small parameters to measure the allocator alone.

Measured on one 16-vCPU host (sipp on the same host, opensips and sipp pinned to separate CPUs, default -O9 build), comparing the build without the knob with hg_mem_check=0 and =1, 3 runs per point (5 for the first row):

test no knob 0 1
alloc/free only (hgstress, no stamps), ns CPU per operation 56.9 56.6 61.4
tm replying 486, 10k cps, µs CPU per call 51.6 53.1 52.8
tm replying 486, 20k cps, µs CPU per call 45.9 45.2 45.9
load balancer (dispatcher, dialog, topology_hiding), 4k cps, µs CPU per call 274.3 274.2 283.6
load balancer, 8k cps, µs CPU per call 272.2 273.0 277.6

With 0 the knob costs nothing measurable. With 1 the guard costs about 4.5 ns per allocation or free; that is within the run-to-run spread of the tm test, and 2.0-3.4% CPU per call for the load balancer, which allocates more per call. Beyond saturation, runs of the same arm differed by up to 15% in completed calls, more than the arms differed from each other (tm at 60k cps: 34.4k / 34.1k / 32.1k calls/s; load balancer at 15k cps: 12.4k / 11.8k / 12.0k). With injected one-byte overruns, 0 found none, 1 found all four at the exact byte, and 2 logged the overrun and aborted. Every table, with the values of each run, the overrun and shrink results and the method: hg_mem_check and the pkg shrink decision.

a148e3e fixes the shrink decision. It asked for a whole grow step of free space plus four reserve floors (two with a profile), the floor taken at the current size, while the drain itself releases page by page and stops before any page that would leave less free space than twice the floor of what remains. So an arena under about a step plus four floors never shrank: a pkg arena that grew once from a 2 MB INIT, to 18 MB with the default 16 MB step, stayed at 18 MB. The decision now asks for what the drain tick asks for, one page plus the same multiple of the floor. On the same host, two SIP workers (-M 2:32) that took 12 MB of pkg at start and freed it kept 18 MB committed through six minutes of traffic with the old decision; with the new one both gave the 16 MB back two minutes in, after the four quiet sweeps, and stayed at 2 MB. A unit test grows a pkg arena one step and checks that it shrinks again. b996af4 drops the hb->lo/hi watermarks, which were written on every buddy allocation and carve but never read.

@Lt-Flash

Copy link
Copy Markdown
Contributor Author

Three more commits:

  • acc93fa fixes a child's copy of the parent's pkg arena. The child keeps that copy, since the frees of pre-fork pointers land in it, and its sweep ran the private-arena policy on it as on its own arena. A parent that forked with its pkg arena under the headroom line therefore made every child that sweeps grow its copy, which nothing allocates from. The copy is now marked as inherited at fork: it is never grown and its figures are not published, but it still gives back the pages those frees empty. With -M 2:32 and the parent holding 8 to 8.5 MB of pkg at fork, four children each grew their copy by 14 MB, to 32 MB, before the fix, and none after. The tables are on the measurements page. A unit test checks it. 62f60d0 adds the hgstress parameter used for this, parent_pkg_kb.
  • 9412ca7 makes the unit tests wait 120 s for a child instead of 30. A fuzz seed has taken from 10 to over 30 s on the qemu arm64 cross-build; one run failed that way, and the child, left running, posted its verdict into the next seed. A child that exits without a verdict is now noticed as it exits, and one that times out is killed before the next test starts.

The clang-arm64-qemu-cross failure in the previous run was in installing the build dependencies (ldconfig crashed under qemu), before anything was built.

@Lt-Flash

Copy link
Copy Markdown
Contributor Author

166d998 fixes a mistake in 9412ca7: internal_fork() returns the child's slot in the process table, not its pid, so the wait passed the slot to waitid(), found no such child and reported every child as having exited without a verdict - the red run on 9412ca7. The pid is now read from pt[], where the child stores it before its start-up completes.

On 166d998 every unit-test job passes, the four qemu cross-builds included, with the fuzz seeds taking 8-19 s there. The one remaining failure, gcc-arm64-qemu-cross in the multi-architecture build, is again ldconfig crashing under qemu while the dependencies install, before anything is built.

@Lt-Flash

Copy link
Copy Markdown
Contributor Author

Hi @bogdan-iancu, I saw you tagged this as investigating. Before I pile more onto it, what do you think: is a new allocator something you'd take into core?

I know it's a lot more complex than the existing allocators, with a lot more functionality. I don't think it's over-engineered, though: sometimes getting the performance out of something takes a more involved design. Earlier versions have been running in production on my servers since early August, and the current one on more than twenty of them for the last few weeks, so I'm fairly confident in it.

It's also big (about 6.8k lines of code), so I'm happy to break it into separate PRs: a fixed-size arena first (about 4k lines), then one each for growth, shrink, debugging and stats. Would that work? I'll keep this as a draft until then.

@Lt-Flash
Lt-Flash marked this pull request as draft October 26, 2026 17:26
@Lt-Flash
Lt-Flash force-pushed the feature/hg-malloc-v3-final branch 5 times, most recently from 743d463 to 78f4726 Compare November 4, 2026 11:50
@Lt-Flash

Lt-Flash commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current master (7a4e46b). There were no conflicts, and the rebase changed nothing in the diff. One fix has gone in since the description was last updated: in an LXC container (Proxmox, LXD) /proc/meminfo comes from LXCFS, which reports huge pages as 0, so HG now counts them from /proc/self/smaps_rollup there. Before, every arena in such a container was logged as plain 4K pages, whatever backed it.

@Lt-Flash
Lt-Flash force-pushed the feature/hg-malloc-v3-final branch from 78f4726 to 6293c4a Compare November 5, 2026 05:16
@Lt-Flash
Lt-Flash marked this pull request as ready for review November 6, 2026 02:13
@Lt-Flash

Lt-Flash commented Nov 6, 2026

Copy link
Copy Markdown
Contributor Author

This is now ready for review. I'd appreciate any feedback when you have time, thank you!

@Lt-Flash
Lt-Flash force-pushed the feature/hg-malloc-v3-final branch from 6293c4a to 5a272c9 Compare November 6, 2026 10:07
init_unit_tests() runs at the start of init_modules() and allocates the
global context there, before any mod_init() registers its context slots.
A test that then uses a module's slot writes past the end of that buffer:
the dialog test does, with ctx_dialog_set(), and Q_MALLOC_DBG aborts with
"beginning overwritten" on arm32.
A new shared- and private-memory allocator, selected at runtime with
-a HG_MALLOC (or -s/-k for one memory type); no behaviour change for
existing configurations.

 - 21 size classes (64 B - 64 KB) served from per-process free lists, so
   the common allocation and free take no lock; cells move between
   processes through a shared per-class pool, in batches. Larger requests
   go to a coalescing large tier.
 - A transfer cache in each shared arena parks a donated private chain
   whole and hands it whole to a refill, a pointer swap under the arena
   lock instead of a per-cell walk; up to 64 chains per class, 1/128 of
   the arena and at most 32 MB. An idle class gives one chain back to its
   blocks per GC cycle; a carve at exhaustion drains them all.
 - Blocks are carved from a buddy allocator over a grid of pages the size
   of the host's huge page (2 MB on x86-64; 256 KB for small arenas on
   4 KB memory), whichever tier backs them; a block whose cells are all
   free returns to the buddy and can be re-cut for any class.
 - The arena is acquired through a four-tier ladder - MAP_HUGETLB, THP via
   MADV_HUGEPAGE, MADV_COLLAPSE, plain 4 KB pages - each tier verified from
   the kernel's counters. Huge pages are an optimisation, not a requirement.
   MAP_HUGETLB is for shared arenas only; a private arena starts on THP or
   4 KB pages. A refused MAP_HUGETLB is logged with the pool's free pages
   per NUMA node. On tier 3 a grow done by a worker that ran out of memory
   is published on 4 KB pages at once and the maintenance process collapses
   it afterwards, so no worker waits for MADV_COLLAPSE.
 - Elastic arenas: -m INIT:CAP / -M INIT:CAP (k/m/g suffixes, bare numbers
   in MB) reserve the cap and commit pages on demand, in a configurable
   granule (shm_grow_granule / pkg_grow_granule), growing on exhaustion or
   ahead of it through an auto_scaling_profile (shm_/pkg_auto_scaling_profile),
   and releasing quiet pages. A maintenance process grows ahead of demand and
   runs the release with the arena lock dropped. Growth stops at the cap,
   the backing tier and a host free-memory floor (hg_ram_floor_mb); a
   sustained refusal by the backing or the floor latches GROW-BLOCKED,
   raised as E_CORE_SHM_GROW_BLOCKED (reaching the cap is only a notice).
 - Host memory is checked before anything is committed, on every tier
   (MemAvailable above hg_ram_floor_mb, the memory cgroup's limit, plus
   the hugetlb pool for arenas on hugetlb pages): at startup for every
   process's -M INIT (refuse when the startup processes do not fit, warn
   for the auto-scaling maximum and for caps beyond MemTotal), before each
   fork (an auto-scaler fork that does not fit is skipped), and on each
   arena creation and grow.
 - On hugetlb pages every grow is an mlock() of the new range, and mlock()
   checks RLIMIT_MEMLOCK there as well: the arena warns at startup (and
   under -C) when the limit cannot cover its growth, and a refused grow
   names the resource that ran out - the limit or the hugetlb pool. On 4 KB
   and THP pages a limit too low to pin the arena leaves it running and
   growing unpinned (swappable) instead.
 - Every cell header carries the cell's state and a check over its address,
   class and state; a free moves it from live to free in one
   compare-and-swap, so a double free is refused, also from two processes
   at once, and a damaged header is refused, not followed. A per-block free
   map at the pool boundary stops a free-list walk at a scribbled link.
   Each is counted and logged and the process keeps running.
   hg_mem_check = 1 adds a tail guard checked at free and realloc, 2 also
   aborts on the first corruption. HG_MALLOC_DBG adds red zones and a
   quarantine of freed cells. ASan or Valgrind (-DHG_VALGRIND) is told
   which bytes of a private arena a caller may touch, ThreadSanitizer
   about the arena lock.
 - core:hg_stats (MI) per arena: tier, committed/reserved, per-class carve
   and reuse, grows/shrinks, the last grow refusal and its cause, whether
   the arena runs unpinned, transfer-cache puts/gets and parked chains,
   failed allocations (alloc_failed, out_of_memory), lock hold/wait
   histograms per reason and the corruption counters; core:hg_advise (MI)
   recommends -m/-M from the peaks reached; an hgmem: statistics group,
   including hg_pkg_grow_refused and hg_pkg_grow_blocked summed over every
   process's private arena; E_CORE_HG_LOCK_STALL for a hold above
   hg_lock_stall_us.
 - mem/mem_arena.h: a module may ask for its own HG-managed arena,
   independent of the core allocator choice (stubs returning NULL when HG
   is not built).
 - Unit tests (mem/test) for the allocator and its pkg/shm integration,
   fork, double free, corruption and growth cases; CI runs the unit tests
   with HG_MALLOC and HG_MALLOC_DBG as well.
 - modules/hgstress: a soak and fault-injection test module, excluded from
   the default build. Every worker churns stamped shm cells across all
   classes and the large tier and verifies the stamps while the others
   churn; MI commands park and release memory to drive grow and shrink, and
   inject a double free, an overrun or a scribbled free-list link.
 - PREFERRED_MALLOC=HG_MALLOC makes HG_MALLOC the build's default.
 - A fix to existing code: add_stats_group() sized a group's array as
   sizeof(stat_var) * no + 1, giving the first stat a 1-byte array.

Design, configuration, deployment and test procedures:
mem/README.hg_malloc.md.
The set was (address >> 3) % timer_sets. HG_MALLOC hands out cells of one
size class at a stride that is a multiple of 64 bytes from 128 bytes up,
so every transaction got the same set and timer_partitions had no effect.
A Fibonacci multiply spreads the address bits over all sets.
The bucket was address & (size - 1). Every allocator aligns a dialog to at
least 8 bytes, so with the default 16 buckets only 2 were used, and under
HG_MALLOC, whose same-size cells sit 64 bytes apart or more, only one: all
dialogs of such a profile shared one lock. A Fibonacci multiply spreads them.
@Lt-Flash
Lt-Flash force-pushed the feature/hg-malloc-v3-final branch from 5a272c9 to e6af534 Compare November 13, 2026 11:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants