Skip to content

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

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

Lt-Flash wants to merge 19 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

Summary

A new allocator for shared and private memory, selected with -a HG_MALLOC. Nothing changes unless it is selected.

  • 9-27% less CPU than F_MALLOC, F_PARALLEL_MALLOC and HP_MALLOC at 20k-60k INVITEs/s answered by tm, with a hugetlb pool, and about half on a registrar taking 60k REGISTERs/s.
  • Over TLS and WSS too: at the same rate of calls relayed by a stateful proxy, 7-11% less CPU per call (16-23% less in the TCP workers) and 54-69% fewer sched_yield calls than F_MALLOC, with HG_MALLOC's shared arena on plain 4 KB pages like F_MALLOC's; over UDP with the default 512 KB socket buffer the highest sustained rate rises from 10k to up to 22k relayed calls/s.
  • 0 UDP drops and 0 failed INVITEs at 60k INVITEs/s with 8 MB socket buffers; F_MALLOC and HP_MALLOC drop 166,000 to 1.1 M datagrams, also with 32 MB socket buffers, and F_MALLOC fails INVITEs; F_PARALLEL_MALLOC drops a few thousand with 8 MB buffers. With relayed calls and the same 8 MB buffer, 0 drops up to 16k calls/s, where F_MALLOC already drops at 12k.
  • Less memory under overload: with no drops, transactions are released on time instead of waiting in memory for retransmissions - at 60k INVITEs/s HG_MALLOC peaked at 1.5 GB against 2.0-2.5 GB for HP_MALLOC and F_MALLOC, and stayed within a 2 GB arena that F_MALLOC and F_PARALLEL_MALLOC ran out of.
  • REGISTER p99 4 ms at 60k/s into an empty registrar; the others take 15-28 s, and HP_MALLOC loses more than half the registrations.
  • Elastic memory: -m 128:1024 starts at 128 MB, grows on demand up to 1 GB and gives memory back when idle.
  • No OOM surprises: every arena's INIT - the shared -m and each process's -M - is checked against host memory before it is committed; an impossible combination is refused at startup, and shared-arena growth and the auto-scaler both stop at a host free-memory floor instead of crashing mid-run.
  • Runs on any host: memory comes through a four-tier ladder - a hugetlb pool, THP by MADV_HUGEPAGE, THP by MADV_COLLAPSE, plain 4 KB pages - each tier verified from the kernel's counters; huge pages are used when the host offers them, not required. On plain 4 KB pages, like the other allocators, HG_MALLOC still uses 4-14% less CPU at 60k INVITEs/s; huge pages save another 13% and halve the p99.
  • Sizes that fit the host: below 2 MB an arena runs on 256 KB pages when it lands on 4 KB memory (-M 256k:2m works), and on huge pages INIT is rounded up to one page with a notice.
  • Safe and observable: double frees - a cell freed twice in a row, any that reaches the shared pool, a large fragment freed twice - and corrupted free-list links are caught and counted; grows, shrinks and lock holds are exported via MI, statistics and events.
  • In production since August 2026, now on 19 nodes: 6 billing gateways and 2 SIP load balancers since August, 8 SBCs and 3 registrars since October.

For reviewers: a guide to the code - reading order, the invariants and where they are enforced, known limits, and which test shows which property.

Docs, configuration and benchmarks: https://github.com/Lt-Flash/opensips/tree/hg_malloc (in this PR: mem/README.hg_malloc.md, opensips.8); 4.1 migration and troubleshooting notes: OpenSIPS/opensips-docs#30

Details

At high call rates the allocator becomes the bottleneck: at 60k INVITEs/s with 8 UDP workers, F_MALLOC, F_PARALLEL_MALLOC and HP_MALLOC drop packets while 10 or more of the host's 16 cores are idle (mpstat), and HG_MALLOC drops none. And -m/-M are fixed at boot: too small fails at peak, too big pins memory that is never used.

Two setups that count different things, so their rates do not compare: on setup A one unit is an INVITE answered 486 by tm on the server (1 transaction, 3 datagrams); on setup B, further down, it is a call relayed to a UAS (2 transactions, 13 datagrams). What was measured where: https://github.com/Lt-Flash/opensips/blob/hg_malloc/docs/benchmarks.md#what-was-measured-where

