Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/ghc-worker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# The per-module compiles' persistent worker (tools/ghc-worker) is a program
# against GHC's API, built with the GHC it compiles for: build and test it
# with the GHC versions it supports. The rules themselves are exercised by
# cabal-buck2's CI, on its example.
name: ghc-worker

on:
- push
- pull_request
- workflow_dispatch

jobs:
ghc-worker:
name: ghc-worker (${{ matrix.os }}, GHC ${{ matrix.ghc }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
ghc: ["9.6.7", "9.8.4", "9.10.3", "9.12.4", "9.14.1"]
include:
- os: macos-latest
ghc: "9.12.4"
defaults:
run:
shell: bash
working-directory: tools/ghc-worker
steps:
- uses: actions/checkout@v7

- name: Install GHC and cabal
uses: haskell-actions/setup@v2
with:
ghc-version: ${{ matrix.ghc }}
cabal-version: latest

- name: Update Hackage index
run: cabal update

- name: Build
run: |
printf 'package ghc-worker\n ghc-options: -Werror\n' > cabal.project.local
cabal build all

- name: Test
run: cabal test

- name: Install as ghc-worker-<version>
run: cabal install --installdir="$RUNNER_TEMP/bin" --program-suffix="-${{ matrix.ghc }}" exe:ghc-worker
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@ cabal.project.local
# Generated by gen-haskell-prebuilt.py
**/third-party/haskell/

# Generated by `cabal buck2` when a project builds the worker from source
/tools/ghc-worker/BUCK
/tools/ghc-worker/BUCK.cabal.bzl

# example/'s stand-in for "clone this repo as buck2/" (README) - this
# repo is already checked out, so a symlink to it takes the place of a
# real clone. Created by example/setup.sh.
/example/buck2
__pycache__/
*.pyc
/tools/ghc-worker/cabal-buck2/
70 changes: 70 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,76 @@ buck2 build my-package:my-program -m opt

There are other build options that can be selected in a similar way, such as `-m prof` to enable profiling. See `constraints/BUCK` for details.

# Per-module compilation (experimental)

By default each `haskell_library()`/`haskell_binary()` is compiled by one
`ghc --make` action. With `per_module = True` (which
`cabal buck2 --per-module` sets on every target), each module is compiled
by an action of its own. buck2 then compiles a target's modules in
parallel with everything else it has to do, and after an edit recompiles
only the modules that GHC's own recompilation check would: a comment-only
edit recompiles the one module, and a change to a module's interface
recompiles the modules that import it.

Across targets, a module waits only for the modules it imports from a
dependency that is itself compiled per module, not for the whole
dependency, so it can start before that dependency is fully compiled, let
alone linked. The exception is a module that runs Template Haskell (or
evaluates an `ANN` pragma), which needs the libraries of the dependencies
whose code its splices run, and by default waits for all of the
dependencies' libraries. Which modules run Template Haskell is decided by
the extensions they (or their target) enable, so enabling
`TemplateHaskell` for a whole package makes all of its modules wait. A
target can say which of its dependencies compiled per module its splices
run code from, with `th_build_depends`:

```
haskell_library(
name = "model",
per_module = True,
# The splices are rel8's deriveRel8able and PyF's quasi-quoters, from
# Hackage: no dependency of ours.
th_build_depends = [],
...
)
```

Its Template Haskell modules then wait for the libraries of those (and of
their own dependencies) alone, and for the other dependencies only as any
other module does. A dependency not compiled per module (from Hackage, say)
needs no listing, its library being there from the start. A splice that
runs code from a dependency left out fails to compile, naming the symbol it
couldn't load. With `cabal buck2`, set it in the project's overrides
(`cabal-buck2.bzl`).

The cost is CPU. Each module's compile starts a GHC and reads the
interfaces of its imports, which `--make` does once per target, so a
target's modules take several times the CPU in total, and a clean compile
of a target is slower than with `--make` even with every core busy. Which
way is faster for a project depends on how its time divides between clean
builds and rebuilds after small edits.

Two settings help the per-module build and cost nothing elsewhere: run
buck2 with `-j` set to the number of physical cores (the compiles gain
nothing from SMT siblings, and each takes memory), and give GHC a larger
allocation area, `compiler_flags = +RTS -A64m -RTS` in the `[haskell]`
section of the buckconfig (see below), which took 13% off the per-module
compiles of one project.

The persistent worker in `tools/ghc-worker` takes away much of that CPU cost:
buck2 sends the compiles of a target to one long-running process, which
compiles them in a single GHC session and so reads each interface once.
`per_module_worker` in the `[haskell]` section of the buckconfig says
which to use:

- `local`: the project's own, which buck2 builds with the project's GHC
(add `buck2/tools/ghc-worker` to the packages of `cabal.project`);
- a path, or a name on `$PATH`: one installed with the project's GHC (see
`tools/ghc-worker/README.md`);
- `none`, or unset: no worker.

The worker is used when buck2 runs the compiles locally.

# Compiler and linker flags from the environment

buck2 runs every build action with a clean environment, so directories that
Expand Down
10 changes: 10 additions & 0 deletions cabal.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# "cabal_buck2": str, # the release of cabal-buck2 that wrote this; see version.bzl
# "package": {"name": str, "version": str}, # where it lives: see PACKAGES, below
# "ghc_options": [str], # supplied by the project, not the .cabal file
# "per_module": bool, # compile each module in its own action (`cabal buck2 --per-module`)
# "data": {"dir": str, "files": [str]}, # the package's data-dir and data-files (patterns)
# "extra_source_files": [str], # the package's extra-source-files (patterns)
# "components": [
Expand Down Expand Up @@ -73,6 +74,7 @@
# a tools.bzl from an earlier release fails here, on a symbol that says what
# it is about, and an incompatible one is reported just below.
load("@third-party-haskell//:tools.bzl", TOOLS_CABAL_BUCK2_VERSION = "CABAL_BUCK2_VERSION")
load("@toolchains//:per_module_worker.bzl", "project_per_module_worker")
load("//buck2:version.bzl", "CABAL_BUCK2_VERSION")
load("//buck2:cmm.bzl", "cmm_library")
load("//buck2:cxx.bzl", "cxx_binary", "cxx_library")
Expand Down Expand Up @@ -441,6 +443,8 @@ def _haskell_kwargs(c, spec, helpers):
}
if "exe_name" in c:
kwargs["exe_name"] = c["exe_name"]
if spec.get("per_module", False):
kwargs["per_module"] = True
if kind == "test-suite":
kwargs["cwd"] = pkg_dir
if c.get("test_args"):
Expand Down Expand Up @@ -573,6 +577,12 @@ def cabal_targets(spec, defaults = {}, overrides = {}, transform = None, rules =
kwargs = transform(kind, c, kwargs)
if kwargs == None:
continue
# The worker of the per-module compiles, when it is the project's own
# (`per_module_worker = local`, see buck2/toolchains/
# per_module_worker.bzl): every per-module target (however it came to
# be one) but the worker's own.
if kwargs.get("per_module", False) and "per_module_worker" not in kwargs and project_per_module_worker() != None and spec["package"]["name"] != "ghc-worker":
kwargs["per_module_worker"] = project_per_module_worker()
_check_provided(spec, c, kwargs.get("srcs", {}))
rules.get(kind, _DEFAULT_RULES[kind])(**kwargs)
_data_files(spec, pkg_dir)
Expand Down
4 changes: 4 additions & 0 deletions gen-haskell-prebuilt.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,10 @@ def generate_tools_file(tool_paths):
# _ghc_dynamic()'s own comment above, and buck2/toolchains/
# haskell.bzl's `dynamic_ghc` toolchain attr, which this feeds.
f'GHC_DYNAMIC = {GHC_DYNAMIC!r}',
# The project's own target for the per-module worker, if it builds
# it (see buck2/toolchains/per_module_worker.bzl): only `cabal buck2`
# knows the project's packages.
'GHC_WORKER_TARGET = None',
'',
]
for tool in BUILD_TOOLS:
Expand Down
17 changes: 15 additions & 2 deletions platforms/defs.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,26 @@
# stays correct even if that vendored rule's own executor_config logic
# ever changes.
load("@prelude//cfg/exec_platform:marker.bzl", "get_exec_platform_marker")
load("@toolchains//:per_module_worker.bzl", "has_per_module_worker")

