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

  1. Leak detection: If mem.net (= sum(D) + sum(I)) grows over time, the system is leaking. The rate is mem.net / count KB per frame.

  2. Allocation profiling: Per-handler mem_delta tells you who allocates the most. handler.mem_delta / parent.mem_delta gives each handler's share of total allocation.

  3. Reclamation budget: inverse_mem.total tells you how much GC is recovering between frames. If |inverse_mem.total| < mem_delta.total, GC isn't keeping up with allocation.

  4. 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

  1. Zero overhead on the hot path: mark/done do two gcinfo() calls, one os.clock() call, and a few arithmetic operations. No allocations on the steady-state path (one-time init only).

  2. Bounded memory per node: 128-sample reservoir means memory usage is O(nodes), not O(nodes * frames).

  3. 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).

  4. Closed-loop accounting: mem.net is 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.

  5. 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.