Translators Everywhere All at Once

2026/09/25
compilerjitpythondotnet

Is CIL an assembly language? Is .NET code still managed after AOT compilation? Translator, compiler, interpreter, assembler, transpiler, JIT, AOT and managed often get mixed up. What does each one actually mean?

The forms between source code and machine code

Between high-level language, assembly language and machine language there are more forms. Follow x = a + b down the chain:

  • source code (also high-level code): the text a person writes, x = a + b
  • compiler IR, such as LLVM IR, rustc's MIR or GCC's GIMPLE: the compiler's own internal form, %x = add nsw i32 %a, %b. Most optimization happens here
  • assembly (asm, file extension .s): one CPU instruction per line, add eax, ebx. It only works for one CPU family
  • object code (.o or .obj): what the assembler produces. It is already machine code, but addresses of functions in other files are still placeholders
  • machine code (also native code): the bytes after the linker fills in those addresses, 01 d8. The CPU runs these directly

Bytecode is not on that line. Once source is compiled to bytecode, it goes to a virtual machine and never passes through assembly or object code. CPython's BINARY_OP +, the JVM's iadd and CIL's add are all instructions for a VM, not for any CPU. They are stored as .pyc, .class and .dll files; .NET's bytecode is called CIL, also IL or MSIL. An interpreter executes them one at a time, and a JIT compiles the most frequently run parts into machine code. Only at that point does the code rejoin the line above, at its last stop.

Object code only exists in toolchains that have a linker. C, C++, Rust and Go produce one .o per file or package and hand them to the linker. Java's .class and C#'s .dll hold bytecode and have no linker step; the class loader or the CLR resolves names when it loads them. Python and JavaScript have no such layer at all. Machine code from a JIT goes straight into memory and is never written out as a .o.

What counts as IR

IR (intermediate representation) is any form that sits between what a person writes and what the CPU runs:

IR (the category)
├─ MIR            rustc's own layer, before LLVM IR. The borrow checker runs here
├─ LLVM IR        produced by clang and rustc. Typed, not tied to a CPU, usually never saved to disk
├─ GIMPLE         gcc's internal form
├─ Java bytecode  .class files from javac, read by the JVM
├─ CIL            produced by Roslyn, stored in .dll files, read by the CLR. Also called IL or MSIL
└─ assembly       text for the CPU, input to the assembler. Technically IR too, though rarely called that

Only assembly is tied to a CPU: x86 assembly is useless on ARM, while the others run anywhere. Each has a different reader: people and assemblers read assembly, VMs read bytecode and CIL, and LLVM IR exists for the compiler's optimization passes. LLVM IR still has types, and flags like nsw, which marks a signed addition that cannot overflow. JVM bytecode and CIL have types too (CPython's does not), but no flags like nsw for the optimizer. Assembly has no types at all, only registers and instructions. So LLVM IR is the highest level and assembly the lowest.

CIL is not tied to a CPU and is read by a VM, so it belongs with Java bytecode, not with assembly (someone asked this on Stack Overflow).

Which tools count as translators

A translator is any tool that converts code from one of those forms into another:

  • preprocessor: text to text, such as C's #include and #define
  • compiler: one language into another, usually higher level into lower
    • AOT compiler: gcc, rustc. Finishes before the program runs
    • JIT compiler: V8's TurboFan, HotSpot's C2, the CLR's RyuJIT. Compiles while the program runs, and its input is bytecode rather than source
    • transpiler: Babel, tsc. One high-level language into another, so the output is still text for people to read
  • assembler: assembly into machine code
  • interpreter: produces no new form and executes the code directly. Early BASIC and today's bash read source text line by line. CPython and the JVM read bytecode that was compiled beforehand
  • linker: joins several .o files into one program without converting anything, so it is not counted among the translators even though every native toolchain has one

C's toolchain is preprocessor, compiler, assembler, linker. Other languages use fewer:

LanguagepreprocessorcompilerassemblerlinkerAt run time
C, C++yesgcc, clang, MSVCgcc calls as; clang uses LLVM's built-in oneyesmachine code runs directly
Rustnone; macros expand inside rustcrustc, via LLVMbuilt into LLVMyesruns directly
assembly--yesyesruns directly
Java-javac → .class--HotSpot interprets, then JITs
C#-Roslyn → IL--CLR JIT, or Native AOT
Python-built in, source → .pyc--CPython interprets; JIT optional from 3.13
JavaScript-built into the engine, source → bytecode--V8 interprets, then JITs

