feat(qa): memory + CPU waste sanitizer over the memory core

Answers a question the existing tooling could not: of everything we
allocate, how much is never used?

Lanes, all driven by scripts/memwaste.sh:
  event    every allocation observed through the memory core — size, age,
           churn, and whether the bytes were ever written
  access   SanitizerCoverage load/store instrumentation, so "allocated and
           never touched" is measured rather than inferred
  scaling  k vs 2k replicas through clang profile counters, to catch
           super-linear work per file
  padding  allocator slack per size class
  ratchet  the numbers recorded per venue so a regression is visible

The event lane needed a foreign-free counter: on Windows the override sees
frees for blocks it never allocated (they come from the CRT), and counting
those as untracked tripped the soundness gate. mem_events now separates
them (14 daemon / 5 worker on the Go corpus).

Two portability fixes the lanes surfaced, both real:
  - the enable decision must not be cached before `environ` exists. Under
    -fsanitize-coverage the UBSan preinit runs dlsym -> malloc -> our
    wrapper before glibc sets up environ, so the lane silently disabled
    itself and produced no dump at all on Linux.
  - the access map needs 48 bits: arm64 Linux hands out addresses above
    2^47, which a 47-bit map dropped on the floor.

mem_profile is retired into this: it measured a strict subset and had its
own sampling story.

