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

SituationUse
scripts, tools, iterationj2 run (or the bare form)
compute-heavy program, run repeatedlyj2 build FILE.j2 -o OUT, run the binary
one-off heavy runJ2_FORCE_NATIVE=1 j2 FILE.j2
measuring parallel gainsbuild twice, second time with J2_PARALLEL=0, compare
native build failsJ2_DEBUG=1 for the real error; j2 run to keep moving