Case Study: Mill vs Maven

Compared to Maven:

  • Mill follows Maven’s innovation of good built-in defaults: Mill’s built-in JavaModules follow Maven’s "convention over configuration" style, so small Mill projects require minimal effort to get started, and larger Mill projects have a consistent structure building on these defaults.

  • Mill automatically caches and parallelizes your build, offering 4-10x speedups: Not just the built-in tasks that Mill ships with, but also any custom tasks or modules. This maximizes the snappiness of your command-line build workflows to keep you productive, which especially matters in larger codebases where builds tend to get slow: a Maven "clean install" workflow taking over a minute might take just a few seconds in Mill.

  • Mill makes customizing the build tool much easier than Maven. Projects usually grow beyond just compiling a single language: needing custom code generation, linting workflows, tool integrations, output artifacts, or support for additional languages. Mill’s extensibility and IDE experience makes doing this yourself easy and safe, with type-checked code and sandboxed tasks

This page compares using Mill to Maven, using the Netty Network Server codebase as the example. Netty is a large, old codebase. 500,000 lines of Java, written by over 100 contributors across 15 years, split over 47 subprojects, with over 10,000 lines of Maven pom.xml configuration alone. By porting it to Mill, this case study should give you an idea of how Mill compares to Maven in larger, real-world projects.

To do this, we have written a Mill build.mill file for the Netty project. This can be used with Mill to build and test the various submodules of the Netty project without needing to change any other files in the repository:

Completeness

The Mill build for Netty is not 100% complete, but it covers most of the major parts of Netty: compiling Java, compiling and linking C code via JNI, running JUnit tests and some integration tests using H2Spec. All 47 Maven subprojects are modelled using Mill, with the entire Netty codebase being approximately 500,000 lines of code.

$ git ls-files | grep \\.java | xargs wc -l
...
513805 total

The goal of this exercise is not to be 100% feature complete enough to replace the Maven build today. It is instead meant to provide a realistic comparison of how using Mill in a large, complex project compares to using Maven.

Both Mill and Maven builds end up compiling the same set of files, although the number being reported by the command line is slightly higher for Mill (2915 files) than Maven (2822) due to differences in the reporting (e.g. Maven does not report package-info.java files as part of the compiled file count).

Performance

The Mill build for Netty is much more performant than the default Maven build. This applies to most workflows.

For the benchmarks below, each provided number is the wall time of three consecutive runs on my M1 Macbook Pro. While ad-hoc, these benchmarks are enough to give you a flavor of how Mill’s performance compares to Maven:

Benchmark Maven Mill Speedup

Sequential Clean Compile All

98.80s

23.14s

4.3x

Parallel Clean Compile All

48.92s

8.79s

5.6x

Clean Compile Single-Module

4.89s

1.11s

4.4x

Incremental Compile Single-Module

6.82s

0.54s

12.6x

No-Op Compile Single-Module

5.25s

0.47s

11.2x

The column on the right shows the speedups of how much faster Mill is compared to the equivalent Maven workflow. In most cases, Mill is 4-10x faster than Maven. Below, we will go into more detail of each benchmark: how they were run, what they mean, and how we can explain the difference in performing the same task with the two different build tools.

Sequential Clean Compile All

$ ./mvnw clean; time ./mvnw -Pfast  -Dcheckstyle.skip -Denforcer.skip=true -DskipTests install
98.80s
96.14s
99.95s

$ ./mill clean; time ./mill -j1 __.compile
23.99s
23.14s
22.68s

This benchmark exercises the simple "build everything from scratch" workflow, with all remote artifacts already in the local cache. The actual files being compiled are the same in either case (as mentioned in the Completeness section). I have explicitly disabled the various linters and tests for the Maven build, to just focus on the compilation of Java source code making it an apples-to-apples comparison. As Mill runs tasks in parallel by default, I have disabled parallelism explicitly via -j1

As a point of reference, Java typically compiles at 10,000-50,000 lines per second on a single thread, and the Netty codebase is ~500,000 lines of code, so we would expect compile to take 10-50 seconds without parallelism. The 20-30s taken by Mill seems about what you would expect for a codebase of this size, and the ~150s taken by Maven is far beyond what you would expect from simple Java compilation.

Maven Compile vs Install

