Stress Test Pathfinding. I need help!

Hello friends! I have recently updated my game with some new pathfinding Ai. My game memory climbs very high over time and eventually crashes the server at 6200MB. I am predicting a memory leak somewhere. I am suspicious of pathfinding in the Ai. This isn’t the code in my Ai but I wanted to stress test Roblox PathfindingService and see how fast it could crash a server.

Is there a way to run this code without the memory climbing forever and crashing the server? Roblox Garbage Collection can’t keep up… Why is PathfindingService holding onto so much memory? Do I need to remove/disconnect something? The code is causing a memory leak?

Any advice is appreciated! Thank you!

--cloning 424 pathfinding scripts to run
for i = 1,424 do
	local pathfinding = script.Pathfinding:Clone()
	pathfinding.Parent = workspace
	pathfinding.Disabled = false
end
--to make unnecessary amount of pathfinding activity
local RunService = game:GetService('RunService')
local PathfindingService = game:GetService('PathfindingService')

local origin = workspace.Origin
local goal = workspace.Goal

function stepped()
	local path = PathfindingService:CreatePath()
	
	path:ComputeAsync(origin.Position,goal.Position)
end

RunService.Stepped:Connect(stepped)

Here’s a screenshot of the Dev Console.

This is what PathfindingService is navigating through.

5 Likes

First, I am surprised that the PathfindingService was able to find its way through that – I’ve ran it on mazes and had it fail when the maze becomes decently sized.

Second, the path object behaves like most other instances. You may not see it because it is hidden in the call to CreatePath, but I suspect that it has references to it that still exist, maybe even being in the game instance tree. When you compute a path, some information is stored (the waypoints at the least, some instrumentation for detecting obstructions, and likely some cached search memory). This memory is what is not being freed, and is significant. To resolve the issue, try calling path:Destroy() in your stepped() function.

If you are interested in completely offloading the pathfinding computational load, I am building a customizable pathfinding service in Lua. It can run either on your game server or I can host it for you. If you are interested in learning more, following the development, or providing your valuable feedback, please join the discord server: Polaris-Nav

Hello IdiomicLanguage! I was hoping and wishing you would see this topic! You’re one of the most talented developers on this forum! Thank you for the information!

Adding :Destroy() doesn’t seem to do anything for the performance… The path is a Instance which I learned recently but the ancestry apears to be nil? I’m not sure if there’s a way to clean it up besides waiting on the garabage collector…

function stepped()
	local path = PathfindingService:CreatePath()
	
	path:ComputeAsync(origin.Position,goal.Position)
	
	print(path:GetFullName()) --Instance
	
	print(path.Parent) --nil
	
	path:Destroy()
	print(path.Parent) --nill
end

RunService.Stepped:Connect(stepped)

Hello Brayden, and thank you! That’s very, very kind of you to say that. I’m sorry my idea didn’t help.

Just to make sure: when you comment out the compute async call, or the async call and create path call, the memory doesn’t rise at the same extreme rate?

Also, when you call Destroy() does the DebugSettings InstanceCount decrease by exactly one? Maybe after a little bit? And does it only increase by one when the CreatePath is called? Sometimes these methods create multiple hidden instances.

Finally, is the memory freed when the scripts that created the path is destroyed?

Sorry for all the questions. I would do it myself if I was near my computer.

So commenting out path:ComputeAsync(), the climbing memory does in fact slow down but still at a abnormal speed due to CreatePath() being within the function.

Here’s a clip of how fast it goes up without ComputeAsync().

Here’s another clip of the climbing SPEED with both CreatePath() and ComputeAsync().

I put this in a separate script. I disabled and enabled this script.

local path = PathfindingService:CreatePath()
path:ComputeAsync(origin.Position,goal.Position)
path:Destroy()

You can see the InstanceCount moves up by one then drops immediately since it was removed. If the path wasn’t destroyed immediately, then multiple instances are created which I assume are the waypoints.

No, the memory is not freed when the core script is destroyed. The memory continues to climb since the ComputeAsync yields and waits for the others to complete even after removal of their script.

I’m not sure if these clips let me post them or not. Hopefully you understand though! Thanks.

Sorry false information. Only ONE instance is created when I enable that script. Honestly maybe there isn’t anything we can do about it. Roblox PathfindingService should never be used like this. The paths created on top of paths on top of paths and the yielding is probably the root of the cause of the rise.

Yeah, this is definitely a bug Roblox should look into. I would definitely submit a bug report for it and include your findings.

In the mean time, I wonder if the instances created by the path are accessable and can be destroyed? I’ll do some digging tomorrow. If not the waypoints, then maybe the instances for path obstruction detection.

I’ll keep working on my pathfinder so we don’t have to deal with these bugs anymore.

1 Like

I joined your server and will be following your pathfinding quest very closely. Thank you!

Ah, one last point. You may be able to reuse the path objects, as mentioned here: How to detect if something is blocking a pathfinding path? - #11 by chess123mate

It may be that the memory leak is per path object instead of per computed waypoints. Maybe try to pool your path objects.