Interpreting, JIT and AOT: when does compilation happen

AOT (ahead of time) means machine code is ready before the program starts. C, Rust and Go work this way. JIT (just in time) means machine code is produced while the program runs. Take this foo.py, which calls a small function ten thousand times:

def add(a, b):
    return a + b
 
total = 0
for i in range(10_000):
    total = add(total, i)
print(total)

Running python foo.py goes like this:

start
 → compile all of foo.py into bytecode (done once; the script run directly is not saved as .pyc, imported modules are)
 → interpreter reads and executes bytecode one instruction at a time   ← "interpreting"
 → (if JIT is on) once a piece of bytecode has run enough times, turn it into micro-ops
 → compile those micro-ops into machine code                            ← "JIT"
 → from then on, that piece runs the machine code; everything else is still interpreted
end

The first step compiles to bytecode. It happens before execution and covers the whole file at once. Interpreting translates nothing: CPython's interpreter is a large loop written in C and already compiled, and when it reads BINARY_OP + it jumps to the C code that adds two Python objects, then reads the next instruction. The JIT step does translate, into machine code, and only for code that has run many times. Once it sees a + b run thousands of times with integers on both sides every time, it generates a small block of machine code specialised for that type.

That first step also happens "at run time" in a literal sense. Nobody calls it JIT, though, because its output is bytecode the CPU cannot run. It is the same kind of step as javac turning .java into .class. In common usage, JIT always means compiling to machine code.

JIT is a feature of a runtime, not of a language. V8 and HotSpot turn it on by default. CPython has offered it since 3.13, still off by default in 3.15. iOS forbids generating executable code at run time, so .NET on iOS can only use AOT. A JIT also needs repetition before it pays off. The same Node program, measured with JIT on and with --jitless, runs equally fast when run once, and 1.23 times faster with JIT when run 300 times.

Some languages have one mainstream approach, others have both:

LanguageWhy
AOT onlyC, C++, Rust, Go, SwiftEvery type is known at compile time, so a JIT would learn nothing new at run time
JIT or interpreter onlyJavaScript in the browserThe code arrives over the network while the page runs, so there is no "before" to compile in
BothJava, C#, Python, DartHotSpot is JIT, GraalVM Native Image is AOT. The CLR is JIT, Native AOT is AOT. CPython interprets, Nuitka compiles AOT. Dart uses JIT during development and AOT for release

Who frees memory: managed and unmanaged

This has nothing to do with the forms above; it is about who frees memory:

  • the runtime, using a GC (garbage collector). The program allocates and never frees. Java, C#, Go and Python work this way
  • the programmer, with C's malloc and free. Forget to free and memory leaks; free twice or too early and the program crashes
  • the compiler, as in Rust's ownership model. When a variable goes out of scope, the compiler inserts the code that frees it. There is no GC, and the programmer never writes free

"Managed code" is a .NET term for code whose execution is managed by a runtime: the runtime compiles it to machine code and runs it, and also frees memory and enforces type safety, such as null and bounds checks. C, C++ and Rust are unmanaged. Python meets the definition too, but nobody calls it managed.

Managed code in .NET and Java happens to have a GC, run on a VM and be JIT compiled, all at once. So the three often get treated as one property, and to assume that compiling C# ahead of time to machine code makes it unmanaged. They are independent:

  • managed or not depends on whether a runtime manages execution; GC is one of its services
  • AOT or JIT is about when machine code is produced
  • whether there is a VM is another matter: Java has one, Go does not

Go has a GC, no VM, and is compiled AOT. Native AOT C# is the same: it becomes machine code and is still managed.

File extensions

