Profiling logo

Profiling

Community
kevinpbuckley
profiling

Diagnose frame-rate bottlenecks (CPU vs GPU bound FIRST), control Unreal Insights traces, sample live frame times, and annotate performance captures. Use when FPS is low/bad, the game is slow, or you need to find what is limiting the frame rate.

Overview

Publisherkevinpbuckley
RepositoryVibeUE
Skill nameprofiling
Stars
735
Forks
161
Bundled files
Instructions only
LicenseMIT
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • Self-contained

    Everything the model needs lives in the instructions — no extra files to sync.

  • Open source

    Published by kevinpbuckley on GitHub. Read the source before you install it.

Installation

Install the Profiling AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/kevinpbuckley/VibeUE/tree/master/Content/Skills/profiling
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/kevinpbuckley/VibeUE.git /tmp/VibeUE
mkdir -p .claude/skills
cp -r /tmp/VibeUE/Content/Skills/profiling .claude/skills/profiling
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Profiling in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Profiling on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Profiling is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

Profiling Skill

Unreal 5.8's native toolsets have no performance or tracing tools, so this is VibeUE's net-new capability: unreal.PerformanceService answers "are we CPU- or GPU-bound?" and drives Unreal Insights captures from Python with no C++ required. Run everything here through execute_python_code.

The whole API is small — read the live signatures once with discover_python_class(class_name="unreal.PerformanceService"):

MethodPurpose
frame_timing(target_fps=60.0)Game/Render/GPU/RHI thread ms + a CPU-vs-GPU bound verdict + a hint, plus a per-thread budget breakdown gated against target_fps (budget.meets_target PASS/FAIL). Run FIRST.
force_hitch(thread="game", ms=250.0, frames=1)Validation helper — deliberately stall a KNOWN thread ("game"/"render"/"both"/"gpu") to confirm the verdict fires. CPU paths are self-measuring: validate from the SAME response (observed_peak_*_ms, verdict_matched_expect) — do NOT follow up with frame_timing. Only the async gpu path is read back on a following frame. Clamped (≤5000 ms, ~10 s total) for safety.
report(title=..., source="both", file="")Write a self-contained, shareable HTML report (live verdict + budget + analyse stats + a data-driven "fix in this order" list) to Saved/VibeUE/Performance/report_<timestamp>.html.
start_pie() / stop_pie()In-process PIE so frame_timing/force_hitch read a live game world (PIE starts on the next tick — read on a following frame, pie_running flips true). Quick checks only; prefer start_standalone when the numbers must be trusted.
start_trace(name="mcp_capture", channels="")Start an Insights trace to file (default channel set if channels empty).
stop_trace()Stop the active trace; returns the trace file path + size.
get_trace_status()Whether a trace is active and which channels are enabled.
bookmark(name)Drop a point-in-time bookmark in the active trace.
region_start(name) / region_end(name)Begin / end a named region span in the active trace.
analyse(source="both", file="")Read back trace and/or log → frame stats, worst frames, hitches, notable log lines. Combined analysis reports complete, partial, or failed; partial is never top-level success.
start_standalone(name="standalone_capture", channels="")Request a separate process with a unique direct-to-file trace. The first response is start_pending; poll status for verified capture.
stop_standalone()Gracefully stop the exact tracked process and verify the exact trace finalized; forced/partial outcomes are explicit failures.
get_standalone_status()Session ID, PID, map, exact trace/log destinations, process state, and capture/finalization verification.

All methods return a JSON string. For a representative reading, profile under PIE or a standalone session (start_standalone), not the bare editor viewport.

🚦 STEP 0 — Is it CPU-bound or GPU-bound? (DO THIS FIRST, ALWAYS)

Never optimise before you know which processor is the bottleneck. The frame time is roughly max(GameThread, RenderThread, GPU) — these run in parallel, so only the longest one sets your FPS. Cutting GPU cost (shadows, Lumen, post-process) does nothing if the frame is actually game-thread or render-thread bound, and vice-versa. Getting this wrong wastes the whole session.

Fastest check — one call

python
import unreal, json
result = json.loads(unreal.PerformanceService.frame_timing())
print(result)  # game_thread_ms, render_thread_ms, gpu_ms, rhi_thread_ms, frame_ms, bound, hint

