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.
Mental model
Section titled “Mental model”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:
Cyclesgoes up by one, and the shrink run’s total becomesLastReclaimKband adds toReclaimedKb. - 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:
--!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 = kernelend
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() endend
return HealthServiceIf the game already calls kernel:enableDiagnostics(), it attaches a GcWatch for you. See
Diagnostics integration.
API reference
Section titled “API reference”| 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.
Diagnostics integration
Section titled “Diagnostics integration”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.