In general, the reason we have to use ./mvnw install rather than ./mvnw compile is that Maven’s main mechanism for managing inter-module dependencies is via the local artifact cache at ~/.m2/repository. Although many workflows work with compile, some don’t, and ./mvnw clean compile on the Netty repository fails with:

[ERROR] Failed to execute goal org.apache.maven.plugins:maven-dependency-plugin:2.10:unpack-dependencies
(unpack) on project netty-resolver-dns-native-macos: Artifact has not been packaged yet.
When used on reactor artifact, unpack should be executed after packaging: see MDEP-98. -> [Help 1]
[ERROR]
[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch.
[ERROR] Re-run Maven using the -X switch to enable full debug logging.
[ERROR]
[ERROR] For more information about the errors and possible solutions, please read the following articles:
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoExecutionException
[ERROR]
[ERROR] After correcting the problems, you can resume the build with the command
[ERROR]   mvn <args> -rf :netty-resolver-dns-native-macos

In contrast, Mill builds do not rely on the local artifact cache, even though Mill is able to publish to it. That means Mill builds are able to work directly with classfiles on disk, simply referencing them and using them as-is without spending time packing and unpacking them into .jar files. Furthermore, even if we did want Mill to generate the .jars, the overhead of doing so is just a few seconds, far less than the two entire minutes that Maven’s overhead adds to the clean build:

$ ./mill clean; time ./mill -j1 __.jar
32.58s
24.90s
23.35s

From this benchmark, we can see that although both Mill and Maven are doing the same work, Mill takes about as long as it should for this task of compiling 500,000 lines of Java source code, while Maven takes considerably longer. And much of this overhead comes from Maven doing unnecessary work packing/unpacking jar files and publishing to a local repository, whereas Mill directly uses the classfiles generated on disk to bypass all that work.

Parallel Clean Compile All

$ ./mvnw clean; time ./mvnw -T 10 -Pfast -DskipTests  -Dcheckstyle.skip -Denforcer.skip=true install
48.92s
48.41s
49.50s

$ ./mill clean; time ./mill __.compile
9.07s
8.79s
7.93s

This example compares Maven v.s. Mill, when performing the clean build on 10 threads. Both build tools support parallelism (-T 10 in Maven, by default in Mill), and both tools see a similar ~2x speedup for building the Netty project using 4 threads. Again, this tests a clean build using ./mvnw clean or ./mill clean.

This comparison shows that much of Mill’s speedup over Maven is unrelated to parallelism. Whether sequential or parallel, Mill has approximately the same ~7x speedup over Maven when performing a clean build of the Netty repository.

Clean Compile Single-Module

$ ./mvnw clean; time ./mvnw -pl common -Pfast -DskipTests  -Dcheckstyle.skip -Denforcer.skip=true -Dmaven.test.skip=true install
4.85s
4.96s
4.89s

$ ./mill clean common; time ./mill common.compile
1.10s
1.12s
1.11s

This exercise limits the comparison to compiling a single module, in this case common/, ignore test sources.

Again, we can see a significant speedup of Mill v.s. Maven remains even when compiling a single module: a clean compile of common/ is about 4x faster with Mill than with Maven! Again, common/ is about 30,000 lines of Java source code, so at 10,000-50,000 lines per second we would expect it to compile in about 1-4s. That puts Mill’s compile times right at what you would expect, whereas Maven’s has a significant overhead.

Incremental Compile Single-Module

$ echo "" >> common/src/main/java/io/netty/util/AbstractConstant.java
$ time ./mvnw -pl common -Pfast -DskipTests  -Dcheckstyle.skip -Denforcer.skip=true install
Compiling 174 source files to /Users/lihaoyi/Github/netty/common/target/classes
Compiling 60 source files to /Users/lihaoyi/Github/netty/common/target/test-classes

6.89s
6.34s
6.82s


$ echo "" >> common/src/main/java/io/netty/util/AbstractConstant.java
$ time ./mill common.test.compile
compiling 1 Java source to /Users/lihaoyi/Github/netty/out/common/compile.dest/classes ...

0.78s
0.54s
0.51s

This benchmark explores editing a single file and re-compiling common/.

Maven by default takes about as long to re-compile common/s main/ and test/ sources after a single-line edit as it does from scratch, about 20 seconds. However, Mill takes just about 0.5s to compile and be done! Looking at the logs, we can see it is because Mill only compiles the single file we changed, and not the others.

For this incremental compilation, Mill uses the Zinc Incremental Compiler. Zinc is able to analyze the dependencies between files to figure out what needs to re-compile: for an internal change that doesn’t affect downstream compilation (e.g. changing a string literal) Zinc only needs to compile the file that changed, taking barely half a second:

$ git diff
diff --git a/common/src/main/java/io/netty/util/AbstractConstant.java b/common/src/main/java/io/netty/util/AbstractConstant.java
index de16653cee..9818f6b3ce 100644
--- a/common/src/main/java/io/netty/util/AbstractConstant.java
+++ b/common/src/main/java/io/netty/util/AbstractConstant.java
@@ -83,7 +83,7 @@ public abstract class AbstractConstant<T extends AbstractConstant<T>> implements
             return 1;
         }