Measurements, setup A (16-vCPU VM, Xeon E5-2699 v4, Ubuntu 24.04, gcc 13, 16-queue NIC, 8 MB UDP socket buffers; HG_MALLOC on a hugetlb pool (tier 1) except in the per-tier table, the other allocators on plain 4 KB pages; sipp from two hosts with 16-queue NICs; same binary, allocator chosen on the command line; F_PARALLEL_MALLOC as -a F_MALLOC -s F_PARALLEL_MALLOC)

INVITE answered by tm, 8 UDP workers, -m 2048 (HG -m 128:2048), and the 60k point again with -m 4096 (HG -m 128:4096); difference from F_MALLOC in brackets; peak memory is the shared memory in use (for HG_MALLOC the peak carved, which slightly overstates it):

INVITEs/s F_MALLOC F_PARALLEL_MALLOC HP_MALLOC HG_MALLOC
20,000 cores 2.40 2.30 (-4%) 2.25 (-6%) 2.04 (-15%)
UDP drops 0 0 0 0
probe p99 0.94 ms 0.82 ms (-13%) 0.73 ms (-23%) 0.78 ms (-17%)
peak memory 445 MB 479 MB (+8%) 449 MB (+1%) 502 MB (+13%)
45,000 cores 4.41 4.14 (-6%) 4.22 (-4%) 3.62 (-18%)
UDP drops 31,030 167 19,342 0
probe p99 6.1 ms 3.3 ms (-46%) 4.6 ms (-24%) 1.4 ms (-78%)
peak memory 1,051 MB 1,038 MB (-1%) 1,009 MB (-4%) 1,118 MB (+6%)
60,000 cores 8.57 8.13 (-5%) 5.40 (-37%) 4.69 (-45%)
UDP drops 7.7 M 3.6 M 247,051 0
probe p99 36.2 ms 37.7 ms (+4%) 8.4 ms (-77%) 3.3 ms (-91%)
peak memory 2,022 MB 1,747 MB (-14%) 1,499 MB (-26%) 1,489 MB (-26%)
failed INVITEs 508,165 122,206 0 0
60,000, -m 4096 cores 5.98 5.43 (-9%) 6.31 (+6%) 4.59 (-23%)
UDP drops 1.1 M 4,554 363,419 0
probe p99 13.8 ms 6.5 ms (-53%) 13.4 ms (-3%) 1.9 ms (-86%)
peak memory 2,467 MB 1,374 MB (-44%) 1,978 MB (-20%) 1,486 MB (-40%)
failed INVITEs 4,412 0 0 0

At 60k INVITEs/s with -m 2048 F_MALLOC ran out of shared memory 16 s into the run, and F_PARALLEL_MALLOC ran out in one of its pools after 11 s; tm then failed INVITEs. With -m 4096 neither ran out (F_MALLOC peaked at 2.5 GB), and F_MALLOC still lost 4,412 INVITEs to drops. HG_MALLOC grew from 128 MB to 1.5 GB in both runs.

The 60k point with -m 4096 twice more, back to back, with 8 MB and with 32 MB socket buffers (maxbuffer and the kernel limits); difference from F_MALLOC in brackets, host idle from mpstat:

buffers F_MALLOC F_PARALLEL_MALLOC HP_MALLOC HG_MALLOC
8 MB cores 5.69 5.19 (-9%) 5.12 (-10%) 4.45 (-22%)
host idle 63% 68% 68% 72%
UDP drops 541,454 2,998 165,880 0
probe p99 14.9 ms 10.2 ms (-31%) 8.4 ms (-43%) 2.4 ms (-84%)
peak memory 1,893 MB 1,375 MB (-27%) 1,527 MB (-19%) 1,491 MB (-21%)
failed INVITEs 368 0 0 0
32 MB cores 5.71 5.29 (-7%) 5.33 (-7%) 4.44 (-22%)
host idle 62% 65% 65% 71%
UDP drops 459,068 0 230,920 0
probe p99 12.1 ms 7.6 ms (-37%) 8.4 ms (-31%) 3.1 ms (-74%)
peak memory 1,980 MB 1,369 MB (-31%) 1,588 MB (-20%) 1,489 MB (-25%)
failed INVITEs 8 0 0 0

The larger buffer does not stop F_MALLOC's and HP_MALLOC's drops: with about 10 of the 16 cores idle in every run (9.8 to 11.5, mpstat), their workers stall for longer than 32 MB covers.

