-1

I followed the guide here https://quarkus.io/guides/building-native-image to set up a minimal quarkus webservice graalvm native image. Ran command 'time quarkus build --native' to compile the example. The compile time on my laptop was 2 minutes, 12 seconds, the resulting image size was 38Mb. I then followed the guide here https://go.dev/doc/tutorial/web-service-gin to set up a minimal golang webservice native image. Ran 'time go build' to compile the executable. It took 1.006 seconds on my laptop and produced a 16Mb executable.

What is the underlying technical reason why graalvm native-image is so much slower to compile than golang build? From a high level it seems to me that they are both accomplishing more or less the same thing. Are there choices in the java language design itself that make it hard to compile AOT?

EDIT: just to address the feedback about compiling twice (once to bytecode, once to native) for java, here are some numbers for the java->bytecode step.

Made sure to set JAVA_HOME to point to the graalvm java11 22.1 CE directory, invoked javac from the graalvm directory.

The quarkus example takes 4.88 seconds to run time mvn package -DskipTests, while time javac Hello.java for the HelloWorld example (in my comment below) using the graalvm javac takes 0.782 seconds. When compared with the much longer times for the native-image operation this shows that bottleneck causing the 100x+ compile-time difference is the bytecode->native step.

4
  • Keep in mind that graalvm has to compile all of the quarkus framework (38mb of dependencies) while dependencies and framework for go are probably pre-built to native code
    – marstato
    Jun 26, 2022 at 20:44
  • For comparison without any web frameworks involved, a pure "hello world!" native-image java program too 38.6 seconds to compile on my laptop (using command time native-image HelloWorld) and produced an 11Mb executable , vs .235 seconds for the comparable hello.go program which produced a 2 Mb executable. There is still a massive compile time difference, native-image is taking 164 times longer. Just trying to understand what the underlying reasons for this are...
    – vancan1ty
    Jun 26, 2022 at 21:00
  • Have you read How does Go compile so quickly? on Stack Overflow? Jun 26, 2022 at 23:24
  • I hadn't read that originally but I have now -- tyvm.
    – vancan1ty
    Jun 27, 2022 at 18:28

1 Answer 1

6

There are many different dimensions to this answer, and I can't guarantee that I will hit them all, or even that I will hit the most significant ones. Here are just the ones that come to mind.

If you haven't read the following Stack Overflow question, then go ahead and so so now:

How does Go compile so quickly?

First off, as always, it is important to accurately define what, precisely, you are measuring. You are not measuring AOT compilation of Go vs. AOT compilation of Java. You are measuring AOT compilation of Go using a specific version of a specific implementation of Go vs. AOT compilation using a specific version of a specific implementation of Java.

For example, there is no reason to expect that AOT compilation of Go using the gc compiler and AOT compilation of Go using the gccgo compiler will take the same time. In fact, it is well known that gccgo is slower than gc. So, if compiling the same program written in the same programming language using two different compilers takes significantly longer on one compiler than the other, it should be immediately obvious that the compile time cannot possibly be solely a property of the programming language.

The architecture of gc is based on the architecture of the Plan 9 C Compilers, which are widely known to be extremely fast. The Plan 9 compilers and as a result also the gc compiler are specifically designed to be fast.

The truth of the matter is that most modern compilers are actually not designed to be fast. They make a lot of design decisions that actively work against them being fast. For example, most C compilers primarily targeting Unixoid systems (e.g. GCC) work based on a very traditional split of responsibilities between the compiler, the assembler, the archiver, and the linker: the compiler generates assembly code which it stores on disk as a text file. The assembler (which could be from a totally different project with totally different developers) reads the text file and produces an object file, which it again stores on disk. The archiver reads multiple object files and stores them in an archive file on disk. The linker reads multiple archive files and links them together into an executable which it stores on disk.

The gc compiler, OTOH skips many of those steps. The compiler produces a specially designed binary assembly format, which it hands to the assembler in memory, without ever storing it on disk. The binary assembly format is much more compact than textual assembly, it is much faster to generate and much faster to process, since it is designed for machines, whereas textual assembly languages are designed for humans. Also, it is assumed that only the assemblers and linkers which are part of gc are being used, so there is no need to store any intermediate results on disk in a standardized format.

