Docker Images¶
fc-data uses a three-tier Docker image hierarchy to build reproducible environments for each pull request.
Image hierarchy¶
- Base image (
formulacode/base:latest) — Common dependencies and tooling - Repo image (
formulacode/{owner}-{repo}:latest) — Repository-specific setup - PR image (
formulacode/{owner}-{repo}:{issue_number}) — PR-specific build scripts and verification
Building images¶
from datasmith.docker import ImageManager
mgr = ImageManager()
# Build each tier
mgr.build_base_image() # formulacode/base:latest
mgr.build_repo_image("pandas-dev", "pandas") # formulacode/pandas-dev-pandas:latest
mgr.build_pr_image("pandas-dev", "pandas", 16222) # formulacode/pandas-dev-pandas:16222
# Use custom build contexts
mgr.build_base_image(context="path/to/custom/context")
mgr.build_repo_image("pandas-dev", "pandas", context="path/to/custom/context")
mgr.build_pr_image("pandas-dev", "pandas", 16222, context="path/to/custom/context")
Build caches¶
Dockerfile.pr declares two BuildKit cache mounts, on both the env and the
pkg stage — the two steps that download and compile:
| Mount | Backs | Holds |
|---|---|---|
/ccache |
CCACHE_DIR |
C and Cython object files |
/opt/uvcache |
UV_CACHE_DIR |
Downloaded and built wheels |
Three other caches were measured and deliberately left out. /opt/conda/pkgs:
only 16.9% of tasks run micromamba install at PR-build time, and a warm pkgs
dir did not speed that install (5.30s vs 5.80s, noise). /root/.cache/conda: 7.2s
off the solve, on those same 16.9% of tasks. /opt/pipcache: the only pip call is
an editable install of the local repo, which pip does not cache (4 KB - 7.6 MB per
image).
Measured on pandas-dev/pandas#50501, a --target final build with an
otherwise cold layer cache:
| Build | Wall | env step |
pkg step |
Image |
|---|---|---|---|---|
| before the mounts existed, cold | 832.3s | 64.2s | 624.1s | 11.15 GB |
| cold — mounts present but empty | 841.1s | 72.5s | 618.6s | 10.66 GB |
| warm — same task, layer cache busted | 268.5s | 29.4s | 86.8s | 10.66 GB |
a late-stage file changed (docker_build_final.sh) |
30.8s | cached | cached | 10.66 GB |
| a neighbouring commit of the same repo | 258.4s | 30.3s | 75.9s | 10.66 GB |
A warm rebuild is 3.1x faster, and the same recipe at a neighbouring commit — the case a producer/verifier split actually pays for — is 3.2x. Cold costs 8.8s (+1.1%): the 3.4s ccache bootstrap plus ccache's store-on-miss overhead. The uv mount takes 0.49 GB off the image, because a cache mount never enters one.
The pkg stage reports ccache's counters into the build log. Per-build deltas:
| Build | cacheable calls | hits |
|---|---|---|
| cold | 117 | 15 (12.8%) |
| warm, same task | 68 | 68 (100%) |
| neighbouring commit, first time | 69 | 64 (92.8%) |
| neighbouring commit, cache holding both | 68 | 68 (100%) |
All 43 .so files rebuilt entirely from the cache are byte-for-byte identical to
the ones the pre-change build produced.
What the caches cannot touch is the ~133s floor: docker_build_run.sh's git
repack (87s), the final stage (18s) and the image export (27s).
After five pandas builds the mounts hold 14 MB (fc-ccache) and 1.6 GB
(fc-uv) — read them with docker buildx du --verbose.
Compilation is where the time is: 54.6% of the fleet's 1.69M recorded
build_duration_s comes from the 54 of 134 repos whose PRs touch compiled
sources, and pandas-dev/pandas alone is 18.1% of it.
A cache mount lives in the BuildKit state directory, never in the image.
How ccache is wired in¶
ccache is installed by Dockerfile.pr itself, into its own /opt/ccache-env
prefix, and interposed through masquerade symlinks in /opt/ccache-bin.
It has to live in the Dockerfile rather than in docker_build_base.sh or
docker_build_env.sh: candidate_containers stores a frozen per-task copy of
build_env_sh, so a template edit there never reaches the rows already
synthesized, and formulacode/base:latest is rebuilt too rarely to depend on.
Dockerfile.pr is backfilled from the templates directory on every build
(runners/synthesize_images._fill_missing_scripts, agents/sandbox.py), and
local_ci.py lists it in _IMMUTABLE_FILES, so it is the one surface that
reaches every task and that no synthesis agent can edit.
The bootstrap RUN sits above ARG COMMIT_SHA and the git checkout.
Nothing in it depends on the commit, so its layer cache key carries no per-task
input and BuildKit builds it once per repo image rather than once per task —
134 conda-forge solves instead of 1856. Below the checkout it would miss on
every task.
ccache setup is deliberately non-fatal: a build must not fail because
conda-forge was unreachable. The price of that tolerance is a build that
succeeds while compiling nothing from cache, so both the no-ccache branch and
the stats step print FORMULACODE_CCACHE_UNAVAILABLE. Grep a suspiciously slow
build for it.
Interposition is by PATH, not by CC. conda's activate-gcc_linux-64.sh sets
CC to the bare name x86_64-conda-linux-gnu-cc, resolved through PATH;
CC="ccache gcc" must not be used because distutils, meson and cmake each split
a multi-word CC differently. Across all 1856 stored build_pkg_sh scripts only
one sets CC to an unconditional absolute path, so PATH reaches effectively
the whole fleet.
PATH gets /opt/ccache-bin prepended twice, deliberately. The Dockerfile ENV
covers uv's isolated sdist build environments, which never activate a conda env.
micromamba run -n <env> prepends the environment's own bin after any
Dockerfile ENV, so for those calls the prepend has to run post-activation —
hence the activate.d/zz-ccache.sh hook the bootstrap writes into every asv_*
env, named zz- so it sorts after activate-gcc_linux-64.sh.
CCACHE_COMPILERCHECK=content rather than the default mtime: a
conda-installed compiler gets a fresh mtime in every image, which would miss
100% across images.
What these caches do not do¶
- They are host-local. BuildKit cache mounts cannot be exported or pulled. A verifier rebuilding on a different host starts cold, and additionally pays a full base-image pull. This makes same-host rebuilds cheap; it does not make a cross-host rebuild cheap.
- BuildKit garbage-collects them.
docker buildx inspect defaultshows GC rule#0 coveringtype==exec.cachemountwithKeep Duration: 48handMax Used Space: 91.37GiB.fc-dataruns monthly, so a cache populated by one run is gone before the next. Lifting that needs abuilder.gcpolicy in/etc/docker/daemon.jsonand asudo systemctl restart docker, which interrupts running builds. --no-cacheempties them, and the previously accumulated contents do not come back. Nodocker.build()call site passes it;tests/docker/test_build_caches.pyasserts none starts.docker builder prunedeletes them.utils/docker_prune.pyis disk-aware and skips belowDATASMITH_DOCKER_PRUNE_MIN_USED_PCT(default 85).
tests/docker/test_build_caches.py also asserts that each mount target still
equals the environment variable the tool reads. A mount whose target drifts from
CCACHE_DIR/UV_CACHE_DIR still builds successfully — it is just permanently
cold, with nothing to report it.
Build contexts¶
A DockerContext is a Pydantic model holding a Dockerfile and shell scripts. It can be loaded from a task directory or constructed programmatically.
from datasmith.docker.context import DockerContext
# Load from a task directory
ctx = DockerContext.from_directory("dataset/formulacode_verified/pandas-dev_pandas/abc123")
# Produce a reproducible tarball (zero mtimes, deterministic order)
tar_bytes = ctx.to_tar_bytes()
Verification¶
Verification is performed by local_ci.py during stage-6 synthesis, which
builds the image, runs the test suite, runs the measure step, and gates on the
build manifest's FATAL invariants. See the
Verification guide for the full pipeline.
To inspect an already-built image, read its manifest:
from datasmith.docker import read_build_manifest, evaluate_invariants
manifest = read_build_manifest("formulacode/pandas-dev-pandas:16222")
report = evaluate_invariants(manifest)
report.ok # True (all fatals held) / False (a fatal failed) / None (no manifest)
report.fatal # ["asv_exec_failed", "oracle_patch_failed"]
report.warnings # ["speedup_direction", "discovery_fallback_used"]
report.skipped # invariants whose inputs were absent
ok is deliberately three-valued. Every image built before build manifests
existed has none, and for those read_build_manifest returns None and
evaluate_invariants(None) reports ok=None with every invariant skipped —
rather than raising, or worse, reporting a clean pass it never checked.
This reads facts the build already recorded, so it is fast, offline, and works against any published image without rebuilding it.
Invariant severity¶
| Severity | Effect |
|---|---|
fatal |
Fails the verification step; recorded in report.fatal |
warn |
Recorded in candidate_containers.manifest_warnings, non-blocking |
| (skipped) | The invariant's inputs were absent, so it was not evaluated |
Implementation notes¶
- Docker operations use
python-on-whales(notdocker-py) — subprocess-based and thread-safe by design - Scales to 40-50+ concurrent threads without connection pool issues
- Image tags are lowercased to comply with Docker registry requirements