---
title: "Performance & memory"
slug: /workflows/perf-memory/
markdown: /workflows/perf-memory.md
section: performance
tier: 2
summary: "Per-frame cost, allocation, logging and silent-zero configs."
sources: ['unity/perf-memory.md']
canonical: https://unityworkflowlibrary.dev/workflows/perf-memory/
read_first: /read-first.md
---

> Read first, on every prompt: [non-destructive rules](/read-first.md).

# Performance & memory

> 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 raw `Debug.Log/LogWarning/LogError`
  for genuine one-shot flow (connect/disconnect, world-load, session dispose) and real errors. The tell
  you got it wrong: a `Debug.Log` whose 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 `_warned`
  bool so it logs once, not every call.

- **Hot paths allocate nothing and scan nothing per frame.** `Update`/`LateUpdate`/`OnGUI` (and per-tick
  system `OnUpdate`) 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 a `List`/`HashSet`/array (hoist it to a member, `Clear`
  and reuse), LINQ/`ToArray`, string interpolation into a per-frame log or `SetText` (cache it, rebuild
  only on change), and `GetComponent*`/`Find*ByType`. Special trap: a `GetComponentInParent<TInterface>`
  that returns null every frame makes Unity build an internal error string each call (steady ~1KB/frame
  GC, profiler marker `GetComponentNullErrorMessage`) — cache the ref at spawn/`Awake`, restrict the
  raycast with a `LayerMask`, or call a concrete `TryGetComponent<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.name` mints a new managed string on every read.**
  `material.name`, `gameObject.name`, `clip.name` inside 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]`, a `RenderTexture`, or a re-tessellated mesh deferred to
  first-use surfaces as a multi-MB mid-game hitch (real 4 MB spikes here). Allocate in `Awake`/`OnEnable`,
  or register a `ClientWarmup` step so the cost lands behind the loading screen; for a buffer rebuilt
  repeatedly, reuse a static grow-only scratch array instead of `new` per rebuild.

- **Invalidate a cache on the specific input that changed, never a blanket `force`-clear wired to a
  broad event.** A `Rebuild(force:true)` subscribed to a generic `SettingsChanged`/`OnChanged` event
  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 = 1` is 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.WaitForGPU` in 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](/workflows/csharp-style/) page.

## Audio — world SFX density

- **A voice-pool size is not a loudness cap.** Round-robin steal of the oldest `AudioSource`
  still 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 → default` so 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 = 256` turned 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.Render` timing 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 a `finally` (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 `beginContextRendering`
  camera list, and re-enabling it in `Application.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.enabled` left on during the developer's
  live play wrote 77 GB to `Temp/Profiles` in ~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
  `NativeArray` allocation 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 `maxTextureSize` in kept metas, and gate it with an import check.
- **PARTICLE-COST-NEEDS-A-REAL-DRAW .** `maxParticles` is a ceiling and `Camera.Render` time 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.BeginSample` markers issued from event handlers or
  from an injected `PlayerLoop` delegate never appeared in `HierarchyFrameDataView` in Editor Play (`profileEditor`
  off). Map an event to its frame by stamping `ProfilerDriver.lastFrameIndex + 1` when it fires (lag measured as one
  frame), then read that frame's hierarchy. Editor frame time is padded by `EditorLoop` to about 16.7 ms, so judge
  game work by the `PlayerLoop` total. 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 managed
  `EntityManager` get/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](/workflows/html-css-to-ui-toolkit/) page.

- **MAIN-THREAD-FILE-IO-STALLS-THE-TICK .** Slow dedicated-server frames measured 60-381 ms in
  `Update.ScriptRunBehaviourUpdate` while 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 with `Directory.GetFiles` 4x/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 a `ProfilerMarker` so a
  slow-frame breakdown names it instead of "scripts: not timed".

- **PROFILER-RECORDER-MISSES-LATE-MARKERS .** A `ProfilerRecorder` set built from
  `ProfilerRecorderHandle.GetAvailable` never records a marker registered afterwards, and a `static readonly
  ProfilerMarker` registers 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 `ExportPhysicsWorld` 51 ms
  and `HybridLightBakingDataSystem` 29.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: `GetSystemTimes` and
  `GetProcessTimes` over 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 `GetSystemTimes` read 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; `GetProcessTimes` in 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.batches` and its siblings no longer
  exist in Unity 6. Read render counters with `ProfilerRecorder.StartNew(ProfilerCategory.Render, "SetPass Calls Count")`
  (likewise "Triangles Count") and read `LastValue` in 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 a `camera.Render`
  capture 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 `LastValue` for 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.
