Using old build results as a starting point to speed up new builds.
Together with Harry Garrood I have been looking into speeding up Haskell builds with Nix. He recently wrote a blog post about changes in GHC 9.4 that made this task a little bit easier.
With this patch, source file modification times play no part in recompilation checking.
So what is happening instead?
… the hash of the contents of the source file [will] be stored within the corresponding
.hi
file and GHC [will] determine whether the source file had been changed by comparing this [old] hash to the source file’s current hash …
But how would we make use of that with Nix?
The Nix Wrapper
Haskell packages, that are built with cabal
, can easily be turned into a derivation.
{
packages.x86_64-linux.default =
with import nixpkgs { system = "x86_64-linux"; };
haskellPackages.callCabal2nix "example-package" ./. {};
# ...
}
All the hard work is being done by cabal2nix.
Now we will add a wrapper around packages.x86_64-linux.default
to make use of GHCs cleverness.
{
# ...
packages.x86_64-linux.incremental =
with import nixpkgs { system = "x86_64-linux"; };
with import ./nix/haskell/lib.nix {
lib = pkgs.lib;
haskellLib = pkgs.haskell.lib;
};
buildIncrementally {
regularPackage = self.packages.x86_64-linux.default;
previousIncrement = incremental.packages.x86_64-linux.incremental.incrementalBase;
};
# ...
}
buildIncrementally
-
A wrapper function, that turns a regular derivation into an incremental one.
regularPackage
-
The derivation, that we want to speed up.
previousIncrement
-
The derivation, that you previously built using
buildIncrementally
. You can set this tonull
or remove the attribute, if you don’t have any previous result yet.
Implementation of buildIncrementally
We add an additional output "incremental"
to the regular package , so that it becomes a multi-output derivation.
This new output contains a tarball with basically all the files generated by GHC (.o
, .hi
, .dyn_o
, .dyn_hi
, .p_o
, .p_hi
).
Now we pass all of this information to the next incremental build by extracting the tarball during preBuild
.
postInstall
and preFixup
take care of generating the tarball for the next incremental build.
let buildIncrementally =
{ regularPackage, previousIncrement ? null }:
(haskellLib.overrideCabal regularPackage
(drv: {
preBuild = lib.optionalString (previousIncrement != null) ''
mkdir -p dist/build
tar xzf ${previousIncrement.incremental}/dist.tar.gz -C dist/build
'';
postInstall = ''
mkdir $incremental
tar czf $incremental/dist.tar.gz -C dist/build \
--mtime='1970-01-01T00:00:00Z' .
'';
preFixup = ''
# Don't try to strip incremental build outputs
outputs=(${"\\" + "\${"}outputs[@]/incremental})
'';
})
).overrideAttrs (finalAttrs: previousAttrs: {
outputs = previousAttrs.outputs ++ ["incremental"];
});
in ...
Using the Wrapper with Nix Flakes
It’s a bit tricky to get this to work with flakes.
Our flake requires two inputs (nixpkgs
and incremental
).
The input incremental
is pointing to the upstream of the current git repository.
We need this to access a prior version of this flake.
{
inputs = {
nixpkgs = {
type = "github";
owner = "NixOS";
repo = "nixpkgs";
ref = "nixpkgs-unstable";
};
incremental = {
type = "github";
owner = "example-user";
repo = "example-package";
ref = "master";
inputs.nixpkgs.follows = "nixpkgs";
inputs.incremental.follows = "incremental";
};
};
# ...
}
The packages default
and incremental
have already been explained.
{
# ...
outputs = { self, nixpkgs, incremental }: {
packages.x86_64-linux.default = ...; # same as above
packages.x86_64-linux.incremental = ...; # same as above
packages.x86_64-linux.incrementalBase =
with import nixpkgs { system = "x86_64-linux"; };
with import ./nix/haskell/lib.nix {
lib = pkgs.lib;
haskellLib = pkgs.haskell.lib;
};
buildIncrementally {
regularPackage = self.packages.x86_64-linux.default;
};
};
}
We also added incrementalBase
, which produces the same result as incremental
, but doesn’t depend on earlier versions.
We are using it to set previousIncrement
in the incremental
package.
Note
|
In the definition of |
Conclusion
Overall the wrapper function buildIncrementally
can speed up compilation a whole lot.
The more modules you have, the more time will be saved.
With hundreds of modules you can likely reduce your CI time by 90% with the incremental
output.
This can probably be built into callCabal2nix
, which would make the interface a bit more comfortable.
The integration with flakes feels a bit awkward, but I guess it works.
Examples
-
I played around with this approach here. With flakes enabled you can easily try it out by building an output.
nix build --print-build-logs \ 'github:jumper149/consuming-parser/incremental#incremental'
You will notice that you are building
consuming-parser
twice. First you will build it regularly viaincrementalBase
and then you will also buildincremental
, which doesn’t have to compile any modules, because they are already provided byincrementalBase
. -
Harry has an even smaller example, that doesn’t use nix flakes.