Skip to main content
Most of what this extension reports is about whether code is correct. This one is about whether the server keeps its tick rate. Garry’s Mod runs some of your functions sixty-odd times a second, forever. The work inside them is usually fine on its own, which is why it survives review — a Material lookup or an ents.FindByClass sweep looks like nothing. It stops looking like nothing when it happens on every frame, four calls below a hook you wrote a year ago.
Neither line looks wrong where it is written. The problem lives in the join, and the join is in another file.

How it works

The workspace index builds a call graph: every function body, and every call it makes that lands on a function this workspace defines. From there, it starts at the places the engine calls into and walks outwards. Entry points are the things that run on a schedule: A hook registered by name counts too — hook.Add("Think", "id", MyAddon.Update) makes MyAddon.Update an entry point, the same as writing the function inline. Repetitions matter for a timer, and only 0 means forever: timer.Create("x", 0.1, 1, fn) fires once and is left alone however expensive it is, because what it costs is bounded. Running forever is the whole premise. Anything reachable from one of those, up to six calls deep, is a hot path. A call that costs something and sits on one gets reported where it is written, with the chain that reaches it:

What counts as expensive

Roughly forty calls, in five groups:
surface.CreateFont, hook.Add, hook.Remove, timer.Create, concommand.Add, CreateConVar, CreateClientConVar, util.AddNetworkString, net.Receive, sound.Add, language.Add, killicon.Add, resource.AddFile, include, AddCSLuaFile, CompileString, RunString, CompileFile.A registration inside a per-frame function replaces itself before it can do anything. surface.CreateFont in a paint hook is the classic version of this, and the most expensive.
file.Read, file.Write, file.Append, file.Find, file.Exists, file.Size, file.Time, sql.Query, sql.QueryRow, sql.QueryValue, http.Fetch, http.Post, HTTP.
util.TableToJSON, util.JSONToTable, util.TableToKeyValues, util.KeyValuesToTable, util.Compress, util.Decompress, util.CRC, util.Base64Encode, util.Base64Decode, table.Copy.All of these walk the whole value. Doing that when the data changes rather than when it is read is usually a one-line change.
ents.GetAll, ents.FindByClass, ents.FindByClassAndParent, ents.FindByModel, ents.FindByName, ents.FindInSphere, ents.FindInBox, ents.FindInCone, ents.FindAlongRay, player.GetAll, player.GetHumans, player.GetBots, team.GetPlayers.
Material and surface.GetTextureID look their argument up by string; GetConVarNumber and GetConVarString look the console variable up by name. net.Start and the SetNW* / SetNW2* setters send to every client — a value written every tick is bandwidth every tick, whether or not it changed.

What it leaves alone

A per-frame function that rate-limits itself is not a hot path, and reporting on one is the fastest way to get a rule switched off. Three shapes are recognised:
Anything mentioning CurTime, RealTime, SysTime, UnPredictedCurTime, FrameNumber, engine.TickCount or os.clock counts as a time guard, as does a name beginning next, last, cooldown or delay. Calls behind one are skipped, and so is anything they reach. A not x or x == nil condition counts as a one-time gate, but only where it wraps a block — the third example above. As an early return it is a validity check rather than a rate limit, and the rest of the body still runs every frame:
Nor does a call inside the condition make one: IsValid(ply) is re-evaluated every run, so nothing behind it happens only once. A guard also only covers what it actually gates. Registering a hook behind one says nothing about the callback:
The condition decides whether the hook is added. Once it is, the callback runs every frame like any other, so a function body only counts as guarded by the conditions opened inside it. Hooks that fire on an event rather than a clock — PlayerSay, PlayerSpawn, EntityTakeDamage — are not entry points at all.

The quick fix

Material and surface.GetTextureID with literal arguments can be lifted out of the frame mechanically, so the light bulb offers it:
Only offered when every argument is a literal. A variable or a concatenation might differ between calls, and hoisting it would change what the code does rather than how often it does it. The local goes immediately above the statement that writes the function, rather than at the top of the file, so it stays inside every guard the call site was already under:
Lifted to the top of the file instead, that line would run on the server, where surface does not exist.

Seeing the whole picture

GLua: Project Report — or glua doctor on the command line — has a Hot paths section listing the findings furthest from their entry point, since those are the ones nobody spots by reading a single file. It also counts how many entry points the project has, and which expensive calls turn up most often across all of them.

Turning it down

The rule is perf-hot-path, keyed perfHotPath, and reported as a warning:
Suppress a single finding inline when the cost is deliberate — a cache being built on purpose, work that really does have to happen every frame:

Reading the call graph directly

The same index answers call hierarchy, so you can walk it yourself rather than waiting for a finding. Put the cursor on a function and use your editor’s call hierarchy command — Shift+Alt+H in VS Code — to see who calls it, or switch the tree to outgoing calls to see what it reaches. Callbacks registered on a hook are named after the hook they serve, so a chain that ends at HUDPaint says so.