Compilers in Dart

·

9 min read

Hi! This is the series of learning Dart programming language. In the previous chapter, I explained the difference between var and dynamic and introduced you to the Dart. Today, we discuss Dart compilers and how your code executes and turns into working programs. This is an important question to take a look at.

What is the compiler in general?

A compiler is a special program that translates a programming language's source code into machine code, bytecode or another programming language. The source code is typically written in a high-level, human-readable language such as Java. C++ or in our case, Dart.

Dart’s compiler technology lets you run code in different ways:

  • Native platform: For apps targeting mobile and desktop devices, Dart includes both a Dart VM with just-in-time (JIT) compilation and an ahead-of-time (AOT) compiler for producing machine code.

  • Web platform: For apps targeting the web, Dart can compile for development or production purposes. Its web compiler translates Dart into JavaScript. Called dart2js. The Dart development compiler (dartdevc) compiles Dart as JavaScript AMD modules. These modules work for web app development in modern browsers.

    But there's a note that Dart 2.18 removes the dartdevs command-line tool from the Dart package, but retains the dartdevc compiler. To compile code to modular JS use webdev serve (runs a development server that continuously builds a web app.

During development, a fast developer cycle is critical for iteration. The Dart VM offers a just-in-time compiler (JIT) with incremental recompilation (enabling hot reload), live metrics collections powering DevTools (Dart DevTools is a suite of debugging and performance tools for Dart and Flutter. These tools are distributed as part of the dart tool and interact with tools such as IDEs, dart run, and webdev.), and rich debugging support.

The development compiler (dartdevc) supports only Chrome. To view your app in another browser, use the production compiler (dart2js). The dart-to-JavaScript compiler, dartdevc , is for development use only. The webdev build command uses dart2js by default. The webdev serve command uses dartdevc by default, but you can switch to dart2js using the --release flag.

$ dart2js -O2 -o test.js test.dart This command produces a file that contains the JavaScript equivalent of your Dart code. It also produces a source map, which can help you debug the JavaScript version of the app more easily.

The -On argument specifies the optimization level. Starting at -O1 (the default) and then increasing to -O2 or higher when you’re ready to deploy. The -O3 and -O4 optimization levels are suitable only for well-tested code.

When apps are ready to be deployed to production — whether you’re publishing to an app store or deploying to a production backend — the Dart ahead-of-time (AOT) compiler can compile to native ARM or x64 machine code. Your AOT-compiled app launches with a consistent, short startup time.

The AOT-compiled code runs inside an efficient Dart runtime that enforces the sound Dart type system and manages memory using fast object allocation and a generational garbage collector.

Dart’s runtime is ever-present, both in debug and release modes, but there are big differences between the two build flavors.

In debug mode, most of Dart’s plumbing is shipped to the device: the Dart runtime, the just-in-time compiler/interpreter (JIT for Android and interpreter for iOS), debugging and profiling services. In release mode, the JIT/interpreter and debugging services are stripped out, but the runtime remains, and this is a major contributor to the base size of a Flutter app.

Dart’s runtime includes a garbage collector, a necessary component for allocating and deallocating memory as objects are instantiated and become unreachable.

Dart uses a garbage collector to automatically manage memory. This means that when an object is no longer being used, the garbage collector will automatically free up the memory used by that object.

The Dart runtime

