From 24 minutes to under 10: faster FreeCAD builds with RunsOn pools
Faster runners halved FreeCAD's Linux CI time. Preparing dependencies in stopped Fleet pools brought it under ten minutes.
FreeCAD is a popular, actively maintained open-source 3D modeller written in C++.
The product is shipped regularly via GitHub Actions with their linux builds taking ~24m on an ubuntu-24.04 instance. This is pretty reasonable for a large project such as this and is also a good enough time to stretch and make a coffee between builds..
But not for us.
We believe Continuous Integration needs to be, well, continuous, as much as possible. There’s no reason why you’d have an agent speed up code production and not speed up shipping it out to the world.
So here’s how we got FreeCAD’s Linux workflow from 24 minutes to under 10:
First, we created a better baseline#
We swapped GitHub’s hosted runner for an equivalent AWS m8azn instance with RunsOn and kept everything else the same.
runs-on: ubuntu-24.04runs-on: runs-on/fleet=linux-freecad-build/env=demoThat label points to a fleet in our Fleet stack, and these are the two runners we tested it with:
runners = { linux-freecad-large = { cpu = [2] ram = [8] family = ["m8azn"] image = "ubuntu24-full-x64" } linux-freecad-xlarge = { cpu = [4] ram = [16] family = ["m8azn"] image = "ubuntu24-full-x64" }}And thanks to just better hardware, this halved the total workflow time right out the gate:
| Runner | CPU / RAM | Job execution | Cost/run (On-demand) | Cost/run (Spot) |
|---|---|---|---|---|
GitHub ubuntu-24.04, private | 2 / 8 GB | 37m27s | $0.228 | — |
RunsOn m8azn.large | 2 / 8 GB | 14m38s ↓61% | $0.0576 ↓75% | $0.0229 ↓90% |
GitHub ubuntu-24.04, public | 4 / 16 GB | 23m55s | Free | — |
GitHub github24-4cpu-x64, larger runner | 4 / 16 GB | 26m10s | $0.324 | — |
RunsOn m8azn.xlarge | 4 / 16 GB | 12m04s ↓54% | $0.0902 ↓72% | $0.0337 ↓90% |
The public ubuntu-24.04 runner is free, so we also ran GitHub’s 4 vCPU larger runner to have a GitHub runner we can compare with on both performance and price. The 2 vCPU arrows are against the private ubuntu-24.04 runner and the 4 vCPU ones against the larger runner.
Each time is a single warm run with FreeCAD’s ccache hitting 98.5% (the 4 vCPU pool time further down averages two), and the cost per run covers the instance, its disk and its public IP. If you want the details, here’s how we benchmark real repos.
For most people, this is more than reasonable value by swapping just 1 line of config. But if you want to go further, RunsOn provides a few neat tricks that we can make use of, as easily:
Making way for the critical path#
The fundamental idea of CI is to test what you built and get feedback on it so that you can iterate on your product as quickly as possible at scale.
So ideally you’d want your CI to build the code and test it as quickly as possible and do just that.
But it gets tricky when you have a product as large and intricate as FreeCAD that needs dependencies installed every time it needs to be built in an ephemeral CI environment. This eventually becomes a significant problem as the codebase and the dependencies grow over time.
| Step | GitHub private, 2 vCPU | RunsOn, 2 vCPU | GitHub public, 4 vCPU | RunsOn, 4 vCPU |
|---|---|---|---|---|
| Native dependencies | 2m22s | 1m41s | 2m15s | 2m42s |
| Compile | 25m40s | 7m28s | 12m52s | 4m01s |
| Tests: snapshots, C++, CLI and GUI | 7m44s | 4m32s | 6m58s | 4m19s |
However with RunsOn, this problem becomes trivial.
Prepare before the build arrives#
The preinstall hook runs setup while preparing the machine. In our Fleet stack, we added one field to the runner definition:
linux-freecad-xlarge = { cpu = [4] ram = [16] family = ["m8azn"] image = "ubuntu24-full-x64" preinstall = file("${path.module}/scripts/freecad-preinstall.sh")}Then the workflow can replace dependency installation with a quick check that its dependency inputs match the prepared environment:
- name: Install FreeCAD dependencies run: ./package/ubuntu/install-apt-packages.sh- name: Verify preinstalled FreeCAD dependencies run: | cd package/ubuntu sha256sum --check /opt/freecad-preinstall/manifest.sha256 sudo apt-get checkBut since RunsOn also spawns ephemeral runners by default, these dependencies will still get preinstalled every time this workflow is queued. In our run that was 3 minutes of waiting in the queue before the job even started. This presents us with 2 solutions:
- Create, use and maintain a custom runner image thats purpose built for this project.
- Preinstall dependencies in a runner machine once, and keep it running.
One is not better than the other as it totally depends on what you need. Though I’d assume most people would prefer not having to create and maintain a whole runner image right of the bat. So choosing the second option becomes much more feasible without being a fuss.
Enter pools#
What’s better is that you don’t even need an instance running at all times if you choose to. You can just have the disk an instance uses already warmed up and ready to go:
fleets = { linux-freecad-build = { runner = "linux-freecad-xlarge" runner_group = github_enterprise_actions_runner_group.runs_on.name max_runners = 2 timezone = "UTC" schedule = [ { name = "default" hot = 0 stopped = 1 } ] }}When you define a schedule for your Fleet runners, you essentially create a pool with that amount of runner instances.
A stopped instance is an instance that boots with the runner image, prepares the EBS volume (with preinstalled dependencies if any) and stops. This only stops the AWS instance and doesn’t detach the prepared EBS volume. When queued, it only boots the instance and is ready to go.
You can also have a hot instance that is always running if you prefer.
So by using a stopped instance with pre-installed dependencies, we were able to cut down the workflow time even further:
| Runner | Without preinstall or pools | Stopped pool + preinstall |
|---|---|---|
m8azn.large, 2 vCPU | 14m38s | 13m11s ↓10% |
m8azn.xlarge, 4 vCPU | 12m04s | 9m28s ↓22% |
And thats that.
This is how you speed up your workflows with RunsOn by optimising what you need and making way for the critical path: the build and tests.
How the timings and costs stack up across all configs#
| Configuration | Queue | Job execution | Cost/Run (Spot) | Cost/Run (On-demand) |
|---|---|---|---|---|
| GitHub public, 4 vCPU | 2s | 23m55s | — | Free |
| GitHub private, 2 vCPU | 3s | 37m27s | — | $0.228 |
| GitHub larger runner, 4 vCPU | 4s | 26m10s | — | $0.324 |
| RunsOn 2 vCPU | 25s | 14m38s ↓61% | $0.0229 ↓90% | $0.0576 ↓75% |
| RunsOn 4 vCPU | 20s | 12m04s ↓54% | $0.0337 ↓90% | $0.0902 ↓72% |
| RunsOn 2 vCPU, stopped + preinstall | 15s | 13m11s ↓65% | — | $0.0593 ↓74% |
| RunsOn 4 vCPU, stopped + preinstall | 13s | 9m28s ↓64% | — | $0.0947 ↓71% |
As you can see, those minutes being spent installing dependencies and slow runs is equivalent to having a stopped instance running for 3 days and buys 3 RunsOn runs for the price of 1 GitHub run.
Plus you get back your time to make coffee and have a proper stretch.
Cheers.