Okay, so hey, so hi, I'm Tom, and I guess thank you for staying until the end.
And today I'm going to be talking a little bit about how the Native Image Builder works.
So I'm a principal researcher at Graal VM, and I joined the Native Image Team back in 2020.
So in this talk to the alphars, we have a brief overview of what Native Image is.
In case you have no clue, basically it's a tool for creating standalone executables from Java applications.
I also then kind of describe at a high level kind of what the process that Native Image is going through to build your standalone executable.
And then after that, I'll give some more details about the front end of the builder.
In particular, how we create grau graphs from Java byte code, what our grau graphs look like, how to look at them.
And also within these graphs, how we interact with both the substrate world and the host JVM.
Now, by the substrate world, what I mean is all the metadata methods and objects that we actually install into the final executable.
And by the host JVM, I mean the JVM that's running the Native Image Builder itself.
Because in case you don't know, Native Image is just another Java application.
So hopefully by the end of this talk, you have a better understanding of kind of what the general builder process is.
How do we view and, sorry, retrieve and view grau graphs?
How within these graphs, the substrate universe is represented and how the substrate universe relates to the Java universe of the host JVM.
So, as I said before, Native Image is a tool for creating stand-alone executables from Java applications.
And what we do is through analysis, we kind of determine all the possible Java code that you could execute during runtime and we had a time of A of T-A-O-T compile that.
In addition to this code, we also are able to evenly initialize some objects, we run some class initializers as part of the build process,
and we inject those objects into the executable as well.
Now, these objects that we're injecting into the executable would just be part of the normal Java heap.
But this portion that we install during the build process is what I call the image heap.
So, there's some benefits of using Native Image, including that it's just a standalone executable.
You don't need a JDK distribution or many dependencies at runtime.
Because it's A-O-T compiled code, you have fast startup.
Also, you have low memory footprint, because we're doing all the stuff.
As part of the build process, we're able to purge all the normal metadata that you like hotspot has to keep around during execution.
And also, because we A-O-T compiled code, we have very predictable performance.
You don't have to worry about warm-up, and also you don't have to worry about any performance cliffs on due to the optimization.
And yeah, Native Image works on a variety of popular frameworks, including Spring Boots, Micronot, and Quarkus.
So, the 1000-foot view of Native Image and what it's doing kind of looks something like this.
So, basically what we have to do is kind of suck up all the dependencies your application has,
including on third-party libraries, the JDK, and kind of the runtime itself.
Figure out what you're actually going to execute from it, and then actually install it into code in the text section.
As I said before, too, also, we're going to run some initializations as part of the build process,
and take the objects that are present in those static fields in these classes, and install them in the image heap.
Of course, before we do that, we're going to have to invert these hosts, JVM objects, and to the layout that represents the substrate world.
Now, of course, when you're installing some objects into the image heap, you'll have to install the transitive closure of those objects to the image heap as well.
So, we have to do some object scanning.
So, in general, kind of the way we determine what code you have to install in the text section is we have to use a points to analysis,
and we create a types reachable graph, whereas we kind of inject more types, as, sorry, as more types become part of the substrate world,
and implies that more code becomes reachable.
So, kind of what's going along on here with regards to the analysis, initialization, scanning.
It's not like a one-shot thing, but it's kind of a gradual process that gradually fills out the substrate world.
And the reason for that is, for example, when you scan an object, you actually might cause a new object to become read that has a different type that you haven't seen before,
which then might cause more code to become reachable, which then in that new code you're analyzing, you might actually see a new constant,
which then causes more scanning, so you can see it's kind of this iterative process that we have to go through.
So, yeah, so this is kind of my definition, but the way I'd kind of classify the native image builder would be into four different stages,
the analysis, finalized substrate world, compile, and prepare executable.
So, in the analysis phase stage rather, we need to do three main things.
We need to create graph graphs from Java bytecode.
We need to run all the class initialization, for objects we're going to install in the image heap,
and we need to discover all the information that potentially is going to be installed in the substrate world.
And once again, by substrate world, I mean all the metadata methods and objects that we put in the executable at the end.
So, now, in the second stage, our analysis is complete.
We know all that could be possibly in the substrate world, so now we could finalize it.
So, what that means is we need to kind of finalize the object layout and also calculate some and header information,
such as kind of the metadata needed for type checks and also such things as like, you know,
V-table layouts and what V-table indexes methods have.
Also, at this point, because the analysis is complete, we could optimize the graphs that we use based on the analysis.
Compile stage, pretty self-implanatory, we just take our graphs and compile them down to machine code.
And then, and then, in the final stage, is when we actually need to generate the executable itself.
So, we need to write all the image heap objects to the data section.
We need to lay out all the code we have compiled and put it in the text section.
Then, also, any dependencies between different methods or references to the heap.
We either need to patch ourselves to reflect their layout or install relocations.
And then, we have to invoke the linker itself to create the executable.
So, yeah, this is not sure how easy this is to see, but normally when you run native image, this is kind of on any application.
This is kind of the output you get from the command line.
And before it's described in four stages and kind of, how does it not to this?
Well, let's say these first two stages, initializing and performing analysis,
correspond to that analysis stage I was talking about before.
Building universe corresponds to the finalized substrate world.
These next three stages, phases, parsing and lining and compiling correspond to the compile phase.
And the final two phases lay out methods in creating an image correspond to the prepared executable phase stage.
So, now, and we'll talk a little bit about how we create graphs.
And actually, the code and within the graph repo where all this graph creation is done is in the bytecode parser.
So, what it's doing is it's reading the Java bytecode and creating graph grass from it.
And this is only done at the very beginning the first time we've touched a method.
And I should also mention in these slides, especially in these later slides, I've added a bunch of links to actually references to the source code.
So, you know, in 20 minutes I don't have that much, I can't go too much in detail, but I encourage you to look at these links.
I'm sorry, later on if you really curious how like the nitty-gritty of how things work.
So, yes, from the bytecode, we create this graph graphs only once in a very beginning.
And then as soon as we create the graph graphs, the graphs are our source of truth.
We never look at the bytecode again, we could just throw it away.
Everything we do operates via viewing and transforming or analyzing these graphs, including the analysis itself.
We create this type flow analysis directly by looking and processing the graph graph.
And the perk of this is that it allows us to do optimizations at any point of the builder process.
We could include, you know, as soon as you optimize a graph, all later stages will see those changes you make,
because we're always just looking at these graph graphs.
So, it's nice so we could do some optimizations before analysis, and then it can make our analysis work better.
So, what our graphs look like?
Well, if you're familiar with hot spot, graph graphs look a lot like it.
We use a C of node representation, where different nodes or denote different operations or actions being performed,
and then the arrows denote dependencies between these nodes.
So, in this graph here, the red lines are controlled dependencies, whereas the blue lines are value dependencies.
So, because the graph, so the source of truth throughout the builder process,
it's very valuable to actually look at these graphs and see how they're changing.
It could give you a lot of insights.
And the tool we have for doing that is something called IGV, or ideal graph visualizer.
Now, technically this tool isn't unique to grow.
Hot spot for viewing C2 graphs also has an IGV.
I should point out the two versions of IGV are different.
So, if you want to view our graph, please look here.
Please go to this link here and get our version of IGV.
Now, from native image, the builder process itself, the way to actually get it to dump out graphs,
are to add these three methods to your build command.
And I've glued the links here too, because you could make it only print out one method
or in various levels of detail.
So, yeah, it's a good to read the options at this links.
In general, too, if you want to see all the options available to you as flags,
you could run the command on native image expert options all.
So, using IGV was the tool look like, well, once again, I assume you can't see it too well,
it's not that important, but basically in the middle, you could see the graph itself.
IGV provides opportunity to zoom in if you want or select a subset of the nodes,
which is very important, because this is a very simple graph, but usually throughout the build
or process, these graphs get quite large.
On the right side, when you select a node, it shows various properties of it.
And then on the left side, then you could see the methods of the graphs you've actually dumped out.
Now, due to a little quirk, in the way throughout the build or process for any one graph,
native image oftentimes is serializes and decirilizes that graph.
So, even for one method, you actually see it listed multiple times on this left hand side,
which is just kind of a thing to keep in mind.
Now, how these different, but they're one method is listed multiple times.
It's usually very easy to figure out which of these folders have what phase of the build or process
of corresponds to, because you could just look at the optimization phases,
which I won't go into now either, but just a reference.
The one thing I will say is this analysis strengthens graph phase is quite important,
and that's the phase where we're actually injecting all of our analysis results into the graph.
So, that's a spot where a lot of changes are made to the graph.
So, now we have these graph graphs, they're all important.
We also need to represent Java information in these graph graphs,
and we need a way to clear the Java world.
Now, the normal way to kind of interact with the Java world and application would be to use reflection.
But we really don't want to do that for two reasons.
One is we need additional information about the VM internals,
and second, I guess, even more importantly, is we need a degree of separation.
Because if you're via reflection, what you're seeing is the Java world of the host JVN,
then these graph graphs themselves is actually, we're creating the Java world for the sub,
we're not creating the Java world, we're creating the substrate world.
So, the solution that hotspot provided for us is something called JVNCI,
or Java level JVN compiler interface, and it was added to hotspot as part of JF243.
And it provides an API for creating the JVN instance information we need.
So, if we kind of have some mapping between reflection data,
that it has different names.
So, instead of having the accesses to on class fields and methods,
now we have JVNCI semi-equivalence of resolved Java type,
resolved Java field, and resolved Java method.
So, it's important to note now in these graphs, whenever we're reporting,
in these graphs, we're referring to the Java world or creating the substrate world,
we always have to refer to it via JVNCI objects.
So, for instance, in a call, the target of the call will be a resolved Java method.
Likewise, we're doing a field access, when we refer to a field,
it's going to be a type resolved Java field.
So, via this JVNCI, what we're doing is we're actually exposing our substrate world.
And the way we do that is we actually create need of image has its own implementation
of these JVNCI objects.
In particular, when we first create the graph, what we're inserting into it
are analysis types, analysis fields, and analysis methods.
So, going back to this example here, this field here,
will have an object that will be of type analysis field.
Later on as part of the builder process, we actually swap out these analysis
and types fields and methods for hosted fields and methods.
And we do this once we've actually finalized the substrate world.
So, these new hosted variants, like this is also just another native
implementation of these JVNCI objects.
But, these new hosted variants of these JVNCI objects are created
as part of the finalized substrate world stage.
And they're inserted into graphs right before the compile stage.
Via this on class here, this analysis, the hosted graph transplanter.
Now, JVNCI is very nice and it's easy to use,
but because we are creating our own objects.
However, from our object often times, we actually, they just serve as a wrapper
and delicate to the host JVN, JVNCI objects.
So, we heavily rely on using the host JVN to answer a lot of queries for us,
including just, you know, general information, such as methods
signature or method resolutions, for instance, for like a virtual call
with a given receiver, what would its target be.
It's nice to use the host JVN for this, simply because we don't
then have to implement any of this logic ourselves.
However, because we do have a wrapper, we can modify this world to actually,
or sorry, we could always modify callbacks to always accurately reflect
the substrate world we're creating.
So, we're able to add and renew fields, and we also could change method
implementations whenever we want to.
So, one other thing I should mention is, you know,
I've talked about this hosted types, or as I analysis and hosted,
but also for the constants that we represent in these graphs,
we also have to represent them via the proper JVNCI way.
And the proper way in graph to represent on constants,
to be a JVNCI is through Java constants.
Now, for native image, what we're doing is we actually wrap these
no more Java constants with something called an image heap constant,
which is kind of the same thing we're doing with regards to this analysis
and hosted types.
Normally, then we have this delegate and point to a hosted JVN value,
but we don't strictly have to, and we've actually started to not
in some situations now.
So, kind of one thing I want to circle back on,
is I mentioned before that the strength and graphs,
is a piece of code I highly recommend looking at,
because this is the spot where we apply all of our analysis results.
A granted later on it could be further optimized based on it,
but this is the phase where we inject better information about the type system via
steps.
Because through our analysis, we've been able to sometimes figure out
more specific types that are going to be used,
or other nullness information.
We're able to fold constants, I'm sorry, logic checks,
since as a null, if we know an object could be never null.
And also, in many cases, if we know a given virtual or invoker,
interface invoke, actually only has one possible target,
at this stage we can convert it to a direct call.
So, yeah, with that, once again, these are the goals I listed that,
hopefully you have a better understanding of after this talk.
In regards to answering them, in terms of what I kind of view as the four
stages of the builder, they are analysis,
find their life substrates world, compile, and prepare executable.
It's very worthwhile to use IGV and look at growl graphs,
because that's kind of the main currency of what native image builder
is working with.
And then within these growl graphs, the way we're representing the
substrate world we're creating is representing it via
JVMCI and our analysis or hosted types and objects.
One good read that kind of says some more information about this
are analysis types or hosted types and how they relate to the host
JVM is the Java doc within hosted universe.
So, I'd encourage you to read that later if you're interested to learn more.
And finally, within our JVMCI implementation,
we oftentimes just use the host JVM objects to answer a lot
of our queries.
But we're able to, because we have our own objects,
modify the results to accurately model our substrate world.
And yeah, with that, I'm done.
Thank you for listening and, oh,
and yeah, I'll take any questions now if you have any.
So, yeah, any questions?
Yes.
So, the question is, how is the host world relate to the substrate world?
When you use it as a share library and create an isolate.
You've introduced many concepts there, right?
So, like the notion of ice, for people who don't know,
the notion of an isolate is that from within a native image,
you could actually have multiple instances running on the same process.
And that's called an isolate.
The share library doesn't really affect it,
whether it's an execute or a share of libraries the same way.
But the main difference between the host world and the substrate world
is, usually, we could prune out methods or fields.
We don't actually access this part of execution.
Also, too, for our substitutions, we have to make a lot of changes.
We try not to make too many, but that's the main kind of thing
that differs between the substrate world and the host JVM objects.
Any other questions?
Nope.
All right. Well, thank you.