commit 8b439b84471d69bd6021942966a2f3049b0566aa
parent c62703fb3078209bc4464af2d062c2623810fd48
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Mon, 15 Jun 2026 18:00:45 -0700
driver: opt-in heap allocation counters via KIT_METRICS=1
Wire heap_libc_{alloc,realloc,free} to a KitProfiler whose counters occupy the
embedder external-id range, leaving kit-core's counter enum untouched. When
KIT_METRICS is unset, the path is a single NULL check with zero overhead. At
exit, prints:
kit heap metrics:
heap.allocs=… heap.alloc_kib=… heap.large_allocs=…
heap.reallocs=… heap.realloc_moves=… heap.frees=…
Large allocs are those >=32 KiB (~one default arena block). The KitProfiler is
allocated via raw calloc so it does not count itself and there is no reentrancy
through the vtable. Document the recipe in PERF.md §6 and update §5 item 5 to
reflect the arena churn work landed.
Diffstat:
2 files changed, 127 insertions(+), 11 deletions(-)
diff --git a/doc/plan/PERF.md b/doc/plan/PERF.md
@@ -209,8 +209,26 @@ metadata ~9 % (down from ~15 %), libc allocation/memory helpers ~10 %, lazy-loc
a top-tier frontier — the remaining leaves (`abi_cg_type_info` register-struct
returns, `api_type_pred_bits`, `cg_type_get`) are already at/under ~2 % and were
explicitly *not* micro-optimized. The next frontiers are the scanner (`lex_next`
-~15 %) and the hosted heap (`malloc` 6 %). Trust callgrind for *where*; trust
-macOS instructions for *how much*.
+~15 %) and the hosted heap (`malloc` 6 %, partly addressed — see below). Trust
+callgrind for *where*; trust macOS instructions for *how much*.
+
+**Allocator (hosted heap).** `malloc` is ~6 % of `Ir` but only **~4,278 calls**
+for the whole sqlite TU (`KIT_METRICS=1` heap counters): the cost is in large,
+churned blocks, **not** call frequency. Most allocation never reaches the heap —
+arenas bump-allocate, and the variable-count structures (interner table/entries,
+vectors, segvecs) already grow geometrically (**351 reallocs total**, all grows).
+The one gap was **fixed-size 64 KiB arena blocks**. The arena now (a) grows
+blocks **geometrically** (64 KiB doubling to a 1 MiB cap, so a large arena needs
+O(log n) heap calls not O(n)) and (b) `arena_reset` **retains the high-water
+blocks** — it rewinds the bump cursor and frees nothing; only `arena_fini`
+returns memory, so reset/refill cycles (per-statement fold, per-function MC,
+per-expansion pp scratch) reuse their blocks with zero heap traffic. Effect on
+the sqlite compile (`KIT_METRICS`): large (≥32 KiB) block allocs **440 → 124
+(−72 %)**, total allocs 4,278 → 3,961, frees 4,571 → 4,254; **macOS instructions
+−0.22 %, byte-identical** (isolated golden-vs-candidate). Small on the
+instruction metric (Apple malloc is cheap) but it corrects the reset-vs-free
+semantics and cuts heap pressure/fragmentation. Heap counters reuse the
+`KitProfiler` machinery (embedder counter range), opt-in via `KIT_METRICS=1`.
### Code size is still locally spill-bound
@@ -302,6 +320,15 @@ makes `--inclusive=yes` double-count to absurd numbers). The Linux/ELF total is
not comparable to the macOS/Mach-O hardware figure (different libc, sysroot,
format; counts glibc + loader + the `-lc` probe) — the *distribution* is the point.
+**Heap allocation counts** — `KIT_METRICS=1` prints the hosted-heap counters
+(allocs / large allocs ≥32 KiB / reallocs / frees) at exit; the counts are
+host-independent (kit issues the same `h->alloc` calls everywhere):
+
+```sh
+KIT_METRICS=1 build/release/kit cc -c sqlite3.c --sysroot "$SDK" -o /tmp/k.o
+# -> kit heap metrics: heap.allocs=… heap.large_allocs=… heap.reallocs=… heap.frees=…
+```
+
**Code size** (`.text` machine code, the honest metric):
```sh
@@ -416,11 +443,14 @@ as a small follow-on); #5 ○ open.
sentinel array (`94e642e3`). *Open (Cut C):* the dead paste file-id
registration could become a bare `nfiles++` after auditing no diagnostic ever
queries a paste file-id.
-5. ○ **[OPEN]** **`memset` / arena churn (~2–3 %).** Right-size per-expression / per-emit struct
- zeroing (designated-init the per-op clears); audit per-statement/per-temp arena
- allocation vs reuse. (`memset` here is explicit zero-init, **not**
- `-ftrivial-auto-var-init` — proven by rebuilding with the flag off; attack call
- sites, keep the hardening flag.)
+5. ◑ **[arena churn LANDED; memset OPEN]** **`memset` / arena churn (~2–3 %).**
+ Arena churn done: blocks now grow geometrically and `arena_reset` retains the
+ high-water capacity instead of freeing all-but-head (large block allocs
+ **−72 %**, byte-identical) — see §2 *Allocator*. *Still open:* right-size
+ per-expression / per-emit struct zeroing (designated-init the per-op clears).
+ (`memset` here is explicit zero-init, **not** `-ftrivial-auto-var-init` —
+ proven by rebuilding with the flag off; attack call sites, keep the hardening
+ flag.)
Near-dead-ends for *instructions* (revisit only under a cache-stall study, not
expected to pay): the `pool_intern_slice` probe/insert side (the self-sufficient
diff --git a/driver/env/common.c b/driver/env/common.c
@@ -2,6 +2,7 @@
* stderr diag sink, stdout/fd writers, and the small printf/errf/alloc
* helpers that route through stdio + malloc. Compiled on every host. */
+#include <kit/profile.h>
#include <stdarg.h>
#include <stdint.h>
#include <stdio.h>
@@ -12,23 +13,108 @@
/* ---------------- heap (libc-backed) ---------------- */
+/* Heap allocator metrics, reusing the KitProfiler counter machinery. The heap
+ * is the process-wide allocation chokepoint (arenas and pools all bottom out
+ * here), so it counts into the profiler pointed to by KitHeap.user — driver_env
+ * wires that to a profiler when KIT_METRICS is set, leaving it NULL (one
+ * branch, no work) otherwise. Counters live in the embedder-owned external id
+ * range so the kit-core counter enum stays untouched; names are attached once
+ * via kit_profiler_define_counter and read back by the generic counter dump.
+ * The ~32 KiB threshold approximates an arena block (default block is 64 KiB),
+ * the dominant large-allocation source. */
+enum {
+ HEAP_C_ALLOCS = KIT_PROFILE_COUNTER_EXTERNAL_FIRST,
+ HEAP_C_ALLOC_KIB,
+ HEAP_C_LARGE_ALLOCS, /* >= 32 KiB, ~arena blocks */
+ HEAP_C_REALLOCS, /* grows (the only realloc kit issues) */
+ HEAP_C_REALLOC_MOVES,
+ HEAP_C_FREES,
+};
+
+static void driver_heap_metrics_define(KitProfiler* pr) {
+ if (!pr) return;
+ kit_profiler_define_counter(pr, (KitProfileCounter)HEAP_C_ALLOCS,
+ "heap.allocs");
+ kit_profiler_define_counter(pr, (KitProfileCounter)HEAP_C_ALLOC_KIB,
+ "heap.alloc_kib");
+ kit_profiler_define_counter(pr, (KitProfileCounter)HEAP_C_LARGE_ALLOCS,
+ "heap.large_allocs");
+ kit_profiler_define_counter(pr, (KitProfileCounter)HEAP_C_REALLOCS,
+ "heap.reallocs");
+ kit_profiler_define_counter(pr, (KitProfileCounter)HEAP_C_REALLOC_MOVES,
+ "heap.realloc_moves");
+ kit_profiler_define_counter(pr, (KitProfileCounter)HEAP_C_FREES,
+ "heap.frees");
+}
+
+/* Process-wide heap metrics, opt-in via KIT_METRICS. The heap is global, so its
+ * stats are too; this stays NULL/inert unless asked. When an external profiler
+ * is already attached (e.g. a future `kit run --metrics` pointing KitHeap.user
+ * at its per-run profiler), we leave it alone and merge into that instead. */
+static KitProfiler* g_heap_metrics_prof;
+static int g_heap_metrics_inited;
+
+static void driver_heap_metrics_dump(void) {
+ KitProfiler* pr = g_heap_metrics_prof;
+ uint32_t id;
+ if (!pr) return;
+ fprintf(stderr, "kit heap metrics:\n");
+ for (id = KIT_PROFILE_COUNTER_EXTERNAL_FIRST; id <= (uint32_t)HEAP_C_FREES;
+ ++id) {
+ const char* name = kit_profiler_counter_name(pr, (KitProfileCounter)id);
+ fprintf(stderr, " %s=%llu\n", name ? name : "heap.?",
+ (unsigned long long)kit_profiler_counter_value(
+ pr, (KitProfileCounter)id));
+ }
+}
+
+static void driver_heap_metrics_maybe_begin(KitHeap* h) {
+ const char* e;
+ if (g_heap_metrics_inited) return;
+ g_heap_metrics_inited = 1;
+ if (!h || h->user) return; /* already wired to an external profiler */
+ e = getenv("KIT_METRICS");
+ if (!(e && e[0] && e[0] != '0')) return;
+ /* Raw libc alloc (not through the vtable) so the profiler storage itself is
+ * not counted and there is no reentrancy. */
+ g_heap_metrics_prof = (KitProfiler*)calloc(1, sizeof(*g_heap_metrics_prof));
+ if (!g_heap_metrics_prof) return;
+ driver_heap_metrics_define(g_heap_metrics_prof);
+ h->user = g_heap_metrics_prof;
+ atexit(driver_heap_metrics_dump);
+}
+
static void* heap_libc_alloc(KitHeap* h, size_t size, size_t align) {
- (void)h;
+ KitProfiler* pr;
(void)align; /* malloc satisfies all max_align_t alignments */
+ if (!g_heap_metrics_inited) driver_heap_metrics_maybe_begin(h);
+ pr = h ? (KitProfiler*)h->user : NULL;
+ if (pr && size) {
+ kit_profiler_count(pr, (KitProfileCounter)HEAP_C_ALLOCS, 1);
+ kit_profiler_count(pr, (KitProfileCounter)HEAP_C_ALLOC_KIB, size >> 10);
+ if (size >= 32u * 1024u)
+ kit_profiler_count(pr, (KitProfileCounter)HEAP_C_LARGE_ALLOCS, 1);
+ }
return size ? malloc(size) : NULL;
}
static void* heap_libc_realloc(KitHeap* h, void* p, size_t old_size,
size_t new_size, size_t align) {
- (void)h;
+ KitProfiler* pr = h ? (KitProfiler*)h->user : NULL;
+ void* np;
(void)old_size;
(void)align;
- return realloc(p, new_size);
+ if (pr) kit_profiler_count(pr, (KitProfileCounter)HEAP_C_REALLOCS, 1);
+ np = realloc(p, new_size);
+ if (pr && p && np && np != p)
+ kit_profiler_count(pr, (KitProfileCounter)HEAP_C_REALLOC_MOVES, 1);
+ return np;
}
static void heap_libc_free(KitHeap* h, void* p, size_t size) {
- (void)h;
+ KitProfiler* pr = h ? (KitProfiler*)h->user : NULL;
(void)size;
+ if (pr && p) kit_profiler_count(pr, (KitProfileCounter)HEAP_C_FREES, 1);
free(p);
}