ExtensionContentsProduced byAt run timeManaged
.c .cc .rs .go .java .cs .py .jssource codea personsee belowsee below
.s .asmassemblygcc -S, or written by handnot run; assembled first
.o .objobject codeassemblernot run; linked firstunmanaged
.so .dylib, C++ .dllmachine code, dynamically linkedlinkerloaded at run time, AOTunmanaged
ELF, Mach-O, C .exemachine codelinkerruns directly, AOTunmanaged
.pycPython bytecodeCPython, automaticallyinterpreted; JIT available since 3.13has a GC, not called managed
.class .jarJava bytecodejavac; a .jar is a zip of themJVM interprets, then JITsmanaged
.dexAndroid bytecoded8ART interprets, then JITsmanaged
.NET .dll .exeCIL plus metadataRoslynCLR JIT; a Native AOT .exe is pure machine codemanaged
.wasmWebAssembly bytecodeemcc, rustc, GoJIT in the browser or wasmtimedepends on the language; Rust output has no GC

A .dll or .exe built by a C++ compiler holds machine code; one built from C# by Roslyn holds CIL. The file command tells them apart. It ignores the extension and reads the first few bytes of the file, then looks them up in a table of known formats:

$ file translators-lite.en.md
translators-lite.en.md: Unicode text, UTF-8 text
$ file /bin/ls
/bin/ls: Mach-O universal binary with 2 architectures: [x86_64:Mach-O 64-bit executable x86_64] [arm64e:Mach-O 64-bit executable arm64e]
$ file UnityPlayer.dll
UnityPlayer.dll: PE32+ executable (DLL) (console) x86-64, for MS Windows

Both kinds of .dll use the PE format and start with the two bytes MZ. A .NET one also carries a CLR header, and file adds Mono/.Net assembly to its output. A C++ one has no such header, so its line ends at x86-64, like UnityPlayer.dll above.

Terms with two meanings

  • assembly: assembly language, or the .dll/.exe file in .NET. The .NET kind holds CIL and metadata and has nothing to do with assembly language. The shared name is a coincidence
  • binary: in this post, any file that is not plain text. Machine code is binary, and so are .pyc and .class files, which are not machine code. The Java specification calls a .class file the program's binary
  • IL, CIL, MSIL: three names for .NET's bytecode. IR is the category; LLVM IR, bytecode and CIL are members of it
  • AOT and JIT in frontend frameworks: in Vue and Angular these refer to when templates are compiled into render functions. At build time is AOT, in the browser is JIT. Either way the output is JavaScript, which V8 then interprets and JITs on its own terms. npm run build usually transpiles, bundles and minifies; Angular is the exception, where ng build has compiled templates AOT by default since Angular 9
  • native code: sometimes a synonym for machine code, sometimes a synonym for unmanaged code. The second meaning only comes up when contrasting managed and unmanaged, where C and C++ are the "native" side

The top answer to this Stack Overflow question shows a Visual Studio Disassembly window: a C# hello world after the JIT has compiled it. Each line has three columns:

00000003  E8 30 BE 03 6F     call  6F03BE38          ; Console.Out getter
0000000a  8B 15 88 20 BD 02  mov   edx, [02BD2088h]

The first column is the address. The second is machine code, the bytes the CPU executes; 8B is MOV. The third is the same instruction as assembly, one line per instruction. The CLR's JIT compiled this from CIL. That makes it machine code, which is native in the first sense. The GC still manages it, which makes it managed and not native in the second sense.

What equals what

  • IL = CIL = MSIL, .NET's intermediate form
  • LLVM IR, bytecode and assembly are all kinds of IR, but bytecode ≠ assembly. Assembly is tied to a CPU and read by an assembler; bytecode runs on any CPU and is read by a VM
  • CIL and Java bytecode belong to the same group; the CLR reads one, the JVM the other
  • .NET assembly ≠ assembly language
  • assembly ≠ what the assembler produces; the assembler turns assembly into object code
  • machine code is binary, and so are bytecode files. Calling something "compiled to a binary" does not mean it is machine code: the Java specification calls a .class file a binary, and it holds bytecode
  • machine code = native code in its first sense; the second sense means unmanaged
  • managed ≠ JIT. Go and Native AOT C# have a GC and no JIT
  • the JVM and the CLR do the same job. Each loads bytecode files, compiles them to machine code and runs them, and leaves memory to a GC. The difference is that the JVM interprets first and sends frequently called methods to a JIT, while the CLR has no interpreter: a method is compiled by the quick JIT on its first call and recompiled by the optimizing JIT once it runs often. The JVM reads Java bytecode from .class files, and the CLR reads CIL from .dll files

Further reading