Skip to content

GcWatch

Samples the Lua heap and derives garbage collector activity from it, as allocation rate, cycle cadence and reclaim size.

Realm
Shared
Require
ReplicatedStorage.ChloeKernel.GcWatch
Since
v0.3.0
Source
GcWatch.luau

GcWatch reads gcinfo() on a schedule and turns the heap curve into three numbers: how fast the game allocates, how often the collector completes a cycle, and how much each cycle frees. Roblox exposes no GC pause timings and collectgarbage only answers "count", so the heap size is the only signal there is. GcWatch attaches the same way to a server or a client kernel. ErrorWatch is the sibling for script errors. When GcWatch shows churn, Pool and TablePool are the fixes.

GcWatch.attach(kernel, options?) reads the heap once, then samples it with kernel.Scheduler:every(SampleSeconds, ..., kernel.Priority.Background), every 0.1 s by default. Each sample compares the heap with the previous one:

  • Growth is allocation and adds to the current window. If the heap had been shrinking, the growth also closes one GC cycle: Cycles goes up by one, and the shrink run’s total becomes LastReclaimKb and adds to ReclaimedKb.
  • A drop means a sweep is freeing memory. Consecutive drops add up to one reclaim run, which stays open until the heap grows again.
  • An unchanged heap books nothing.

Every WindowSeconds (default 5) the window closes: AllocKbPerSec becomes the window’s growth divided by its length, CyclesPerMin becomes the window’s cycles scaled to a minute, and both counters reset. The rates describe the last closed window. A leak that starts ten minutes into a session shows up in the next window instead of being averaged away.

The numbers are approximate. Luau’s collector is incremental, so sweeps interleave with allocation, and one sample interval can net a grow and a shrink into a single delta. Allocation that happens while the heap shrinks overall is not counted. Read the shape, not the exact kilobytes.

Allocation rate is the number to act on. Everything allocated has to be marked and swept later, and the incremental collector does that work in steps inside your frames. Allocating less is the only way to spend less time collecting.

The stats together separate the two memory failure modes:

Heap curve Cycles and reclaims Diagnosis
Climbing Reclaims near zero Leak. The collector runs and finds nothing to free. Look for insert-only tables, connections never disconnected, and per-player state that outlives the player (session:bind exists for this).
Stable Frequent cycles, steady reclaims Churn. Tables, strings or instances are created every frame and thrown away. Pool or hoist them.

A server service that logs the numbers every 30 seconds:

src/Server/Game/Services/HealthService.luau
--!strict
-- HealthService: logs heap size and GC rates every 30 seconds.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local GcWatch = require(ReplicatedStorage.ChloeKernel.GcWatch)
local Logger = require(ReplicatedStorage.ChloeKernel.Logger)
local Log = Logger.scope("Health")
local HealthService = {
Name = "HealthService",
}
function HealthService.init(self: any, kernel: any)
self.Kernel = kernel
end
function HealthService.start(self: any)
local Kernel = self.Kernel
self.Gc = GcWatch.attach(Kernel)
self.Report = Kernel.Scheduler:every(30, function()
local Stats = self.Gc:stats()
Log.info(
`heap {math.round(Stats.HeapKb / 1024)} MB, alloc {math.round(Stats.AllocKbPerSec)} KB/s, {Stats.CyclesPerMin} cycles/min`
)
if Stats.AllocKbPerSec > 4096 then
Log.warn("allocating over 4 MB/s: collector steps are taking frame time")
end
end, Kernel.Priority.Background)
end
function HealthService.stop(self: any)
if self.Report then
self.Report:cancel()
end
if self.Gc then
self.Gc:destroy()
end
end
return HealthService

If the game already calls kernel:enableDiagnostics(), it attaches a GcWatch for you. See Diagnostics integration.

Member Description
GcWatch.attach(kernel, options?) -> GcWatch Reads the heap once and starts the sampler on kernel.Scheduler at Priority.Background, unless SkipLoop. Reading gcinfo() is a counter read.
watch:stats() -> Stats The current snapshot (fields below).
watch:destroy() Cancels the sampler. Safe to call more than once.
watch:_sample(now) Takes one sample at clock time now. The spec seam for SkipLoop.

Options, all optional:

Field Default Description
SampleSeconds 0.1 Sampling interval.
WindowSeconds 5 Rate window. AllocKbPerSec and CyclesPerMin recompute and reset on this cadence.
Heap gcinfo Heap reader in KB. Injectable for specs.
Clock os.clock Clock for windows. Injectable for specs.
SkipLoop false Schedule nothing. The caller drives _sample(now).

Stats fields:

Field Meaning
HeapKb The most recent heap sample.
PeakHeapKb Highest sample since attach.
AllocKbPerSec Allocation rate over the last closed window.
CyclesPerMin Completed GC cycles per minute over the last closed window.
Cycles Cycles (a shrink run followed by growth) since attach.
LastReclaimKb KB freed by the most recent complete shrink run.
AvgReclaimKb ReclaimedKb / Cycles, or 0 before the first cycle.
ReclaimedKb Total KB freed since attach.

Before the first window closes, stats() estimates both rates from the partial window once at least 1 second has passed. Before that the rates read 0. Attach at boot so the numbers mean something when you first look.

kernel:enableDiagnostics(intervalSeconds?, extend?) stores a GcWatch on kernel.GcWatch (reusing one that is already there) and writes its stats every interval (default 5 s) as attributes on the per-role Configuration ReplicatedStorage.ChloeKernelDiag{Role}, which is ChloeKernelDiagServer on the server:

Attribute Source
GcAllocKbS AllocKbPerSec, rounded.
GcCyclesMin CyclesPerMin, one decimal.
GcReclaimKb LastReclaimKb, rounded.
LuaHeapMb gcinfo() / 1024, read directly on each tick.
MemoryMb Stats:GetTotalMemoryUsageMb(), the process total from the operating system.

Since 0.9.0 MemoryMb is written on every build. The total does not depend on Stats.MemoryTrackingEnabled, so live servers report it too; before 0.9.0 it was written only in Studio or with memory tracking on. The diagnostics task runs at Priority.Kernel, and kernel:shutdown() destroys kernel.GcWatch.

The F8 debug panel reads the server attributes into graded GC and memory rows in its SERVER window. Its CLIENT window attaches its own GcWatch when F8 opens and destroys it when F8 closes, so a hidden panel samples nothing. The rest of the diagnostics surface is on Kernel and boot.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.