Start PIE first (see the pie-testing skill) and park in a representative/worst spot, then call it. It returns game_thread_ms, render_thread_ms, gpu_ms, rhi_thread_ms, the frame_ms, a bound verdict (GameThread / RenderThread / GPU), and a hint with what to do next. This is the same data the stat unit overlay shows, read straight from engine globals — no screenshots, no trace parsing.

⏱️ Frame-time budgets — what a target FPS actually costs

FPS is just 1000 / frame_ms. Because the threads run in parallel, every thread (GameThread, RenderThread, GPU) must individually finish inside the budget below — the slowest one alone sets your FPS. A 12 ms GPU is wasted if the game thread takes 25 ms: you still get ~40 FPS.

Target FPSPer-frame budgetMeaning
30 FPS33.33 msMaximum allowable time per frame
60 FPS16.66 msMaximum allowable time per frame
120 FPS8.33 msMaximum allowable time per frame
240 FPS4.16 msMaximum allowable time per frame
360 FPS2.77 msMaximum allowable time per frame

Read frame_timing() against this table: if game_thread_ms = 25, you are hard-capped at ~40 FPS no matter what you do to the GPU. To hit 60 FPS the game thread must drop under 16.66 ms; to hit 120 FPS, under 8.33 ms. Always state the bottleneck thread's ms next to the target budget so the gap is explicit (e.g. "game thread 25 ms vs 16.66 ms for 60 FPS → must cut 8.3 ms on the game thread").

You don't have to do this arithmetic yourself: frame_timing(target_fps=...) returns the gate — the budget block carries each thread's headroom / over_budget against the per-frame budget and a single budget.meets_target PASS/FAIL you can assert on (CI-style perf checks included). To prove the verdict logic works against a known ground truth, use force_hitch (see the API table).

🛠️ CVars tune the renderer — they do NOT fix the game thread

r.* console variables almost exclusively move GPU and RenderThread cost (Lumen, shadows, reflections, resolution, draw setup). There is no CVar that makes your Tick, AI, or animation logic cheaper. When bound == GameThread, the fix lives in code and Blueprints, driven by what the profiler shows:

Profiler symptom (stat dumpframe -root=gamethread)Fix lives inTypical change
High FTickFunctionTask / many ticking actorsC++ / BlueprintThrottle PrimaryActorTick.TickInterval, disable tick when idle/far, event-drive instead of polling every frame
High AnimGameThreadTime / many skeletal meshesC++ / mesh setupEnable URO (bEnableUpdateRateOptimizations), VisibilityBasedAnimTickOption = OnlyTickPoseWhenRendered
Expensive Blueprint ReceiveTickBlueprint graphMove per-frame logic to timers/events, cache results, early-out
CharacterMovement / physics / AI heavyC++ / configSignificance-based LOD, fewer simulated bodies, coarser AI update rate
Per-frame SetTimer, allocations, logging in hot pathsC++ / BlueprintRemove redundant work; gate UE_LOG behind a debug flag

So the workflow for a game-thread bottleneck is: profile → read the offending scope → edit the code/Blueprint that owns it → rebuild → re-profile to confirm. Reaching for CVars here is a category error; they will not move the number.

Decision tree

boundMeaningWhere to look next
GameThreadCPU, game threadTick / Blueprint / AI / animation. Run stat dumpframe -ms=0.5 -root=gamethread then read the log (LogsToolset). Fix is code/Blueprint, not CVars (see "CVars do NOT fix the game thread" above): throttle ticks, enable anim URO, event-drive logic. Compare the ms to the budget table.
RenderThreadCPU, render threadDraw calls & primitives, dynamic shadow-casting lights. Check stat scenerendering. Instance/merge meshes, enable Nanite, cut dynamic lights.
GPUGPUNow run ProfileGPU (below). Shadows (VSM), Lumen, translucency, resolution.

Confirm / fallback methods

  1. Numeric thread split via log — reliable, works headless:
    python
    import unreal
    w = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_game_world()
    unreal.SystemLibrary.execute_console_command(w, "stat dumpframe -ms=0.5 -root=gamethread")
    unreal.SystemLibrary.execute_console_command(w, "stat dumpframe -ms=0.5 -root=renderthread")
    Then read the dump with the engine LogsToolset (tail the main log, ~150 lines) — it prints the full thread hierarchy in ms (e.g. World Tick Time, FTickFunctionTask, individual Blueprint ReceiveTick costs). This is the single most useful CPU drill-down.
  2. Resolution-scaling test (decisive GPU-bound proof) — sample frame time at full res via frame_timing(), then r.ScreenPercentage 50, and compare. FPS jumps a lot → GPU-bound. FPS barely moves → CPU-bound.
  3. stat unit is the canonical overlay, but its numbers do not go to the log, and PIE often runs in a separate window so screenshots are unreliable. Prefer frame_timing() or methods 1–2 over trying to OCR stat unit.

