From 7a31d994e309bff54e5e65d06cf333fc73576b6e Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Tue, 28 Jul 2026 11:54:45 +0200 Subject: [PATCH 1/6] feat(heapwatch): add crate scaffolding and initial design Introduce `heapwatch`, a global allocator wrapper that continuously accounts heap usage: live bytes, peak, cumulative bytes allocated and freed, and allocation / free / reallocation counts. This change lands the design ahead of the code so the architecture can be reviewed on its own terms. The crate compiles and is registered in the workspace, but exposes no API yet: - `crates/heapwatch/docs/DESIGN.md` -- the shipped architecture: the measurement boundary, per-thread accumulation with batched publication into process-wide atomics, the accuracy contract and its bounds, peak tracking, re-entrancy, edge cases, and the verification strategy. - `crates/heapwatch/docs/TODO.md` -- ideas deliberately left out of the design, with the reasoning for each. - `crates/heapwatch/src/lib.rs` -- crate-level rustdoc summarising the above and pointing at both documents. Registered in `[workspace.dependencies]` so dependents can pick it up once the implementation lands. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: def144ff-f8ea-4dc1-822f-f58665301c4d --- .spelling | 2 + Cargo.lock | 4 + Cargo.toml | 1 + crates/heapwatch/CHANGELOG.md | 1 + crates/heapwatch/Cargo.toml | 23 ++ crates/heapwatch/README.md | 67 +++++ crates/heapwatch/docs/DESIGN.md | 440 ++++++++++++++++++++++++++++++++ crates/heapwatch/docs/TODO.md | 79 ++++++ crates/heapwatch/src/lib.rs | 52 ++++ 9 files changed, 669 insertions(+) create mode 100644 crates/heapwatch/CHANGELOG.md create mode 100644 crates/heapwatch/Cargo.toml create mode 100644 crates/heapwatch/README.md create mode 100644 crates/heapwatch/docs/DESIGN.md create mode 100644 crates/heapwatch/docs/TODO.md create mode 100644 crates/heapwatch/src/lib.rs diff --git a/.spelling b/.spelling index b255437f6..4cfa31fab 100644 --- a/.spelling +++ b/.spelling @@ -78,6 +78,7 @@ Fundle Fundle's HRESULT Hardcoded +Heapwatch How-tos Hyper IOCP @@ -345,6 +346,7 @@ growable hashbrown hasher hashers +heapwatch hedges honour hostname diff --git a/Cargo.lock b/Cargo.lock index 8eae0adda..777b15c47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1857,6 +1857,10 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "heapwatch" +version = "0.1.0" + [[package]] name = "heck" version = "0.5.0" diff --git a/Cargo.toml b/Cargo.toml index 8916c8f70..c56fd90dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -110,6 +110,7 @@ futures-core = { version = "0.3.31", default-features = false } futures-util = { version = "0.3.31", default-features = false } gungraun = { version = "0.19.2", default-features = false } hashbrown = { version = "0.17.0", default-features = false } +heapwatch = { path = "crates/heapwatch", default-features = false, version = "0.1.0" } heck = { version = "0.5.0", default-features = false } http = { version = "1.4.1", default-features = false, features = ["std"] } http-body = { version = "1.0.1", default-features = false } diff --git a/crates/heapwatch/CHANGELOG.md b/crates/heapwatch/CHANGELOG.md new file mode 100644 index 000000000..825c32f0d --- /dev/null +++ b/crates/heapwatch/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/crates/heapwatch/Cargo.toml b/crates/heapwatch/Cargo.toml new file mode 100644 index 000000000..f9fa807b8 --- /dev/null +++ b/crates/heapwatch/Cargo.toml @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[package] +name = "heapwatch" +version = "0.1.0" +description = "A low-overhead global allocator wrapper that continuously accounts heap usage" +readme = "README.md" +keywords = ["allocator", "heap", "memory", "metrics", "diagnostics"] +categories = ["memory-management", "development-tools::profiling"] + +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +include.workspace = true +repository = "https://github.com/microsoft/oxidizer/tree/main/crates/heapwatch" + +# >>> anvil-managed: anvil-lints +[lints] +workspace = true +# <<< anvil-managed: anvil-lints diff --git a/crates/heapwatch/README.md b/crates/heapwatch/README.md new file mode 100644 index 000000000..12b463a3e --- /dev/null +++ b/crates/heapwatch/README.md @@ -0,0 +1,67 @@ +
+ Heapwatch Logo + +# Heapwatch + +[![crate.io](https://img.shields.io/crates/v/heapwatch.svg)](https://crates.io/crates/heapwatch) +[![docs.rs](https://docs.rs/heapwatch/badge.svg)](https://docs.rs/heapwatch) +[![MSRV](https://img.shields.io/crates/msrv/heapwatch)](https://crates.io/crates/heapwatch) +[![CI](https://github.com/microsoft/oxidizer/actions/workflows/main.yml/badge.svg?event=push)](https://github.com/microsoft/oxidizer/actions/workflows/main.yml) +[![Coverage](https://codecov.io/gh/microsoft/oxidizer/graph/badge.svg?token=FCUG0EL5TI)](https://codecov.io/gh/microsoft/oxidizer) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/microsoft/oxidizer/blob/main/LICENSE) +This crate was developed as part of the Oxidizer project + +
+ +A global allocator wrapper that continuously accounts heap usage. + +Heapwatch wraps another allocator and, installed as a binary’s +`#[global_allocator]`, reports how many bytes that binary’s Rust heap holds, +how high it has been, and how much has moved through it. It answers *how +much*, never *who* — no backtraces, no per-allocation metadata, no +pointer-to-size shadow map — which is what keeps the per-allocation cost to +a few non-atomic adds, low enough to leave enabled in production. + +## Status + +**The API is not implemented yet — this crate is currently scaffolding.** +The architecture is settled and written up in [`DESIGN.md`][__link0], and the ideas +deliberately left out of it are recorded in [`TODO.md`][__link1]. Both land ahead of +the code so the design can be reviewed on its own terms. + +## The mechanism + +Each thread accumulates its own totals with plain, non-atomic arithmetic and +publishes them into a fixed set of process-wide atomics in batches — once +*bytes allocated plus bytes freed* since its last publication crosses a +compile-time threshold, when the thread exits, or on request. That removes +the atomic read-modify-write per allocation that an exact accounting wrapper +pays, which is the cost that scales badly with core count. + +The trade is a small, bounded, stated inaccuracy: a reading omits whatever +each live thread has not yet published, at most the threshold times the +number of live threads. That bound depends on neither the allocation rate +nor how long the process has been running. Reading is O(1) in the thread +count — a handful of relaxed loads against fixed statics, with no registry +to walk. + +## Measurement boundary + +Heapwatch counts successful calls through the registered `GlobalAlloc`, and +counts them as *requested* rather than as reserved, since the trait has no +hook to ask the inner allocator what it actually rounded up to. Allocations +that never reach the global allocator — native and FFI heaps, direct OS +mappings, anything routed to `std::alloc::System` — are outside the +boundary, as are thread stacks, static data, and the inner allocator’s own +metadata and fragmentation. It therefore complements, rather than replaces, +allocator-native statistics and process-level metrics such as resident set +size. + + +
+ +This crate was developed as part of The Oxidizer Project. Browse this crate's source code. + + + [__link0]: https://github.com/microsoft/oxidizer/blob/main/crates/heapwatch/docs/DESIGN.md + [__link1]: https://github.com/microsoft/oxidizer/blob/main/crates/heapwatch/docs/TODO.md diff --git a/crates/heapwatch/docs/DESIGN.md b/crates/heapwatch/docs/DESIGN.md new file mode 100644 index 000000000..337d446d2 --- /dev/null +++ b/crates/heapwatch/docs/DESIGN.md @@ -0,0 +1,440 @@ +# Heapwatch — Architecture + +How the heap accountant works, and the accuracy contract its mechanism buys. +Implementation-agnostic — for the API see the crate-level rustdoc, for +forward-looking ideas see [`TODO.md`](./TODO.md). + +## What heapwatch is + +Heapwatch **wraps a global allocator and accounts the volume passing through +it**. Installed as a binary's `#[global_allocator]`, it reports how many bytes +that binary's Rust heap holds, how high it has been, and how much has moved +through it — continuously, in production, at a cost low enough that leaving it +on is not a decision anyone has to weigh. + +It sits among four neighbours: + +- Unlike a **heap profiler** (`dhat`, `heaptrack`, jemalloc's profiler), it does + not attribute bytes to call sites — no backtraces, no per-allocation metadata, + no pointer-to-size shadow map. It answers *how much*, never *who*, which is + what lets its per-allocation cost be a few adds instead of a stack walk. +- Unlike an **exact accounting wrapper** (`stats_alloc`), it does not pay an + atomic read-modify-write on a shared cache line per allocation, trading a + small, bounded, stated inaccuracy for removing the one cost that scales badly + with core count. +- Unlike **allocator-native statistics** (jemalloc's `mallctl`, mimalloc's + stats), it is independent of what is underneath — including the system + allocator, where no such statistics exist — and counts *requested* bytes, + the figure a code change moves. Native counters also report retention and + fragmentation, which heapwatch cannot see; the two are complementary. +- Unlike **OS-level metrics** (RSS, working set, cgroup counters), it isolates + the Rust heap from stacks, static data, code, and non-Rust allocations. + +The design target is a long-running server: a heap in gigabytes, hundreds of +threads, millions of allocations per second, and an operator who wants a gauge +to alert on and a regression signal to bisect. + +Four properties hold regardless of workload: + +- **Transparency** — the wrapper never changes what the inner allocator does, + which pointer it returns, or which layout it sees. +- **The recording path is allocation-free and cannot fail** — a fixed number of + arithmetic operations, never a call back into the allocator, never a panic. +- **The error does not grow with uptime** — it is bounded by the thread count + and the flush threshold, neither of which depends on how long the process has + been running. +- **Reading is O(1) in the thread count** — a fixed number of relaxed loads. + +Everything else in this document is the price of those four. + +## The measurement boundary + +The boundary is **successful calls through this `GlobalAlloc`**. + +**Inside**: every allocation Rust's heap routes through the registered global +allocator — `Box`, `Vec`, `String`, `Arc`, collection growth, and the same +inside any dependency that uses the standard allocator. + +**Outside**: allocations made by native or FFI dependencies that manage their +own memory, even when Rust triggered the work; direct OS calls (`mmap`, +`VirtualAlloc`); anything deliberately routed to `std::alloc::System`, which +bypasses the registered allocator entirely — including Rust's own thread-local +bookkeeping; thread stacks, static data, and the executable image; and the inner +allocator's own metadata, free-list retention, and fragmentation. + +**Counted as requested, not as reserved.** `GlobalAlloc` hands the wrapper a +`Layout` — what the caller *asked for*. Every real allocator rounds that up to a +size class, and the trait has no hook to ask what the rounded figure was. Each +individual allocation is therefore recorded below what the allocator actually +reserved for it, by a margin set by the inner allocator's size classes. This is +inherent to the trait, not a shortcut, and it says nothing about how the total +compares to RSS: heap pages that are requested but never touched, or swapped +out, are not resident at all. + +## The mechanism + +One idea: **accumulate per thread without synchronization, publish in batches**. + +```text + alloc / dealloc / realloc + │ + │ plain, non-atomic adds — no atomics, no shared state + ▼ + ┌───────────────────────────────────┐ + │ Pending (this thread only) │ ◄── reset to zero after a flush + │ live · peak · bytes · op counts │ + └───────────────┬───────────────────┘ + │ churn ≥ threshold ─┐ + │ thread exit ───────┼─► one batched commit + │ explicit flush ────┘ + ▼ + ┌───────────────────────────────────┐ + │ process-wide atomic totals │ + │ (one fixed set: no shard │ + │ registry, no aggregation walk) │ + └───────────────┬───────────────────┘ + │ a handful of relaxed loads + ▼ + stats() +``` + +**Churn, not live bytes, triggers the flush.** A thread publishes once *bytes +allocated plus bytes freed* since its last flush reaches the threshold. Churn, +because a thread that allocates and frees in a loop barely moves `live` while +still running the cumulative counters up — and because `|live| ≤ churn`, so +bounding churn bounds both. A thread also flushes when it exits, or live bytes +would drift upward for the life of the process as threads came and went. + +### One window, end to end + +```text + alloc(4 KiB) ........ thread block: live +4096, allocated +4096, alloc_ops +1, + local peak raised if live is a new local high. + Nothing global moves — stats() cannot see this yet. + ~1000 more ops ...... churn accumulates; between operations the residue is + always below the threshold. This residue IS the error. + op crossing 64 KiB .. one batched commit: + · one fetch_add per cumulative counter + · base = CURRENT.fetch_add(live) ← base read HERE + · PEAK.fetch_max(base + local peak) + thread block resets to zero; error returns to zero + dealloc(4 KiB) ...... live −4096, freed +4096, free_ops +1; no peak work + thread exits ........ the destructor commits whatever remains + stats() ............. relaxed loads of the committed totals only +``` + +### Cost + +| Path | How often | Work | +|---|---|---| +| Record | every operation | one thread-local address, a few non-atomic adds, one compare-and-branch on churn | +| Commit | once per threshold's churn | one `fetch_add` per cumulative counter, one on live, one `fetch_max` on peak | +| Read | on demand | one relaxed load per field | + +At the 64 KiB default and a 64-byte mean allocation, a commit lands roughly once +per thousand operations, so the atomics amortize to a small fraction of the +recording cost — which is the entire point of the threshold. + +### Alternatives rejected + +Both obvious competitors keep an atomic read-modify-write on the hot path, which +is the cost this design exists to remove: + +- **Per-thread shard registry** — a slot claimed at thread start, read by + walking all slots. Pays registry contention at thread start, a lifetime + problem for recycled slots, and a read whose cost grows with thread count. +- **Fixed shard array**, indexed per CPU or by thread hash. Attractive because + the shard count is fixed and needs no destructor, but stable Rust has no + cheap, migration-safe per-CPU index — no restartable sequences, and + `sched_getcpu` is a syscall — so the index comes from a thread-local anyway. + That pays the atomic *and* the thread-local read, suffers false sharing when + hot threads collide, and leaves no clean way to derive a peak from counters + that cannot be summed atomically. + +### Ordering, signedness, and arithmetic + +**Relaxed ordering is sufficient.** The totals carry no data — they *are* the +data. Nothing is published through them and no reader needs a happens-before +relationship with the writer. Stronger ordering would not buy a coherent +snapshot either, since the fields are read independently. + +**Live bytes are signed.** A thread that frees memory another thread allocated +accumulates a negative `live`, and if it flushes first the process-wide total +goes briefly negative. Unsigned modular arithmetic would recover once the +matching allocation was published, but meanwhile every reader would see a value +near `u64::MAX`, and a maximum computed from such a reading would be poisoned +permanently. The derived net allocation count is signed for the same reason: +batching can publish frees before their allocations. + +**All accounting arithmetic is explicitly wrapping or saturating and cannot +panic.** Unwinding out of `GlobalAlloc` is undefined behavior, so an overflow +check that panics in a debug build is not an acceptable failure mode. `Layout` +guarantees a size no greater than `isize::MAX`, which makes the width conversion +into signed accounting exact on targets up to 64 bits; the `|live| ≤ churn` +argument holds under those semantics. + +## Re-entrancy + +The accounting path runs **inside the allocator**, so anything it does that +itself allocates re-enters `alloc` unboundedly. The design's answer is blunt: +**nothing on the accounting path may allocate, ever** — no formatting, no +collections, no boxing error types, no logging. That is a standing invariant on +the implementation, not a property of the current code. + +The one step that looks like it might allocate is the thread-local access, +since the pending block carries the destructor that performs the exit flush, and +a thread-local with a destructor must register it on first touch. It does not +re-enter, because the standard library deliberately backs *all* of its +thread-local machinery with `std::alloc::System` rather than the registered +global allocator — the destructor list is a `Vec` in `System`, the boxed value +on targets without native thread-locals is a `System` allocation carrying the +comment "to avoid interfering with a potential Global allocator using +thread-local storage", and the linux-like path defers to libc's +`__cxa_thread_atexit_impl`, which allocates on libc's own heap. First touch may +therefore allocate, but never through the allocator being measured. + +Consequently the design carries **no re-entrancy guard flag**, which would cost +a branch and a second thread-local access on every operation to defend a case +that cannot arise. This rests on a standard-library implementation choice rather +than a documented guarantee, so the compensating control is a test that installs +heapwatch as the real global allocator and forces first-touch initialization +from inside it: a regression fails CI loudly instead of degrading silently. + +One fallback is genuinely necessary. Once a thread's pending block has been +destroyed, further allocations on that thread — during the tail of thread +shutdown, as other thread-locals tear down — find it gone. Those events **commit +a one-off batch straight to the process-wide atomics**, applying the same update +rules including peak observation. Dropping them would be worse than imprecise: +the matching frees would still be counted, skewing live bytes downward a little +further for every thread the process ever started. + +## Accuracy + +The proposition is not that the numbers are exact, but that the error is +**bounded, enumerated, and small relative to the decisions they inform**. + +| Source | Direction | Bound | +|---|---|---| +| Unflushed pending values | either, self-correcting | `threshold × live threads` | +| Requested vs. reserved size | under | inner allocator's size-class rounding | +| Peak estimation at flush | over, persistent | `threshold × live threads` | +| Non-`GlobalAlloc` allocations | under | outside the stated scope | + +`stats()` omits whatever each live thread has not yet flushed. The threshold +test runs *after* an operation is applied, so a single large allocation can push +a window far past the threshold — but it flushes immediately when it does, so +between operations a thread's residue is always below the threshold, and +`|live| ≤ churn` carries that to live bytes. Hence the bound, which holds at +quiescent operation boundaries: a reader racing an allocation in flight on +another thread can miss that whole allocation, however large. At the 64 KiB +default and a few hundred threads it is tens of megabytes against a heap in +gigabytes. Every later reference to *the bound* means this quantity. + +Two caveats on the bound. It does not *converge*: a thread that parks below the +threshold hides its residue for as long as it sleeps, so a large idle pool can +hold the full bound indefinitely — which is why a worker about to block should +flush first. And it is stated in live threads, a quantity `stats()` does not +report, so a caller wanting a numeric error bar must supply the thread-count +ceiling from its own deployment knowledge. + +The threshold is the crate's one tuning dial, a const generic so it stays a +compile-time constant on the hot path. Lowering it tightens the bound and raises +the per-allocation share of the atomic cost; raising it does the reverse. Zero +flushes every operation: exact accounting with the cost profile of a +conventional atomic-per-allocation tracker — useful in tests, not in production. + +**Snapshot consistency is not claimed.** Reading is a sequence of independent +relaxed loads, not a stop-the-world snapshot, so fields of one reading may be +mutually inconsistent by a few operations. Treat each as a gauge; do not solve +equations across them. + +## Peak + +Peak exists to catch spikes shorter than the emission interval. Every other view +of a maximum — arbitrary windows, fleet aggregation, correlation — is better +derived downstream from the live-bytes gauge. + +Catching short spikes is why peak is tracked **per thread** rather than sampled +at flush points. Each thread tracks its highest `live` as it goes, so a spike +that rises and falls entirely inside one unflushed window is still observed. At +flush, that local peak is added to the committed total observed *at that moment* +and offered as a candidate maximum, which a single `fetch_max` folds in. Frees +can never raise a local peak, so they skip peak tracking entirely. + +The result satisfies one clean invariant: **the recorded peak is never below the +highest value the committed live total ever reached.** Any commit that raises +that total to a new maximum has a positive pending `live`, and the local peak is +the running maximum of local live, so the candidate it offers is at least the +new total. With the bound, that gives the undershoot side. + +Overshoot differs in kind. A candidate combines a local peak measured at one +instant with a committed total read at a later one, so other threads carrying +negative pending values inflate it. Because the recorded peak is a monotone +maximum, an overshoot **never self-corrects**; it persists until the peak is +reset. Peak therefore carries a small, *persistent* upward bias of up to the +bound, where live bytes carry a transient error of the same size. + +Resetting drops the peak to the current live total. It cannot be exact, for the +same reason nothing else can: threads whose windows opened before the reset will +later flush pre-reset local peaks against a post-reset total, re-polluting the +value by up to the bound. A clean reset would need the cross-thread reach the +design forbids. + +## Operation counting + +Every `GlobalAlloc` entry point maps to counters by one rule — bytes follow the +*change* in the block's size, counts follow whether a logical block came or went: + +| Call | Live bytes | Cumulative bytes | Count | +|---|---|---|---| +| `alloc`, `alloc_zeroed` | `+size` | allocated `+size` | `alloc_ops` | +| `dealloc` | `−size` | freed `+size` | `free_ops` | +| `realloc` grown | `+delta` | allocated `+delta` | `realloc_ops` | +| `realloc` shrunk | `−delta` | freed `+delta` | `realloc_ops` | +| any null return | — | — | — | + +Reallocations are counted separately because a reallocation is the same logical +block at a new size, not a new block: folding them into `alloc_ops` would make +every growing `Vec` look like a leak. Failed allocations moved no bytes, and +counting them would drift the count upward under exactly the pressure where a +correct leak signal matters most. + +That separation is what makes `alloc_ops − free_ops` meaningful as the **net +successful allocation count**: roughly flat at steady state regardless of +traffic, climbing under a leak of whole blocks. It is often the earlier signal, +because many small leaked allocations move the count long before they move a +byte total dominated by a few large buffers. It is a batched estimate rather +than a census, can go transiently negative, and does not detect growth that +comes purely from reallocating existing blocks larger. + +## Transparency of the wrapper + +Pointers and layouts pass through unchanged — no layout adjustment for metadata +and no per-allocation header, which is also why `dealloc` can rely on the +caller's `Layout` rather than a shadow map. `realloc` and `alloc_zeroed` are +forwarded rather than left to their `GlobalAlloc` defaults, which would +respectively discard the inner allocator's in-place growth and forfeit pages the +OS already zeroed — regressions far costlier than the tracking itself. The inner +allocator needs nothing beyond `GlobalAlloc` and is reachable by reference, so +one that exposes its own controls stays usable. + +The wrapper also stores a **name** for the inner allocator. Heapwatch emits no +telemetry itself; the name is an accessor so that whatever does emit can tag the +series, making a before/after comparison across an allocator change a dashboard +filter rather than a reconstruction from deployment timestamps. Because the +counters are process-global, it describes the installed allocator, not a +partition of the numbers. + +## Public surface + +The counters are process-global, which follows from the domain: a binary has +exactly one `#[global_allocator]`, so there is exactly one thing to count. Every +access is a direct static or thread-local address with no indirection, and +reading does not require a handle on the allocator — a diagnostic endpoint can +report the heap without the allocator type being threaded through to it. The +corollary is that *every* instance contributes to the same totals, whatever its +type or threshold. + +The surface is correspondingly small: + +- A **wrapper type**, generic over the inner allocator and the flush threshold, + constructible in a `const` context so it can initialize a `static`. +- **Reading** — one call returning a plain `Copy` snapshot: current bytes, peak + bytes, cumulative bytes allocated and freed, allocation, free, and + reallocation counts, plus the derived net allocation count. It is + `#[non_exhaustive]`, so adding a counter later stays additive. +- **Flushing this thread** — fold the caller's pending values in before reading. +- **Resetting the peak** — drop it to the current live total. + +**Concurrency contract.** Every entry point is callable from any thread at any +time; none blocks, none allocates, and none can fail. `flush_thread()` touches +only the calling thread's block, and is the one operation no other thread can +perform on its behalf. `stats()` and `reset_peak()` act on the process-wide +totals, so concurrent callers race in the ordinary way — two simultaneous resets +are safe but only one ordering is observed, and a reset racing a commit may keep +or discard that commit's peak candidate. Since the counters are a gauge rather +than a protocol, none of these races is a correctness problem; they are the same +imprecision the accuracy section already bounds. + +Heapwatch requires `std`, a deliberate exception to the workspace's `no_std` +preference: the mechanism needs a thread-local with a thread-exit destructor, +and `thread_local!` is the stable way to get one. It also requires 64-bit +atomics, which excludes targets without `target_has_atomic = "64"`. + +## Edge cases and limits + +- **Thread exit is best-effort.** The standard library does not guarantee + thread-local destructors run — notably not for the main thread at process + exit, nor after `process::exit`. A thread whose destructor is skipped loses + its residue, which is bounded by the threshold, so the accuracy contract + survives; the "exactly once" property does not. +- **Signal handlers.** Recording is a non-atomic read-modify-write of the + thread's own block, so an allocating signal handler interleaved with it can + lose an update. Allocation in a signal handler is already unsound — the inner + allocator is generally not async-signal-safe — so this is declared + unsupported rather than defended. +- **`fork()` without `exec`.** The child inherits the committed totals, the + parent's whole allocation history, and only the forking thread. Every other + thread vanishes without flushing. Nothing re-initializes; fork-without-exec is + unsupported. +- **Nesting.** `HeapWatch>` records every event into the same + counters twice. The type system does not prevent it; it is unsupported. +- **Zero sizes and OOM.** `GlobalAlloc` already requires a non-zero layout and a + non-zero `realloc` size, so zero-sized operations never reach the wrapper and + "shrink to zero" is not a valid reallocation. Heapwatch neither installs nor + intercepts an allocation-error hook; an aborting OOM leaves pending values + unpublished. +- **Counter widths.** Cumulative byte totals are 64-bit and would need centuries + at gigabytes per second to wrap; live and peak are signed 64-bit, beyond the + reach of any real heap. + +## Design invariants at a glance + +1. **The accounting path never allocates**, and never panics. Every step is + wrapping or saturating arithmetic on a thread-local block, or a relaxed + atomic on a fixed static. +2. **Re-entrancy cannot occur**, because thread-local machinery is backed by + `System` rather than the registered allocator, and post-teardown events take + the direct-to-globals path. +3. **Every recorded event reaches the totals at most once, and exactly once** + for normal thread termination outside a signal handler; what a skipped + destructor loses is bounded by the threshold. +4. **A thread's residue is below the threshold between operations**, so the + reporting error is bounded and does not grow with uptime. +5. **The recorded peak is never below the highest value the committed live total + ever reached**, and never more than the bound above the true peak. +6. **Reading is O(1) in the thread count.** No registry, no walk, no iteration. +7. **The wrapper is transparent.** Pointers, layouts, and the `realloc` and + `alloc_zeroed` specializations pass through unchanged. +8. **Bytes are counted as requested**, so each recorded allocation is a lower + bound on what the inner allocator reserved for it. + +## Verification strategy + +The failure modes are concurrency-, platform-, and re-entrancy-shaped, so the +suite is layered to match: + +- **Unit tests on the arithmetic** — the pending block's update rules are pure + functions of their inputs, tested without a global or an allocator in sight, + including the saturating and wrapping boundaries that must not panic. +- **In-process wrapper tests** drive the wrapper without installing it, so the + only thing moving the totals is the test. Because the counters are global + statics and the harness is multi-threaded, these must be serialized. +- **Installed-allocator tests** run in their own processes with heapwatch as the + real `#[global_allocator]` — the only configuration giving true isolation, and + the only one exercising first-touch thread-local initialization from inside + the allocator. This is the named regression gate for invariants 1 and 2: a + violation overflows the stack rather than failing an assertion. It must run on + both native-thread-local and key-based targets, since only the latter + exercises the `System`-backed boxed storage. +- **Thread lifecycle tests** confirm the exit flush publishes a departing + thread's tail, and that allocations after teardown take the direct path. +- **Concurrency tests** run many threads through balanced allocate/free cycles + and assert the totals land exactly where the arithmetic says — that batched + flushing under contention loses nothing, and that peak never falls below the + highest committed total observed. +- **Undefined-behavior checking** covers the thread-local and atomic paths, with + the caveat that it cannot validate real platform destructor registration. +- **Instruction-exact and wall-clock benchmarks** measure the wrapper against + the bare inner allocator running identical bodies, so overhead is reported as + a subtraction — separately for the common path, the flush path, and reading. diff --git a/crates/heapwatch/docs/TODO.md b/crates/heapwatch/docs/TODO.md new file mode 100644 index 000000000..abd4e03ac --- /dev/null +++ b/crates/heapwatch/docs/TODO.md @@ -0,0 +1,79 @@ +# Heapwatch — Unimplemented Ideas & Future Work + +Designs and ideas that are **not** part of the shipped architecture, which is +documented in [`DESIGN.md`](./DESIGN.md). Items here range from small +follow-ups to features that would change the crate's character. + +## 1. A reported error bar + +The accuracy bound is `threshold × live threads`, and `stats()` reports neither +factor — so a caller cannot turn the documented bound into a number. Tracking a +live-thread count would cost one atomic increment at thread start and one +decrement at exit, nothing on the allocation path, and would let the snapshot +carry a computed `error_bound_bytes`. + +Deliberately not done for now: it adds a counter that most consumers would +ignore, and an operator who needs the figure already knows their deployment's +thread-count ceiling. Worth revisiting if the crate grows a consumer that has to +decide programmatically whether a reading is trustworthy. + +## 2. Telemetry integration + +Today the crate reports numbers and nothing more; emitting them is the caller's +job. A thin optional layer that registers the gauges with a metrics backend +would remove the boilerplate every adopter otherwise writes — periodic emission +of live bytes, peak, and the net allocation count, with the allocator name as a +dimension, followed by a peak reset. + +The reason to keep it optional and separate is dependency weight: the core crate +should stay a `GlobalAlloc` wrapper with no metrics dependency, so that a binary +that already has its own emission path pays nothing. + +## 3. Usable-size accounting + +Byte figures are recorded as *requested*, because `GlobalAlloc` has no hook to +ask the inner allocator what it actually reserved. Allocators generally do know +— jemalloc exposes `nallocx`/`malloc_usable_size`, mimalloc exposes +`mi_usable_size` — so an optional trait that an inner allocator could implement +to report the rounded size would close the largest systematic error in the +accuracy table. + +The obstacle is that the answer is only useful if it is cheap: a per-allocation +size-class lookup on the hot path would trade a documented, stable +under-estimate for real overhead. Worth exploring only for allocators whose +rounding can be computed arithmetically from the layout. + +## 4. Scoped or per-category accounting + +The counters are process-global, which answers "how big is the heap" but not +"which subsystem grew". A scoped variant — a guard that attributes allocations +on the current thread to a named category for the duration of a request or task +— would give per-category totals without call-site attribution. + +This is a significant change in character, not an increment: it needs a +thread-local category stack, a set of counters per category, and a policy for +allocations that outlive the scope that created them (the common case, and the +hard part). Freed-elsewhere allocations would be attributed to whoever frees +them unless a shadow map recorded ownership, which is the cost the whole design +avoids. + +## 5. Sampling-based call-site attribution + +A sampled backtrace on one allocation in every N would recover a coarse version +of what a heap profiler provides, at a cost proportional to the sampling rate +rather than to the allocation rate. It fits the crate's philosophy — bounded, +stated inaccuracy in exchange for affordable continuous operation. + +It also conflicts with the crate's simplest promise, that the recording path +never allocates: capturing and symbolizing a backtrace does. A viable version +would have to buffer raw frame pointers into fixed-size storage and resolve them +off the allocation path. + +## 6. Allocation-size histogram + +A small fixed set of size-class buckets, incremented on the same thread-local +block and flushed with everything else, would show whether a heap grew because +of more allocations or larger ones — a question the current counters can only +answer by division. The cost is one extra branch and increment on the record +path, plus a wider commit; whether that is worth it depends on how often the +distinction actually drives a decision. diff --git a/crates/heapwatch/src/lib.rs b/crates/heapwatch/src/lib.rs new file mode 100644 index 000000000..e81982b37 --- /dev/null +++ b/crates/heapwatch/src/lib.rs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#![cfg_attr(all(coverage_nightly, test), feature(coverage_attribute))] +#![cfg_attr(docsrs, feature(doc_cfg))] + +//! A global allocator wrapper that continuously accounts heap usage. +//! +//! Heapwatch wraps another allocator and, installed as a binary's +//! `#[global_allocator]`, reports how many bytes that binary's Rust heap holds, +//! how high it has been, and how much has moved through it. It answers *how +//! much*, never *who* — no backtraces, no per-allocation metadata, no +//! pointer-to-size shadow map — which is what keeps the per-allocation cost to +//! a few non-atomic adds, low enough to leave enabled in production. +//! +//! # Status +//! +//! **The API is not implemented yet — this crate is currently scaffolding.** +//! The architecture is settled and written up in [`DESIGN.md`], and the ideas +//! deliberately left out of it are recorded in [`TODO.md`]. Both land ahead of +//! the code so the design can be reviewed on its own terms. +//! +//! # The mechanism +//! +//! Each thread accumulates its own totals with plain, non-atomic arithmetic and +//! publishes them into a fixed set of process-wide atomics in batches — once +//! *bytes allocated plus bytes freed* since its last publication crosses a +//! compile-time threshold, when the thread exits, or on request. That removes +//! the atomic read-modify-write per allocation that an exact accounting wrapper +//! pays, which is the cost that scales badly with core count. +//! +//! The trade is a small, bounded, stated inaccuracy: a reading omits whatever +//! each live thread has not yet published, at most the threshold times the +//! number of live threads. That bound depends on neither the allocation rate +//! nor how long the process has been running. Reading is O(1) in the thread +//! count — a handful of relaxed loads against fixed statics, with no registry +//! to walk. +//! +//! # Measurement boundary +//! +//! Heapwatch counts successful calls through the registered `GlobalAlloc`, and +//! counts them as *requested* rather than as reserved, since the trait has no +//! hook to ask the inner allocator what it actually rounded up to. Allocations +//! that never reach the global allocator — native and FFI heaps, direct OS +//! mappings, anything routed to `std::alloc::System` — are outside the +//! boundary, as are thread stacks, static data, and the inner allocator's own +//! metadata and fragmentation. It therefore complements, rather than replaces, +//! allocator-native statistics and process-level metrics such as resident set +//! size. +//! +//! [`DESIGN.md`]: https://github.com/microsoft/oxidizer/blob/main/crates/heapwatch/docs/DESIGN.md +//! [`TODO.md`]: https://github.com/microsoft/oxidizer/blob/main/crates/heapwatch/docs/TODO.md From c0803fa16f9b5c65345a3ca56543abd26a20f25f Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Tue, 28 Jul 2026 12:19:25 +0200 Subject: [PATCH 2/6] fix(heapwatch): opt out of the coverage gate while the crate has no code The impact-scoped coverage run (`anvil-llvm-cov`) invokes `cargo llvm-cov nextest --package heapwatch@0.1.0`, and nextest exits 4 on "no tests to run". Because heapwatch is scaffolding -- design documents and crate-level rustdoc, no code and no tests -- it was the sole affected package, so `pr-test` failed on all four platforms. Declare `[package.metadata.coverage-gate] min-lines-percent = 0`, the opt-out the recipe already understands for packages with nothing to instrument, so the affected set resolves to empty and the run skips. The comment marks the section for removal once the implementation lands and the workspace-default 100% gate should apply again. Also from code review: - Add `[package.metadata.docs.rs]`. `lib.rs` declares `#![cfg_attr(docsrs, feature(doc_cfg))]`, but without this section docs.rs never sets the cfg, so feature-gated items would be silently omitted once features exist. - Qualify the peak invariant in `DESIGN.md`. Publishing a commit is two atomics (`CURRENT.fetch_add` then `PEAK.fetch_max`), so a reader that lands between them observes a live total the peak has not caught up with. The invariant holds for completed commits; `peak >= current` is not assertable across a reading. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: def144ff-f8ea-4dc1-822f-f58665301c4d --- crates/heapwatch/Cargo.toml | 12 ++++++++++++ crates/heapwatch/docs/DESIGN.md | 12 ++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/heapwatch/Cargo.toml b/crates/heapwatch/Cargo.toml index f9fa807b8..b79cbef10 100644 --- a/crates/heapwatch/Cargo.toml +++ b/crates/heapwatch/Cargo.toml @@ -17,6 +17,18 @@ homepage.workspace = true include.workspace = true repository = "https://github.com/microsoft/oxidizer/tree/main/crates/heapwatch" +# The crate is scaffolding: it carries the design documents but no code, so +# there is nothing to instrument and no test to run. Without this opt-out the +# impact-scoped coverage run fails with nextest's "no tests to run" (exit 4). +# REMOVE THIS SECTION when the implementation lands, so the workspace-default +# 100% line-coverage gate applies again. +[package.metadata.coverage-gate] +min-lines-percent = 0 + +[package.metadata.docs.rs] +rustdoc-args = ["--cfg", "docsrs"] +all-features = true + # >>> anvil-managed: anvil-lints [lints] workspace = true diff --git a/crates/heapwatch/docs/DESIGN.md b/crates/heapwatch/docs/DESIGN.md index 337d446d2..a2476c7c3 100644 --- a/crates/heapwatch/docs/DESIGN.md +++ b/crates/heapwatch/docs/DESIGN.md @@ -267,6 +267,13 @@ that total to a new maximum has a positive pending `live`, and the local peak is the running maximum of local live, so the candidate it offers is at least the new total. With the bound, that gives the undershoot side. +The invariant describes completed commits, not commits in flight. Raising the +committed total and folding in the peak candidate are two separate atomics, so a +reader that lands between them sees a live total the peak has not caught up with +yet. That window closes as soon as the `fetch_max` retires — unlike the +overshoot below, it self-corrects — but `peak ≥ current` is consequently not +something a caller may assert across a reading. + Overshoot differs in kind. A candidate combines a local peak measured at one instant with a committed total read at a later one, so other threads carrying negative pending values inflate it. Because the recorded peak is a monotone @@ -401,8 +408,9 @@ atomics, which excludes targets without `target_has_atomic = "64"`. destructor loses is bounded by the threshold. 4. **A thread's residue is below the threshold between operations**, so the reporting error is bounded and does not grow with uptime. -5. **The recorded peak is never below the highest value the committed live total - ever reached**, and never more than the bound above the true peak. +5. **Once a commit has completed, the recorded peak is never below the highest + value the committed live total ever reached**, and never more than the bound + above the true peak. 6. **Reading is O(1) in the thread count.** No registry, no walk, no iteration. 7. **The wrapper is transparent.** Pointers, layouts, and the `realloc` and `alloc_zeroed` specializations pass through unchanged. From b0806cc44d5a3c1fe33299ac853616cb3b071e2e Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Tue, 28 Jul 2026 12:27:27 +0200 Subject: [PATCH 3/6] docs(heapwatch): align the peak invariant statement with invariant 5 The previous commit qualified invariant 5 ("Once a commit has completed, ...") but left the bolded statement in the Peak section unqualified, so the two sections made different claims about the same invariant. Qualify the bolded statement identically, and reword the following paragraph so it elaborates on the qualification rather than walking back a stronger claim. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: def144ff-f8ea-4dc1-822f-f58665301c4d --- crates/heapwatch/docs/DESIGN.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/heapwatch/docs/DESIGN.md b/crates/heapwatch/docs/DESIGN.md index a2476c7c3..bc50e9775 100644 --- a/crates/heapwatch/docs/DESIGN.md +++ b/crates/heapwatch/docs/DESIGN.md @@ -261,14 +261,15 @@ flush, that local peak is added to the committed total observed *at that moment* and offered as a candidate maximum, which a single `fetch_max` folds in. Frees can never raise a local peak, so they skip peak tracking entirely. -The result satisfies one clean invariant: **the recorded peak is never below the -highest value the committed live total ever reached.** Any commit that raises -that total to a new maximum has a positive pending `live`, and the local peak is -the running maximum of local live, so the candidate it offers is at least the -new total. With the bound, that gives the undershoot side. - -The invariant describes completed commits, not commits in flight. Raising the -committed total and folding in the peak candidate are two separate atomics, so a +The result satisfies one clean invariant: **once a commit has completed, the +recorded peak is never below the highest value the committed live total ever +reached.** Any commit that raises that total to a new maximum has a positive +pending `live`, and the local peak is the running maximum of local live, so the +candidate it offers is at least the new total. With the bound, that gives the +undershoot side. + +The qualification matters because publishing a commit takes two separate +atomics: raising the committed total, then folding in the peak candidate. A reader that lands between them sees a live total the peak has not caught up with yet. That window closes as soon as the `fetch_max` retires — unlike the overshoot below, it self-corrects — but `peak ≥ current` is consequently not From 79e8ca9250a05182407dc1f54839eb056908a1ac Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Tue, 28 Jul 2026 12:55:16 +0200 Subject: [PATCH 4/6] docs(heapwatch): drop peak tracking, own counters per instance Addresses review feedback on the design: - Drop peak entirely. A consumer that samples live bytes can maintain its own maximum, and every other view of one is better derived downstream. Removing it also drops the reset operation and the subtler contract a monotone maximum needs, since a candidate combines a local peak and a total read at different instants and so acquires a persistent bias. Recorded in TODO.md as deferred work. - Replace the process-wide statics with counters owned by the HeapWatch instance, read through a Watch handle. The handle carries no type parameters, so a diagnostic endpoint takes it without the allocator's generics being threaded through. Adds a section explaining which totals a thread's pending block commits to. - Add public-surface snippets for both registration and reading. - Tighten the prose throughout: 3872 to 3121 words despite the new section and examples. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 26bd2118-4c88-4d5c-bc3e-d12121a95297 --- crates/heapwatch/README.md | 16 +- crates/heapwatch/docs/DESIGN.md | 357 ++++++++++++++------------------ crates/heapwatch/docs/TODO.md | 34 ++- crates/heapwatch/src/lib.rs | 16 +- 4 files changed, 195 insertions(+), 228 deletions(-) diff --git a/crates/heapwatch/README.md b/crates/heapwatch/README.md index 12b463a3e..81adf47bd 100644 --- a/crates/heapwatch/README.md +++ b/crates/heapwatch/README.md @@ -16,11 +16,11 @@ A global allocator wrapper that continuously accounts heap usage. Heapwatch wraps another allocator and, installed as a binary’s -`#[global_allocator]`, reports how many bytes that binary’s Rust heap holds, -how high it has been, and how much has moved through it. It answers *how -much*, never *who* — no backtraces, no per-allocation metadata, no -pointer-to-size shadow map — which is what keeps the per-allocation cost to -a few non-atomic adds, low enough to leave enabled in production. +`#[global_allocator]`, reports how many bytes that binary’s Rust heap holds +and how much has moved through it. It answers *how much*, never *who* — no +backtraces, no per-allocation metadata, no pointer-to-size shadow map — +which is what keeps the per-allocation cost to a few non-atomic adds, low +enough to leave enabled in production. ## Status @@ -32,7 +32,7 @@ the code so the design can be reviewed on its own terms. ## The mechanism Each thread accumulates its own totals with plain, non-atomic arithmetic and -publishes them into a fixed set of process-wide atomics in batches — once +publishes them into the allocator instance’s atomic totals in batches — once *bytes allocated plus bytes freed* since its last publication crosses a compile-time threshold, when the thread exits, or on request. That removes the atomic read-modify-write per allocation that an exact accounting wrapper @@ -42,8 +42,8 @@ The trade is a small, bounded, stated inaccuracy: a reading omits whatever each live thread has not yet published, at most the threshold times the number of live threads. That bound depends on neither the allocation rate nor how long the process has been running. Reading is O(1) in the thread -count — a handful of relaxed loads against fixed statics, with no registry -to walk. +count — a handful of relaxed loads through a handle obtained from the +allocator, with no registry to walk. ## Measurement boundary diff --git a/crates/heapwatch/docs/DESIGN.md b/crates/heapwatch/docs/DESIGN.md index bc50e9775..58a34fa7b 100644 --- a/crates/heapwatch/docs/DESIGN.md +++ b/crates/heapwatch/docs/DESIGN.md @@ -8,24 +8,23 @@ forward-looking ideas see [`TODO.md`](./TODO.md). Heapwatch **wraps a global allocator and accounts the volume passing through it**. Installed as a binary's `#[global_allocator]`, it reports how many bytes -that binary's Rust heap holds, how high it has been, and how much has moved -through it — continuously, in production, at a cost low enough that leaving it -on is not a decision anyone has to weigh. +that binary's Rust heap holds and how much has moved through it — continuously, +in production, at a cost low enough to leave on. It sits among four neighbours: - Unlike a **heap profiler** (`dhat`, `heaptrack`, jemalloc's profiler), it does not attribute bytes to call sites — no backtraces, no per-allocation metadata, - no pointer-to-size shadow map. It answers *how much*, never *who*, which is - what lets its per-allocation cost be a few adds instead of a stack walk. + no shadow map. It answers *how much*, never *who*, which is what buys a + per-allocation cost of a few adds instead of a stack walk. - Unlike an **exact accounting wrapper** (`stats_alloc`), it does not pay an atomic read-modify-write on a shared cache line per allocation, trading a - small, bounded, stated inaccuracy for removing the one cost that scales badly - with core count. + small, bounded, stated inaccuracy for the one cost that scales badly with core + count. - Unlike **allocator-native statistics** (jemalloc's `mallctl`, mimalloc's stats), it is independent of what is underneath — including the system - allocator, where no such statistics exist — and counts *requested* bytes, - the figure a code change moves. Native counters also report retention and + allocator, where no such statistics exist — and counts *requested* bytes, the + figure a code change moves. Native counters also report retention and fragmentation, which heapwatch cannot see; the two are complementary. - Unlike **OS-level metrics** (RSS, working set, cgroup counters), it isolates the Rust heap from stacks, static data, code, and non-Rust allocations. @@ -63,13 +62,12 @@ bookkeeping; thread stacks, static data, and the executable image; and the inner allocator's own metadata, free-list retention, and fragmentation. **Counted as requested, not as reserved.** `GlobalAlloc` hands the wrapper a -`Layout` — what the caller *asked for*. Every real allocator rounds that up to a -size class, and the trait has no hook to ask what the rounded figure was. Each -individual allocation is therefore recorded below what the allocator actually -reserved for it, by a margin set by the inner allocator's size classes. This is -inherent to the trait, not a shortcut, and it says nothing about how the total -compares to RSS: heap pages that are requested but never touched, or swapped -out, are not resident at all. +`Layout` — what the caller *asked for* — and offers no hook to ask what the +inner allocator rounded that up to. Each allocation is therefore recorded below +what was actually reserved, by a margin set by the inner allocator's size +classes. This is inherent to the trait, not a shortcut, and it says nothing +about how the total compares to RSS: heap pages requested but never touched, or +swapped out, are not resident at all. ## The mechanism @@ -82,16 +80,16 @@ One idea: **accumulate per thread without synchronization, publish in batches**. ▼ ┌───────────────────────────────────┐ │ Pending (this thread only) │ ◄── reset to zero after a flush - │ live · peak · bytes · op counts │ + │ live · bytes · op counts │ └───────────────┬───────────────────┘ │ churn ≥ threshold ─┐ │ thread exit ───────┼─► one batched commit │ explicit flush ────┘ ▼ ┌───────────────────────────────────┐ - │ process-wide atomic totals │ - │ (one fixed set: no shard │ - │ registry, no aggregation walk) │ + │ atomic totals owned by the │ + │ HeapWatch instance (no shard │ + │ registry, no aggregation walk) │ └───────────────┬───────────────────┘ │ a handful of relaxed loads ▼ @@ -108,27 +106,34 @@ would drift upward for the life of the process as threads came and went. ### One window, end to end ```text - alloc(4 KiB) ........ thread block: live +4096, allocated +4096, alloc_ops +1, - local peak raised if live is a new local high. + alloc(4 KiB) ........ thread block: live +4096, allocated +4096, alloc_ops +1. Nothing global moves — stats() cannot see this yet. ~1000 more ops ...... churn accumulates; between operations the residue is always below the threshold. This residue IS the error. - op crossing 64 KiB .. one batched commit: - · one fetch_add per cumulative counter - · base = CURRENT.fetch_add(live) ← base read HERE - · PEAK.fetch_max(base + local peak) - thread block resets to zero; error returns to zero - dealloc(4 KiB) ...... live −4096, freed +4096, free_ops +1; no peak work + op crossing 64 KiB .. one batched commit — one fetch_add per counter, live + included. The thread block resets to zero and the error + returns to zero. + dealloc(4 KiB) ...... live −4096, freed +4096, free_ops +1 thread exits ........ the destructor commits whatever remains stats() ............. relaxed loads of the committed totals only ``` +### Which totals a thread commits to + +Totals live on the instance, but the pending block is one thread-local rather +than one per instance, so the block records which counter set it is accumulating +for. Recording compares that target against the instance handling the call; a +mismatch commits the pending values to the previous target and re-targets the +block. A binary has one `#[global_allocator]`, so the compare is a predictable +never-taken branch in practice, and the fallback exists only so that a second +instance cannot silently mix its numbers into the first's. + ### Cost | Path | How often | Work | |---|---|---| -| Record | every operation | one thread-local address, a few non-atomic adds, one compare-and-branch on churn | -| Commit | once per threshold's churn | one `fetch_add` per cumulative counter, one on live, one `fetch_max` on peak | +| Record | every operation | one thread-local address, one target compare, a few non-atomic adds, one compare-and-branch on churn | +| Commit | once per threshold's churn | one `fetch_add` per counter | | Read | on demand | one relaxed load per field | At the 64 KiB default and a 64-byte mean allocation, a commit lands roughly once @@ -143,13 +148,11 @@ is the cost this design exists to remove: - **Per-thread shard registry** — a slot claimed at thread start, read by walking all slots. Pays registry contention at thread start, a lifetime problem for recycled slots, and a read whose cost grows with thread count. -- **Fixed shard array**, indexed per CPU or by thread hash. Attractive because - the shard count is fixed and needs no destructor, but stable Rust has no - cheap, migration-safe per-CPU index — no restartable sequences, and - `sched_getcpu` is a syscall — so the index comes from a thread-local anyway. - That pays the atomic *and* the thread-local read, suffers false sharing when - hot threads collide, and leaves no clean way to derive a peak from counters - that cannot be summed atomically. +- **Fixed shard array**, indexed per CPU or by thread hash. The shard count is + fixed and needs no destructor, but stable Rust has no cheap, migration-safe + per-CPU index — no restartable sequences, and `sched_getcpu` is a syscall — so + the index comes from a thread-local anyway. That pays the atomic *and* the + thread-local read, and suffers false sharing when hot threads collide. ### Ordering, signedness, and arithmetic @@ -159,11 +162,10 @@ relationship with the writer. Stronger ordering would not buy a coherent snapshot either, since the fields are read independently. **Live bytes are signed.** A thread that frees memory another thread allocated -accumulates a negative `live`, and if it flushes first the process-wide total -goes briefly negative. Unsigned modular arithmetic would recover once the -matching allocation was published, but meanwhile every reader would see a value -near `u64::MAX`, and a maximum computed from such a reading would be poisoned -permanently. The derived net allocation count is signed for the same reason: +accumulates a negative `live`, and if it flushes first the instance's total goes +briefly negative. Unsigned modular arithmetic would recover once the matching +allocation was published, but meanwhile every reader would see a value near +`u64::MAX`. The derived net allocation count is signed for the same reason: batching can publish frees before their allocations. **All accounting arithmetic is explicitly wrapping or saturating and cannot @@ -173,40 +175,75 @@ guarantees a size no greater than `isize::MAX`, which makes the width conversion into signed accounting exact on targets up to 64 bits; the `|live| ≤ churn` argument holds under those semantics. -## Re-entrancy +## Public surface -The accounting path runs **inside the allocator**, so anything it does that -itself allocates re-enters `alloc` unboundedly. The design's answer is blunt: -**nothing on the accounting path may allocate, ever** — no formatting, no -collections, no boxing error types, no logging. That is a standing invariant on -the implementation, not a property of the current code. +Counters belong to the **instance**, not to a set of process-wide statics. A +`HeapWatch` owns its totals and hands out a handle to read them, so the crate +carries no hidden global state and two instances count independently. -The one step that looks like it might allocate is the thread-local access, -since the pending block carries the destructor that performs the exit flush, and -a thread-local with a destructor must register it on first touch. It does not -re-enter, because the standard library deliberately backs *all* of its -thread-local machinery with `std::alloc::System` rather than the registered -global allocator — the destructor list is a `Vec` in `System`, the boxed value -on targets without native thread-locals is a `System` allocation carrying the -comment "to avoid interfering with a potential Global allocator using -thread-local storage", and the linux-like path defers to libc's -`__cxa_thread_atexit_impl`, which allocates on libc's own heap. First touch may -therefore allocate, but never through the allocator being measured. +Registration is a `const` constructor in a `static`, as `#[global_allocator]` +requires. The flush threshold is a const generic — so it stays a compile-time +constant on the hot path — and defaults to 64 KiB: -Consequently the design carries **no re-entrancy guard flag**, which would cost -a branch and a second thread-local access on every operation to defend a case -that cannot arise. This rests on a standard-library implementation choice rather -than a documented guarantee, so the compensating control is a test that installs -heapwatch as the real global allocator and forces first-touch initialization -from inside it: a regression fails CI loudly instead of degrading silently. +```rust +use heapwatch::HeapWatch; +use std::alloc::System; -One fallback is genuinely necessary. Once a thread's pending block has been -destroyed, further allocations on that thread — during the tail of thread -shutdown, as other thread-locals tear down — find it gone. Those events **commit -a one-off batch straight to the process-wide atomics**, applying the same update -rules including peak observation. Dropping them would be worse than imprecise: -the matching frees would still be counted, skewing live bytes downward a little -further for every thread the process ever started. +#[global_allocator] +static HEAP: HeapWatch = HeapWatch::new(System, "system"); + +// Or with an explicit threshold: +// static HEAP: HeapWatch = HeapWatch::new(System, "system"); +``` + +Reading goes through `Watch`, a handle with no type parameters, so a diagnostic +endpoint can report the heap without the allocator's generics being threaded +through to it: + +```rust +fn report(heap: heapwatch::Watch) { + let s = heap.stats(); + println!( + "{} live, {} allocated, {} freed, {} net allocations", + s.current_bytes, + s.allocated_bytes, + s.freed_bytes, + s.net_alloc_ops(), + ); +} + +fn main() { + report(HEAP.watch()); +} +``` + +The rest of the surface: + +- **`HeapWatch::new(inner, name)`** — `const`, generic over the inner allocator + and the threshold. `name()` returns the stored name: heapwatch emits no + telemetry itself, so whatever does emit can tag the series with it, making a + before/after comparison across an allocator change a dashboard filter rather + than a reconstruction from deployment timestamps. +- **`watch()`** — a `Copy + Send + Sync` handle valid for the life of the + process. `stats()` and `flush_thread()` are callable on the instance and on + the handle alike. +- **`stats()`** — a plain `Copy` snapshot: current bytes, cumulative bytes + allocated and freed, allocation, free, and reallocation counts, plus the + derived net allocation count. It is `#[non_exhaustive]`, so adding a counter + later stays additive. +- **`flush_thread()`** — fold the caller's pending values in before reading. + +**Concurrency contract.** Every entry point is callable from any thread at any +time; none blocks, none allocates, and none can fail. `flush_thread()` touches +only the calling thread's block, and is the one operation no other thread can +perform on its behalf. Concurrent readers and writers otherwise race in the +ordinary way; since the counters are a gauge rather than a protocol, that is not +a correctness problem, only the imprecision the accuracy section bounds. + +Heapwatch requires `std`, a deliberate exception to the workspace's `no_std` +preference: the mechanism needs a thread-local with a thread-exit destructor, +and `thread_local!` is the stable way to get one. It also requires 64-bit +atomics, which excludes targets without `target_has_atomic = "64"`. ## Accuracy @@ -217,7 +254,6 @@ The proposition is not that the numbers are exact, but that the error is |---|---|---| | Unflushed pending values | either, self-correcting | `threshold × live threads` | | Requested vs. reserved size | under | inner allocator's size-class rounding | -| Peak estimation at flush | over, persistent | `threshold × live threads` | | Non-`GlobalAlloc` allocations | under | outside the stated scope | `stats()` omits whatever each live thread has not yet flushed. The threshold @@ -228,65 +264,56 @@ between operations a thread's residue is always below the threshold, and quiescent operation boundaries: a reader racing an allocation in flight on another thread can miss that whole allocation, however large. At the 64 KiB default and a few hundred threads it is tens of megabytes against a heap in -gigabytes. Every later reference to *the bound* means this quantity. +gigabytes. -Two caveats on the bound. It does not *converge*: a thread that parks below the +Two caveats. The bound does not *converge*: a thread that parks below the threshold hides its residue for as long as it sleeps, so a large idle pool can hold the full bound indefinitely — which is why a worker about to block should flush first. And it is stated in live threads, a quantity `stats()` does not report, so a caller wanting a numeric error bar must supply the thread-count ceiling from its own deployment knowledge. -The threshold is the crate's one tuning dial, a const generic so it stays a -compile-time constant on the hot path. Lowering it tightens the bound and raises -the per-allocation share of the atomic cost; raising it does the reverse. Zero -flushes every operation: exact accounting with the cost profile of a -conventional atomic-per-allocation tracker — useful in tests, not in production. +Lowering the threshold tightens the bound and raises the per-allocation share of +the atomic cost; raising it does the reverse. Zero flushes every operation: +exact accounting with the cost profile of a conventional atomic-per-allocation +tracker — useful in tests, not in production. **Snapshot consistency is not claimed.** Reading is a sequence of independent relaxed loads, not a stop-the-world snapshot, so fields of one reading may be mutually inconsistent by a few operations. Treat each as a gauge; do not solve equations across them. -## Peak - -Peak exists to catch spikes shorter than the emission interval. Every other view -of a maximum — arbitrary windows, fleet aggregation, correlation — is better -derived downstream from the live-bytes gauge. - -Catching short spikes is why peak is tracked **per thread** rather than sampled -at flush points. Each thread tracks its highest `live` as it goes, so a spike -that rises and falls entirely inside one unflushed window is still observed. At -flush, that local peak is added to the committed total observed *at that moment* -and offered as a candidate maximum, which a single `fetch_max` folds in. Frees -can never raise a local peak, so they skip peak tracking entirely. - -The result satisfies one clean invariant: **once a commit has completed, the -recorded peak is never below the highest value the committed live total ever -reached.** Any commit that raises that total to a new maximum has a positive -pending `live`, and the local peak is the running maximum of local live, so the -candidate it offers is at least the new total. With the bound, that gives the -undershoot side. - -The qualification matters because publishing a commit takes two separate -atomics: raising the committed total, then folding in the peak candidate. A -reader that lands between them sees a live total the peak has not caught up with -yet. That window closes as soon as the `fetch_max` retires — unlike the -overshoot below, it self-corrects — but `peak ≥ current` is consequently not -something a caller may assert across a reading. - -Overshoot differs in kind. A candidate combines a local peak measured at one -instant with a committed total read at a later one, so other threads carrying -negative pending values inflate it. Because the recorded peak is a monotone -maximum, an overshoot **never self-corrects**; it persists until the peak is -reset. Peak therefore carries a small, *persistent* upward bias of up to the -bound, where live bytes carry a transient error of the same size. - -Resetting drops the peak to the current live total. It cannot be exact, for the -same reason nothing else can: threads whose windows opened before the reset will -later flush pre-reset local peaks against a post-reset total, re-polluting the -value by up to the bound. A clean reset would need the cross-thread reach the -design forbids. +## Re-entrancy + +The accounting path runs **inside the allocator**, so anything it does that +itself allocates re-enters `alloc` unboundedly. The design's answer is blunt: +**nothing on the accounting path may allocate, ever** — no formatting, no +collections, no boxing error types, no logging. That is a standing invariant on +the implementation, not a property of the current code. + +The one step that looks like it might allocate is the thread-local access, since +the pending block carries the exit-flush destructor and a thread-local with a +destructor must register it on first touch. It does not re-enter, because the +standard library deliberately backs *all* of its thread-local machinery with +`std::alloc::System` rather than the registered global allocator — the +destructor list, the boxed value on targets without native thread-locals (whose +source comment cites exactly this hazard), and the linux-like path's use of +libc's `__cxa_thread_atexit_impl`. First touch may therefore allocate, but never +through the allocator being measured. + +Consequently the design carries **no re-entrancy guard flag**, which would cost +a branch and a second thread-local access on every operation to defend a case +that cannot arise. This rests on a standard-library implementation choice rather +than a documented guarantee, so the compensating control is a test that installs +heapwatch as the real global allocator and forces first-touch initialization +from inside it: a regression fails CI loudly instead of degrading silently. + +One fallback is genuinely necessary. Once a thread's pending block has been +destroyed, further allocations on that thread — during the tail of thread +shutdown, as other thread-locals tear down — find it gone. Those events **commit +a one-off batch straight to the instance's atomics**. Dropping them would be +worse than imprecise: the matching frees would still be counted, skewing live +bytes downward a little further for every thread the process ever started. ## Operation counting @@ -326,49 +353,6 @@ OS already zeroed — regressions far costlier than the tracking itself. The inn allocator needs nothing beyond `GlobalAlloc` and is reachable by reference, so one that exposes its own controls stays usable. -The wrapper also stores a **name** for the inner allocator. Heapwatch emits no -telemetry itself; the name is an accessor so that whatever does emit can tag the -series, making a before/after comparison across an allocator change a dashboard -filter rather than a reconstruction from deployment timestamps. Because the -counters are process-global, it describes the installed allocator, not a -partition of the numbers. - -## Public surface - -The counters are process-global, which follows from the domain: a binary has -exactly one `#[global_allocator]`, so there is exactly one thing to count. Every -access is a direct static or thread-local address with no indirection, and -reading does not require a handle on the allocator — a diagnostic endpoint can -report the heap without the allocator type being threaded through to it. The -corollary is that *every* instance contributes to the same totals, whatever its -type or threshold. - -The surface is correspondingly small: - -- A **wrapper type**, generic over the inner allocator and the flush threshold, - constructible in a `const` context so it can initialize a `static`. -- **Reading** — one call returning a plain `Copy` snapshot: current bytes, peak - bytes, cumulative bytes allocated and freed, allocation, free, and - reallocation counts, plus the derived net allocation count. It is - `#[non_exhaustive]`, so adding a counter later stays additive. -- **Flushing this thread** — fold the caller's pending values in before reading. -- **Resetting the peak** — drop it to the current live total. - -**Concurrency contract.** Every entry point is callable from any thread at any -time; none blocks, none allocates, and none can fail. `flush_thread()` touches -only the calling thread's block, and is the one operation no other thread can -perform on its behalf. `stats()` and `reset_peak()` act on the process-wide -totals, so concurrent callers race in the ordinary way — two simultaneous resets -are safe but only one ordering is observed, and a reset racing a commit may keep -or discard that commit's peak candidate. Since the counters are a gauge rather -than a protocol, none of these races is a correctness problem; they are the same -imprecision the accuracy section already bounds. - -Heapwatch requires `std`, a deliberate exception to the workspace's `no_std` -preference: the mechanism needs a thread-local with a thread-exit destructor, -and `thread_local!` is the stable way to get one. It also requires 64-bit -atomics, which excludes targets without `target_has_atomic = "64"`. - ## Edge cases and limits - **Thread exit is best-effort.** The standard library does not guarantee @@ -385,65 +369,32 @@ atomics, which excludes targets without `target_has_atomic = "64"`. parent's whole allocation history, and only the forking thread. Every other thread vanishes without flushing. Nothing re-initializes; fork-without-exec is unsupported. -- **Nesting.** `HeapWatch>` records every event into the same - counters twice. The type system does not prevent it; it is unsupported. +- **Nesting.** `HeapWatch>` records every event into both + instances' counters. The type system does not prevent it; it is unsupported. - **Zero sizes and OOM.** `GlobalAlloc` already requires a non-zero layout and a non-zero `realloc` size, so zero-sized operations never reach the wrapper and "shrink to zero" is not a valid reallocation. Heapwatch neither installs nor intercepts an allocation-error hook; an aborting OOM leaves pending values unpublished. - **Counter widths.** Cumulative byte totals are 64-bit and would need centuries - at gigabytes per second to wrap; live and peak are signed 64-bit, beyond the + at gigabytes per second to wrap; live bytes are signed 64-bit, beyond the reach of any real heap. ## Design invariants at a glance 1. **The accounting path never allocates**, and never panics. Every step is wrapping or saturating arithmetic on a thread-local block, or a relaxed - atomic on a fixed static. + atomic on the instance's counters. 2. **Re-entrancy cannot occur**, because thread-local machinery is backed by `System` rather than the registered allocator, and post-teardown events take - the direct-to-globals path. + the direct-to-totals path. 3. **Every recorded event reaches the totals at most once, and exactly once** for normal thread termination outside a signal handler; what a skipped destructor loses is bounded by the threshold. 4. **A thread's residue is below the threshold between operations**, so the reporting error is bounded and does not grow with uptime. -5. **Once a commit has completed, the recorded peak is never below the highest - value the committed live total ever reached**, and never more than the bound - above the true peak. -6. **Reading is O(1) in the thread count.** No registry, no walk, no iteration. -7. **The wrapper is transparent.** Pointers, layouts, and the `realloc` and +5. **Reading is O(1) in the thread count.** No registry, no walk, no iteration. +6. **The wrapper is transparent.** Pointers, layouts, and the `realloc` and `alloc_zeroed` specializations pass through unchanged. -8. **Bytes are counted as requested**, so each recorded allocation is a lower +7. **Bytes are counted as requested**, so each recorded allocation is a lower bound on what the inner allocator reserved for it. - -## Verification strategy - -The failure modes are concurrency-, platform-, and re-entrancy-shaped, so the -suite is layered to match: - -- **Unit tests on the arithmetic** — the pending block's update rules are pure - functions of their inputs, tested without a global or an allocator in sight, - including the saturating and wrapping boundaries that must not panic. -- **In-process wrapper tests** drive the wrapper without installing it, so the - only thing moving the totals is the test. Because the counters are global - statics and the harness is multi-threaded, these must be serialized. -- **Installed-allocator tests** run in their own processes with heapwatch as the - real `#[global_allocator]` — the only configuration giving true isolation, and - the only one exercising first-touch thread-local initialization from inside - the allocator. This is the named regression gate for invariants 1 and 2: a - violation overflows the stack rather than failing an assertion. It must run on - both native-thread-local and key-based targets, since only the latter - exercises the `System`-backed boxed storage. -- **Thread lifecycle tests** confirm the exit flush publishes a departing - thread's tail, and that allocations after teardown take the direct path. -- **Concurrency tests** run many threads through balanced allocate/free cycles - and assert the totals land exactly where the arithmetic says — that batched - flushing under contention loses nothing, and that peak never falls below the - highest committed total observed. -- **Undefined-behavior checking** covers the thread-local and atomic paths, with - the caveat that it cannot validate real platform destructor registration. -- **Instruction-exact and wall-clock benchmarks** measure the wrapper against - the bare inner allocator running identical bodies, so overhead is reported as - a subtraction — separately for the common path, the flush path, and reading. diff --git a/crates/heapwatch/docs/TODO.md b/crates/heapwatch/docs/TODO.md index abd4e03ac..7353ff1cf 100644 --- a/crates/heapwatch/docs/TODO.md +++ b/crates/heapwatch/docs/TODO.md @@ -4,7 +4,23 @@ Designs and ideas that are **not** part of the shipped architecture, which is documented in [`DESIGN.md`](./DESIGN.md). Items here range from small follow-ups to features that would change the crate's character. -## 1. A reported error bar +## 1. Peak live bytes + +The counters report the heap as it is now, not the highest it has been, so a +spike shorter than the emission interval leaves no trace. Tracking a peak +per thread — the running maximum of local live, folded into a process-wide +`fetch_max` at each commit — would catch those spikes at the cost of one +compare per allocation and one atomic per commit. + +Deliberately not done for now: a consumer that already samples live bytes can +maintain its own maximum, and every other view of one — arbitrary windows, fleet +aggregation, correlation — is better derived downstream anyway. Doing it in the +crate also drags in a reset operation and a subtler contract, because a +candidate combines a local maximum measured at one instant with a total read at +a later one, so a monotone maximum acquires a small *persistent* upward bias +where the other counters carry only a transient one. + +## 2. A reported error bar The accuracy bound is `threshold × live threads`, and `stats()` reports neither factor — so a caller cannot turn the documented bound into a number. Tracking a @@ -17,19 +33,19 @@ ignore, and an operator who needs the figure already knows their deployment's thread-count ceiling. Worth revisiting if the crate grows a consumer that has to decide programmatically whether a reading is trustworthy. -## 2. Telemetry integration +## 3. Telemetry integration Today the crate reports numbers and nothing more; emitting them is the caller's job. A thin optional layer that registers the gauges with a metrics backend would remove the boilerplate every adopter otherwise writes — periodic emission -of live bytes, peak, and the net allocation count, with the allocator name as a -dimension, followed by a peak reset. +of live bytes and the net allocation count, with the allocator name as a +dimension. The reason to keep it optional and separate is dependency weight: the core crate should stay a `GlobalAlloc` wrapper with no metrics dependency, so that a binary that already has its own emission path pays nothing. -## 3. Usable-size accounting +## 4. Usable-size accounting Byte figures are recorded as *requested*, because `GlobalAlloc` has no hook to ask the inner allocator what it actually reserved. Allocators generally do know @@ -43,9 +59,9 @@ size-class lookup on the hot path would trade a documented, stable under-estimate for real overhead. Worth exploring only for allocators whose rounding can be computed arithmetically from the layout. -## 4. Scoped or per-category accounting +## 5. Scoped or per-category accounting -The counters are process-global, which answers "how big is the heap" but not +The counters cover the whole process, which answers "how big is the heap" but not "which subsystem grew". A scoped variant — a guard that attributes allocations on the current thread to a named category for the duration of a request or task — would give per-category totals without call-site attribution. @@ -57,7 +73,7 @@ hard part). Freed-elsewhere allocations would be attributed to whoever frees them unless a shadow map recorded ownership, which is the cost the whole design avoids. -## 5. Sampling-based call-site attribution +## 6. Sampling-based call-site attribution A sampled backtrace on one allocation in every N would recover a coarse version of what a heap profiler provides, at a cost proportional to the sampling rate @@ -69,7 +85,7 @@ never allocates: capturing and symbolizing a backtrace does. A viable version would have to buffer raw frame pointers into fixed-size storage and resolve them off the allocation path. -## 6. Allocation-size histogram +## 7. Allocation-size histogram A small fixed set of size-class buckets, incremented on the same thread-local block and flushed with everything else, would show whether a heap grew because diff --git a/crates/heapwatch/src/lib.rs b/crates/heapwatch/src/lib.rs index e81982b37..08f2bfd02 100644 --- a/crates/heapwatch/src/lib.rs +++ b/crates/heapwatch/src/lib.rs @@ -7,11 +7,11 @@ //! A global allocator wrapper that continuously accounts heap usage. //! //! Heapwatch wraps another allocator and, installed as a binary's -//! `#[global_allocator]`, reports how many bytes that binary's Rust heap holds, -//! how high it has been, and how much has moved through it. It answers *how -//! much*, never *who* — no backtraces, no per-allocation metadata, no -//! pointer-to-size shadow map — which is what keeps the per-allocation cost to -//! a few non-atomic adds, low enough to leave enabled in production. +//! `#[global_allocator]`, reports how many bytes that binary's Rust heap holds +//! and how much has moved through it. It answers *how much*, never *who* — no +//! backtraces, no per-allocation metadata, no pointer-to-size shadow map — +//! which is what keeps the per-allocation cost to a few non-atomic adds, low +//! enough to leave enabled in production. //! //! # Status //! @@ -23,7 +23,7 @@ //! # The mechanism //! //! Each thread accumulates its own totals with plain, non-atomic arithmetic and -//! publishes them into a fixed set of process-wide atomics in batches — once +//! publishes them into the allocator instance's atomic totals in batches — once //! *bytes allocated plus bytes freed* since its last publication crosses a //! compile-time threshold, when the thread exits, or on request. That removes //! the atomic read-modify-write per allocation that an exact accounting wrapper @@ -33,8 +33,8 @@ //! each live thread has not yet published, at most the threshold times the //! number of live threads. That bound depends on neither the allocation rate //! nor how long the process has been running. Reading is O(1) in the thread -//! count — a handful of relaxed loads against fixed statics, with no registry -//! to walk. +//! count — a handful of relaxed loads through a handle obtained from the +//! allocator, with no registry to walk. //! //! # Measurement boundary //! From c457298038404a5f804039bb92058f8331d9879c Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Tue, 28 Jul 2026 13:00:33 +0200 Subject: [PATCH 5/6] docs(heapwatch): state the demand-vs-footprint limit explicitly Review raised the case of swapping the system allocator for mimalloc: heapwatch's numbers do not move, because it counts what callers requested and the call pattern is unchanged, yet the memory the process actually holds does move. That invariance is deliberate -- it is what makes heapwatch the control variable -- but the design only implied it, in a passing note that allocator-native counters also report retention and fragmentation. Say it outright in the measurement boundary, and add a TODO item sketching the pairing: an optional trait an inner allocator can implement to report committed bytes on demand, so demand and footprint can be reported side by side. Notes that an allocator instance linked into one library also gives the per-component attribution the OS cannot. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 26bd2118-4c88-4d5c-bc3e-d12121a95297 --- crates/heapwatch/docs/DESIGN.md | 10 ++++++++++ crates/heapwatch/docs/TODO.md | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/crates/heapwatch/docs/DESIGN.md b/crates/heapwatch/docs/DESIGN.md index 58a34fa7b..8f052d183 100644 --- a/crates/heapwatch/docs/DESIGN.md +++ b/crates/heapwatch/docs/DESIGN.md @@ -69,6 +69,16 @@ classes. This is inherent to the trait, not a shortcut, and it says nothing about how the total compares to RSS: heap pages requested but never touched, or swapped out, are not resident at all. +**Demand, not footprint.** Because the wrapper counts what callers asked for, +its numbers are invariant across inner allocators: swapping the system allocator +for mimalloc leaves every counter identical, since the call pattern did not +change. That invariance is deliberate — it is what makes heapwatch the control +variable when something else changes — but it also means heapwatch alone cannot +say whether an allocator swap reduced the memory a process actually holds. +Retention, arena caching, and fragmentation are supply-side quantities, visible +only to the allocator or the OS. Pairing the two is sketched under *Allocator +footprint reporting* in [`TODO.md`](./TODO.md). + ## The mechanism One idea: **accumulate per thread without synchronization, publish in batches**. diff --git a/crates/heapwatch/docs/TODO.md b/crates/heapwatch/docs/TODO.md index 7353ff1cf..09539caf7 100644 --- a/crates/heapwatch/docs/TODO.md +++ b/crates/heapwatch/docs/TODO.md @@ -93,3 +93,28 @@ of more allocations or larger ones — a question the current counters can only answer by division. The cost is one extra branch and increment on the record path, plus a wider commit; whether that is worth it depends on how often the distinction actually drives a decision. + +## 8. Allocator footprint reporting + +Counters record what callers *requested*, so they are invariant across inner +allocators by construction: swapping the system allocator for mimalloc leaves +every number identical, because the call pattern did not change. What does +change is the memory the allocator holds from the OS — size-class rounding, +free-list retention, arena caching, fragmentation, and metadata — and none of +that is reachable through `GlobalAlloc`. + +Most production allocators do report it: mimalloc's `mi_process_info` yields +current and peak committed bytes, and jemalloc's `mallctl` exposes +`stats.active`, `stats.resident`, `stats.mapped`, and `stats.retained`. An +optional trait that an inner allocator could implement — one method returning +committed bytes, called on demand rather than per allocation — would let +heapwatch report demand and footprint side by side. The ratio between them is +the figure that actually moves when the allocator changes, and it is also the +per-component attribution the OS cannot give: an allocator instance linked into +one library measures that library's heap, not the whole process. + +The reasons to keep it optional and off the recording path: the figures come +from the allocator's own bookkeeping, whose cost and freshness vary (jemalloc +needs an epoch advance to refresh, mimalloc merges thread-local stats), and no +equivalent counter exists for the system allocator — so the trait has to be +implementable only where it means something. From 228dfbb27bfe57f610ce2c3e8bfd0783523b4bc3 Mon Sep 17 00:00:00 2001 From: Martin Tomka Date: Tue, 28 Jul 2026 13:14:10 +0200 Subject: [PATCH 6/6] docs(heapwatch): drop the caller-facing flush from the design Review asked for a surface with one verb. Committing is now entirely internal -- the churn threshold and thread exit -- so a reader calls stats() and has nothing to sequence before it. Removes flush_thread from the public surface, the explicit-flush arrow from the mechanism diagram, and the flush clause from the concurrency contract and the rustdoc. States why there is no flush operation: it would only affect the calling thread, so tightening a process-wide reading would mean calling it on every thread, which a diagnostic endpoint cannot do -- to tighten a bound that is already documented and small. Reworks the idle-pool caveat in the accuracy section, which previously advised flushing before blocking. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 26bd2118-4c88-4d5c-bc3e-d12121a95297 --- crates/heapwatch/README.md | 4 ++-- crates/heapwatch/docs/DESIGN.md | 33 +++++++++++++++++++-------------- crates/heapwatch/src/lib.rs | 4 ++-- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/crates/heapwatch/README.md b/crates/heapwatch/README.md index 81adf47bd..c6c9689e3 100644 --- a/crates/heapwatch/README.md +++ b/crates/heapwatch/README.md @@ -34,8 +34,8 @@ the code so the design can be reviewed on its own terms. Each thread accumulates its own totals with plain, non-atomic arithmetic and publishes them into the allocator instance’s atomic totals in batches — once *bytes allocated plus bytes freed* since its last publication crosses a -compile-time threshold, when the thread exits, or on request. That removes -the atomic read-modify-write per allocation that an exact accounting wrapper +compile-time threshold, and again when the thread exits. That removes the +atomic read-modify-write per allocation that an exact accounting wrapper pays, which is the cost that scales badly with core count. The trade is a small, bounded, stated inaccuracy: a reading omits whatever diff --git a/crates/heapwatch/docs/DESIGN.md b/crates/heapwatch/docs/DESIGN.md index 8f052d183..a35e9bf71 100644 --- a/crates/heapwatch/docs/DESIGN.md +++ b/crates/heapwatch/docs/DESIGN.md @@ -93,8 +93,8 @@ One idea: **accumulate per thread without synchronization, publish in batches**. │ live · bytes · op counts │ └───────────────┬───────────────────┘ │ churn ≥ threshold ─┐ - │ thread exit ───────┼─► one batched commit - │ explicit flush ────┘ + │ ├─► one batched commit + │ thread exit ───────┘ ▼ ┌───────────────────────────────────┐ │ atomic totals owned by the │ @@ -235,20 +235,25 @@ The rest of the surface: before/after comparison across an allocator change a dashboard filter rather than a reconstruction from deployment timestamps. - **`watch()`** — a `Copy + Send + Sync` handle valid for the life of the - process. `stats()` and `flush_thread()` are callable on the instance and on - the handle alike. + process. `stats()` is callable on the instance and on the handle alike. - **`stats()`** — a plain `Copy` snapshot: current bytes, cumulative bytes allocated and freed, allocation, free, and reallocation counts, plus the derived net allocation count. It is `#[non_exhaustive]`, so adding a counter later stays additive. -- **`flush_thread()`** — fold the caller's pending values in before reading. + +**There is deliberately no flush operation.** Committing is entirely internal — +the churn threshold and thread exit — so a caller has one verb, *read*, and +nothing to sequence before it. Exposing a flush would offer callers a way to +tighten a bound that is already documented and small, in exchange for an API +whose correct use is subtle: it only affects the calling thread, so tightening +a process-wide reading would mean calling it on every thread, which is not +something a diagnostic endpoint can do. **Concurrency contract.** Every entry point is callable from any thread at any -time; none blocks, none allocates, and none can fail. `flush_thread()` touches -only the calling thread's block, and is the one operation no other thread can -perform on its behalf. Concurrent readers and writers otherwise race in the -ordinary way; since the counters are a gauge rather than a protocol, that is not -a correctness problem, only the imprecision the accuracy section bounds. +time; none blocks, none allocates, and none can fail. Concurrent readers and +writers race in the ordinary way; since the counters are a gauge rather than a +protocol, that is not a correctness problem, only the imprecision the accuracy +section bounds. Heapwatch requires `std`, a deliberate exception to the workspace's `no_std` preference: the mechanism needs a thread-local with a thread-exit destructor, @@ -278,10 +283,10 @@ gigabytes. Two caveats. The bound does not *converge*: a thread that parks below the threshold hides its residue for as long as it sleeps, so a large idle pool can -hold the full bound indefinitely — which is why a worker about to block should -flush first. And it is stated in live threads, a quantity `stats()` does not -report, so a caller wanting a numeric error bar must supply the thread-count -ceiling from its own deployment knowledge. +hold the full bound indefinitely, and with no flush operation there is nothing a +reader can do about it. And it is stated in live threads, a quantity `stats()` +does not report, so a caller wanting a numeric error bar must supply the +thread-count ceiling from its own deployment knowledge. Lowering the threshold tightens the bound and raises the per-allocation share of the atomic cost; raising it does the reverse. Zero flushes every operation: diff --git a/crates/heapwatch/src/lib.rs b/crates/heapwatch/src/lib.rs index 08f2bfd02..e85937d0c 100644 --- a/crates/heapwatch/src/lib.rs +++ b/crates/heapwatch/src/lib.rs @@ -25,8 +25,8 @@ //! Each thread accumulates its own totals with plain, non-atomic arithmetic and //! publishes them into the allocator instance's atomic totals in batches — once //! *bytes allocated plus bytes freed* since its last publication crosses a -//! compile-time threshold, when the thread exits, or on request. That removes -//! the atomic read-modify-write per allocation that an exact accounting wrapper +//! compile-time threshold, and again when the thread exits. That removes the +//! atomic read-modify-write per allocation that an exact accounting wrapper //! pays, which is the cost that scales badly with core count. //! //! The trade is a small, bounded, stated inaccuracy: a reading omits whatever