I’ve hunted for memory leaks before and can share a few things I am aware of:

  • Most importantly, I once ran into a scenario where the memory would keep going up for a long time, and then just stop (it was at ~4 or 5GB, but I can’t remember). As I already had systems in place tracking every other possible source of memory leak I could think of - and they’d found plenty before that point but weren’t finding anymore - I interpret this to mean that Roblox doesn’t clean up all the memory immediately, but could clean it up if it became a problem. If your device is slowing down or crashing, however, you still have a memory leak (though see below for why your test might crash your device).
  • Destroy is useful for garbage collection even if there is no parent because it disconnects all event listeners (which can be the source of memory leaks in some cases). This is especially important if you listen to the Blocked event of a path as Roblox does a bunch of calculations in response to you connecting to that event (and it keeps doing those calculations so that it can detect when the path becomes Blocked). Note: If you have tons of paths, Roblox won’t lag your place, as it spreads out these calculations over time. Instead, it just might take literally seconds or minutes for the Blocked event to fire or for paths to be calculated.
  • In my experience, some memory leaks can be tricky to find/track down (though I’m not sure to what degree this comes from the first point).

In your case, you probably haven’t found a memory leak. Here’s a one-script test I just ran (based off of yours):

local RunService = game:GetService('RunService')
local PathfindingService = game:GetService('PathfindingService')

local t = setmetatable({}, {__mode = "k"})
local function count()
	local n = 0
	for v in pairs(t) do n += 1 end
	return n
end

local run = Instance.new("BoolValue")
run.Name = "Run"
run.Value = true
run.Parent = workspace
run.Changed:Connect(function()
	print("Run:", run.Value and "On" or "Off")
end)

local created = 0
local computed = 0
local con = RunService.Stepped:Connect(function()
	if not run.Value then return end
	for i = 1, 5000 do
		coroutine.wrap(function()
			local path = PathfindingService:CreatePath()
			t[path] = true
			created += 1
			path:ComputeAsync(Vector3.new(math.random(-100, 100), 0, math.random(-100, 100)), Vector3.new(math.random(-100, 100), 0, math.random(-100, 100)))
			computed += 1
			path:Destroy()
		end)()
	end
end)

while true do
	print(created, created - computed, count())
	wait(1)
end

The output for me looks like this:

  0 0 0
  5000 5000 5000
  105000 78916 89260
  235000 166617 185074
  335000 236131 250916
  435000 305997 329703
  540000 383902 407627
  650000 470091 517627
  735000 535565 571018
  850000 621407 655190
  955000 697986 760190
  1065000 778998 870190
  1140000 835223 897965
  1250000 919883 1007965
  1345000 991109 1102965
  1405000 1034381 1099801
  1525000 1121367 1219801
  Run: Off
  1620000 1183593 1314801
  1620000 1102203 1314801
  1620000 1033202 1314801
  1620000 960656 1314801
  1620000 888509 1314801
  1620000 814336 1314801
  1620000 746703 1314801
  1620000 671170 1314801
  1620000 628796 1314801
  1620000 589040 1314801
  1620000 549304 1314801
  1620000 509370 1314801
  1620000 469640 1314801
  1620000 430352 1314801
  1620000 391075 1314801
  1620000 352015 1314801
  1620000 312745 1314801
  1620000 272923 1314801
  1620000 234011 1314801
  1620000 193504 1314801
  1620000 153198 1314801
  1620000 113506 1314801
  1620000 73886 1314801
  1620000 34146 1314801
   ▶ 1620000 0 1314801 (x5)
  Run: On
  1635000 13185 1329801
  1740000 96910 773962
  1755000 107914 788962
  Run: Off
  Script timeout: exhausted allowed execution time
  Stack Begin
  Script 'Workspace.Script', Line 25
  Stack End
  1794984 138744 143875
  1794984 111129 143875
  1794984 90543 143875
  1794984 51869 143875
   ▶ 1794984 0 143875 (x16)
  Run: On
  1824984 26703 35828
  1829984 29706 32639
  1919984 99055 104052
  2024984 181455 192928
  2144984 274957 299175
  2244984 353156 382705
  Run: Off
  2269984 335416 380725
  2269984 268388 380725
  2269984 195548 380725
  2269984 139757 380725
  2269984 106598 380725
  2269984 75787 380725
  2269984 45059 380725
  2269984 14946 380725
   ▶ 2269984 0 380725 (x22)

The table ‘t’ keeps track of how many paths are still in memory, but it’s a weak table so it doesn’t prevent them from being garbage collected. In the output we have:

  1. How many paths have been created
  2. How many paths have not finished computing
  3. How many path objects are still in memory

And also in the output:

  • When I turned the Run BoolValue on/off
  • A random script timeout error (I don’t know why it errored but we do see that created is not a multiple of 1000 after it occurred, so that’s interesting)

Now, for a moment I thought there was a memory leak (we end up with a bunch of paths in ‘t’ at the end), but just like I reported in my first point, sometimes Roblox simply doesn’t “feel like” collecting memory until you’re over some limit (though I haven’t experienced it leaving a bunch of them behind in weak tables before). Roblox was nearing 8GB of memory usage on my computer and I was just about to turn off the “Run” BoolValue again when all of a sudden it agreed that 8GB was too much and started collecting all this memory - the memory usage went down to ~3-4GB within seconds! You can see when this happened because the “count” returns 32639 at one point.

The reason that your test is likely to crash your device is that Roblox spreads out pathfinding over time so as not to lag your place, no matter how many pathfinding requests you give it. If you’re constantly generating new paths, Roblox won’t process them faster than you’re creating them, so you will end up with unlimited memory usage. You can see this in the output above, as well: after “Run: Off” displays, it takes some seconds before the remaining paths are processed. Naturally Roblox has to queue the paths (and threads) that haven’t gotten their result yet, which requires memory.

3 Likes