Local-only by decision — not a PR-CI gate. _memwaste.yml runs the lanes on
all three platforms report-only, and pr.yml/release.yml carry the wiring.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-09-18 03:30:39 +02:00
parent 59a05eb1bf
commit 81264814f1
25 changed files with 6192 additions and 472 deletions
+163
View File
@@ -0,0 +1,163 @@
# Reusable: the waste sanitizer (memory + CPU waste), REPORT-ONLY.
#
# What runs (scripts/memwaste.sh, memwaste-scaling.py, memwaste-ratchet.py):
# mode=pr Linux: the event lane (memory waste + CPU work) indexing this
# repository at the checked-out commit, and the CPU scaling lane
# (src/ replicated 2x against 4x, per-function counter ratios).
# mode=full the same, plus the event lane on macOS and Windows and the access
# lane (read/write verdicts per allocation) on Linux.
#
# The corpus is the repository itself: pinned by the commit, no network, and a
# real mix of C, C++, Python, JavaScript and vendored code.
#
# REPORT-ONLY: every job uploads its report and prints the ratchet comparison
# against scripts/memwaste-baseline.json, but nothing here can turn a PR red --
# the ratchet runs without --enforce and the PR's ci-ok gate does not depend on
# these jobs. A job fails only when the MEASUREMENT is unsound (a table
# overflowed, frees the layer never saw): that is a defect of the tool, and a
# red job is how it gets noticed. Switching the ratchet to --enforce is a
# separate, deliberate change once the numbers have been stable across reruns.
name: Memwaste
on:
workflow_call:
inputs:
mode:
description: "pr (Linux event + scaling) or full (all platforms, access lane)"
type: string
default: pr
permissions:
contents: read
jobs:
event-linux:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install deps
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 clang llvm
- name: Event lane on this repository
run: |
mkdir -p "$RUNNER_TEMP/memwaste-event"
scripts/memwaste.sh "$GITHUB_WORKSPACE" --top 40 --out "$RUNNER_TEMP/memwaste-event" \
| tee "$RUNNER_TEMP/memwaste-event/report.txt"
- name: Ratchet (report-only)
run: python3 scripts/memwaste-ratchet.py --out "$RUNNER_TEMP/memwaste-event" --venue ${{ inputs.mode }}-linux
- name: Upload report
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: memwaste-event-linux-${{ inputs.mode }}
path: |
${{ runner.temp }}/memwaste-event/report.txt
${{ runner.temp }}/memwaste-event/memwaste.json
${{ runner.temp }}/memwaste-event/index.json
if-no-files-found: warn
scaling-linux:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install deps
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 clang llvm
- name: CPU scaling lane (src/ at 2x against 4x)
# Report-only: the script exits 1 when it flags a super-linear function.
continue-on-error: true
run: |
mkdir -p "$RUNNER_TEMP/memwaste-scaling"
python3 scripts/memwaste-scaling.py --corpus src --replicas 2 \
--out "$RUNNER_TEMP/memwaste-scaling" | tee "$RUNNER_TEMP/memwaste-scaling/report.txt"
- name: Upload report
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: memwaste-scaling-linux-${{ inputs.mode }}
path: ${{ runner.temp }}/memwaste-scaling/report.txt
if-no-files-found: warn
access-linux:
if: ${{ inputs.mode == 'full' }}
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install deps
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev python3 clang llvm
- name: Access lane on this repository
run: |
mkdir -p "$RUNNER_TEMP/memwaste-access"
scripts/memwaste.sh "$GITHUB_WORKSPACE" --lane access --top 40 --sort untouched \
--out "$RUNNER_TEMP/memwaste-access" | tee "$RUNNER_TEMP/memwaste-access/report.txt"
- name: Upload report
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: memwaste-access-linux-${{ inputs.mode }}
path: |
${{ runner.temp }}/memwaste-access/report.txt
${{ runner.temp }}/memwaste-access/memwaste.json
if-no-files-found: warn
event-macos:
if: ${{ inputs.mode == 'full' }}
runs-on: macos-14
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Event lane on this repository
run: |
mkdir -p "$RUNNER_TEMP/memwaste-event"
scripts/memwaste.sh "$GITHUB_WORKSPACE" --top 40 --out "$RUNNER_TEMP/memwaste-event" \
| tee "$RUNNER_TEMP/memwaste-event/report.txt"
- name: Ratchet (report-only)
run: python3 scripts/memwaste-ratchet.py --out "$RUNNER_TEMP/memwaste-event" --venue full-macos
- name: Upload report
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: memwaste-event-macos-${{ inputs.mode }}
path: |
${{ runner.temp }}/memwaste-event/report.txt
${{ runner.temp }}/memwaste-event/memwaste.json
if-no-files-found: warn
event-windows:
if: ${{ inputs.mode == 'full' }}
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2
with:
msystem: CLANG64
path-type: inherit
install: >-
mingw-w64-clang-x86_64-clang
mingw-w64-clang-x86_64-llvm
mingw-w64-clang-x86_64-zlib
mingw-w64-clang-x86_64-python3
make
git
coreutils
- name: Event lane on this repository
shell: msys2 {0}
run: |
out="$(cygpath -u "$RUNNER_TEMP")/memwaste-event"
mkdir -p "$out"
CC=clang CXX=clang++ scripts/memwaste.sh "$(cygpath -u "$GITHUB_WORKSPACE")" --top 40 --out "$out" \
| tee "$out/report.txt"
- name: Ratchet (report-only)
shell: msys2 {0}
run: python3 scripts/memwaste-ratchet.py --out "$(cygpath -u "$RUNNER_TEMP")/memwaste-event" --venue full-windows
- name: Upload report
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: memwaste-event-windows-${{ inputs.mode }}
path: |
${{ runner.temp }}/memwaste-event/report.txt
${{ runner.temp }}/memwaste-event/memwaste.json
if-no-files-found: warn
+11
View File
@@ -154,6 +154,17 @@ jobs:
CCACHE_DIR: ${{ github.workspace }}/.ccache
CCACHE_MAXSIZE: 1000M
# ── Waste sanitizer, REPORT-ONLY: memory + CPU waste on this repository and
# the per-function scaling lane (Linux). Deliberately NOT in ci-ok's needs:
# it uploads evidence and prints the ratchet, it never blocks a merge. Only
# product-code PRs pay for it. ──
memwaste:
needs: [changes]
if: ${{ !cancelled() && needs.changes.outputs.product == 'true' }}
uses: ./.github/workflows/_memwaste.yml
with:
mode: pr
ci-ok:
# The one required context (besides dco) — fails unless every PR stage
# succeeded, so matrix renames can never silently deadlock merges. `skipped`
+9
View File
@@ -145,6 +145,15 @@ jobs:
version: ${{ inputs.version }}
use_release_artifacts: true
# ── Waste sanitizer, REPORT-ONLY ────────────────────────────────
# The same full run as the dry run, on the release commit. Not in
# release-draft's needs: the report is evidence for the release notes, never
# a publication gate.
memwaste:
uses: ./.github/workflows/_memwaste.yml
with:
mode: full
# ── 6. Create DRAFT release ────────────────────────────────────
# Requires smoke to have actually SUCCEEDED, not merely "not failed". The bare
# !cancelled() && !failure() form is fail-OPEN: a skipped smoke is neither
+118 -14
View File
@@ -76,8 +76,8 @@ endif
# the prod build uses mimalloc (MI_OVERRIDE=1); the test build is CRT+ASan, where
# binding would create an alloc/free mismatch, so the guard is prod-only.
CFLAGS_PROD = $(CFLAGS_COMMON) -O2 -DCBM_BIND_TS_ALLOCATOR=1 $(TEST_SEAM_DEFINE) \
$(GLOBAL_OVERRIDE_DEFINE) $(CFLAGS_EXTRA)
CXXFLAGS_PROD = $(CXXFLAGS_COMMON) -O2
$(GLOBAL_OVERRIDE_DEFINE) $(MEMWASTE_CFLAGS) $(CFLAGS_EXTRA)
CXXFLAGS_PROD = $(CXXFLAGS_COMMON) -O2 $(MEMWASTE_VENDOR_CFLAGS)
# Test flags: debug + sanitizers (override SANITIZE= to disable on Windows)
SANITIZE = -fsanitize=address,undefined -fno-omit-frame-pointer
@@ -216,6 +216,75 @@ ELF_HARDENING_FLAGS := -Wl,-z,noexecstack -Wl,-z,separate-code \
endif
endif
# MEMWASTE=1: the waste-sanitizer flavour (src/foundation/mem_events.h). Built
# like production -- -O2, mimalloc, the real allocator routing -- plus the event
# layer and its observers, because waste measured under ASan's allocator would
# describe ASan. Frame pointers stay so the macOS observer can walk to the
# caller. On Linux this is also what finally LINKS the --wrap observation shim:
# its flags and source were defined here but never reached a link line, so the
# Linux profiler had been reporting nothing.
#
# Every object is built this way, VENDORED ones included (grammars, the
# tree-sitter runtime, SQLite, tre, lz4, zstd, the C++ preprocessor): waste in
# a vendored parser is waste in this program. mimalloc alone is left as it is --
# its reads and writes are the allocator's own bookkeeping, not program data.
#
# MEMWASTE_ACCESS=1 adds the access lane (reads and writes of every tracked
# block through the compiler's load/store callbacks; clang only). It builds at
# -O1 without vectorisation: at -O2 the callbacks describe the OPTIMISED program,
# and reads the optimiser removed would be reported as never read.
# MEMWASTE_PROFILE=1 adds clang's execution counters (-fprofile-instr-generate)
# for the CPU scaling lane (scripts/memwaste-scaling.py).
# Each flavour needs its own BUILD_DIR: vendored objects are not rebuilt when
# only the flavour changes.
#
# The libc wrappers (mem_override_libc.c) need --wrap on Linux and Windows;
# macOS defines the symbols instead. STATIC=1 (musl) is not a memwaste target.
MEMWASTE ?= 0
MEMWASTE_ACCESS ?= 0
MEMWASTE_PROFILE ?= 0
ifeq ($(MEMWASTE_ACCESS),1)
override MEMWASTE := 1
endif
ifeq ($(MEMWASTE_PROFILE),1)
override MEMWASTE := 1
endif
MEMWASTE_CFLAGS :=
MEMWASTE_VENDOR_CFLAGS :=
MEMWASTE_LDFLAGS :=
MEMWASTE_SRCS :=
MEMWASTE_ALLOC_SYMS_LINUX := malloc calloc realloc free strdup strndup posix_memalign \
aligned_alloc memalign
MEMWASTE_LIBC_SYMS_LINUX := memcpy memmove memset memcmp strlen strcmp strncmp \
__memcpy_chk __memmove_chk __memset_chk read __read_chk write \
pread pwrite pread64 pwrite64 open open64 __open_2 __open64_2 \
stat lstat fopen fopen64 fread __fread_chk fwrite opendir readdir \
pthread_mutex_lock
MEMWASTE_LIBC_SYMS_WIN := memcpy memmove memset memcmp strlen strcmp strncmp _wfopen fread fwrite
ifeq ($(MEMWASTE),1)
MEMWASTE_CFLAGS := -DCBM_MEMWASTE=1 -fno-omit-frame-pointer
MEMWASTE_VENDOR_CFLAGS := -DCBM_MEMWASTE=1 -fno-omit-frame-pointer
ifeq ($(IS_LINUX),yes)
MEMWASTE_LDFLAGS := $(foreach sym,$(MEMWASTE_ALLOC_SYMS_LINUX) $(MEMWASTE_LIBC_SYMS_LINUX),-Wl,--wrap=$(sym))
MEMWASTE_SRCS := src/foundation/mem_override_posix.c
endif
ifeq ($(IS_MINGW),yes)
MEMWASTE_LDFLAGS := $(foreach sym,$(MEMWASTE_LIBC_SYMS_WIN),-Wl,--wrap=$(sym))
endif
endif
ifeq ($(MEMWASTE_ACCESS),1)
MEMWASTE_ACCESS_FLAGS := -DCBM_MEMWASTE_ACCESS=1 -O1 -fno-vectorize -fno-slp-vectorize \
-fsanitize-coverage=inline-bool-flag,trace-loads,trace-stores
MEMWASTE_CFLAGS += $(MEMWASTE_ACCESS_FLAGS)
MEMWASTE_VENDOR_CFLAGS += $(MEMWASTE_ACCESS_FLAGS)
endif
ifeq ($(MEMWASTE_PROFILE),1)
MEMWASTE_PROFILE_FLAGS := -DCBM_MEMWASTE_PROFILE=1 -fprofile-instr-generate -fprofile-update=atomic
MEMWASTE_CFLAGS += $(MEMWASTE_PROFILE_FLAGS)
MEMWASTE_VENDOR_CFLAGS += $(MEMWASTE_PROFILE_FLAGS)
MEMWASTE_LDFLAGS += $(MEMWASTE_PROFILE_FLAGS)
endif
# The POSIX wrap shim exists only so the profiler can observe allocations. It
# must never reach a sanitized build: the Linux/macOS test builds are CRT+ASan,
# and redirecting malloc into mimalloc underneath ASan's own interception mixes
@@ -225,7 +294,7 @@ endif
# instead of the system libstdc++ (scripts/msan.sh overrides both vars).
CXX_STDLIB ?= -lstdc++
CXX_STDLIB_FLAGS ?=
LDFLAGS = -lm $(CXX_STDLIB) -lpthread -lz $(WIN32_LIBS) $(STATIC_FLAGS) $(MIMALLOC_WRAP_FLAGS) $(ELF_HARDENING_FLAGS)
LDFLAGS = -lm $(CXX_STDLIB) -lpthread -lz $(WIN32_LIBS) $(STATIC_FLAGS) $(MIMALLOC_WRAP_FLAGS) $(MEMWASTE_LDFLAGS) $(ELF_HARDENING_FLAGS)
LDFLAGS_TEST = -lm $(CXX_STDLIB) -lpthread -lz $(SANITIZE) $(WIN32_LIBS) $(MIMALLOC_WRAP_FLAGS) $(ELF_HARDENING_FLAGS)
LDFLAGS_TSAN = -lm $(CXX_STDLIB) -lpthread -lz $(TSAN_SANITIZE) $(WIN32_LIBS) $(MIMALLOC_WRAP_FLAGS) $(ELF_HARDENING_FLAGS)
@@ -249,6 +318,9 @@ FOUNDATION_SRCS = \
src/foundation/compat_regex.c \
src/foundation/mem.c \
src/foundation/mem_core.c \
src/foundation/mem_events.c \
src/foundation/mem_override_darwin.c \
src/foundation/mem_override_libc.c \
src/foundation/diagnostics.c \
src/foundation/profile.c \
src/foundation/dump_verify.c \
@@ -472,10 +544,19 @@ ifneq ($(filter yes,$(IS_MINGW) $(IS_LINUX)),)
MIMALLOC_OVERRIDE_DEFINE := -DMI_MALLOC_OVERRIDE=1
GLOBAL_OVERRIDE_DEFINE := -DCBM_MEM_GLOBAL_OVERRIDE=1
endif
# MI_DEBUG=0: the production allocator is mimalloc's RELEASE build. Its header
# defaults to MI_DEBUG=2 unless NDEBUG is defined, and nothing here defined it,
# so every shipped binary up to v0.11.0 ran mimalloc's debug build: internal
# assertions, fine-grained statistics, padding on every block and a memset of
# every freed block. The waste sanitizer measured it on the Go corpus as 92 GB
# (macOS) and 444 GB (Linux, where mimalloc also serves libc's malloc) of byte
# work inside the allocator (2026-09-17). Committed-bytes accounting, which the
# memory budget reads, is kept in every build (subproc stats, not MI_STAT).
# The TEST build below keeps mimalloc's debug checks.
MIMALLOC_CFLAGS = -std=c11 -O2 -w \
-Ivendored/mimalloc/include \
-Ivendored/mimalloc/src \
-DMI_OVERRIDE=1 \
-DMI_OVERRIDE=1 -DMI_DEBUG=0 \
$(MIMALLOC_OVERRIDE_DEFINE)
MIMALLOC_CFLAGS_TEST = -std=c11 -g -O1 -w \
-Ivendored/mimalloc/include \
@@ -496,7 +577,7 @@ MIMALLOC_CFLAGS_TEST = -std=c11 -g -O1 -w \
SQLITE3_OMIT_FLAGS = -DSQLITE_OMIT_LOAD_EXTENSION
SQLITE3_SRC = vendored/sqlite3/sqlite3.c
SQLITE3_CFLAGS = -std=c11 -O2 -w -DSQLITE_DQS=0 -DSQLITE_THREADSAFE=1 -DSQLITE_ENABLE_FTS5 \
$(SQLITE3_OMIT_FLAGS)
$(SQLITE3_OMIT_FLAGS) $(MEMWASTE_VENDOR_CFLAGS)
SQLITE3_CFLAGS_TEST = -std=c11 -g -O1 -w -DSQLITE_DQS=0 -DSQLITE_THREADSAFE=1 -DSQLITE_ENABLE_FTS5 \
$(SQLITE3_OMIT_FLAGS)
@@ -508,7 +589,7 @@ TRE_CFLAGS = -std=c11 -g -O1 -w -Ivendored/tre
YYJSON_SRC = vendored/yyjson/yyjson.c
# All production sources
PROD_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(CYPHER_SRCS) $(MCP_SRCS) $(DAEMON_SRCS) $(DISCOVER_SRCS) $(GRAPH_BUFFER_SRCS) $(PIPELINE_SRCS) $(SIMHASH_SRCS) $(SEMANTIC_SRCS) $(TRACES_SRCS) $(WATCHER_SRCS) $(GIT_SRCS) $(CLI_SRCS) $(UI_SRCS) $(YYJSON_SRC)
PROD_SRCS = $(MEMWASTE_SRCS) $(FOUNDATION_SRCS) $(STORE_SRCS) $(CYPHER_SRCS) $(MCP_SRCS) $(DAEMON_SRCS) $(DISCOVER_SRCS) $(GRAPH_BUFFER_SRCS) $(PIPELINE_SRCS) $(SIMHASH_SRCS) $(SEMANTIC_SRCS) $(TRACES_SRCS) $(WATCHER_SRCS) $(GIT_SRCS) $(CLI_SRCS) $(UI_SRCS) $(YYJSON_SRC)
EXISTING_C_SRCS = $(EXTRACTION_SRCS) $(LSP_SRCS) $(TS_RUNTIME_SRC) \
$(GRAMMAR_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC)
@@ -641,7 +722,7 @@ TEST_CLI_SRCS = tests/test_cli.c tests/test_agent_clients.c tests/test_agent_pro
tests/test_config_toml_edit.c tests/test_config_yaml_edit.c tests/test_config_text_edit.c \
tests/test_activation_transaction.c
TEST_MEM_SRCS = tests/test_mem.c
TEST_MEM_SRCS = tests/test_mem.c tests/test_mem_events.c
TEST_UI_SRCS = tests/test_ui.c
TEST_HTTPD_SRCS = tests/test_httpd.c
@@ -746,7 +827,8 @@ BUILD_DIR = build/c
# ── Object file compilation (grammars need relaxed warnings) ─────
# Grammar + tree-sitter runtime: compiled without -Werror (upstream code has warnings)
GRAMMAR_CFLAGS = -std=c11 -D_DEFAULT_SOURCE -O2 -w -I$(CBM_DIR) -I$(TS_INCLUDE) -I$(TS_SRC)
GRAMMAR_CFLAGS = -std=c11 -D_DEFAULT_SOURCE -O2 -w -I$(CBM_DIR) -I$(TS_INCLUDE) -I$(TS_SRC) \
$(MEMWASTE_VENDOR_CFLAGS)
GRAMMAR_CFLAGS_TEST = -std=c11 -D_DEFAULT_SOURCE -g -O1 -w -I$(CBM_DIR) -I$(TS_INCLUDE) -I$(TS_SRC) \
-DCBM_ENABLE_TEST_SEAMS=1 $(SANITIZED_DEFINE) $(SANITIZE)
GRAMMAR_CFLAGS_TSAN = -std=c11 -D_DEFAULT_SOURCE -g -O1 -w -I$(CBM_DIR) -I$(TS_INCLUDE) -I$(TS_SRC) \
@@ -764,7 +846,7 @@ PP_OBJ_TSAN = $(BUILD_DIR)/tsan_preprocessor.o
# ── Targets ──────────────────────────────────────────────────────
.PHONY: test test-par test-repro test-foundation test-tsan test-daemon-smoke cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-tidy-diff lint-cppcheck lint-format security
.PHONY: test test-par test-repro test-foundation test-tsan test-daemon-smoke cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-tidy-diff lint-cppcheck lint-format security fuzz
$(BUILD_DIR):
mkdir -p $(BUILD_DIR)
@@ -868,7 +950,7 @@ $(BUILD_DIR)/tsan_tre.o: $(TRE_SRC) | $(BUILD_DIR)
$(CC) $(TRE_CFLAGS) $(TSAN_SANITIZE) -c -o $@ $<
$(BUILD_DIR)/prod_tre.o: $(TRE_SRC) | $(BUILD_DIR)
$(CC) $(TRE_CFLAGS) -O2 -c -o $@ $<
$(CC) $(TRE_CFLAGS) -O2 $(MEMWASTE_VENDOR_CFLAGS) -c -o $@ $<
endif
# Vendored LZ4 (test build)
@@ -1036,14 +1118,14 @@ $(BUILD_DIR)/prod_preprocessor.o: $(CBM_DIR)/preprocessor.cpp | $(BUILD_DIR)
# Vendored LZ4 (compiled separately, not unity-built via lz4_store.c)
LZ4_OBJ_PROD = $(BUILD_DIR)/prod_lz4.o $(BUILD_DIR)/prod_lz4hc.o
$(BUILD_DIR)/prod_lz4.o: $(CBM_DIR)/vendored/lz4/lz4.c | $(BUILD_DIR)
$(CC) -std=c11 -D_DEFAULT_SOURCE -O2 -w -I$(CBM_DIR) -c -o $@ $<
$(CC) -std=c11 -D_DEFAULT_SOURCE -O2 -w $(MEMWASTE_VENDOR_CFLAGS) -I$(CBM_DIR) -c -o $@ $<
$(BUILD_DIR)/prod_lz4hc.o: $(CBM_DIR)/vendored/lz4/lz4hc.c | $(BUILD_DIR)
$(CC) -std=c11 -D_DEFAULT_SOURCE -O2 -w -I$(CBM_DIR)/vendored/lz4 -c -o $@ $<
$(CC) -std=c11 -D_DEFAULT_SOURCE -O2 -w $(MEMWASTE_VENDOR_CFLAGS) -I$(CBM_DIR)/vendored/lz4 -c -o $@ $<
# Vendored zstd (compiled separately, not unity-built via zstd_store.c)
ZSTD_OBJ_PROD = $(BUILD_DIR)/prod_zstd.o
$(BUILD_DIR)/prod_zstd.o: $(CBM_DIR)/vendored/zstd/zstd.c | $(BUILD_DIR)
$(CC) -std=c11 -D_DEFAULT_SOURCE -O2 -w -I$(CBM_DIR)/vendored/zstd -c -o $@ $<
$(CC) -std=c11 -D_DEFAULT_SOURCE -O2 -w $(MEMWASTE_VENDOR_CFLAGS) -I$(CBM_DIR)/vendored/zstd -c -o $@ $<
OBJS_VENDORED_PROD = $(MIMALLOC_OBJ_PROD) $(SQLITE3_OBJ_PROD) $(TRE_OBJ_PROD) $(GRAMMAR_OBJS_PROD) $(TS_RUNTIME_OBJ_PROD) $(LSP_OBJ_PROD) $(PP_OBJ_PROD) $(LZ4_OBJ_PROD) $(ZSTD_OBJ_PROD) $(UNIXCODER_OBJ)
@@ -1065,7 +1147,7 @@ MAIN_SRC = src/main.c
# for trap debugging -- silently re-run the previously instrumented binary,
# which during this bisect produced a "crashes without sanitizers too" result
# that was pure artifact and sent the investigation down the wrong path.
BUILD_CONFIG_SIG := TEST_SEAMS=$(TEST_SEAMS)|CFLAGS_EXTRA=$(CFLAGS_EXTRA)|SANITIZE=$(SANITIZE)
BUILD_CONFIG_SIG := TEST_SEAMS=$(TEST_SEAMS)|CFLAGS_EXTRA=$(CFLAGS_EXTRA)|SANITIZE=$(SANITIZE)|MEMWASTE=$(MEMWASTE)|MEMWASTE_ACCESS=$(MEMWASTE_ACCESS)|MEMWASTE_PROFILE=$(MEMWASTE_PROFILE)
.PHONY: build-config-check
build-config-check:
$(BUILD_DIR)/.build-config: build-config-check | $(BUILD_DIR)
@@ -1261,6 +1343,28 @@ test-lsan:
cd $(CURDIR) && ASAN_OPTIONS="detect_leaks=1:halt_on_error=1" \
./build/lsan/test-runner $(LSAN_SUITES)
# ── Coverage-guided fuzzing (libFuzzer) ─────────────────────────
# One binary, three targets (tests/fuzz/cbm_fuzz.c): the per-file extractor,
# the Cypher parser, the TOML/YAML/JSON config editors. ASan+UBSan as in the
# test lane, plus fuzzer-no-link on EVERY object -- the vendored grammars and
# the tree-sitter runtime feed coverage back, which is where the parser state
# space lives. Needs a clang that ships libFuzzer (Homebrew LLVM on macOS; Apple
# clang does not). Run through scripts/fuzz.sh.
FUZZ_CC ?= clang
FUZZ_CXX ?= clang++
FUZZ_SANITIZE = -fsanitize=address,undefined,fuzzer-no-link -fno-omit-frame-pointer
FUZZ_SRC = tests/fuzz/cbm_fuzz.c
fuzz:
$(MAKE) -f Makefile.cbm build/fuzz/cbm-fuzz BUILD_DIR=build/fuzz \
CC=$(FUZZ_CC) CXX=$(FUZZ_CXX) SANITIZE="$(FUZZ_SANITIZE)"
$(BUILD_DIR)/cbm-fuzz: $(FUZZ_SRC) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_TEST) $(PROJECT_HDRS) | $(BUILD_DIR)
$(CC) $(CFLAGS_TEST) -fsanitize=fuzzer -o $@ \
$(FUZZ_SRC) $(PROD_SRCS) \
$(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) \
$(OBJS_VENDORED_TEST) \
$(LDFLAGS_TEST)
# Path-sensitive memory analysis: leak paths, null derefs, uninitialized reads.
# Non-gating: triage findings against the code (this lane's first run produced
# 9 real fixes and 6 recorded false positives — see the analyzer batch commit).
+20 -4
View File
@@ -32,6 +32,16 @@ def rpc(proc, payload, timeout_note):
return message
def tool_failed(reply):
"""A JSON-RPC error, or a tool result the server marked isError: MCP reports
a failed tool call as a normal result, so checking only "error" counts every
failure as served (a lab measuring nothing looked healthy)."""
if "error" in reply:
return True
result = reply.get("result")
return isinstance(result, dict) and bool(result.get("isError"))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("binary")
@@ -63,14 +73,16 @@ def main():
"params": {"protocolVersion": "2024-11-05", "capabilities": {},
"clientInfo": {"name": "memlab", "version": "1"}}},
"initialize")
project = None
if not args.skip_index:
indexed = rpc(proc, {"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "index_repository",
"arguments": {"path": args.corpus}}},
"arguments": {"repo_path": args.corpus}}},
"index_repository")
if "error" in indexed:
print(f"index failed: {indexed['error']}", file=sys.stderr)
if tool_failed(indexed):
print(f"index failed: {indexed.get('error') or indexed.get('result')}", file=sys.stderr)
return 3
project = (indexed.get("result", {}).get("structuredContent") or {}).get("project")
halfway = 2 + args.requests // 2
for i in range(2, args.requests + 2):
@@ -84,11 +96,15 @@ def main():
"list_projects": {},
"get_graph_schema": {},
"search_code": {"pattern": "Widget"}}.get(args.tool, {})
if project and args.tool != "list_projects":
arguments = dict(arguments, project=project)
reply = rpc(proc, {"jsonrpc": "2.0", "id": i, "method": "tools/call",
"params": {"name": args.tool, "arguments": arguments}},
f"request {i}")
served += 1
if "error" in reply:
if tool_failed(reply):
if failures == 0:
print(f"first failure (request {i}): {json.dumps(reply)[:600]}", file=sys.stderr, flush=True)
failures += 1
finally:
try:
+61 -34
View File
@@ -1,19 +1,20 @@
#!/usr/bin/env python3
"""Analyse #581 memory-lab output: attribute growth to allocation sites.
Reads the JSONL the profiler appends (CBM_MEM_PROFILE_OUT) and the mem.census
lines from a daemon log, and prints:
Reads the waste layer's dumps (CBM_MEMWASTE_OUT, one at every request-stage phase
mark; see scripts/memlab.sh) and the mem.census lines from a daemon log, and
prints:
* retention per request, as a linear fit rather than first-vs-last, so noise
does not masquerade as a trend
* the call sites whose live bytes grow across the run, ranked by growth
* an explicit unattributed remainder: process growth the profiler could not
account for. That number is the honesty check — a report that attributed
everything while tracking only >=32 KiB allocations would be lying.
* an explicit unattributed remainder: process growth the layer could not
account for (allocator metadata, fragmentation, memory outside malloc).
That number is the honesty check.
Usage:
memlab-report.py <profile.jsonl> [--census <daemon.log>] [--top N]
memlab-report.py <a.jsonl> --diff <b.jsonl> # platform comparison
memlab-report.py <dump.jsonl> [--census <daemon.log>] [--binary BIN] [--top N]
memlab-report.py <a.jsonl> --diff <b.jsonl> [--binary BIN] # platform comparison
"""
import argparse
import json
@@ -23,9 +24,11 @@ from collections import defaultdict
def load_profile(path):
"""Return (summaries, site_series). Records are appended over time, so the
Nth occurrence of a site is its Nth sample."""
summaries, series = [], defaultdict(list)
"""Return (summaries, site_series) for the process with the most dumps.
Every dump lists every site, so the Nth occurrence of a site is its Nth
sample; a site first seen late is padded with zeros so series align."""
procs = defaultdict(lambda: {"summaries": [], "series": defaultdict(list)})
cur = None
with open(path, "r", errors="replace") as handle:
for line in handle:
line = line.strip()
@@ -35,11 +38,31 @@ def load_profile(path):
record = json.loads(line)
except json.JSONDecodeError:
continue
if "site" in record:
series[record["site"]].append(record)
else:
summaries.append(record)
return summaries, series
if "memwaste" in record:
cur = procs[record.get("pid", 0)]
cur["summaries"].append(record)
elif cur is not None and "site" in record and "work" not in record:
samples = cur["series"][record["site"]]
while len(samples) < len(cur["summaries"]) - 1:
samples.append({"live_bytes": 0})
samples.append(record)
if not procs:
return [], {}
best = max(procs.values(), key=lambda p: len(p["summaries"]))
return best["summaries"], best["series"]
def load_symbols(binary, summaries, sites):
"""Symbolise site addresses with the waste report's own symboliser."""
if not binary or not summaries:
return {}
import importlib.util
import os
spec = importlib.util.spec_from_file_location(
"memwaste_report", os.path.join(os.path.dirname(os.path.abspath(__file__)), "memwaste-report.py"))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod.symbolise(binary, summaries[-1].get("image_base", "0x0"), list(sites))
def linear_slope(values):
@@ -57,7 +80,7 @@ def linear_slope(values):
def parse_census(path):
"""Pull the pool totals out of mem.census log lines."""
fields = ("priv_kb", "rss_kb", "crt_kb", "biggest_kb", "prof_live_kb")
fields = ("priv_kb", "rss_kb", "crt_kb", "biggest_kb")
rows = []
pattern = re.compile(r"(\w+)=(\S+)")
with open(path, "r", errors="replace") as handle:
@@ -71,16 +94,16 @@ def parse_census(path):
return rows
def report(path, census_path, top):
def report(path, census_path, top, binary=None):
summaries, series = load_profile(path)
if not summaries and not series:
print(f"{path}: no profiler records — was CBM_MEM_PROFILE=1 set?")
print(f"{path}: no waste-layer records -- was the memwaste flavour run with CBM_MEMWASTE=1?")
return
names = load_symbols(binary, summaries, series.keys())
last = summaries[-1] if summaries else {}
print(f"=== {path} ===")
if last:
print(f"threshold : {last.get('threshold', 0)} bytes "
f"(allocations below this are invisible by design)")
print(f"samples : {len(summaries)} dumps (every allocation size is tracked)")
print(f"sites seen : {last.get('sites', 0)}")
print(f"attributed live: {last.get('live_bytes', 0)/1048576:.1f} MB "
f"in {last.get('live_blocks', 0)} blocks")
@@ -94,23 +117,23 @@ def report(path, census_path, top):
slope = linear_slope(live)
if slope <= 0 and live[-1] <= live[0]:
continue
growth.append((slope, live[-1] - live[0], live[-1], samples[-1]))
growth.append((slope, live[-1] - live[0], live[-1], names.get(site, site)))
growth.sort(key=lambda row: row[0], reverse=True)
print(f"\n--- sites whose retained bytes GROW (top {top}) ---")
if not growth:
print("none — no tracked site retains more over time.")
for slope, delta, live, sample in growth[:top]:
frames = " <- ".join(sample.get("frames", [])[:4])
for slope, delta, live, name in growth[:top]:
print(f" +{slope/1024:8.1f} KB/sample total +{delta/1048576:6.2f} MB "
f"live {live/1048576:6.2f} MB")
print(f" {frames}")
print(f" {name}")
if census_path:
rows = parse_census(census_path)
if rows:
priv = [r["priv_kb"] for r in rows if "priv_kb" in r]
prof = [r["prof_live_kb"] for r in rows if "prof_live_kb" in r]
# attributed = what the waste layer holds live, sampled at its own dumps
prof = [int(h.get("live_bytes", 0)) // 1024 for h in summaries]
print(f"\n--- pools vs attribution ({len(rows)} samples) ---")
if priv:
print(f" private committed : {priv[0]/1024:.1f} -> {priv[-1]/1024:.1f} MB "
@@ -119,24 +142,27 @@ def report(path, census_path, top):
unattributed = (priv[-1] - priv[0]) - (prof[-1] - prof[0])
print(f" attributed growth : {(prof[-1]-prof[0])/1024:+.1f} MB")
print(f" UNATTRIBUTED : {unattributed/1024:+.1f} MB "
"<- below-threshold allocations, allocator metadata, or fragmentation")
"<- allocator metadata, fragmentation, or memory outside malloc (mmap, stacks)")
def diff(path_a, path_b, top):
"""Same sites, two platforms: what does one retain that the other does not?"""
_, series_a = load_profile(path_a)
def diff(path_a, path_b, top, binary=None):
"""Same sites, two platforms: what does one retain that the other does not?
Raw addresses differ between binaries, so sites are matched by symbol name
when --binary is given (the same binary for both runs) and by address otherwise."""
summaries_a, series_a = load_profile(path_a)
_, series_b = load_profile(path_b)
names = load_symbols(binary, summaries_a, set(series_a) | set(series_b))
rows = []
for site, samples in series_a.items():
a_live = samples[-1]["live_bytes"]
b_live = series_b[site][-1]["live_bytes"] if site in series_b else 0
if a_live - b_live > 0:
rows.append((a_live - b_live, a_live, b_live, samples[-1]))
rows.append((a_live - b_live, a_live, b_live, site))
rows.sort(key=lambda row: row[0], reverse=True)
print(f"=== {path_a} minus {path_b} (top {top}) ===")
for delta, a_live, b_live, sample in rows[:top]:
for delta, a_live, b_live, site in rows[:top]:
print(f" +{delta/1048576:7.2f} MB A={a_live/1048576:.2f} MB B={b_live/1048576:.2f} MB")
print(f" {' <- '.join(sample.get('frames', [])[:4])}")
print(f" {names.get(site, site)}")
def main():
@@ -145,11 +171,12 @@ def main():
parser.add_argument("--census")
parser.add_argument("--diff")
parser.add_argument("--top", type=int, default=12)
parser.add_argument("--binary", help="the memwaste binary, for symbol names")
args = parser.parse_args()
if args.diff:
diff(args.profile, args.diff, args.top)
diff(args.profile, args.diff, args.top, args.binary)
else:
report(args.profile, args.census, args.top)
report(args.profile, args.census, args.top, args.binary)
return 0
+16 -7
View File
@@ -10,9 +10,14 @@
#
# Usage: scripts/memlab.sh <binary> [requests] [label]
#
# Output: memlab-<label>.jsonl (profiler records, one block per sample)
# <binary> must be the waste-sanitizer flavour (make -f Makefile.cbm cbm
# MEMWASTE=1 BUILD_DIR=build/memwaste): the attribution comes from its event
# layer, which observes every allocation on Linux, macOS and Windows alike.
#
# Output: memlab-<label>.jsonl (a waste-layer dump at every request-stage phase mark)
# memlab-<label>.log (daemon log with mem.census lines)
# Analyse with: scripts/memlab-report.py memlab-<label>.jsonl --census memlab-<label>.log
# Analyse with: scripts/memlab-report.py memlab-<label>.jsonl --census memlab-<label>.log \
# --binary <binary>
set -u
BINARY="${1:?usage: memlab.sh <binary> [requests] [label]}"
@@ -115,8 +120,13 @@ if command -v cygpath >/dev/null 2>&1 && ! command -v winepath >/dev/null 2>&1;
else
export CBM_CACHE_DIR="$WORK/cache"
fi
export CBM_MEM_PROFILE=1
export CBM_MEM_PROFILE_OUT="$PROFILE_OUT"
export CBM_MEMWASTE=1
export CBM_MEMWASTE_OUT="$PROFILE_OUT"
# The server marks a memory phase at every request stage, and the layer dumps
# at each mark: live bytes per site become a series over identical requests. The
# series needs neither the fill scan nor the duplicate hash, which only cost time.
export CBM_MEMWASTE_FILL=0
export CBM_MEMWASTE_DUPES=0
export CBM_MEM_CENSUS=1
export CBM_LOG_LEVEL=info
export CBM_LOG_FORMAT=text
@@ -155,9 +165,8 @@ if [ "$CENSUS" -eq 0 ]; then
echo "WARN: no census samples — check CBM_MEM_CENSUS wiring" >&2
fi
if [ "$SITES" -eq 0 ]; then
# Not fatal, but never silent: on macOS there is no --wrap, so the
# profiler legitimately has no observation point.
echo "WARN: no profiler records — expected on macOS (no --wrap); a gap anywhere else" >&2
# Never silent: an empty dump means the binary is not the memwaste flavour.
echo "WARN: no waste-layer records -- is $BINARY built with MEMWASTE=1?" >&2
fi
echo "profile: $PROFILE_OUT"
echo "log: $RUN_LOG"
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""
memwaste-padding.py — the layout lane of the waste sanitizer: bytes every
instance of a struct carries for alignment alone.
Padding is waste multiplied by the instance count: 4 bytes in a record the graph
buffer holds 15 million of is 57 MB. The compiler knows every hole; this lane
asks it, for EVERY translation unit of the production build -- vendored code
included (grammars, the tree-sitter runtime, SQLite, mimalloc, tre, lz4, zstd,
the C++ preprocessor) -- by replaying the build's own compile commands with
`-fsyntax-only -Wno-everything -Wpadded`.
Per struct it reports the interior holes (removable by ordering fields from the
largest alignment down), the tail padding (removable only when the interior
reordering frees enough bytes), and how many allocation sites name the type in a
sizeof, as a first hint of instance counts. The event lane's per-site requested
sizes are the runtime evidence to pair it with.
Usage:
scripts/memwaste-padding.py # every TU, top 40 by interior padding
scripts/memwaste-padding.py --project-only # skip vendored TUs
scripts/memwaste-padding.py --json
"""
from __future__ import annotations
import argparse
import concurrent.futures as cf
import json
import os
import re
import shlex
import subprocess
import sys
from collections import defaultdict
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC_EXT = (".c", ".cc", ".cpp")
KEEP_WITH_ARG = {"-include", "-isystem", "-iquote", "-arch", "-target"}
WARN_RE = re.compile(
r"^(?P<file>[^:]+):(?P<line>\d+):\d+: warning: padding (?:struct|class|size of) "
r"'(?P<type>[^']+)' with (?P<bytes>\d+) bytes? to (?P<what>align '(?P<field>[^']*)'|alignment boundary)")
def build_commands(cc: str, cxx: str) -> list[tuple[str, list[str], str]]:
dry = subprocess.run(["make", "-f", "Makefile.cbm", "-n", "cbm", "BUILD_DIR=build/memwaste-padding-dry",
f"CC={cc}", f"CXX={cxx}", "MEMWASTE=1"],
cwd=ROOT, capture_output=True, text=True, check=False)
text = dry.stdout.replace("\\\n", " ")
jobs = []
seen = set()
for line in text.splitlines():
try:
argv = shlex.split(line)
except ValueError:
continue
if not argv or os.path.basename(argv[0]) not in (cc, cxx, os.path.basename(cc), os.path.basename(cxx)):
continue
flags = []
sources = []
i = 1
while i < len(argv):
a = argv[i]
if a in KEEP_WITH_ARG and i + 1 < len(argv):
flags += [a, argv[i + 1]]
i += 2
continue
if a == "-o":
i += 2
continue
if a.startswith(("-I", "-D", "-U", "-std=")):
flags.append(a)
elif a.endswith(SRC_EXT) and os.path.isfile(os.path.join(ROOT, a)):
sources.append(a)
i += 1
for s in sources:
key = (s, tuple(flags))
if key not in seen:
seen.add(key)
jobs.append((argv[0], flags, s))
return jobs
def run_one(job: tuple[str, list[str], str]) -> tuple[str, str]:
compiler, flags, source = job
res = subprocess.run([compiler, "-fsyntax-only", "-Wno-everything", "-Wpadded"] + flags + [source],
cwd=ROOT, capture_output=True, text=True, check=False)
return source, res.stderr
def is_vendored(path: str) -> bool:
return "/vendored/" in f"/{path}" or path.startswith("vendored/")
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--cc", default="clang")
ap.add_argument("--cxx", default="clang++")
ap.add_argument("--top", type=int, default=40)
ap.add_argument("--project-only", action="store_true")
ap.add_argument("--jobs", type=int, default=os.cpu_count() or 4)
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
jobs = build_commands(args.cc, args.cxx)
if args.project_only:
jobs = [j for j in jobs if not is_vendored(j[2])]
if not jobs:
sys.exit("memwaste-padding: no compile commands found (make -n printed none for the compiler)")
holes: dict[str, dict] = {}
with cf.ThreadPoolExecutor(max_workers=args.jobs) as pool:
for source, stderr in pool.map(run_one, jobs):
for line in stderr.splitlines():
m = WARN_RE.match(line)
if not m:
continue
file = os.path.relpath(os.path.join(ROOT, m.group("file")), ROOT) \
if not os.path.isabs(m.group("file")) else os.path.relpath(m.group("file"), ROOT)
t = holes.setdefault(m.group("type"), {"type": m.group("type"), "interior": {}, "tail": 0,
"where": f"{file}:{m.group('line')}",
"vendored": is_vendored(file)})
n = int(m.group("bytes"))
if m.group("field") is not None:
t["interior"][m.group("field")] = n # a header seen by many TUs reports the same hole
else:
t["tail"] = n
t["where"] = f"{file}:{m.group('line')}"
# Allocation hint: how many allocation expressions size by this type.
sizeof_sites: dict[str, int] = defaultdict(int)
names = {t.split(" ", 1)[-1] for t in holes}
alloc_re = re.compile(r"(?:alloc|calloc|realloc|arena_alloc|CBM_DA_PUSH|cbm_da_push)\w*\s*\([^;]*sizeof\s*\(\s*"
r"(?:struct\s+)?(\w+)\s*\)")
for base in ("src", "internal/cbm"):
for dirpath, _, filenames in os.walk(os.path.join(ROOT, base)):
if "/vendored" in dirpath:
continue
for fn in filenames:
if not fn.endswith((".c", ".h")):
continue
try:
text = open(os.path.join(dirpath, fn), encoding="utf-8", errors="replace").read()
except OSError:
continue
for m in alloc_re.finditer(text):
if m.group(1) in names:
sizeof_sites[m.group(1)] += 1
rows = []
for t in holes.values():
interior = sum(t["interior"].values())
short = t["type"].split(" ", 1)[-1]
rows.append({"type": t["type"], "interior_bytes": interior, "tail_bytes": t["tail"],
"holes": len(t["interior"]), "alloc_sites": sizeof_sites.get(short, 0),
"where": t["where"], "vendored": t["vendored"]})
rows.sort(key=lambda r: (-(r["alloc_sites"] > 0), -r["interior_bytes"], r["type"]))
if args.json:
json.dump({"translation_units": len(jobs), "structs": rows}, sys.stdout, indent=1)
print()
return 0
print(f"translation units checked: {len(jobs)} structs with padding: {len(rows)} "
f"(vendored: {sum(1 for r in rows if r['vendored'])})")
print()
print(f" {'interior B':>10} {'tail B':>6} {'holes':>5} {'alloc sites':>11} struct (declared at)")
for r in rows[: args.top]:
tag = " [vendored]" if r["vendored"] else ""
print(f" {r['interior_bytes']:>10} {r['tail_bytes']:>6} {r['holes']:>5} {r['alloc_sites']:>11} "
f"{r['type']}{tag} ({r['where']})")
return 0
if __name__ == "__main__":
sys.exit(main())
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""
memwaste-ratchet.py — hold the waste numbers where they are, lower them as they fall.
Same shape as the memory-core linter's ratchet: a checked-in baseline per venue
(scripts/memwaste-baseline.json), every waste class normalised PER THOUSAND GRAPH
NODES so a corpus that grows does not look like a regression. A class that grows
past its baseline by more than --tolerance fails under --enforce; without it the
comparison is printed and the exit is 0 (report-only rollout). A class that
FALLS is reported so the baseline can be lowered with --record.
The inputs are what scripts/memwaste.sh leaves in its --out directory:
memwaste.json (the report's --json) and index.json (the index result, for the
node count).
Usage:
memwaste-ratchet.py --out DIR --venue pr-linux # compare, report-only
memwaste-ratchet.py --out DIR --venue pr-linux --enforce # compare, gate
memwaste-ratchet.py --out DIR --venue pr-linux --record # write the baseline
"""
from __future__ import annotations
import argparse
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASELINE = os.path.join(ROOT, "scripts", "memwaste-baseline.json")
# Classes the ratchet holds. Left out on purpose: allocator-internal byte work
# (it moves with allocator settings), the access-lane classes (one lane only),
# and contended mutex acquisitions and pool imbalance -- those depend on thread
# scheduling, and a gate on them would be a lottery (O9). They stay in the report.
CLASSES = [
"slack_bytes", "never_written_bytes", "over_requested_bytes", "realloc_copy_bytes",
"short_lived", "dup_bytes_max", "retained_bytes_max", "raw_bytes",
"container_idle_peak_bytes", "byte_work_bytes", "byte_work_repeats", "io_tiny_calls",
"path_repeats", "ht_repeat_lookups", "ht_growths",
]
def current(out_dir: str) -> dict:
report = json.load(open(os.path.join(out_dir, "memwaste.json")))
index = json.load(open(os.path.join(out_dir, "index.json")))
nodes = int(index.get("nodes") or 0)
if nodes <= 0:
sys.exit("memwaste-ratchet: index.json has no node count")
# The index worker is the process that allocated the most.
worker = max(report["processes"].values(), key=lambda p: p["header"].get("usable_bytes", 0))
summary = worker["summary"]
return {
"nodes": nodes,
"per_1k_nodes": {c: round(summary.get(c, 0) * 1000.0 / nodes, 3) for c in CLASSES},
}
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", required=True, help="memwaste.sh --out directory")
ap.add_argument("--venue", required=True, help="baseline key, e.g. pr-linux, dryrun-macos")
ap.add_argument("--tolerance", type=float, default=0.05, help="allowed growth before a class fails")
ap.add_argument("--enforce", action="store_true")
ap.add_argument("--record", action="store_true")
args = ap.parse_args()
cur = current(args.out)
base_all = json.load(open(BASELINE)) if os.path.exists(BASELINE) else {}
if args.record:
base_all[args.venue] = cur
with open(BASELINE, "w") as fh:
json.dump(base_all, fh, indent=1, sort_keys=True)
fh.write("\n")
print(f"baseline recorded for {args.venue} ({cur['nodes']:,} nodes)")
return 0
base = base_all.get(args.venue)
if not base:
print(f"no baseline for {args.venue}: nothing to hold yet (record one with --record)")
return 0
worse, better = [], []
print(f"{'class':<28} {'baseline/1k':>14} {'now/1k':>14} {'delta':>8}")
for c in CLASSES:
b = base["per_1k_nodes"].get(c, 0.0)
n = cur["per_1k_nodes"].get(c, 0.0)
delta = (n - b) / b if b else (0.0 if n == 0 else float("inf"))
mark = ""
if delta > args.tolerance:
worse.append(c)
mark = " WORSE"
elif delta < -args.tolerance:
better.append(c)
mark = " better"
shown = "new" if delta == float("inf") else f"{delta * 100:+.1f}%"
print(f"{c:<28} {b:>14,.3f} {n:>14,.3f} {shown:>8}{mark}")
if better:
print(f"\n{len(better)} class(es) improved beyond tolerance: lower the baseline with --record")
if worse:
print(f"\n{len(worse)} class(es) grew beyond {args.tolerance * 100:.0f}%: {', '.join(worse)}")
return 1 if args.enforce else 0
return 0
if __name__ == "__main__":
sys.exit(main())
+392
View File
@@ -0,0 +1,392 @@
#!/usr/bin/env python3
"""
memwaste-report.py — turn waste-sanitizer dumps into ranked, symbolised evidence.
The sanitizer (src/foundation/mem_events.h) appends JSON lines at every phase
mark: a header per dump, then one object per allocation site and one per CPU
work row, all with RAW return addresses. Symbolisation is offline so the hot
path never loads a symbol handler. This tool reads every dump, keeps the LAST
dump of each process for the cumulative counters, the MAXIMUM over its dumps for
the phase snapshots (retained, duplicates), resolves the addresses against the
binary that produced them, and prints:
1. a waste summary per process — one line per waste class, in bytes/calls;
2. the memory table — allocation sites ranked by a chosen waste metric;
3. containers — arenas, dynamic arrays, hash tables: capacity against use;
4. CPU work — byte work, I/O, contention, pool imbalance, repeated work.
Usage:
memwaste-report.py DUMP.jsonl --binary build/memwaste/codebase-memory-mcp
memwaste-report.py DUMP.jsonl --binary ... --sort never_written --top 30
memwaste-report.py DUMP.jsonl --binary ... --json (for the ratchet)
"""
from __future__ import annotations
import argparse
import json
import os
import platform
import re
import shutil
import subprocess
import sys
from collections import defaultdict
MB = 1024.0 * 1024.0
SNAPSHOT_FIELDS = ("retained_bytes", "dup_bytes", "dup_blocks")
WASTE_CLASSES = [
# (key, label, unit)
("slack_bytes", "allocator slack (usable - requested)", "bytes"),
("never_written_bytes", "never-written bytes (fill pattern intact at free)", "bytes"),
("over_requested_bytes", "requested beyond the last written byte", "bytes"),
("zero_untouched_bytes", "calloc tail still zero at free (upper bound)", "bytes"),
("realloc_copy_bytes", "bytes copied by realloc growth", "bytes"),
("short_lived", "short-lived allocations (churn)", "calls"),
("dup_bytes_max", "duplicate small blocks (peak over phases)", "bytes"),
("retained_bytes_max", "live across a phase boundary (peak)", "bytes"),
("raw_bytes", "bytes that never went through the memory core", "bytes"),
("acc_untouched_bytes", "blocks never read or written (access lane)", "bytes"),
("acc_dead_bytes", "blocks written, never read by instrumented code (access lane)", "bytes"),
("acc_opaque_blocks", "blocks used only by code the access lane cannot see (no verdict)", "blocks"),
("acc_idle_bytes", "blocks idle for at least one phase before free (access lane)", "bytes"),
("acc_uninit_reads", "reads before any write, not calloc (access lane)", "blocks"),
("container_idle_peak_bytes", "container capacity left unused (largest instance, per site)", "bytes"),
("byte_work_bytes", "bytes moved/compared by mem*/str* calls in program code", "bytes"),
("allocator_byte_work_bytes", "bytes moved/compared inside the allocator (mimalloc)", "bytes"),
("byte_work_repeats", "strlen of a string just measured", "calls"),
("io_tiny_calls", "I/O calls under 512 bytes", "calls"),
("path_repeats", "open/stat of a path already opened/statted", "calls"),
("mutex_contended", "contended mutex acquisitions", "calls"),
("pool_imbalance_items", "work items an even pool would have spread", "items"),
("pool_ops_imbalance", "worker events an even pool would have spread (work done)", "events"),
("ht_repeat_lookups", "hash lookups of the key object just looked up", "calls"),
("ht_growths", "hash-table growths", "calls"),
]
BYTE_KINDS = {"memcpy", "memmove", "memset", "memcmp", "strlen", "strcmp", "strncmp"}
IO_KINDS = {"read", "write", "pread", "pwrite", "fread", "fwrite"}
PATH_KINDS = {"open", "stat", "fopen", "opendir"}
CONTAINER_KINDS = {"ct_arena", "ct_dyn_array", "ct_hash_table"}
# Symbols of the vendored allocator. Its byte work is real CPU, but the fix is in
# how the allocator is built or configured, not at a program call site.
ALLOCATOR_SYMBOL = re.compile(r"^_?mi_")
def load(path: str):
"""{pid: {"header": last header, "headers": [...], "sites": {addr: row}, "snap_max": {addr: {...}},
"work": {(kind, addr): row}}} — sites/work from the LAST dump of the process."""
procs: dict[int, dict] = {}
cur = None
with open(path, encoding="utf-8", errors="replace") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue # a torn line from a process that died mid-write
if "memwaste" in obj:
pid = int(obj.get("pid", 0))
p = procs.setdefault(pid, {"headers": [], "snap_max": defaultdict(dict)})
p["headers"].append(obj)
p["header"] = obj
p["sites"] = {}
p["work"] = {}
cur = p
elif cur is None:
continue
elif "work" in obj:
cur["work"][(obj["work"], obj["site"])] = obj
elif "site" in obj:
cur["sites"][obj["site"]] = obj
snap = cur["snap_max"][obj["site"]]
for f in SNAPSHOT_FIELDS:
v = obj.get(f, 0)
if v > snap.get(f, 0):
snap[f] = v
snap[f + "_phase"] = cur["header"].get("phase_label", "?")
return procs
def pe_image_base(binary: str) -> int | None:
"""The preferred ImageBase of a PE file, or None for anything else. A PE
symboliser wants VA = ImageBase + RVA, and the dump gives RVA + runtime base."""
try:
with open(binary, "rb") as fh:
head = fh.read(4096)
except OSError:
return None
if head[:2] != b"MZ" or len(head) < 0x40:
return None
pe = int.from_bytes(head[0x3C:0x40], "little")
if head[pe:pe + 4] != b"PE\0\0":
return None
opt = pe + 24
magic = int.from_bytes(head[opt:opt + 2], "little")
if magic == 0x20B: # PE32+
return int.from_bytes(head[opt + 24:opt + 32], "little")
if magic == 0x10B: # PE32
return int.from_bytes(head[opt + 28:opt + 32], "little")
return None
def symbolise(binary: str | None, base: str, addrs: list[str]) -> dict[str, str]:
if not binary or not addrs:
return {}
if not os.path.exists(binary) and os.path.exists(binary + ".exe"):
binary = binary + ".exe"
out: dict[str, str] = {}
uniq = sorted(set(addrs))
if platform.system() == "Darwin" and shutil.which("atos"):
for i in range(0, len(uniq), 2000):
chunk = uniq[i:i + 2000]
res = subprocess.run(["atos", "-o", binary, "-l", base] + chunk,
capture_output=True, text=True, check=False)
for addr, name in zip(chunk, res.stdout.splitlines()):
out[addr] = name.strip()
elif shutil.which("addr2line") or shutil.which("llvm-addr2line"):
tool = shutil.which("llvm-addr2line") or shutil.which("addr2line")
b = int(base, 16)
preferred = pe_image_base(binary) or 0
for i in range(0, len(uniq), 2000):
chunk = uniq[i:i + 2000]
rel = [hex(int(a, 16) - b + preferred) if b else a for a in chunk]
res = subprocess.run([tool, "-f", "-C", "-p", "-e", binary] + rel,
capture_output=True, text=True, check=False)
for addr, name in zip(chunk, res.stdout.splitlines()):
out[addr] = name.strip()
return out
def pct(part: float, whole: float) -> float:
return 100.0 * part / whole if whole else 0.0
def summarise(p: dict, names: dict[str, str]) -> dict:
s = defaultdict(int)
for addr, r in p["sites"].items():
s["slack_bytes"] += max(r["usable_bytes"] - r["requested_bytes"], 0)
for k in ("never_written_bytes", "over_requested_bytes", "zero_untouched_bytes",
"realloc_copy_bytes", "short_lived", "raw_bytes", "acc_untouched_bytes",
"acc_dead_bytes", "acc_idle_bytes", "acc_uninit_reads", "acc_opaque_blocks"):
s[k] += r.get(k, 0)
snap = p["snap_max"].get(addr, {})
s["dup_bytes_max"] += snap.get("dup_bytes", 0)
s["retained_bytes_max"] += snap.get("retained_bytes", 0)
for (kind, addr), w in p["work"].items():
if kind in CONTAINER_KINDS:
s["container_idle_peak_bytes"] += w.get("peak", max(w["bytes"] - w["aux1"], 0))
if kind == "ct_hash_table":
s["ht_growths"] += w["aux2"]
elif kind in BYTE_KINDS:
if ALLOCATOR_SYMBOL.match(names.get(addr, "")):
s["allocator_byte_work_bytes"] += w["bytes"]
else:
s["byte_work_bytes"] += w["bytes"]
s["byte_work_repeats"] += w["repeats"]
elif kind in IO_KINDS:
s["io_tiny_calls"] += w["aux1"]
elif kind in PATH_KINDS:
s["path_repeats"] += w["repeats"]
elif kind == "mutex":
s["mutex_contended"] += w["aux1"]
elif kind == "parallel_for":
s["pool_imbalance_items"] += w["aux1"]
elif kind == "pool_ops":
s["pool_ops_imbalance"] += w["aux1"]
elif kind == "ht_get":
s["ht_repeat_lookups"] += w["repeats"]
return dict(s)
def fmt(v: float, unit: str) -> str:
if unit == "bytes":
return f"{v / MB:,.1f} MB"
return f"{v:,.0f} {unit}"
SITE_SORTS = {
"bytes": lambda r, s: r["usable_bytes"],
"slack": lambda r, s: r["usable_bytes"] - r["requested_bytes"],
"never_written": lambda r, s: r.get("never_written_bytes", 0),
"over_requested": lambda r, s: r.get("over_requested_bytes", 0),
"churn": lambda r, s: r["short_lived"],
"copy": lambda r, s: r["realloc_copy_bytes"],
"live": lambda r, s: r["live_bytes"],
"allocs": lambda r, s: r["allocs"],
"dups": lambda r, s: s.get("dup_bytes", 0),
"retained": lambda r, s: s.get("retained_bytes", 0),
"dead": lambda r, s: r.get("acc_dead_bytes", 0),
"untouched": lambda r, s: r.get("acc_untouched_bytes", 0),
"idle": lambda r, s: r.get("acc_idle_bytes", 0),
"uninit": lambda r, s: r.get("acc_uninit_reads", 0),
}
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("dump")
ap.add_argument("--binary", help="the binary that produced the dump, for symbolisation")
ap.add_argument("--top", type=int, default=20)
ap.add_argument("--sort", choices=sorted(SITE_SORTS), default="bytes")
ap.add_argument("--json", action="store_true", help="emit the merged tables as JSON")
args = ap.parse_args()
procs = load(args.dump)
if not procs:
print("no waste-sanitizer dump found in", args.dump, file=sys.stderr)
return 2
names_by_pid = {}
for pid, p in procs.items():
addrs = list(p["sites"].keys()) + [a for (_, a) in p["work"].keys()]
names_by_pid[pid] = symbolise(args.binary, p["header"].get("image_base", "0x0"), addrs)
if args.json:
out = {"processes": {}}
for pid, p in procs.items():
names = names_by_pid[pid]
out["processes"][str(pid)] = {
"header": p["header"],
"phases": [h.get("phase_label") for h in p["headers"]],
"summary": summarise(p, names),
"sites": [dict(r, name=names.get(a, a),
**{(k if k.endswith("_phase") else k + "_max"): v
for k, v in p["snap_max"].get(a, {}).items()})
for a, r in p["sites"].items()],
"work": [dict(w, name=names.get(a, a)) for (_, a), w in p["work"].items()],
}
json.dump(out, sys.stdout, indent=1)
print()
return 0
# Work-heavy processes first: the index worker is the one that matters.
order = sorted(procs.items(), key=lambda kv: -kv[1]["header"].get("usable_bytes", 0))
for pid, p in order:
h = p["header"]
names = names_by_pid[pid]
s = summarise(p, names)
print("=" * 110)
print(f"process {pid} last dump={h.get('why')} phases={len(p['headers'])} allocs={h['allocs']:,} "
f"cum={h['usable_bytes'] / MB:,.0f} MB live={h['live_bytes'] / MB:,.0f} MB "
f"rss={h.get('rss_bytes', 0) / MB:,.0f} MB sites={h['sites']:,} fill={h.get('fill')} access={h.get('access')} "
f"layer={h.get('layer_vm_bytes', 0) / MB:,.0f} MB/{h.get('layer_threads', 0)} threads")
blind = {k: h.get(k, 0) for k in ("site_table_full", "pointer_table_full", "work_table_full", "untracked_frees")}
if any(blind.values()):
print(" blind spots: " + " ".join(f"{k}={v:,}" for k, v in blind.items()))
if h.get("foreign_frees", 0):
print(f" outside the shim: {h['foreign_frees']:,} frees of blocks another allocator made "
"(C runtime / system DLL; their allocations are invisible to the layer)")
print()
print(" WASTE SUMMARY")
for key, label, unit in WASTE_CLASSES:
v = s.get(key, 0)
if v:
print(f" {fmt(v, unit):>18} {label}")
if len(p["sites"]) == 0 and len(p["work"]) == 0:
continue
print()
print(f" MEMORY — top {args.top} sites by {args.sort}")
print(f" {'cum MB':>9} {'allocs':>12} {'slack%':>6} {'churn%':>6} {'copy MB':>8} {'unwrit MB':>9} "
f"{'overreq MB':>10} {'dup MB':>7} {'retain MB':>9} {'raw%':>5} {'live MB':>8} site")
key = SITE_SORTS[args.sort]
rows = sorted(p["sites"].items(), key=lambda kv: (-key(kv[1], p["snap_max"].get(kv[0], {})), kv[0]))
for addr, r in rows[:args.top]:
snap = p["snap_max"].get(addr, {})
print(f" {r['usable_bytes'] / MB:>9,.1f} {r['allocs']:>12,} "
f"{pct(r['usable_bytes'] - r['requested_bytes'], r['usable_bytes']):>6.1f} "
f"{pct(r['short_lived'], r['allocs']):>6.1f} {r['realloc_copy_bytes'] / MB:>8,.1f} "
f"{r.get('never_written_bytes', 0) / MB:>9,.1f} {r.get('over_requested_bytes', 0) / MB:>10,.1f} "
f"{snap.get('dup_bytes', 0) / MB:>7,.1f} {snap.get('retained_bytes', 0) / MB:>9,.1f} "
f"{pct(r['raw_bytes'], r['usable_bytes']):>5.0f} {r['live_bytes'] / MB:>8,.1f} "
f"{names.get(addr, addr)[:90]}")
if h.get("access"):
# The access lane's verdicts per allocation site. Library-owned blocks (a FILE
# buffer) are not tracked; bytes a library wrote without a store callback are
# recognised through the fill pattern.
acc_cols = [("untouched", "acc_untouched_bytes", "never read or written"),
("dead", "acc_dead_bytes", "written, never read"),
("idle", "acc_idle_bytes", "idle for a phase before free")]
for sort_key, field, label in acc_cols:
rows = [(a, r) for a, r in p["sites"].items() if r.get(field, 0) > 0]
if not rows:
continue
print()
print(f" ACCESS — top {args.top} sites by bytes {label}")
print(f" {'MB':>9} {'blocks':>11} {'allocs':>12} {'uninit rd':>10} site")
blocks_field = field.replace("_bytes", "_blocks")
for a, r in sorted(rows, key=lambda t: (-t[1][field], t[0]))[:args.top]:
print(f" {r[field] / MB:>9,.1f} {r.get(blocks_field, 0):>11,} {r['allocs']:>12,} "
f"{r.get('acc_uninit_reads', 0):>10,} {names.get(a, a)[:90]}")
rows = [(a, r) for a, r in p["sites"].items() if r.get("acc_uninit_reads", 0) > 0]
if rows:
print()
print(f" ACCESS — top {args.top} sites by blocks read before any write (not calloc)")
print(f" {'blocks':>11} {'allocs':>12} site")
for a, r in sorted(rows, key=lambda t: (-t[1]["acc_uninit_reads"], t[0]))[:args.top]:
print(f" {r['acc_uninit_reads']:>11,} {r['allocs']:>12,} {names.get(a, a)[:90]}")
ct = [(k, a, w) for (k, a), w in p["work"].items() if k in CONTAINER_KINDS]
if ct:
print()
# A container reports at every reset: calls are USES, cap/used sum over uses.
# "peak idle" is the largest capacity a single instance left unused.
print(f" CONTAINERS — top {args.top} by largest unused capacity of one instance")
print(f" {'kind':<10} {'uses':>9} {'avg cap MB':>10} {'avg used MB':>11} {'unused%':>7} "
f"{'peak idle MB':>12} {'growths':>9} created at")
for k, a, w in sorted(ct, key=lambda t: (-t[2].get("peak", 0), t[1]))[:args.top]:
uses = max(w["calls"], 1)
print(f" {k[3:]:<10} {w['calls']:>9,} {w['bytes'] / uses / MB:>10,.2f} {w['aux1'] / uses / MB:>11,.2f} "
f"{pct(w['bytes'] - w['aux1'], w['bytes']):>7.1f} {w.get('peak', 0) / MB:>12,.1f} "
f"{w['aux2']:>9,} {names.get(a, a)[:90]}")
def section(title, kinds, sort_key, cols):
rows = [(k, a, w) for (k, a), w in p["work"].items() if k in kinds]
if not rows:
return
print()
print(f" CPU — {title}")
print(" " + " ".join(f"{c[0]:>{c[1]}}" for c in cols) + " site")
for k, a, w in sorted(rows, key=lambda t: (-sort_key(t[2]), t[1]))[:args.top]:
cells = []
for c in cols:
v = c[2](k, w)
cells.append(f"{v:>{c[1]}}" if isinstance(v, str) else f"{v:>{c[1]},.0f}")
print(" " + " ".join(cells) + f" {names.get(a, a)[:90]}")
section("byte work (mem*/str*) by bytes", BYTE_KINDS, lambda w: w["bytes"],
[("kind", 8, lambda k, w: k), ("calls", 12, lambda k, w: w["calls"]),
("MB", 10, lambda k, w: w["bytes"] / MB), ("repeats", 10, lambda k, w: w["repeats"]),
("max call", 10, lambda k, w: w.get("peak", 0))])
section("I/O by calls", IO_KINDS, lambda w: w["calls"],
[("kind", 8, lambda k, w: k), ("calls", 12, lambda k, w: w["calls"]),
("MB", 10, lambda k, w: w["bytes"] / MB), ("tiny", 10, lambda k, w: w["aux1"])])
section("paths opened/statted again", PATH_KINDS, lambda w: w["repeats"],
[("kind", 8, lambda k, w: k), ("calls", 12, lambda k, w: w["calls"]),
("repeats", 10, lambda k, w: w["repeats"]), ("failed", 10, lambda k, w: w["aux1"])])
section("mutex contention", {"mutex"}, lambda w: w["aux1"],
[("calls", 12, lambda k, w: w["calls"]), ("contended", 10, lambda k, w: w["aux1"])])
section("parallel_for imbalance", {"parallel_for"}, lambda w: w["aux1"],
[("calls", 8, lambda k, w: w["calls"]), ("items", 10, lambda k, w: w["bytes"]),
("imbalance", 10, lambda k, w: w["aux1"])])
# imbalance / (work + imbalance): the share of the pool's capacity that sat idle
# while the busiest worker finished (0% = perfectly even).
section("pool balance by work done (per-worker events)", {"pool_ops"}, lambda w: w["aux1"],
[("calls", 8, lambda k, w: w["calls"]), ("events", 14, lambda k, w: w["bytes"]),
("idle%", 6, lambda k, w: f"{pct(w['aux1'], w['bytes'] + w['aux1']):.1f}"),
("workers", 8, lambda k, w: w["aux2"] / max(w["calls"], 1))])
section("hash-table lookups", {"ht_get"}, lambda w: w["repeats"],
[("gets", 12, lambda k, w: w["calls"]), ("misses", 12, lambda k, w: w["aux1"]),
("repeats", 12, lambda k, w: w["repeats"])])
section("dead writes (access lane)", {"dead_write"}, lambda w: w["bytes"],
[("blocks", 10, lambda k, w: w["aux1"]), ("MB", 10, lambda k, w: w["bytes"] / MB)])
return 0
if __name__ == "__main__":
sys.exit(main())
+262
View File
@@ -0,0 +1,262 @@
#!/usr/bin/env python3
"""
memwaste-scaling.py — the CPU scaling lane of the waste sanitizer.
Work that grows faster than the input is the costliest CPU waste there is, and a
single run cannot see it: a function that burns 5 % of the time on the Go corpus
may burn 60 % on a corpus four times larger. This lane indexes the SAME real
source tree replicated K times and 2K times, with clang's execution counters in
every object (vendored ones included), and compares, function by function, how
much work each leg did. Linear work doubles; a function whose counters grow by
more than --threshold (default 2.3, i.e. 15 % above linear) while doing at least
--min-excess extra counter increments is reported as super-linear.
The counters are COUNTS, not time: the verdict is a function of the code and the
input, so it can gate (O9). Wall time is printed as information only.
Replicas are hard links (copies when the file system refuses) under
<out>/leg-<n>/r<i>/, so every replica is a separate directory -- a separate Go
package, a separate module -- and the registry, namespace and resolution paths
see N times the same names: exactly the coupling behind the #1669 class of bugs.
Usage:
scripts/memwaste-scaling.py --corpus ~/perf-bench/go/src/net --replicas 2
scripts/memwaste-scaling.py --corpus DIR --replicas 3 --no-build --json
Build: MEMWASTE_PROFILE=1 into build/memwaste-profile with clang (the driver does
it unless --no-build). llvm-profdata is taken from the directory of the clang
that built the binary, so the profile format always matches.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BUILD_DIR = "build/memwaste-profile"
def find_profdata(cc: str) -> str:
cc_path = shutil.which(cc)
if cc_path:
cand = os.path.join(os.path.dirname(os.path.realpath(cc_path)), "llvm-profdata")
if os.access(cand, os.X_OK):
return cand
for name in ("llvm-profdata",) + tuple(f"llvm-profdata-{v}" for v in range(30, 13, -1)):
p = shutil.which(name)
if p:
return p
if shutil.which("xcrun"):
res = subprocess.run(["xcrun", "--find", "llvm-profdata"], capture_output=True, text=True, check=False)
if res.returncode == 0 and res.stdout.strip():
return res.stdout.strip()
sys.exit("memwaste-scaling: llvm-profdata not found next to the compiler or on PATH")
def replicate(src: str, dst: str, copies: int) -> int:
"""Hard-link `copies` replicas of src under dst/r<i>; returns the file count."""
files = 0
for i in range(copies):
base = os.path.join(dst, f"r{i}")
for dirpath, dirnames, filenames in os.walk(src):
dirnames[:] = [d for d in dirnames if d not in (".git", ".hg", ".svn")]
rel = os.path.relpath(dirpath, src)
out_dir = os.path.join(base, rel)
os.makedirs(out_dir, exist_ok=True)
for name in filenames:
s = os.path.join(dirpath, name)
if os.path.islink(s) or not os.path.isfile(s):
continue
d = os.path.join(out_dir, name)
try:
os.link(s, d)
except OSError:
shutil.copy2(s, d)
files += 1
return files
def run_leg(binary: str, profdata: str, corpus: str, work: str, copies: int) -> tuple[dict, dict]:
leg = os.path.join(work, f"leg-{copies}")
tree = os.path.join(leg, "corpus")
os.makedirs(tree, exist_ok=True)
nfiles = replicate(corpus, tree, copies)
raw_dir = os.path.join(leg, "profraw")
os.makedirs(raw_dir, exist_ok=True)
runtime = tempfile.mkdtemp(prefix="cbm-sc.", dir="/tmp")
os.chmod(runtime, 0o700)
env = dict(os.environ)
env.update({
"LLVM_PROFILE_FILE": os.path.join(raw_dir, "cbm-%p.profraw"),
"CBM_PROFILE_DIR": raw_dir, # the index worker names its own: worker-<pid>.profraw
"CBM_CACHE_DIR": os.path.join(leg, "cache"),
"CBM_RUNTIME_DIR": runtime,
"CBM_LOG_LEVEL": "error",
})
env.pop("CBM_MEMWASTE", None) # production code paths: the event layer stays dormant
t0 = time.monotonic()
try:
res = subprocess.run([binary, "cli", "index_repository", "--repo-path", tree, "--mode", "full"],
env=env, capture_output=True, text=True, check=False)
finally:
shutil.rmtree(runtime, ignore_errors=True)
wall = time.monotonic() - t0
if res.returncode != 0:
sys.exit(f"memwaste-scaling: index of leg {copies} failed (rc={res.returncode}):\n{res.stderr[-2000:]}")
raws = sorted(os.path.join(raw_dir, f) for f in os.listdir(raw_dir) if f.endswith(".profraw"))
workers = [r for r in raws if os.path.basename(r).startswith("worker-")]
if not workers:
sys.exit("memwaste-scaling: the index worker wrote no profile -- is the binary built with "
"MEMWASTE_PROFILE=1, and does it call cbm_memev_process_exit before _Exit?")
# The comparison is the WORKER's work: the daemon and CLI processes differ in
# number between legs (and hash the binary at start-up), which is not scaling.
info = {"copies": copies, "files": nfiles, "wall_s": round(wall, 1), "profiles": len(raws)}
return merge_counts(profdata, workers, os.path.join(leg, "worker.profdata")), \
dict(info, other=merge_counts(profdata, [r for r in raws if r not in workers],
os.path.join(leg, "other.profdata")))
def merge_counts(profdata: str, raws: list[str], merged: str) -> dict[str, int]:
if not raws:
return {}
subprocess.run([profdata, "merge", "-o", merged] + raws, check=True)
shown = subprocess.run([profdata, "show", "--all-functions", "--counts", merged],
capture_output=True, text=True, check=True).stdout
return parse_counts(shown)
FUNC_RE = re.compile(r"^ (\S.*):$")
def parse_counts(text: str) -> dict[str, int]:
"""Total counter increments per function from `llvm-profdata show --counts`."""
out: dict[str, int] = {}
name = None
for line in text.splitlines():
m = FUNC_RE.match(line)
if m:
name = m.group(1)
continue
if name is None:
continue
s = line.strip()
if s.startswith("Function count:"):
out[name] = out.get(name, 0) + int(s.split(":", 1)[1])
elif s.startswith("Block counts:"):
vals = s.split(":", 1)[1].strip().strip("[]")
if vals:
out[name] = out.get(name, 0) + sum(int(v) for v in vals.split(",") if v.strip())
return out
def display_name(raw: str) -> str:
# static functions are "file.c;name" (or "file.c:name" in older formats)
for sep in (";", ":"):
if sep in raw:
file_part, fn = raw.rsplit(sep, 1)
return f"{fn} ({os.path.basename(file_part)})"
return raw
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--corpus", required=True, help="a real source tree to replicate")
ap.add_argument("--replicas", type=int, default=2, help="K: legs index K and 2K replicas")
ap.add_argument("--threshold", type=float, default=2.3, help="2K/K counter ratio that is super-linear")
ap.add_argument("--min-excess", type=int, default=10_000_000,
help="ignore functions whose work beyond linear is below this many counter increments")
ap.add_argument("--top", type=int, default=30)
ap.add_argument("--out", help="work directory (default: a new temp dir)")
ap.add_argument("--no-build", action="store_true")
ap.add_argument("--cc", default="clang")
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
corpus = os.path.abspath(os.path.expanduser(args.corpus))
if not os.path.isdir(corpus):
sys.exit(f"memwaste-scaling: {corpus} is not a directory")
binary = os.path.join(ROOT, BUILD_DIR, "codebase-memory-mcp")
if not args.no_build:
cxx = "clang++" if args.cc == "clang" else args.cc.replace("clang", "clang++")
subprocess.run([os.path.join(ROOT, "scripts", "build.sh"), f"BUILD_DIR={BUILD_DIR}",
"MEMWASTE_PROFILE=1", f"CC={args.cc}", f"CXX={cxx}"],
check=True, stdout=subprocess.DEVNULL)
if not os.access(binary, os.X_OK):
sys.exit(f"memwaste-scaling: {binary} is missing (build it, or drop --no-build)")
profdata = find_profdata(args.cc)
work = os.path.abspath(args.out) if args.out else tempfile.mkdtemp(prefix="cbm-scaling.")
os.makedirs(work, exist_ok=True)
for sub in os.listdir(work):
if sub.startswith("leg-"):
shutil.rmtree(os.path.join(work, sub), ignore_errors=True)
k = args.replicas
small, info_k = run_leg(binary, profdata, corpus, work, k)
large, info_2k = run_leg(binary, profdata, corpus, work, 2 * k)
total_k = sum(small.values())
total_2k = sum(large.values())
rows = []
for name, c2 in large.items():
c1 = small.get(name, 0)
excess = c2 - 2 * c1
ratio = (c2 / c1) if c1 else float("inf")
rows.append({"function": name, "k": c1, "2k": c2, "ratio": ratio, "excess": excess})
flagged = [r for r in rows if r["ratio"] > args.threshold and r["excess"] >= args.min_excess]
flagged.sort(key=lambda r: -r["excess"])
heaviest = sorted(rows, key=lambda r: -r["2k"])[: args.top]
for info in (info_k, info_2k):
other = info.pop("other", {}) or {}
info["other_total"] = sum(other.values())
info["other_top"] = sorted(other.items(), key=lambda kv: -kv[1])[:10]
report = {
"corpus": corpus, "legs": [info_k, info_2k], "threshold": args.threshold,
"min_excess": args.min_excess, "total_counts": {"k": total_k, "2k": total_2k},
"total_ratio": (total_2k / total_k) if total_k else None,
"superlinear": flagged,
"work_dir": work,
}
if args.json:
json.dump(report, sys.stdout, indent=1, default=lambda o: None if o == float("inf") else o)
print()
return 1 if flagged else 0
print(f"corpus {corpus}")
for info in (info_k, info_2k):
print(f" leg {info['copies']:>2} replicas: {info['files']:,} files, {info['profiles']} process profile(s), "
f"{info['wall_s']} s wall (information only)")
print(f" worker counter increments: k={total_k:,} 2k={total_2k:,} ratio={report['total_ratio']:.3f}")
for info in (info_k, info_2k):
other = info.get("other") or {}
top = sorted(other.items(), key=lambda kv: -kv[1])[:3]
print(f" other processes at {info['copies']} replicas: {sum(other.values()):,} increments; heaviest: "
+ ", ".join(f"{display_name(n)} {c:,}" for n, c in top))
print()
print(f" SUPER-LINEAR — ratio > {args.threshold} and >= {args.min_excess:,} increments beyond linear")
if not flagged:
print(" none")
else:
print(f" {'ratio':>7} {'excess':>16} {'k':>16} {'2k':>16} function")
for r in flagged[: args.top]:
ratio = "new" if r["ratio"] == float("inf") else f"{r['ratio']:.2f}"
print(f" {ratio:>7} {r['excess']:>16,} {r['k']:>16,} {r['2k']:>16,} {display_name(r['function'])[:100]}")
print()
print(f" HEAVIEST at 2k — top {args.top} by counter increments (share of all work, scaling ratio)")
for r in heaviest:
ratio = "new" if r["ratio"] == float("inf") else f"{r['ratio']:.2f}"
print(f" {100.0 * r['2k'] / total_2k:>6.2f}% x{ratio:>5} {display_name(r['function'])[:100]}")
print()
print(f"work dir: {work}")
return 1 if flagged else 0
if __name__ == "__main__":
sys.exit(main())
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
# memwaste.sh — run the waste sanitizer on a repository and print the ranked report.
#
# The sanitizer (src/foundation/mem_events.h) measures memory that is CORRECT but
# unnecessary: allocator slack, churn, realloc copying, memory that never reaches
# the memory core. This driver is the one entry point for every venue: a local
# run on a bench corpus, the PR-CI fixture run, the dry-run and release runs.
#
# Usage:
# scripts/memwaste.sh <repo-path> [--lane event|access] [--top N] [--sort METRIC]
# [--out DIR] [--no-build] [--json] [--binary PATH]
#
# --binary runs a kept copy of a flavour binary (a baseline, a replay) instead of
# the one in the build directory; it implies --no-build.
#
# What it does
# 1. builds the `memwaste` flavour (production flags + the event layer) into
# build/memwaste, unless --no-build;
# 2. indexes <repo-path> with CBM_MEMWASTE=1 into a private cache and a private,
# SHORT runtime directory (the coordination socket has a 104-byte path limit);
# 3. prints the symbolised report and writes the raw dump + JSON next to it;
# 4. FAILS when the measurement itself is unsound: a table overflowed, or more
# than 0.1 % of frees were of blocks the layer never saw. A sanitizer that
# silently loses records produces false confidence.
#
# Nothing here reads a clock to decide anything: wall time is printed as
# information only. Gates are counts.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
REPO=""
TOP=25
SORT=bytes
OUT=""
BUILD=1
JSON=0
LANE=event
BINARY_OVERRIDE=""
while [ $# -gt 0 ]; do
case "$1" in
--binary) BINARY_OVERRIDE="$2"; BUILD=0; shift 2 ;;
--top) TOP="$2"; shift 2 ;;
--sort) SORT="$2"; shift 2 ;;
--out) OUT="$2"; shift 2 ;;
--no-build) BUILD=0; shift ;;
--json) JSON=1; shift ;;
--lane) LANE="$2"; shift 2 ;;
-h | --help) sed -n '2,24p' "$0"; exit 0 ;;
# A typo'd flag must never be read as the repository path — it would index
# nothing and report an empty lane as a clean one.
-*) echo "memwaste.sh: unknown argument $1. Please consult --help." >&2; exit 2 ;;
*) REPO="$1"; shift ;;
esac
done
if [ -z "$REPO" ] || [ ! -d "$REPO" ]; then
echo "memwaste.sh: a repository directory is required. Please consult --help." >&2
exit 2
fi
case "$LANE" in
event) BUILD_ARGS=(BUILD_DIR=build/memwaste MEMWASTE=1 ${CC:+CC=$CC} ${CXX:+CXX=$CXX})
BIN="$ROOT/build/memwaste/codebase-memory-mcp" ;;
access) BUILD_ARGS=(BUILD_DIR=build/memwaste-access MEMWASTE_ACCESS=1 CC=clang CXX=clang++)
BIN="$ROOT/build/memwaste-access/codebase-memory-mcp" ;;
*) echo "memwaste.sh: --lane must be event or access" >&2; exit 2 ;;
esac
if [ "$BUILD" -eq 1 ]; then
"$ROOT/scripts/build.sh" "${BUILD_ARGS[@]}" >/dev/null
fi
if [ -n "$BINARY_OVERRIDE" ]; then
BIN="$BINARY_OVERRIDE"
fi
if [ ! -x "$BIN" ]; then
echo "memwaste.sh: $BIN is missing (build failed, or --no-build without a build)" >&2
exit 2
fi
if [ -z "$OUT" ]; then
OUT="$(mktemp -d "${TMPDIR:-/tmp}/cbm-memwaste.XXXXXX")"
fi
mkdir -p "$OUT"
DUMP="$OUT/memwaste.jsonl"
rm -f "$DUMP"
RUN_BIN="$BIN"
if command -v cygpath >/dev/null 2>&1 && ! command -v winepath >/dev/null 2>&1; then
# Native Windows: the server walks the ancestor chain of its binary, cache and
# runtime directories and refuses any that grant mutation to untrusted SIDs.
# The checkout and msys /tmp both do, so the run happens from a root under
# USERPROFILE stamped to the current user, SYSTEM and Administrators -- by
# SID, the way scripts/memlab.sh does it (#1532).
PROFILE_DIR="$(cygpath -u "$USERPROFILE")"
WORK="$(mktemp -d "$PROFILE_DIR/cbm-mw.XXXXXX")"
WORK_W="$(cygpath -w "$WORK")"
SID_QUERY='[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value'
ME="$(powershell.exe -NoProfile -NonInteractive -Command "$SID_QUERY" 2>/dev/null | tr -d '\r\n')"
case "$ME" in
S-1-*) ME="*$ME" ;;
*) echo "memwaste.sh: could not resolve the current user's SID" >&2; exit 2 ;;
esac
if ! MSYS2_ARG_CONV_EXCL='*' icacls "$WORK_W" /inheritance:r \
/grant:r "${ME}:(OI)(CI)F" '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' /Q >/dev/null 2>&1; then
echo "memwaste.sh: could not stamp $WORK" >&2
exit 2
fi
mkdir -p "$WORK/cache" "$WORK/rt"
cp "$BIN.exe" "$WORK/codebase-memory-mcp.exe" 2>/dev/null || cp "$BIN" "$WORK/codebase-memory-mcp.exe"
RUN_BIN="$WORK/codebase-memory-mcp.exe"
CACHE_DIR="$WORK_W\\cache"
RUNTIME="$WORK_W\\rt"
DUMP_ENV="$(cygpath -w "$DUMP")"
else
WORK="$(mktemp -d /tmp/cbm-mw.XXXXXX)"
chmod 700 "$WORK"
# A fresh cache every run: an existing index turns --mode full into an
# incremental pass that measures hashing instead of indexing.
rm -rf "$OUT/cache"
mkdir -p "$OUT/cache"
CACHE_DIR="$OUT/cache"
RUNTIME="$WORK"
DUMP_ENV="$DUMP"
fi
# CBM_MEMWASTE_KEEP=1 keeps the private root (binary copy, cache, logs) for inspection.
cleanup() {
if [ "${CBM_MEMWASTE_KEEP:-0}" != 1 ]; then
rm -rf "$WORK"
fi
}
trap cleanup EXIT
INDEX_RC=0
CBM_MEMWASTE=1 CBM_MEMWASTE_OUT="$DUMP_ENV" CBM_CACHE_DIR="$CACHE_DIR" CBM_RUNTIME_DIR="$RUNTIME" \
CBM_LOG_LEVEL=error "$RUN_BIN" cli index_repository --repo-path "$REPO" --mode full \
>"$OUT/index.json" || INDEX_RC=$?
if [ "$INDEX_RC" -ne 0 ]; then
# A failed index is a finding about the flavour, never a silent partial report.
echo "memwaste.sh: the index failed (rc=$INDEX_RC); its logs follow" >&2
for log in "$WORK"/cache/logs/* "$WORK"/cache/logs/.worker-log-* "$OUT"/cache/logs/* "$OUT"/cache/logs/.worker-log-*; do
if [ -f "$log" ]; then
echo "--- $log" >&2
tail -n 60 "$log" >&2
fi
done
exit 1
fi
if [ ! -s "$DUMP" ]; then
echo "memwaste.sh: the sanitizer wrote no dump -- is the flavour built with MEMWASTE=1?" >&2
exit 1
fi
python3 "$ROOT/scripts/memwaste-report.py" "$DUMP" --binary "$BIN" --json >"$OUT/memwaste.json"
if [ "$JSON" -eq 1 ]; then
cat "$OUT/memwaste.json"
else
python3 "$ROOT/scripts/memwaste-report.py" "$DUMP" --binary "$BIN" --top "$TOP" --sort "$SORT"
echo
echo "raw dump: $DUMP"
echo "json: $OUT/memwaste.json"
fi
# Soundness of the measurement itself.
python3 - "$OUT/memwaste.json" <<'PY'
import json, sys
procs = json.load(open(sys.argv[1]))["processes"]
bad = []
for pid, proc in procs.items():
h = proc["header"]
if h.get("site_table_full", 0) or h.get("pointer_table_full", 0):
bad.append(f"process {pid}: a table overflowed (sites={h.get('site_table_full')}, pointers={h.get('pointer_table_full')})")
frees = h.get("frees", 0) + h.get("untracked_frees", 0)
if frees and h.get("untracked_frees", 0) * 1000 > frees:
bad.append(f"process {pid}: {h['untracked_frees']:,} of {frees:,} frees were of blocks the layer never saw (> 0.1 %)")
if bad:
print("memwaste.sh: UNSOUND MEASUREMENT", file=sys.stderr)
for b in bad:
print(" " + b, file=sys.stderr)
sys.exit(1)
PY
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env python3
"""
sanitizer-coverage-map.py — what our sanitizer lanes instrument, what they
actually execute, and every hole, vendored code included.
A sanitizer only checks code that runs under it. A function no suite reaches is
unchecked in every lane, and until now nobody could name those functions. This
tool answers three questions and keeps the answers from regressing:
static Which translation units does each lane COMPILE with its sanitizer,
per the build's own compile commands (make -n)? Lists every TU no
dynamic lane instruments and every TU the static lanes filter out.
run Build one lane with clang source coverage on top of its sanitizer
flags, run its test runner (suites optional), and export per-function
execution counts (lcov) -- vendored objects included, because the
flags ride on the lane's own sanitizer variable, which the vendored
rules already use.
union Merge the per-lane exports: the functions that ran under NO lane,
grouped by file, vendored code flagged.
ratchet Compare a union against the checked-in baseline: a function that
joins the never-executed list fails; one that leaves it is reported
so the baseline can be lowered.
map Validate and print scripts/sanitizer-map.json: every defect class x
platform cell must be covered, partial, or a named limit with a reason.
`run` executes test suites, so it belongs to the lane's own venue (local CI, the
CI job); `static`, `union`, `ratchet` and `map` run anywhere in seconds.
Usage:
scripts/sanitizer-coverage-map.py static
scripts/sanitizer-coverage-map.py run --lane asan --out build/covmap [SUITE...]
scripts/sanitizer-coverage-map.py union --out build/covmap
scripts/sanitizer-coverage-map.py ratchet --out build/covmap
scripts/sanitizer-coverage-map.py map
"""
from __future__ import annotations
import argparse
import json
import os
import shlex
import shutil
import subprocess
import sys
import tempfile
from collections import defaultdict
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASELINE = os.path.join(ROOT, "scripts", "sanitizer-coverage-baseline.txt")
MAP_FILE = os.path.join(ROOT, "scripts", "sanitizer-map.json")
COVERAGE_FLAGS = "-fprofile-instr-generate -fcoverage-mapping"
SRC_EXT = (".c", ".cc", ".cpp")
# lane -> how the lane builds and runs. `var` is the make variable carrying the
# lane's sanitizer flags; the coverage flags are appended to it.
LANES = {
"asan": {"target": "test-runner", "var": "SANITIZE",
"value": "-fsanitize=address,undefined -fno-omit-frame-pointer", "env": {}},
"tsan": {"target": "test-runner-tsan", "var": "TSAN_SANITIZE",
"value": "-fsanitize=thread -fno-omit-frame-pointer",
"env": {"TSAN_OPTIONS": "halt_on_error=1:report_thread_leaks=0"}},
"lsan": {"target": "test-runner", "var": "SANITIZE",
"value": "-fsanitize=address,undefined -fno-omit-frame-pointer",
"env": {"ASAN_OPTIONS": "detect_leaks=1:halt_on_error=1"}},
}
DYNAMIC_FLAGS = ("-fsanitize=address", "-fsanitize=thread", "-fsanitize=memory", "-fsanitize=undefined",
"-fsanitize=address,undefined")
def is_vendored(path: str) -> bool:
# grammar_<lang>.c and the runtime/unity wrappers only #include vendored sources
base = os.path.basename(path)
return "vendored/" in path or base.startswith(("grammar_", "ts_runtime")) or base in (
"sqlite3.c", "mimalloc.c", "unixcoder_blob.c")
def dry_commands(target: str, extra: list[str]) -> list[list[str]]:
res = subprocess.run(["make", "-f", "Makefile.cbm", "-n", "-B", target] + extra,
cwd=ROOT, capture_output=True, text=True, check=False)
cmds = []
for line in res.stdout.replace("\\\n", " ").splitlines():
try:
argv = shlex.split(line)
except ValueError:
continue
if argv and any(a.endswith(SRC_EXT) for a in argv) and ("-c" in argv or "-o" in argv):
cmds.append(argv)
return cmds
def make_var(name: str) -> list[str]:
"""The fully expanded value of a Makefile.cbm variable (works with make 3.81: no --eval)."""
with tempfile.NamedTemporaryFile("w", suffix=".mk", delete=False) as mk:
mk.write("print-cbm-var-%:\n\t@echo $($*)\n")
helper = mk.name
try:
res = subprocess.run(["make", "-s", "-f", "Makefile.cbm", "-f", helper, f"print-cbm-var-{name}"],
cwd=ROOT, capture_output=True, text=True, check=False)
finally:
os.unlink(helper)
return res.stdout.split() if res.returncode == 0 else []
def cmd_static(args) -> int:
instrumented: dict[str, set[str]] = defaultdict(set)
compiled: set[str] = set()
for lane, spec in {"asan": LANES["asan"], "tsan": LANES["tsan"]}.items():
bdir = f"build/covmap-static-{lane}"
for argv in dry_commands(f"{bdir}/{spec['target']}", [f"BUILD_DIR={bdir}"]):
flags = " ".join(argv)
for a in argv:
if a.endswith(SRC_EXT) and os.path.isfile(os.path.join(ROOT, a)):
compiled.add(a)
if any(f in flags for f in ("-fsanitize=address", "-fsanitize=thread")):
instrumented[a].add(lane)
uninstrumented = sorted(s for s in compiled if not instrumented.get(s))
lint_srcs = set(make_var("LINT_SRCS"))
static_skipped = sorted(s for s in compiled if s not in lint_srcs)
out = {
"translation_units": len(compiled),
"not_instrumented_by_any_dynamic_lane": uninstrumented,
"not_in_static_lanes": static_skipped,
"per_tu": {s: sorted(instrumented.get(s, ())) for s in sorted(compiled)},
}
if args.json:
json.dump(out, sys.stdout, indent=1)
print()
return 0
print(f"translation units in the test builds: {len(compiled)}")
print(f" instrumented by ASan+UBSan: {sum(1 for v in instrumented.values() if 'asan' in v)}"
f" by TSan: {sum(1 for v in instrumented.values() if 'tsan' in v)}")
print()
print(f" NOT instrumented by any dynamic lane ({len(uninstrumented)}):")
for s in uninstrumented:
print(f" {s}{' [vendored]' if is_vendored(s) else ''}")
print()
print(f" outside the static lanes' source list (cppcheck, clang-analyzer) ({len(static_skipped)}):")
vend = [s for s in static_skipped if is_vendored(s)]
own = [s for s in static_skipped if not is_vendored(s) and not s.startswith("tests/")]
print(f" vendored: {len(vend)} project: {len(own)}")
for s in own:
print(f" {s}")
return 0
def find_llvm_tool(name: str, cc: str) -> str:
cc_path = shutil.which(cc)
if cc_path:
cand = os.path.join(os.path.dirname(os.path.realpath(cc_path)), name)
if os.access(cand, os.X_OK):
return cand
p = shutil.which(name)
if p:
return p
if shutil.which("xcrun"):
res = subprocess.run(["xcrun", "--find", name], capture_output=True, text=True, check=False)
if res.returncode == 0:
return res.stdout.strip()
sys.exit(f"sanitizer-coverage-map: {name} not found next to {cc} or on PATH")
def cmd_run(args) -> int:
spec = LANES[args.lane]
out = os.path.abspath(args.out)
os.makedirs(out, exist_ok=True)
bdir = f"build/covmap-{args.lane}"
cxx = "clang++" if args.cc == "clang" else args.cc.replace("clang", "clang++")
flags = f"{spec['value']} {COVERAGE_FLAGS}"
subprocess.run(["make", "-f", "Makefile.cbm", f"-j{os.cpu_count() or 4}", f"{bdir}/{spec['target']}",
f"BUILD_DIR={bdir}", f"CC={args.cc}", f"CXX={cxx}", f"{spec['var']}={flags}"],
cwd=ROOT, check=True)
raw_dir = os.path.join(out, f"{args.lane}-profraw")
shutil.rmtree(raw_dir, ignore_errors=True)
os.makedirs(raw_dir)
env = dict(os.environ)
env.update(spec["env"])
env["LLVM_PROFILE_FILE"] = os.path.join(raw_dir, "%p-%m.profraw")
runner = os.path.join(ROOT, bdir, spec["target"])
rc = subprocess.run([runner] + args.suites, cwd=ROOT, env=env, check=False).returncode
raws = [os.path.join(raw_dir, f) for f in os.listdir(raw_dir) if f.endswith(".profraw")]
if not raws:
sys.exit("sanitizer-coverage-map: the runner wrote no profile")
profdata = find_llvm_tool("llvm-profdata", args.cc)
cov = find_llvm_tool("llvm-cov", args.cc)
merged = os.path.join(out, f"{args.lane}.profdata")
subprocess.run([profdata, "merge", "-sparse", "-o", merged] + raws, check=True)
with open(os.path.join(out, f"{args.lane}.lcov"), "w") as fh:
subprocess.run([cov, "export", "-format=lcov", "-skip-expansions", f"-instr-profile={merged}", runner],
cwd=ROOT, stdout=fh, check=True)
print(f"{args.lane}: runner rc={rc}, {len(raws)} profile(s) -> {out}/{args.lane}.lcov")
return 0 if rc == 0 else rc
def read_lcov(path: str) -> dict[tuple[str, str], int]:
counts: dict[tuple[str, str], int] = {}
src = None
for line in open(path, encoding="utf-8", errors="replace"):
if line.startswith("SF:"):
src = os.path.relpath(line[3:].strip(), ROOT)
elif line.startswith("FNDA:") and src:
count, name = line[5:].strip().split(",", 1)
key = (src, name)
counts[key] = max(counts.get(key, 0), int(count))
return counts
def cmd_union(args) -> int:
out = os.path.abspath(args.out)
lanes = [f[:-5] for f in sorted(os.listdir(out)) if f.endswith(".lcov")]
if not lanes:
sys.exit(f"sanitizer-coverage-map: no <lane>.lcov under {out} (run `run` first)")
total: dict[tuple[str, str], int] = {}
for lane in lanes:
for key, c in read_lcov(os.path.join(out, f"{lane}.lcov")).items():
total[key] = max(total.get(key, 0), c)
never = sorted(k for k, c in total.items() if c == 0 and not k[0].startswith(("tests/", "..")))
with open(os.path.join(out, "never-executed.txt"), "w") as fh:
for src, fn in never:
fh.write(f"{src}\t{fn}\n")
by_file = defaultdict(int)
for src, _ in never:
by_file[src] += 1
functions = sum(1 for k in total if not k[0].startswith(("tests/", "..")))
print(f"lanes merged: {', '.join(lanes)}")
print(f"functions: {functions:,} never executed under any lane: {len(never):,} "
f"(vendored: {sum(1 for s, _ in never if is_vendored(s)):,})")
for src, n in sorted(by_file.items(), key=lambda kv: (-kv[1], kv[0]))[: args.top]:
print(f" {n:>6} {src}{' [vendored]' if is_vendored(src) else ''}")
print(f"list: {out}/never-executed.txt")
return 0
def cmd_ratchet(args) -> int:
current_path = os.path.join(os.path.abspath(args.out), "never-executed.txt")
current = set(open(current_path).read().splitlines())
if not os.path.exists(BASELINE):
print(f"no baseline yet: copy {current_path} to {BASELINE} to arm the ratchet")
return 0
base = set(l for l in open(BASELINE).read().splitlines() if l and not l.startswith("#"))
new = sorted(current - base)
gone = sorted(base - current)
for line in new:
print(f"NEW never-executed: {line}")
if gone:
print(f"{len(gone)} function(s) now executed: lower the baseline (remove them from {BASELINE})")
return 1 if new else 0
def cmd_map(args) -> int:
data = json.load(open(MAP_FILE))
platforms = data["platforms"]
bad = []
for cls in data["classes"]:
cells = cls["cells"]
for plat in platforms:
cell = cells.get(plat)
if not cell:
bad.append(f"{cls['name']} / {plat}: missing")
elif cell.get("status") not in ("covered", "partial", "limit"):
bad.append(f"{cls['name']} / {plat}: status must be covered, partial or limit")
elif cell["status"] != "covered" and not cell.get("why"):
bad.append(f"{cls['name']} / {plat}: {cell['status']} needs a why")
if not args.quiet:
width = max(len(c["name"]) for c in data["classes"])
print(f"{'defect class':<{width}} " + " ".join(f"{p:<14}" for p in platforms))
for cls in data["classes"]:
row = []
for plat in platforms:
cell = cls["cells"].get(plat, {})
row.append(f"{cell.get('status', '?') + ':' + cell.get('lane', ''):<14}"[:14])
print(f"{cls['name']:<{width}} " + " ".join(row))
print()
for cls in data["classes"]:
for plat in platforms:
cell = cls["cells"].get(plat, {})
if cell.get("status") in ("partial", "limit"):
print(f" {cell['status']:<7} {cls['name']} / {plat}: {cell.get('why', '')}")
for b in bad:
print(f"MAP DEFECT: {b}", file=sys.stderr)
return 1 if bad else 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("static")
s.add_argument("--json", action="store_true")
r = sub.add_parser("run")
r.add_argument("--lane", choices=sorted(LANES), required=True)
r.add_argument("--out", default="build/covmap")
r.add_argument("--cc", default="clang")
r.add_argument("suites", nargs="*")
u = sub.add_parser("union")
u.add_argument("--out", default="build/covmap")
u.add_argument("--top", type=int, default=40)
t = sub.add_parser("ratchet")
t.add_argument("--out", default="build/covmap")
m = sub.add_parser("map")
m.add_argument("--quiet", action="store_true")
args = ap.parse_args()
return {"static": cmd_static, "run": cmd_run, "union": cmd_union,
"ratchet": cmd_ratchet, "map": cmd_map}[args.cmd](args)
if __name__ == "__main__":
sys.exit(main())
+126
View File
@@ -0,0 +1,126 @@
{
"about": "Defect class x platform coverage of the sanitizer lanes. Every cell is covered, partial (with why), or a named limit (with why). Validated by scripts/sanitizer-coverage-map.py map. Which functions each lane actually EXECUTES is the run/union/ratchet part of the same tool.",
"platforms": ["linux-x64", "linux-arm64", "macos-arm64", "windows-x64", "windows-arm64"],
"classes": [
{
"name": "heap/stack memory errors",
"cells": {
"linux-x64": {"status": "covered", "lane": "asan", "venue": "PR CI"},
"linux-arm64": {"status": "covered", "lane": "asan", "venue": "local container"},
"macos-arm64": {"status": "partial", "lane": "asan+diag", "why": "use-after-return and use-after-scope only on the Homebrew-LLVM diag lane, which is local-only"},
"windows-x64": {"status": "covered", "lane": "asan", "venue": "PR CI"},
"windows-arm64": {"status": "limit", "lane": "none", "why": "no ASan runtime for Windows ARM64; the class is covered for Windows by the x64 leg"}
}
},
{
"name": "allocator-path memory errors",
"cells": {
"linux-x64": {"status": "partial", "lane": "asan", "why": "test builds use the C runtime allocator: mimalloc bindings, the --wrap shims and the slab never run sanitized; mimalloc itself is built without ASan"},
"linux-arm64": {"status": "partial", "lane": "asan", "why": "same as linux-x64"},
"macos-arm64": {"status": "partial", "lane": "asan", "why": "same as linux-x64; the zone-observer path runs only in the memwaste flavours"},
"windows-x64": {"status": "partial", "lane": "asan", "why": "same as linux-x64"},
"windows-arm64": {"status": "limit", "lane": "none", "why": "no ASan runtime for Windows ARM64"}
}
},
{
"name": "undefined behaviour",
"cells": {
"linux-x64": {"status": "partial", "lane": "ubsan", "why": "tre (regex) is built with -fno-sanitize=alignment"},
"linux-arm64": {"status": "partial", "lane": "ubsan", "why": "tre (regex) is built with -fno-sanitize=alignment"},
"macos-arm64": {"status": "partial", "lane": "ubsan", "why": "tre (regex) is built with -fno-sanitize=alignment"},
"windows-x64": {"status": "partial", "lane": "ubsan", "why": "tre (regex) is built with -fno-sanitize=alignment"},
"windows-arm64": {"status": "partial", "lane": "ubsan-trap", "why": "trap mode: a violation stops the run without a message"}
}
},
{
"name": "leaks",
"cells": {
"linux-x64": {"status": "covered", "lane": "lsan", "venue": "PR CI (on by default under ASan)"},
"linux-arm64": {"status": "covered", "lane": "lsan", "venue": "local container"},
"macos-arm64": {"status": "covered", "lane": "test-lsan", "venue": "local (Homebrew LLVM; Apple clang refuses detect_leaks)"},
"windows-x64": {"status": "partial", "lane": "memwaste-event", "why": "no leak sanitizer on Windows; the waste lane's live-per-site report at the last phase and the soak slope stand in"},
"windows-arm64": {"status": "partial", "lane": "memwaste-event", "why": "no leak sanitizer on Windows; the waste lane's live-per-site report at the last phase and the soak slope stand in"}
}
},
{
"name": "uninitialised reads",
"cells": {
"linux-x64": {"status": "partial", "lane": "msan", "why": "CI only; deep-recursion suites need larger thread stacks under MSan"},
"linux-arm64": {"status": "partial", "lane": "msan", "why": "locally red: three deep-recursion suites overflow their thread stacks"},
"macos-arm64": {"status": "partial", "lane": "memwaste-access", "why": "no MSan on macOS; the access lane's read-before-write verdict on a real corpus is the second net, per block, not per byte"},
"windows-x64": {"status": "partial", "lane": "memwaste-access", "why": "no MSan on Windows; the access lane (clang) is the second net"},
"windows-arm64": {"status": "partial", "lane": "memwaste-access", "why": "no MSan on Windows; the access lane (clang) is the second net"}
}
},
{
"name": "data races",
"cells": {
"linux-x64": {"status": "partial", "lane": "tsan", "why": "a subset of suites runs under TSan"},
"linux-arm64": {"status": "partial", "lane": "tsan", "why": "a subset of suites runs under TSan"},
"macos-arm64": {"status": "partial", "lane": "tsan", "why": "a subset of suites runs under TSan"},
"windows-x64": {"status": "limit", "lane": "none", "why": "no TSan on Windows"},
"windows-arm64": {"status": "limit", "lane": "none", "why": "no TSan on Windows"}
}
},
{
"name": "static analysis",
"cells": {
"linux-x64": {"status": "partial", "lane": "cppcheck+lint-mem+codeql", "why": "vendored trees are filtered out of cppcheck and the analyzer gate"},
"linux-arm64": {"status": "partial", "lane": "cppcheck+lint-mem", "why": "vendored trees are filtered out"},
"macos-arm64": {"status": "partial", "lane": "lint-mem", "why": "vendored trees are filtered out"},
"windows-x64": {"status": "limit", "lane": "none", "why": "static lanes are platform-independent source passes; Windows-only code is analysed by CodeQL only"},
"windows-arm64": {"status": "limit", "lane": "none", "why": "static lanes are platform-independent source passes"}
}
},
{
"name": "hostile input",
"cells": {
"linux-x64": {"status": "partial", "lane": "security-fuzz+libfuzzer", "why": "fixed MCP payloads in CI; libFuzzer targets for extractors, Cypher and config editors run in dry-run/nightly"},
"linux-arm64": {"status": "partial", "lane": "libfuzzer", "why": "local only"},
"macos-arm64": {"status": "partial", "lane": "libfuzzer", "why": "local only (Homebrew LLVM ships libFuzzer; Apple clang does not)"},
"windows-x64": {"status": "limit", "lane": "none", "why": "libFuzzer is not built for the MinGW toolchain"},
"windows-arm64": {"status": "limit", "lane": "none", "why": "libFuzzer is not built for the MinGW toolchain"}
}
},
{
"name": "algorithmic blow-up",
"cells": {
"linux-x64": {"status": "covered", "lane": "complexity+memwaste-scaling"},
"linux-arm64": {"status": "covered", "lane": "complexity+memwaste-scaling"},
"macos-arm64": {"status": "covered", "lane": "complexity+memwaste-scaling"},
"windows-x64": {"status": "partial", "lane": "complexity", "why": "the per-function scaling lane needs clang's profile runtime; whole-pipeline counters only"},
"windows-arm64": {"status": "partial", "lane": "complexity", "why": "the per-function scaling lane needs clang's profile runtime; whole-pipeline counters only"}
}
},
{
"name": "memory waste",
"cells": {
"linux-x64": {"status": "covered", "lane": "memwaste-event+access"},
"linux-arm64": {"status": "covered", "lane": "memwaste-event+access"},
"macos-arm64": {"status": "covered", "lane": "memwaste-event+access"},
"windows-x64": {"status": "partial", "lane": "memwaste-event", "why": "libc wrappers cover mem*/str* and FILE I/O only; the access lane needs a clang build"},
"windows-arm64": {"status": "partial", "lane": "memwaste-event", "why": "libc wrappers cover mem*/str* and FILE I/O only"}
}
},
{
"name": "CPU waste",
"cells": {
"linux-x64": {"status": "covered", "lane": "memwaste-cpu+scaling"},
"linux-arm64": {"status": "covered", "lane": "memwaste-cpu+scaling"},
"macos-arm64": {"status": "covered", "lane": "memwaste-cpu+scaling"},
"windows-x64": {"status": "partial", "lane": "memwaste-cpu", "why": "no system-call or pthread lock wrappers on Windows: Win32 file and lock APIs are imports --wrap cannot redirect"},
"windows-arm64": {"status": "partial", "lane": "memwaste-cpu", "why": "no system-call or pthread lock wrappers on Windows: Win32 file and lock APIs are imports --wrap cannot redirect"}
}
},
{
"name": "executed-code coverage of the lanes",
"cells": {
"linux-x64": {"status": "partial", "lane": "sanitizer-coverage-map", "why": "needs a clang job; the gcc legs are not measured"},
"linux-arm64": {"status": "covered", "lane": "sanitizer-coverage-map"},
"macos-arm64": {"status": "covered", "lane": "sanitizer-coverage-map"},
"windows-x64": {"status": "limit", "lane": "none", "why": "no sanitizer lane on Windows executes under clang source coverage yet"},
"windows-arm64": {"status": "limit", "lane": "none", "why": "no sanitizer lane on Windows executes under clang source coverage yet"}
}
}
]
}
+64
View File
@@ -3,6 +3,7 @@
*/
#include "foundation/mem_core.h"
#include "foundation/mem.h"
#include "foundation/mem_events.h"
/* Ownership check for blocks handed back to the core (defined with cbm_free). */
static void check_owned(const void *block, const char *op);
@@ -152,6 +153,7 @@ static void class_flush_one(cbm_mem_class_t cls) {
}
void cbm_mem_class_flush_thread(void) {
cbm_memev_flush_thread(); /* same seam: work-item end, thread end, every reader */
for (int i = 0; i < CBM_MEM_CLASS_COUNT; i++) {
class_flush_one((cbm_mem_class_t)i);
}
@@ -205,8 +207,62 @@ size_t cbm_mem_usable_size(const void *block) {
}
#endif
/* -- Waste-sanitizer hooks (mem_events.h) ---------------------------------
* Compiled to nothing outside the `memwaste` flavour. The core names the
* caller's site and the class; who REPORTS the event depends on whether an
* observer sees the backing allocator:
* - backing is mimalloc called directly (CBM_BIND_TS_ALLOCATOR): no observer
* ever sees it, the core reports everything itself;
* - backing is plain malloc and an observer is installed: the observer
* reports, the hint hands it the site and the class;
* - backing is plain malloc, no observer: the core reports.
* Frees are reported BEFORE the block goes back (see mem_events.h). */
#if defined(CBM_MEMWASTE) && CBM_MEMWASTE
static bool core_reports_frees(void) {
#if defined(CBM_BIND_TS_ALLOCATOR) && CBM_BIND_TS_ALLOCATOR
return true;
#else
return !cbm_memev_observer_installed();
#endif
}
#define MEMEV_HINT(cls) \
do { \
if (cbm_memev_enabled() && !cbm_memev_hint_pending()) { \
cbm_memev_hint(__builtin_return_address(0), (int)(cls)); \
} \
} while (0)
#define MEMEV_ALLOCATED(block, bytes, flags) \
do { \
if (cbm_memev_hint_pending()) { \
cbm_memev_alloc_ex((block), (bytes), \
(block) ? charge_size((block), (bytes)) : 0, NULL, \
(flags)); \
} \
} while (0)
#define MEMEV_REALLOCATED(old_block, grown, bytes) \
do { \
if (cbm_memev_hint_pending()) { \
cbm_memev_realloc((old_block), (grown), (bytes), \
(grown) ? charge_size((grown), (bytes)) : 0, NULL); \
} \
} while (0)
#define MEMEV_FREEING(block) \
do { \
if (cbm_memev_enabled() && core_reports_frees()) { \
cbm_memev_free(block); \
} \
} while (0)
#else
#define MEMEV_HINT(cls) ((void)0)
#define MEMEV_ALLOCATED(block, bytes, flags) ((void)0)
#define MEMEV_REALLOCATED(old_block, grown, bytes) ((void)0)
#define MEMEV_FREEING(block) ((void)0)
#endif
void *cbm_alloc(cbm_mem_class_t cls, size_t bytes) {
MEMEV_HINT(cls);
void *block = CBM_BACKING_MALLOC(bytes ? bytes : CBM_ALLOC_ONE);
MEMEV_ALLOCATED(block, bytes, 0);
if (!block) {
return NULL;
}
@@ -215,7 +271,9 @@ void *cbm_alloc(cbm_mem_class_t cls, size_t bytes) {
}
void *cbm_calloc(cbm_mem_class_t cls, size_t bytes) {
MEMEV_HINT(cls);
void *block = CBM_BACKING_CALLOC(bytes ? bytes : CBM_ALLOC_ONE);
MEMEV_ALLOCATED(block, bytes, CBM_MEMEV_ZEROED);
if (!block) {
return NULL;
}
@@ -231,7 +289,11 @@ void *cbm_realloc(cbm_mem_class_t cls, void *block, size_t bytes) {
/* Measure BEFORE: after realloc the old block is gone and its size is
* unknowable, so the decrement has to be computed first. */
size_t old = charge_size(block, 0);
MEMEV_HINT(cls);
CBM_MEMEV_BACKING(1);
void *next = CBM_BACKING_REALLOC(block, bytes ? bytes : CBM_ALLOC_ONE);
CBM_MEMEV_BACKING(-1);
MEMEV_REALLOCATED(block, next, bytes);
if (!next) {
return NULL; /* original intact and still charged - correct */
}
@@ -245,6 +307,7 @@ char *cbm_mem_strdup(cbm_mem_class_t cls, const char *s) {
return NULL;
}
size_t len = strlen(s) + CBM_ALLOC_ONE;
MEMEV_HINT(cls); /* the site is OUR caller, not cbm_alloc's */
char *copy = (char *)cbm_alloc(cls, len);
if (!copy) {
return NULL;
@@ -279,6 +342,7 @@ void cbm_free(cbm_mem_class_t cls, void *block) {
}
check_owned(block, "free");
class_sub(cls, charge_size(block, 0), CBM_ALLOC_ONE);
MEMEV_FREEING(block);
CBM_BACKING_FREE(block);
}
File diff suppressed because it is too large Load Diff
+359
View File
@@ -0,0 +1,359 @@
/*
* mem_events.h — the waste sanitizer: memory and CPU work that is correct but
* unnecessary, attributed to the code that caused it.
*
* ASan, MSan, TSan and LSan find memory that is WRONG. Nothing in the tree
* measured memory or work that is merely WASTED — allocator slack, blocks
* bigger than what is written into them, churn an arena would have served,
* realloc chains copying the same bytes again, duplicate strings, memory held
* across phases that never touch it, the same string measured twice, the same
* file opened twice. The memory-core rounds found such waste BY HAND; this
* layer turns it into a ranked report.
*
* On 2026-09-17 a forwarding malloc-zone observer showed the Go indexing worker
* making 92.3 million raw allocations OUTSIDE the memory core on macOS — 128 GB
* requested cumulatively in a 41 s run — that no instrument in the tree saw.
*
* WHERE IT LISTENS
* - the memory core (mem_core.c) announces its own calls with a site + class;
* - platform observers see the malloc family underneath: the Linux and
* Windows --wrap shims, the macOS malloc-zone observer;
* - bound allocators that bypass both (SQLite, tree-sitter -> mimalloc) report
* directly;
* - containers (arena, dynamic array, hash table) report capacity vs use;
* - libc wrappers (mem_override_libc.c) report byte and system-call work;
* - the ACCESS lane (-fsanitize-coverage=trace-loads,trace-stores) reports
* reads and writes of every tracked block.
*
* COSTS NOTHING UNLESS ASKED FOR. Compiled in only under -DCBM_MEMWASTE=1 (the
* `memwaste` build flavour, `make MEMWASTE=1`) and dormant unless CBM_MEMWASTE=1
* is set. The access lane additionally needs -DCBM_MEMWASTE_ACCESS=1
* (`make MEMWASTE_ACCESS=1`). Release binaries contain none of it.
*
* NO PER-BLOCK HEADER (see mem_core.h: 16 bytes x ~100 M kernel blocks is
* 1.6 GB). Block state lives in side tables backed by raw virtual memory, so
* the layer never calls the allocator it is observing.
*
* DETERMINISM (O9). Every quantity a gate may read is a SUM over events —
* per-site totals — or a snapshot taken at a phase mark after the workers
* joined. Nothing here reads a clock.
*
* ENVIRONMENT
* CBM_MEMWASTE=1 wake the layer
* CBM_MEMWASTE_OUT=path append JSON-lines dumps at every phase mark
* CBM_MEMWASTE_FILL=0 do not fill new blocks with the never-written pattern
* CBM_MEMWASTE_DUPES=0 skip the duplicate-content scan at phase marks
*/
#ifndef CBM_MEM_EVENTS_H
#define CBM_MEM_EVENTS_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
/* Class value for blocks that never passed through the memory core. */
#define CBM_MEMEV_CLASS_RAW (-1)
/* Allocation flags. */
enum {
CBM_MEMEV_ZEROED = 1u, /* calloc: the allocator zeroed it, never fill it */
CBM_MEMEV_FOREIGN = 2u, /* a system library allocating for itself: observe, never fill */
CBM_MEMEV_LIBRARY = 4u, /* allocated inside a wrapped library call, charged to its caller */
/* The block already holds its content when it is reported (a strdup copy, a
* realloc whose old block the layer never saw): observe, NEVER fill -- the
* fill pattern would overwrite live data. */
CBM_MEMEV_WRITTEN = 8u,
};
/* Blocks at or below this size take part in the duplicate-content scan. */
enum { CBM_MEMEV_DUP_MAX = 256 };
/* Freed by the allocating thread within this many of its own allocations:
* churn an arena or a stack buffer would have served. */
enum { CBM_MEMEV_CHURN_WINDOW = 64 };
/* True when the layer is compiled in AND CBM_MEMWASTE=1. Cached after the
* first call; safe from inside an allocator hook (it never allocates). */
bool cbm_memev_enabled(void);
/* ── Allocation events ────────────────────────────────────────────────
* `site` is a return address inside the code that asked for the memory.
* `usable` is what the allocator actually handed out (0 when unknown: the
* layer then charges `requested`). All return immediately when dormant. */
void cbm_memev_alloc(void *block, size_t requested, size_t usable, void *site);
void cbm_memev_alloc_ex(void *block, size_t requested, size_t usable, void *site, unsigned flags);
void cbm_memev_free(void *block);
/* A free of a block another allocator made (Windows: the C runtime or a system
* DLL allocating where --wrap cannot reach). Counted apart from untracked
* frees: its allocation was never observable, so it says what the layer cannot
* see, not that the layer missed something it should have seen. */
void cbm_memev_free_foreign(void *block);
/* One event for a realloc: the old block's record moves to `grown`, the copy
* the allocator had to make is charged to the ORIGINAL allocation site. */
void cbm_memev_realloc(void *old_block, void *grown, size_t requested, size_t usable, void *site);
/* ── Core <-> observer handshake ──────────────────────────────────────
* The memory core and a platform observer can both see one allocation. The
* core announces itself first; the observer consumes the hint and the event
* carries the CORE's caller as site and the class attached. When nothing
* consumed the hint, the core reports the event itself:
*
* cbm_memev_hint(site, cls);
* block = backing_malloc(n);
* if (cbm_memev_hint_pending()) {
* cbm_memev_alloc(block, n, usable, NULL);
* }
*
* FREES HAVE NO HANDSHAKE, on purpose: a free event must be recorded BEFORE
* the block goes back to the allocator, or another thread can be handed the
* same address, record its allocation, and have it erased by the late free.
* Where an observer sees the backing allocator it reports frees; everywhere
* else the core reports them itself, first. */
void cbm_memev_hint(void *site, int mem_class);
bool cbm_memev_hint_pending(void);
void cbm_memev_set_observer_installed(bool installed);
bool cbm_memev_observer_installed(void);
/* Load address of the main image, written into every report header: sites are
* raw addresses in a randomised process, and offline symbolisation needs the
* base they are relative to. 0 = unknown. */
void cbm_memev_set_image_base(uintptr_t base);
/* ── Merge points ─────────────────────────────────────────────────────
* Per-thread counters are pushed to the shared tables here: work-item end,
* thread end, before any reader. */
void cbm_memev_flush_thread(void);
/* This thread is ending: merge its counters and release its state now. POSIX
* does this from a pthread key destructor. A static MinGW link runs no FLS or
* key destructors at thread exit (the same reason compat_thread.c releases the
* mimalloc thread heap from a loader TLS callback, #581), so Windows threads
* left their 22 MB state behind -- 71 of them by the similarity pass on the
* Go corpus, then commit exhaustion (2026-09-17). Safe to call twice. */
void cbm_memev_thread_end(void);
/* A pipeline phase boundary. Blocks allocated from now on carry the new phase;
* the live set is snapshotted (retained-across-phases, duplicate content) and
* a dump is appended to CBM_MEMWASTE_OUT. Called after the workers joined. */
void cbm_memev_phase(const char *label);
/* ── Process role and exit ────────────────────────────────────────────
* The index worker leaves with _Exit (no exit handlers: multi-GB teardown is
* skipped), which also skips every at-exit writer -- the waste dump and clang's
* profile runtime. cbm_memev_process_exit writes both first; it is a no-op when
* neither lane is compiled in or active.
* cbm_memev_process_role names this process's execution-count profile
* <CBM_PROFILE_DIR>/<role>-<pid>.profraw, so the scaling lane can compare the
* worker's work without the daemon and CLI processes mixed in. */
void cbm_memev_process_role(const char *role);
void cbm_memev_process_exit(void);
/* ── Access lane: what the compiler cannot see ─────────────────────────
* The access lane learns about reads and writes from compiler callbacks, and a
* library call is not compiled with them: a block filled by memcpy or read(2)
* would look never written. The library wrappers report those ranges here.
* While the allocator itself runs (a realloc copying the old block), its reads
* and writes are not the program's: the shims bracket every backing call that
* can copy. Both vanish outside the access lane. */
#if defined(CBM_MEMWASTE_ACCESS) && CBM_MEMWASTE_ACCESS
void cbm_memev_access(const void *p, size_t n, bool store, void *pc);
void cbm_memev_backing(int delta);
#define CBM_MEMEV_ACCESS(p, n, store, pc) cbm_memev_access((p), (n), (store), (pc))
#define CBM_MEMEV_BACKING(delta) cbm_memev_backing(delta)
#else
#define CBM_MEMEV_ACCESS(p, n, store, pc) ((void)0)
#define CBM_MEMEV_BACKING(delta) ((void)0)
#endif
/* A wrapped library call (fopen, fread, opendir...) is running on this thread.
* Memory the library allocates for itself meanwhile -- a FILE buffer, a DIR
* stream -- is charged to `site` (the wrapper's caller) with CBM_MEMEV_LIBRARY:
* it is counted like any memory, but the access lane leaves it alone, because
* only the uninstrumented library ever reads it. Nestable; the outermost site
* wins. */
void cbm_memev_library_enter(void *site);
void cbm_memev_library_leave(void);
void *cbm_memev_library_site(void);
/* ── CPU work ─────────────────────────────────────────────────────────
* Work is COUNTED, never timed: a count is a function of the code and the
* input, so it can gate. Semantics of the counters per kind:
*
* kind bytes aux1 aux2
* memcpy..strncmp bytes processed - -
* read, write,
* pread, pwrite,
* fread, fwrite bytes transferred tiny calls (<512 B) -
* open, stat,
* fopen, opendir - failures -
* readdir - - -
* mutex - contended -
* parallel_for items imbalance items workers
* pool_ops events by workers imbalance events workers
* ht_get - misses -
* ht_set - table growths -
* dead_write bytes never read blocks -
* ct_arena,
* ct_dyn_array,
* ct_hash_table capacity bytes used bytes growths
*
* `repeats` counts the same work done again, where the answer cannot have
* changed: strlen of the same pointer holding the same bytes (recent, same
* thread), open/stat of the same path (process-wide), a hash-table lookup of
* the same key TEXT in the same table with no write to that table in between
* (recent, same thread). A reused buffer holding a different string is not a
* repeat.
*
* `peak` is the largest single note: the biggest call for byte work and I/O,
* and for containers the largest capacity one instance left unused. Containers
* report at every reset, so `bytes` and `aux1` sum over USES (a per-thread arena
* rewound for 28,000 files reports 28,000 times); `peak` is the memory figure. */
typedef enum {
CBM_WORK_MEMCPY = 0,
CBM_WORK_MEMMOVE,
CBM_WORK_MEMSET,
CBM_WORK_MEMCMP,
CBM_WORK_STRLEN,
CBM_WORK_STRCMP,
CBM_WORK_STRNCMP,
CBM_WORK_READ,
CBM_WORK_WRITE,
CBM_WORK_PREAD,
CBM_WORK_PWRITE,
CBM_WORK_OPEN,
CBM_WORK_STAT,
CBM_WORK_FOPEN,
CBM_WORK_FREAD,
CBM_WORK_FWRITE,
CBM_WORK_OPENDIR,
CBM_WORK_READDIR,
CBM_WORK_MUTEX,
CBM_WORK_PARALLEL_FOR,
CBM_WORK_POOL_OPS,
CBM_WORK_HT_GET,
CBM_WORK_HT_SET,
CBM_WORK_DEAD_WRITE,
CBM_WORK_CT_ARENA,
CBM_WORK_CT_DYN_ARRAY,
CBM_WORK_CT_HASH_TABLE,
CBM_WORK_KIND_COUNT
} cbm_work_kind_t;
enum { CBM_WORK_TINY_IO = 512 };
const char *cbm_work_kind_name(cbm_work_kind_t kind);
void cbm_work_note(cbm_work_kind_t kind, void *site, uint64_t bytes, uint64_t aux1,
uint64_t aux2);
void cbm_work_note_strlen(void *site, const char *s, size_t len);
/* Events (allocations and work notes) this thread has produced so far. A pool
* compares its workers by this: a pool over N work SLOTS whose workers each pull
* their own items shows its real imbalance only in the work done. */
uint64_t cbm_memev_thread_ops(void);
#if defined(CBM_MEMWASTE) && CBM_MEMWASTE && !defined(_WIN32)
/* The one counting lock behind every pthread_mutex_lock in the flavour
* (mem_override_libc.c): try first, count a contended acquisition at `site`,
* then block. cbm_mutex_lock passes its own caller as the site. */
int cbm_memev_mutex_lock(void *pthread_mutex, void *site);
#endif
void cbm_work_note_path(cbm_work_kind_t kind, void *site, const char *path, bool failed);
/* `generation` is the table's write counter: a lookup repeats only when the
* table has not been written since the same key was last looked up. */
void cbm_work_note_ht(cbm_work_kind_t kind, void *site, const void *table, const char *key,
uint64_t generation, bool hit);
/* Containers report capacity against use when they are freed or reset.
* `site` is where the container was created. */
static inline void cbm_memev_container(cbm_work_kind_t kind, void *site, uint64_t capacity,
uint64_t used, uint64_t growths) {
cbm_work_note(kind, site, capacity, used, growths);
}
/* A dynamic array being freed: capacity against count, in bytes. The site is
* the function that freed it (the helper is never inlined, so its return
* address lands there). Growths are the doublings from 8 slots to `cap`. */
void cbm_memev_da_note(size_t cap_bytes, size_t used_bytes, size_t cap_slots);
/* ── Report ───────────────────────────────────────────────────────────*/
typedef struct {
uint64_t allocs; /* alloc events seen */
uint64_t frees; /* free events matched to a tracked block */
uint64_t untracked_frees; /* frees of blocks the layer never saw */
uint64_t foreign_frees; /* frees of blocks another allocator made */
uint64_t reallocs;
uint64_t requested_bytes; /* cumulative */
uint64_t usable_bytes; /* cumulative */
uint64_t realloc_copy_bytes; /* bytes the allocator had to move */
uint64_t live_bytes; /* usable bytes outstanding now */
uint64_t live_blocks;
uint64_t raw_bytes; /* cumulative usable bytes never tagged by the core */
uint64_t sites; /* distinct allocation sites */
/* Records the fixed tables could not hold. A profiler that silently loses
* records produces false confidence; these make the blind spot a number. */
uint64_t site_table_full;
uint64_t pointer_table_full;
uint64_t work_table_full;
} cbm_memev_totals_t;
void cbm_memev_totals(cbm_memev_totals_t *out);
typedef struct {
uintptr_t site;
uint64_t allocs;
uint64_t frees;
uint64_t reallocs;
uint64_t requested_bytes;
uint64_t usable_bytes;
uint64_t realloc_copy_bytes;
uint64_t live_bytes;
uint64_t live_blocks;
uint64_t short_lived;
uint64_t raw_bytes;
/* fill-pattern scan at free */
uint64_t scanned_blocks;
uint64_t never_written_bytes; /* bytes still holding the fill pattern */
uint64_t over_requested_bytes; /* requested beyond the last written byte */
uint64_t zero_untouched_bytes; /* calloc: trailing zero bytes (upper bound) */
/* phase snapshot (the most recent phase mark) */
uint64_t retained_bytes; /* live, allocated in an EARLIER phase */
uint64_t dup_bytes; /* live small blocks identical to another */
uint64_t dup_blocks;
/* access lane */
uint64_t acc_untouched_blocks;
uint64_t acc_untouched_bytes;
uint64_t acc_dead_blocks; /* written, never read */
uint64_t acc_dead_bytes;
uint64_t acc_uninit_reads; /* read before any write, not calloc */
uint64_t acc_idle_bytes; /* last accessed in an earlier phase than freed */
uint64_t acc_opaque_blocks; /* written (fill scan) with no callback: no verdict possible */
} cbm_memev_site_t;
size_t cbm_memev_sites(cbm_memev_site_t *out, size_t max);
typedef struct {
uintptr_t site;
cbm_work_kind_t kind;
uint64_t calls;
uint64_t bytes;
uint64_t aux1;
uint64_t aux2;
uint64_t repeats;
uint64_t peak;
} cbm_work_row_t;
size_t cbm_work_rows(cbm_work_row_t *out, size_t max);
/* Append the report as JSON lines to `path`: one header object, one object per
* allocation site, one per work row. Raw addresses; symbolisation is offline
* (scripts/memwaste-report.py). `why` labels the dump. */
bool cbm_memev_dump(const char *path, const char *why);
/* Test seams. Never called in production. */
void cbm_memev_reset_for_tests(void);
void cbm_memev_force_for_tests(bool on);
/* Tests that hand the layer REAL memory turn the fill pattern and the duplicate
* scan on (reset turns fill off: most tests use synthetic addresses). */
void cbm_memev_scan_for_tests(bool fill, bool dupes);
#endif /* CBM_MEM_EVENTS_H */
+267
View File
@@ -0,0 +1,267 @@
/*
* mem_override_darwin.c — the macOS allocation observer of the waste sanitizer.
*
* WHY THIS FILE EXISTS
*
* Linux and Windows observe the malloc family through the linker's --wrap
* (mem_override_posix.c, mem_override_win.c). macOS ld has no --wrap, and the
* mimalloc global override is off there permanently: the two-level namespace
* splits this binary's free from the system libraries' malloc, and a pointer
* crossing that boundary aborts. So on macOS nothing saw raw malloc at all —
* the platform the project is developed on was the one platform that could not
* be measured.
*
* HOW IT WORKS
*
* A malloc ZONE that forwards every entry to the original default zone and
* reports the event. Forwarding is the whole safety argument: a block never
* changes allocator, so there is no boundary for it to cross. The zone claims
* ownership through size(), which is what routes free() back through it.
*
* Proven on 2026-09-17 inside the real indexer (Go corpus): no abort, a block
* allocated BEFORE installation frees cleanly after it, wall time 41.2 s ->
* 41.3 s, and 92.3 million raw allocations that nothing had ever counted.
*
* THREE TRAPS, each one hit during that proof:
* 1. malloc_default_zone() returns a VIRTUAL wrapper that dispatches to
* whatever is default at call time. Forwarding to it recurses forever once
* this zone is the default. The real slot 0 comes from
* malloc_get_all_zones().
* 2. Registration appends. The default is slot 0, and the only way in is the
* rotation jemalloc uses: unregister slot 0 (the last zone moves into it),
* re-register it (appended), until this zone is first.
* 3. free() arrives through free_definite_size, not through free.
*
* Compiled only into the `memwaste` flavour; dormant unless CBM_MEMWASTE=1.
*/
#if defined(__APPLE__) && defined(CBM_MEMWASTE) && CBM_MEMWASTE
#include "foundation/mem_events.h"
#include <mach-o/dyld.h>
#include <mach-o/getsect.h>
#include <mach/mach.h>
#include <malloc/malloc.h>
#include <pthread.h>
#include <stdint.h>
#include <string.h>
enum { OBS_ZONE_VERSION = 9, OBS_MAX_ROTATIONS = 16, OBS_MAX_FRAMES = 24 };
static malloc_zone_t *g_orig;
static malloc_zone_t g_obs;
static uintptr_t g_text_lo;
static uintptr_t g_text_hi;
/* The site of a raw allocation is the first return address inside OUR image:
* above the zone entry sit libmalloc's own frames, and often libc's (strdup,
* getpwuid), before the code of ours that caused the call. Frames are walked
* through the frame-pointer chain, bounded by this thread's stack, so a
* foreign or corrupt frame ends the walk instead of faulting. */
static void *caller_site(void *frame, unsigned *flags) {
*flags = 0;
if (cbm_memev_hint_pending()) {
return NULL; /* the memory core already named the site */
}
void *library_caller = cbm_memev_library_site();
if (library_caller) {
*flags = CBM_MEMEV_LIBRARY; /* a FILE buffer, a DIR stream: the library's own */
return library_caller;
}
pthread_t self = pthread_self();
uintptr_t hi = (uintptr_t)pthread_get_stackaddr_np(self);
uintptr_t lo = hi - pthread_get_stacksize_np(self);
uintptr_t *fp = (uintptr_t *)frame;
void *first = NULL;
for (int depth = 0; depth < OBS_MAX_FRAMES; depth++) {
if ((uintptr_t)fp < lo || (uintptr_t)fp + (2 * sizeof(uintptr_t)) > hi ||
((uintptr_t)fp & (sizeof(uintptr_t) - 1)) != 0) {
break;
}
uintptr_t ret = fp[1];
uintptr_t *next = (uintptr_t *)fp[0];
if (ret == 0) {
break;
}
if (!first) {
first = (void *)ret;
}
if (ret >= g_text_lo && ret < g_text_hi) {
return (void *)ret;
}
if (next <= fp) {
break; /* the chain must climb */
}
fp = next;
}
*flags = CBM_MEMEV_FOREIGN;
return first; /* a system library allocating for itself: observe, never fill */
}
static size_t obs_size(malloc_zone_t *z, const void *p) {
(void)z;
return g_orig->size(g_orig, p);
}
static void *obs_malloc(malloc_zone_t *z, size_t n) {
(void)z;
void *p = g_orig->malloc(g_orig, n);
if (p && cbm_memev_enabled()) {
unsigned flags;
void *site = caller_site(__builtin_frame_address(0), &flags);
cbm_memev_alloc_ex(p, n, g_orig->size(g_orig, p), site, flags);
}
return p;
}
static void *obs_calloc(malloc_zone_t *z, size_t count, size_t n) {
(void)z;
void *p = g_orig->calloc(g_orig, count, n);
if (p && cbm_memev_enabled()) {
unsigned flags;
void *site = caller_site(__builtin_frame_address(0), &flags);
cbm_memev_alloc_ex(p, count * n, g_orig->size(g_orig, p), site,
flags | CBM_MEMEV_ZEROED);
}
return p;
}
static void *obs_valloc(malloc_zone_t *z, size_t n) {
(void)z;
void *p = g_orig->valloc(g_orig, n);
if (p && cbm_memev_enabled()) {
unsigned flags;
void *site = caller_site(__builtin_frame_address(0), &flags);
cbm_memev_alloc_ex(p, n, g_orig->size(g_orig, p), site, flags);
}
return p;
}
static void *obs_memalign(malloc_zone_t *z, size_t align, size_t n) {
(void)z;
void *p = g_orig->memalign(g_orig, align, n);
if (p && cbm_memev_enabled()) {
unsigned flags;
void *site = caller_site(__builtin_frame_address(0), &flags);
cbm_memev_alloc_ex(p, n, g_orig->size(g_orig, p), site, flags);
}
return p;
}
static void *obs_realloc(malloc_zone_t *z, void *p, size_t n) {
(void)z;
unsigned flags = 0;
void *site = cbm_memev_enabled() ? caller_site(__builtin_frame_address(0), &flags) : NULL;
CBM_MEMEV_BACKING(1);
void *q = g_orig->realloc(g_orig, p, n);
CBM_MEMEV_BACKING(-1);
if (q && cbm_memev_enabled()) {
cbm_memev_realloc(p, q, n, g_orig->size(g_orig, q), site);
}
return q;
}
static void obs_free(malloc_zone_t *z, void *p) {
(void)z;
cbm_memev_free(p);
g_orig->free(g_orig, p);
}
static void obs_free_definite_size(malloc_zone_t *z, void *p, size_t n) {
(void)z;
cbm_memev_free(p);
if (g_orig->free_definite_size) {
g_orig->free_definite_size(g_orig, p, n);
} else {
g_orig->free(g_orig, p);
}
}
static unsigned obs_batch_malloc(malloc_zone_t *z, size_t size, void **results, unsigned count) {
(void)z;
unsigned got = g_orig->batch_malloc ? g_orig->batch_malloc(g_orig, size, results, count) : 0;
if (cbm_memev_enabled()) {
unsigned flags;
void *site = caller_site(__builtin_frame_address(0), &flags);
for (unsigned i = 0; i < got; i++) {
cbm_memev_alloc_ex(results[i], size, g_orig->size(g_orig, results[i]), site, flags);
}
}
return got;
}
static void obs_batch_free(malloc_zone_t *z, void **blocks, unsigned count) {
(void)z;
for (unsigned i = 0; i < count; i++) {
cbm_memev_free(blocks[i]);
}
if (g_orig->batch_free) {
g_orig->batch_free(g_orig, blocks, count);
}
}
static void obs_destroy(malloc_zone_t *z) {
(void)z;
}
static size_t obs_pressure_relief(malloc_zone_t *z, size_t goal) {
(void)z;
return g_orig->pressure_relief ? g_orig->pressure_relief(g_orig, goal) : 0;
}
static malloc_zone_t *slot_zero(void) {
vm_address_t *zones = NULL;
unsigned count = 0;
if (malloc_get_all_zones(mach_task_self(), NULL, &zones, &count) != KERN_SUCCESS ||
count == 0) {
return NULL;
}
return (malloc_zone_t *)zones[0];
}
__attribute__((constructor)) static void cbm_mem_observe_install(void) {
if (!cbm_memev_enabled()) {
return;
}
const struct mach_header_64 *hdr = (const struct mach_header_64 *)_dyld_get_image_header(0);
unsigned long text_size = 0;
uint8_t *text = hdr ? getsegmentdata(hdr, "__TEXT", &text_size) : NULL;
g_text_lo = (uintptr_t)text;
g_text_hi = g_text_lo + text_size;
cbm_memev_set_image_base((uintptr_t)hdr);
g_orig = slot_zero();
if (!g_orig) {
return;
}
memset(&g_obs, 0, sizeof(g_obs));
g_obs.size = obs_size;
g_obs.malloc = obs_malloc;
g_obs.calloc = obs_calloc;
g_obs.valloc = obs_valloc;
g_obs.free = obs_free;
g_obs.realloc = obs_realloc;
g_obs.destroy = obs_destroy;
g_obs.zone_name = "cbm_memwaste_observer";
g_obs.batch_malloc = obs_batch_malloc;
g_obs.batch_free = obs_batch_free;
g_obs.introspect = g_orig->introspect; /* leaks/heap keep working */
g_obs.version = OBS_ZONE_VERSION;
g_obs.memalign = obs_memalign;
g_obs.free_definite_size = obs_free_definite_size;
g_obs.pressure_relief = obs_pressure_relief;
malloc_zone_register(&g_obs);
for (int spin = 0; spin < OBS_MAX_ROTATIONS; spin++) {
malloc_zone_t *first = slot_zero();
if (!first || first == &g_obs) {
break;
}
malloc_zone_unregister(first);
malloc_zone_register(first);
}
cbm_memev_set_observer_installed(slot_zero() == &g_obs);
}
#else
typedef int cbm_mem_override_darwin_unused_t;
#endif
+738
View File
@@ -0,0 +1,738 @@
/*
* mem_override_libc.c — the CPU lane of the waste sanitizer: byte work and
* system calls, counted per call site.
*
* WHAT IT COUNTS. memcpy, memmove, memset, memcmp, strlen, strcmp, strncmp
* (calls and bytes), read/write/pread/pwrite/fread/fwrite (bytes and tiny
* calls), open/stat/fopen/opendir (failures and the same path again), readdir,
* and pthread_mutex_lock (contended acquisitions; POSIX only -- Windows locks
* are counted where cbm_mutex_lock takes them).
* Calls the compiler inlines (small constant-size copies) are not calls and are
* not counted: what is counted is exactly the work that reaches the library.
*
* HOW IT LISTENS, per platform
* Linux, Windows the linker's --wrap redirects every reference in OUR
* objects to __wrap_<name>; __real_<name> is the original.
* The flavour adds the flags (Makefile.cbm, MEMWASTE=1).
* macOS ld has no --wrap and dyld ignores __interpose tuples in the
* main executable (proven 2026-09-17). Instead this file
* DEFINES the symbols: references in our objects bind to the
* definitions in our own image, and each definition reaches
* libSystem's through dlsym(RTLD_NEXT). Calls made inside
* libSystem keep libSystem's own functions. Fortified builds
* call __memcpy_chk and friends, so those are defined too.
*
* The CALLER's return address is the site: this file is the observation point.
* Compiled only into the `memwaste` flavour; dormant unless CBM_MEMWASTE=1.
* Everything here forwards unconditionally: counting can only ever be skipped,
* never the work.
*/
#if defined(CBM_MEMWASTE) && CBM_MEMWASTE
#include "foundation/mem_events.h"
#include <errno.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#if !defined(_WIN32)
#include <pthread.h>
#endif
#if defined(__clang__)
#pragma clang attribute push(__attribute__((no_sanitize("coverage"))), apply_to = function)
#endif
#define SITE() __builtin_return_address(0)
static inline void note_io(cbm_work_kind_t kind, void *site, long long got, size_t asked) {
if (!cbm_memev_enabled()) {
return;
}
uint64_t bytes = got > 0 ? (uint64_t)got : 0;
cbm_work_note(kind, site, bytes, (asked > 0 && asked < CBM_WORK_TINY_IO) ? 1 : 0, 0);
}
/* Access lane: a library call's reads and writes, charged to its caller. */
#define ACC_LOAD(p, n) CBM_MEMEV_ACCESS((p), (n), false, SITE())
#define ACC_STORE(p, n) CBM_MEMEV_ACCESS((p), (n), true, SITE())
static inline void note_bytes(cbm_work_kind_t kind, void *site, size_t n) {
if (cbm_memev_enabled()) {
cbm_work_note(kind, site, n, 0, 0);
}
}
/* ══════════════════════════════════════════════════════════════════════ */
#if defined(__APPLE__)
#include <dirent.h>
#include <dlfcn.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#undef memcpy
#undef memmove
#undef memset
#undef strlen
#undef strcmp
#undef strncmp
#undef memcmp
/* Resolved in a constructor. Until then the byte functions run a plain loop
* (dlsym and the dynamic loader call them before any constructor), marked
* no_builtin so the optimiser cannot turn the loop back into a call to itself. */
static void *(*r_memcpy)(void *, const void *, size_t);
static void *(*r_memmove)(void *, const void *, size_t);
static void *(*r_memset)(void *, int, size_t);
static int (*r_memcmp)(const void *, const void *, size_t);
static size_t (*r_strlen)(const char *);
static int (*r_strcmp)(const char *, const char *);
static int (*r_strncmp)(const char *, const char *, size_t);
static void *(*r_memcpy_chk)(void *, const void *, size_t, size_t);
static void *(*r_memmove_chk)(void *, const void *, size_t, size_t);
static void *(*r_memset_chk)(void *, int, size_t, size_t);
static ssize_t (*r_read)(int, void *, size_t);
static ssize_t (*r_write)(int, const void *, size_t);
static ssize_t (*r_pread)(int, void *, size_t, off_t);
static ssize_t (*r_pwrite)(int, const void *, size_t, off_t);
static int (*r_open)(const char *, int, ...);
static int (*r_stat)(const char *, struct stat *);
static int (*r_lstat)(const char *, struct stat *);
static FILE *(*r_fopen)(const char *, const char *);
static size_t (*r_fread)(void *, size_t, size_t, FILE *);
static size_t (*r_fwrite)(const void *, size_t, size_t, FILE *);
static DIR *(*r_opendir)(const char *);
static struct dirent *(*r_readdir)(DIR *);
static int (*r_mutex_lock)(pthread_mutex_t *);
static int (*r_mutex_trylock)(pthread_mutex_t *);
__attribute__((constructor(101))) static void libc_resolve(void) {
r_memcpy = dlsym(RTLD_NEXT, "memcpy");
r_memmove = dlsym(RTLD_NEXT, "memmove");
r_memset = dlsym(RTLD_NEXT, "memset");
r_memcmp = dlsym(RTLD_NEXT, "memcmp");
r_strlen = dlsym(RTLD_NEXT, "strlen");
r_strcmp = dlsym(RTLD_NEXT, "strcmp");
r_strncmp = dlsym(RTLD_NEXT, "strncmp");
r_memcpy_chk = dlsym(RTLD_NEXT, "__memcpy_chk");
r_memmove_chk = dlsym(RTLD_NEXT, "__memmove_chk");
r_memset_chk = dlsym(RTLD_NEXT, "__memset_chk");
r_read = dlsym(RTLD_NEXT, "read");
r_write = dlsym(RTLD_NEXT, "write");
r_pread = dlsym(RTLD_NEXT, "pread");
r_pwrite = dlsym(RTLD_NEXT, "pwrite");
r_open = dlsym(RTLD_NEXT, "open");
r_stat = dlsym(RTLD_NEXT, "stat");
r_lstat = dlsym(RTLD_NEXT, "lstat");
r_fopen = dlsym(RTLD_NEXT, "fopen");
r_fread = dlsym(RTLD_NEXT, "fread");
r_fwrite = dlsym(RTLD_NEXT, "fwrite");
r_opendir = dlsym(RTLD_NEXT, "opendir");
r_readdir = dlsym(RTLD_NEXT, "readdir");
r_mutex_lock = dlsym(RTLD_NEXT, "pthread_mutex_lock");
r_mutex_trylock = dlsym(RTLD_NEXT, "pthread_mutex_trylock");
}
/* System calls are only reachable after libc_resolve; a call from an earlier
* constructor resolves its own target once. */
#define RESOLVED(ptr, name) ((ptr) ? (ptr) : ((ptr) = dlsym(RTLD_NEXT, name)))
__attribute__((no_builtin("memcpy", "memmove", "memset")))
static void *slow_move(void *d, const void *s, size_t n) {
unsigned char *dd = (unsigned char *)d;
const unsigned char *ss = (const unsigned char *)s;
if (dd < ss) {
for (size_t i = 0; i < n; i++) {
dd[i] = ss[i];
}
} else {
for (size_t i = n; i > 0; i--) {
dd[i - 1] = ss[i - 1];
}
}
return d;
}
__attribute__((no_builtin("memset")))
static void *slow_set(void *d, int c, size_t n) {
unsigned char *dd = (unsigned char *)d;
for (size_t i = 0; i < n; i++) {
dd[i] = (unsigned char)c;
}
return d;
}
__attribute__((no_builtin("strlen")))
static size_t slow_strlen(const char *s) {
size_t i = 0;
while (s[i]) {
i++;
}
return i;
}
__attribute__((no_builtin("memcmp", "strcmp", "strncmp")))
static int slow_ncmp(const char *a, const char *b, size_t n, bool stop_at_nul) {
for (size_t i = 0; i < n; i++) {
unsigned char x = (unsigned char)a[i];
unsigned char y = (unsigned char)b[i];
if (x != y) {
return x < y ? -1 : 1;
}
if (stop_at_nul && x == 0) {
return 0;
}
}
return 0;
}
void *(memcpy)(void *restrict d, const void *restrict s, size_t n) {
note_bytes(CBM_WORK_MEMCPY, SITE(), n);
ACC_LOAD(s, n);
ACC_STORE(d, n);
return r_memcpy ? r_memcpy(d, s, n) : slow_move(d, s, n);
}
void *(memmove)(void *d, const void *s, size_t n) {
note_bytes(CBM_WORK_MEMMOVE, SITE(), n);
ACC_LOAD(s, n);
ACC_STORE(d, n);
return r_memmove ? r_memmove(d, s, n) : slow_move(d, s, n);
}
void *(memset)(void *d, int c, size_t n) {
note_bytes(CBM_WORK_MEMSET, SITE(), n);
ACC_STORE(d, n);
return r_memset ? r_memset(d, c, n) : slow_set(d, c, n);
}
int (memcmp)(const void *a, const void *b, size_t n) {
note_bytes(CBM_WORK_MEMCMP, SITE(), n);
ACC_LOAD(a, n);
ACC_LOAD(b, n);
return r_memcmp ? r_memcmp(a, b, n) : slow_ncmp((const char *)a, (const char *)b, n, false);
}
size_t (strlen)(const char *s) {
size_t n = r_strlen ? r_strlen(s) : slow_strlen(s);
if (cbm_memev_enabled()) {
cbm_work_note_strlen(SITE(), s, n);
ACC_LOAD(s, n + 1);
}
return n;
}
int (strcmp)(const char *a, const char *b) {
note_bytes(CBM_WORK_STRCMP, SITE(), 0);
ACC_LOAD(a, 1);
ACC_LOAD(b, 1);
return r_strcmp ? r_strcmp(a, b) : slow_ncmp(a, b, (size_t)-1, true);
}
int (strncmp)(const char *a, const char *b, size_t n) {
note_bytes(CBM_WORK_STRNCMP, SITE(), n);
ACC_LOAD(a, 1);
ACC_LOAD(b, 1);
return r_strncmp ? r_strncmp(a, b, n) : slow_ncmp(a, b, n, true);
}
void *__memcpy_chk(void *d, const void *s, size_t n, size_t dn) {
note_bytes(CBM_WORK_MEMCPY, SITE(), n);
ACC_LOAD(s, n);
ACC_STORE(d, n);
return RESOLVED(r_memcpy_chk, "__memcpy_chk")(d, s, n, dn);
}
void *__memmove_chk(void *d, const void *s, size_t n, size_t dn) {
note_bytes(CBM_WORK_MEMMOVE, SITE(), n);
ACC_LOAD(s, n);
ACC_STORE(d, n);
return RESOLVED(r_memmove_chk, "__memmove_chk")(d, s, n, dn);
}
void *__memset_chk(void *d, int c, size_t n, size_t dn) {
note_bytes(CBM_WORK_MEMSET, SITE(), n);
ACC_STORE(d, n);
return RESOLVED(r_memset_chk, "__memset_chk")(d, c, n, dn);
}
ssize_t read(int fd, void *buf, size_t n) {
ssize_t got = RESOLVED(r_read, "read")(fd, buf, n);
note_io(CBM_WORK_READ, SITE(), (long long)got, n);
ACC_STORE(buf, got > 0 ? (size_t)got : 0);
return got;
}
ssize_t write(int fd, const void *buf, size_t n) {
ssize_t put = RESOLVED(r_write, "write")(fd, buf, n);
note_io(CBM_WORK_WRITE, SITE(), (long long)put, n);
ACC_LOAD(buf, put > 0 ? (size_t)put : 0);
return put;
}
ssize_t pread(int fd, void *buf, size_t n, off_t off) {
ssize_t got = RESOLVED(r_pread, "pread")(fd, buf, n, off);
note_io(CBM_WORK_PREAD, SITE(), (long long)got, n);
ACC_STORE(buf, got > 0 ? (size_t)got : 0);
return got;
}
ssize_t pwrite(int fd, const void *buf, size_t n, off_t off) {
ssize_t put = RESOLVED(r_pwrite, "pwrite")(fd, buf, n, off);
note_io(CBM_WORK_PWRITE, SITE(), (long long)put, n);
ACC_LOAD(buf, put > 0 ? (size_t)put : 0);
return put;
}
int open(const char *path, int flags, ...) {
int mode = 0;
if (flags & O_CREAT) {
va_list ap;
va_start(ap, flags);
mode = va_arg(ap, int);
va_end(ap);
}
int fd = RESOLVED(r_open, "open")(path, flags, mode);
if (cbm_memev_enabled()) {
cbm_work_note_path(CBM_WORK_OPEN, SITE(), path, fd < 0);
}
return fd;
}
int stat(const char *path, struct stat *st) {
int rc = RESOLVED(r_stat, "stat")(path, st);
if (cbm_memev_enabled()) {
cbm_work_note_path(CBM_WORK_STAT, SITE(), path, rc != 0);
}
return rc;
}
int lstat(const char *path, struct stat *st) {
int rc = RESOLVED(r_lstat, "lstat")(path, st);
if (cbm_memev_enabled()) {
cbm_work_note_path(CBM_WORK_STAT, SITE(), path, rc != 0);
}
return rc;
}
FILE *fopen(const char *restrict path, const char *restrict mode) {
cbm_memev_library_enter(SITE());
FILE *f = RESOLVED(r_fopen, "fopen")(path, mode);
cbm_memev_library_leave();
if (cbm_memev_enabled()) {
cbm_work_note_path(CBM_WORK_FOPEN, SITE(), path, f == NULL);
}
return f;
}
size_t fread(void *restrict buf, size_t size, size_t count, FILE *restrict f) {
cbm_memev_library_enter(SITE());
size_t got = RESOLVED(r_fread, "fread")(buf, size, count, f);
cbm_memev_library_leave();
note_io(CBM_WORK_FREAD, SITE(), (long long)(got * size), size * count);
ACC_STORE(buf, got * size);
return got;
}
size_t fwrite(const void *restrict buf, size_t size, size_t count, FILE *restrict f) {
cbm_memev_library_enter(SITE());
size_t put = RESOLVED(r_fwrite, "fwrite")(buf, size, count, f);
cbm_memev_library_leave();
note_io(CBM_WORK_FWRITE, SITE(), (long long)(put * size), size * count);
ACC_LOAD(buf, put * size);
return put;
}
DIR *opendir(const char *path) {
cbm_memev_library_enter(SITE());
DIR *d = RESOLVED(r_opendir, "opendir")(path);
cbm_memev_library_leave();
if (cbm_memev_enabled()) {
cbm_work_note_path(CBM_WORK_OPENDIR, SITE(), path, d == NULL);
}
return d;
}
struct dirent *readdir(DIR *d) {
note_bytes(CBM_WORK_READDIR, SITE(), 0);
cbm_memev_library_enter(SITE());
struct dirent *ent = RESOLVED(r_readdir, "readdir")(d);
cbm_memev_library_leave();
return ent;
}
int cbm_memev_mutex_lock(void *pthread_mutex, void *site) {
pthread_mutex_t *m = (pthread_mutex_t *)pthread_mutex;
int (*lock)(pthread_mutex_t *) = RESOLVED(r_mutex_lock, "pthread_mutex_lock");
if (!cbm_memev_enabled()) {
return lock(m);
}
int rc = RESOLVED(r_mutex_trylock, "pthread_mutex_trylock")(m);
bool contended = rc == EBUSY;
if (contended) {
rc = lock(m);
}
cbm_work_note(CBM_WORK_MUTEX, site, 0, contended ? 1 : 0, 0);
return rc;
}
int pthread_mutex_lock(pthread_mutex_t *m) {
return cbm_memev_mutex_lock(m, SITE());
}
/* ══════════════════════════════════════════════════════════════════════ */
#elif defined(__linux__)
#include <dirent.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
void *__real_memcpy(void *, const void *, size_t);
void *__real_memmove(void *, const void *, size_t);
void *__real_memset(void *, int, size_t);
int __real_memcmp(const void *, const void *, size_t);
size_t __real_strlen(const char *);
int __real_strcmp(const char *, const char *);
int __real_strncmp(const char *, const char *, size_t);
void *__real___memcpy_chk(void *, const void *, size_t, size_t);
void *__real___memmove_chk(void *, const void *, size_t, size_t);
void *__real___memset_chk(void *, int, size_t, size_t);
ssize_t __real_read(int, void *, size_t);
ssize_t __real___read_chk(int, void *, size_t, size_t);
ssize_t __real_write(int, const void *, size_t);
ssize_t __real_pread(int, void *, size_t, off_t);
ssize_t __real_pwrite(int, const void *, size_t, off_t);
ssize_t __real_pread64(int, void *, size_t, off_t);
ssize_t __real_pwrite64(int, const void *, size_t, off_t);
int __real_open(const char *, int, ...);
int __real_open64(const char *, int, ...);
int __real___open_2(const char *, int);
int __real___open64_2(const char *, int);
int __real_stat(const char *, struct stat *);
int __real_lstat(const char *, struct stat *);
FILE *__real_fopen(const char *, const char *);
FILE *__real_fopen64(const char *, const char *);
size_t __real_fread(void *, size_t, size_t, FILE *);
size_t __real___fread_chk(void *, size_t, size_t, size_t, FILE *);
size_t __real_fwrite(const void *, size_t, size_t, FILE *);
DIR *__real_opendir(const char *);
struct dirent *__real_readdir(DIR *);
int __real_pthread_mutex_lock(pthread_mutex_t *);
void *__wrap_memcpy(void *d, const void *s, size_t n) {
note_bytes(CBM_WORK_MEMCPY, SITE(), n);
ACC_LOAD(s, n);
ACC_STORE(d, n);
return __real_memcpy(d, s, n);
}
void *__wrap_memmove(void *d, const void *s, size_t n) {
note_bytes(CBM_WORK_MEMMOVE, SITE(), n);
ACC_LOAD(s, n);
ACC_STORE(d, n);
return __real_memmove(d, s, n);
}
void *__wrap_memset(void *d, int c, size_t n) {
note_bytes(CBM_WORK_MEMSET, SITE(), n);
ACC_STORE(d, n);
return __real_memset(d, c, n);
}
int __wrap_memcmp(const void *a, const void *b, size_t n) {
note_bytes(CBM_WORK_MEMCMP, SITE(), n);
ACC_LOAD(a, n);
ACC_LOAD(b, n);
return __real_memcmp(a, b, n);
}
size_t __wrap_strlen(const char *s) {
size_t n = __real_strlen(s);
if (cbm_memev_enabled()) {
cbm_work_note_strlen(SITE(), s, n);
ACC_LOAD(s, n + 1);
}
return n;
}
int __wrap_strcmp(const char *a, const char *b) {
note_bytes(CBM_WORK_STRCMP, SITE(), 0);
ACC_LOAD(a, 1);
ACC_LOAD(b, 1);
return __real_strcmp(a, b);
}
int __wrap_strncmp(const char *a, const char *b, size_t n) {
note_bytes(CBM_WORK_STRNCMP, SITE(), n);
ACC_LOAD(a, 1);
ACC_LOAD(b, 1);
return __real_strncmp(a, b, n);
}
void *__wrap___memcpy_chk(void *d, const void *s, size_t n, size_t dn) {
note_bytes(CBM_WORK_MEMCPY, SITE(), n);
ACC_LOAD(s, n);
ACC_STORE(d, n);
return __real___memcpy_chk(d, s, n, dn);
}
void *__wrap___memmove_chk(void *d, const void *s, size_t n, size_t dn) {
note_bytes(CBM_WORK_MEMMOVE, SITE(), n);
ACC_LOAD(s, n);
ACC_STORE(d, n);
return __real___memmove_chk(d, s, n, dn);
}
void *__wrap___memset_chk(void *d, int c, size_t n, size_t dn) {
note_bytes(CBM_WORK_MEMSET, SITE(), n);
ACC_STORE(d, n);
return __real___memset_chk(d, c, n, dn);
}
ssize_t __wrap_read(int fd, void *buf, size_t n) {
ssize_t got = __real_read(fd, buf, n);
note_io(CBM_WORK_READ, SITE(), (long long)got, n);
ACC_STORE(buf, got > 0 ? (size_t)got : 0);
return got;
}
ssize_t __wrap___read_chk(int fd, void *buf, size_t n, size_t bn) {
ssize_t got = __real___read_chk(fd, buf, n, bn);
note_io(CBM_WORK_READ, SITE(), (long long)got, n);
ACC_STORE(buf, got > 0 ? (size_t)got : 0);
return got;
}
ssize_t __wrap_write(int fd, const void *buf, size_t n) {
ssize_t put = __real_write(fd, buf, n);
note_io(CBM_WORK_WRITE, SITE(), (long long)put, n);
ACC_LOAD(buf, put > 0 ? (size_t)put : 0);
return put;
}
ssize_t __wrap_pread(int fd, void *buf, size_t n, off_t off) {
ssize_t got = __real_pread(fd, buf, n, off);
note_io(CBM_WORK_PREAD, SITE(), (long long)got, n);
ACC_STORE(buf, got > 0 ? (size_t)got : 0);
return got;
}
ssize_t __wrap_pwrite(int fd, const void *buf, size_t n, off_t off) {
ssize_t put = __real_pwrite(fd, buf, n, off);
note_io(CBM_WORK_PWRITE, SITE(), (long long)put, n);
ACC_LOAD(buf, put > 0 ? (size_t)put : 0);
return put;
}
ssize_t __wrap_pread64(int fd, void *buf, size_t n, off_t off) {
ssize_t got = __real_pread64(fd, buf, n, off);
note_io(CBM_WORK_PREAD, SITE(), (long long)got, n);
ACC_STORE(buf, got > 0 ? (size_t)got : 0);
return got;
}
ssize_t __wrap_pwrite64(int fd, const void *buf, size_t n, off_t off) {
ssize_t put = __real_pwrite64(fd, buf, n, off);
note_io(CBM_WORK_PWRITE, SITE(), (long long)put, n);
ACC_LOAD(buf, put > 0 ? (size_t)put : 0);
return put;
}
static int open_mode(int flags, va_list ap) {
return (flags & O_CREAT) ? va_arg(ap, int) : 0;
}
int __wrap_open(const char *path, int flags, ...) {
va_list ap;
va_start(ap, flags);
int mode = open_mode(flags, ap);
va_end(ap);
int fd = __real_open(path, flags, mode);
if (cbm_memev_enabled()) {
cbm_work_note_path(CBM_WORK_OPEN, SITE(), path, fd < 0);
}
return fd;
}
int __wrap_open64(const char *path, int flags, ...) {
va_list ap;
va_start(ap, flags);
int mode = open_mode(flags, ap);
va_end(ap);
int fd = __real_open64(path, flags, mode);
if (cbm_memev_enabled()) {
cbm_work_note_path(CBM_WORK_OPEN, SITE(), path, fd < 0);
}
return fd;
}
int __wrap___open_2(const char *path, int flags) {
int fd = __real___open_2(path, flags);
if (cbm_memev_enabled()) {
cbm_work_note_path(CBM_WORK_OPEN, SITE(), path, fd < 0);
}
return fd;
}
int __wrap___open64_2(const char *path, int flags) {
int fd = __real___open64_2(path, flags);
if (cbm_memev_enabled()) {
cbm_work_note_path(CBM_WORK_OPEN, SITE(), path, fd < 0);
}
return fd;
}
int __wrap_stat(const char *path, struct stat *st) {
int rc = __real_stat(path, st);
if (cbm_memev_enabled()) {
cbm_work_note_path(CBM_WORK_STAT, SITE(), path, rc != 0);
}
return rc;
}
int __wrap_lstat(const char *path, struct stat *st) {
int rc = __real_lstat(path, st);
if (cbm_memev_enabled()) {
cbm_work_note_path(CBM_WORK_STAT, SITE(), path, rc != 0);
}
return rc;
}
FILE *__wrap_fopen(const char *path, const char *mode) {
FILE *f = __real_fopen(path, mode);
if (cbm_memev_enabled()) {
cbm_work_note_path(CBM_WORK_FOPEN, SITE(), path, f == NULL);
}
return f;
}
FILE *__wrap_fopen64(const char *path, const char *mode) {
FILE *f = __real_fopen64(path, mode);
if (cbm_memev_enabled()) {
cbm_work_note_path(CBM_WORK_FOPEN, SITE(), path, f == NULL);
}
return f;
}
size_t __wrap_fread(void *buf, size_t size, size_t count, FILE *f) {
size_t got = __real_fread(buf, size, count, f);
note_io(CBM_WORK_FREAD, SITE(), (long long)(got * size), size * count);
ACC_STORE(buf, got * size);
return got;
}
size_t __wrap___fread_chk(void *buf, size_t bn, size_t size, size_t count, FILE *f) {
size_t got = __real___fread_chk(buf, bn, size, count, f);
note_io(CBM_WORK_FREAD, SITE(), (long long)(got * size), size * count);
ACC_STORE(buf, got * size);
return got;
}
size_t __wrap_fwrite(const void *buf, size_t size, size_t count, FILE *f) {
size_t put = __real_fwrite(buf, size, count, f);
note_io(CBM_WORK_FWRITE, SITE(), (long long)(put * size), size * count);
ACC_LOAD(buf, put * size);
return put;
}
DIR *__wrap_opendir(const char *path) {
DIR *d = __real_opendir(path);
if (cbm_memev_enabled()) {
cbm_work_note_path(CBM_WORK_OPENDIR, SITE(), path, d == NULL);
}
return d;
}
struct dirent *__wrap_readdir(DIR *d) {
note_bytes(CBM_WORK_READDIR, SITE(), 0);
return __real_readdir(d);
}
int cbm_memev_mutex_lock(void *pthread_mutex, void *site) {
pthread_mutex_t *m = (pthread_mutex_t *)pthread_mutex;
if (!cbm_memev_enabled()) {
return __real_pthread_mutex_lock(m);
}
int rc = pthread_mutex_trylock(m);
bool contended = rc == EBUSY;
if (contended) {
rc = __real_pthread_mutex_lock(m);
}
cbm_work_note(CBM_WORK_MUTEX, site, 0, contended ? 1 : 0, 0);
return rc;
}
int __wrap_pthread_mutex_lock(pthread_mutex_t *m) {
return cbm_memev_mutex_lock(m, SITE());
}
/* ══════════════════════════════════════════════════════════════════════ */
#elif defined(_WIN32)
#include <wchar.h>
void *__real_memcpy(void *, const void *, size_t);
void *__real_memmove(void *, const void *, size_t);
void *__real_memset(void *, int, size_t);
int __real_memcmp(const void *, const void *, size_t);
size_t __real_strlen(const char *);
int __real_strcmp(const char *, const char *);
int __real_strncmp(const char *, const char *, size_t);
FILE *__real__wfopen(const wchar_t *, const wchar_t *);
size_t __real_fread(void *, size_t, size_t, FILE *);
size_t __real_fwrite(const void *, size_t, size_t, FILE *);
void *__wrap_memcpy(void *d, const void *s, size_t n) {
note_bytes(CBM_WORK_MEMCPY, SITE(), n);
ACC_LOAD(s, n);
ACC_STORE(d, n);
return __real_memcpy(d, s, n);
}
void *__wrap_memmove(void *d, const void *s, size_t n) {
note_bytes(CBM_WORK_MEMMOVE, SITE(), n);
ACC_LOAD(s, n);
ACC_STORE(d, n);
return __real_memmove(d, s, n);
}
void *__wrap_memset(void *d, int c, size_t n) {
note_bytes(CBM_WORK_MEMSET, SITE(), n);
ACC_STORE(d, n);
return __real_memset(d, c, n);
}
int __wrap_memcmp(const void *a, const void *b, size_t n) {
note_bytes(CBM_WORK_MEMCMP, SITE(), n);
ACC_LOAD(a, n);
ACC_LOAD(b, n);
return __real_memcmp(a, b, n);
}
size_t __wrap_strlen(const char *s) {
size_t n = __real_strlen(s);
if (cbm_memev_enabled()) {
cbm_work_note_strlen(SITE(), s, n);
ACC_LOAD(s, n + 1);
}
return n;
}
int __wrap_strcmp(const char *a, const char *b) {
note_bytes(CBM_WORK_STRCMP, SITE(), 0);
ACC_LOAD(a, 1);
ACC_LOAD(b, 1);
return __real_strcmp(a, b);
}
int __wrap_strncmp(const char *a, const char *b, size_t n) {
note_bytes(CBM_WORK_STRNCMP, SITE(), n);
ACC_LOAD(a, 1);
ACC_LOAD(b, 1);
return __real_strncmp(a, b, n);
}
/* A wide path's hash is taken over its UTF-16 units squeezed to bytes: equal
* paths hash equal, which is all the repeat detector needs. */
FILE *__wrap__wfopen(const wchar_t *path, const wchar_t *mode) {
FILE *f = __real__wfopen(path, mode);
if (cbm_memev_enabled()) {
char narrow[1024];
size_t i = 0;
for (; path && path[i] && i + 1 < sizeof(narrow); i++) {
narrow[i] = (char)(path[i] & 0x7F ? path[i] : '?');
}
narrow[i] = '\0';
cbm_work_note_path(CBM_WORK_FOPEN, SITE(), narrow, f == NULL);
}
return f;
}
size_t __wrap_fread(void *buf, size_t size, size_t count, FILE *f) {
size_t got = __real_fread(buf, size, count, f);
note_io(CBM_WORK_FREAD, SITE(), (long long)(got * size), size * count);
ACC_STORE(buf, got * size);
return got;
}
size_t __wrap_fwrite(const void *buf, size_t size, size_t count, FILE *f) {
size_t put = __real_fwrite(buf, size, count, f);
note_io(CBM_WORK_FWRITE, SITE(), (long long)(put * size), size * count);
ACC_LOAD(buf, put * size);
return put;
}
#endif /* platform */
#if defined(__clang__)
#pragma clang attribute pop
#endif
#else
typedef int cbm_mem_override_libc_unused_t;
#endif /* CBM_MEMWASTE */
+68 -16
View File
@@ -32,36 +32,61 @@
* and a difference in the output is a difference in behaviour rather than an
* artifact of two different instrumentation schemes.
*
* Linux only: --wrap is a GNU ld/lld feature. macOS ld has no equivalent, so
* macOS runs census-only and the profiler reports nothing there. That gap is
* stated in the harness spec rather than hidden behind zeros that look like
* "no allocations".
* Linux only: --wrap is a GNU ld/lld feature. macOS ld has no equivalent; there
* the waste layer observes through a forwarding malloc zone instead
* (mem_override_darwin.c), so all three platforms report the same site tables.
*/
#include "foundation/mem_profile.h"
#include <mimalloc.h>
#include <stddef.h>
#include <string.h>
#if defined(__linux__)
/* Waste sanitizer (mem_events.h). The wrapper IS the observation point, so the
* caller's return address is the allocation site. Compiled to nothing outside
* the `memwaste` flavour. Frees are reported BEFORE the block goes back. */
#if defined(CBM_MEMWASTE) && CBM_MEMWASTE
#include "foundation/mem_events.h"
#define OBS_ALLOC_F(block, requested, flags) \
do { \
if ((block) && cbm_memev_enabled()) { \
cbm_memev_alloc_ex((block), (requested), mi_usable_size(block), \
__builtin_return_address(0), (unsigned)(flags)); \
} \
} while (0)
#define OBS_ALLOC(block, requested) OBS_ALLOC_F((block), (requested), 0)
#define OBS_REALLOC(old_block, grown, requested) \
do { \
if ((grown) && cbm_memev_enabled()) { \
cbm_memev_realloc((old_block), (grown), (requested), mi_usable_size(grown), \
__builtin_return_address(0)); \
} \
} while (0)
#define OBS_FREE(block) cbm_memev_free(block)
#else
#define OBS_ALLOC(block, requested) ((void)0)
#define OBS_ALLOC_F(block, requested, flags) ((void)0)
#define OBS_REALLOC(old_block, grown, requested) ((void)0)
#define OBS_FREE(block) ((void)0)
#endif
void *__wrap_malloc(size_t size) {
void *block = mi_malloc(size);
cbm_mem_profile_alloc(block, size);
OBS_ALLOC(block, size);
return block;
}
void *__wrap_calloc(size_t count, size_t size) {
void *block = mi_calloc(count, size);
cbm_mem_profile_alloc(block, count * size);
OBS_ALLOC_F(block, count * size, CBM_MEMEV_ZEROED);
return block;
}
void *__wrap_realloc(void *block, size_t size) {
if (block) {
cbm_mem_profile_free(block);
}
CBM_MEMEV_BACKING(1);
void *grown = mi_realloc(block, size);
cbm_mem_profile_alloc(grown, size);
CBM_MEMEV_BACKING(-1);
OBS_REALLOC(block, grown, size);
return grown;
}
@@ -71,16 +96,43 @@ void __wrap_free(void *block) {
}
/* Unlike Windows there is no foreign-allocator hazard to route around:
* every malloc in this image is already mimalloc's. */
cbm_mem_profile_free(block);
OBS_FREE(block);
mi_free(block);
}
char *__wrap_strdup(const char *text) {
char *copy = mi_strdup(text);
if (copy) {
cbm_mem_profile_alloc(copy, mi_usable_size(copy));
}
OBS_ALLOC_F(copy, copy ? strlen(copy) + 1 : 0, CBM_MEMEV_WRITTEN);
return copy;
}
/* The aligned family and strndup reach mimalloc too; without these a block
* from posix_memalign (the slab allocator's pages) is invisible, and its
* free reads as untracked. */
char *__wrap_strndup(const char *text, size_t length) {
char *copy = mi_strndup(text, length);
OBS_ALLOC_F(copy, copy ? strlen(copy) + 1 : 0, CBM_MEMEV_WRITTEN);
return copy;
}
int __wrap_posix_memalign(void **out, size_t alignment, size_t size) {
int rc = mi_posix_memalign(out, alignment, size);
if (rc == 0 && out) {
OBS_ALLOC(*out, size);
}
return rc;
}
void *__wrap_aligned_alloc(size_t alignment, size_t size) {
void *block = mi_aligned_alloc(alignment, size);
OBS_ALLOC(block, size);
return block;
}
void *__wrap_memalign(size_t alignment, size_t size) {
void *block = mi_memalign(alignment, size);
OBS_ALLOC(block, size);
return block;
}
#endif /* __linux__ */
+55 -6
View File
@@ -42,6 +42,7 @@
* mimalloc is always safe.
*/
#include "foundation/mem.h"
#include "foundation/mem_events.h" /* CBM_MEMEV_BACKING: empty outside the access lane */
#include <mimalloc.h>
#include <stddef.h>
@@ -68,32 +69,73 @@ static inline bool mem_override_is_ours(const void *block) {
return mi_is_in_heap_region(block);
}
/* Waste sanitizer (mem_events.h). The wrapper IS the observation point, so the
* caller's return address is the allocation site. Compiled to nothing outside
* the `memwaste` flavour. Frees are reported BEFORE the block goes back. */
#if defined(CBM_MEMWASTE) && CBM_MEMWASTE
#define OBS_ALLOC_F(block, requested, flags) \
do { \
if ((block) && cbm_memev_enabled()) { \
cbm_memev_alloc_ex((block), (requested), mi_usable_size(block), \
__builtin_return_address(0), (unsigned)(flags)); \
} \
} while (0)
#define OBS_ALLOC(block, requested) OBS_ALLOC_F((block), (requested), 0)
#define OBS_REALLOC(old_block, grown, requested) \
do { \
if ((grown) && cbm_memev_enabled()) { \
cbm_memev_realloc((old_block), (grown), (requested), mi_usable_size(grown), \
__builtin_return_address(0)); \
} \
} while (0)
/* A block mimalloc did not make came from the CRT or a system DLL: the layer
* could never have seen its allocation, so it is not an untracked free. */
#define OBS_FREE(block) \
(mem_override_is_ours(block) ? cbm_memev_free(block) : cbm_memev_free_foreign(block))
#else
#define OBS_ALLOC(block, requested) ((void)0)
#define OBS_ALLOC_F(block, requested, flags) ((void)0)
#define OBS_REALLOC(old_block, grown, requested) ((void)0)
#define OBS_FREE(block) ((void)0)
#endif
void *__wrap_malloc(size_t size) {
return mi_malloc(size);
void *block = mi_malloc(size);
OBS_ALLOC(block, size);
return block;
}
void *__wrap_calloc(size_t count, size_t size) {
return mi_calloc(count, size);
void *block = mi_calloc(count, size);
OBS_ALLOC_F(block, count * size, CBM_MEMEV_ZEROED);
return block;
}
char *__wrap_strdup(const char *text) {
return mi_strdup(text);
char *copy = mi_strdup(text);
OBS_ALLOC_F(copy, copy ? strlen(copy) + 1 : 0, CBM_MEMEV_WRITTEN);
return copy;
}
char *__wrap_strndup(const char *text, size_t length) {
return mi_strndup(text, length);
char *copy = mi_strndup(text, length);
OBS_ALLOC_F(copy, copy ? strlen(copy) + 1 : 0, CBM_MEMEV_WRITTEN);
return copy;
}
void *__wrap__aligned_malloc(size_t size, size_t alignment) {
/* Windows spells aligned allocation _aligned_malloc(size, alignment) —
* note the argument order is the reverse of mi_malloc_aligned's. */
return mi_malloc_aligned(size, alignment);
void *block = mi_malloc_aligned(size, alignment);
OBS_ALLOC(block, size);
return block;
}
void __wrap_free(void *block) {
if (!block) {
return;
}
OBS_FREE(block);
if (mem_override_is_ours(block)) {
mi_free(block);
return;
@@ -108,6 +150,7 @@ void __wrap__aligned_free(void *block) {
if (!block) {
return;
}
OBS_FREE(block);
if (mem_override_is_ours(block)) {
mi_free(block);
return;
@@ -117,10 +160,15 @@ void __wrap__aligned_free(void *block) {
void *__wrap_realloc(void *block, size_t size) {
if (!block) {
return mi_malloc(size);
void *fresh = mi_malloc(size);
OBS_ALLOC(fresh, size);
return fresh;
}
if (mem_override_is_ours(block)) {
CBM_MEMEV_BACKING(1);
void *grown = mi_realloc(block, size);
CBM_MEMEV_BACKING(-1);
OBS_REALLOC(block, grown, size);
return grown;
}
/* A CRT block cannot be handed to mi_realloc, and the CRT's realloc must
@@ -134,6 +182,7 @@ void *__wrap_realloc(void *block, size_t size) {
return NULL; /* original stays valid, as realloc requires */
}
memcpy(moved, block, old_size < size ? old_size : size);
OBS_REALLOC(block, moved, size);
__real_free(block);
return moved;
}
-325
View File
@@ -1,325 +0,0 @@
/*
* mem_profile.c — see mem_profile.h.
*
* Two fixed tables, both open-addressed with linear probing:
* sites[] stack hash -> {frames, live bytes, live blocks, totals}
* pointers[] block address -> {site index, size}
*
* A single mutex guards both. That is heavy for a hot path and entirely
* acceptable here: profiling is opt-in, and a diagnostic that is fast but
* racy would produce numbers nobody can trust — which is the failure mode
* this whole harness exists to correct.
*/
#include "mem_profile.h"
#include "compat_fs.h"
#include "compat_thread.h"
#include "foundation/constants.h"
#include "platform.h"
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#else
#include <execinfo.h>
#endif
enum {
PROFILE_FRAMES = 8, /* stack depth per site */
PROFILE_SKIP_FRAMES = 2, /* this file's own frames */
PROFILE_SITES = 4096, /* distinct call sites */
PROFILE_POINTERS = 262144, /* tracked live blocks */
PROFILE_DEFAULT_MIN = 32768,
};
typedef struct {
uint64_t hash; /* 0 = empty slot */
void *frames[PROFILE_FRAMES];
int frame_count;
size_t live_bytes;
size_t live_blocks;
size_t total_bytes;
size_t total_blocks;
size_t peak_live_bytes;
} profile_site_t;
typedef struct {
void *block; /* NULL = empty slot */
size_t size;
size_t site;
} profile_pointer_t;
/* The profiler runs INSIDE the allocator: every hook here is reached from
* malloc/free. Anything it calls that itself allocates — reading an env var,
* the logger, even a probe — re-enters the hook, which recurses until the stack
* dies (silently: the process produces no stdout, no stderr and no log) and
* would deadlock the non-recursive mutex on the way. This guard makes the
* profiler invisible to itself. */
static _Thread_local bool g_profile_reentrant;
static cbm_mutex_t g_profile_mutex;
static profile_site_t g_sites[PROFILE_SITES];
static profile_pointer_t g_pointers[PROFILE_POINTERS];
static cbm_mem_profile_totals_t g_totals;
static atomic_int g_profile_enabled = -1;
static size_t g_profile_min = PROFILE_DEFAULT_MIN;
size_t cbm_mem_profile_threshold(void) {
(void)cbm_mem_profile_enabled();
return g_profile_min;
}
bool cbm_mem_profile_enabled(void) {
int state = atomic_load_explicit(&g_profile_enabled, memory_order_acquire);
if (state >= 0) {
return state == 1;
}
char buf[CBM_SZ_32];
bool on = cbm_safe_getenv("CBM_MEM_PROFILE", buf, sizeof(buf), NULL) != NULL && buf[0] == '1';
if (on) {
char min_buf[CBM_SZ_32];
if (cbm_safe_getenv("CBM_MEM_PROFILE_MIN", min_buf, sizeof(min_buf), NULL) != NULL) {
long parsed = strtol(min_buf, NULL, 10);
if (parsed > 0) {
g_profile_min = (size_t)parsed;
}
}
cbm_mutex_init(&g_profile_mutex);
}
atomic_store_explicit(&g_profile_enabled, on ? 1 : 0, memory_order_release);
return on;
}
/* FNV-1a over the return addresses: cheap, and collisions merely merge two
* sites rather than corrupting counts. */
static uint64_t profile_hash(void *const *frames, int count) {
uint64_t hash = 1469598103934665603ULL;
for (int i = 0; i < count; i++) {
uintptr_t value = (uintptr_t)frames[i];
for (size_t byte = 0; byte < sizeof(value); byte++) {
hash ^= (value >> (byte * 8)) & 0xFFU;
hash *= 1099511628211ULL;
}
}
return hash == 0 ? 1 : hash; /* 0 marks an empty slot */
}
static int profile_capture(void **frames, void *caller) {
#ifdef _WIN32
int captured = (int)CaptureStackBackTrace(PROFILE_SKIP_FRAMES, PROFILE_FRAMES, frames, NULL);
if (captured > 0) {
return captured;
}
/* Fall back to the caller the hook supplied. Using a return address from
* THIS frame would name the profiler, not the allocating code. */
if (caller) {
frames[0] = caller;
return 1;
}
frames[0] = __builtin_return_address(0);
return frames[0] != NULL ? 1 : 0;
#else
(void)caller;
void *raw[PROFILE_FRAMES + PROFILE_SKIP_FRAMES];
int got = backtrace(raw, PROFILE_FRAMES + PROFILE_SKIP_FRAMES);
int count = got - PROFILE_SKIP_FRAMES;
if (count <= 0) {
return 0;
}
if (count > PROFILE_FRAMES) {
count = PROFILE_FRAMES;
}
memcpy(frames, raw + PROFILE_SKIP_FRAMES, (size_t)count * sizeof(*frames));
return count;
#endif
}
/* Returns SIZE_MAX when the table is full, so the caller can count the loss. */
static size_t profile_intern_site_locked(void **frames, int count) {
uint64_t hash = profile_hash(frames, count);
size_t index = (size_t)(hash % PROFILE_SITES);
for (size_t probe = 0; probe < PROFILE_SITES; probe++) {
profile_site_t *site = &g_sites[index];
if (site->hash == hash) {
return index;
}
if (site->hash == 0) {
site->hash = hash;
site->frame_count = count;
memcpy(site->frames, frames, (size_t)count * sizeof(*frames));
g_totals.sites++;
return index;
}
index = (index + 1) % PROFILE_SITES;
}
return SIZE_MAX;
}
static size_t profile_pointer_slot(const void *block) {
uintptr_t value = (uintptr_t)block;
/* Block addresses are page-aligned at these sizes, so mix the high bits
* down before masking or every entry lands in the same few buckets. */
value ^= value >> 20;
value *= 2654435761U;
return (size_t)(value % PROFILE_POINTERS);
}
void cbm_mem_profile_alloc(void *block, size_t size) {
cbm_mem_profile_alloc_at(block, size, NULL);
}
void cbm_mem_profile_alloc_at(void *block, size_t size, void *caller) {
if (!block || g_profile_reentrant) {
return;
}
g_profile_reentrant = true;
if (!cbm_mem_profile_enabled() || size < g_profile_min) {
g_profile_reentrant = false;
return;
}
void *frames[PROFILE_FRAMES];
int count = profile_capture(frames, caller);
if (count <= 0) {
cbm_mutex_lock(&g_profile_mutex);
g_totals.capture_failed++;
cbm_mutex_unlock(&g_profile_mutex);
g_profile_reentrant = false;
return;
}
cbm_mutex_lock(&g_profile_mutex);
size_t site_index = profile_intern_site_locked(frames, count);
if (site_index == SIZE_MAX) {
g_totals.site_table_full++;
cbm_mutex_unlock(&g_profile_mutex);
g_profile_reentrant = false;
return;
}
size_t slot = profile_pointer_slot(block);
bool stored = false;
for (size_t probe = 0; probe < PROFILE_POINTERS; probe++) {
if (g_pointers[slot].block == NULL) {
g_pointers[slot].block = block;
g_pointers[slot].size = size;
g_pointers[slot].site = site_index;
stored = true;
break;
}
slot = (slot + 1) % PROFILE_POINTERS;
}
if (!stored) {
g_totals.pointer_table_full++;
cbm_mutex_unlock(&g_profile_mutex);
g_profile_reentrant = false;
return;
}
profile_site_t *site = &g_sites[site_index];
site->live_bytes += size;
site->live_blocks++;
site->total_bytes += size;
site->total_blocks++;
if (site->live_bytes > site->peak_live_bytes) {
site->peak_live_bytes = site->live_bytes;
}
g_totals.live_bytes += size;
g_totals.live_blocks++;
g_totals.total_bytes += size;
cbm_mutex_unlock(&g_profile_mutex);
g_profile_reentrant = false;
}
void cbm_mem_profile_free(void *block) {
if (!block || g_profile_reentrant) {
return;
}
g_profile_reentrant = true;
if (!cbm_mem_profile_enabled()) {
g_profile_reentrant = false;
return;
}
cbm_mutex_lock(&g_profile_mutex);
size_t slot = profile_pointer_slot(block);
for (size_t probe = 0; probe < PROFILE_POINTERS; probe++) {
profile_pointer_t *entry = &g_pointers[slot];
if (entry->block == block) {
profile_site_t *site = &g_sites[entry->site];
site->live_bytes -= entry->size;
site->live_blocks--;
g_totals.live_bytes -= entry->size;
g_totals.live_blocks--;
entry->block = NULL;
entry->size = 0;
entry->site = 0;
cbm_mutex_unlock(&g_profile_mutex);
g_profile_reentrant = false;
return;
}
if (entry->block == NULL) {
break; /* linear probe ended: never tracked (below threshold) */
}
slot = (slot + 1) % PROFILE_POINTERS;
}
/* Expected for every sub-threshold allocation, so this is a scale check,
* not an error: it should track the small-allocation rate, not grow with
* retained bytes. */
g_totals.untracked_frees++;
cbm_mutex_unlock(&g_profile_mutex);
g_profile_reentrant = false;
}
void cbm_mem_profile_totals(cbm_mem_profile_totals_t *out) {
if (!out) {
return;
}
memset(out, 0, sizeof(*out));
if (!cbm_mem_profile_enabled()) {
return;
}
cbm_mutex_lock(&g_profile_mutex);
*out = g_totals;
cbm_mutex_unlock(&g_profile_mutex);
}
bool cbm_mem_profile_dump(const char *path, const char *label) {
if (!path || !cbm_mem_profile_enabled()) {
return false;
}
FILE *out = cbm_fopen(path, "ab");
if (!out) {
return false;
}
cbm_mutex_lock(&g_profile_mutex);
(void)fprintf(out,
"{\"label\":\"%s\",\"threshold\":%zu,\"sites\":%zu,\"live_bytes\":%zu,"
"\"live_blocks\":%zu,\"total_bytes\":%zu,\"untracked_frees\":%zu,"
"\"site_table_full\":%zu,\"pointer_table_full\":%zu}\n",
label ? label : "", g_profile_min, g_totals.sites, g_totals.live_bytes,
g_totals.live_blocks, g_totals.total_bytes, g_totals.untracked_frees,
g_totals.site_table_full, g_totals.pointer_table_full);
for (size_t i = 0; i < PROFILE_SITES; i++) {
profile_site_t *site = &g_sites[i];
if (site->hash == 0 || site->total_blocks == 0) {
continue;
}
(void)fprintf(out,
"{\"label\":\"%s\",\"site\":%llu,\"live_bytes\":%zu,\"live_blocks\":%zu,"
"\"total_bytes\":%zu,\"total_blocks\":%zu,\"peak_live\":%zu,\"frames\":[",
label ? label : "", (unsigned long long)site->hash, site->live_bytes,
site->live_blocks, site->total_bytes, site->total_blocks,
site->peak_live_bytes);
for (int f = 0; f < site->frame_count; f++) {
(void)fprintf(out, "%s\"%p\"", f == 0 ? "" : ",", site->frames[f]);
}
(void)fprintf(out, "]}\n");
}
cbm_mutex_unlock(&g_profile_mutex);
(void)fclose(out);
return true;
}
-66
View File
@@ -1,66 +0,0 @@
/*
* mem_profile.h — Which allocation sites retain memory?
*
* The census answers "which POOL holds the memory"; it cannot answer "which
* allocation put it there". Without that, every hypothesis about #581 needed a
* code change plus a soak to test, so the investigation could only ask one
* yes/no question per eight minutes — and two mechanisms that looked confirmed
* were later falsified. This records the missing half.
*
* Only allocations at or above a size threshold are tracked (default 32 KiB).
* That is deliberate: arena fragmentation is driven by large transient runs,
* and tracking every small allocation would cost more than the signal is worth.
* The threshold is reported alongside the data so the blind spot is explicit.
*
* Off unless CBM_MEM_PROFILE=1. All tables are fixed-capacity and allocated
* once; when a table fills, the overflow is COUNTED and reported rather than
* silently dropped, because a profiler that quietly loses records produces
* exactly the false confidence this is meant to end.
*/
#ifndef CBM_MEM_PROFILE_H
#define CBM_MEM_PROFILE_H
#include <stdbool.h>
#include <stddef.h>
/* Gate: CBM_MEM_PROFILE=1. Read once, cached. */
bool cbm_mem_profile_enabled(void);
/* Minimum allocation size recorded, from CBM_MEM_PROFILE_MIN (bytes). */
size_t cbm_mem_profile_threshold(void);
/* Record an allocation / release. Safe to call unconditionally: both return
* immediately when profiling is off or the size is below the threshold. */
void cbm_mem_profile_alloc(void *block, size_t size);
/* Same, but with the caller's return address supplied by the hook itself.
* Required on Windows ARM64, where CaptureStackBackTrace returns nothing: a
* return address captured inside the capture helper names the helper, so every
* allocation collapses onto a handful of hook addresses and the attribution
* column becomes useless. Each hook must evaluate __builtin_return_address(0)
* in its OWN frame and pass it here. */
void cbm_mem_profile_alloc_at(void *block, size_t size, void *caller);
void cbm_mem_profile_free(void *block);
typedef struct {
size_t sites; /* distinct call sites seen */
size_t live_bytes; /* currently retained, attributed */
size_t live_blocks;
size_t total_bytes; /* cumulative allocated at/above threshold */
size_t untracked_frees; /* frees whose pointer was not in the table */
size_t site_table_full; /* records lost: site table exhausted */
size_t pointer_table_full; /* records lost: pointer table exhausted */
/* Stack capture returned nothing. On Windows ARM64 CaptureStackBackTrace
* can legitimately fail, and dropping those samples silently makes a
* working profiler look like an empty one. */
size_t capture_failed;
} cbm_mem_profile_totals_t;
void cbm_mem_profile_totals(cbm_mem_profile_totals_t *out);
/* Append one JSON object per site to `path`, biggest live first, with the raw
* return addresses of each site's stack. Symbolisation is deliberately left to
* the offline analyser so the hot path never loads a symbol handler. */
bool cbm_mem_profile_dump(const char *path, const char *label);
#endif /* CBM_MEM_PROFILE_H */
+8
View File
@@ -71,6 +71,7 @@ enum {
#include "foundation/compat_fs.h"
#include "foundation/compat_thread.h"
#include "foundation/mem.h"
#include "foundation/mem_events.h"
#include "foundation/profile.h"
#include "foundation/sha256.h"
#include "foundation/secure_random.h"
@@ -858,6 +859,7 @@ static int run_cli(int argc, char **argv, cbm_project_lock_manager_t *project_lo
/* The graph lives on this process's heaps: SQLite gets heaps of its
* own here, and only here (cbm_sqlite_dedicated_heap). */
cbm_sqlite_dedicated_heap(true);
cbm_memev_process_role("worker");
}
cbm_index_set_worker_role_options(index_worker, response_out, worker_single_thread,
worker_marker, worker_quarantine,
@@ -1024,6 +1026,12 @@ static int run_cli(int argc, char **argv, cbm_project_lock_manager_t *project_lo
/* Supervised worker: hand the full result string to the parent via the
* response file before printing (parent reads it back on a clean exit). */
const char *ro = cbm_index_worker_response_out();
if (cbm_index_worker_active()) {
/* Waste dump + execution counts BEFORE the response: the parent may
* end the worker as soon as the response file is complete, and the
* fast exit below skips every at-exit writer anyway. */
cbm_memev_process_exit();
}
bool worker_response_written = false;
if (ro) {
FILE *rf = cbm_fopen(ro, "wb");
+510
View File
@@ -0,0 +1,510 @@
/*
* test_mem_events.c — the allocation-event layer of the waste sanitizer.
*
* The layer never dereferences a block, so these tests feed it synthetic
* addresses: every assertion is about ACCOUNTING, and the verdicts are pure
* functions of the event sequence (O9) — no allocator, no clock, no timing.
*/
#include "test_framework.h"
#include "../src/foundation/compat.h"
#include "../src/foundation/compat_fs.h"
#include "../src/foundation/compat_thread.h"
#include "../src/foundation/mem_events.h"
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#ifndef _WIN32
#include <unistd.h>
#else
#include <process.h>
#define getpid _getpid
#endif
#define BLK(n) ((void *)(uintptr_t)(0x100000u + ((uintptr_t)(n) << 4)))
#define SITE_A ((void *)(uintptr_t)0xA000)
#define SITE_B ((void *)(uintptr_t)0xB000)
#define SITE_C ((void *)(uintptr_t)0xC000)
static void fresh(void) {
cbm_memev_force_for_tests(true);
cbm_memev_reset_for_tests();
}
static bool site_row(void *site, cbm_memev_site_t *out) {
static cbm_memev_site_t rows[64];
size_t n = cbm_memev_sites(rows, 64);
for (size_t i = 0; i < n; i++) {
if (rows[i].site == (uintptr_t)site) {
*out = rows[i];
return true;
}
}
return false;
}
/* Requested vs usable is the allocator's slack, and it belongs to the site
* that asked: 3 x (100 requested, 112 handed out) = 36 bytes nobody can use. */
TEST(memev_slack_is_charged_to_the_asking_site) {
fresh();
for (int i = 0; i < 3; i++) {
cbm_memev_alloc(BLK(i), 100, 112, SITE_A);
}
cbm_memev_alloc(BLK(9), 64, 64, SITE_B);
cbm_memev_site_t a;
ASSERT_TRUE(site_row(SITE_A, &a));
ASSERT_EQ(a.allocs, 3);
ASSERT_EQ(a.requested_bytes, 300);
ASSERT_EQ(a.usable_bytes, 336);
ASSERT_EQ(a.live_bytes, 336);
ASSERT_EQ(a.live_blocks, 3);
cbm_memev_free(BLK(1));
ASSERT_TRUE(site_row(SITE_A, &a));
ASSERT_EQ(a.frees, 1);
ASSERT_EQ(a.live_bytes, 224);
ASSERT_EQ(a.live_blocks, 2);
cbm_memev_totals_t t;
cbm_memev_totals(&t);
ASSERT_EQ(t.allocs, 4);
ASSERT_EQ(t.sites, 2);
ASSERT_EQ(t.live_bytes, 288);
ASSERT_EQ(t.untracked_frees, 0);
PASS();
}
/* A block freed by its own thread within a few allocations is churn: an
* arena or a stack buffer would have served. One that outlives the window
* is not. */
TEST(memev_short_lived_blocks_are_counted_as_churn) {
fresh();
for (int i = 0; i < 10; i++) {
cbm_memev_alloc(BLK(i), 32, 32, SITE_A);
cbm_memev_free(BLK(i));
}
cbm_memev_alloc(BLK(100), 32, 32, SITE_B); /* lives across the window */
for (int i = 0; i < CBM_MEMEV_CHURN_WINDOW + 8; i++) {
cbm_memev_alloc(BLK(200 + i), 8, 8, SITE_C);
}
cbm_memev_free(BLK(100));
cbm_memev_site_t a;
cbm_memev_site_t b;
ASSERT_TRUE(site_row(SITE_A, &a));
ASSERT_TRUE(site_row(SITE_B, &b));
ASSERT_EQ(a.short_lived, 10);
ASSERT_EQ(b.frees, 1);
ASSERT_EQ(b.short_lived, 0);
PASS();
}
/* A realloc chain is ONE object growing. The bytes the allocator had to move
* are that object's cost, charged to the site that created it — not to the
* realloc call. Growth in place moves nothing. */
TEST(memev_realloc_copy_bytes_go_to_the_original_site) {
fresh();
cbm_memev_alloc(BLK(1), 64, 64, SITE_A);
cbm_memev_realloc(BLK(1), BLK(2), 128, 128, SITE_B); /* moved: 64 bytes copied */
cbm_memev_realloc(BLK(2), BLK(2), 200, 256, SITE_B); /* in place: nothing copied */
cbm_memev_realloc(BLK(2), BLK(3), 512, 512, SITE_B); /* moved: 256 bytes copied */
cbm_memev_site_t a;
cbm_memev_site_t b;
ASSERT_TRUE(site_row(SITE_A, &a));
ASSERT_FALSE(site_row(SITE_B, &b)); /* the realloc call site owns nothing */
ASSERT_EQ(a.reallocs, 3);
ASSERT_EQ(a.realloc_copy_bytes, 64 + 256);
ASSERT_EQ(a.live_blocks, 1);
ASSERT_EQ(a.live_bytes, 512);
cbm_memev_free(BLK(3));
ASSERT_TRUE(site_row(SITE_A, &a));
ASSERT_EQ(a.live_bytes, 0);
cbm_memev_totals_t t;
cbm_memev_totals(&t);
ASSERT_EQ(t.untracked_frees, 0);
PASS();
}
/* The memory core announces itself before calling its backing allocator. An
* observer underneath consumes the hint: one event, the CORE's caller as the
* site, the class attached. Without an observer the hint stays pending and the
* core emits the event itself. Either way the block is counted once. */
TEST(memev_hint_gives_one_event_with_the_cores_site_and_class) {
fresh();
/* observed path: hint, then the observer's event (which passes its own frame) */
cbm_memev_hint(SITE_A, 5);
ASSERT_TRUE(cbm_memev_hint_pending());
cbm_memev_alloc(BLK(1), 40, 48, SITE_C);
ASSERT_FALSE(cbm_memev_hint_pending());
/* unobserved path: nobody consumed it, the core emits */
cbm_memev_hint(SITE_A, 5);
if (cbm_memev_hint_pending()) {
cbm_memev_alloc(BLK(2), 40, 48, NULL);
}
/* an allocation that never touched the core */
cbm_memev_alloc(BLK(3), 40, 48, SITE_B);
cbm_memev_site_t a;
cbm_memev_site_t b;
cbm_memev_site_t c;
ASSERT_TRUE(site_row(SITE_A, &a));
ASSERT_TRUE(site_row(SITE_B, &b));
ASSERT_FALSE(site_row(SITE_C, &c)); /* the observer's frame never becomes a site */
ASSERT_EQ(a.allocs, 2);
ASSERT_EQ(a.raw_bytes, 0); /* tagged by the core */
ASSERT_EQ(b.raw_bytes, 48); /* the unmigrated surface, as a number */
cbm_memev_totals_t t;
cbm_memev_totals(&t);
ASSERT_EQ(t.raw_bytes, 48);
/* frees carry no handshake: whoever is responsible reports BEFORE the block goes back */
cbm_memev_free(BLK(1));
cbm_memev_free(BLK(2));
cbm_memev_totals(&t);
ASSERT_EQ(t.frees, 2);
ASSERT_EQ(t.untracked_frees, 0);
PASS();
}
/* A free of a block the layer never saw is a NUMBER, not a silent no-op: it
* measures how much traffic predates or bypasses the observers. */
TEST(memev_untracked_free_is_counted_not_dropped) {
fresh();
cbm_memev_free(BLK(77));
cbm_memev_free(NULL);
cbm_memev_totals_t t;
cbm_memev_totals(&t);
ASSERT_EQ(t.untracked_frees, 1);
ASSERT_EQ(t.frees, 0);
PASS();
}
/* A block another allocator made (the Windows C runtime, a system DLL) is not a
* block the layer missed: it is counted apart, so the soundness gate that reads
* untracked frees sees only real misses (the Windows daemon's 14 CRT blocks
* tripped it, 2026-09-17). */
TEST(memev_foreign_free_is_counted_apart_from_untracked) {
fresh();
cbm_memev_free_foreign(BLK(78));
cbm_memev_free_foreign(NULL);
cbm_memev_totals_t t;
cbm_memev_totals(&t);
ASSERT_EQ(t.foreign_frees, 1);
ASSERT_EQ(t.untracked_frees, 0);
ASSERT_EQ(t.frees, 0);
PASS();
}
typedef struct {
int first;
int count;
void *site;
} span_t;
static void *alloc_span(void *arg) {
const span_t *s = (const span_t *)arg;
for (int i = 0; i < s->count; i++) {
cbm_memev_alloc(BLK(s->first + i), 48, 64, s->site);
}
cbm_memev_flush_thread();
return NULL;
}
static void *free_span(void *arg) {
const span_t *s = (const span_t *)arg;
for (int i = 0; i < s->count; i++) {
cbm_memev_free(BLK(s->first + i));
}
cbm_memev_flush_thread();
return NULL;
}
enum { MEMEV_T = 4, MEMEV_PER_T = 50000 };
/* Blocks are routinely freed by a thread that did not allocate them, and the
* per-site totals are SUMS — so they cannot depend on which thread did what or
* in which order the threads ran. 200,000 live blocks also walks every shard
* through several growth steps. */
TEST(memev_cross_thread_frees_balance_and_totals_are_order_independent) {
fresh();
cbm_thread_t th[MEMEV_T];
span_t spans[MEMEV_T];
for (int i = 0; i < MEMEV_T; i++) {
spans[i] = (span_t){
.first = i * MEMEV_PER_T, .count = MEMEV_PER_T, .site = (i % 2) ? SITE_A : SITE_B};
ASSERT_EQ(cbm_thread_create(&th[i], 0, alloc_span, &spans[i]), 0);
}
for (int i = 0; i < MEMEV_T; i++) {
cbm_thread_join(&th[i]);
}
cbm_memev_totals_t t;
cbm_memev_totals(&t);
ASSERT_EQ(t.allocs, (uint64_t)MEMEV_T * MEMEV_PER_T);
ASSERT_EQ(t.live_blocks, (uint64_t)MEMEV_T * MEMEV_PER_T);
ASSERT_EQ(t.live_bytes, (uint64_t)MEMEV_T * MEMEV_PER_T * 64);
ASSERT_EQ(t.pointer_table_full, 0);
/* every span is freed by a DIFFERENT thread than the one that allocated it */
span_t rotated[MEMEV_T];
for (int i = 0; i < MEMEV_T; i++) {
rotated[i] = spans[(i + 1) % MEMEV_T];
ASSERT_EQ(cbm_thread_create(&th[i], 0, free_span, &rotated[i]), 0);
}
for (int i = 0; i < MEMEV_T; i++) {
cbm_thread_join(&th[i]);
}
cbm_memev_totals(&t);
ASSERT_EQ(t.frees, (uint64_t)MEMEV_T * MEMEV_PER_T);
ASSERT_EQ(t.live_blocks, 0);
ASSERT_EQ(t.live_bytes, 0);
ASSERT_EQ(t.untracked_frees, 0);
cbm_memev_site_t a;
cbm_memev_site_t b;
ASSERT_TRUE(site_row(SITE_A, &a));
ASSERT_TRUE(site_row(SITE_B, &b));
ASSERT_EQ(a.allocs, (uint64_t)(MEMEV_T / 2) * MEMEV_PER_T);
ASSERT_EQ(b.usable_bytes, (uint64_t)(MEMEV_T / 2) * MEMEV_PER_T * 64);
ASSERT_EQ(a.short_lived, 0); /* freed by another thread: never churn */
PASS();
}
/* Dormant means dormant: with the layer off nothing is recorded and a hint
* never lingers to mis-attribute a later event. */
TEST(memev_dormant_layer_records_nothing) {
fresh();
cbm_memev_force_for_tests(false);
cbm_memev_hint(SITE_A, 3);
cbm_memev_alloc(BLK(1), 10, 16, SITE_A);
ASSERT_FALSE(cbm_memev_hint_pending());
cbm_memev_free(BLK(1));
cbm_memev_force_for_tests(true);
cbm_memev_totals_t t;
cbm_memev_totals(&t);
ASSERT_EQ(t.allocs, 0);
ASSERT_EQ(t.untracked_frees, 0);
ASSERT_EQ(t.sites, 0);
PASS();
}
/* The report is JSON lines: one header, then one line per site and one per
* work row. Ordering is the report tool's job; the dump only has to be
* complete. */
TEST(memev_dump_writes_header_sites_and_work) {
fresh();
cbm_memev_alloc(BLK(1), 10, 16, SITE_A);
cbm_memev_alloc(BLK(2), 900, 1024, SITE_B);
cbm_work_note(CBM_WORK_MEMCPY, SITE_C, 4096, 0, 0);
char path[512];
snprintf(path, sizeof(path), "%s/cbm_memev_dump_%d.jsonl", cbm_tmpdir(), (int)getpid());
(void)remove(path);
ASSERT_TRUE(cbm_memev_dump(path, "test"));
FILE *f = cbm_fopen(path, "r");
ASSERT_NOT_NULL(f);
char line[4096];
int n = 0;
bool header = false;
bool site_a = false;
bool site_b = false;
bool work = false;
while (fgets(line, sizeof(line), f)) {
if (n++ == 0) {
header = strstr(line, "\"memwaste\":2") && strstr(line, "\"why\":\"test\"") &&
strstr(line, "\"allocs\":2");
}
site_a = site_a || strstr(line, "\"site\":\"0xa000\",\"allocs\":1");
site_b = site_b || (strstr(line, "\"site\":\"0xb000\"") && strstr(line, "\"usable_bytes\":1024"));
work = work || (strstr(line, "\"work\":\"memcpy\"") && strstr(line, "\"site\":\"0xc000\"") &&
strstr(line, "\"bytes\":4096") && strstr(line, "\"peak\":4096"));
}
fclose(f);
(void)remove(path);
ASSERT_TRUE(header);
ASSERT_TRUE(site_a);
ASSERT_TRUE(site_b);
ASSERT_TRUE(work);
ASSERT_EQ(n, 4);
PASS();
}
static bool work_row(cbm_work_kind_t kind, void *site, cbm_work_row_t *out) {
static cbm_work_row_t rows[128];
size_t n = cbm_work_rows(rows, 128);
for (size_t i = 0; i < n; i++) {
if (rows[i].kind == kind && rows[i].site == (uintptr_t)site) {
*out = rows[i];
return true;
}
}
return false;
}
/* The fill pattern is how the layer sees memory nobody wrote. A 4 KB block
* that got 100 bytes: 3,996 bytes never written, and of the 1,000 requested
* 900 lie beyond the last write. A hole in the middle is never-written memory
* too, but it is not over-requested: the bytes after it were used. */
TEST(memev_fill_scan_measures_never_written_and_over_requested) {
fresh();
cbm_memev_scan_for_tests(true, false);
static uint8_t big[4096];
static uint8_t holed[128];
cbm_memev_alloc(big, 1000, sizeof(big), SITE_A);
ASSERT_EQ(big[4095], 0xA5); /* the layer filled it */
memset(big, 1, 100);
cbm_memev_free(big);
cbm_memev_alloc(holed, 80, sizeof(holed), SITE_B);
memset(holed, 2, 16); /* [0, 16) written */
memset(holed + 64, 3, 16); /* [64, 80) written: [16, 64) is a 48-byte hole */
cbm_memev_free(holed);
cbm_memev_site_t a;
cbm_memev_site_t b;
ASSERT_TRUE(site_row(SITE_A, &a));
ASSERT_TRUE(site_row(SITE_B, &b));
ASSERT_EQ(a.scanned_blocks, 1);
ASSERT_EQ(a.never_written_bytes, 3996);
ASSERT_EQ(a.over_requested_bytes, 900);
ASSERT_EQ(b.never_written_bytes, 48 + 48); /* the hole + the unwritten tail */
ASSERT_EQ(b.over_requested_bytes, 0);
PASS();
}
/* A calloc'd block is not filled -- its zeros are the contract. Its zero tail
* at free is an UPPER bound on untouched memory (a written zero looks the same). */
TEST(memev_calloc_tail_is_scanned_for_zeros) {
fresh();
cbm_memev_scan_for_tests(true, false);
static uint8_t block[256];
memset(block, 0, sizeof(block)); /* what calloc hands out */
cbm_memev_alloc_ex(block, 256, sizeof(block), SITE_A, CBM_MEMEV_ZEROED);
ASSERT_EQ(block[200], 0); /* never filled */
memset(block, 7, 32);
cbm_memev_free(block);
cbm_memev_site_t a;
ASSERT_TRUE(site_row(SITE_A, &a));
ASSERT_EQ(a.zero_untouched_bytes, 224);
ASSERT_EQ(a.never_written_bytes, 0);
PASS();
}
/* A phase boundary is a snapshot: what each site still holds, and which small
* live blocks carry the same bytes as another -- memory an interning table or
* a shared constant would have served once. */
TEST(memev_phase_snapshot_finds_retained_memory_and_duplicates) {
fresh();
cbm_memev_scan_for_tests(true, true);
static uint8_t blocks[4][64];
for (int i = 0; i < 3; i++) {
cbm_memev_alloc(blocks[i], 16, sizeof(blocks[i]), SITE_A);
memcpy(blocks[i], "same bytes here!", 16);
}
cbm_memev_alloc(blocks[3], 16, sizeof(blocks[3]), SITE_B);
memcpy(blocks[3], "different bytes!", 16);
cbm_memev_phase("snapshot");
cbm_memev_site_t a;
cbm_memev_site_t b;
ASSERT_TRUE(site_row(SITE_A, &a));
ASSERT_TRUE(site_row(SITE_B, &b));
ASSERT_EQ(a.retained_bytes, 3 * 64);
ASSERT_EQ(a.dup_blocks, 2); /* the first copy is the original */
ASSERT_EQ(a.dup_bytes, 2 * 64);
ASSERT_EQ(b.retained_bytes, 64);
ASSERT_EQ(b.dup_blocks, 0);
for (int i = 0; i < 4; i++) {
cbm_memev_free(blocks[i]);
}
PASS();
}
/* Work rows sum per (kind, site); `peak` keeps the largest single note. For a
* container that is the biggest capacity one instance left unused -- the sums
* run over USES, the peak is the memory figure. */
TEST(memev_work_rows_sum_and_keep_the_peak) {
fresh();
cbm_work_note(CBM_WORK_MEMCPY, SITE_A, 100, 0, 0);
cbm_work_note(CBM_WORK_MEMCPY, SITE_A, 5000, 0, 0);
cbm_memev_container(CBM_WORK_CT_ARENA, SITE_B, 1000, 100, 2);
cbm_memev_container(CBM_WORK_CT_ARENA, SITE_B, 500, 450, 0);
cbm_work_row_t w;
ASSERT_TRUE(work_row(CBM_WORK_MEMCPY, SITE_A, &w));
ASSERT_EQ(w.calls, 2);
ASSERT_EQ(w.bytes, 5100);
ASSERT_EQ(w.peak, 5000);
ASSERT_TRUE(work_row(CBM_WORK_CT_ARENA, SITE_B, &w));
ASSERT_EQ(w.calls, 2);
ASSERT_EQ(w.bytes, 1500);
ASSERT_EQ(w.aux1, 550);
ASSERT_EQ(w.aux2, 2);
ASSERT_EQ(w.peak, 900);
PASS();
}
/* A repeat is work whose answer could not have changed. strlen of the same
* buffer holding a DIFFERENT string of the same length is new work; a lookup
* of the same key text repeats even from another buffer, unless the table was
* written in between. */
TEST(memev_repeats_require_unchanged_input) {
fresh();
char buf[16];
memcpy(buf, "alpha", 6);
cbm_work_note_strlen(SITE_A, buf, 5);
cbm_work_note_strlen(SITE_A, buf, 5); /* repeat */
memcpy(buf, "omega", 6);
cbm_work_note_strlen(SITE_A, buf, 5); /* same pointer, same length, new text */
cbm_work_row_t w;
ASSERT_TRUE(work_row(CBM_WORK_STRLEN, SITE_A, &w));
ASSERT_EQ(w.calls, 3);
ASSERT_EQ(w.repeats, 1);
static const int table = 0;
char k1[8];
char k2[8];
memcpy(k1, "key", 4);
memcpy(k2, "key", 4);
cbm_work_note_ht(CBM_WORK_HT_GET, SITE_B, &table, k1, 0, true);
cbm_work_note_ht(CBM_WORK_HT_GET, SITE_B, &table, k2, 0, true); /* same text: repeat */
cbm_work_note_ht(CBM_WORK_HT_GET, SITE_B, &table, k2, 1, true); /* table written since */
cbm_work_note_ht(CBM_WORK_HT_GET, SITE_B, &table, "other", 1, false);
ASSERT_TRUE(work_row(CBM_WORK_HT_GET, SITE_B, &w));
ASSERT_EQ(w.calls, 4);
ASSERT_EQ(w.repeats, 1);
ASSERT_EQ(w.aux1, 1); /* one miss */
cbm_work_note_path(CBM_WORK_STAT, SITE_A, "/tmp/cbm-memev-path", false);
cbm_work_note_path(CBM_WORK_STAT, SITE_C, "/tmp/cbm-memev-path", false); /* process-wide */
cbm_work_note_path(CBM_WORK_OPEN, SITE_C, "/tmp/cbm-memev-path", true); /* other kind */
ASSERT_TRUE(work_row(CBM_WORK_STAT, SITE_C, &w));
ASSERT_EQ(w.repeats, 1);
ASSERT_TRUE(work_row(CBM_WORK_OPEN, SITE_C, &w));
ASSERT_EQ(w.repeats, 0);
ASSERT_EQ(w.aux1, 1); /* a failure */
PASS();
}
/* Pools compare their workers by the events each produced. */
TEST(memev_thread_ops_count_this_threads_events) {
fresh();
uint64_t before = cbm_memev_thread_ops();
cbm_memev_alloc(BLK(1), 8, 8, SITE_A);
cbm_memev_alloc(BLK(2), 8, 8, SITE_A);
cbm_work_note(CBM_WORK_MEMSET, SITE_B, 8, 0, 0);
ASSERT_EQ(cbm_memev_thread_ops() - before, 3);
cbm_memev_free(BLK(1));
cbm_memev_free(BLK(2));
PASS();
}
SUITE(mem_events) {
RUN_TEST(memev_slack_is_charged_to_the_asking_site);
RUN_TEST(memev_short_lived_blocks_are_counted_as_churn);
RUN_TEST(memev_realloc_copy_bytes_go_to_the_original_site);
RUN_TEST(memev_hint_gives_one_event_with_the_cores_site_and_class);
RUN_TEST(memev_untracked_free_is_counted_not_dropped);
RUN_TEST(memev_foreign_free_is_counted_apart_from_untracked);
RUN_TEST(memev_cross_thread_frees_balance_and_totals_are_order_independent);
RUN_TEST(memev_dormant_layer_records_nothing);
RUN_TEST(memev_dump_writes_header_sites_and_work);
RUN_TEST(memev_fill_scan_measures_never_written_and_over_requested);
RUN_TEST(memev_calloc_tail_is_scanned_for_zeros);
RUN_TEST(memev_phase_snapshot_finds_retained_memory_and_duplicates);
RUN_TEST(memev_work_rows_sum_and_keep_the_peak);
RUN_TEST(memev_repeats_require_unchanged_input);
RUN_TEST(memev_thread_ops_count_this_threads_events);
}