HG_MALLOC alone at 60k INVITEs/s on each backing tier, the host set up for one tier at a time (-m 128:4096, 8 MB buffers; the other allocators above ran on plain 4 KB pages):

tier cores vs plain 4 KB UDP drops probe p99 vs plain 4 KB
hugetlb pool 4.27 -13% 0 1.9 ms -52%
THP by advice 4.47 -9% 0 2.3 ms -43%
THP by collapse 4.69 -4% 0 5.8 ms +43%
plain 4 KB, what the other allocators use 4.90 - 0 4.0 ms -

On THP by collapse the arena grew from 128 MB to 1.6 GB on 4 KB pages during the run and the maintenance process collapsed it in the background within about 25 s, so part of that run was measured on 4 KB pages while pages were being collapsed.

1.2 M REGISTERs at 60k/s into an empty registrar, 4 UDP workers, so 4.0 cores means saturated (-m 4096; HG grows from 128 MB to 1.7 GB), two runs each, difference from F_MALLOC in brackets:

F_MALLOC F_PARALLEL_MALLOC HP_MALLOC HG_MALLOC
registered 1.2 M 1.2 M 0.52 M (-57%) 1.2 M
UDP drops 1.9-2.2 M 1.3-1.6 M 6.9-7.0 M 0
p99 19.5-19.6 s 15.5-15.6 s (-20%) 28 s (+43%) 4 ms (-99.98%)
cores 4.0 3.8-4.0 (-2%) 4.0 (0%) 1.9-2.0 (-51%)
peak memory 1,459 MB 1,484 MB (+2%) 591-596 MB (-59%, half the registrations failed) 1,481 MB (+2%)

Longest HG lock hold in all setup A runs: 1.4 ms (5 holds over 1 ms, all at 45k-60k INVITEs/s).

Measurements, setup B: calls relayed by a stateful proxy over UDP, TLS and WSS, F_MALLOC against HG_MALLOC, on another host (16-vCPU VM, Xeon E5-2699 v4, Debian 13, Linux 6.12; no hugetlb pool and THP shmem_enabled = never, so both allocators' shared memory on plain 4 KB pages and HG_MALLOC's private arenas on THP; 8 workers per transport; HG -m 512:4096 -M 64:512, F -m 2048 -M 32; TLS and WSS over 24 long-lived connections); same binary, script and load; 30-second cells, two repetitions; ranges cover the three receive modes of #4365, shared being the current code. Full tables and the per-syscall breakdown: 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

same rate, F_MALLOC -> HG_MALLOC UDP 12,000/s TLS 6,000/s WSS 5,000/s
CPU per call -25 to -32% -9 to -11% -7 to -8%
TCP workers' CPU - -18 to -23% -16 to -21%
syscalls per call -69 to -72% -14 to -20% -10 to -12%
sched_yield per call - -54 to -69% -59 to -64%
failed calls 0.2-0.8% -> 0 0 -> 0 0 -> 0

With the default 512 KB socket buffer the highest sustained rate rises from 10,000 relayed calls/s to 20,000 on the current receive path and 22,000 with #4365's auto (F_MALLOC: 13,000 at best, with an 8 MB socket buffer). With an 8 MB socket buffer HG_MALLOC drops nothing up to 16,000 relayed calls/s (12 of 12 cells), starts to drop at 18,000 (3 of 6 cells), and up to 20,000 retransmissions recover every drop; F_MALLOC already drops at 12,000. F_MALLOC hits the buffer limit before the CPU limit: at 12,000/s the kernel drops datagrams while its workers are 63-84% busy. Its extra syscalls are sched_yield from the shared-memory lock's adaptive wait and grow with load (57 to 92 per call from 5,000 to 12,000/s), while HG_MALLOC's stay at 28-30 per call from 12,000 to 20,000/s: its common path takes no lock. Over TLS and WSS the TCP workers, which run the script, use 16-23% less CPU; TCP main, which does the TLS I/O, is within 3% with either allocator and sets the ceiling (5,000-8,000 calls/s), so there HG_MALLOC lowers the cost per call rather than raising the ceiling.

Builds: gcc and clang -Werror, x86-64, i386, arm32/arm64, glibc and musl, and with HG compiled out; soak-tested on x86-64, i386 and musl.

