How to run 14 iOS Simulators in Parallel on an M1 Mac

After making these changes, I can now run my UI test suite across 14 simulators in parallel, and reduced my UI test suite runtime by 38%.

Four iPhones in someone's hands
Photo Credit: Daniel Romero

I have a large UI test suite I run for Nihongo, and the more simulators I can run in parallel, the faster it can complete.

I upgraded my build machines to M1 Mac Minis recently, and despite clearly having the CPU and memory headroom to run more simulators, I was getting strange errors (XPC daemon crashes, hangs, etc.) once I tried to push beyond about 5 simulators in parallel.

Fortunately, with some help from the Simulators team in a WWDC lab, I was able to figure out the issue and get a reliable setup of 10+ simulators running in parallel.

Simulator Settings

File > GPU Selection > Prefer Discrete GPU
Make sure this setting is enabled. You will see serious animation and UI slowdown when this is not selected.

Edit > Automatically Sync Clipboard
Make sure this setting is disabled. Multiple simulators attempting to access the clipboard can cause deadlocks that leave them frozen.

System Resource Limits

The next two biggest issues are hitting the system limits for open file descriptors, and open processes. I'll start with the minimal solution I came up with (as of macOS 11.4), and then give more of an explanation below.

Add the following two files to /Library/LaunchDaemons:

com.launchd.maxproc.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>Label</key>
	<string>com.launchd.maxproc</string>
	<key>ProgramArguments</key>
	<array>
		<string>launchctl</string>
		<string>limit</string>
		<string>maxproc</string>
		<string>4000</string>
		<string>unlimited</string>
	</array>
	<key>RunAtLoad</key>
	<true/>
</dict>
</plist>

com.kern.maxfiles.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>Label</key>
	<string>com.launchd.maxproc</string>
	<key>ProgramArguments</key>
	<array>
		<string>sysctl</string>
		<string>-w</string>
		<string>kern.maxfiles=300000</string>
	</array>
	<key>RunAtLoad</key>
	<true/>
</dict>
</plist>

Then update their ownership using the following command (once for each file):
sudo chown root:wheel /Library/LaunchDaemons/<filename>.plist.

Finally, restart your machine for these changes to take effect. You should be all set.

What's Going On

The system sets up some default maximums for the number of active processes and open file descriptors on the system, based on the specs of the machine. For an M1 Mac Mini, these default to 2666 processes, and 49,152 file descriptors. For processes, it also sets up a hard maximum, which on an M1 Mac Mini is 4000 processes. The logic for this calculation is actually open-source, so you can see how it's done yourself.

Each simulator I load adds about 150 processes and 3000 file descriptors, and in practice we will start bumping up against these limits around 9-10 simulators. You can check the current number of processes by running top and looking at the first line, and the current number of open file descriptors by running lsof | wc -l.

To complicate things, there are actually many different limits at different layers of the system, and they interact with each other in some unexpected ways. I don't claim to be an expert on this, but I have deduced the following limits:

  1. Shell Limits (check with ulimit -u and ulimit -n)
  2. Launchd Limits, soft and hard (check with launchctl limit)
  3. Sysctl Limits (check with sysctl -a | grep kern.max)
  4. Hard Kernel Limits

All but the hard kernel limits can be manipulated by running certain commands, that take effect until the end of the current user's session. So, we need to add these commands to run on machine launch, which we accomplish using launch daemons, as shown above. Let's look at each resource separately.

Processes

The hard kernel limit is 4000, so we want to set the shell, launchd, and sysctl limits all to 4000.

For whatever reason, it seems that setting the launchd limit for processes also updates the shell limit and the sysctl limit. So we only need one call on launch to set the process limit, launchctl limit maxproc 4000 unlimited. That should set the launchd soft limit to 4000, and the hard limit to unlimited.

File Descriptors

Before making any changes, both the shell limit and the soft launchctl limit claim to be 256. This doesn't make much sense to me, since even without running any simulators, I have about 5000 open file descriptors. If anyone understands what this 256 limit refers to, let me know!

The sysctl limit seems to be what is actually enforced, and it has a default value of 49,152. To increase that to 300,000, we run sysctl -w kern.maxfiles=300000. That gives us enough headroom to run plenty of simulators.

Conclusion

After making these changes, I have successfully run my UI test suite across up to 14 simulators in parallel, and reduced my UI test suite runtime from 66 minutes (on 5 simulators) to 41 minutes (on 10 simualtors).

In my experience 10 simulators seemed to perform best on my M1 Mac Mini with 16GB of RAM. Each simulator seems to use about 1.3GB of RAM, so beyond that things started to slow down quite a bit. Can't wait to see how much further I can push this on M1 Pro and Max!

Hope this helps you speed up your own UI tests!