What is a Resource List?

It’s a list that contains all the asset paths used in our game. It is being updated on every change regarding assets, whether it be adding new ones, deleting old ones, or changing existing assets. So, for example, every time you drop a new file in the game repo, the ResourceList needs to be updated. Unity has some type of resource list used for Resource.Load() and some other things, but we wanted to have our own Resources class with more functionality and full control. In order to do that, we had to take care of our own resource lists, which means we had to build it and update it accordingly.

How did we first make it?

To automatically generate the resources list every time relevant assets are added, removed, or refactored in the Unity project, we created a ResourcesListBuilder class that inherits from Unity's AssetPostprocessor. This allowed us to hook into Unity’s import pipeline and rebuild the list on every event. So basically every time the hook gets activated we would rebuild the whole list. 

Now you may ask yourself why would we build the list everytime from scratch? Why not just add/remove/change the specific thing that changed? We prototyped that solution many times but it always eventually led to an inconsistent ResourceList which later resulted in bugs hard to find. So in the end we decided to rebuild it from scratch every time.

Why didn't it scale well?

This part of the system was written long ago when our asset database wasn’t nearly as large as it is today. Some rebuilds of the resource lists have grown to be around half a minute and we were in need of a faster way to build it. During development this became a serious bottleneck. Developers often performed actions that were rebuilding these lists while working on new features or debugging issues, and each of these rebuilds blocked progress for ~30 seconds. With multiple rebuilds per day, this added up fast and noticeably slowed down iteration speed. In the next section we will describe some of the biggest bottlenecks and how we tackled them.

How did we optimize it?

The first thing we did was to identify all the bottlenecks in the system. In order to do that, we attached a profiler to Unity and did things to trigger a rebuild of the ResourcesList. The results we got gave us insight about the bottlenecks, which we then proceeded to optimize one by one, even if it resulted in 1% speed up.

Optimize string.EndsWith() methods:

Imagine code snippet like this:

if(path.EndsWith(“.meta”) || path.EndsWith(“.manifest”) …) { //Do something }

Now this code would be alright in most cases but when this code runs more than 250k times on each recompile it takes some significant execution time. In order to optimize it we took an approach similar to how Trie works. Basically instead of checking each time for every extension we used a simple test to know which EndsWith() we should test. It took down the whole execution time of this snippet from 8% to less than 1%.

The resulted code looked something like this:

var shouldDoSomething = path.Length > 5 && path[^1] switch 
{
	‘a’ => path.EndsWith(“.meta”),
	‘t’ => path[^2] switch
	{
		‘s’ => path.EndsWith(“.manifest”),
		‘x’ => path.EndsWith(“.readme.txt”),
		_  => false
},

…

}
if (shouldDoSomething)
{
   //Do something
}

Optimize Directory.Exists() calls:

Imagine code snippet like:

if (Directory.Exists(path))
{
  //Do something
}

Now, nothing wrong with this code, but remember it is executed 250k times, again. Those Directory.Exists() started to pile up. And it took 3% of the already too long execution time. We optimized it like this:

if (!path.HasExtension() && Directory.Exists(path)
{
  //Do something
}

How and why did this bring meaningful optimization? Basically what Directory.Exists() does is ask the operating system if a given path is a directory. That is an operation that takes time since we have to wait for a system call. Now we only check whether directory exists if path is string without extension and we know we don’t add extension to directory names. This made this code snippet from taking 3% of initial execution time down to less than 1%. It may look like insignificant improvement but a lot of insignificant improvements can make a significant one :) We optimized a few more smaller bottlenecks like this and dropped down execution time from initial 100% down to about 80% of initial time. Which still wasn’t enough for the smooth experience so we had to dig deeper and reevaluate even things we thought we can’t optimize.

Tackle the biggest bottleneck

The biggest bottleneck was calling Unity's function AssetImporter.GetAtPath(). It took ~80% execution time on windows and ~40% on Macs with M chips. We needed to call that to check if the asset is marked to belong to some asset bundle. So the first conclusion was that we can't optimize things we didn't write? But what if we don’t have to execute it at all? 

foreach (var path in paths)
{
  …
  var isBundlePath = path.Contains("/AssetBundles/");
  if (!isBundlePath) continue;
  var importer = AssetImporter.GetAtPath(path);
  if (importer == null || importer.assetBundleName.IsNullOrWhiteSpace()) continue;
  …
}

As you can see from the code above, we start by checking whether the path points to a bundle location. In our projects we have a convention to put all asset bundle assets somewhere under the AssetBundles folder just like in Unity’s conventions embedded assets go under the Resources folder. After that, we call AssetImporter.GetAtPath(path) and then check whether the imported asset is actually marked for asset bundle. Only if it is we proceed with further processing. The issue was that we were calling AssetImporter.GetAtPath(path) on a large number of paths. But we have one more convention in our projects: we only mark whole directories for asset bundles, never individual assets. So if a path doesn’t represent a directory it cannot represent an asset bundle so we added a directory check before calling Unity’s method. This small optimization allowed us to skip over 95% of the paths under /AssetBundles/, because each bundle has one root directory and many files inside it. The optimized code is as shown bellow:

foreach (var path in paths)
{
  …
  var isBundlePath = path.Contains("/AssetBundles/");
  // Every asset bundle should be a directory.
  if (!isBundlePath || !Directory.Exists(path)) continue;
  var importer = AssetImporter.GetAtPath(path);
  if(importer == null || importer.assetBundleName.IsNullOrWhiteSpace()) continue;
	…
}

Reflections on Improving Performance

This whole process showed us how small things can actually make a big difference. At first, optimizing EndsWith() or avoiding unnecessary Directory.Exists() calls didn’t seem like much, they were giving us a small percentage boost. But when something runs 250,000+ times, even a 1% speed-up adds up fast. However, the biggest breakthrough didn’t come from tweaking slow code, but from realizing we didn’t have to run the slowest part at all.  The mindset shift from “how do we make this faster?” to “do we need to do this at all?” is what made the biggest difference.