Solution

  • 21 size classes (64 B - 64 KB) with a per-process free list each: the common alloc/free takes no lock. Cells move between processes in batches through a shared pool.
  • Size-class blocks are carved by a buddy allocator from a grid of pages the size of the host's huge page (2 MB on x86-64; 256 KB for an arena under 2 MB on 4 KB memory), whichever tier backs them; empty blocks return to the buddy, so memory is reused across sizes.
  • Backing memory through a four-tier ladder, best first, each tier verified from the kernel's counters: a hugetlb pool, THP by MADV_HUGEPAGE, THP by MADV_COLLAPSE, plain 4 KB pages. Huge pages are optional. On the collapse tier a grow done by a worker that ran out of memory is published on 4 KB pages at once and collapsed afterwards by the maintenance process, so no worker waits for MADV_COLLAPSE.
  • -m INIT:CAP / -M INIT:CAP: the cap is reserved, pages are committed as needed and released when idle. Growth can be driven ahead of demand by the existing auto_scaling_profile grammar (shm_auto_scaling_profile, pkg_auto_scaling_profile). If growth keeps being refused - on any tier by the host free-memory floor (hg_ram_floor_mb) or the kernel failing to back the memory, and on hugetlb pages also by the memory-lock limit or an empty pool - E_CORE_SHM_GROW_BLOCKED is raised; reaching the cap is only logged.
  • Host memory checks on every tier: the shared arena's -m INIT is committed first, then every process's -M INIT is checked against what is left: RAM above hg_ram_floor_mb, plus the hugetlb pool when the host has one. OpenSIPS refuses a startup that cannot fit, warns when the -m cap plus -M cap x processes exceeds the host, and at runtime shared-arena grows and auto-scaler forks are both refused at the floor.
  • Monitoring: core:hg_stats and core:hg_advise (recommended -m/-M from observed peaks) MI commands and an hgmem: statistics group (including private-arena growth refusals summed over all processes), with what to alert on: https://github.com/Lt-Flash/opensips/blob/hg_malloc/docs/configuration.md#monitoring
  • Events: E_CORE_SHM_GROW_BLOCKED and E_CORE_HG_LOCK_STALL, parameters and examples: https://github.com/Lt-Flash/opensips/blob/hg_malloc/docs/configuration.md#events
  • mem/mem_arena.h: modules can request their own HG arena.
  • modules/hgstress: a soak and fault-injection test module, not built by default.
  • Unit tests: the core suites, the allocator stress suite and mem/test/test_hg_malloc.c run with HG_MALLOC and HG_MALLOC_DBG in the unit-test workflow (make test UT_ALLOC=HG_MALLOC UT_MEM="-m 64:1024 -M 16:256").

Compatibility

  • Opt-in: no behaviour change for existing configurations. -m/-M now also accept k/m/g suffixes and INIT:CAP for every allocator; only HG uses the cap, and the others warn that they use INIT.
  • 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.
  • LimitMEMLOCK=infinity recommended: it keeps the arena pinned, so it cannot be swapped. With a lower memory-lock limit, HG runs and grows unpinned on 4 KB and THP pages, but on hugetlb pages an elastic arena cannot grow past the limit. HG warns at startup when the limit is too low.
  • Huge pages are optional: HG uses a hugetlb pool or THP when the host provides them, plain 4 KB pages otherwise.
  • Memory per object: a little more than the other allocators, from size-class rounding - +2% live memory on 400k registrations, 5-13% on tm's 1-4 KB objects at rates with no drops. Under overload the total is lower (see Summary): https://github.com/Lt-Flash/opensips/blob/hg_malloc/docs/benchmarks.md#memory-under-load
  • Other systems: HG is compiled in by default and builds off Linux too, but the huge-page tiers and returning memory to the OS are Linux-only. There an arena is a plain anonymous mapping of 4 KB pages that grows but does not shrink, and HG logs a NOTICE saying so the first time it would shrink.

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
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
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
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
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.

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.
 - 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.
   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, 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.
 - core:hg_stats (MI) per arena: tier, committed/reserved, per-class carve
   and reuse, grows/shrinks, 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.
 - A per-block free map at the pool boundary catches a double free when
   it reaches the shared pool and stops a free-list walk at a scribbled
   link; both are counted and logged and the process keeps running.
 - 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).
 - 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 or a scribbled free-list link.

