One of the places where TruffleRuby is still noticeably slower than CRuby is in finding and loading a lot of files. Do Ruby apps really do this?

Let’s take a look at one of our internal Rails applications that we call the Vault, our knowledge site where we keep documentation and team information, and track project status. Its dependencies are fairly up to date, running on Rails 7 with a standard architecture. It has 33,493 lines of code according to rails stats, so we might call this a medium to large application.

Ruby files

In our Vault app we have 747 *.rb files in ./app/. We also have 135 gem declarations in the Gemfile which, including transitive dependencies, gives us a total of 384 external gems.

Eager loading the app results in a $LOADED_FEATURES.size of 6,658. It also has a $LOAD_PATH.size of 513 which is quite a lot of directories to search as it looks for each of those six thousand files.

This is because directories are added to the $LOAD_PATH for each ruby gem used. If a gem uses require instead of require_relative, every entry in $LOAD_PATH will be checked for each file. If you require M files with a $LOAD_PATH size of N, it’s an O(M * N) operation. In this case that’s 513 * 6658 which is 3,415,554 operations.

YAML files

There is an old GitHub issue with some great research about speeding up YAML handling in TruffleRuby.

Does a production runtime load that many YAML files? Booting Vault loads a total of 430 YAML files, 423 of which are i18n files from external gems.

Bootsnap

Finding and parsing a lot of files can be slow in any environment as this primarily involves system calls and IO operations which require expensive context switching.

Bootsnap was created to improve the speed of these operations for CRuby. Since TruffleRuby is capable of loading Ruby C-extensions could it gain any performance from what Bootsnap does? With a small amount of effort it seems the answer is “yes”.

Bootsnap thus far has only been written to support CRuby. Expanding that to support TruffleRuby was fairly simple.

Bootsnap::LoadPathCache

Bootsnap has a LoadPathCache which optimizes the finding and loading of Ruby files.

Using TruffleRuby to boot the app under strace shows there are around 7 thousand open calls for .rb files, and 3.2 million stat calls. A significant number of these stat calls result in ENOENT because it’s searching for each .rb file in every directory in $LOAD_PATH.

Running with LoadPathCache enabled reduces the number of stat calls to 2.1 million, a 35% reduction.

To get this working on TruffleRuby required a few small tweaks, mostly handling slightly incompatible internals that most application code wouldn’t be concerned with (like a path prefix on builtin features or a test expecting enumerator to be written in C when in TruffleRuby it’s actually written in Ruby). We also ran into an esoteric issue with super calls resolving incorrectly with singleton classes which has been fixed and is available in TruffleRuby 23.1.0.

While native extensions are fairly common in Ruby, there really isn’t an API for extensions. Instead, extension authors link against CRuby’s internals (sometimes linking against functions they really shouldn’t). Since TruffleRuby is an entirely different VM, to run native extensions the VM has to treat a subset of CRuby’s internal functions as if they were an extension API. The last piece of the puzzle was rounding out TruffleRuby’s native extension support by implementing another C function.

Bootsnap::CompileCache

Bootsnap also supports compilation caching for Ruby, YAML, and JSON files.

The RubyVM constant does not exist under TruffleRuby which means the Bootsnap::CompileCache::ISeq can’t work but we can simply guard against that and only load the code when it’s available.

With a few more if supported? checks strewn about the codebase the YAML and JSON compile cache tests were able to pass on TruffleRuby.

With the benchmark in the previously linked GitHub issue about YAML performance utilizing Bootsnap::CompileCache::YAML improved the speed on my machine by 50%.

Conclusion

Eager loading the app with a native build of the latest TruffleRuby commit on a development cloud instance was taking as long as 134 seconds. By simply enabling Bootsnap it finishes in under 122 seconds (consistently about 10% faster). With another small PR to TruffleRuby we can remove several thousand more of those system calls and shave another 2 seconds off the start up time.

The Bootsnap changes for TruffleRuby have been merged and will be included in the next release. We’re excited for this and more improvements to come as we investigate further optimization opportunities.

While Bootsnap was designed for CRuby and is largely implemented as a C extension, thanks to TruffleRuby’s compatibility with native extensions, we were able to extend Bootsnap to TruffleRuby with minimal changes.

Acknowledgments

Many thanks to Kevin Menard who reviewed this post several times and provided a lot of excellent feedback. I had the opportunity to work with Kevin this summer on a variety of projects to make it easier for applications to adopt TruffleRuby and it was a wonderful experience.

Thanks also to Jean Boussier who reviewed many of my pull requests and encouraged many improvements.