Skip to main content
_
Back to posts

🔧 The slowest part of my speed comparison

· 5 min read · by Niklas Heer
cinixdaggeropen-source

My speed-comparison repository runs the same numerical task in many programming languages. The visible result is a chart.

The less visible result is that I have spent a remarkable amount of time installing programming languages.

Compilers, interpreters, libraries, base images, version checks, build flags: before anything can race, somebody has to get the contestants to the starting line. Eventually, that became the more interesting engineering problem.

The project has gone through three approaches to it. Reading the commits is a useful reminder that a tidy chart can have a very untidy supply cupboard behind it.

THREE WAYS TO GET TO THE STARTING LINE

The benchmark stayed. The plumbing changed.

pacman -S pypy ruby php rust go nodejs …

Every language moves into the same container. The environment is a shared maintenance concern before the benchmark starts.

A map of the repository's evolution, not a timing comparison. The linked commits below show the actual configurations.

First: put everything in the Arch container

In February 2018, I switched the environment to Arch Linux. The Dockerfile installed the base tools, then a long list of language runtimes with pacman. More setup handled plotting, Python dependencies, and the package helper.

The mental model was straightforward: build one large environment with everything installed, then run the comparison inside it.

That was convenient until the environment itself became a substantial thing to maintain. Adding another language meant adding another dependency to the shared home. Updating the distribution and updating the contestants were uncomfortably close to the same operation.

I wanted a benchmark suite. I was gradually acquiring the responsibilities of a small Linux distribution.

Then: give each language an Earthly target

The first Earthfile arrived in October 2022. Splitting the work into targets was a useful improvement. Shared preparation and benchmark steps could be reused, with language-specific instructions where needed.

There was even an early NixOS detour: the very next commit removed those images because of the load they put on the system. Apparently the route to declarative infrastructure was going to include a round trip.

But the recipe for “install this language” still depended on where it came from.

One target used an Alpine package. Another needed a Debian-based image. Another started from a language’s official container. Bumping a version could mean changing a package name, replacing an image tag, revisiting an installation script, or discovering that the image brought along a different version of something else.

A concrete example is the Python/Numba addition: a python:3.13-slim base, Debian preparation, compiler and development packages, then pip install numba. It is a reasonable recipe. It is also another particular environment to understand when something changes.

Earthly can reuse targets and run work in parallel. My frustration was with how this project had accumulated different installation recipes and tightly connected responsibilities. Preparing toolchains, building programs, and collecting results were harder to separate than I wanted.

The new direction: describe the language once

In December 2025, I added the Dagger pipeline. Dagger handles the container work from Python. Nix packages, managed through Devbox, supply the language environments.

The part I like most is almost disappointingly small: a Python dictionary.

dagger-poc/languages.py holds the LANGUAGES definitions. Here is the useful core of the actual Go entry, with display metadata omitted:

"go": Language(
name="Go",
nixpkgs=("go@1.25.4",),
file="leibniz.go",
compile="go build -ldflags='-s -w' -o leibniz leibniz.go",
run="./leibniz",
version_cmd="go version",
),

The package version, source file, compilation command, execution command, and version check now live together. The same data can drive the build, the benchmark, validation, and update tooling.

For a straightforward version bump, I can change the declaration and let the pipeline do the repetitive work. I do not have to remember which Docker image happened to contain the version I wanted.

The version-check workflow builds on that common definition. Declarative configuration is particularly satisfying when the alternative is repeatedly rediscovering your own installation instructions.

Separate the kitchen from the stopwatch

The newer pipeline builds language environments ahead of time and stores them in the container registry. Benchmark source is added when it is needed. That gives me a useful separation between preparing a toolchain and running a measurement.

The commits show that separation becoming more precise:

Preparation can be split into independently managed jobs. That does not mean every timed benchmark should compete for the same CPU at once; the runner’s loop still executes its selected targets sequentially.

Declarative does not mean magical

The migration has plenty of repair commits too. Swift needed particular Nix packages and headers. Commands had to run through devbox run to get the intended environment. Some languages still need explicit setup steps beyond a package declaration.

The Earthfile also remains in the repository, alongside a directory still named dagger-poc. This is an evolving migration, not a claim that the old system vanished overnight.

What improved is where the knowledge lives. Versions and exceptions have a common, testable representation. Building environments and running benchmarks have clearer boundaries. Updating the collection becomes less of an expedition through unrelated containers.

The chart is still the thing most visitors come to see. But the work I am happiest about is making the next update less interesting.

That is a surprisingly good outcome for infrastructure.