But, there are Java compilers which are designed for speed as well. The Jikes Java compiler was explicitly designed for speed, it is much faster than javac from Oracle. Jikes also supports incremental compilation at the method-level, where it only needs to compile methods which have changed since the last time they were compiled.

So, that's one dimension: you just happened to compare one of the fastest Go compilers with one of the slowest Java compilers. Gccgo would probably be slower than gc. Jikes, unfortunately, is no longer maintained, so you would probably not be able to compile a modern Java program with it, but if you could, it would be significantly faster than Oracle's javac.

The second dimension is that you are comparing apples to oranges: gc compiles Go to native machine code. But in your Java example, you are compiling Java to JVM bytecode, and then in a second, separate, step, you are compiling JVM bytecode to native machine code. So, you are compiling twice.

Also, you are compiling the Java standard library (which is distributed in the form of JVM bytecode) every single time, whereas with gc, you only compile the Go standard library once, when you install Go (or even never, if you install a pre-built release). So, you are compiling a lot more code than you do with gc.

A third dimension is the size of the runtime system and the standard library. The Substrate VM's Garbage Collector, for example, is a lot more sophisticated than gc's. That's not to say that gc's GC (pardon the pun) is not good, in fact, it is extremely efficient. But Substrate VM's GC is much bigger. And again, all the runtime code in Substrate VM, such as the garbage collector, are written in Java and delivered as JVM bytecode, so they are compiled every time, whereas gc merely needs to link the already-compiled code into the final executable.

Another dimension is dependency analysis. Go is explicitly designed so that dependency analysis is easy. All dependencies are explicitly listed at the top of each file. As soon as you have read the first top-level declaration, you know, there are no more imports. And you never need to recursively read all imports, you only need the direct dependencies. Dependency analysis is linear in the number of dependencies, whereas e.g. for C++, it is exponential.

Dependency analysis for JVM bytecode is hard. In fact, given that JVM bytecode supports dynamic code loading, it is actually equivalent to solving the Halting Problem in the general case. This doesn't matter if you interpret the code or JIT compile it, because you only compile the code that is actually running, so you never compile code that is not needed. But for AOT, if you can't do precise dependency analysis, then you have to preemptively compile a lot more code than you actually need.

Since JVM bytecode was not intended to be implemented with an AOT compiler, dependency analysis was never a priority during language design. This has changed recently with the introduction of Modules in Java 9, where dependencies between Modules need to be explicitly declared. But, Modules are fairly large, and their dependencies are fairly coarse-grained.

As a result, you are compiling a lot more code in your Java example. That is also why the size of the resulting executable is so different.

3
  • Thank you for sharing your knowledge. Re "you are compiling a lot more code in your Java example", makes me wonder how hard it would be to precompile a set of dependencies for my java application and just have the native-image step newly compile my application code every time... "native jars"... It's probably overkill as java does have the bytecode compiler to fall back to for development/testing but I will look into it.
    – vancan1ty
    Jun 27, 2022 at 1:59
  • 1
    you can test Jorg last two conclusions by trying the very same you did with quarkus but with Spring Boot. Quarkus has been designed to be a lot more lighter (in terms of dependency graph) than Spring and their code has been properly configured (via metadata) to speed up the compilation to native. Something Spring didn't and it struggles with. Add to this equation transitive dependencies than will never provide the metadata required to ease the compilation or change its design to make it portable... Spring has a hell out of transitive dependencies that leads to more transitive dependencies.
    – Laiv
    Jun 27, 2022 at 12:00
  • 1
    A hint that makes this obvious is the minimum heap required by one framework and the other. While I have been able to compile Quarkus's demo with less than 6GB (in a reasonable time), I have not been able to do so with Spring Boot, which minimum heap (in my case) could not be less than 8GB. Well, it can be, but I have never seen the compilation finish.
    – Laiv
    Jun 27, 2022 at 12:06

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.