rvy/mach6
Hierarchical profiler with zero-sum memory accounting
mach6
Hierarchical profiler with zero-sum memory accounting for analytics pipelines.
Installation
Mach6 is packaged for Luau through pesde. The Wally release is a single Roblox module generated from that same source with Darklua.
# luau / lune projects → installs into luau_packages
mach6 = { name = "rvy/mach6", version = "^0.1.0" }
# roblox projects → installs into roblox_packages
mach6 = { wally = "revvy02/mach6", version = "^0.1.0" }
Mach6 depends on rvy/retained_scope. Darklua embeds that dependency in the
Wally artifact, so Roblox consumers only install Mach6.
Building and publishing
mise run build # writes dist/init.luau
mise run publish-pesde # rebuilds and publishes the Luau package
mise run publish-wally # rebuilds, validates, and publishes the Wally package
src is the only authored source directory. dist and luau_packages are
generated by the build and ignored by Git.
Modules
- profiler -- Hierarchical timing, memory allocation, and inverse memory accounting.
- sample -- Fixed-size reservoir sampling and summary helpers.
Profiler
API
local profiler = require("mach6").profiler
profiler.mark(label) -- open a profiling scope
profiler.done() -- close the current scope
profiler.root -- the tree root (walk .children for inspection)
profiler.convert(node) -- convert a raw node into a stats payload
Usage Pattern: Inverse Done/Mark
The consumer drives the frame boundary by inverting done/mark:
profiler.mark("frame")
RunService.Heartbeat:Connect(function()
profiler.done() -- close previous frame
local frame_node = profiler.root.children.frame
local payload = profiler.convert(frame_node)
profiler.mark("frame") -- open next frame
profiler.mark("physics")
runPhysics()
profiler.done()
profiler.mark("render")
runRender()
profiler.done()
end)
The gap between done() and mark("frame") is where the inverse delta is captured. done() records gcinfo() at frame end. The next mark("frame") records gcinfo() at frame start. The difference is the inverse delta.
Stats Payload
convert(node) returns:
{
label = "frame",
stats = {
["time.avg"] -- average duration (seconds)
["time.p50"] -- median duration
["time.p90"] -- 90th percentile duration
["time.max"] -- maximum sampled duration
["mem_delta.avg"] -- average memory allocated (KB)
["mem.net"] -- closed-loop net memory drift
["count"] -- number of samples
},
children = { ... } -- recursive, same structure
}
Mathematical Foundation
The Problem
gcinfo() gives a single global heap number. Per-handler mem_delta (allocation during a handler) grows monotonically because it never sees GC reclamation. You can't distinguish a handler that allocates 100KB of transient garbage (reclaimed next GC cycle) from one that leaks 100KB permanently.
The Telescoping Identity
Each frame has two memory measurements:
Frame N: mark("frame") ----- done()
start_N end_N
~~~~ gap (GC, engine, other scripts) ~~~~
Frame N+1: mark("frame") ----- done()
start_{N+1} end_{N+1}
Define:
D(N) = end_N - start_N(parent delta: total allocation by handlers)I(N) = start_{N+1} - end_N(inverse delta: between-frame change)
Their sum:
D(N) + I(N) = (end_N - start_N) + (start_{N+1} - end_N)
= start_{N+1} - start_N
The end_N terms cancel. Summing over N frames:
sum_{i=1}^{N} [D(i) + I(i)] = start_{N+1} - start_1
= heap_now - heap_at_start
Every intermediate term cancels. This is the telescoping identity. It holds unconditionally -- no assumptions about GC timing, allocation patterns, or frame regularity. It is an algebraic identity, not an approximation.
What This Gives You
-
Leak detection: If
mem.net(=sum(D) + sum(I)) grows over time, the system is leaking. The rate ismem.net / countKB per frame. -
Allocation profiling: Per-handler
mem_deltatells you who allocates the most.handler.mem_delta / parent.mem_deltagives each handler's share of total allocation. -
Reclamation budget:
inverse_mem.totaltells you how much GC is recovering between frames. If|inverse_mem.total| < mem_delta.total, GC isn't keeping up with allocation. -
GC noise immunity: Individual frame measurements are noisy (GC may not run every frame). But the telescoping sum is always exact over any window. Noise smooths out with more samples.
Why Child-Level Inverse Deltas Are Noisy
For a child handler like physics, the inverse delta measures start_physics(frame N+1) - end_physics(frame N). This gap includes other handlers running, the between-frame gap, and engine work. It's not measuring GC reclamation of physics's allocations specifically.
Only the parent-level inverse delta is clean: it measures the gap between frames where no handlers run. The parent's mem_delta is the sum of all children (they run contiguously), so parent-level accounting forms a closed system.
Resolution Limit
gcinfo() returns integer KB. Handlers allocating less than 1KB per frame produce mem_delta = 0. The books still balance (0 + 0 = 0), but sub-KB allocations are invisible. This is a measurement granularity floor, not a flaw in the accounting.
Reservoir Sampling
All statistics use Vitter's Algorithm R (reservoir sampling) with a fixed buffer of 128 samples. This provides:
- Bounded memory: Each node stores at most 128 time samples and 128 memory samples, regardless of how many frames have elapsed.
- Uniform representation: After K observations, each has a 128/K probability of being in the reservoir. The sample is an unbiased representation of the full history.
- Streaming percentiles: p10/p50/p90 are computed from the reservoir on demand by
sample.summarize, giving approximate quantiles without storing every observation. The profiler publishes p50 and p90 for duration.
The tradeoff: running sums and counts reflect all observations, while percentiles reflect the 128-entry reservoir. Over long runs, the average (sum/count) and median (p50) may diverge if the distribution shifts. This is expected: the average is all-time, while percentiles are sampled from the full stream.
Why This Is Good for Analytics
-
Zero overhead on the hot path:
mark/donedo twogcinfo()calls, oneos.clock()call, and a few arithmetic operations. No allocations on the steady-state path (one-time init only). -
Bounded memory per node: 128-sample reservoir means memory usage is O(nodes), not O(nodes * frames).
-
Self-describing payloads:
convert()produces a nested structure with labeled stats that maps directly to analytics schemas. Each node is a metric with dimensions (label hierarchy) and measures (time, memory, inverse memory). -
Closed-loop accounting:
mem.netis a single number that answers "is the system leaking?" without requiring external heap snapshots or GC instrumentation. The answer is mathematically exact over any time window. -
Hierarchical attribution: The scope tree naturally mirrors your system architecture. Parent nodes aggregate children. You get both per-handler detail and system-level summaries from the same tree.