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..b79cbef10 --- /dev/null +++ b/crates/heapwatch/Cargo.toml @@ -0,0 +1,35 @@ +# 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" + +# 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 +# <<< anvil-managed: anvil-lints diff --git a/crates/heapwatch/README.md b/crates/heapwatch/README.md new file mode 100644 index 000000000..c6c9689e3 --- /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 +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 the allocator instance’s atomic totals in batches — once +*bytes allocated plus bytes freed* since its last publication crosses a +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 +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 through a handle obtained from the +allocator, 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..a35e9bf71 --- /dev/null +++ b/crates/heapwatch/docs/DESIGN.md @@ -0,0 +1,415 @@ +# 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 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 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 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* — 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. + +**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**. + +```text + alloc / dealloc / realloc + │ + │ plain, non-atomic adds — no atomics, no shared state + ▼ + ┌───────────────────────────────────┐ + │ Pending (this thread only) │ ◄── reset to zero after a flush + │ live · bytes · op counts │ + └───────────────┬───────────────────┘ + │ churn ≥ threshold ─┐ + │ ├─► one batched commit + │ thread exit ───────┘ + ▼ + ┌───────────────────────────────────┐ + │ atomic totals owned by the │ + │ HeapWatch instance (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. + 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 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, 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 +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. 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 + +**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 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 +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. + +## Public surface + +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. + +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: + +```rust +use heapwatch::HeapWatch; +use std::alloc::System; + +#[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()` 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. + +**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. 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, +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 + +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 | +| 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. + +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, 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: +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. + +## 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 + +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. + +## 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 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 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 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-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. **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. +7. **Bytes are counted as requested**, so each recorded allocation is a lower + bound on what the inner allocator reserved for it. diff --git a/crates/heapwatch/docs/TODO.md b/crates/heapwatch/docs/TODO.md new file mode 100644 index 000000000..09539caf7 --- /dev/null +++ b/crates/heapwatch/docs/TODO.md @@ -0,0 +1,120 @@ +# 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. 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 +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. + +## 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 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. + +## 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 +— 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. + +## 5. Scoped or per-category accounting + +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. + +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. + +## 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 +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. + +## 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 +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. diff --git a/crates/heapwatch/src/lib.rs b/crates/heapwatch/src/lib.rs new file mode 100644 index 000000000..e85937d0c --- /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 +//! 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 the allocator instance's atomic totals in batches — once +//! *bytes allocated plus bytes freed* since its last publication crosses a +//! 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 +//! 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 through a handle obtained from the +//! allocator, 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