Skip to content

How Tlaloc runs a program

What happens between the Kotlin you write and the numbers that come back: when derivatives are taken, when a program is compiled, where tensors live, and what crosses between the host and a device.

Two ways to a derivative

grad { } capture(...) (:nn, :autograd)
Taken at compile time, by the K2 compiler plugin at run time, by tracing the model once
Becomes Kotlin bytecode in your class files a DxirFunction (the gradient program)
Runs on the JVM, over host tensors in the host interpreter, or on a device through PjrtSession
Shapes symbolic: one gradient serves every shape concrete: the traced shapes
Read it tlaloc { dumpGradSource.set(true) } step.gradSource()

grad { }

The plugin lowers the lambda to DXIR, Tlaloc's tensor IR, while Kotlin compiles your file. It coarsens loops and branches, takes the reverse-mode derivative, and writes the result back as ordinary Kotlin IR that replaces the call. At run time there is no plugin, no interpreter and no device: the gradient is a function in your jar that calls io.tlaloc.core.ops on host tensors. Values the lambda captures (immutable local Float, Double, Int or Long values) become parameters read at the call.

When a step fails the build fails at the call's file and line, unless strictLowering is off; then the call compiles, and throws when it runs.

capture

capture(model, inputs) { prediction -> loss } runs the model once on tracers, records the program, and derives its gradient. The result, a CapturedStep, holds both as DxirFunctions with the traced shapes:

val step = capture(model, listOf(x)) { prediction -> (prediction * prediction).mean() }

val out = step.run(model, listOf(x))   // loss and gradients, in the host interpreter
val text = step.gradient.toStablehlo() // the program a device compiles

Capture once and run every step; only the values change. A new input shape, or a target baked in as a constant, needs a new capture and, on a device, a new compile.

On a device: PjrtSession

A PjrtSession (:runtime-pjrt) loads a PJRT plugin through the JDK's foreign function interface (no JNI, no Python), creates a client on one device and compiles programs for it.

val inputs = listOf(x.hostF32()) + step.parameterTensors(model).map { it.hostF32() }

PjrtSession(target = PjrtTarget.Cuda).use { session ->
    val first = session.runOn(step.gradient, inputs)   // compiles, then runs
    val again = session.runOn(step.gradient, inputs)   // runs the same executable
    println(session.cacheSize)                         // 1
}

What a session compiles

Before compiling, a session lowers TPU kernel ops (on other targets an op that allows it is replaced by its reference program; one that does not is refused by name), emits StableHLO, and on TPU and Metal rewrites the explicit f32 dot algorithm into a portable precision. It runs no pattern recognition and no kernel selection; those are separate passes you call yourself (CUSTOM_KERNELS.md).

A program is compiled the first time it is run, or ahead of time with prepare(fn). Executables are kept for the session's life, keyed by the StableHLO text, so any change to the text (a shape, a dtype, a constant) is a new compile. A cacheKey argument skips re-emitting the text on later calls; the key must then determine the program.

With TLALOC_PJRT_CACHE_DIR set, compiled executables are also kept on disk and loaded by later processes (PERFORMANCE.md).

Calls are synchronous

Every call returns when the device has finished. An upload returns once the device has the data; an execution returns once its results are complete; a download returns with the values.

What crosses to the host

Call Moves
runOn, runOnHost, runOnF64, runOnBf16 every input up, every output down, on every call
bufferFromHostF32, bufferFromHostI32, … one array up; the PjrtBuffer stays on the device
executeOn(fn, buffers), executeStablehlo(text, buffers) nothing; the outputs are new device buffers
PjrtBuffer.toFloatArray(n), … one buffer down

A training loop over runOn moves every parameter up and every gradient down on every step. Keeping them on the device is the difference between 25.78 ms and 2.22 ms a step for a small transformer (the case study).

Buffers and donation

A PjrtBuffer belongs to whoever created it; the session does not track it. Close buffers before the session. A closed buffer is refused by name (PjrtBuffer used after close()), as is a buffer of a closed session.

A program can hand an input's memory to an output: toStablehlo(outputAliases = mapOf(param to result)) marks the parameter tf.aliasing_output, and PJRT then writes the result into the input's buffer. The input is consumed by the call; close it and do not pass it again. Only executeStablehlo runs such text; runOn, executeOn and prepare emit without aliases, so they never donate. CapturedStep.withDeviceAdamW(optimizer) builds a training step whose parameters and moments are donated this way, with the learning rate and bias corrections as inputs, so a schedule never recompiles.

Device memory

Note

With no options, the CUDA plugin reserves 75 % of GPU memory when a client is created. On a machine with unified memory, such as the GB10, that is host RAM, and every session's client reserves its own. Tlaloc therefore always passes options: no preallocation (the pool grows as needed and is freed when the session closes) and at most half the memory.

Variable Default
TLALOC_PJRT_MEMORY_FRACTION 0.5 the most the client may use, in (0, 1]
TLALOC_PJRT_PREALLOCATE false reserve the fraction at creation

session.memoryStats() returns the bytes in use and the peak. XLA's autotuning can need more memory at a program's first compile than the program needs to run.

Threads

Every session method may be called from several threads. Calls of the same executable run one at a time; different executables do not wait for each other. close() waits for calls in flight.

Other backends

Backend Compiles Notes
DxirInterpreter nothing f32, op by op; the reference the device tests compare with
DxirInterpreterF64 nothing computes in Double, rounds f32 and bf16 nodes after each op
runOnPjrt(fn, inputs) on every call a new client per call (about 0.5 s); for one-off runs
runOnIree(fn, inputs) on every call iree-compile and iree-run-module as subprocesses

Precision by backend

f32 dot f64
JVM (grad { }), interpreters f32 Double
PJRT CUDA (GB10) TF32 for a plain MATMUL Double; dots about 9× slower than f32
PJRT Metal, Cerebras not measured computed in f32
PJRT TPU one bf16 pass not run

NUMERICAL_PRECISION.md has the details and the tolerances the tests use.