Comparing opam package builds across compiler variants with day10
Mark Elvers
7 min read

Categories

  • ocaml,day10

Tags

  • tunbury.org

This post walks through how to use mtelvers/day10 to compare which opam packages build successfully under two different compiler configurations.

As a real-world example, I will use the comparison of OCaml 5.4.1 with and without --disable-ocamldoc, but the same approach applies to any compiler variant, configure flag, or opam-repository fork.

Prerequisites

You need a Linux machine with:

  • runc installed (the OCI container runtime)
  • Docker (used to build the base container image)
  • A local clone of opam-repository
  • day10 built from source (requires OCaml >= 5.3.0)
git clone https://github.com/mtelvers/day10
cd day10
opam install . --deps-only -y
dune build @install

The built binary is at ./_build/install/default/bin/day10.

For this example, I am going to additionally clone the opam-repository from dra27/opam-repository, which has a branch that adds a single line ("--disable-ocamldoc") to ocaml-compiler.5.4.1/opam.

git clone --branch 5.4.1-no-ocamldoc \
  https://github.com/dra27/opam-repository \
  ~/opam-repository-no-ocamldoc

Step 1: Generate the package list

day10 list queries an opam-repository and outputs every package (latest version only unless --all-versions is specified) whose constraints are compatible with a given compiler, OS and architecture. The --json flag writes the list in the format that day10 health-check expects.

day10 list \
  --opam-repository ~/opam-repository \
  --ocaml-version ocaml.5.4.1 \
  --os-distribution debian --os-family debian --os-version 13 \
  --json packages-5.4.1.json

This produced a JSON file containing 4,322 packages:

{"packages":["ambient-context-eio.0.2","bap-emacs-dot.0.1","opus.1.0.0", ...]}

The same package list is used for both runs so the results are directly comparable.

Step 2: Solve (dry-run pass)

The solver step is embarrassingly parallel and requires little disk ok, so we run it with high parallelism to quickly identify which packages have a valid solution and which do not. The --dry-run flag skips building entirely.

day10 health-check \
  --cache-dir ~/cache \
  --opam-repository ~/opam-repository \
  --ocaml-version ocaml.5.4.1 \
  --os-distribution debian --os-family debian --os-version 13 \
  --json output-dryrun/ \
  --fork 256 \
  --dry-run \
  @packages-5.4.1.json

This writes one JSON file per package into output-dryrun/. Each file contains a status field:

  • solution: a valid dependency solution exists (but nothing was built)
  • no_solution: no dependency solution for this compiler/OS combination

Other possible values of status are dependency_failed, failed, or success, but these cannot occur with an empty cache directory. More on this later.

On my test machine (AMD EPYC 9965) this completed in 36 seconds for 4,322 packages.

Extract just the solvable packages with jq or Python.

python3 -c '
import json, glob
packages = []
for f in glob.glob("output-dryrun/*.json"):
    with open(f) as fh:
        d = json.load(fh)
        if d.get("status") == "solution":
            packages.append(d["name"])
packages.sort()
with open("packages-solvable.json", "w") as fh:
    json.dump({"packages": packages}, fh)
print(f"Solvable packages: {len(packages)}")
'

Result: 3,312 solvable, 1,010 no solution.

Step 3: Build (run A — standard compiler)

Now build all solvable packages for real. The --fork 64 flag runs 64 concurrent container builds. Each container uses runc with an overlay filesystem layered on top of the cached dependency layers. This needs to be adjusted to suit your machine.

day10 health-check \
  --cache-dir ~/cache \
  --opam-repository ~/opam-repository \
  --ocaml-version ocaml.5.4.1 \
  --os-distribution debian --os-family debian --os-version 13 \
  --json output-541/ \
  --fork 64 \
  @packages-solvable.json

day10 first builds the Debian 13 base image (via Docker), then the compiler, then all packages. The threads use lock files to wait for dependencies to be built.

This produces one JSON file per package in output-541/. For example, output-541/ocamlfind.1.9.8.json:

{
  "name": "ocamlfind.1.9.8",
  "status": "success",
  "sha": "4f056bfedf536e66065c3783e694e6aa0b38261a",
  "layer": "abc123...",
  "log": "...",
  "solution": "digraph opam { ... }"
}

The status field is one of:

  • success: built and installed successfully
  • failure: the package itself failed to build
  • dependency_failed: a dependency failed, so this package was not attempted
  • solution: has a solution but was not built (dry-run only)
  • no_solution: no valid dependency solution

Step 4: Build (run B — variant compiler)

The second run is identical except for --opam-repository:

day10 health-check \
  --cache-dir ~/cache \
  --opam-repository ~/opam-repository-no-ocamldoc \
  --ocaml-version ocaml.5.4.1 \
  --os-distribution debian --os-family debian --os-version 13 \
  --json output-541-no-ocamldoc/ \
  --fork 64 \
  @packages-solvable.json

Because both runs share the same cache directory, all layers whose opam files are identical are reused. The compiler layer diverges (different opam file hash due to the added --disable-ocamldoc flag), so the compiler and everything above it is rebuilt. Layers below the compiler (conf-* packages, the base image) are shared.

Step 5: Compare results

With both output directories populated, a simple Python script compares the status of every package:

import json, glob

def get_results(d):
    results = {}
    for f in glob.glob(d + "/*.json"):
        with open(f) as fh:
            data = json.load(fh)
            results[data["name"]] = data
    return results

std = get_results("output-541")
nod = get_results("output-541-no-ocamldoc")