⚠️ Gotchas — read before writing any code

  1. ProfileGPU tells you WHERE GPU time goes, not WHETHER you are GPU-bound. It always produces a GPU breakdown even on a CPU-bound frame — so reading it first will happily send you optimising shadows on a frame whose real cost is the game thread. Run STEP 0 (frame_timing()) first; only reach for ProfileGPU once bound == GPU.

0b. Never run ProfileGPU (or stat dumpframe) during a trace you intend to average. Each ProfileGPU stalls the GPU for a full readback (hundreds of ms to seconds), and those stall frames poison the frame-time stats. Capture clean frame-time traces separately from GPU/CPU drill-downs.

0c. analyse() reports frame-time aggregates (avg/p95/worst frames, hitches, notable log lines), not the live CPU/GPU split. Use frame_timing() for the per-thread split.

  1. Profile under PIE or standalone, not the bare editor viewport. The empty editor viewport is not representative. Start PIE (pie-testing skill) or use start_standalone().

  2. Trace files are large — a ~10 second trace with default channels is 30–50 MB. Traces land under Saved/Profiling/ (already excluded from source control).

  3. A trace must be active before you bookmark / mark regions. bookmark() and region_start()/region_end() only do something while get_trace_status() reports a live trace. Call start_trace() first.


Trace Control

Start / stop a trace

python
import unreal, json

# Default channel set (frame, cpu, gpu, log, ...) — pass channels="" or omit
res = json.loads(unreal.PerformanceService.start_trace("combat_encounter"))
print(res)  # includes the trace file path

# ... reproduce the workload ...

stopped = json.loads(unreal.PerformanceService.stop_trace())
print(stopped)  # trace file path + size

Custom channels

python
# Comma-separated channel list (short names accepted by the service)
unreal.PerformanceService.start_trace("mem_capture", "frame,cpu,memalloc,memtag,object,loadtime")

Check status

python
import unreal, json
print(json.loads(unreal.PerformanceService.get_trace_status()))  # active? which channels?

Annotations — Mark Regions and Bookmarks

Use these while a trace is active to label interesting moments in the Unreal Insights timeline.

python
import unreal

# Single point-in-time bookmark (vertical line in Insights)
unreal.PerformanceService.bookmark("spawn_wave_3")

# Named region (coloured span)
unreal.PerformanceService.region_start("loading_level")
# ... trigger the thing you want to measure ...
unreal.PerformanceService.region_end("loading_level")

Reading a capture back — analyse()

analyse() reads a finished trace and/or the log and returns a perf summary (frame stats, worst frames, hitches, notable log lines) without opening Unreal Insights:

python
import unreal, json

summary = json.loads(unreal.PerformanceService.analyse("both"))   # "trace", "logs", or "both"
print(summary)  # avg/p95/worst frame ms, hitches, notable log lines

# Analyse a specific file instead of the last trace started/stopped
unreal.PerformanceService.analyse("trace", "Saved/Profiling/combat_encounter.utrace")

For source="both", inspect status: complete means both sources succeeded, partial means exactly one did, and failed means neither did. A partial result deliberately has success=false and names available_sources / failed_sources; this prevents a missing trace from masquerading as a successful capture. Log output separates pso_hitch_event_lines, cumulative pso_hitches_reported, and pso_hitch_summary_lines; repeated cumulative summary lines are not summed. The backward-compatible pso_hitches field is the best supported count.

Remember: analyse() is frame-time aggregates only — use frame_timing() for the CPU/GPU split.


Standalone capture — representative readings

The editor viewport (and even PIE) is not always representative of a packaged run. start_standalone() launches the game as a separate standalone process with one unique, direct-to-file trace. It does not use the global Unreal Trace Server store and never substitutes an unrelated "latest" trace:

python
import unreal, json

