Refining Dune’s Dependency Graph: Per-Module Library Filtering
I’ve been working on improving Dune’s inter-library dependency tracking, and wanted to share the experience — both the technical details and what it’s like as a first-time contributor to this large open source OCaml project.
The Problem I Took On
When libA depends on libB, Dune gives every module in libA a glob dependency on all .cmi files in libB. If any .cmi in libB changes, every module in libA is recompiled — even modules that never reference libB.
For projects with many libraries, this creates a cascade of unnecessary recompilations. The issue that tracks this matter #4572 has been open since 2021.
My Approach
Dune already runs ocamldep to compute intra-library module dependencies. The key insight: that same output tells us which libraries each module references, via their entry module names. We can use this to filter both the build dependencies and the -I/-H compiler flags per-module.
The implementation (PR #14116 and PR #14186) works as follows:
- For each module and its transitive intra-library dependencies, read the
ocamldepoutput (both.mland.mli) - Union all referenced module names, including
-openflags - Map those names to libraries via a
Lib_index - Transitively close the filtered library set via
Lib.closure - Use the result for both hidden deps and
-I/-Hcompiler flags, partitioning into direct (visible via-I) and hidden (via-H) based onrequires_compilemembership
With both deps and flags filtered, a clean build will fail if a module references a library it doesn’t declare — previously, overly-broad -I flags could mask such errors.
A False Start
My first attempt (PR #14021) tried to implement the filtering in a single PR without sufficient test coverage. It was closed after review revealed that the approach was fragile in edge cases I hadn’t anticipated — particularly around transparent module aliases and virtual libraries.
Challenges
Transparent module aliases. OCaml’s module alias mechanism means ocamldep doesn’t always report all libraries a module transitively depends on. If libB has module M = LibC.Something, and a module in libA uses LibB.M, ocamldep reports LibB but not LibC. The fix: transitively close the filtered library set using Lib.closure, bounded by the compilation context.
Root modules. The (root_module) stanza creates a module that implicitly aliases all libraries in the compilation context. When ocamldep reports a reference to a root module, we can’t determine which underlying libraries are actually needed, so we fall back to the full dependency set.
Virtual libraries. When virtual library implementations are present in the compilation context, parameter libraries may not appear in requires_compile, so filtering could miss them. Another fallback case.
Menhir-generated modules. These mock modules aren’t in the ocamldep dependency graph, so we skip filtering for them.
Null build overhead. The filtering reads .d files and computes library closures per-module. On a fresh dune process (no memo cache), this is new work on every build — including null builds where nothing changed. This is a real trade-off: better incremental rebuild performance at the cost of some null-build overhead.
Prerequisite Test PRs
Before the implementation PRs, I submitted six test-only PRs to document existing behavior and establish a safety net:
- #14017 — Baseline tests documenting current inter-library recompilation behavior
- #14031 — Test documenting module name shadowing between stanzas and libraries
- #14100 — Test verifying library file deps in compilation rules and sandboxed builds
- #14101 — Test verifying transparent alias incremental build safety
- #14129 — Test verifying incremental builds with alias re-exported libraries
- #14178 — Test documenting
ocamldepbehavior with transparent alias chains
This made the implementation PRs’ diffs focused on the actual change, and gave reviewers confidence that existing behavior was preserved. It also helped me understand the edge cases that tripped up my first attempt.
The Review Process
The Dune maintainers (@rgrinberg and @art-w) provided thorough, constructive reviews. Some highlights:
- Replacing my hand-rolled transitive closure with
Lib.closurefrom the existing library — a cleaner approach I wouldn’t have found without familiarity with Dune’s internals - Identifying that both
.mland.mliocamldep output need to be read, since the interface can reference different libraries than the implementation - Suggesting per-module
-I/-Hflag filtering, which makes clean builds more precise and improves caching - Questioning every fallback case and special-cased module kind, leading to simpler code
The PRs went through significant refactoring during review — the final versions are substantially tighter than the initial submissions.
What Could Be Better
Working on this was a positive experience overall, but a few things created friction:
No way to benchmark before merging. The null-build overhead question came up late in the process. I discovered through manual benchmarking that the change added ~70% to null build time — a significant regression. Dune’s benchmark CI workflow runs only on pushes to main, not on PRs. Contributor-accessible performance tooling would help catch regressions before they land.
Review momentum vs. rebasing. The test PRs merged quickly, but the implementation PR required multiple rounds of review over days. Between rounds, main moves forward, requiring rebases that risk introducing conflicts. The contributor carries the burden of keeping branches fresh. This is compounded when PRs depend on each other — every rebase of #14116 required rebasing #14186 as well. GitHub has no first-class support for PR stacks, so this is manual and error-prone. Of course, all GitHub-hosted repos suffer from this.
Flaky CI. Many CI runs had errors that were not related to my code. It was often an upstream provider of an OCaml package that was unreachable or faulty (temporarily). These problems often resolved themselves, but caused day-long delays in the PR lifetimes. The problem stems from the setup code that is run and re-run over and over in CI jobs.
Reflections
The Dune codebase is well-structured, with clear separation between the build engine, rule generation, and scheduler. It is also of good quality, making it feel like time spent on keeping the quality high is worthwhile.
I found the cram test infrastructure good for testing. Each test scenario is a self-contained shell script with expected output, making it easy to document and verify exact recompilation behavior. It inspires confidence in the code.
The maintainers have been responsive and the review process, while slowed by thoroughness, is collaborative and professional. Thank you, maintainers!