Builds clean with gcc and clang -Werror, with HG_MALLOC compiled out, on
arm32/arm64, i386 and musl. Design, configuration, deployment and test
procedures: mem/README.hg_malloc.md.
@Lt-Flash
Lt-Flash force-pushed the feature/hg-malloc-v3-final branch from e749a5a to 31d1c97 Compare October 21, 2026 14:28
 - A process leaving for good (auto-scaling down) gives back what it holds
   in the shared arenas - its cached cells and the unused rest of its chunks
   (hg_cache_drain_self(), from dynamic_process_final_exit()); they stayed
   counted as live in their blocks, which never drained until restart.
 - TCP main's IO threads flush their caches at a job boundary when a sweep
   was published: the sweep RPC bumps hg_sweep_gen in the process it runs
   in (the counter is per process, and nothing called
   hg_cache_flush_if_due() before).
 - A double free that stays on the freeing thread's private list is refused
   and counted: the list head in every build, the whole list under
   HG_MALLOC_DBG. A large fragment is tagged when freed, and a second free
   of it is refused and counted.
 - HG_MALLOC_DBG: a freshly carved cell gets empty debug fields - on
   recycled memory they held old payload bytes, which the status dump at
   shutdown dereferenced.
 - A worker that ran out of memory while a trim or punch held the range
   token took it for a completed grow and failed its allocation; it now
   compares a grow generation, and grows itself otherwise.
 - The free-list rebuild skips pages being released; cell_block() counts
   and logs an address off the page grid instead of returning silently; a
   free the pool refuses no longer moves the statistics.
 - Process groups check their auto-scaling profile where they take it: a
   profile in k/m/g units or with 1000+ processes stops the startup again,
   as before memory profiles existed (it only disabled auto-scaling).
 - -m/-M: a negative size is refused; INIT:CAP with an allocator other than
   HG_MALLOC warns that only the initial size is used.
 - The rpm allocator error names -e (not -r); stale comments; the tier-1
   README note on growing back after a shrink.