# Actions run locally, and their results are looked up in, and uploaded to, a
# remote action cache (the one `[buck2_re_client]` in .buckconfig points to).
# Nothing runs remotely.
def _cache_executor_config():
def _cache_executor_config(persistent_workers):
return CommandExecutorConfig(
local_enabled = True,
remote_enabled = False,
remote_cache_enabled = True,
allow_cache_uploads = True,
use_persistent_workers = persistent_workers,
)

# As the base platform's (local, no remote cache), with persistent workers.
def _worker_executor_config():
return CommandExecutorConfig(
local_enabled = True,
remote_enabled = False,
use_persistent_workers = True,
)

def _execution_platform_with_constraints_impl(ctx: AnalysisContext) -> list[Provider]:
Expand All @@ -31,7 +41,7 @@ def _execution_platform_with_constraints_impl(ctx: AnalysisContext) -> list[Prov
platform = ExecutionPlatformInfo(
label = name,
configuration = cfg,
executor_config = _cache_executor_config() if ctx.attrs.cache else base.executor_config,
executor_config = _cache_executor_config(ctx.attrs.persistent_workers) if ctx.attrs.cache else (_worker_executor_config() if ctx.attrs.persistent_workers else base.executor_config),
)

return [
Expand All @@ -52,6 +62,9 @@ execution_platform_with_constraints = rule(
# `[cabal_buck2] cache = true` in .buckconfig, which `cabal buck2
# --cache=ADDRESS` writes together with the remote cache's address.
"cache": attrs.bool(default = read_root_config("cabal_buck2", "cache", "false") == "true"),
# Actions may run in persistent workers: the per-module compiles do
# when there is one (see toolchains/per_module_worker.bzl).
"persistent_workers": attrs.bool(default = has_per_module_worker()),
},
)

Expand Down
31 changes: 31 additions & 0 deletions prelude/decls/haskell_rules.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ haskell_binary = prelude_rule(
"ghci_platform_preload_deps": attrs.list(attrs.tuple(attrs.regex(), attrs.set(attrs.dep(), sorted = True)), default = []),
"ghci_preload_deps": attrs.set(attrs.dep(), sorted = True, default = []),
"link_deps_query_whole": attrs.bool(default = False),
"per_module": attrs.bool(default = False), # as haskell_library's
"_per_module_compile": attrs.default_only(attrs.source(default = "prelude//haskell/tools:per_module_compile.sh")),
"_per_module_deps": attrs.default_only(attrs.source(default = "prelude//haskell/tools:per_module_deps.sh")),
"per_module_worker": attrs.option(attrs.exec_dep(providers = [RunInfo]), default = None), # as haskell_library's
"th_build_depends": attrs.option(attrs.list(attrs.dep()), default = None), # as haskell_library's
"linker_flags": attrs.list(attrs.arg(), default = []),
"platform": attrs.option(attrs.string(), default = None),
}
Expand Down Expand Up @@ -185,6 +190,32 @@ haskell_library = prelude_rule(
"ghci_platform_preload_deps": attrs.list(attrs.tuple(attrs.regex(), attrs.set(attrs.dep(), sorted = True)), default = []),
"ghci_preload_deps": attrs.set(attrs.dep(), sorted = True, default = []),
"haddock_flags": attrs.list(attrs.arg(), default = []),
# Experimental. Compile each module in an action of its own
# rather than the whole target in one ghc --make: buck2 then
# schedules the modules in parallel and rebuilds only those whose
# imports' interfaces changed. See the README for what it costs.
"per_module": attrs.bool(default = False),
# The scripts of the per-module compiles.
"_per_module_compile": attrs.default_only(attrs.source(default = "prelude//haskell/tools:per_module_compile.sh")),
"_per_module_deps": attrs.default_only(attrs.source(default = "prelude//haskell/tools:per_module_deps.sh")),
# The persistent worker of the per-module compiles, when the
# project builds it from source (tools/ghc-worker). The toolchain
# can't depend on a target the project builds, so each per-module
# target names it. None: the toolchain's, if it has one.
"per_module_worker": attrs.option(attrs.exec_dep(providers = [RunInfo]), default = None),
# With per_module: the dependencies, among this target's deps
# compiled per module, whose code its Template Haskell splices
# and ANN pragmas run (that code's own dependencies come with
# them). A module that runs Template Haskell then waits for
# the libraries of those alone, and compiles against the
# interfaces of the rest as soon as it imports them, as any
# other module does. None (the default): all of the
# dependencies. A dependency built from a Hackage package (or
# anything not compiled per module) needs no listing: its
# library is there from the start. A splice that runs code from
# a dependency not listed fails to compile, saying which symbol
# it couldn't load.
"th_build_depends": attrs.option(attrs.list(attrs.dep()), default = None),
# Libraries that are part of this one, as the C of a Cabal package
# is part of its library: their objects are in its archive, and
# linked into its shared library, and what depends on it isn't
Expand Down
Loading
Loading