Godot Profiler Basics: Pull the Name of the Culprit Function Out of a Heavy Frame

Created: 2026-09-23Last updated: 2026-10-10

How to pull out the name of the slow function with Monitors and the Profiler when your Godot game gets heavy, and measure again after fixing it.

When your game gets heavy, the FPS number alone won't tell you what to fix. Godot's debugger comes with a tool that shows, by function name, which functions used a frame's time.

Fixes based on guesses often miss. As you cut the number of enemies or print timings with print, the code slowly falls apart in places.

This article explains how to pull out the name of the heavy function with Monitors and the Profiler, then fix it and confirm the result.

A figure stands in front of a panel with graph peaks and a list of functions, pointing to one row filled in navy

What You'll Learn

  • Why FPS alone doesn't tell you what's heavy
  • The order to open Monitors, Profiler, and Visual Profiler
  • Reading the Profiler list to tell apart total time including callees, the function's own time, and how many times it was called
  • Hands-On: Pull one heavy function out of a swarm of 300 and fix it

Tested with: Godot 4.7

Sponsored

The time available per frame

If you're aiming for 60 FPS, you need to have the next screen ready about every 16.7 milliseconds (1000 ÷ 60). In that time, physics calculations (_physics_process() and collision), game processing (_process()), and draw preparation all run.

If physics, game processing, and draw preparation fit within the roughly 16.7 ms line, the frame makes it; if game processing grows and overflows, FPS drops

If any of them takes too long, the next screen isn't ready in time and FPS drops. FPS is the result of counting how many frames were drawn about every second. It tells you it dropped, but not which part grew, or which function within it. The measurement tabs in the debugger are for looking at that.

When to use each of the three tabs

Of the tabs in the Debugger panel at the bottom of the editor, this article uses three.

  • Monitors: Records FPS, processing time, node count, and more about once a second. As long as the game is running, it records without pressing anything
  • Profiler: Records, for each frame, how many milliseconds each function took. Recording starts when you press Start
  • Visual Profiler: Records the CPU and GPU (the part responsible for drawing) time for each rendering step. This also starts recording when you press Start, and the names of functions you wrote don't appear
Branching from Monitors into three: if Process rises, the Profiler; if your functions are small, the Visual Profiler; if Nodes keeps increasing, forgotten nodes

Open them from coarse to fine. First, in Monitors, look for numbers that move along with the FPS drop. If Process has risen, look for the function name in the Profiler.

If Process is high but all your own functions are small, suspect rendering and open the Visual Profiler.

Monitors: numbers about once a second

Check an item in the list on the left of the Monitors tab, and a graph of that item appears on the right. Start with these four.

ItemWhat the number is
FPS under TimeThe number of frames drawn in about one second
Process under TimeThe longest non-physics processing time within a roughly one-second window. Includes draw preparation
Physics Process under TimeThe longest processing time for a single physics step within a roughly one-second window
Nodes under ObjectThe number of nodes currently in the tree

FPS and Process are counted differently. Even with a hitch that stalls for 0.1 seconds, the number of frames drawn in that second only drops by a few, so it doesn't stand out in FPS. Process is the maximum in the window, so it spikes only in the window where things stalled. If the stall is inside physics, the spike shows up in Physics Process instead. When things stutter now and then, look for spikes in these two rather than in FPS.

The FPS line is almost flat with only a shallow dip, while the Process line forms a tall peak only in the window with the 0.1-second stall

Nodes helps you tell what kind of load it is. If it grows every time enemies or bullets spawn and doesn't come back down when they disappear, suspect forgotten nodes rather than a heavy function. If bullets that flew off screen aren't queue_free()d, they stay in the tree and the count keeps growing.

Nodes graphs. Spawning and freeing goes up and down but returns to the same height; forgetting to free makes it keep rising to the right
Sponsored

Profiler: time per function

Use the Profiler in the order record, then select.

  1. Run the game from the editor and press Start in the Profiler tab
  2. Reproduce the heavy scene for a few seconds
  3. Press Stop
  4. Click one heavy frame in the graph on the right

