Repository navigation
Conversation
Author
|
For example, in our codebase, I can say "this module uses TH splices from |
michaelpj
force-pushed
the
feat/th-build-depends
branch
from
October 26, 2026 21:33
ac86e15 to
b667d44
Compare
A haskell_library() or haskell_binary() is compiled by one `ghc --make`
action: its modules compile one after another, and an edit to any of
them re-runs the whole action. With `per_module = True` a `ghc -M`
action computes the module graph (it needs only the sources and the
package dbs) and a dynamic_output registers one `ghc -c` action per
module. Every module writes to one output directory, which is the
import path of each compile, with the interfaces and objects of its
transitive imports as its inputs; the objects and hi directories the
rest of the rules consume are assembled from the per-module outputs. A module's
.hs-boot is compiled by an action of its own, which the module and its
{-# SOURCE #-} importers depend on.
Each action records what it read in a buck2 dep file, which a script
(prelude/haskell/tools/per_module_compile.sh) takes after the compile
from the usages GHC writes into the module's interface: for each import, the
imported module's ABI hash (written next to every interface, and
exported with a package's interfaces) rather than the interface itself,
whose bytes also change with the source hash on a comment-only edit;
and each object that a Template Haskell splice loaded, which GHC
records as a dependent file. So the recompilation policy is exactly
GHC's own, and after an edit only the modules whose imports' ABI
changed are recompiled, within and across packages: a comment-only
edit recompiles the one module.
The cost is that each one-shot compile starts a GHC and loads its
imports' interfaces (and, for Template Haskell, the dependencies'
shared libraries), which --make does once per target, so a target's
modules take several times the CPU in total. On a 126-package project,
a clean compile of a 248-module package with no Template Haskell took
53s against 19s with --make on 16 cores, an ABI-changing edit with 43
dependent modules 29s against 24s, while a comment-only edit to the
same module went from re-checking 43 actions to recompiling one.
cabal.bzl sets the attribute on every target of a spec that has
"per_module": True, which `cabal buck2 --per-module` writes. This
repository has no tests of its own; the cabal-buck2 branch of the same
name builds and tests its example per module in CI, in dev, opt and prof
modes (a cross-package splice, hsc2hs, C++ and an hs-boot cycle), and
checks that a comment-only edit recompiles one module. That runs once
cabal-buck2's submodule points at this commit.
Change-Id: I339d3f6b20cab49d4e46de7b82b705066a6a6964
A module compiled per module depended on each dependency whole: its interface directory, assembled when all of its modules are compiled, its package db, registered after that, and its library. So packages still built one after another, module-parallel within each. Now a module that runs no Template Haskell depends only on the interfaces it imports from a dependency compiled per module, with their transitive closure (GHC reads those for instances and type families), and compiles against the dependency's early package db: the package without its library, its import directory the one the modules' interfaces are written to one by one, registered before anything is compiled (--force: ghc-pkg would refuse interfaces that don't exist yet). The imports come from `ghc -M -include-pkg-deps`, which names each imported interface by the path the package db gives it, before it exists; the closure from the dependency's own closures, which every per-module target writes out for its dependents, transitively complete, so each target reads only its direct dependencies'. A Template Haskell module needs the dependencies' libraries, so it depends on them whole as before. Which modules run Template Haskell is decided by the extensions they or their target enable, read from the sources by the `ghc -M` action's script (prelude/haskell/tools/per_module_deps.sh). The per-module outputs are declared outside the dynamic output so that they can be exported, with the early db, the closures and the output directory's path, as fields of HaskellLibraryInfo; a library built with --make leaves them unset and is depended on whole. A profiled library's early db names the directory of its profiled interfaces alone: `ghc -M` takes a package's interfaces on trust (they don't exist when the early db is used) only when the package has exactly one import directory. On the cabal-buck2 example, the test binary's Main depends on the early db of the library and the ABI files of the three modules it imports, where it depended on the library's shared object and interface directory; the splicing module still depends on its dependency's final db and library. Change-Id: Ib057a1c79420c3d64c379d76f1abd0e06a6a6964
michaelpj
force-pushed
the
feat/th-build-depends
branch
from
October 27, 2026 10:13
b667d44 to
b3046f5
Compare
The per-module build's remaining cost over `ghc --make` is one GHC process per module, each reading the interfaces of its imports again. tools/ghc-worker is a buck2 persistent worker that takes that away: buck2 sends the compiles of a target to one process per target, which compiles each as it arrives, concurrently, with GHC's own per-module compile (compileOne, what `--make -j` runs for each module) in a session that keeps the interfaces it has read. The session is in --make mode, so it keeps what that mode needs: the home package table, with each compile's result and the modules a compile imports that this process didn't compile, read from disk (buck2 sends a module only once its imports' actions are done); and the module graph, from GHC's dependency analysis of the target's sources, done once. The worker writes each module's .abi file and buck2 dep file from the interface GHC produced, as the compile script does from its text, and answers each request with its module's diagnostics. A request carries the module's source and the compile's flags in two parts, the session's and the module's own; the rules send the same request to the compile script when there is no worker, which runs the two parts as one `ghc -c`. A module that runs Template Haskell compiles against its dependencies' final package dbs, the others against their early dbs, so a target's requests come with one of two sets of flags: the worker keeps a GHC session for each, made when the first request with its flags comes, and a session reads the modules another compiled from disk. The early dbs name no library, which is what makes them safe to compile against before the dependencies are linked: GHC's interpreter loads the library of every package on the command line, and one that is on disk but was linked against one that isn't fails to load (see Note [Libraries of the early package dbs]). The import path is a symlink tree of the target's own sources, not the project's source directories, where a module of another target (a sublibrary's, say) would shadow that target's package. Objects are compiled with -fobject-determinism, without which a module's object depends on what the session compiled before it and every edit ends in a relink. The worker is used only when `[haskell] per_module_worker` in the buckconfig says so: `local` for the project's own, when it is a package of the project (`cabal buck2` records its target in tools.bzl, and every per-module target names it, since the toolchain can't depend on a target of the project); a path or a name on $PATH for one installed with the project's GHC; `none`, or unset, for none. The execution platform enables persistent workers when there is one. Each request names the version of the rules' contract with the worker, which a worker installed for other rules refuses, saying so. The worker's environment is a compile's, with the target's shared library tree on LD_LIBRARY_PATH by path only, so that starting it waits for no link. The same program compiles one module and exits when buck2 runs the action without a worker; it re-executes itself with LD_LIBRARY_PATH set first, which the dynamic linker reads at process start. The worker is a program against GHC's API, built with the GHC it compiles for, so its correctness against a GHC version is this repository's to check, not the example's in cabal-buck2. Its cabal test suite compiles a small package through the built worker, one request per module as the rules send them: a module with a boot file, its SOURCE importer, an importer of both, a module with no imports; and checks the interfaces, objects, ABI and dep files it writes, that a comment-only edit leaves the ABI (and, from GHC 9.12, the object) as it was, that an exported binding changes the ABI, that an edit to the boot file and the compiles that follow it succeed, that a package-qualified import of a module whose name a home module has too is the package's, that a failing module's errors come back naming their source, and that a request for another version of the rules is refused. Twice: each request in a process of its own, the mode of an action run without a worker; and through one worker over buck2's socket protocol, with an http2 client, which is how the session's state is exercised: results kept, two independent modules compiled at once, a module compiled again after edits, and the worker ended with SIGTERM as buck2 ends it. It builds and passes that with GHC 9.6, 9.8, 9.10, 9.12 and 9.14, the differences behind CPP: the home package table is a mutable table since 9.14, where the dependency analysis also takes a diagnostic wrapper, the interface's hash and usages are reached differently, imports carry a level, and the unit database cache is cleared by hand; before 9.12 there is no -fobject-determinism (so an edit may end in a relink), and the summary of an hs-boot file has to be marked as one; before 9.8 a usage's file path is a String. The workflow builds the worker with -Werror and runs the suite on Linux with each of those GHCs, and on macOS with 9.12. The rules' use of it is tested on the cabal-buck2 example with --per-module: dev, opt and prof builds and tests, with and without the worker, the worker installed or built by the project; dep files identical in content to the script's; a comment-only edit recompiling one module and nothing else, an ABI change recompiling the module and its importer, edits to an hs-boot file, to the module it belongs to and to its SOURCE importer. Change-Id: If0c6f86513cd23af7b814ee7427197446a6a6964
…code from A module compiled per module that runs Template Haskell (or evaluates an ANN pragma) waits for the libraries of all its dependencies, because GHC's interpreter loads the library of every package on the command line before running a splice. Most splices run code from no dependency of the project's own: rel8's deriveRel8able, PyF's quasi-quoters, effectful's makeEffect all come from Hackage packages, whose libraries are there from the start. th_build_depends lets a target say which of its dependencies compiled per module its splices run code from; its Template Haskell modules then compile against the final package dbs of those, of every dependency not compiled per module, and of their dependencies, and against the early dbs of the rest, so they start as soon as the interfaces they import exist, as the other modules do. Unset, it means all of them, as before. The set is closed over dependencies because a library that is on disk but was linked against one that isn't fails to load, while one simply absent is tolerated (see Note [Libraries of the early package dbs]); the early dbs name no library for that reason. The shared libraries such a module's process gets are restricted to the same set: there is a tree of them per dependency compiled per module (and one for the rest), and its LD_LIBRARY_PATH lists those of the set. A splice that runs code from a dependency left out fails to compile, naming the symbol it couldn't load; listing a target that isn't a dependency fails analysis. On the cabal-buck2 example, with th's code left out, the module splicing from th fails, naming the symbol, and the module with an ANN pragma compiles against th's early db and doesn't depend on th's library. On a chain of eleven packages of a large project (all compiled per module, 103 modules of the last with 20 splicing from rel8 and effectful), a clean compile took 32s with th_build_depends = [] against 36s without, and its critical path no longer went through any dependency's link. Change-Id: Ic122b286d27defa892f95b6f30926d036a6a6964
michaelpj
force-pushed
the
feat/th-build-depends
branch
from
October 27, 2026 12:53
b3046f5 to
f9f9d7f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This adds a way to tell us that we only need certain packages for TH, rather than conservatively assuming that we need all of them. This is crucial for cutting down the dependencies for module builds that use TH, and is easy enough to set for most builds that largely use TH from Hackage packages.
The idea is that it should be nicely forwards-compatible with computing this information from ExplicitLevelImports, at which point the user won't need to configure anything.
A module compiled per module that runs Template Haskell (or evaluates an
ANN pragma) waits for the libraries of all its dependencies, because GHC's
interpreter loads the library of every package on the command line before
running a splice. Most splices run code from no dependency of the
project's own: rel8's deriveRel8able, PyF's quasi-quoters, effectful's
makeEffect all come from Hackage packages, whose libraries are there from
the start. th_build_depends lets a target say which of its dependencies
compiled per module its splices run code from; its Template Haskell
modules then compile against the final package dbs of those, of every
dependency not compiled per module, and of their dependencies, and against
the early dbs of the rest, so they start as soon as the interfaces they
import exist, as the other modules do. Unset, it means all of them, as
before.
The set is closed over dependencies because a library that is on disk but
was linked against one that isn't fails to load, while one simply absent
is tolerated (see Note [Libraries of the early package dbs]); the early
dbs name no library for that reason. The tree of shared libraries such a
module's process gets is restricted to the same set. A splice that runs
code from a dependency left out fails to compile, naming the symbol it
couldn't load; listing a target that isn't a dependency fails analysis.
On the cabal-buck2 example, with th's code left out, the module splicing
from th fails, naming the symbol, and the module with an ANN pragma
compiles against th's early db and doesn't depend on th's library. On a
chain of eleven packages of a large project (all compiled per module, 103
modules of the last with 20 splicing from rel8 and effectful), a clean
compile took 32s with th_build_depends = [] against 36s without, and its
critical path no longer went through any dependency's link.