Tier 2 · Performance and memory
Performance & memory
Per-frame cost, allocation, logging and silent-zero configs.
Per-frame cost, allocation, logging and silent-zero configs.
Per-frame cost, allocation, and the silent-config family — values that read back as 0 and quietly disable the feature they gate.
-
Routine per-action logs go through a gated logger — raw
Debug.Log*is for one-shot lifecycle and real errors only. Every system that logs a routine OUTCOME (item picked up, piece placed, upgrade accepted/rejected, RPC sent/received) adds console flood + a per-call string-interpolation alloc + (in editor) a managed stack-walk per log — and these accumulate silently as systems multiply until the console is unusable and the editor stutters (this is a recurring, real failure here). Route them through a[Conditional("RJ_VERBOSE")]logger (RjLog.Verbose) so the call AND its argument evaluation compile out when the define is off (the default). Keep rawDebug.Log/LogWarning/LogErrorfor genuine one-shot flow (connect/disconnect, world-load, session dispose) and real errors. The tell you got it wrong: aDebug.Logwhose enclosing method runs per-action/per-tick/per-frame, or that just confirms a normal outcome. Server RPC-handler rejection logs are hostile-spammable (a client floods the RPC) — always gate those. A one-shot warning that can re-fire must latch behind a_warnedbool so it logs once, not every call. -
Hot paths allocate nothing and scan nothing per frame.
Update/LateUpdate/OnGUI(and per-tick systemOnUpdate) run every frame, so any garbage there feeds incremental GC and any scan is O(n) every frame. BANNED in a hot path:new-ing aList/HashSet/array (hoist it to a member,Clearand reuse), LINQ/ToArray, string interpolation into a per-frame log orSetText(cache it, rebuild only on change), andGetComponent*/Find*ByType. Special trap: aGetComponentInParent<TInterface>that returns null every frame makes Unity build an internal error string each call (steady ~1KB/frame GC, profiler markerGetComponentNullErrorMessage) — cache the ref at spawn/Awake, restrict the raycast with aLayerMask, or call a concreteTryGetComponent<T>instead of walking a single-implementer interface. Gate the whole hot path on an early-out when nothing changed (pointer didn't move, the set is empty). -
OBJECT-NAME-READ-ALLOCATES —
UnityEngine.Object.namemints a new managed string on every read.material.name,gameObject.name,clip.nameinside a per-camera / per-frame path (a shadow-mode decision keyed on "is this the foliage material", a tag test, a log guard) is a hidden allocation per call — the class header can say "a camera pass allocates nothing" and be wrong. Resolve the answer once at registration and cache the bool/int; compare by reference or by a cached id, never by name, in a hot path. (a tower-defense game: ported verbatim from a mobile strategy game, caught by the lane B worker before it landed.) -
Pre-allocate big buffers/textures/meshes up front — never lazily on the first gameplay frame that needs them. A
new Color32[1024*1024], aRenderTexture, or a re-tessellated mesh deferred to first-use surfaces as a multi-MB mid-game hitch (real 4 MB spikes here). Allocate inAwake/OnEnable, or register aClientWarmupstep so the cost lands behind the loading screen; for a buffer rebuilt repeatedly, reuse a static grow-only scratch array instead ofnewper rebuild. -
Invalidate a cache on the specific input that changed, never a blanket
force-clear wired to a broad event. ARebuild(force:true)subscribed to a genericSettingsChanged/OnChangedevent re-bakes expensive state (a tessellated cursor, a baked texture) when an UNRELATED setting moves (a volume slider clearing the cursor cache). Key the clear to the field that matters; if you must listen to a broad event, diff the relevant value and early-out when it's unchanged. -
FRAMES-IN-FLIGHT-SERIALIZE-CPU-AND-GPU -
QualitySettings.maxQueuedFrames = 1is a latency trade that only pays when the GPU is the bottleneck. At 1 the main thread waits at the top of every frame for the previous frame's GPU work (GfxDeviceD3D12.WaitForLastPresentation.WaitForGPUin the profiler), so frame time becomes CPU + GPU instead of max(CPU, GPU). CPU-bound, a second queued frame adds no input latency, so 1 is pure loss; in a fixed-tick netcode game the longer frame also buys extra catch-up ticks, which compounds. With vSync on, 1 halves the rate whenever CPU + GPU crosses one refresh: a menu that runs at 94 fps uncapped locked at 30 fps on a 60 Hz display. Measured by flipping the value live in one Play session: 47.2 -> 30.5 ms per frame, main thread 36.7 -> 21.5 ms, server ticks per frame 2.7 -> 1.8. Before setting 1 for aim latency, measure CPU vs GPU per frame (FrameTimingManager) on the real target and A/B the value live; a large WaitForGPU marker on the main thread is the tell.
Zero managed allocation by default is a hard coding standard: see ZERO-ALLOCATION-BY-DEFAULT on the C# style & conventions page.
Audio — world SFX density
-
A voice-pool size is not a loudness cap. Round-robin steal of the oldest
AudioSourcestill plays every attack — 16 overlapping copies of the same swing is a wall of noise. Cap concurrent world/3D voices, new starts per frame, and copies of the same clip. Drop the extra shot; steal only if the new source is meaningfully closer than the farthest playing 3D voice. Never steal a playing 2D/UI voice. Pool size stays larger than the cap so UI still has idle sources. Authored numbers, with<= 0 → defaultso a newly added serialized field on an existing config asset cannot silently disable the cap. -
NEAR-FIRST-STREAMING-BUDGET — a per-tick budget walked over a category-major catalog streams the HORIZON first. A linear scan spent the whole budget on the 1,050–3,400 m bands before the first tree at the spawn; prioritise near bands with a second pass. And a budget cap is a shock absorber, not the fix:
MaxConvexBuildsPerScan = 256turned a 99 s frame into a 16 s drain per sim world that held readiness — and the motor — down the whole time. Cover the drain with a gate and DO LESS WORK. (a survival game) - RENDER-BENCH-PROVES-PIXELS — an instancing number counts only after emission AND pixels are
proven. A path that draws nothing measures FASTER than an empty scene and looks like a win;
Camera.Rendertiming is CPU-side (a flat 4.49 ms at 1M instances while the GPU pushed ~500M verts); a pipeline flag being ON in the asset proves nothing about it working — A/B it before crediting it; and per-frame resubmission hooks bill the ENTIRE editor, so arm / measure / disarm in afinally(one armed hook took the editor to 98 GB). (a survival game) -
**FORCED-ARM-SHOWS-ITS-COST ** - a bench arm that forces a gated camera back on counts only after it measurably costs something. Two HDRP forcing methods rendered nothing, with CPU and GPU time identical to the gated arm over 240 frames: inserting the disabled camera into the
beginContextRenderingcamera list, and re-enabling it inApplication.onBeforeRender. The "every skipped frame reads black" sweep built on them proved nothing. Force it through the code that owns the enabled flag (make its own visibility test pass), and read the forced arm's cost before trusting its pixels. -
GPU-WARMUP-NEEDS-A-REAL-DRAW — a first-view hitch is shader variants the gameplay path never compiled; enabling culled renderers warms nothing. Warm with an actual draw of the variant (an off-screen camera or a one-frame visible quad), and measure the second entry. (a project
docs/pitfalls/globe-morph.md:8,27.) -
PROFILER-CAPTURE-IS-BOUNDED — a Profiler binary log armed from a bridge call never stops on its own.
Profiler.logFile+enableBinaryLog+ProfilerDriver.enabledleft on during the developer's live play wrote 77 GB toTemp/Profilesin ~45 min and held the Editor at 17-20 fps; the developer read it as "the game is laggy" and the raw file was unopenable. Capture seconds, disarm in the same call chain (finally), delete the raw once read, and never leave a capture armed across turns or a context compaction. (a space sandbox game.) -
EDITOR-GC-IS-AN-UPPER-BOUND - an Editor Play allocation census is not the shipped number. Three inflators measured: profiler-gated systems (a NetCode metrics collector showed 1.06 KB/frame; it returns early unless the Profiler is on and is compiled out of players); Collections safety checks (every
NativeArrayallocation creates managed safety objects, so a vendor call making six arrays reads as fat managed garbage); and Deep Profile itself, which slowed frames enough to trip wall-clock load gates twice. Confirm an allocation in a development player before optimizing it. - TEXTURE-DEFAULT-CAP-SHIPS-WHEN-STANDALONE-IS-NOT-OVERRIDDEN . Generated 2048 maps shipped at
1024 DXT: the importer's Default platform size and format applied because Standalone was not overridden. Set
the Standalone override in the import script (source size, BC7 colour, BC5 normals, streamed mips), re-apply
it after a rebuild that rewrites
maxTextureSizein kept metas, and gate it with an import check. -
PARTICLE-COST-NEEDS-A-REAL-DRAW .
maxParticlesis a ceiling andCamera.Rendertime is CPU-side while particle work runs on the GPU; neither measures an effect's cost. Measure a real draw with the effect's pixels on screen (GPU frame time in a capture); see GPU-WARMUP-NEEDS-A-REAL-DRAW. -
PROFILER-MARKERS-FROM-EVENTS-ARE-INVISIBLE .
Profiler.BeginSamplemarkers issued from event handlers or from an injectedPlayerLoopdelegate never appeared inHierarchyFrameDataViewin Editor Play (profileEditoroff). Map an event to its frame by stampingProfilerDriver.lastFrameIndex + 1when it fires (lag measured as one frame), then read that frame's hierarchy. Editor frame time is padded byEditorLoopto about 16.7 ms, so judge game work by thePlayerLooptotal. A hitch on an event edge (level-up, purchase) is often structural work on that frame: destroying and recreating prototype entities with about 30 component adds each, or a managedEntityManagerget/set loop over every live unit. Keep the entities for the session and write values in place; for text, see DYNAMIC-FONT-FIRST-GLYPH-SPIKES-THE-FRAME in the HTML/CSS mock to UI Toolkit page. -
MAIN-THREAD-FILE-IO-STALLS-THE-TICK . Slow dedicated-server frames measured 60-381 ms in
Update.ScriptRunBehaviourUpdatewhile every ECS world summed about 1 ms. The only MonoBehaviour Update was an operator console that wrote a heartbeat file every 2 s, scanned a command inbox withDirectory.GetFiles4x/s and flushed an event log per line, so a busy disk (an Editor build or antivirus on the same machine) stalled the simulation tick. Give each file writer one background thread: the main thread formats and queues, the heartbeat is latest-wins (a hung main thread still leaves it stale). Wrap each section of that Update in aProfilerMarkerso a slow-frame breakdown names it instead of "scripts: not timed". -
PROFILER-RECORDER-MISSES-LATE-MARKERS . A
ProfilerRecorderset built fromProfilerRecorderHandle.GetAvailablenever records a marker registered afterwards, and astatic readonly ProfilerMarkerregisters only when its type's statics initialize. Declare the markers on types constructed before the recorders are enumerated, or rebuild the recorder set once they exist. -
ONE-SAMPLE-SPIKE-IN-AN-IDLE-SYSTEM-IS-STARVATION . A slow-frame breakdown named
ExportPhysicsWorld51 ms andHybridLightBakingDataSystem29.8 ms in a server's idle local world, while across 26 slow-frame breakdowns the same groups read 0.1-1 ms (median 0.3): the process was descheduled mid-system (the machine also ran an Editor at 3.3 cores idle and two more Unity processes). Before fixing a system, compare its spike with its typical cost across all breakdowns; a single outlier in an otherwise idle system is CPU starvation, and a large untimed share of slow frames is the same signature. Amended: the direct proof is machine versus process CPU for the slow frame (Windows:GetSystemTimesandGetProcessTimesover the frame). An empty dedicated server whose sleep-mode tick warnings climbed from 297 to 3513 in 20 minutes read machine 97-100 % busy and its own process 0-1 %: other programs (patch hashing on every core, compiler and player builds) held the CPU. Print both numbers on every slow frame. Measure capacity on a quiet machine, and tell the developer plainly: the PC was busy with other programs; the server was waiting, not overloaded. Amended: read those counters on a background thread, never per frame on the game thread, and only trust the reading when the slow frame's time is NOT inside the CPU read itself (see CPU-COUNTERS-OFF-THE-GAME-THREAD). -
CPU-COUNTERS-OFF-THE-GAME-THREAD . Windows
GetSystemTimesread once per frame on the main thread cost 0.15-0.2 ms (p99 0.7 ms, max 19 ms in a 60 Hz probe) and held an idle dedicated server's main thread 55-176 ms in 3 of 3 slow frames;GetProcessTimesin the same probe never passed 0.5 ms. A machine/process delta taken across that stall reads "machine 100 % busy, this process 0 %" while 5 s machine samples around it read 17-51 %, so the observer invents starvation. Sample the counters on a background thread (every 50 ms into a small ring), make no syscall on the game thread, and report the span a reading covers. Wrap each call of a per-frame observer in its own profiler marker so a slow frame can name the observer. -
EDITOR-RENDER-STATS-VIA-PROFILER-RECORDER .
UnityEditor.UnityStats.batchesand its siblings no longer exist in Unity 6. Read render counters withProfilerRecorder.StartNew(ProfilerCategory.Render, "SetPass Calls Count")(likewise "Triangles Count") and readLastValuein a LATER call, after frames have rendered. In a URP Editor, "Batches Count" and "Draw Calls Count" read 0, so budget on SetPass and triangles there, and acamera.Rendercapture in the same call inflates the counters. See PROFILER-RECORDER-MISSES-LATE-MARKERS above. -
SURFACEFLINGER-LATENCY-IS-EMPTY-ON-ANDROID-16 .
adb shell dumpsys SurfaceFlinger --latency <layer>returns only the refresh period for a Unity SurfaceView on Android 16 (BLAST layer names), with no frame rows. Take device fps from the game's own FPS readout or Unity's frame timing and profiler, and never report an empty latency dump as a measurement. -
SETTING-COST-IS-SWITCHED-IN-PLACE . Measure one render setting's cost by switching it on a static scene, never by comparing two runs or two moments of a live wave; unit counts drift between reads, so the pair mixes the setting's cost with the wave's. Freeze the scene (a results screen with a fixed camera), then switch the value in separate calls: each call reads the counter's
LastValuefor the value set in the previous call, then sets the next value (see EDITOR-RENDER-STATS-VIA-PROFILER-RECORDER above). End by re-reading the first value; an identical number proves there was no drift. Measured: terrain pixel error 5 / 2 / 3 / 5 read 412,777 / 500,305 / 460,601 / 412,777 (+87.5k for 2), while a cross-run pair had reported +95.7k.
Related incident rules
- BUDGETING-SPREADS-COST-DOES-NOT-REMOVE-IT — a per-frame cap on expensive boot work only moves the total. Why: frame budgeting changes distribution, not work. Check: measure total cost before budgeting; remove or cache the work first, then budget what remains. See also LOADING-WORK-IS-FRAME-BUDGETED (the scene flow page). Status: locked (empirical, seen in shipped/active projects).