Time you didn't record can't be viewed afterward. Even if you open the tab after the stutter, that spike isn't there. Recording overwrites older data, so press Stop right after reproducing the heavy scene. The Script Functions numbers shown right after pressing Stop are also a total over the whole recording, not a single frame. Click one bar partway through the graph before reading.

Script Functions in the list on the left shows your own functions that ran in that frame. The columns are Time (time taken) and Calls (number of times called in that frame).

The first thing people stumble on is the order. The list is sorted by Inclusive time, largest first. Inclusive is the total including the time of functions called from within that function. If _process() calls a heavy function, the caller, _process(), comes out on top.

Switch Time: at the top to Self, and it shows only the time the function itself used. The order stays the same; only the numbers change. Compare the Time numbers rather than the order, and find the row with the largest one. That's the function actually using the time.

The same two-row list. In Inclusive, _process is 30.0 ms; in Self it drops to 1.5 ms, while _push_from_crowd's 28.5 ms remains

In other engines: The debugger's Profiler tab corresponds to Unity's Profiler and Unreal Engine's Unreal Insights or stat commands.

If a script has a class_name (a class name given to the script), names in the list appear with the class name. With class_name Enemy, it's Enemy._process. Without one, every script's _process is listed under the same name, and the file name only appears when you hover over the row.

Built-in functions don't appear as rows in the list. With the default settings, the time for get_nodes_in_group() or distance_to() goes into the Self time of your own function that called them. The list only gives you names down to functions you wrote yourself; what's heavy inside them, you confirm by reading the code.

How to read Calls (the number of times called)

A frame's Time is the average time per call multiplied by Calls. Switch Measure: at the top from the default Frame Time (ms) to Average Time (ms), and the time per call shows directly. Even for the same 5 milliseconds, looking at Calls changes what you check next.

Calls 300 matches the number of enemies, so question whether the work is needed every frame; with Calls 1, look inside the single call

If Calls matches the number of enemies or bullets, every one of them is doing one unit's worth of work every frame. First, check whether that work is needed every frame. If it can be thinned out, thin it out; if the result only needs to be computed once, remember it and reuse it. If it really is needed every time, look into making each call lighter. If Calls is 1 but Time is large, what's inside that single call is heavy.

Hands-On: Pull one heavy function out of a swarm of 300

Enemy swarms in a survivor-like, bullets in a bullet hell, grunts in a top-down ARPG — they all have hundreds of nodes with the same script. It's where one unit's small waste gets multiplied by hundreds. Here you'll build a scene where 300 enemies close in on the player. You'll pull out the name of the function that made it heavy with the Profiler, and after fixing it, confirm that the row shrinks.

Enemies swarm the figure in the center of the screen, and in the list the Enemy._push_from_crowd row is highlighted blue with Calls 300

① What to place

Start with a new 2D project. Leave the screen size at the default 1152×648.

  • main.tscn: The root is a Node2D (named Main, set as the main scene). Add Player and Enemies as children
  • Player: A Sprite2D (icon.svg, position (576, 324), Z Index 1). Select Player, press + in the Groups dock, and create a group named player. Player gets checked when you create it (the enemies use this to find who to chase)
  • Enemies: A Node2D. The container for enemies
  • enemy.tscn: The root is a Sprite2D (named Enemy, icon.svg, Scale (0.25, 0.25)). Attach enemy.gd

Setting Player's Z Index to 1 keeps it visible instead of buried under the gathering enemies.

Turn off V-Sync while comparing. V-Sync is a setting that times frames to the screen's refresh (FPS management). Set Project Settings → Display → Window → V-Sync → V-Sync Mode to Disabled and rerun the game. With V-Sync on, FPS is capped at the screen's refresh rate, and the time spent waiting for the screen gets mixed into Process, so you can't see the improvement after fixing. Measure before and after with the same settings, and restore it when you're done.

