Skip to content

th_build_depends for restricting which packages are needed for TH - #24

Closed
michaelpj wants to merge 4 commits into
simonmar:0.5.0.0from
michaelpj:feat/th-build-depends
Closed

michaelpj wants to merge 4 commits into
simonmar:0.5.0.0from
michaelpj:feat/th-build-depends

Conversation

@michaelpj

Copy link
Copy Markdown

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.

@michaelpj

Copy link
Copy Markdown
Author

For example, in our codebase, I can say "this module uses TH splices from PyF" and then we'll compile it without needing the local packages to be fully compiled, since we only need to have PyF for TH evaluation.

@michaelpj
michaelpj force-pushed the feat/th-build-depends branch from ac86e15 to b667d44 Compare October 26, 2026 21:33
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
michaelpj force-pushed the feat/th-build-depends branch from b667d44 to b3046f5 Compare October 27, 2026 10:13
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
michaelpj force-pushed the feat/th-build-depends branch from b3046f5 to f9f9d7f Compare October 27, 2026 12:53
@michaelpj michaelpj closed this Oct 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant