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:
runcinstalled (the OCI container runtime)- Docker (used to build the base container image)
- A local clone of opam-repository
day10built 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, orsuccess, 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 successfullyfailure: the package itself failed to builddependency_failed: a dependency failed, so this package was not attemptedsolution: 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:
-
Configure scripts that hard-require ocamldoc (mlcuddidl, mlgmpidl, ocamldot, ollvm, prooftree, zarith). These check for
ocamldocduring./configureand abort if it’s missing, even if they don’t strictly need it for compilation. -
Build targets that generate documentation (camlgpc, camlpdf, inspect, ocp-ocamlres, exn-source). These invoke
ocamldocorocamlfind ocamldocas part of their default build, not as an optional doc target. -
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 listand 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
--forkto 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 suggestednproc / 4and 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.