② Put it together

Main spawns 300 enemies scattered around the screen in _ready(). Fix the seed (seed) of the random numbers used for placement so you can measure from the same layout before and after the fix.

# main.gd (attach to Main)
extends Node2D

const ENEMY_SCENE := preload("res://enemy.tscn")

@export var enemy_count := 300

func _ready() -> void:
	var rng := RandomNumberGenerator.new()
	rng.seed = 12345   # Measure from the same layout before and after the fix
	for i in enemy_count:
		var enemy: Enemy = ENEMY_SCENE.instantiate()
		enemy.position = Vector2(rng.randf_range(0.0, 1152.0), rng.randf_range(0.0, 648.0))
		$Enemies.add_child(enemy)

Each enemy heads toward the player while moving away from allies that are too close. Every enemy calls _push_from_crowd(), which looks for allies, every frame.

# enemy.gd (attach to Enemy)
class_name Enemy
extends Sprite2D

const SPEED := 60.0
const PERSONAL_SPACE := 32.0   # Move away from allies closer than this

@onready var player: Node2D = get_tree().get_first_node_in_group("player")

func _ready() -> void:
	add_to_group("enemies")

func _process(delta: float) -> void:
	var to_player := global_position.direction_to(player.global_position)
	var away := _push_from_crowd()                  # Checks everyone, every frame
	global_position += (to_player + away).normalized() * SPEED * delta

# Returns the direction to move away from allies that are too close
func _push_from_crowd() -> Vector2:
	var away := Vector2.ZERO
	for other: Enemy in get_tree().get_nodes_in_group("enemies"):
		if other != self and global_position.distance_to(other.global_position) < PERSONAL_SPACE:
			away += other.global_position.direction_to(global_position)
	return away

Each of the 300 checks all 300, so the loop runs 90,000 times per frame. Because of class_name Enemy, it shows up in the list as Enemy._process.

③ Run it and check

Before the fix, the time bar for Enemy._push_from_crowd is long with Calls 300; after the fix, the bar is shorter and Calls drops

Run it with F5. Check three things.

First, check FPS, Process, and Nodes in Monitors. The enemies gather in the center, forming a big swarm on top of the player's position. Process stays stuck high, and FPS stays low. If it doesn't get clearly heavy, select Main and set Enemy Count to 600 in the Inspector (4 times as many loop iterations). Nodes levels off after the enemies finish spawning and doesn't keep growing.

Next, wait about 5 seconds for the enemies to finish gathering in the center, then press Start in the Profiler, wait about 3 seconds, and press Stop. Click one bar partway through the graph, and the list shows Enemy._process then Enemy._push_from_crowd from the top. As long as you avoid frames right after launch, Calls is 300 for both. Switch Time: to Self, and _process's number shrinks while _push_from_crowd's stays large. The one using the time is _push_from_crowd, and since its Calls matches the number of enemies, you question whether everyone needs to check every frame.

To finish, change it so _push_from_crowd() is called only once every 0.2 seconds. Keep _ready() and _push_from_crowd(), add two variables below the const PERSONAL_SPACE line, and replace only _process() with the following.

var crowd_push := Vector2.ZERO                 # The last "move away" direction checked
var push_timer := randf_range(0.0, 0.2)        # Staggered so everyone doesn't check on the same frame

func _process(delta: float) -> void:
	push_timer -= delta
	if push_timer <= 0.0:
		push_timer = 0.2
		crowd_push = _push_from_crowd()        # Check only once every 0.2 seconds
	var to_player := global_position.direction_to(player.global_position)
	global_position += (to_player + crowd_push).normalized() * SPEED * delta

With the same number of enemies, wait 5 seconds the same way and measure again. Enemy._process's Calls is still 300. Enemy._push_from_crowd's Calls becomes 1500 per second (300 enemies ÷ 0.2 seconds) divided by the frames. At 60 FPS, that's about 25 per frame on average, and the function's total time shrinks by about the same proportion. If this function was most of the frame's load, FPS in Monitors rises and Process drops too.