The unit tests now also run with HG_MALLOC and HG_MALLOC_DBG: the core
suites, the allocator stress suite (test_malloc, disabled upstream since
2021, enabled when HG serves shm and pkg) and HG-specific cases:

 - pkg memory allocated before fork() realloc'ed and freed in the child
 - shm cells of every class freed by another process, then reused
 - the large tier, and growth of an elastic arena past INIT
 - a process leaving through dynamic_process_final_exit() gives its cells
   back (the arena's real_used returns to its baseline)
 - a cell freed twice in a row, and a large fragment freed twice, are
   refused, counted and never handed out twice
 - the corruption counters

A child reports through shm with a timeout, so a crash or abort fails its
test instead of hanging the run, and it starts only after internal_fork()
has returned in the parent. make test takes UT_ALLOC and UT_MEM (default
HP_MALLOC, as before); the CI build wrapper passes both into its
container, and each HG step fails unless the log shows HG_MALLOC was the
allocator under test.
@Lt-Flash

Lt-Flash commented Oct 22, 2026 •

Copy link
Copy Markdown
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?

…flush output before forking

The growth case stopped allocating 32 MB past INIT; on a slow runner the
maintenance process had already grown the arena beyond that, so nothing
grew and the case failed. It now allocates until a grow (or a re-grow
into holes) is seen. stdout is flushed before every test fork, or each
child printed the parent's pending TAP lines again on exit.
@Lt-Flash

Copy link
Copy Markdown
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.

add_stats_group() grew the array with
	sizeof(stat_var) * grp->no + 1
which C reads as (sizeof(stat_var) * no) + 1: the first stat added to a
group got a 1-byte array and its 8-byte pointer was written past it, and
every later one over-allocated a whole stat_var per element. It went
unnoticed because every allocator rounds a 1-byte request up to a cell
that holds the pointer; HG_MALLOC_DBG's red zone reports it.
…ks, memory limits

- Every cell header carries the cell's state (fresh, live, free) and a
  16-bit check over its address, class and state, seeded per run - a
  check against accidents, not a security mechanism. A free needs an
  intact live header and moves it to free in one compare-and-swap (a
  large fragment under the arena lock), so a double free is refused
  whatever came between the two frees, also two frees of one cell in two
  processes at once; an allocation takes only an intact fresh or free
  cell of its class, so a cell is never handed out twice; a damaged
  header or a pointer into a cell is refused, not followed. One multiply
  per operation: the check is base(address, class) ^ key(state).
- HG_MALLOC_DBG (mem/hg_dbg.{h,c}), per arena rather than global: a red
  zone and trailer after the size asked for, checked at free and realloc;
  freed cells poisoned and held in a per-thread quarantine (256 cells,
  about a thousandth of the reservation), checked when they leave it and
  reported with the site of the free; drained at exit and before an
  allocation would fail; a child forgets the parent's.
- Sanitizer hooks (mem/hg_san.h): ASan, or Valgrind with -DHG_VALGRIND,
  is told which bytes of a private arena a caller may touch;
  ThreadSanitizer is told about the arena lock. Nothing without them.
- The default host floor is MemTotal/20, at least the smaller of 256 MB
  and MemTotal/8: the flat 256 MB minimum was more than a small host has,
  and OpenSIPS refused to start there.
- Commit checks also respect the memory cgroup's limit
  (mem/hg_memlimit.{h,c}, cgroup v1 and v2): in a container or a unit
  with MemoryMax, /proc/meminfo shows the host, and arenas grew until the
  cgroup's OOM killer stepped in.
A grower that found the range token taken polled it without the lock and,
once it saw it clear, took the lock back. If another range operation had
taken the token in between - a grow following one that was refused, the
drain's punch right after its trim - it counted a wait timeout and failed
the allocation after microseconds. It now waits again, within the same
cumulative budget, until the token is clear under the lock or the budget
is spent. Found by model-checking the grow/trim/punch protocol.
…fuzz tests

- test_hg_header: a double free with frees in between, a damaged header,
  a pointer into a cell, a realloc of freed memory - all refused.
- test_hg_dbg (HG_MALLOC_DBG): writes past the request at free and around
  a realloc, small and large; a write after free, from this process and
  from another, found when the cell leaves quarantine and never handed out
  again; a child forked over a full quarantine finds its copy empty.
- test_hg_san (ASan builds): pkg cells open for the bytes asked for and
  closed after them and once freed; shm cells left alone.
- test_hg_memlimit: the floor formula, and the cgroup room read from fake
  cgroup v1 and v2 trees.
- test_hg_fuzz: a seeded alloc/free/realloc sequence against an
  independent model - no address handed out twice, every block keeps its
  bytes, realloc keeps the prefix, the arena's counters follow the model;
  a capped arena fails at its cap and serves again. HG_FUZZ_SEED and
  HG_FUZZ_OPS replay a failure.
- The child runner's verdict is published with release/acquire ordering.
The cgroup files count in 64-bit bytes, and hg_memlimit.c read them into
an unsigned long: a 32-bit build did not compile (1UL << 62), and with
that fixed it would have misread any limit or charge above 4 GB. They
are now read and compared as unsigned long long; a room of 4 GB or more
is no limit to a 32-bit process. struct hg_cg_info carries MB, which an
unsigned long holds on every build. test_hg_memlimit: a 6 GB limit.
/proc/meminfo counts the host in kB, and the checks turned that into
bytes in an unsigned long. On a 32-bit build with 4 GB or more available
the count wrapped: startup refused or let through depending on how much
happened to be free, grows refused at random, and the hugetlb pool and
the sums over processes (every child's -M INIT, a pkg grow times the
process count) misjudged - those sums pass 4 GB even when no single
process does. The room above the floor, the hugetlb pool and those sums
are now 64-bit on every build (HG_ROOM_ANY: no limit known), and so is
the cgroup's room.

/proc/meminfo is read through a test hook; test_hg_memlimit: on an 8 GB
host, a grow with 4 GB + 100 MB available passes and one with 8 MB above
the floor is refused. README: a test-table row for the memory checks.
@Lt-Flash

Lt-Flash commented Oct 23, 2026 •

Copy link
Copy Markdown
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

…rena

core:hg_stats gains two fields per arena:

- unpinned: 1 when the arena runs swappable - init could not mlock() it,
  or a grow the memory-lock limit could not cover went on unpinned.
- last_grow_refusal: what refused the most recent grow - cap,
  profile_ceiling, host_ram_floor, cgroup_limit, memlock_limit,
  hugetlb_pool, mlock_failed, hole_repin_failed, grow_wait_timeout or
  dry_run ("none" until the first refusal). It stays once growth works
  again; grow_blocked says whether it is current. Until now the cause
  was only in the line logged once per refusal episode, which names the
  episode's first cause, not a later one.

Each refusal site records its cause. An mlock() refusal is classified
once per episode and cached: telling the memory-lock limit from the
hugetlb pool reads /proc, and an arena that cannot grow retries on every
exhausted request. The classification is now a pure function,
hg_mlock_refusal_why(); the warnings it selects are unchanged.

Unit tests: the classifier's cases; the host-floor and cgroup causes on
the fake /proc/meminfo and cgroup tree; the dry-run and ceiling causes
through hg_buddy_grow() under the arena lock, with every borrowed field
restored before the lock is released.
… the contract at both ends

Four functions release hb->lock in the middle and take it again before
returning - the grow's two-phase commit: hg_buddy_grow (around the
populate and the in-flight wait), hg_buddy_shrink and hg_buddy_punch
(around the release), hg_buddy_regrow_holes (around the re-commit) - and
the three ticks that call them inherit it. Nothing at a call site said
so: a reader of carve_chunk() or hg_cache_flush_self() saw an ordinary
call under the lock, and every value read before it silently stopped
being trustworthy after it.

