How J2 Runs
Under the single j2 command sit two engines: an interpreter
built for immediacy and a native compiler built for throughput. Knowing how they divide
the work explains J2's performance, and how to get more of it.
The interpreter
The interpreter executes your source directly. It starts in a few milliseconds,
needs no build step or cache, and is the engine behind j2 run and the
default for the bare j2 FILE.j2 form. For scripts, glue, and the
edit-run-edit loop, it is the mode you want: startup dominates such workloads, and the
interpreter's startup is effectively free.
Interpreted execution is single threaded and unhurried by design; its job is to be correct, instant, and predictable.
The native compiler
The native engine translates J2 into lowered systems-language source, compiles that with a toolchain shipped inside the J2 installation, and links it against the J2 runtime. The result is an ordinary executable. Because the entire toolchain and every library it needs are bundled, native builds work offline on a machine with no development tools installed.
This is the engine behind j2 build, behind
J2_FORCE_NATIVE=1, and behind the silent fallback the bare form uses when a
program needs something the interpreter lacks. It is also where
automatic parallelization happens: the compiler
recognizes safe patterns during lowering and emits parallel code for them.
The first build on a machine warms a compilation cache (the installer usually does this for you); after that, builds are quick, and the REPL leans on the same cache to compile each line natively without a noticeable wait.
Two engines, one meaning
A language with two execution paths owes you a guarantee that they agree. J2's test suite runs each program through the interpreter and the native build and requires identical output. Where an optimization could change observable results, such as regrouping a floating-point reduction, the optimization is not taken. If the engines ever disagree, that is a J2 bug, full stop.
The performance model
Where the speed comes from, in plain terms:
- Untyped code compiled natively runs through dynamic dispatch. It is fine for logic and orchestration, but a hot numeric loop over dynamic values can be one to three orders of magnitude slower than the same loop typed.
- Typed code compiled natively is the fast path. Full annotations on a function let the compiler monomorphize it down to plain machine arithmetic; the design target for typed kernels is parity with handwritten systems code, within a few percent.
- Parallelization then multiplies the typed path across cores for the shapes described in Automatic Parallelism.
The practical recipe follows directly. Write the whole program untyped; when
something is slow, find the hot function, annotate its signature, and let
j2 build do the rest:
# Before: pleasant, dynamic, plenty fast for small n.
func mean_sq(xs) = {
s := 0.0
for x in xs { s += x * x }
give s / len(xs)
}
# After: same body, typed signature; native builds now
# compile this to a specialized, parallelizable kernel.
func mean_sq_fast(xs: seq<float>) -> float = {
s := 0.0
for i in 0..(len(xs) - 1) { s += xs[i] * xs[i] }
give s / len(xs)
}
data = [0.5, 1.5, 2.5]
print(mean_sq(data))
print(mean_sq_fast(data))
Three habits keep numeric code on the fast path: size sequences up front with
make_seq and write by index, keep data in flat sequences rather than deep
nesting, and measure with time.now and time.elapsed_ms before
and after.
The escape hatch
For the rare case where a program needs something the language does not expose, a
rust { } block embeds raw native code whose last expression, a
JValue, becomes the block's value. It runs through the native engine as
part of your program:
threads = rust {
let n = std::thread::available_parallelism()
.map(|n| n.get() as i64)
.unwrap_or(1);
JValue::Int(n)
}
print(threads)
Because an escape hatch is by definition arbitrary native code, J2 refuses it unless
the run is explicitly trusted with --allow-unsafe (or
J2_TRUSTED=1). Reach for it last: the standard library covers the common
needs (sys.cpu_count() replaces the example above), and code inside
rust { } gives up J2's safety story. It exists so that the ceiling of the
language is honesty rather than hope.
Choosing a mode
| Situation | Use |
|---|---|
| scripts, tools, iteration | j2 run (or the bare form) |
| compute-heavy program, run repeatedly | j2 build FILE.j2 -o OUT, run the binary |
| one-off heavy run | J2_FORCE_NATIVE=1 j2 FILE.j2 |
| measuring parallel gains | build twice, second time with J2_PARALLEL=0, compare |
| native build fails | J2_DEBUG=1 for the real error; j2 run to keep moving |