The Mill Evaluation Model
Evaluating a Mill target typically goes through the following phases:
-
Compilation: Mill compiles the
build.scto classfiles, following the The Mill Bootstrapping Process to eventually produce aRootModuleobject -
Planning: Mill resolves the list of Tasks given from the command line, e.g.
resolveorfoo.compileor{bar,qux}._.test, to a list of concreteTaskobjects nested on Modules within theRootModulealong with their transitive dependencies-
In the process, the relevant Mill
Modules are lazily instantiated
-
-
Evaluation: Mill evaluates the gathered
Tasks in dependency-order, either serially or in parallel
Limitations of the Mill Evaluation Model
This three-phase evaluation model has consequences for how you structure your build. For example:
-
You can have arbitrary code outside of
Tasks that helps set up your task graph and module hierarchy, e.g. computing what keys exist in aCrossmodule, or specifying yourdef moduleDeps -
You can have arbitrary code inside of
Tasks, to perform your build actions -
But your code inside of
Tasks cannot influence the shape of the task graph or module hierarchy, as all Resolving and Planning happens first before anyTasks are evaluated.
This should not be a problem for most builds, but it is something to be aware
of. In general, we have found that having "two places" to put code - outside of
Tasks to run during Planning or inside of Tasks to run during
Evaluation - is generally enough flexibility for most use cases.
Caching at Each Layer of the Evaluation Model
Apart from fine-grained caching of Tasks during Evaluation, Mill also
performs incremental evaluation of the other phases. This helps ensure
the overall workflow remains fast even for large projects:
-
Compilation:
-
Done on-demand and incrementally using the Scala incremental compiler Zinc.
-
If some of the files
build.scimported changed but not others, only the changed files are re-compiled before theRootModuleis re-instantiated -
In the common case where
build.scwas not changed at all, this step is skipped entirely and theRootModuleobject simply re-used from the last run.
-
-
Planning:
-
If the
RootModulewas re-used, then all previously-instantiated modules are simply-re-used
-
-
Evaluation:
-
Tasks are evaluated in dependency order -
Targets only re-evaluate if their input
Tasks change. -
T.persistents preserve the
T.destfolder on disk between runs, allowing for finer-grained caching than Mill’s default target-by-target caching and invalidation -
T.workers are kept in-memory between runs where possible, and only invalidated if their input
Tasks change as well. -
Tasks in general are invalidated if the code they depend on changes, at a method-level granularity via callgraph reachability analysis. See #2417 for more details
-
This approach to caching does assume a certain programming style inside your
build.sc: we may or may not re-instantiate the RootModule of your
build.sc depending on caching, and your code should work either way. For code
written in a typical Scala style, this is not a problem at all.
One thing to note is for code that runs during Planning: any reading of
external mutable state needs to be wrapped in an interp.watchValue{…}
wrapper. This ensures that Mill knows where these external reads are, so that
it can check if their value changed and if so re-instantiate RootModule with
the new value.
The Mill Bootstrapping Process
Mill’s bootstrapping proceeds roughly in the following phases:
-
If using the bootstrap script, it first checks if the right version of Mill is already present, and if not it downloads it to
~/.mill/download -
It instantiates an in-memory
MillBuildRootModule.BootstrapModule, which is a hard-codedbuild.scused for bootstrapping Mill -
If there is a meta-build present
mill-build/build.sc, it processes that first and uses theMillBuildRootModulereturned for the next steps. Otherwise it uses theMillBuildRootModule.BootstrapModuledirectly -
Mill evaluates the
MillBuildRootModuleto parse thebuild.sc, generate a list ofivyDepsas well as appropriately wrapped Scala code that we can compile, and compiles it to classfiles -
Mill loads the compiled classfiles of the
build.scinto ajava.lang.ClassLoaderto access it’sRootModule
Everything earlier in the doc applies to each level of meta-builds in the Mill bootstrapping process as well.
In general, .sc files, import $file, and import $ivy can be thought of as
a short-hand for configuring the meta-build living in mill-build/build.sc:
-
.scandimport $fileare a shorthand for specifying the.scalafiles living inmill-build/src/ -
import $ivyis a short-hand for configurin thedef ivyDepsinmill-build/build.sc
Most builds would not need the flexibility of a meta-build’s
mill-build/build.sc, but it is there if necessary.
Mill supports multiple levels of meta-builds for bootstrapping:
-
Just
build.sc -
One level of meta-builds:
mill-build/build.scandbuild.sc -
Two level of meta-builds:
mill-build/mill-build/build.sc,mill-build/build.scandbuild.sc
Extending Mill: The Mill Meta Build works through a simple use case and example for meta-builds.