Now the lock knows when it is free - hg_lock_leave() writes HG_LK_NONE
into lk_cur before releasing - and hg_lock_assert_held() can check the
one thing that matters: held on entry, held again on return. The four
are renamed hg_buddy_*_unlocks and wrapped so the check runs at both
ends; hg_grow_tick, hg_shrink_tick and hg_grow_headroom_tick are wrapped
the same way. A breach counts as corruption.internal and logs a BUG
naming the function and the end. The bodies are untouched; the callers,
the comments and the hg_stats unit test use the new names.

Fail-first: a mutant that returns from the grow body right after the
populate's lock release fires "hg_buddy_grow_unlocks return without
hb->lock held" on the first grow of the exhaustion rig; the real code
runs the churn, the exhaustion grows and the give-back shrinks with
zero such lines.
The fast-path counters (used, fragments, cell_live) live in one slot per
process so that no two processes ever write the same words. TCP main is
not one process in that sense any more: since the threaded TCP main
landed, its IO pool threads allocate and free pkg concurrently - the WS
handshake parser alone - and all of them hit the TCP main's slot with
plain read-modify-writes. Lost updates, in both directions; the TCP
main's pkg "used" and "fragments" drifted under WSS load and nothing
could say by how much.

A thread now claims a slot of its own the moment it starts
(hg_thread_stats_init() from tcp_thread_routine, under HG_MALLOC): slots
are handed out from the top of the array through a counter in the shm
arena, so the index is unique across every process that could start a
thread, and the readers that sum the slots need no change. The main
thread keeps the process slot; a thread that finds no free slot (more
than three quarters of the array in use) shares the process slot as
before and says so once. One __thread read on the fast path, no shared
write.

hg_status() prints the live figures - used, fragments, cell bytes - so a
pkg dump of a process shows what its slots sum to.

Measured with a WSS handshake rig (64 connections x 3 rounds repeated
for 30 s against a 4-worker TCP main with an IO thread per CPU), the TCP
main's pkg counters read before the storm and twelve seconds after it,
everything freed:
  shared slot     - 54,016 handshakes: used +264 B, fragments +3 (lost)
  slot per thread - 51,456 handshakes: used +0, fragments +0
…the rest off the request path

Every drained block went straight back to the buddy (HG_GC_KEEP 0), and
it went back on the donate path: a worker whose private cache tipped
over its threshold pushed cells to the pool and, if that drained a
block, walked the drained list and freed it to the buddy - under the
arena lock, in the middle of a free. A class that then carved again a
second later walked the buddy for a fresh block, split it, and stamped
every cell, to get back exactly what it had just given away.

Keeping: each class keeps gc_keep[c] drained blocks - carved, empty,
the right class, which gpool_pop() already prefers over a carve. The
keep follows demand: the class's carves in the policy cycle just ended,
decaying by half per idle cycle, capped at HG_GC_KEEP_MAX (8) blocks and
1/256 of the arena per class. A class that stops carving stops keeping
within a few cycles; one that carves every cycle keeps what it carved.

Off the request path: the maintenance process returns drained blocks
beyond keep at most HG_GC_TICK_BUDGET (32) per second, round-robin over
the classes; a private arena does the same in its owner's sweep. The
donate path only acts past HG_GC_EMERGENCY (32) blocks beyond keep, so
the list cannot grow without bound between ticks; the pressure flush
waives the keep (a kept block is a page the carve could use, and the
alternative is a grow); the sweep flush and the exit drain return down
to keep after each batch. hg_stats reports gc_tick_blocks,
gc_inline_blocks, gc_kept_blocks and each class's gc_keep and drained
count.