# Summary
for label, res in [("Standard", std), ("No-ocamldoc", nod)]:
    statuses = {}
    for r in res.values():
        statuses[r["status"]] = statuses.get(r["status"], 0) + 1
    print(f"{label}:")
    for k, v in sorted(statuses.items()):
        print(f"  {k}: {v}")
    print()

# Differences
for name in sorted(set(std) | set(nod)):
    s1 = std.get(name, {}).get("status")
    s2 = nod.get(name, {}).get("status")
    if s1 != s2:
        print(f"  {name}: {s1} -> {s2}")

To see why a package failed, inspect the log field in the JSON:

jq -r '.log' output-541-no-ocamldoc/camlpdf.2.8.1.json

Example results: --disable-ocamldoc

Build summary

  Standard 5.4.1 No-ocamldoc 5.4.1
Success 3,163 3,136
Failure 77 87
Dependency failed 72 89
Total 3,312 3,312
Wall-clock time 29m 47s 27m 45s

Packages broken by --disable-ocamldoc

27 packages changed status from success to failure or dependency_failed. Of these, 13 are direct failures as they explicitly require ocamldoc during their build. The remaining 14 are cascading failures from depending on one of the 13.

Direct failures (13 packages)

Package Failure mode
broken.0.4.2 Uses bsdowl build system
camlgpc.1.2 Runs ocamldoc -html via OCamlMakefile
camlpdf.2.8.1 Runs ocamldoc -html via OCamlMakefile
exn-source.0.1 ocamlfind ocamldoc — “Not supported in your configuration”
inspect.0.2.1 Runs ocamldoc -html via OCamlMakefile
llvm.19-static OCaml bindings build requires ocamldoc
mlcuddidl.3.0.8 ./configure checks for ocamldoc, fails if absent
mlgmpidl.1.3.0 ./configure checks for ocamldoc, reports “OCaml not found”
ocamldot.1.1 ./configure raises Program_not_found "ocamldoc"
ocp-ocamlres.0.4 make doc target fails via ocamlfind
ollvm.0.99 ./configure requires ocamldoc
ollvm-tapir.0.99.1 ./configure requires ocamldoc
prooftree.0.14 ./configure checks for ocamldoc.opt, fails if absent

Additionally, zarith.1.11 (pulled as a dependency of bls12-381-js) and camlpdf.2.5.3 (pulled as a dependency of graphicspdf) also fail their configure checks for ocamldoc.

Cascading failures (14 packages)

Package Failed dependency
absolute.0.3 mlgmpidl
apron.v0.9.15 mlgmpidl
apronext.1.0.4 mlgmpidl
bls12-381-js-gen.0.5.0 zarith (via configure check)
bls12-381-js.0.5.0 zarith (via configure check)
calli.0.2 llvm
configuration.0.4.1 broken
cpdf.2.8.1 camlpdf
graphicspdf.2.2.1 camlpdf (older version)
jasmin.2026.03.0 mlgmpidl
libabsolute.0.1 mlgmpidl
mopsa.1.2 mlgmpidl
opam-graph.0.1.1 ocamldot
picasso.0.4.0 mlgmpidl

Failure patterns

The failures fall into a few categories:

  1. Configure scripts that hard-require ocamldoc (mlcuddidl, mlgmpidl, ocamldot, ollvm, prooftree, zarith). These check for ocamldoc during ./configure and abort if it’s missing, even if they don’t strictly need it for compilation.

  2. Build targets that generate documentation (camlgpc, camlpdf, inspect, ocp-ocamlres, exn-source). These invoke ocamldoc or ocamlfind ocamldoc as part of their default build, not as an optional doc target.

  3. Build systems that assume ocamldoc exists (broken via bsdowl, llvm OCaml bindings). These integrate ocamldoc into their build infrastructure.

Conclusion

Removing ocamldoc from the default OCaml 5.4.1 installation affects 27 out of 3,312 solvable packages (0.8%), with only 15 distinct root causes (13 latest versions plus 2 older versions pulled as dependencies). The remaining 3,136 packages (94.7% of the total, 99.1% of those that built successfully) are completely unaffected.

Most of the affected packages use older build systems (OCamlMakefile, custom configure scripts) that unconditionally check for or invoke ocamldoc. Modern build systems like dune do not exhibit this problem.

Tips

  • Use the same package list for both runs: Generate it once with day10 list and pass it to both builds. The solver is unaffected by build instruction changes (like --disable-ocamldoc), so the solvable set is identical. If you generate the list a section time, invert the logic to use build where solution != “no_solution”.
  • Shared cache is safe and beneficial: Both runs can use the same --cache-dir. Layers are keyed by a hash of their opam file contents and dependency chain, so different compiler configurations naturally get different hashes. Compiler-independent layers (base image, conf-* packages) are reused automatically.
  • Tune --fork to your machine: The solve pass is I/O light, so can use a --fork $(nproc). However, the build pass is I/O intensive so I suggested nproc / 4 and revise accordingly.

Timings

These timings are from an AMD EPYC 9965 with NMVe storage:

Step Wall-clock User Sys
Solve (4,322 packages, --fork 256) 36s 25m 50s 101m 36s
Build standard (3,312 packages, --fork 64) 29m 47s 567m 36s 444m 17s
Build no-ocamldoc (3,312 packages, --fork 64) 27m 45s 486m 55s 398m 24s

The no-ocamldoc build was approximately 2 minutes faster, as the conf-* packages were cached since they are not dependent on the compiler.