core: add tools/bench heap-profile (no massif/heaptrack in this environment)
For the M7 RSS gap (core/docs/m7-baseline.md: ~70 MB measured vs ADR 0012's ~45-50 MB estimate) -- "find where before tuning anything". No root here to install massif or heaptrack, so this is a small in-process equivalent: operator new/delete (already overridden for alloc-check's counter) now also track, per call site (one return address via __builtin_return_address(0), cheap enough to run for a whole scenario), live (not-yet-freed) bytes. Runs the same 20-task concurrent scenario as `load`, waits for peak RSS to stop growing, then prints the top sites by live bytes -- resolved via one batched addr2line invocation against /proc/self/exe (dladdr alone only resolves dynamic-symbol-table entries, which misses most of this codebase's internal-linkage call sites), with dladdr as a per-site fallback. Also adds the nothrow operator new/delete overloads alongside the existing plain ones: without them, anything that allocates via the nothrow form (e.g. std::stable_sort's std::get_temporary_buffer, hit once while investigating the RSS gap) falls through to ASan's own default nothrow new while still being freed through this file's plain delete override -- an alloc-dealloc-mismatch ASan correctly flags as a real ABI-level bug in an allocator override that claims to intercept "everything". Using this tool, sum(effective_segments) across all 20 tasks vs. Engine::segment_budget().budget() (now cross-checked and printed together) showed the budget hands out well more than max_active_segments -- the actual root cause, in core/src/segment/budget.cpp, not covered by this commit. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Q3QrF7rCt21bkAjt9BCDFQ
This commit is contained in:
+300
-10
@@ -22,6 +22,18 @@
|
||||
// for a --preset release run, where the number means something (a sanitizer roughly
|
||||
// doubles-to-quadruples RSS via redzones/shadow memory).
|
||||
//
|
||||
// vdm_bench heap-profile [--tasks 20] [--task-size 4M] [--segments N] [--top N]
|
||||
// Answers "where does the RSS actually go", for the M7 RSS target (docs/04 §8) --
|
||||
// no massif/heaptrack in this environment (no root to install them), so this is a
|
||||
// small in-process equivalent: the same operator new/delete override tracks, per
|
||||
// allocation, which call site made it (one return address, via
|
||||
// __builtin_return_address(0) right in operator new -- cheap enough to run for the
|
||||
// whole scenario, unlike a full backtrace) and keeps a live (not-yet-freed) byte
|
||||
// count per site. Runs the same 20-task concurrent scenario as `load`, waits for
|
||||
// peak RSS to stop growing, then prints the top sites by live bytes -- the
|
||||
// breakdown ADR 0012's "45-50 MB" estimate needs to be checked against, and
|
||||
// core/docs/m7-baseline.md's ~70 MB measurement needs explained.
|
||||
//
|
||||
// vdm_bench alloc-check [--size 128M] [--window-s 2]
|
||||
// docs/agents/AGENT-CORE.md: "no allocation in the curl write callback... checked in
|
||||
// review and by a bench assertion." operator new/delete are overridden process-wide
|
||||
@@ -42,17 +54,22 @@
|
||||
|
||||
#include "vdm/engine.hpp"
|
||||
|
||||
#include <cxxabi.h>
|
||||
#include <dlfcn.h>
|
||||
#include <sys/resource.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <future>
|
||||
#include <mutex>
|
||||
#include <new>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "support/local_server.hpp"
|
||||
@@ -62,20 +79,194 @@ using namespace vdm;
|
||||
using namespace vdm::task;
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
// --- allocation counting (alloc-check only; harmless overhead otherwise) --------------
|
||||
// --- allocation tracking: a plain counter for alloc-check, and (only when heap-profile
|
||||
// turns it on) a per-call-site live-byte tracker standing in for massif/heaptrack, neither
|
||||
// of which is installable here (no root). Both share one operator new/delete override
|
||||
// since a process gets exactly one. ---------------------------------------------------
|
||||
|
||||
namespace {
|
||||
std::atomic<std::uint64_t> g_alloc_count{0};
|
||||
|
||||
std::atomic<bool> g_site_tracking{false};
|
||||
// Re-entrancy guard: our own bookkeeping below calls into std::unordered_map, which
|
||||
// allocates. Without this, that nested operator new call would recurse into the same
|
||||
// tracking logic -- not infinitely (the nested call's own bookkeeping call would itself be
|
||||
// guarded the same way, so it terminates), but it would count the tracker's own node
|
||||
// allocations as if they were the program's, polluting the profile. When set, an
|
||||
// allocation is just satisfied via malloc/free with no bookkeeping.
|
||||
thread_local bool g_in_tracker = false;
|
||||
|
||||
struct LiveAlloc {
|
||||
std::size_t size;
|
||||
void *site; // __builtin_return_address(0) at the call to operator new
|
||||
};
|
||||
struct SiteStats {
|
||||
std::uint64_t live_bytes = 0;
|
||||
std::uint64_t alloc_count = 0; // lifetime, not just live -- how *often* a site fires
|
||||
};
|
||||
|
||||
std::mutex g_track_mu;
|
||||
std::unordered_map<void *, LiveAlloc> g_live; // pointer -> what/where
|
||||
std::unordered_map<void *, SiteStats> g_site_stats; // call site -> aggregate
|
||||
|
||||
void track_alloc(void *p, std::size_t n) {
|
||||
if (!g_site_tracking.load(std::memory_order_relaxed) || g_in_tracker)
|
||||
return;
|
||||
g_in_tracker = true;
|
||||
void *site = __builtin_return_address(0);
|
||||
{
|
||||
std::lock_guard lk(g_track_mu);
|
||||
g_live.emplace(p, LiveAlloc{n, site});
|
||||
auto &st = g_site_stats[site];
|
||||
st.live_bytes += n;
|
||||
st.alloc_count += 1;
|
||||
}
|
||||
g_in_tracker = false;
|
||||
}
|
||||
|
||||
void track_free(void *p) {
|
||||
if (!p || !g_site_tracking.load(std::memory_order_relaxed) || g_in_tracker)
|
||||
return;
|
||||
g_in_tracker = true;
|
||||
{
|
||||
std::lock_guard lk(g_track_mu);
|
||||
if (auto it = g_live.find(p); it != g_live.end()) {
|
||||
g_site_stats[it->second.site].live_bytes -= it->second.size;
|
||||
g_live.erase(it);
|
||||
}
|
||||
}
|
||||
g_in_tracker = false;
|
||||
}
|
||||
|
||||
std::string describe_site(void *site); // defined below; used as a per-site fallback here
|
||||
|
||||
// Resolves a batch of call-site addresses to "function (file:line)" via one `addr2line`
|
||||
// invocation (reads .symtab + DWARF, unlike dladdr below -- most of this codebase's
|
||||
// call sites are internal-linkage or file-static and never make it into the *dynamic*
|
||||
// symbol table dladdr is limited to). `sites` and the returned vector are in the same
|
||||
// order. dladdr's `dli_fbase` (the object's runtime load base -- works even when it can't
|
||||
// name the specific symbol, which is exactly the "site not in .dynsym" case this exists
|
||||
// for) converts each ASLR'd runtime address into the file-relative offset addr2line wants.
|
||||
// Falls back to a raw "0x..." string per site if addr2line isn't on PATH or the batch call
|
||||
// otherwise fails -- describe_site() below still has dladdr as a second-line fallback for
|
||||
// an individual site in that case.
|
||||
std::vector<std::string> resolve_sites_via_addr2line(const std::vector<void *> &sites) {
|
||||
std::vector<std::string> out(sites.size());
|
||||
for (std::size_t i = 0; i < sites.size(); ++i) {
|
||||
char buf[32];
|
||||
std::snprintf(buf, sizeof buf, "%p", sites[i]);
|
||||
out[i] = buf;
|
||||
}
|
||||
if (sites.empty())
|
||||
return out;
|
||||
|
||||
// `-e /proc/self/exe` would resolve inside the *addr2line* process this spawns, i.e.
|
||||
// to addr2line's own binary, not vdm_bench's -- readlink it here, in-process, first.
|
||||
char exe_path[4096];
|
||||
ssize_t exe_len = ::readlink("/proc/self/exe", exe_path, sizeof(exe_path) - 1);
|
||||
if (exe_len <= 0)
|
||||
return out;
|
||||
exe_path[exe_len] = '\0';
|
||||
|
||||
std::string cmd = std::string("addr2line -f -C -e '") + exe_path + "'";
|
||||
for (void *site : sites) {
|
||||
Dl_info info{};
|
||||
std::uintptr_t off = reinterpret_cast<std::uintptr_t>(site);
|
||||
if (::dladdr(site, &info) && info.dli_fbase)
|
||||
off -= reinterpret_cast<std::uintptr_t>(info.dli_fbase);
|
||||
char buf[24];
|
||||
std::snprintf(buf, sizeof buf, " %zx", off);
|
||||
cmd += buf;
|
||||
}
|
||||
cmd += " 2>/dev/null";
|
||||
|
||||
FILE *pipe = ::popen(cmd.c_str(), "r");
|
||||
if (!pipe)
|
||||
return out;
|
||||
std::vector<std::string> lines;
|
||||
char line[1024];
|
||||
while (std::fgets(line, sizeof line, pipe)) {
|
||||
std::string s = line;
|
||||
while (!s.empty() && (s.back() == '\n' || s.back() == '\r')) s.pop_back();
|
||||
lines.push_back(std::move(s));
|
||||
}
|
||||
::pclose(pipe);
|
||||
|
||||
// addr2line -f prints two lines per address: function name, then file:line.
|
||||
for (std::size_t i = 0; i * 2 + 1 < lines.size() && i < sites.size(); ++i) {
|
||||
const std::string &fn = lines[i * 2];
|
||||
const std::string &loc = lines[i * 2 + 1];
|
||||
if (fn == "??" && loc == "??:0")
|
||||
out[i] = describe_site(sites[i]); // addr2line drew a blank -- try dladdr
|
||||
else
|
||||
out[i] = fn + " (" + loc + ")";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Single-site fallback (dladdr only) for when addr2line can't place an address (it read
|
||||
// only .symtab -- e.g. no debug/symbol data at all for that address) or isn't available.
|
||||
std::string describe_site(void *site) {
|
||||
Dl_info info{};
|
||||
if (!::dladdr(site, &info) || !info.dli_sname) {
|
||||
char buf[32];
|
||||
std::snprintf(buf, sizeof buf, "%p", site);
|
||||
return buf;
|
||||
}
|
||||
std::string name = info.dli_sname;
|
||||
int status = 0;
|
||||
if (char *dem = abi::__cxa_demangle(name.c_str(), nullptr, nullptr, &status);
|
||||
dem && status == 0) {
|
||||
name = dem;
|
||||
std::free(dem);
|
||||
}
|
||||
auto off = static_cast<std::uintptr_t>(reinterpret_cast<char *>(site) -
|
||||
reinterpret_cast<char *>(info.dli_saddr));
|
||||
return name + " +0x" + [&] {
|
||||
char buf[24];
|
||||
std::snprintf(buf, sizeof buf, "%zx", off);
|
||||
return std::string(buf);
|
||||
}();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void *operator new(std::size_t n) {
|
||||
g_alloc_count.fetch_add(1, std::memory_order_relaxed);
|
||||
if (void *p = std::malloc(n ? n : 1))
|
||||
return p;
|
||||
throw std::bad_alloc();
|
||||
void *p = std::malloc(n ? n : 1);
|
||||
if (!p)
|
||||
throw std::bad_alloc();
|
||||
track_alloc(p, n);
|
||||
return p;
|
||||
}
|
||||
// The nothrow overload is a distinct allocation function from the plain one above, not an
|
||||
// alternate spelling of it: leaving it un-overridden means it falls through to ASan's own
|
||||
// default (nothrow) operator new, while a deallocation of that same pointer still reaches
|
||||
// the plain `operator delete` override below -- a real alloc-dealloc-mismatch ASan catches
|
||||
// (hit via std::stable_sort's std::get_temporary_buffer, which allocates this way; see
|
||||
// core/src/segment/budget.cpp's SegmentBudget::run() for why that path no longer uses
|
||||
// stable_sort at all, but this override is fixed too since anything else pulling in a
|
||||
// nothrow allocation -- directly or via another std:: temp-buffer user -- would hit it
|
||||
// again otherwise).
|
||||
void *operator new(std::size_t n, const std::nothrow_t &) noexcept {
|
||||
g_alloc_count.fetch_add(1, std::memory_order_relaxed);
|
||||
void *p = std::malloc(n ? n : 1);
|
||||
if (p)
|
||||
track_alloc(p, n);
|
||||
return p;
|
||||
}
|
||||
void operator delete(void *p) noexcept {
|
||||
track_free(p);
|
||||
std::free(p);
|
||||
}
|
||||
void operator delete(void *p, std::size_t) noexcept {
|
||||
track_free(p);
|
||||
std::free(p);
|
||||
}
|
||||
void operator delete(void *p, const std::nothrow_t &) noexcept {
|
||||
track_free(p);
|
||||
std::free(p);
|
||||
}
|
||||
void operator delete(void *p) noexcept { std::free(p); }
|
||||
void operator delete(void *p, std::size_t) noexcept { std::free(p); }
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -336,6 +527,102 @@ int cmd_load(const Args &a) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cmd_heap_profile(const Args &a) {
|
||||
const int tasks = static_cast<int>(a.get_long("--tasks", 20));
|
||||
const std::uint64_t task_size = a.get_size("--task-size", "4M");
|
||||
const auto segments_override = static_cast<std::uint32_t>(a.get_long("--segments", 0));
|
||||
const int top_n = static_cast<int>(a.get_long("--top", 20));
|
||||
|
||||
std::string dir = tmp_workdir();
|
||||
const std::uint64_t assumed_segments = segments_override ? segments_override : 8;
|
||||
const std::uint64_t per_conn_bps =
|
||||
std::max<std::uint64_t>(1, task_size / (5 * assumed_segments));
|
||||
vdm::bench::TestServerProc srv(per_conn_bps);
|
||||
if (!srv.available()) {
|
||||
std::fprintf(stderr,
|
||||
"vdm_bench: tools/testserver unavailable -- skipping heap-profile.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// On from before Engine's own construction, so its fixed cost (http worker threads,
|
||||
// curl_multi handles, ...) shows up in the profile too, not just what the tasks add.
|
||||
g_site_tracking.store(true, std::memory_order_relaxed);
|
||||
Engine eng;
|
||||
|
||||
std::vector<DownloadHandle> handles;
|
||||
handles.reserve(tasks);
|
||||
for (int i = 0; i < tasks; ++i) {
|
||||
DownloadSpec spec;
|
||||
spec.url = srv.url("/throttled/file/" + std::to_string(task_size));
|
||||
spec.save_path = dir + "/out" + std::to_string(i) + ".bin";
|
||||
if (segments_override)
|
||||
spec.segments = segments_override;
|
||||
handles.push_back(eng.start(std::move(spec), DownloadCallbacks{}));
|
||||
}
|
||||
|
||||
// Wait for peak RSS to stop growing (5 consecutive 200ms samples with no increase, or
|
||||
// a generous cap) -- that's the moment whose live-allocation breakdown answers "where
|
||||
// does the RSS actually go", the same question core/docs/m7-baseline.md's ~70 MB
|
||||
// number raises against ADR 0012's ~45-50 MB estimate.
|
||||
long last_rss = 0, stable_for = 0;
|
||||
for (int i = 0; i < 300 && stable_for < 5; ++i) {
|
||||
std::this_thread::sleep_for(200ms);
|
||||
long rss = sample_rusage().peak_rss_kb;
|
||||
stable_for = (rss <= last_rss) ? stable_for + 1 : 0;
|
||||
last_rss = rss;
|
||||
}
|
||||
|
||||
// Cross-check against the budget's own accounting, independent of the allocator-site
|
||||
// snapshot below: this is what caught the real find here (core/docs/m7-baseline.md,
|
||||
// docs/adr/0012) -- sum(effective_segments) tracked budget.active well past
|
||||
// budget.total before the SegmentBudget fix these numbers are now reported alongside.
|
||||
std::uint32_t total_effective_segments = 0;
|
||||
for (auto &h : handles) total_effective_segments += h.progress().effective_segments;
|
||||
auto eb = eng.segment_budget().budget();
|
||||
std::fprintf(stderr,
|
||||
"segment budget: %u live across tasks, engine reports total=%u active=%u "
|
||||
"starved=%u\n",
|
||||
total_effective_segments, eb.total, eb.active, eb.tasks_starved);
|
||||
|
||||
// Turn tracking off *before* touching g_site_stats: the snapshot below allocates its
|
||||
// own vector storage through the very same overridden operator new. With tracking
|
||||
// still on and g_track_mu already held for the snapshot, that allocation would recurse
|
||||
// into track_alloc() and try to lock g_track_mu again -- self-deadlock on a
|
||||
// non-recursive mutex (hit this exact hang while first wiring this subcommand up:
|
||||
// g_in_tracker only guards the tracker's own bookkeeping containers, not other code
|
||||
// that allocates while holding g_track_mu directly).
|
||||
g_site_tracking.store(false, std::memory_order_relaxed);
|
||||
std::vector<std::pair<void *, SiteStats>> sites;
|
||||
{
|
||||
std::lock_guard lk(g_track_mu);
|
||||
sites.assign(g_site_stats.begin(), g_site_stats.end());
|
||||
}
|
||||
std::sort(sites.begin(), sites.end(), [](const auto &x, const auto &y) {
|
||||
return x.second.live_bytes > y.second.live_bytes;
|
||||
});
|
||||
|
||||
std::fprintf(stderr, "heap-profile: peak RSS %.2f MiB, %d tasks, %llu segments assumed\n",
|
||||
last_rss / 1024.0, tasks, (unsigned long long)assumed_segments);
|
||||
std::fprintf(stderr, "%-10s %-10s %s\n", "live KiB", "#allocs", "call site");
|
||||
std::uint64_t total_live = 0;
|
||||
for (auto &[site, st] : sites) total_live += st.live_bytes;
|
||||
std::fprintf(stderr, "total tracked live: %.2f MiB across %zu sites\n\n",
|
||||
total_live / 1024.0 / 1024.0, sites.size());
|
||||
std::vector<void *> top_addrs;
|
||||
for (int i = 0; i < top_n && i < static_cast<int>(sites.size()); ++i) top_addrs.push_back(sites[i].first);
|
||||
auto labels = resolve_sites_via_addr2line(top_addrs);
|
||||
for (std::size_t i = 0; i < top_addrs.size(); ++i) {
|
||||
auto &[site, st] = sites[i];
|
||||
std::fprintf(stderr, "%-10.1f %-10llu %s\n", st.live_bytes / 1024.0,
|
||||
(unsigned long long)st.alloc_count, labels[i].c_str());
|
||||
}
|
||||
|
||||
// Handles/Engine go out of scope here: ~Engine quiesces every task (waits for its
|
||||
// workers to actually drain -- see the quiesce() fix) rather than waiting for the
|
||||
// downloads to run to completion, which isn't the point of this subcommand.
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cmd_alloc_check(const Args &a) {
|
||||
const std::uint64_t size = a.get_size("--size", "128M");
|
||||
const double window_s = a.get_double("--window-s", 2.0);
|
||||
@@ -416,12 +703,13 @@ int cmd_alloc_check(const Args &a) {
|
||||
|
||||
void usage() {
|
||||
std::fprintf(stderr,
|
||||
"usage: vdm_bench <throughput|load|alloc-check> [options]\n"
|
||||
" throughput [--size 5G] [--segments N] [--require-mbps N] "
|
||||
"usage: vdm_bench <throughput|load|heap-profile|alloc-check> [options]\n"
|
||||
" throughput [--size 5G] [--segments N] [--require-mbps N] "
|
||||
"[--max-cpu-pct N]\n"
|
||||
" load [--tasks 20] [--task-size 4M] [--segments N] "
|
||||
" load [--tasks 20] [--task-size 4M] [--segments N] "
|
||||
"[--require-rss-kb N] [--task-timeout-s 300]\n"
|
||||
" alloc-check [--size 128M] [--window-s 2] [--budget-per-s 5]\n");
|
||||
" heap-profile [--tasks 20] [--task-size 4M] [--segments N] [--top 20]\n"
|
||||
" alloc-check [--size 128M] [--window-s 2] [--budget-per-s 5]\n");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -437,6 +725,8 @@ int main(int argc, char **argv) {
|
||||
return cmd_throughput(args);
|
||||
if (cmd == "load")
|
||||
return cmd_load(args);
|
||||
if (cmd == "heap-profile")
|
||||
return cmd_heap_profile(args);
|
||||
if (cmd == "alloc-check")
|
||||
return cmd_alloc_check(args);
|
||||
usage();
|
||||
|
||||
Reference in New Issue
Block a user