Measured on the churn rig (A = before, B = this), two sweeps: carves
123 -> 112, GC passes 46 -> 26 with 18 of 42 blocks returned by the
tick, keeps settling at 3 (64 B - 1 KB), 2 (1.5-2 KB) and 1 (3-4 KB)
blocks, the donate path's hold max 68 -> 65 us; the GC itself costs
0.1 -> 0.3 ms in 80 s either way. Exhaustion rig (8:64, hold_mb 3):
grows 3 and the same give-back (56 -> 32 MB) in both arms, carves
184 -> 139, GC passes 108 -> 37, refill hold max 301 -> 92 us. Kept
blocks are bounded, and a shrink needs whole pages, which a keep cannot
pin for long once its class goes idle.
…ething

The idle-cache sweep sent its flush job to every process every
interval, whether or not that process had freed anything since its
last flush: each job is an IPC round trip, a wake-up and a few lock
holds in a process that may have been idle for an hour - 24 of them
every 30 s on an SBC, most of them for nothing.

A process now says when it has something to sweep: its free fast path
sets a dirty mark in its own stats slot - a store into the cache line
that path is already writing, so it costs nothing on top - and its
flush clears the mark first thing, so a free that lands during the
flush is caught by the next sweep. The dispatcher skips a clean
process and counts it (sweeps_skipped in hg_stats). The mark lives in
the shared arena's slot; a process's pkg caches are its own business
and invisible to the dispatcher, so every HG_SWEEP_FORCE_EVERY (4th)
interval sweeps everyone regardless - pkg is at most two minutes late,
which is well inside the shrink policy's own cadence. The IO-pool
threads keep the generation counter as before.

Measured on the churn rig over four idle intervals after the churn,
A = without, B = with: sweeps 5/10/15/20 vs 5/5/5/6 (skipped 4/13/22/26),
flush lock holds 145 vs 74, the busy processes' cells flushed the same,
the idle MI process's pkg swept at the forced interval (1 -> 2) instead
of every one (2 -> 5). An idle process's residue is collected at the
forced interval, at most two minutes later, instead of the next sweep.
…a constant 16

The per-thread private-cache budget is 25% of the arena divided by a
count of concurrent cache-holders, and that count was the constant 16 -
"a per-CPU IO pool" guess. On a box with more cores than that each
thread's share is too large (the caches can sum past the 25% the figure
is meant to bound); on a smaller box it is needlessly small.

The divisor now tracks the real IO pool: tcp_threads when the config
sets it, otherwise the online-CPU count - the same fallback
tcp_init_children() uses when tcp_threads is unset - clamped to
[4, 64] so a 1-CPU box does not hand one thread the whole share and a
128-CPU box does not shrink each cache to nothing.

tcp_threads is 0 until the config is parsed, which happens after the shm
and pkg arenas are created, so private_caps_init() uses the online-CPU
count at init and hg_autoscale_post_cfg() - the existing post-config
hook, pre-fork - re-runs it once tcp_threads is known, for both the shm
and the pre-fork pkg arena, so every worker inherits the result. The
caps are set here and only read on the fast path; this stays the last
write to them.

Verified: the DBG budget line reports "over N consumers" = the online
CPU count with no tcp_threads, and = tcp_threads when set
(tcp_threads=48 -> 48); churn smoke clean.
…x/nproc

The sweep is staggered by dispatching process i in second
(i * interval / nproc) of each interval. That assumed the live
processes were spread across the whole index range; they are not. The
processes that actually flush - the SIP receivers and the TCP pool -
hold a contiguous low block of indices, so they mapped into the first
fraction of the interval, and skipping the clean processes (previous
commit) tightened the cluster further: measured on a production SBC,
the flushes fell in the first ~13 s of the 30 s interval instead of
spanning it.

A process's sweep second is now a multiplicative hash of its index
(i * 2654435761 mod interval), which scatters any contiguous block of
indices across the whole interval. Each index still maps to exactly one
second per interval, so every process still flushes once per interval -
only the phase changes.

Verified: the busy indices (SIP receivers 6-17, TCP 26-29) span
seconds 0..28 after the change instead of ~4..13 before; all 39
process slots still land one-per-second across the interval.
@Lt-Flash

Copy link
Copy Markdown
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).

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.

2 participants