Regardless of which platform you use or how you compile your code, executing the code requires a Dart runtime. This runtime is responsible for the following critical tasks:

  • Managing memory: Dart uses a managed memory model, where unused memory is reclaimed by a garbage collector (GC).

  • Enforcing the Dart type system: Although most type checks in Dart are static (compile-time), some type checks are dynamic (runtime). For example, the Dart runtime enforces dynamic checks by type check and cast operators. (we'll talk about operators in Dart in the next chapters).

  • Managing isolates: The Dart runtime controls the main isolate (where code normally runs) and any other isolates that the app creates.

On native platforms, the Dart runtime is automatically included inside self-contained executables and is part of the Dart VM provided by the dart run command.

How your code is translated to the machine code?

Dart VM is a virtual machine in the sense that it provides an execution environment for a high-level programming language, however, it does not imply that Dart is always interpreted or JIT-compiled when executing on Dart VM.

DartVM consists of:

  1. The Runtime System

  2. Developer Experience Components (Debugging, Hot reload)

  3. JIT & AOT Compilation Pipelines

DartVM's structure is presented with Heap - GC Controlled Storage which uses a garbage collector. Helper threads - handle VM's Internal tasks and mutator threads: dev code and native code - the code runs here. All of these components are placed inside an isolated group.

DartVM can execute Dart apps in 2 ways:

  1. From SOURCE by using JIT/AOT Compiler Running with JIT used inside the Development phase.

  2. From SNAPSHOTS(JIT, AOT or Kernel).

What is the process of JIT Compilation?

Just In Time Compilation, JIT, or Dynamic Translation, is a compilation that is being done during the execution of a program. Meaning, at run time, as opposed to prior to execution. What happens is the translation to machine code. The advantages of a JIT are due to the fact, that since the compilation takes place in run time, a JIT compiler has access to dynamic runtime information enabling it to make better optimizations (such as inlining functions).

What is important to understand about the JIT compilation, is that it will compile the bytecode into machine code instructions of the running machine.

A Just-In-Time (JIT) compiler is a feature of the run-time interpreter, that instead of interpreting bytecode every time a method is invoked, will compile the bytecode into the machine code instructions of the running machine, and then invoke this object code instead. Ideally, the efficiency of running object code will overcome the inefficiency of recompiling the program every time it runs.

Since Dart 2 VM no longer can directly execute Dart from raw source, instead VM expects to be given Kernel binaries (also called dill files) which contain serialized Kernel ASTs. The task of translating Dart source into Kernel AST is handled by the common front-end (CFE) written in Dart and shared between different Dart tools (e.g. VM, dart2js, Dart Dev Compiler). Dart kernel is a high-level intermediary language derived from Dart.

Translating dart source code into Kernel AST handled by the dart package Common front end (e.g. CFE). Once, the kernel binary is loaded into the VM, it is parsed to create objects representing various program entities (classes, procedures). This is done lazily, which means it parses basic information about these entities. Each of them keeps a pointer, back to the kernel binary.

The information about the class is fully deserialized only when the runtime needs it!

All those entities lie in VM's Heap which is allocated memory.

From this time, our function is compiled sub-optimally, which means the compiler goes and retrieves the function body from the kernel binary converts it into an intermediate language and then later on it lowered directly without any optimization passes right into pure machine code. The intermediate code is converted into machine language only when the application needs that is required codes are only converted to machine code.

But the next time this function is called it uses optimized code, the optimized compile based on the gathered information from the sub-optimal run proceeds to translate the unoptimized intermediate language through a sequence of classical dart-specific optimizations(e.g. optimized inlining, etc.) Code lowers to machine code and run by VM.

This is what happens by typing dart run command.

Use the dart compile command to compile a Dart program to a target platform. The output — which you specify using a subcommand — can either include a Dart runtime or be a module (also known as a snapshot).

Compile your app and set the output file. $ dart compile exe bin/myapp.dart -o /tmp/myapp

The exe subcommand produces a standalone executable for Windows, macOS, or Linux. A standalone executable is native machine code that’s compiled from the specified Dart file and its dependencies, plus a small Dart runtime that handles type checking and garbage collection.

First, you need to know, that AOT was originally introduced for platforms which makes JIT compilation impossible.

Compared to the JIT, the AOT compiler must have access to executable code for each function that could be invoked during application execution. To satisfy this requirement the process of AOT compilation does global static analysis also called type flow analysis (e.g. TFA) to determine which parts of the application are reachable from the set of entry points based on this analysis it will remove unreachable methods that code and virtualized method calls. Virtualization is a compiler optimization that replaces indirect or virtual function calls with direct calls.

The compilation is done on the entire code before runtime (one-time optimization, it doesn't support that speculative optimizations as JIT).

What is Dart Snapshot?

The snapshot contains a representation of all those entities allocated in the DartVM heap. Entities that are needed to start the execution process. This heap is traversed and all the objects are serialized into a file called snapshot. Snapshot is optimized for fast startup time. DartVM can run the code by quickly deserializing the snapshot and accessing all the necessary data.

There are three snapshot types

  1. JIT- snapshot

  2. AOT - snapshot

  3. Kernel - snapshot

Kernel snapshots won't contain anything besides the code that's been parsed into the intermediary format of kernel language. No parsed classes, functions, no architecture of compiled code, portable among all architectures, DartVM will need to compile it from scratch.

When running the application from a JIT snapshot DartVM doesn't need to parse or compile the classes or functions or any other entity that was already generated during the training run( source code - compiled to the Kernel AST - DartVM - executing runtime). To use JIT-snapshot follow this command:

$ dart compile jit-snapshot .\bin\...

Execution time got reduced by many times!

Running for AOT-snapshot doesn't have a training run. It compiles the entire program in the standard AOT compilation approach.

The difference between AOT and AOT - snapshot is that Dart generates only an AOT snapshot file with no Dart runtime. Use dartaot command to run with AOT snapshot.

Conclusion

JIT Compilation + JIT snapshot have peak performance, debugging support, and hot reload, designed for the development phase.

AOT Compilation and AOT snapshot have consistent performance, no debugging support, instant startup time, and high compile times, designed for the production phase.

This is all for now, hope you learned something new about Dart! Stay tuned for the next chapters.

Some used urls: https://mrale.ph/dartvm/ & official dart.dev documentation