-        throw new Error("failed to compare two different constants");
+        throw new Error("failed to compare two different CONSTANTS!!");
     }

 }
$ time ./mill common.test.compile
[info] compiling 1 Java source to /Users/lihaoyi/Github/netty/out/common/compile.dest/classes ...
0m 00.55s6

In contrast, a change to a class or function public signature (e.g. adding a method) may require downstream code to re-compile, and we can see that below:

$ git diff
diff --git a/common/src/main/java/io/netty/util/AbstractConstant.java b/common/src/main/java/io/netty/util/AbstractConstant.java
index de16653cee..f5f5a93e0d 100644
--- a/common/src/main/java/io/netty/util/AbstractConstant.java
+++ b/common/src/main/java/io/netty/util/AbstractConstant.java
@@ -41,6 +41,10 @@ public abstract class AbstractConstant<T extends AbstractConstant<T>> implements
         return name;
     }

+    public final String name2() {
+        return name;
+    }
+
     @Override
     public final int id() {
         return id;
$ time ./mill common.test.compile
[25/48] common.compile
[info] compiling 1 Java source to /Users/lihaoyi/Github/netty/out/common/compile.dest/classes ...
[info] compiling 2 Java sources to /Users/lihaoyi/Github/netty/out/common/compile.dest/classes ...
[info] compiling 4 Java sources to /Users/lihaoyi/Github/netty/out/common/compile.dest/classes ...
[info] compiling 3 Java sources to /Users/lihaoyi/Github/netty/out/common/test/compile.super/mill/scalalib/JavaModule/compile.dest/classes ...
[info] compiling 1 Java source to /Users/lihaoyi/Github/netty/out/common/test/compile.super/mill/scalalib/JavaModule/compile.dest/classes ...
0m 00.81s2

Here, we can see that Zinc ended up re-compiling 7 files in common/src/main/ and 3 files in common/src/test/ as a result of adding a method to AbstractConstant.java.

In general, Zinc is conservative, and does not always end up selecting the minimal set of files that need re-compiling: e.g. in the above example, the new method name2 does not interfere with any existing method, and the ~9 downstream files did not actually need to be re-compiled! However, even conservatively re-compiling 9 files is much faster than Maven blindly re-compiling all 234 files, and as a result the iteration loop of editing-compiling-testing your Java projects in Mill can be much faster than doing the same thing in Maven

No-Op Compile Single-Module

$ time ./mvnw -pl common -Pfast -DskipTests  -Dcheckstyle.skip -Denforcer.skip=true install
5.08s
5.25s
5.26s

$ time ./mill common.test.compile
0.49s
0.47s
0.45s

This last benchmark explores the boundaries of Maven and Mill: what happens if we ask to compile a single module that has already been compiled? In this case, there is literally nothing to do. For Maven, "doing nothing" takes ~17 seconds, whereas for Mill we can see it complete and return in less than 0.5 seconds

Grepping the logs, we can confirm that both build tools skip re-compilation of the common/ source code. In Maven, skipping compilation only saves us ~2 seconds, bringing down the 19s we saw in Clean Compile Single-Module to 17s here. This matches what we expect about Java compilation speed, with the 2s savings on 40,000 lines of code telling us Java compiles at ~20,000 lines per second. However, we still see Maven taking 17 entire seconds before it can decide to do nothing!

In contrast, doing the same no-op compile using Mill, we see the timing from 2.2s in Clean Compile Single-Module to 0.5 seconds here. This is the same ~2s reduction we saw with Maven, but due to Mill’s minimal overhead, in the end the command finishes in less than half a second.

Extensibility & IDE Experience

Even though Maven is designed to be declarative, in many real-world codebases you end up needing to run ad-hoc scripts and logic. This section will explore two such scenarios, so you can see how Mill differs from Maven in the handling of these requirements.

JVM Libraries: Groovy

The Maven build for the common/ subproject uses a Groovy script for code generation. This is configured via:

<properties>
  <collection.template.dir>${project.basedir}/src/main/templates</collection.template.dir>
  <collection.template.test.dir>${project.basedir}/src/test/templates</collection.template.test.dir>
  <collection.src.dir>${project.build.directory}/generated-sources/collections/java</collection.src.dir>
  <collection.testsrc.dir>${project.build.directory}/generated-test-sources/collections/java</collection.testsrc.dir>
</properties>
<plugin>
  <groupId>org.codehaus.gmaven</groupId>
  <artifactId>groovy-maven-plugin</artifactId>
  <version>2.1.1</version>
  <dependencies>
    <dependency>
      <groupId>org.codehaus.groovy</groupId>
      <artifactId>groovy</artifactId>
      <version>3.0.9</version>
    </dependency>
    <dependency>
      <groupId>ant</groupId>
      <artifactId>ant-optional</artifactId>
      <version>1.5.3-1</version>
    </dependency>
  </dependencies>
  <executions>
    <execution>
      <id>generate-collections</id>
      <phase>generate-sources</phase>
      <goals>
        <goal>execute</goal>
      </goals>
      <configuration>
        <source>${project.basedir}/src/main/script/codegen.groovy</source>
      </configuration>
    </execution>
  </executions>
</plugin>

In contrast, the Mill build configures the code generation as follows:

import $ivy.`org.codehaus.groovy:groovy:3.0.9`
import $ivy.`org.codehaus.groovy:groovy-ant:3.0.9`
import $ivy.`ant:ant-optional:1.5.3-1`

object common extends NettyModule{
  ...
  def script = Task.Source(millSourcePath / "src" / "main" / "script")
  def generatedSources0 = Task {
    val shell = new groovy.lang.GroovyShell()
    val context = new java.util.HashMap[String, Object]

    context.put("collection.template.dir", "common/src/main/templates")
    context.put("collection.template.test.dir", "common/src/test/templates")
    context.put("collection.src.dir", (T.dest / "src").toString)
    context.put("collection.testsrc.dir", (T.dest / "testsrc").toString)

    shell.setProperty("properties", context)
    shell.setProperty("ant", new groovy.ant.AntBuilder())

    shell.evaluate((script().path / "codegen.groovy").toIO)

    (PathRef(T.dest / "src"), PathRef(T.dest / "testsrc"))
  }

  def generatedSources = Task { Seq(generatedSources0()._1)}
}

While the number of lines of code written is not that different, the Mill configuration is a lot more direct: rather than writing 35 lines of XML to configure an opaque third-party plugin, we instead write 25 lines of code to directly do what we want: import groovy, configure a GroovyShell, and use it to evaluate our codegen.groovy script. Although you may not be familiar with the Scala language that Mill builds are written in, you could probably skim the snippet above and guess what it is doing, and guess correctly.

This direct control means you are not beholden to third party plugins: rather than being limited to what an existing plugin allows you to do, Mill allows you to directly write the code necessary to do what you need to do. In this case, if we need to invoke Groovy and Groovy-Ant, Mill allows us to direct import $ivy the relevant JVM artifacts from Maven Central and begin using them in our build code in a safe, strongly-typed fashion, with full autocomplete and code assistance:

IntellijNettyAutocomplete

Mill gives you the full power of the JVM ecosystem to use in your build: any Java library on Maven central is just an import $ivy away, and can be used with the full IDE support and tooling experience you are used to in the JVM ecosystem.

Subprocesses: Make

The Maven build for the transport-native-unix-common/ subproject needs to call make in order to compile its C code to modules that can be loaded into Java applications via JNI. Maven does this via the maven-dependency-plugin and maven-antrun-plugin which are approximately configured as below:

<properties>
  <exe.make>make</exe.make>
  <exe.compiler>gcc</exe.compiler>
  <exe.archiver>ar</exe.archiver>
  <nativeLibName>libnetty-unix-common</nativeLibName>
  <nativeIncludeDir>${project.basedir}/src/main/c</nativeIncludeDir>
  <jniUtilIncludeDir>${project.build.directory}/netty-jni-util/</jniUtilIncludeDir>
  <nativeJarWorkdir>${project.build.directory}/native-jar-work</nativeJarWorkdir>
  <nativeObjsOnlyDir>${project.build.directory}/native-objs-only</nativeObjsOnlyDir>
  <nativeLibOnlyDir>${project.build.directory}/native-lib-only</nativeLibOnlyDir>
</properties>

<plugins>
  <plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-dependency-plugin</artifactId>
    <executions>
      <!-- unpack netty-jni-util files -->
      <execution>
        <id>unpack</id>
        <phase>generate-sources</phase>
        <goals>
          <goal>unpack-dependencies</goal>
        </goals>
        <configuration>
          <includeGroupIds>io.netty</includeGroupIds>
          <includeArtifactIds>netty-jni-util</includeArtifactIds>
          <classifier>sources</classifier>
          <outputDirectory>${jniUtilIncludeDir}</outputDirectory>
          <includes>**.h,**.c</includes>
          <overWriteReleases>false</overWriteReleases>
          <overWriteSnapshots>true</overWriteSnapshots>
        </configuration>
      </execution>
    </executions>
  </plugin>
  <plugin>
    <artifactId>maven-antrun-plugin</artifactId>
    <executions>
      <!-- invoke the make file to build a static library -->
      <execution>
        <id>build-native-lib</id>
        <phase>generate-sources</phase>
        <goals>
          <goal>run</goal>
        </goals>
        <configuration>
          <target>
            <exec executable="${exe.make}" failonerror="true" resolveexecutable="true">
              <env key="CC" value="${exe.compiler}" />
              <env key="AR" value="${exe.archiver}" />
              <env key="LIB_DIR" value="${nativeLibOnlyDir}" />
              <env key="OBJ_DIR" value="${nativeObjsOnlyDir}" />
              <env key="JNI_PLATFORM" value="${jni.platform}" />
              <env key="CFLAGS" value="-O3 -Werror -Wno-attributes -fPIC -fno-omit-frame-pointer -Wunused-variable -fvisibility=hidden" />
              <env key="LDFLAGS" value="-Wl,--no-as-needed -lrt -Wl,-platform_version,macos,10.9,10.9" />
              <env key="LIB_NAME" value="${nativeLibName}" />
              <!-- support for __attribute__((weak_import)) by the linker was added in 10.2 so ensure we
                   explicitly set the target platform. Otherwise we may get fatal link errors due to weakly linked
                   methods which are not expected to be present on MacOS (e.g. accept4). -->
              <env key="MACOSX_DEPLOYMENT_TARGET" value="10.9" />
            </exec>
          </target>
        </configuration>
      </execution>
    </executions>
  </plugin>
</plugins>

The maven-dependency-plugin is used to download and unpack a single jar file, while maven-antrun-plugin is used to call make. Both are configured via XML, with the make command essentially being a bash script wrapped in layers of XML.

In contrast, the Mill configuration for this logic is as follows:

def makefile = Task.Source(millSourcePath / "Makefile")
def cSources = Task.Source(millSourcePath / "src" / "main" / "c")
def cHeaders = Task {
  for(p <- os.walk(cSources().path) if p.ext == "h"){
    os.copy(p, T.dest / p.relativeTo(cSources().path), createFolders = true)
  }
  PathRef(T.dest)
}

def make = Task {
  os.copy(makefile().path, T.dest / "Makefile")
  os.copy(cSources().path, T.dest / "src" / "main" / "c", createFolders = true)

  val Seq(sourceJar) = resolveDeps(
    deps = Task.Anon(Agg(ivy"io.netty:netty-jni-util:0.0.9.Final").map(bindDependency())),
    sources = true
  )().toSeq

  os.proc("jar", "xf", sourceJar.path).call(cwd = T.dest  / "src" / "main" / "c")

  os.proc("make").call(
    cwd = T.dest,
    env = Map(
      "CC" -> "clang",
      "AR" -> "ar",
      "JNI_PLATFORM" -> "darwin",
      "LIB_DIR" -> "lib-out",
      "OBJ_DIR" -> "obj-out",
      "MACOSX_DEPLOYMENT_TARGET" -> "10.9",
      "CFLAGS" -> Seq(
        "-mmacosx-version-min=10.9", "-O3", "-Werror", "-Wno-attributes", "-fPIC",
        "-fno-omit-frame-pointer", "-Wunused-variable", "-fvisibility=hidden",
        "-I" + sys.props("java.home") + "/include/",
        "-I" + sys.props("java.home") + "/include/darwin",
        "-I" + sys.props("java.home") + "/include/linux",
      ).mkString(" "),
      "LD_FLAGS" -> "-Wl,--no-as-needed -lrt -Wl,-platform_version,macos,10.9,10.9",
      "LIB_NAME" -> "libnetty-unix-common"
    )
  )

  (PathRef(T.dest / "lib-out"), PathRef(T.dest / "obj-out"))
}
G makefile makefile make make makefile->make cSources cSources cSources->make cHeaders cHeaders cSources->cHeaders

In Mill, we define the makefile, cSources, cHeaders, and make tasks. The bulk of the logic is in def make, which prepares the makefile and C sources, resolves the netty-jni-util source jar and unpacks it with jar xf, and calls make with the given environment variables. Both cHeaders and the output of make are used in downstream modules. In this case, make is a command-line utility rather than a JVM library, so rather than importing it from Maven Central we use os.proc.call to invoke it.

Again, the Maven XML and Mill code contains exactly the same logic, and neither is much more concise or verbose than the other. Rather, what is interesting is that it is much easier to work with this kind of build logic via concise type-checked code, rather than configuring a bunch of third-party plugins to try and achieve what you want. With Mill, you get your full IDE experience working with your build: autocomplete, code assistance, navigation, and so on. Although working with the os.proc.call subprocess API is not as right as working with the JVM libraries we saw earlier, it is still a much richer experience than you typically get configuring XML files:

IntellijNettyPeekDocs

Debugging Tooling

Another area that Mill does better than Maven is providing builtin tools for you to understand what your build is doing. For example, the Netty project build discussed has 47 submodules and associated test suites, but how do these different modules depend on each other? With Mill, you can run ./mill visualize __.compile, and it will show you how the compile task of each module depends on the others (right-click open-image-in-new-tab to see at full size):

NettyCompileGraph

Apart from the static dependency graph, another thing of interest may be the performance profile and timeline: where the time is spent when you actually compile everything. With Mill, when you run a compilation using ./mill __.compile, you automatically get a out/mill-chrome-profile.json file that you can load into your chrome://tracing page and visualize where your build is spending time and where the performance bottlenecks are:

NettyCompileProfile

If you want to inspect the tree of third-party dependencies used by any module, the built in ivyDepsTree command lets you do that easily:

$ ./mill handler.ivyDepsTree
├─ org.jctools:jctools-core:4.0.5
├─ org.junit.jupiter:junit-jupiter-api:5.9.0
│  ├─ org.apiguardian:apiguardian-api:1.1.2
│  ├─ org.junit.platform:junit-platform-commons:1.9.0
│  │  └─ org.apiguardian:apiguardian-api:1.1.2
│  └─ org.opentest4j:opentest4j:1.2.0
└─ com.google.protobuf:protobuf-java:2.6.1

None of these tools are rocket science, but Mill provides all of them out of the box in a convenient package for you to use. Whether you want a visual graph layout, a parallel performance profile, or a third-party dependency tree of your project, Mill makes it easy and convenient without needing to fiddle with custom configuration or third party plugins. This helps make it easy for you to explore, understand, and take ownership of the build tool.

Conclusion

Both the Mill and Maven builds we discussed in this case study do the same thing: they compile Java code, zip them into Jar files, run tests. Sometimes they compile and link C code or run make or Groovy.

Mill doesn’t try to do more than Maven does, but it tries to do it better: faster compiles, shorter and easier to read configs, easier extensibility via libraries (e.g. org.codehaus.groovy:groovy) and subprocesses (e.g. make), better IDE support for working with your build.

Again, the Mill build used in this comparison is for demonstration purposes, and more work would be necessary to make the Mill build production ready: compatibility with different operating system architectures, Java versions, and so on. However, hopefully it demonstrates the potential value: greatly improved performance, easy extensibility, and a much better IDE experience for working with your build. Mill provides builtin tools to help you navigate, visualize, and understand your build, turning a normally opaque "build config" into something that’s transparent and easily understandable.