start = json.loads(unreal.PerformanceService.start_standalone("standalone_capture"))
# start is request_accepted=true, success=false, pending=true until the child and trace exist
status = json.loads(unreal.PerformanceService.get_standalone_status())
# poll on later editor ticks until status["capture_verified"] is true

# ... let it run / drive the workload ...

stopped = json.loads(unreal.PerformanceService.stop_standalone())
# require stopped["success"] and status == "finalized" before trusting the trace

Record the returned session_id, pid, map, trace_file, and log_file with benchmark results. If shutdown is forced or the exact trace stays missing/empty, stop returns failure and may mark the available log/partial trace as partial; do not silently use another store trace. analyse("both") can still return a clearly marked log-only partial summary.

For comparisons, keep map/route, resolution, scalability, difficulty, power mode, and warm-up fixed; disable editor background throttling for unattended runs and restore its prior value afterward. Treat short or sparse samples as observations, not stable averages. Compare median/p95/p99 and frame counts above 33/50/100 ms, and separate cold-start/shader/PSO runs from warm-cache runs. Run ProfileGPU or stat dumpframe separately because their instrumentation stalls contaminate traces.


Recommended flow

python
import unreal, json

# 1. CPU vs GPU verdict FIRST (PIE running, parked in a representative spot)
print(json.loads(unreal.PerformanceService.frame_timing()))

# 2. Capture a clean trace around the workload
unreal.PerformanceService.start_trace("combat_encounter")
unreal.PerformanceService.region_start("wave_spawn")
# (trigger the gameplay here)
unreal.PerformanceService.region_end("wave_spawn")
res = json.loads(unreal.PerformanceService.stop_trace())
print("trace:", res)

# 3. Summarise without leaving Python
print(json.loads(unreal.PerformanceService.analyse("both")))

Channel Reference

start_trace / start_standalone accept a comma-separated channels string; empty uses the default set (frame, cpu, gpu, log, …). Common channels:

ChannelWhat it capturesUse for
frameFrame boundaries, wall timeAlways include — baseline for everything
cpuCPU named scopesCPU hotspots, Blueprint tick
gpuGPU pass timingsGPU bound? Where are draw calls going?
statsUE stat countersActor/component counts, frame budget
logLog output embedded in traceCorrelate log spam with frame spikes
memallocPer-allocation callstacksMemory churn, GC pressure
memtagHigh-level memory category totalsWhich system is eating RAM?
objectUObject create/destroyAsset streaming, actor spawning
niagaraNiagara system tickParticle perf
animationAnimation graph evaluationAnim Blueprint cost
netReplication, RPC timingMultiplayer performance
slateUI widget tick and paintUI overhead
loadtimeAsset streaming / load eventsLoad hitches

Channel naming may vary slightly by build; call get_trace_status() after start_trace() to see the channels actually enabled.

Common channel presets

Investigationchannels string
General (balanced)frame,cpu,gpu,stats,log
Memoryframe,memalloc,memtag,object,loadtime
Animation / characterframe,cpu,animation,stats
UI / Slateframe,cpu,slate,stats
Niagara / VFXframe,cpu,gpu,niagara
Multiplayerframe,cpu,net,stats
Load / streamingframe,loadtime,object,log

Opening Traces in Unreal Insights

analyse() covers most needs from Python, but you can open a .utrace for the full timeline UI:

Editor menu: Tools → Run Unreal Insights

Or from the command line:

"<UE install>/Engine/Binaries/Win64/UnrealInsights.exe" "<path>/combat_encounter.utrace"

Trace files are written under:

<project root>/Saved/Profiling/<name>.utrace

Frequently asked questions

What does the Profiling AI skill do?

Diagnose frame-rate bottlenecks (CPU vs GPU bound FIRST), control Unreal Insights traces, sample live frame times, and annotate performance captures. Use when FPS is low/bad, the game is slow, or you need to find what is limiting the frame rate.

Why use Profiling on TypingMind?

Because you install it once and use it with any model. Profiling is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Profiling in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/kevinpbuckley/VibeUE/tree/master/Content/Skills/profiling. TypingMind reads its SKILL.md and installs it as a skill you can enable per chat.

Which AI models can use Profiling?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Profiling?

As many as you like. As long as a model supports skills, you can use Profiling with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Profiling AI skill free?

Yes. It is published on GitHub by kevinpbuckley under the MIT license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