cachedb_perf: high-performance local memory cache built on modern kernel features#4118
Draft
Lt-Flash wants to merge 8 commits into
Draft
cachedb_perf: high-performance local memory cache built on modern kernel features#4118Lt-Flash wants to merge 8 commits into
Lt-Flash wants to merge 8 commits into
Conversation
Lt-Flash
pushed a commit
to Lt-Flash/opensips
that referenced
this pull request
Jul 24, 2026
The isolated-cache, 50k end-to-end and 100k three-way benchmarks are all published in PR OpenSIPS#4118; only the two-socket huge-page-arena end-to-end number is still outstanding.
A from-scratch cachedb backend for large, high-churn local caches, selected by URL scheme (perf://), as a drop-in alternative to cachedb_local. It implements the same cachedb_funcs vtable, so any module taking a cachedb_url works unchanged. Motivation: cachedb_local sizes its hash table once from cache_collections and never resizes, so a large cache degrades to long lock-held chain walks (measured while benchmarking topology_hiding's th_store backend). This module keeps lookups flat as the cache grows. Core design: - 64-byte, one-cache-line buckets with 1-byte per-slot tags (SWAR scan) and a per-bucket seqlock: readers take no locks, snapshot optimistically and re-check, with a bounded retry and a lock fallback. Legal because shm is mapped once before fork and never unmapped, so a stale pointer read is bounded garbage the version re-check discards. - Slab arena: entries live in fixed-size cells in class-bound chunks that are never returned to shm; per-process bump allocation with no atomics on the fast path (byte 0 of every cell is the immutable class id). - Runtime growth: a segmented directory + linear hashing splits one bucket at a time from the single maintenance timer - buckets never move, the routing word is re-checked on a miss, and the load-factor cliff cannot form (growth_load_factor). - Native int64 counters (add/sub) accumulated under the bucket lock; a versionless TTL bump that refreshes expiry with one atomic store; an overflow side table for full buckets. - No allocator call ever happens under a bucket lock. Operability and durability: - Per-collection expiry sweep (min-expires hints), per-process sharded statistics, and a huge-page arena (hugetlb -> THP -> MADV_COLLAPSE -> 4K, detected by trying; arena_hugepage_mb), measured +7-13%. - Introspection MI (all lock-free): perf_keys, perf_scan (cursored, Redis SCAN semantics), perf_dump, perf_get, perf_set, perf_ttl, perf_del, plus perf_stats; glob script functions perf_del/perf_mget/perf_mget_json. - Observability events: E_CACHEDB_PERF_EXPIRED / NOMEM / GROWN / MEM_DEGRADED, each gated by evi_probe_event so a subscriber-less cluster pays nothing, none on the hot path. - DB persistence: whole-collection save/load to any db_* backend (perf_save/perf_load, db_mode startup-load / shutdown-save), TTLs kept as absolute wall-clock time so they survive a restart. A save/load is a full blocking snapshot - a maintenance/bootstrap operation, not per-request. - Cluster sync: perf_sync saves a collection then signals peers over clusterer to reload it from the shared DB (one message per sync, not per operation), raising E_CACHEDB_PERF_SYNCED. A pull-from-DB refresh for single-writer/read-replica topologies - deliberately not per-operation replication, which would tax the lock-free path this module exists for. v1 is single-node in-memory with optional db_* persistence and the DB- backed cluster refresh; per-operation replication is out of scope.
Lt-Flash
force-pushed
the
feature/cachedb-perf-devel
branch
from
July 25, 2026 00:05
cf2d1e4 to
642c828
Compare
added 7 commits
July 25, 2026 16:39
…rors
A passing startup selftest printed two ERROR lines:
ERROR: pcache_ht_add: value of <nan> is not an integer
ERROR: pcache_ht_store: key 7 + value 65536 bytes exceed the 65536
byte record limit
Both are the selftest deliberately driving a rejection path - a
non-numeric add and an oversize store - to prove each is refused. The
refusals were correct; only the logging was wrong, and it made a clean
run look like a broken one.
Adds a file-local st_expect_reject flag, set around just those two
calls, that downgrades exactly those two messages to DBG. It is only
ever set pre-fork and single-threaded from the selftest, so there is no
concurrency concern, and it is never set in normal operation - a genuine
non-numeric add or oversize store still logs at ERROR, which is
verified from a script.
The obvious alternative, wrapping the calls in the core's
set_proc_log_level(), is unusable here and worth recording: it writes
pt[process_no], and the process table has not been allocated yet when
mod_init runs the selftest, so it segfaults on a NULL pt.
Entries whose wall-clock expiry has passed were skipped at load but left in the table. The snapshot is only ever rewritten by a save, so with db_mode=1, or after any shutdown that was not graceful, dead rows stayed there indefinitely - reread and discarded on every start, and cluttering the table with what looks like live data. The load walk already identifies them, so it now counts them and issues one ranged delete per collection once the result set is released: collection = X AND expires > 0 AND expires <= now. The expires > 0 term is load-bearing: 0 means "never expires" and would otherwise match the <= comparison and destroy exactly the entries meant to be kept. A failure to delete is logged as a warning and does not fail the load - the entries are ignored either way, and the next save clears them. Verified against a table holding three expired rows, one live row, one never-expiring row and an expired row in a collection that is not loaded: the three expired rows are removed, the live and never-expiring rows survive, the other collection is untouched, and the load reports two entries.
Every arena cell carries its size class in byte 0, and both pcache_cell_free() and pcache_cell_free_global() trust that byte to pick the free list. Records honour this (pcache_rec_t.cls is offset 0, set once at carve time and never rewritten), but the overflow chain node did not: struct povf started with its link pointer, so linking a node into a chain overwrote the class byte with the pointer low byte (0x40/0x80/0xC0 for 64-byte-aligned cells). When the expiry sweep or a remove later freed such a node, the free indexed class 64/128/192 into the 21-entry class array - out of bounds - corrupting the private pool state and free lists. The damage then surfaced far from the cause: crashes in the pcache_cell_free donate walk (dereferencing a clobbered next link), lookups missing keys whose cell had been handed out twice, and shutdown segfaults in the attendant while destroying the corrupted table. Reproduced under an 8-process soak with a 1s expiry sweep and deep overflow chains; caught red-handed as a free of "class 192". Fix: reserve byte 0 of struct povf for the class id, moving the link pointer off offset 0, mirroring the pcache_rec_t contract. Also harden both free paths to validate the class byte and leak the cell with an LM_CRIT instead of corrupting the pools should it ever be invalid again.
The removes counter only tracks explicit remove()/perf_del calls, so a
TTL-heavy deployment shows a tiny removes figure while hundreds of
thousands of records quietly age out - with no counter saying where they
went. The sweep already tallied reclaimed records internally
(destroyed), but nothing user-visible carried the number.
Add two read-only statistics, both per-collection in perf_stats and
aggregated in the statistics interface:
* expired - records reclaimed by the TTL expiry sweep, kept separate
from removes;
* destroyed - total records whose cells were freed back to the arena,
from any cause (equals removes + expired; a same-key
overwrite reuses the cell in place and does not count).
With these, entries = created - destroyed holds, and a large
stores-vs-entries gap can be attributed at a glance to overwrites versus
expiry versus deletes.
Reading the health of the cache from raw hits/misses takes mental math, and the ratio is the quickest signal that cached state is being lost or aged out too early. Add hit_rate_pct (hits / (hits + misses), one decimal) to each collection in the perf_stats MI output, alongside a hit_rate_note that states the healthy expectation inline - the large majority of lookups should hit (upwards of 80% under steady dialog traffic), and a low or falling rate points at lost or too-short-lived state.
…he truth The hit_rate_note was a constant string that always read "healthy: the large majority of lookups hit (>80% ...)" whatever the measured rate. It was written as guidance on how to read the number but phrased as a verdict, so at 29.7% on a live SBC it asserted the opposite of the truth - worse than no note, since it trains the reader to skip the figure. It now follows the rate: no lookups yet, healthy at 80% or above, fair at 40% or above with the note that this is normal while the cache refills after a restart, and low below that. That restart caveat is the point. The counters are cumulative since startup, so every rate is a lifetime average: sequential requests arriving for dialogs older than the cache all miss, and keep dragging the average down long after the cache has recovered. perf_stats_reset re-baselines them so the next reading covers a fresh interval, without restarting OpenSIPS. With no parameter it resets every collection, or one by name. The counters are not rewound to do it. Each process owns its own counter cache line and must never have it written from another process, which is why they are sharded in the first place, so the reset only reads them and records a baseline that pcache_ht_totals() subtracts. A worker incrementing concurrently simply lands in the next interval. entries is deliberately excluded from that subtraction: it is a live gauge computed as created minus destroyed, so rebasing those two would report an empty cache while it still held keys. Verified - across a reset the counters go to zero while entries stays put.
A save is one statement per row, and on a backend that commits each one separately that is not merely slow, it does not finish: 30 000 entries to db_sqlite ran at ~140 rows/s and blew the 60 s SHUTDOWN_TIMEOUT, so the core aborted the process with 7 918 of 30 000 rows written. Since a save begins by deleting the collection, what was left was a partially written table where a complete snapshot used to be, with nothing in the log or the reply to say so. Wrapping the whole snapshot in one transaction fixes both halves. The same 30 000 entries now take 0.35 s (86 000 rows/s, a ~600x change), and the delete plus the inserts become atomic - uncommitted work is rolled back when the connection closes, so an interrupted or failed save leaves the previous snapshot in place instead of destroying it. The error paths deliberately return without committing for that reason. No single spelling starts a transaction everywhere, so both are tried: SQLite and PostgreSQL take "BEGIN TRANSACTION", MySQL takes "START TRANSACTION". The bare "BEGIN" that all three accept is unusable, because db_sqlite's raw_query only reaches its exec path for statements at least as long as "select" and mis-parses anything shorter as a SELECT. Backends with no raw_query at all - db_redis - simply save without a transaction, as before. The save now also logs its duration and rate, and warns past 10 s that a shutdown save has a 60 s budget, noting when the backend took no transaction. For reference, db_redis measured 5.05 s for the same 30 000 rows: it has no per-row commit to amortise, but does pay a round trip per row.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR introduces
cachedb_perf— a new, from-scratchcachedbbackend for large, high-churn local caches, selected by URL scheme (perf://). I'm trying to build a much faster local cache module by combining a cache-conscious, lock-free-read design (CLHT / MemC3 lineage) with what recent Linux kernels make possible: overcommit hugetlb pools, shmem THP,MADV_COLLAPSE,MADV_POPULATE_WRITEand swap pinning.It implements the same
cachedb_funcsvtable as every other backend, so any module taking acachedb_urlworks unchanged, and core script usage (cache_store("perf", ...)) only changes the backend name. v1 is a single-node in-memory cache with optionaldb_*persistence — whole collections can be saved to and loaded from an SQL backend, so state survives a restart (see the DB persistence section below). What it deliberately does not do iscachedb_local-style per-operation replication (cluster_idwrite-through); cross-node sharing is instead a shared-DB refresh model (theperf_synccluster-sync below), not a streamed op log.Draft mainly to land multi-node validation of the cluster-sync path and optional index refinements — but the module is functionally complete (data ops, expiry, runtime growth, statistics, huge-page arena, a full introspection MI, observability events,
db_*persistence, and aperf_synccluster refresh), validated by built-in selftests, a script-level end-to-end suite, and a multi-process correctness soak.Motivation
Found while benchmarking
topology_hiding's cacheDB state backend (#4114): settingcache_collections "th=16"cut the load balancer's CPU from 45% to 29% at 4000 CPS. The root cause is structural:cachedb_local's hash table is sized once fromcache_collectionsand never resized. The default is 512 buckets, most deployments never set the parameter, and at 50 000 entries that is a load factor of ~98 — roughly 50 string compares and 50 dependent cache misses per lookup. The module also exports zero statistics, so the cliff is invisible in production.Rather than progressively rewriting a module every deployment depends on, this is a clean backend: operators opt in per collection by changing a URL.
Configuration
Every parameter is optional — with none set you get a single
defaultcollection at 16384 buckets, plain shm, growth and the expiry sweep on. In practice you declare the collection(s) you use and point consumers at them.cache_collectionsdefault(14→ 16384 buckets)nameorname=size,;-separated.sizeis the log2 of the initial bucket count, clamped to[4, 24]; the table grows past it at runtime, so it is a starting point, not a ceiling. Thecachedb_local/rreplication marker is rejected — this cache is single-node.cachedb_urlperf://(thedefaultcollection)perf:///th) or, equivalently, its host part (perf://th);perf://alone selectsdefault. Prefix a group (perf:grp:///th) to address a specific URL from the script. Naming an undeclared collection is a startup error. Repeatable.expiry_sweep_period10disables reclamation (expired entries hold their memory until overwritten).growth_load_factor20disables growth (fixed-size table, i.e.cachedb_localbehaviour).growth_budget4096arena_hugepage_mb0(off)MADV_COLLAPSE→ 4K ladder;0uses plain demand-faulted shm. WantsLimitMEMLOCK=infinity; warns and continues unpinned otherwise.arena_selftest0htable_selftest0db_urldb_*(SQL) backend to persist collections to (the matchingdb_*module must be loaded). Unset = no persistence.db_tablecachedb_perfdb_mode0(off)persist_collections:1= load at startup,2= load at startup + save on graceful shutdown.0= MI-only.persist_collectionsdb_modeauto-loads/saves.perf_save/perf_loadstill work on any collection on demand.sync_cluster_id0(off)perf_sync(needsclustererloaded + adb_url). Soft: with either missing,perf_syncdegrades to a DB save.The motivating setup — topology_hiding state backend:
Generic script cache + glob operations:
MI commands
The full management interface — the operator visibility
cachedb_localnever had. Every command is lock-free (seqlock reads), so a key scan or dump never stalls SIP traffic; every name carries theperf_prefix to match the script functions and stay clear of the core's bareget/set. Each is invoked module-namespaced ascachedb_perf:<command>(OpenSIPS 4.0+). Arguments in<>are required,[]optional; an omittedcollectionmeans the grouplesscachedb_url's collection (wherecache_store("perf", …)writes).perf_stats[collection]perf_stats_reset[collection]perf_keys<glob> [collection] [limit]KEYSequivalentperf_scan<cursor> [glob] [count]SCAN) over the default collection: start at cursor0, repeat with the returned cursor until it comes back0.countbounds the buckets visited per call. The answer for a large cache, whereperf_keyswould truncateperf_dump<glob> [collection] [limit]perf_keysbut includes values — opt-in, never the defaultperf_get<key> [collection]-1= never) and sizeperf_set<key> <value> [ttl] [collection]ttlin seconds (0or omitted = never expires)perf_ttl<glob> <ttl> [collection]expiresstore, readers undisturbed);ttlin seconds (0= never). Returns the count updated. A literal key matches exactly oneperf_del<glob> [collection]perf_del()script functionperf_save[collection]db_urlbackend (all declared collections if none named)perf_load[collection]db_urlbackendperf_sync[collection]MI parameters are named, so any sensible subset resolves — e.g.
perf_keys <glob> limit=Nwithout a collection, orperf_set <key> <value> collection=Cwithout a ttl.Note the argument order: the glob-taking commands (
perf_keys,perf_dump,perf_del,perf_ttl) take the glob first and the collection second, soperf_dump mycolllooks for keys namedmycollrather than dumping that collection — useperf_dump "*" mycoll. Onlyperf_get/perf_setlead with a key. Quote globs, or the shell expands them before opensips-cli sees them.perf_statscounters are cumulative since startup (or the lastperf_stats_reset), so a hit rate read straight after a restart is dominated by sequential requests for dialogs older than the cache and recovers only as those age out. Either reset once the cache has warmed, or — better for monitoring — poll twice and difference the counters.Events
Four EVI events let a script or monitor react to the cache. Each is gated by
evi_probe_event(), so with no subscriber it costs one shared read, and none sit on the lock-free get/set path.E_CACHEDB_PERF_EXPIREDevent_expired_collections)collection,keyE_CACHEDB_PERF_NOMEMcollection,key,sizeE_CACHEDB_PERF_GROWNcollection,prev_buckets,buckets,splits,entriesE_CACHEDB_PERF_MEM_DEGRADEDrequested_mb,tier,backing,overcommit_pagesE_CACHEDB_PERF_SYNCEDperf_synccollection,source_nodeDB persistence
With
db_urlset, a whole collection can be persisted to anydb_*(SQL) backend. The DB is a shared, durable store; the cache is an in-memory view over it. A save is a full snapshot — the collection's rows are deleted and every live entry re-inserted; a load restores them. TTLs are stored as absolute wall-clock time so they survive a restart (the cache's own expiry is monotonic ticks, which reset on reboot); already-expired rows are skipped on both save and load, and native counters round-trip as their decimal value. This is single-node durability; cross-node sharing over the same DB isperf_sync(below), still not per-operation replication.The table (default
cachedb_perf) has four columns:collection(string),pkey(string),pvalue(BLOB, binary-safe),expires(int, absolute unix time,0= never). The startup load runs before the workers fork, so every worker starts with a warm cache.The snapshot is one transaction — measured
A save is one statement per row, so on a backend that commits each one separately it is not merely slow, it does not finish. Before this was addressed, 30 000 entries to
db_sqliteran at ~140 rows/s, blew the 60 sSHUTDOWN_TIMEOUT, and the core aborted the process with 7 918 of 30 000 rows written — and since a save deletes the collection first, what remained was a partially written table where a complete snapshot had been, with nothing in the log or the reply to say so.The whole snapshot now runs inside one transaction, which fixes both halves:
db_sqlitedb_redisThe ~600× on SQLite is the per-row
fsyncdisappearing; it was never slow at inserting.db_redishas noraw_query, so it takes no transaction — it has no per-row commit to amortise, but it does pay a network round trip per row, which is why it is now the slower of the two.More important than the speed: the delete and the inserts are atomic. Uncommitted work is rolled back when the connection closes, so an interrupted or failed save now leaves the previous snapshot in place rather than destroying it. The error paths return without committing deliberately.
No single spelling starts a transaction everywhere, so both are tried — SQLite and PostgreSQL take
BEGIN TRANSACTION, MySQL takesSTART TRANSACTION. The bareBEGINall three accept is unusable:db_sqlite'sraw_queryonly reaches its exec path for statements at least as long asselect, and mis-parses anything shorter as a SELECT.A save also logs its duration and rate, and warns past 10 s that a shutdown save has a 60 s budget — noting when the backend took no transaction, since that is the case where the budget is at risk.
With db_redis
db_redisneeds a schema declared for the table before first use — it fails at load without one, and none ships forcachedb_perf:Its primary key is a single column while a row here is identified by (collection, pkey), so persist one collection: with two, identical key names would collide and the later save would overwrite the earlier. The
/0database component is required by the core URL parser even though Redis-cluster mode ignores it.Cluster sync
perf_sync [collection](MI and script function) builds on the same DB: it saves the collection, then signals the cluster overclustererto reload it — one message per sync, not per operation, so the hot path is untouched. A reload overwrites a peer's copy from the DB, so it's for single-writer / read-replica topologies (one authority updates the DB, the others refresh); a node reloads and raisesE_CACHEDB_PERF_SYNCED. With no clusterer /sync_cluster_id0 it degrades to a DB save. Same blocking cost asperf_save, so: an occasional refresh, not a live primitive.The capability registers with the clusterer, so it shows in its
clusterer_list_capMI — verified on a single-node cluster (withcachedb_perfloaded beforeclusterer, confirming the soft dependency reorders init):Enabling the kernel memory backing
The huge-page arena (
arena_hugepage_mb) climbs a detect-by-trying ladder atmod_init: it attempts each tier in turn and keeps the best one the running kernel actually grants — you do not pick a tier, you enable what you can and the module reports what it got. The tiers, fastest to slowest (the cost is the isolated 2 MB pointer-chase from §5 of the study):MAP_HUGETLBsysctlmlockneededMADV_HUGEPAGEsysfswritemlock(see below)MADV_COLLAPSEafter fillmlock(see below)mlock(still reserved+pinned)Tier 1 — overcommit hugetlb (the one to prefer: on-demand, nothing held while the cache is small, and no memlock grant needed). Allow enough on-demand 2 MB pages for the arena (
arena_hugepage_mb / 2, plus a small margin):Tier 2 — shmem THP (used if tier 1 is unavailable). Put shmem THP in
adviseso it honours the module'sMADV_HUGEPAGE:Tier 3 —
MADV_COLLAPSEneeds no sysctl (kernel ≥ 6.1); on some 6.12 builds it also wants tier 2'sshmem_enabled=advise. Tier 4 is the default and needs nothing.Swap-pinning (
mlock) — tiers 2–4 only. When tier 1 is unavailable the arena is a regular shared mapping, which the modulemlock-pins pre-fork so it can't be swapped out from under the lock-free readers. systemd's defaultLimitMEMLOCK=65536(64 KB) makes thatmlockfail on any real arena — the module then warns and runs unpinned (swappable); the huge pages still form, they are just not pinned. Tier 1 (MAP_HUGETLB) is exempt and needs none of this. To pin tiers 2–4, grant it once:Turn it on and confirm what landed:
opensips-cli -x mi cachedb_perf:perf_stats # -> memory_tier (1 hugetlb .. 4 plain 4K) + memory_backingmod_initalso logs the achieved tier and, when it falls short of tier 1, the exactsysctlto reach it and the measured cost of running without it.The study
Everything below was measured, not assumed — the benchmark rig ships in-tree (
modules/cachedb_perf/bench/,make run, no OpenSIPS build needed) and every figure is reproducible. Hosts: Xeon E5-2699 v4, kernels 5.4 / 6.8 / 6.12; the NUMA numbers come from a vNUMA-pinned two-socket guest on the same silicon. The rig models structures and cache behaviour (single process, threads); it ranks designs rather than predicting server throughput.1. The index structure
strncmp(cachedb_localtoday)Load factor alone is a 20× spread. The chosen design is within 8% of the fastest structure measured, and the fastest one (flat open addressing) is impossible to resize across processes in shm. Also checked:
core_hash()is not at fault (chi²/df 0.65–1.18 vs FNV-1a on thids, dialog ids, AoRs and call-ids — statistically indistinguishable), so the module keeps it.2. Concurrency — an honest negative result
The hypothesis was that
cachedb_local's write-lock-on-every-read destroys scaling. It does not: with a well-sized table workers rarely collide on a bucket lock, and it scales 8.4× on 8 threads. The 4× gap is a per-operation constant factor (no atomic RMW on reads, one cache line per bucket, tag filtering) — not a scaling win. Against the shipped 512-bucket default the gap is ~90×.3. The read protocol — measured before being believed
Readers take no locks: a per-bucket seqlock with bounded retries and a sleeping-lock fallback. What makes this legal in OpenSIPS specifically: shm is mapped once before fork and never unmapped, so a stale pointer read is garbage-but-not-a-fault, and the version re-check discards it — the value is always copied out inside the optimistic section, with every length clamped and every pointer extent-checked before use.
The one credible alternative (QSBR / pointer-publication, no version check at all) was implemented in the rig and rejected on the numbers: identical at 100% reads (on x86/TSO the version loads hit the already-loaded bucket line — the seqlock is free) and ahead only under single-hot-bucket write contention that SIP traffic doesn't exhibit (seqlock retries measured at 1.2 per 1000 reads on a uniform 95/5 mix). The useful piece survived without any grace-period machinery: a byte-identical
set()that only refreshes the TTL — the dominant write in the motivating workload — takes the bucket lock but skips the version bumps and the memcpy entirely. One atomicexpiresstore; concurrent readers of the bucket are undisturbed.4. What was rejected: write staging and queueing
A shared staging buffer loses throughput as threads are added — one atomic append offset is a hotter point of coordination than thousands of bucket locks. Queued writes do less than half the work of writing directly, and break read-your-writes semantics. The rule this established shapes the whole module: per-process regions win for allocation (the arena uses them — zero atomics on the alloc fast path), but never for staging live entries.
5. Modern-kernel memory backing
OpenSIPS shm today is demand-faulted 4K pages — no
MAP_POPULATE, no hugepages, nomadvise, nomlockanywhere inmem/. A multi-hundred-MB cache pays for that in TLB misses. Four routes to 2M pages, ranked as a runtime fallback ladder:vm.nr_overcommit_hugepages+MAP_HUGETLBshmem_enabled=advise+MADV_HUGEPAGEMADV_COLLAPSEafter fillshmem_enabled=neveradviseKey findings:
MADV_COLLAPSEdivergence proves version checks lie, and they lie in both directions: a later 6.12 (6.12.96, Debian 13) collapses fine withshmem_enabled=neveragain — same major version, opposite behaviour, and the probe silently got the better tier. The module already does this inmod_init: it reports the achieved tier and, when hugetlb is unavailable, logs the exact sysctl and the measured cost of running without it.THPeligible: 0, collapseEINVAL; the fix is reservePROT_NONE, thenMAP_FIXEDthe shmem at a 2M boundary), and a shmemMADV_COLLAPSEcreates the huge folio without PMD-mapping the caller — verify via theShmemHugePagesmeminfo delta, not smaps.mlockinmod_initworks across fork (locks are not inherited, but the pages are shared — one pre-fork lock pins the arena for every worker). Found a production blocker on our own SBCs while checking: systemd's defaultLimitMEMLOCK=65536meansmlockof any real arena fails — the unit needs a one-line drop-in.6. Expiry
min_expires, unlocked skipEven the full sweep is 0.13% of a core — expiry is a memory reclamation problem (entries squatting up to
cache_clean_period), not a CPU problem. Themin_expireshint gets 30× for zero hot-path cost; the wheel's further 84× buys nothing and costs 74 ns/insert plus 16 B/entry.7. NUMA — measured on a pinned two-socket testbed
Measured in-guest on a Proxmox VM with vNUMA bound per host socket (
numaN: ...,hostnodes=N,policy=bind, dual E5-2699 v4 host — plainnuma: 1withouthostnodesfabricates topology over one memory domain and measures nothing). Two consequences, both already reflected in the design rather than motivating changes:Where NUMA does matter for the roadmap: page walks against remote memory amplify TLB-miss cost, so the huge-page backing is expected to be worth more on two sockets than the 1.42× measured on one — to be quantified in the end-to-end benchmark. One refinement from the same testbed: with
pdpe1gbexposed, 1 GB pages are allocatable at runtime on a fresh boot (2 granted right after boot) — the earlier "unobtainable" holds only once uptime fragments memory. The ruling against them stands on arithmetic: 2 M pages already give a ≤1 GB arena full TLB coverage on this hardware.Measured: cachedb_perf vs cachedb_local, in-process
The bench rig above ranks designs in a single process. This measures the real modules — real OpenSIPS 4.1-dev, real shared memory, N real worker processes — driving the
th_storeaccess pattern (16-byte thid keys, 200-byte values, 95% get / 5% set). Both backends did byte-for-byte identical work (same 22.8 M hit count). Release build (-O3),Q_MALLOC, pinned to one 8-core socket.Same conditions — both collections sized to 65536 buckets (
th=16):Two effects drive the gap. Scaling: cachedb_perf's lock-free reads scale 10.0× from 1→8 workers vs cachedb_local's 6.6× (it takes a bucket lock on every read). The default: most deployments never set
cache_collections, so cachedb_local runs at its 512-bucket default — at 50k entries that is a load factor of ~98, 3529 ns/op, and cachedb_perf is 7.8× faster than cachedb_local as typically shipped.Honest notes: numbers include a real
pkg_malloc+freeof the 200-byte value on every get (the th_store copy-out), so this is per-operation cost, not a bare lookup. This deliberately isolates the cache from the SIP layer. In a full LB the per-call cost is SIP parsing, header manipulation and transaction state plus the th_store put/get — there is no per-call encryption (th_store values are stored in the clear; the only crypto is one cheap MD5 to derive the key), so the cache is a direct share of that cost. An end-to-end run under 50 000 held calls confirms the direction below.End-to-end: TH under 50 000 held calls
Same LB, topology_hiding with
th_state_urlpointing at each backend (65536 buckets), ramped while holding ~50 000 concurrent calls. "Sustained" means <5% failures and peak concurrency ≤75k (actually still holding 50k, not backlogging):cachedb_perf sustains the 6000-CPS rung where cachedb_local breaks — a sustained-ceiling lift from ~3941 to ~5775 CPS (~1.5×) at 50k live th_store states. The end-to-end gain is smaller than the isolated-cache 2.3–7.8× because SIP processing is the larger share of per-call cost, but it lands exactly where the cache matters: the high-concurrency point where cachedb_local's lock-on-every-read serializes the workers.
At 100 000 concurrent calls: cachedb_perf-TH vs dialog-TH vs no-TH
Pushing to 100 000 held calls, comparing three topology-hiding strategies on the same LB (huge pages / THP enabled): cachedb_perf-backed th_store, the in-memory dialog module (
force_dialog), and plain record-routing with no topology hiding at all.At 100k concurrency cachedb_perf-TH is nearly as cheap as doing no topology hiding at all — it tracks the no-TH curve and holds 4000 CPS at 93% CPU. dialog-TH is the loser here: it saturates CPU by 3000 CPS, breaks at 4000, and carries ~2.5× the resident memory (a full per-dialog state machine + timers vs one compact th_store entry). This is a crossover from lower concurrency, where dialog leads — cachedb_perf's flat per-entry cost wins as the live-state count climbs.
Caveats: the single load generator is unstable at 100k (some cachedb_perf mid-rungs showed generator-side failures at low LB CPU — discarded); and the huge pages here are whole-shm THP that benefits all three equally — the module's own huge-page arena is a separate mechanism, measured on its own in the next section.
Huge-page arena (CP-20)
The arena can now back its chunks with 2 MB huge pages instead of 4 K (modparam
arena_hugepage_mb; the reservation is 2M-aligned, mlock-pinned, created pre-fork and shared by all workers). Measured on the real module (−O3, 8 workers, medians of repeated runs):The gain is larger at 50k, where the working set spreads across enough memory to thrash the 4K TLB — exactly the case huge pages relieve. It's below the 1.19–1.43× the pointer-chase showed in isolation because each operation also pays for the hash, the tag scan and a copy of the value. Detection is by trying each tier (hugetlb → THP → collapse → 4K), never by kernel version;
mlockwantsLimitMEMLOCK=infinityand warns-and-continues otherwise.Design in brief
cachedb_localnests the shm allocator inside bucket locks in five places); records are pre-built beforelock_get, frees happen strictly after release.add/substore an int64 and accumulate fixed-width under the bucket lock; every user-facing read formats them as decimal. No parse/format/realloc in the critical section.Script interface
Single-key operations go through the core cache functions unchanged. The module's own multi-key operations are
perf_-prefixed with Redis verbs — deliberately not thecachedb_localparity names (cache_remove_chunk/fetch_chunk), so migrating those two calls requires a script change; everything else is drop-in:All three ride one lock-free walker (Redis SCAN-class guarantee) with binary-safe JSON escaping;
iter_keysuses the same walker. Two startup selftest modparams (arena_selftest,htable_selftest) ship as permanent diagnostics and fail startup on any mismatch.The same walker backs the introspection MI (full command table in the MI commands section above) — the operator visibility
cachedb_localnever had, and lock-free so a key scan never stalls SIP traffic.perf_scanis the answer for a large cache whereperf_keyswould truncate: its cursor is an ascending bucket index, so it stays valid across a concurrent resize and returns every entry present throughout at least once — without Redis's reverse-binary cursor masking, because the table only grows (buckets never move).Status
1 << sizeon an unbounded unsigned is UB), memory-tier probe with actionable sysctl guidanceiter_keysperf_del/perf_mget/perf_mget_jsonexpiry_sweep_period(default 1 s), reclamation through the global pool strictly after lock releaseupdate_statcounter would recreate the 0.72× collapse measured above), exported as tencachedb_perf:core stats and a per-collectionperf_statsMI (load factor, overflow, seqlock retries/1k, backing tier,expired/destroyed, and a hit rate whose accompanying note follows the measured value rather than asserting a verdict).perf_stats_resetre-baselines the cumulative counters for a fresh measurement interval without a restart, leaving live gauges alonecachedb_localfundamentally cannot do): one-bucket-at-a-time splits driven from the single-process maintenance timer, no rehash, overflow left findable;growth_load_factorkeeps the bucket shape as entries scale. Verified: 1000 entries → 484 splits → 500 buckets, all keys intactperf_keys/perf_scan/perf_dump/perf_get/perf_set/perf_ttl/perf_delas MI commands, all lock-free (a key scan never stalls writers, unlikecachedb_local's).perf_scanis cursor-based (Redis SCAN): an ascending bucket cursor, stable across a concurrent resize, every entry returned at least once. Verified over a datagram MIE_CACHEDB_PERF_EXPIRED(per reaped key, opt-in per collection),E_CACHEDB_PERF_NOMEM(a write dropped because the arena is full),E_CACHEDB_PERF_GROWN(a table resized, with the before/after span),E_CACHEDB_PERF_MEM_DEGRADED(huge pages requested but the arena landed below hugetlb). Eachevi_probe_event()-gated (free with no subscriber) and off the hot path; verified end-to-end overevent_routesarena_hugepage_mb), lock-free bump from it, shm_malloc fallback; measured +7–13% (see above)torn_reads=0, counter sum == adds, all immortals intact; clean under theQ_MALLOC_DBGredzone allocator and under all three core allocators (F_MALLOC/Q_MALLOC/HP_MALLOC, driving both pkg and the arena's shm chunk backing)th_state_urlbenchmark againstcachedb_local(50k held calls) and against dialog-based topology hiding (100k held calls) — both sections abovedb_*backend (perf_save/perf_loadMI, plusdb_modestartup-load / shutdown-save), TTLs kept as absolute wall-clock time so they survive a restart. The snapshot runs in a single transaction where the backend supports one, which makes it both fast and atomic — 30 000 entries save in 0.35 s ondb_sqlite(against ~60 s and an aborted process before), and an interrupted save now rolls back rather than replacing a good snapshot with a partial one. Verified end to end withdb_sqlite(save → shutdown-save → startup-load, values intact, TTL decremented across the cycle) and withdb_redis, which takes no transaction and measures 5.05 s for the same rows. Rows whose wall-clock expiry has passed are dropped at load rather than merely skipped — otherwise, withdb_mode=1or after any shutdown that was not graceful, dead rows accumulate in the table indefinitely. Single-node durability, not replicationperf_sync) — MI command + script function that saves this node's collection to the DB, then signals peers over theclustererAPI to reload it (one message per sync, not per operation); each peer reloads from the DB and raisesE_CACHEDB_PERF_SYNCED. Soft dependency — degrades to a DB save with no broadcast if clusterer/sync_cluster_idis absent. A pull-from-DB refresh model for single-writer/read-replica topologies — deliberately not/r-style per-operation replication. Verified on a single-node cluster: the capability registers and lists in the clusterer'sclusterer_list_capMI (cachedb-perf-sync, state Ok), a softDEP_SILENTclusterer dependency reorders init so it works regardless of load order, andperf_syncdegrades cleanly with no clusterer — no crashes. The multi-node broadcast→reload fan-out follows theratelimitclusterer pattern and awaits a two-node runCorrectness: what the multi-process soak caught
A lock-free read path plus a table that resizes itself under live traffic is exactly the kind of code where a single-process selftest passes and production still corrupts memory. So the soak (
bench/cdbstress.c) runs the real thing: 8 worker processes on one shared backend, a get/set/remove/add mix, with the maintenance timer splitting buckets the whole time. Every value is written all-bytes-equal so a torn read is visible; counters are hammered withadd(+1)so a lost update shows as a shortfall; a set of keys is inserted once and never removed so a split that drops one shows as a miss.It failed inside a second — a segfault on an impossible size class (88) read out of a cell's class byte. Root cause: after
fork()every child holds a copy-on-write copy of the parent's private allocator hoard (same bump pointer, same free-list cell addresses), andpcache_arena_child_inithad each child donate that hoard to the global pool. The identical physical cells were enqueued once per child, popped by several processes at once, and written through concurrently — one process's value byte landed on another's class id. The fix: a child drops its inherited copy and carves its own chunk on first use, never donating cells it doesn't own. After it, the full soak is clean — 24M ops, 3093 concurrent splits, no torn reads, counter sum equals total adds, every immortal key intact — and equally clean under theQ_MALLOC_DBGredzone allocator and under each of OpenSIPS' three core allocators (F_MALLOC,Q_MALLOC,HP_MALLOC), which back both the per-process state and the arena's shm chunk allocation.And what production caught that the soak did not
The soak runs a single collection with generous TTLs, so it never combined
overflow chaining with expiry reclamation — and that combination was the one
that mattered. On a live SBC the module crashed repeatedly inside the arena's
free path, on an impossible size class, with topology-hiding keys going
missing.
struct povf, the overflow node, had itsnextpointer at offset 0 — butbyte 0 of every arena cell is the size class, read by both free paths. Linking
a node wrote the pointer's low byte over the class id (0x40/0x80/0xC0 →
"class" 64/128/192), indexing past the 21-entry class table and corrupting the
pool, so the crash surfaced far from the cause. It needs overflow and the
expiry sweep together to trigger, which is why a table with room to spare never
showed it.
The fix reserves byte 0 in
struct povfand hardens both free paths to log andleak on an invalid class rather than corrupt. Reproduced deliberately
afterwards — a churn set with a 2 s TTL, a 1 s sweep and growth enabled catches
it in under a minute — and the soak was extended to cover the same shape.
The standalone rig behind every figure above lives in
modules/cachedb_perf/bench/(make run, no OpenSIPS build needed) — full measurement history, every rejected alternative and why — so the numbers here are reproducible rather than asserted.