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.
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:Setup that should happen once
Setup that should happen once
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.Disk, database and HTTP
Disk, database and HTTP
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.Serialisation
Serialisation
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.Sweeps over the map
Sweeps over the map
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.Lookups and networking
Lookups and networking
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: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:
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:
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:
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 isperf-hot-path, keyed perfHotPath, and reported as a warning:
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 atHUDPaint says so.