alloc_observe
Internal implementation documentation
Overview · API · Code relationships · Verification · Audit
Overview
Layered allocation and operation hierarchy
Observation of memory behavior across allocator layers needs to distinguish a caller's logical request from the underlying acquisition of backing memory. For example, a high-level allocator such as an arena or memory pool satisfies requests by carving space out of larger regions that it already holds. When an arena exhausts its available capacity, it requests an additional chunk from a backing allocator. In that situation, a single logical allocation at the arena layer can cause a second, nested allocation underneath it.
If an observer records only high-level requests, backing chunk acquisitions remain invisible. If an observer records only backing calls, individual logical objects cannot be tracked. Recording both layers without distinguishing their roles can also mislead analysis, because summing requested lengths across both layers as one memory total can count the same storage relationship twice.
The package connects instrumented allocators through one shared callback. Within this system, a tracking interval is called a span. A span becomes active only when observation is enabled, not suppressed on this thread, and a sink is installed. Each active span reserves its own operation ID, saves the current thread operation ID as parent, becomes current, and restores the saved parent on finish. It has no duration bound, timeout, or automatic finish.
Spans finish in reverse order of their initiation. When an arena allocation triggers a backing allocation, the inner backing operation finishes and delivers its completion notice, or event, before the outer arena operation finishes. Because operation identifiers are reserved at the start of an operation, callback delivery order need not follow increasing identifier order.
Thread nesting is strictly local, with no global event ordering or cross-thread context propagation. Callers must finish spans and silent contexts in reverse initiation order on their originating thread. While invoking finish on the same token twice is harmless, copying an active token does not produce independent ownership, so callers must finish only one authoritative token.
Callers must not infer physical bytes released, total heap use, or complete process coverage from logical allocation events. A free outcome marked as succeeded indicates that the allocator operation completed, not that backing pages were released to the operating system. An arena or pool can retain freed memory internally for subsequent reuse.
In lib/chic/src/profiling/workloads/invocation.zig, the Counts helper filters only successful .arena allocation events, distinguishing root requests with a parent ID of zero from nested arena requests with a non-zero parent ID. In lib/choir/src/core/operation/storage.zig, test assertions confirm that reusing a pool slot emits a logical allocation event even when the pool requires no backing expansion.
Synchronous notification and Session lifecycle
Observation connects instrumented allocators to one caller-installed callback. The package records no history itself, owns no event buffer, and provides no export format or capacity policy. Observation relies on a caller-provided destination, or sink, comprising an opaque context pointer and a function pointer accepting an Event structure by value. The callback returns void. Consequently, error recovery, bounded buffer allocation, sample dropping policies, data aggregation, and thread synchronization belong entirely to the sink implementation.
Notification occurs synchronously when an active span finishes. In the provided allocator adapters, the observed memory operation has already returned before event delivery begins. Callback work participates directly in the caller's execution path, without an asynchronous queue. When multiple application threads allocate concurrently, event delivery from different threads can overlap. A single installed sink serves the shared protocol instance.
An observation run, or session, borrows the caller's sink and its context pointer. The install function registers the sink and returns error.AlreadyInstalled if another session is already active. Sink storage and callback context memory must remain valid and at stable addresses until Session.deinit returns. This lifetime requirement applies immediately during installation, because an emitter on another thread can invoke the callback before install returns. Allocators can exist before observation begins, and their subsequent operations are delivered once a sink is active.
Tearing down an enabled observation session requires calling Session.deinit exactly once. The shutdown sequence first uninstalls the sink and then spins until the shared count of active spans and contexts reaches zero, without providing any timeout or cancellation mechanism. Because outstanding tokens on the thread invoking shutdown cannot finish while that thread spins, callers must finish all thread-local tokens before initiating deinit. Callers must also invoke Session.deinit outside of active callbacks and suppression scopes to satisfy internal assertions, and without holding locks that concurrent callbacks require to make progress. In addition, callers must serialize the complete shutdown of an old session before installing a new one, as the lifecycle rules do not accommodate overlapping transitions.
To prevent infinite recursion, the protocol automatically suppresses observation on the current thread while executing a sink callback. Callers can also initiate manual suppression using suppress, which returns a token whose deinit restores the previous suppression depth. Suppression skips new spans and contexts without preventing the underlying allocation. Suppression does not cancel already active spans, mute other threads, or automatically suppress the separate sys.memory observer. Spans and contexts initiated when no sink is installed remain inactive.
Subsystems can track allocation boundaries through private event representations rather than standard observer events. For example, the memtrace tracing allocator assigns an identifier around a backing call but records that boundary inside its own event structure. To accommodate this pattern, beginContext reserves an identifier and participates in same-thread nesting without emitting an Event. Consequently, a child event may specify a parent_operation_id that has no matching event operation_id in the observer stream. Downstream consumers can read Context.id and parentId to connect that externally recorded boundary with nested operations. On any context that was initially active, both stored getter functions remain readable after finish.
Producer Identity and lifecycle tracking
Correlating allocation activity with specific program components requires stable identifiers that persist across individual operations. The protocol identifies each allocation source through a producer identifier, or producer ID, allocated independently of whether an active observation session exists.
When initializing a session, the system records an identifier boundary by sampling the next producer identifier under the same registry lock that serializes producerId allocations. Identifiers assigned prior to this boundary fall strictly below the sampled value, whereas identifiers assigned after installation are at or above it. This boundary does not filter incoming events emitted by older producers. Instead, downstream consumers such as memtrace use the boundary to qualify histories that may lack allocation records from before observation began. A producer registered at or above the boundary still does not guarantee a complete event history, because active suppression scopes and sink recording policies can omit events.
Full emitter identity comprises four components: producer_id, a producer kind (Producer), a generation number, and an owner cookie. The generation is an owner-supplied lifetime number rather than a counter advanced by this module, and the owner cookie provides an owner-supplied association value. Callers use beginOwned to forward these attributes. The protocol forwards owner-supplied values directly without advancing generations or validating lifetime transitions. Standard begin calls supply generation zero and cookie zero.
The Identity.movable helper derives a non-zero owner cookie from a non-zero producer ID rather than an owner memory address, preserving association when the owner struct moves. However, this derivation does not update allocator handles borrowing old owner addresses, and folding 64-bit identifiers into usize gives no cookie uniqueness guarantee.
Allocator invalidations and teardown phases are communicated through lifecycle spans created via beginLifecycle. These spans record events with operations set to .lifecycle and capture reasons such as reset or deinit. The allocator owner supplies the outcome and actual lifetime behavior. For example, the arena allocator wrapper emits a reset lifecycle span using its current generation and increments the generation afterward, even when reset returns false. Callers must not assume every adapter emits lifecycle events, as generic ObservedAllocator, debug.Allocator, and buffer.First do not emit teardown lifecycle records.
Build configurations and allocator adapters
The package module name is alloc_observe, an internal implementation package rather than tiny.alloc_observe. In standalone builds, -Denabled=true chooses the active implementation, which defaults to false. Consumer build scripts across the repository commonly qualify observation with -Dobserve-allocations=true, though individual packages do not enable it identically. The test root compiles both enabled and disabled variants in a single invocation.
In disabled builds, session installation succeeds as an inert operation, while calls to begin, finish, and suppress perform no work. Internal data structures also adopt different memory layouts compared to enabled builds. State queries reflect this disabled status: producerId, currentOperationId, and context identifiers evaluate to zero, while suppressed returns false. Because generic ObservedAllocator.init always asserts a non-zero producer identifier, callers must not invoke it unconditionally with producerId() in disabled configurations.
An allocator type that tries a supplied buffer, then a fallback allocator, is buffer.First. This buffer stores allocations, not recorded events. When observation is disabled, the type aliases std.heap.BufferFirstAllocator, while enabled observation wraps it. To obtain a diagnostic allocator, invoking debug.Allocator(options) returns a type with init(backing), allocator(), and deinit(), where deinit returns std.heap.Check. Around backing raw calls, enabled adapters use ObservedAllocator, preserve backing outcome, and add no lifetime policy. Allocator handles borrow stable live owners and backing allocators. Sinks called from concurrent threads must synchronize shared state themselves.
Direct protocol execution
Driving spans directly demonstrates how the protocol registers operations, establishes parentage, and delivers completion events.
In this test, the operation IDs and producer IDs are real identifiers allocated by the protocol. Only the addresses and lengths are simulated operation data rather than real memory allocations, and callers must never dereference these synthetic addresses.
The demonstration runs on a single thread. A local Recorder structure provides bounded storage using a fixed array of two events, recording events until capacity is reached and incrementing a dropped counter with saturating addition thereafter. This recorder contains no synchronization. A sink receiving events from concurrent producers requires synchronization to protect its state.
const std = @import("std");const observe = @import("alloc_observe");test "direct protocol event demonstration" { if (comptime !observe.enabled) return error.SkipZigTest; const Recorder = struct { events: [2]observe.Event = undefined, count: usize = 0, dropped: usize = 0, fn record(context: *anyopaque, event: observe.Event) void { const self: *@This() = @ptrCast(@alignCast(context)); if (self.count < self.events.len) { self.events[self.count] = event; self.count += 1; } else { self.dropped = self.dropped +| 1; } } }; var recorder = Recorder{}; var sink = observe.Sink{ .context = &recorder, .record = Recorder.record, }; var session = try observe.install(&sink); defer session.deinit(); const arena_id = observe.producerId(); const backing_id = observe.producerId(); var outer = observe.begin( arena_id, .arena, .alloc, 0, 0, 24, 8, 101, ); defer outer.finish(.{ .address = 0, .succeeded = false }); var inner = observe.begin( backing_id, .boundary, .alloc, 0, 0, 4096, 16, 202, ); defer inner.finish(.{ .address = 0, .succeeded = false }); inner.finish(.{ .address = 0x2000, .succeeded = true }); outer.finish(.{ .address = 0x2010, .succeeded = true }); try std.testing.expectEqual(@as(usize, 2), recorder.count); try std.testing.expectEqual(@as(usize, 0), recorder.dropped); try std.testing.expectEqual( recorder.events[1].operation_id, recorder.events[0].parent_operation_id, ); try std.testing.expectEqual( @as(u64, 0), recorder.events[1].parent_operation_id, ); try std.testing.expectEqual(@as(u64, 0), observe.currentOperationId());}Definitions
Actions
Public operations.
producerId: Allocates a nonzero, monotonically increasing identifier for an allocator producer when observation is enabled, or returns zero in disabled builds.beginOwned: Initiates an ordinary observation span using an explicit caller identity, forwarding prior and requested buffer sizes, addresses, alignment, and return address arguments.beginContext: Reserves an operation identifier and establishes thread nesting without publishing an event when finished.beginLifecycle: Initiates a lifecycle observation span for an allocator transition, requiring disposition and reason values other than none even though disabled builds ultimately discard the event.currentOperationId: Returns the operation identifier currently active on the calling thread, which belongs to either an active span or a silent context, or zero if no operation is active or observation is disabled.install: Installs a borrowed sink and context as the active observation target, returning an active session handle.suppress: Increments the observation suppression depth counter on the current thread, asserting that depth does not overflow an eight-bit integer, and returns a suppression token that decrements the counter upon deinitialization.suppressed: Reports whether observation is currently suspended on the calling thread due to an active callback execution or an explicit suppression scope, returning false in disabled builds.begin: Initiates an ordinary observation span with generation and owner cookie defaulted to zero, forwarding previous address, previous length, requested length, byte alignment, and caller return address to the resulting token.
Types and contracts
Public types and contracts.
Context: A silent thread-bound nesting token that reserves an operation identifier and participates in thread hierarchy tracking without publishing an event upon completion.Event: AnEventis a by-value notification delivered at span finish.Identity: Identifies an allocation source by recording a producer identifier, a producer kind, an owner-supplied generation number, and an owner-supplied cookie value.LifecycleDisposition: Specifies how a lifecycle event affects allocations belonging to the emitting producer: none indicates a standard allocator operation, invalidate marks earlier allocations as having lost validity, and end signals that the producer owner lifetime has completed.LifecycleReason: Describes the motive for an allocator lifecycle transition: none, reset, clearandfree, clearretainingcurrent, clearretaininglargest, clearretainingcapacity, clearretainingcapacity_limit, and deinit.Operation: Identifies raw allocator virtual table operations (alloc, resize, remap, and free) alongside lifecycle transitions.Outcome: AnOutcomecarries the result of an operation as supplied by the producer.Producer: Classifies the origin of an observed allocation event through tags: arena, bump, bufferfirst, fixedbuffer, debug, pool, process, phase, limit, boundary, sys_memory, and custom.Session: Represents an installed observation interval, managing session duration while borrowing caller-owned sink and context storage.Sink: A delivery target holding a type-erased context pointer and a function pointer that accepts an event by value and returnsvoid.Span: A thread-bound tracking token representing an active observation interval.Suppression: A nesting depth token that suspends the creation of new spans and silent contexts on the current thread while active.ObservedAllocator: A generic adapter wrapping a supplied backing allocator, instrumenting allocation, resizing, remapping, and deallocation virtual table calls while returning backing allocator outcomes unchanged.
Namespaces
Public namespaces.
Values and defaults
Public values and defaults.
enabled: A compile-time boolean indicating whether allocation observation is active.
Code relationships
Direct static dependencies extracted from parsed source by semantic graph analysis.
Uses: None
Used by: None
Verification
No verification records are cataloged for this module in this build.
Audit
| Evidence | Value |
|---|---|
| Source | lib/alloc/observe/src/root.zig |
| Definitions | 28 of 30 documented |
| Members | 0 of 58 documented |
| Public names | 30 API, 32 indexed |
| Version | 26.7.0 |
| Revision | daab053ee433 |
| Unresolved targets | 1 |