bit by byte
Source code is for humans. So you should use every bit of syntactic abilities of a programming language combined with your writing skills and strive to actually make it so.
Machines don't really care, they want machine code. But there is an important intermediate representation before a source code gets there (if at all in some cases 🤔).
Java source code journey begins with javac. It's a front-endish compiler that turns java source code files into bytecode class files (it can also process annotations in already compiled class files... which would be a some other time topic for sure). Class files are hardly human readable, but you can disassemble them using javap tool:
➜ ✗ javac BitByByte.java
BitByByte.java
package bitbybyte;
public class BitByByte {
public int y(int x) {
return 3*x;
}
}
➜ ✗ javap -c BitByByte.class
Compiled from "BitByByte.java"
public class bitbybyte.BitByByte {
public bitbybyte.BitByByte();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public int y(int);
Code:
0: iconst_3
1: iload_1
2: imul
3: ireturn
}
1: invokespecial #1 // Method java/lang/Object."<init>":()V
So Java bytecode is actually an instruction set for JVM. And JVM has both an interpreter to run the bytecode in a generic way right away and a JIT compiler that can turn it into machine code. Not all bytecode gets to be JIT-compiled as JVM is clever about compilation performance implication, so only most used (hot spot) methods get to be compiled.
JavaScript started out as an interpreted language. SpiderMonkey, its first engine, was doing just that: reading and executing source code as is. But it couldn't just stay that way and TraceMonkey became the first JIT compiler for JS.
Guess what happens before it gets to machine code though? Bytecode!
bitbybyte.js
let y = x => 3*x;
y(10)
➜ ✗ node --print-bytecode --print-bytecode-filter=y bitbybyte.js
[generated bytecode for function: y (0x2fe97875bf71 <SharedFunctionInfo y>)]
Bytecode length: 6
Parameter count 2
Register count 0
Frame size 0
OSR nesting level: 0
Bytecode Age: 0
14 S> 0x2fe97875c31e @ 0 : 0b 03 Ldar a0
14 E> 0x2fe97875c320 @ 2 : 46 03 00 MulSmi [3], [0]
16 S> 0x2fe97875c323 @ 5 : a8 Return
Constant pool (size = 0)
Handler Table (size = 0)
Source Position Table (size = 8)
0x2fe97875c329 <ByteArray[8]>
So there is that y function again (same triple semantics). And we're viewing
V8 bytecode, at least that's what node runs on at the time of writing. What's
(arguably 🤓) interesting is that it's so lazy that even to get it generate the bytecode, y has to be called, that's
what y(10) is there for. In V8, Ignition
(interpreter) is responsible for bytecode generation. And you can see how Ldar loads
function argument into accumulator register, how MulSmi
decided to use small integer type,
and how it stores multiplication result back into accumulator register [0]
(if curious - V8's bytecodes).
If a function is used a lot (hot spot), Turbofan
takes its bytecode together with type feedback (oh, JS type system) and produces machine code.
Julia is a bit different. Well it's a lot different 😐.
Julia uses JIT compiler aka just-ahead-of-time JAOT compiler
and by default compiles everything to machine code before running it. And to get to machine code
it relies on LLVM Compiler Infrastructure.
I used to think that Julia produces LLVM bitcode and I think you can occasionally read or hear
the same (have friends talking about Julia and bitcode? 🤓). But it actually produces an
in memory LLVM IR: an assembly-like language that is used as an Intermediate Representation
between different compiler optimization passes.
Julia is super transparent about all the transformations (code lowering) that the source code goes through.
julia> y(x) = 3x
y (generic function with 1 method)
julia> @code_lowered y(10)
CodeInfo(
1 ─ %1 = 3 * x
└── return %1
)
There is nothing about type information yet. @code_typed macro allows to see a lower typed level:
julia> @code_typed y(10)
CodeInfo(
1 ─ %1 = Base.mul_int(3, x)::Int64
└── return %1
) => Int64
Next (lower still 😐) comes LLVM. Julia uses LLVM's C++ API to produce in memory LLVM IR. @code_llvm macro prints it:
julia> @code_llvm y(10)
; @ REPL[1]:1 within `y`
define i64 @julia_y_339(i64 signext %0) #0 {
top:
; ┌ @ int.jl:88 within `*`
%1 = mul i64 %0, 3
; └
ret i64 %1
}
At the bottom lies machine (native) code built by LLVM. @code_native shows native assembly instructions:
julia> @code_native y(10)
.text
; ┌ @ REPL[1]:1 within `y`
; │┌ @ int.jl:88 within `*`
leaq (%rdi,%rdi,2), %rax
; │└
retq
nopw %cs:(%rax,%rax)
; └
And if you know Julia rules (performance tips) - it gets really-really fast (even if there is a delay during the first run aka time to first plot).