Back

Speeding up the JavaScript ecosystem - oxlint and oxfmt

tl;dr: Future versions of oxlint and oxfmt will be ~50% faster on projecs with many (>=50k) directories.

At a certain scale, the characteristics of software development change. We are seeing a massive surge in both the size of repositories and the volume of PRs. Consequently, the sheer number of CI runs required to keep a project moving is exploding. This demand for speed is being further accelerated by more and more work being done in parallel on a local machine. For these reasons, tools need to be as fast as possible.

The oxc project is a top contender for becoming the standard tooling platform of choice for this reason. Its Rust parser is well-engineered for performance. It has received an immense amount of scrutiny on that front already.

When I ran both oxfmt and oxlint on codebases with a high directory-to-file ratio something didn't feel right.

Why are you idling, CPU?

Oxc’s parser is benchmarked to process hundreds of megabytes of JavaScript per second. On a modern machine with 12+ cores and a high-speed NVMe drive, a codebase of this size should have been a sub-3-second job. At that speed, you expect the CPU fans to kick in immediately as every core is saturated with parsing and linting logic.

Instead, the execution felt "lazy", coming in at around 6s. Let's fire up a profile to see what's actually happening.

Profiler showing that one thread blocks all other threads from continuing

Welp, that looks like everything is waiting on something. A "healthy" profile would show work being done on every lane which would signal that my machine is being used to the fullest. But that's clearly not the case here. Nothing in the first block was about doing any actual work. No formatting or linting was happening there. Something much more innocent looking was running here: Config file discovery. Huh?

The syscall ghost

What's clear from this profile is that internally oxc was working in phases: Discover config files, then build up a sort of "work map", and then do the actual work based on that. Like most tools oxfmt supports several config files:

  • .oxfmtrc.json
  • .oxfmtrc.jsonc
  • oxlint.config.ts

These config files can also be nested. The inner config file takes precedence over any configs in parent directories.

The straight forward implementation to write this sort of code is to do a simple loop and check for each file whenever you encounter a directory.

rs
// SLOW: Each .is_file() is a syscall (stat/metadata)
for path in [
	dir.join(self.config_file_names.json),
	dir.join(self.config_file_names.jsonc),
	dir.join(self.config_file_names.js),
] {
	if path.is_file()
		&& let Some(config) = self.discover_config_file(&path)
	{
		configs.push(config);
	}
}

This part is very visible in the profile:

34% of the profile is spent in fs::metadata

Like most programming languages, rust makes this code kinda innocent looking. But we have a big performance issue here hiding in plain sight. Every .is_file() call is doing a syscall underneath. A syscall is way more than just a function call. It is a full-blown context switch from user mode to kernel mode.

When you ask the OS if a file exists, your process has to stop what it's doing and hand control over to the kernel. The kernel then has to:

  1. Validate the path string
  2. Traverse the filesystems's internal directory structure
  3. Check security permissions
  4. Interact with the disc controller (in case it's not already in cache)
  5. Context switch back to your process with the result.

Even if the filesystem metadata is hot in the OS cache, you are still paying the penalty of crossing that boundary. In a tight loop, this is a performance killer. When we multiply this tiny overhead by 150k sequential calls, we create a massive wall of latency. Our process was forced to wait for the kernel to finish its paperwork.

Zero cost discovery with readdir

Looking at the traversal code, we were asking the OS questions that we already had the answers for. To traverse over a directory structure the readdir syscall is typically used which returns a list of directory entries. Those entries contain the filename already.

rs
// FAST: Zero syscalls. We are just checking strings in memory.
for entry in entries {
	let filename = entry.file_name();

	// Checking filenames here is a simple string comparison
	if (filename == self.config_file_names.json
		|| filename == self.config_file_names.jsonc
		|| filename == self.config_file_names.js)
		&& entry.file_type().is_file() // usually provided by readdir for free
	{
		if let Some(config) = self.discover_config_file(entry.path()) {
			configs.push(config);
		}
	}
}

What's even better is that we also got rid of all the path construction calls we had earlier: dir.join(...). With 50k directories the old code would trigger 150k syscalls, regardless of whether there were any config files or not. The new code never triggers a syscall when there is no config file. When there is a config file it will only trigger as many syscalls as there are config files, never more. This is a giant leap forward in terms of performance.

Getting rid of phases

The final piece of the puzzle is the code being structured around phases. Since we know that config files can only affect files in the current directory or below, we can process files in the same traversal that we're using for config discovery. No more separate config file prescan. By fusing these steps we eliminated the startup period.

Remember: Syscalls are expensive

When it comes to syscalls modern programming languages make it easy to overlook expensive syscall code. In any high-level language, path.is_file() looks like a simple, harmless boolean check. But beneath that clean abstraction lies a heavy, expensive context switch to the kernel. This is a fun one because when you think of algorithms, but here it's much more about understanding the boundaries between a process and the environment it lives in.

Upcoming versions of both oxlint and oxformat will be more than twice as fast in projects with thousands of directories:

WhatTime
before6.1s
after2.8s

Special thanks to Jovi DeCroock for PR'ing a quick fix for unblocking folks and Yuji Sugiura from the OXC team for refactoring the overall config handling. Original investigations: #22225 and #22250.