local rspamd_util = require "rspamd_util"
local argparse = require "argparse"
+local KNOWN_SUBSYSTEMS = {
+ summary = true,
+ process = true,
+ mempool = true,
+ callsites = true,
+ lua = true,
+ jemalloc = true,
+}
+
local parser = argparse()
:name "rspamadm control memstat"
:description "Show memory usage statistics across all workers"
:description "Disable numbers humanization"
parser:flag "-s --short"
:description "Short output: only the per-worker summary table"
+parser:flag "-c --compact"
+ :description "Compact output: one line per worker per section"
+parser:option "--only"
+ :description "Comma-separated subsystems to show: summary,process,mempool,callsites,lua,jemalloc"
parser:option "--top"
:description "Show top-N mempool callsites per worker (default 20)"
:convert(tonumber)
:description "Skip Lua heap section"
parser:flag "--no-jemalloc"
:description "Skip jemalloc section"
+parser:option "--callsite-sort"
+ :description "Sort callsites by: suggestion, cur_bytes, total_bytes, cur_pools, total_pools (default cur_bytes)"
+ :convert {
+ suggestion = "suggestion",
+ cur_bytes = "cur_bytes",
+ total_bytes = "total_bytes",
+ cur_pools = "cur_pools",
+ total_pools = "total_pools",
+ }
+ :default("cur_bytes")
parser:option "--sort"
:description "Sort summary table by: rss, lua, mempool, jemalloc, pid (default pid)"
:convert {
end
end
+local function build_subsystems_filter(opts)
+ -- Returns a table keyed by subsystem name with boolean values telling
+ -- whether to show that subsystem. --only takes precedence; otherwise
+ -- everything is on except sections turned off via --no-*. --short collapses
+ -- to summary only.
+ local enabled = {
+ summary = true, process = true, mempool = true,
+ callsites = true, lua = true, jemalloc = true,
+ }
+ if opts.only and #opts.only > 0 then
+ for k in pairs(enabled) do
+ enabled[k] = false
+ end
+ for token in string.gmatch(opts.only, "[^,%s]+") do
+ local name = token:lower()
+ if KNOWN_SUBSYSTEMS[name] then
+ enabled[name] = true
+ else
+ io.stderr:write(string.format(
+ "warning: unknown subsystem '%s' in --only (ignored)\n", token))
+ end
+ end
+ -- summary is always implied unless explicitly excluded via --no-summary,
+ -- but keep --only authoritative for that too.
+ return enabled
+ end
+ if opts.short then
+ for k in pairs(enabled) do
+ enabled[k] = (k == "summary")
+ end
+ return enabled
+ end
+ if opts.no_process then enabled.process = false end
+ if opts.no_mempool then enabled.mempool = false end
+ if opts.no_callsites then enabled.callsites = false end
+ if opts.no_lua then enabled.lua = false end
+ if opts.no_jemalloc then enabled.jemalloc = false end
+ return enabled
+end
+
local function print_summary(workers, total, opts)
print("Memory usage by worker:")
print("")
print("")
end
+-- Process memory keys we care about, in render order
+local PROC_KEYS = {
+ "vm_size", "vm_rss", "rss_anon", "rss_file", "rss_shmem",
+ "vm_data", "vm_stack", "vm_text", "vm_lib", "vm_pte",
+}
+
+local function format_kv_line(t, keys, opts)
+ local parts = {}
+ for _, k in ipairs(keys) do
+ local v = t[k]
+ if v and v > 0 then
+ table.insert(parts, string.format("%s=%s", k, bytes(v, opts.number)))
+ end
+ end
+ return table.concat(parts, " ")
+end
+
local function print_process(workers, opts)
local any = false
for _, pid in ipairs(sorted_keys(workers, pid_sort)) do
print("Process memory:")
any = true
end
- print(string.format(" %s (%s):", pid, w.type or "?"))
- local fields = {
- { "vm_size", proc.vm_size },
- { "vm_rss", proc.vm_rss },
- { "rss_anon", proc.rss_anon },
- { "rss_file", proc.rss_file },
- { "rss_shmem", proc.rss_shmem },
- { "vm_data", proc.vm_data },
- { "vm_stack", proc.vm_stack },
- { "vm_text", proc.vm_text },
- { "vm_lib", proc.vm_lib },
- { "vm_pte", proc.vm_pte },
- }
- local parts = {}
- for _, kv in ipairs(fields) do
- if kv[2] and kv[2] > 0 then
- table.insert(parts, string.format("%s=%s", kv[1], bytes(kv[2], opts.number)))
- end
+ if opts.compact then
+ print(string.format(" %-7s %-13s %s",
+ pid, w.type or "?", format_kv_line(proc, PROC_KEYS, opts)))
+ else
+ print(string.format(" %s (%s):", pid, w.type or "?"))
+ print(" " .. format_kv_line(proc, PROC_KEYS, opts):gsub(" ", " "))
end
- print(" " .. table.concat(parts, " "))
end
end
if any then
any = true
end
local a = mp.aggregate
- print(string.format(" %s (%s):", pid, w.type or "?"))
- print(string.format(
- " pools=%d/%d bytes=%s chunks=%d/%d shared=%d oversized=%d fragmented=%s",
+ local line = string.format(
+ "pools=%d/%d bytes=%s chunks=%d/%d shared=%d oversized=%d frag=%s",
a.pools_allocated or 0, a.pools_freed or 0,
bytes(a.bytes_allocated or 0, opts.number),
a.chunks_allocated or 0, a.chunks_freed or 0,
a.shared_chunks_allocated or 0,
a.oversized_chunks or 0,
- bytes(a.fragmented_size or 0, opts.number)))
+ bytes(a.fragmented_size or 0, opts.number))
+ if opts.compact then
+ print(string.format(" %-7s %-13s %s", pid, w.type or "?", line))
+ else
+ print(string.format(" %s (%s):", pid, w.type or "?"))
+ print(" " .. line)
+ end
end
end
if any then
end
end
+local function callsite_basename(src)
+ if not src then return "?" end
+ -- Strip the directory portion: "src/libserver/foo.c:123" -> "foo.c:123".
+ -- Filenames in callsite locations never contain '/' so the last segment
+ -- is always file:line.
+ local tail = string.match(src, "([^/]+)$")
+ return tail or src
+end
+
+local function callsite_key(e, mode)
+ if mode == "suggestion" then
+ return e.cur_suggestion or 0
+ elseif mode == "total_bytes" then
+ return e.bytes_allocated_total or 0
+ elseif mode == "cur_pools" then
+ return (e.pools_allocated or 0) - (e.pools_freed or 0)
+ elseif mode == "total_pools" then
+ return e.pools_allocated or 0
+ end
+ -- default cur_bytes
+ return e.bytes_currently_used or 0
+end
+
local function print_callsites(workers, opts)
local limit = opts.top or 20
+ local sort_mode = opts.callsite_sort or "cur_bytes"
local any = false
for _, pid in ipairs(sorted_keys(workers, pid_sort)) do
local w = workers[pid]
local entries = w.data and w.data.mempool and w.data.mempool.entries
if entries and #entries > 0 then
if not any then
- print(string.format("Top %d mempool callsites by suggestion:", limit))
+ print(string.format("Top %d mempool callsites by %s:", limit, sort_mode))
any = true
end
table.sort(entries, function(a, b)
- return (a.cur_suggestion or 0) > (b.cur_suggestion or 0)
+ return callsite_key(a, sort_mode) > callsite_key(b, sort_mode)
end)
print(string.format(" %s (%s):", pid, w.type or "?"))
- for i = 1, math.min(limit, #entries) do
- local e = entries[i]
+ if opts.compact then
+ print(string.format(" %-32s %10s %10s %8s %8s %10s",
+ "callsite", "cur_bytes", "tot_bytes", "cur_p", "tot_p", "suggest"))
+ for i = 1, math.min(limit, #entries) do
+ local e = entries[i]
+ local cur_pools = (e.pools_allocated or 0) - (e.pools_freed or 0)
+ print(string.format(" %-32s %10s %10s %8d %8d %10s",
+ callsite_basename(e.src),
+ bytes(e.bytes_currently_used or 0, opts.number),
+ bytes(e.bytes_allocated_total or 0, opts.number),
+ cur_pools,
+ e.pools_allocated or 0,
+ bytes(e.cur_suggestion or 0, opts.number)))
+ end
+ else
print(string.format(
- " [%-9s] %-50s elts=%-4d vars=%-4d dtors=%-4d avg_frag=%-9s avg_left=%-9s n=%d",
- bytes(e.cur_suggestion or 0, opts.number),
- e.src or "?",
- e.cur_elts or 0,
- e.cur_vars or 0,
- e.cur_dtors or 0,
- bytes(e.avg_fragmentation or 0, opts.number),
- bytes(e.avg_leftover or 0, opts.number),
- e.samples or 0))
+ " %-32s %10s %10s %8s %8s %10s %5s %5s %5s %10s %10s %5s",
+ "callsite", "cur_bytes", "tot_bytes", "cur_p", "tot_p", "suggest",
+ "elts", "vars", "dtors", "avg_frag", "avg_left", "n"))
+ for i = 1, math.min(limit, #entries) do
+ local e = entries[i]
+ local cur_pools = (e.pools_allocated or 0) - (e.pools_freed or 0)
+ print(string.format(
+ " %-32s %10s %10s %8d %8d %10s %5d %5d %5d %10s %10s %5d",
+ callsite_basename(e.src),
+ bytes(e.bytes_currently_used or 0, opts.number),
+ bytes(e.bytes_allocated_total or 0, opts.number),
+ cur_pools,
+ e.pools_allocated or 0,
+ bytes(e.cur_suggestion or 0, opts.number),
+ e.cur_elts or 0,
+ e.cur_vars or 0,
+ e.cur_dtors or 0,
+ bytes(e.avg_fragmentation or 0, opts.number),
+ bytes(e.avg_leftover or 0, opts.number),
+ e.samples or 0))
+ end
end
end
end
print("Lua heap:")
any = true
end
- print(string.format(" %s (%s): %s",
+ print(string.format(" %-7s %-13s %s",
pid, w.type or "?",
bytes(lua.used_bytes or 0, opts.number)))
end
end
end
+local JEMALLOC_STATS_KEYS = {
+ "allocated", "active", "metadata", "resident", "mapped", "retained",
+}
+
local function print_jemalloc(workers, opts)
local any = false
for _, pid in ipairs(sorted_keys(workers, pid_sort)) do
print("Jemalloc:")
any = true
end
- print(string.format(" %s (%s):", pid, w.type or "?"))
- if j.stats then
- local parts = {}
- for _, k in ipairs({ "allocated", "active", "metadata", "resident", "mapped", "retained" }) do
- if j.stats[k] then
- table.insert(parts, string.format("%s=%s", k, bytes(j.stats[k], opts.number)))
+
+ if opts.compact then
+ local s = j.stats or {}
+ local arenas_count = j.arenas and #j.arenas or 0
+ print(string.format(" %-7s %-13s alloc=%s active=%s mapped=%s resident=%s retained=%s arenas=%d",
+ pid, w.type or "?",
+ bytes(s.allocated or 0, opts.number),
+ bytes(s.active or 0, opts.number),
+ bytes(s.mapped or 0, opts.number),
+ bytes(s.resident or 0, opts.number),
+ bytes(s.retained or 0, opts.number),
+ arenas_count))
+ else
+ print(string.format(" %s (%s):", pid, w.type or "?"))
+
+ if j.config then
+ local cfg_parts = {}
+ if j.config.version then
+ table.insert(cfg_parts, string.format("version=%s", tostring(j.config.version)))
+ end
+ if j.config.narenas then
+ table.insert(cfg_parts, string.format("narenas=%d", j.config.narenas))
+ end
+ if j.config.page_size then
+ table.insert(cfg_parts, string.format("page=%s",
+ bytes(j.config.page_size, opts.number)))
+ end
+ if j.config.dirty_decay_ms ~= nil then
+ table.insert(cfg_parts, string.format("dirty_decay=%dms", j.config.dirty_decay_ms))
+ end
+ if j.config.muzzy_decay_ms ~= nil then
+ table.insert(cfg_parts, string.format("muzzy_decay=%dms", j.config.muzzy_decay_ms))
+ end
+ if #cfg_parts > 0 then
+ print(" config: " .. table.concat(cfg_parts, " "))
end
end
- if #parts > 0 then
- print(" " .. table.concat(parts, " "))
- end
- end
- if j.config then
- local parts = {}
- for k, v in pairs(j.config) do
- table.insert(parts, string.format("%s=%s", k, tostring(v)))
- end
- table.sort(parts)
- if #parts > 0 then
- print(" config: " .. table.concat(parts, " "))
+
+ if j.stats then
+ print(" totals: " .. format_kv_line(j.stats, JEMALLOC_STATS_KEYS, opts))
end
- end
- if j.text and #j.text > 0 then
- print(" --- malloc_stats_print ---")
- for line in tostring(j.text):gmatch("[^\r\n]+") do
- print(" " .. line)
+
+ if j.arenas and #j.arenas > 0 then
+ print(string.format(" %4s %10s %10s %10s %10s %10s %10s %10s %10s %5s",
+ "id", "alloc", "small", "large", "mapped", "retained",
+ "resident", "dirty", "muzzy", "thr"))
+ for _, a in ipairs(j.arenas) do
+ print(string.format(" %4d %10s %10s %10s %10s %10s %10s %10s %10s %5d",
+ a.id or 0,
+ bytes(a.allocated or 0, opts.number),
+ bytes(a.small_allocated or 0, opts.number),
+ bytes(a.large_allocated or 0, opts.number),
+ bytes(a.mapped or 0, opts.number),
+ bytes(a.retained or 0, opts.number),
+ bytes(a.resident or 0, opts.number),
+ bytes(a.dirty or 0, opts.number),
+ bytes(a.muzzy or 0, opts.number),
+ a.nthreads or 0))
+ end
end
- print(" --- end ---")
end
end
end
local opts = parser:parse(args or {})
local workers = res and res.workers or {}
local total = res and res.total
+ local enabled = build_subsystems_filter(opts)
- print_summary(workers, total, opts)
-
- if opts.short then
- return
+ if enabled.summary then
+ print_summary(workers, total, opts)
end
-
- if not opts.no_process then
+ if enabled.process then
print_process(workers, opts)
end
- if not opts.no_mempool then
+ if enabled.mempool then
print_mempool_aggregate(workers, opts)
end
- if not opts.no_callsites then
+ if enabled.callsites then
print_callsites(workers, opts)
end
- if not opts.no_lua then
+ if enabled.lua then
print_lua(workers, opts)
end
- if not opts.no_jemalloc then
+ if enabled.jemalloc then
print_jemalloc(workers, opts)
end
end
"avg_leftover", 0, false);
ucl_object_insert_key(e, ucl_object_fromint(st->samples),
"samples", 0, false);
+ ucl_object_insert_key(e, ucl_object_fromint(st->pools_allocated),
+ "pools_allocated", 0, false);
+ ucl_object_insert_key(e, ucl_object_fromint(st->pools_freed),
+ "pools_freed", 0, false);
+ ucl_object_insert_key(e, ucl_object_fromint(st->chunks_allocated),
+ "chunks_allocated", 0, false);
+ ucl_object_insert_key(e, ucl_object_fromint(st->bytes_allocated_total),
+ "bytes_allocated_total", 0, false);
+ ucl_object_insert_key(e, ucl_object_fromint(st->bytes_currently_used),
+ "bytes_currently_used", 0, false);
ucl_array_append(c->array, e);
},
}
#ifdef WITH_JEMALLOC
-void jemalloc_text_cb(void *ud, const char *msg)
+void emit_jemalloc_arena(ucl_object_t *arr, unsigned int idx)
{
- auto *out = static_cast<rspamd_fstring_t **>(ud);
- rspamd_printf_fstring(out, "%s", msg);
+ char path[128];
+
+ auto get_size = [&](const char *suffix) -> size_t {
+ size_t v = 0;
+ rspamd_snprintf(path, sizeof(path), "stats.arenas.%ud.%s", idx, suffix);
+ size_t sz = sizeof(v);
+ if (mallctl(path, &v, &sz, nullptr, 0) != 0) {
+ return 0;
+ }
+ return v;
+ };
+
+ size_t allocated = get_size("small.allocated") + get_size("large.allocated");
+ size_t mapped = get_size("mapped");
+
+ /* Only report arenas that actually hold something */
+ if (allocated == 0 && mapped == 0) {
+ return;
+ }
+
+ auto *a = ucl_object_typed_new(UCL_OBJECT);
+ ucl_object_insert_key(a, ucl_object_fromint(idx), "id", 0, false);
+ ucl_object_insert_key(a, ucl_object_fromint(allocated), "allocated", 0, false);
+ ucl_object_insert_key(a, ucl_object_fromint(get_size("small.allocated")),
+ "small_allocated", 0, false);
+ ucl_object_insert_key(a, ucl_object_fromint(get_size("large.allocated")),
+ "large_allocated", 0, false);
+ ucl_object_insert_key(a, ucl_object_fromint(mapped), "mapped", 0, false);
+ ucl_object_insert_key(a, ucl_object_fromint(get_size("retained")),
+ "retained", 0, false);
+ ucl_object_insert_key(a, ucl_object_fromint(get_size("resident")),
+ "resident", 0, false);
+ ucl_object_insert_key(a, ucl_object_fromint(get_size("pdirty") * (size_t) sysconf(_SC_PAGESIZE)),
+ "dirty", 0, false);
+ ucl_object_insert_key(a, ucl_object_fromint(get_size("pmuzzy") * (size_t) sysconf(_SC_PAGESIZE)),
+ "muzzy", 0, false);
+ ucl_object_insert_key(a, ucl_object_fromint(get_size("metadata.allocated")),
+ "metadata", 0, false);
+ ucl_object_insert_key(a, ucl_object_fromint(get_size("nthreads")),
+ "nthreads", 0, false);
+
+ ucl_array_append(arr, a);
}
#endif
ucl_object_insert_key(obj, stats, "stats", 0, false);
auto *config = ucl_object_typed_new(UCL_OBJECT);
-
+ unsigned int narenas = 0;
{
- unsigned int narenas = 0;
size_t sz = sizeof(narenas);
if (mallctl("opt.narenas", &narenas, &sz, nullptr, 0) == 0) {
ucl_object_insert_key(config, ucl_object_fromint(narenas), "narenas",
0, false);
}
}
-
+ {
+ size_t page_sz = (size_t) sysconf(_SC_PAGESIZE);
+ ucl_object_insert_key(config, ucl_object_fromint(page_sz), "page_size",
+ 0, false);
+ }
{
ssize_t v = 0;
size_t sz = sizeof(v);
0, false);
}
}
+ {
+ const char *cfg_str = nullptr;
+ size_t sz = sizeof(cfg_str);
+ if (mallctl("version", &cfg_str, &sz, nullptr, 0) == 0 && cfg_str) {
+ ucl_object_insert_key(config, ucl_object_fromstring(cfg_str), "version",
+ 0, false);
+ }
+ }
ucl_object_insert_key(obj, config, "config", 0, false);
- /* Capture the human-readable summary as well */
- rspamd_fstring_t *text = rspamd_fstring_sized_new(4096);
- malloc_stats_print(jemalloc_text_cb, &text, "Jmdablxe");
- if (text->len > 0) {
- ucl_object_insert_key(obj,
- ucl_object_fromlstring(text->str, text->len),
- "text", 0, false);
+ /*
+ * Per-arena breakdown. We probe by index up to opt.narenas; arenas
+ * that have never been populated are skipped.
+ */
+ auto *arenas = ucl_object_typed_new(UCL_ARRAY);
+ if (narenas == 0) {
+ narenas = 32; /* sane upper bound when opt.narenas is unset */
+ }
+ for (unsigned int i = 0; i < narenas; i++) {
+ emit_jemalloc_arena(arenas, i);
}
- rspamd_fstring_free(text);
+ ucl_object_insert_key(obj, arenas, "arenas", 0, false);
ucl_object_insert_key(parent, obj, "jemalloc", 0, false);
#else