Is throwing an exception a faster way to break out of recursion in JavaScript?

Published on
Updated on

While the purpose of exceptions is error handling, try / catch blocks might have a performance characteristic that can be useful for a specific optimization. Sure, there might be a significant performance overhead when there is an exception thrown, the stack is unwound and the catch block is executed. But the code wrapped in try block (the code that runs normally) is expected to have a small (if any) performance overhead.

This low to zero additional overhead of try block might be useful when we want to quickly terminate an intensive computation with deep recursion. Without using an exception we have to do some checks of the return values, which, in total, might add more overhead than try / catch blocks for the normal running code. But keep in mind that this might be true only when the time required for handling the thrown exceptions is negligible compared to the total execution time and wrapping in try block produces less overhead than the checks after the function return.

The benchmark

I decided to do a benchmark with Perflink. The benchmarks will be run on Chrome 145, Ubuntu 24.04.4 LTS and AMD Ryzen 5000U.

The core idea of the benchmark

I implemented 2 versions of recursive backtracking algorithms that find and return the first occurred solution for N Queens Problem.

The first one checks return values for recursion termination:

function findNQueensWithReturn(n) {
	if (n <= 0) {
		return null;
	}

	// occupied columns
	var occupiedCols = Array.from({ length: n }, () => false);
	// occupied top left -> bottom right diagonal
	var occupiedDiags1 = Array.from({ length: 2 * n + 1 }, () => false);
	// occupied top right -> bottom left diagonal
	var occupiedDiags2 = Array.from({ length: 2 * n + 1 }, () => false);

	function findRecursively(row) {
		let col = 0, diag1 = row, diag2 = n + row - 1;
		++row;

		// try to place queen in each column
		do {
			// if the current column and diagonals are not occupied
			if (!occupiedCols[col] && !occupiedDiags1[diag1]
				&& !occupiedDiags2[diag2]) {
				// if reached to the last row, it means solved, so 
					// the solution can be returned
				if (row === n) {
					return [col];
				}

				// mark the column and diagonals as occupied
				occupiedCols[col] = occupiedDiags1[diag1] = occupiedDiags2[diag2] = true;
				// recursively repeat the same process for the next rows
				const result = findRecursively(row);
				// if an array is returned, push the column index an return the array
				if (result) {
					result.push(col);
					return result;
				}
				// unmark the column and the diagonals
				occupiedCols[col] = occupiedDiags1[diag1] = occupiedDiags2[diag2] = false;
			}

			// for the next column the first diagonal index increases
			++diag1;
			// for the next column the second diagonal index decreases
			--diag2;
		} while (++col < n)
	}

	return findRecursively(0) ?? null;
}

console.log(findNQueensWithReturn(6));
console.log(findNQueensWithReturn(16));

The second one uses exceptions for recursion termination:

function findNQueensWithException(n) {
	if (n <= 0) {
		return null;
	}

	// occupied columns
	var occupiedCols = Array.from({ length: n }, () => false);
	// occupied top left -> bottom right diagonal
	var occupiedDiags1 = Array.from({ length: 2 * n + 1 }, () => false);
	// occupied top right -> bottom left diagonal
	var occupiedDiags2 = Array.from({ length: 2 * n + 1 }, () => false);

	function findRecursively(row) {
		let col = 0, diag1 = row, diag2 = n + row - 1;
		++row;

		try {
			// try to place queen in each column
			do {
				// if the current column and diagonals are not occupied
				if (!occupiedCols[col] && !occupiedDiags1[diag1]
					&& !occupiedDiags2[diag2]) {
					// if reached to the last row, it means solved, 
						// so the solution can be thrown
					if (row === n) {
						throw [];
					}

					// mark the column and diagonals as occupied
					occupiedCols[col] = occupiedDiags1[diag1] = 
						occupiedDiags2[diag2] = true;
					// recursively repeat the same process for the next rows
					findRecursively(row)
					// unmark the column and the diagonals
					occupiedCols[col] = occupiedDiags1[diag1] = 
						occupiedDiags2[diag2] = false;
				}

				// for the next column the first diagonal index increases
				++diag1;
				// for the next column the second diagonal index decreases
				--diag2;
			} while (++col < n)
		} catch (arr) {
			// handling the thrown solution, pushing the column index to the solution
			arr.push(col);

			// if it's the first row (row was 0 before incrementing in this function) 
				// return instead of rethrow
			if (row === 1) {
				return arr;
			}

			// otherwise rethrow
			throw arr;
		}
	}

	return findRecursively(0) ?? null;
}

console.log(findNQueensWithException(6));
console.log(findNQueensWithException(16));

Both findNQueensWithReturn and findNQueensWithException return array that contains the column indexes of the queens on each row. Keep in mind, that this can be done in O(n) time if we want just one solution, but for the sake of the experiment, we want to have a long running computation.

The setup

Both findNQueensWithReturn and findNQueensWithException will be run with n = 26 and compared.

The results

Here are the results:

N = 26 benchmark results
N = 26 benchmark results

As you can see, with exceptions it runs a bit faster.

I did another benchmark with n = 18 and 10 repeats. So, this will create more warmup and more potential for optimization:

Results for N = 18 with 10 repeats
Results for N = 18 with 10 repeats

Still, slightly faster with exceptions.

What about other browsers?

Although I didn't test much on other browsers, I did a run the second benchmark on Firefox 147. Unlike Chrome, it ran much slower with exceptions (5 ops/s vs 17 ops/s). This is likely because throwing exceptions on Firefox is much more expensive.

Conclusion

So, using exceptions for breaking out of recursions can slightly increase performance on Chromium based browsers. However, this doesn't seem to work reliably for all browsers (such as Firefox, mentioned above). You probably should benchmark on all your targeted browsers to make sure this doesn't do the opposite thing. Also, this might be fragile, even on Chromium based browsers the performance might vary between versions because of changes in optimization heuristics. The performance win here is small (around 5%), and with more extreme warmups, the difference might even out. Also, keep in mind that Perflink runs the tests in isolated workers, it might produce different results than running on the browser main thread. But, in any case, running CPU-heavy tasks on the main thread should be avoided as much as possible.






Disqus uses cookies, please check Privacy & cookies before loading the comments.
Please enable JavaScript to view the comments powered by Disqus.


UP
This site uses cookies for some services. By clicking Accept, you agree to their use. To find out more, including how to control cookies, see here: Privacy & cookies.