Finally, change the interval 0.2 to 1.0 in both places and measure. Calls drops by about another fifth. In exchange, enemies clearly take longer to untangle after overlapping. You can see lightness and responsiveness being traded off through a single number.

There are two key points.

  • Measuring means narrowing down to one name: Knowing that Process is high doesn't decide what to fix. Only once you've narrowed it down to Enemy._push_from_crowd can you act
  • Compare numbers under the same conditions: Keep the enemy count, V-Sync, and placement randomness the same. The graph's vertical axis adjusts to the data being shown, so compare the numbers in the same row rather than the heights of the lines. Checking less often delays how quickly enemies move away from each other, so also check that the overlaps and movement are acceptable

Where to look next from the function name

What the Profiler tells you isn't how to fix it, but where to look next. Pick candidates from the function name and Calls, and use the table below as a guide for what to read.

How it looks in the list or MonitorsWhat to suspectNext
Your function's Calls matches the enemy count, and inside it checks distances to everyoneBrute-force checks every frameArea2D Basics (pick up only nearby targets with collision)
Your function's Calls matches the enemy count, and inside it looks up nodes every timeget_node() or re-fetching groups every frameNode Reference Basics
A function that spawns bullets or effects is heavy, and Nodes goes up and downCreating and destroying nodesObject Pooling Basics
Only on the one frame where the scene switches, a function with Calls 1 is heavyLoading, creation, initializationDynamic Loading Basics
Physics Process is high, and the Profiler's Physics 2D entry is largeThe number of collision pairsCollision Layer and Mask Basics
Process is high, but all your functions are smallRenderingLook at per-step times in the Visual Profiler (not available with the Compatibility renderer on macOS)
Sponsored

Troubleshooting

SymptomCauseFix
Clicking the graph paused the gameClicking while recording sends a pause request to the gameResume with the Continue button on the Debugger's Stack Trace tab. Press Stop first before reading
Right after Stop, Calls is in the tens of thousandsThe last bar added when stopping is the total for the whole recordingClick a bar partway through the graph to see a single frame
Numbers measured in the editor are larger than in a game exported in release formRunning from the editor adds the load of debug features and measurementIn the editor, look at candidates to fix and before/after differences. Confirm final behavior in a game exported with Export With Debug (the checkbox for including development features in the export) unchecked
Nodes is flat, but memory use grows the longer you playNodes only removed from the tree with remove_child() aren't freed; they aren't counted in Nodes and pile up in Orphan Nodes (nodes left outside the tree)Also check Object → Orphan Nodes in Monitors. For nodes you don't need, queue_free() them instead of just removing them

Bonus: Good to Know First

  • Show numbers in the game screen: Call something like Performance.get_monitor(Performance.TIME_FPS) to show the same numbers as Monitors on screen. TIME_PROCESS is in seconds, so multiply by 1000 for milliseconds. Both only change about once a second
  • Separate one-time load: If it's heavy only the first time, loading, initialization, and draw preparation are the candidates. Do the same action again and check whether it happens every time
Sponsored

Summary

  • FPS is a result: See what grew with Process and Physics Process in Monitors. Both are the maximum for the window, so they catch brief stalls too
  • Open Monitors → Profiler: If Process is high but your functions are small, the Visual Profiler. If Nodes keeps growing, forgotten nodes
  • The list is in Inclusive order: Switch to Self and look for the row with the largest number rather than going by order
  • Use Calls to tell the kind of load: If it matches the enemy count, question whether the work is needed every frame. If it's 1, look inside the single call
  • After fixing, measure again under the same conditions: Keep the enemy count, V-Sync, and placement the same, and watch the same row shrink

Start by looking at Calls for the _process() of the script with the most instances. Which script has the most instances in your game?

Read nextNext in the learning path
Performance

Stabilizing FPS in Godot: Frame Rate Management and Optimization

Read this note
Godot Notes in this section104