readable-gradients — read the derivative the compiler wrote¶
What it shows. Every autodiff framework hands you a number. Tlaloc hands you the program: the Kotlin source of the gradient it derived, which you can read, review in a pull request, compile on its own, and run.
This project does exactly that, three times over, and checks that all three answers are the same one:
| where it comes from | what it proves | |
|---|---|---|
1. compiled grad { } |
the K2 plugin rewrote the lambda into synthesized bytecode at compile time | the fast path — no tape, no graph object, no framework at runtime |
| 2. the printed source | the same derivation, dumped as Kotlin by the dumpGradSourceDir plugin flag, compiled by a source set with no Tlaloc plugin on its classpath, called here by reflection |
what you read is what ran, and it is ordinary Kotlin |
| 3. a central difference | a hand-written kotlin.math transcription of the same formula that never touches Tlaloc |
the derivation is correct, checked by something that shares no code with it |
1 and 2 are the same program, so they agree raw-bit-identically — the
example asserts Float.toRawBits() equality, with no epsilon. 3 is numerical,
so it agrees to finite-difference tolerance (observed: 5.5e-08).
The function¶
Deliberately not a parabola. A "damped swish":
swish(x) = x · σ(x) a smooth, non-monotonic gate
softplus(x) = log(1 + eˣ) a smooth, positive magnitude
f(x) = swish(x) / √(1 + softplus(x))
In Kotlin, inside the block the compiler differentiates:
By hand, f'(x) is a quotient rule whose numerator needs the product rule
through a sigmoid, over a denominator that chains sqrt ∘ log ∘ exp:
Four rules, two shared subexpressions, and an easy sign to get wrong. Nobody writes that by hand. Below is the machine's version of it — and unlike a number, you can check it.
Running it¶
A standalone Gradle project that resolves Tlaloc from mavenLocal, exactly as your own project would, so publish first:
# from the Tlaloc repo root
./gradlew publishToMavenLocal
./gradlew -p examples/readable-gradients run
No GPU, no native library, no Python — this one runs anywhere a JVM does.
Expected output¶
Verbatim, from this machine (GB10 / aarch64, JDK 25, Kotlin 2.4.20):
Tlaloc readable gradients
f(x) = x*sigmoid(x) / sqrt(1 + log(1 + exp(x)))
--- the gradient, as the compiler printed it during the build ---
--- Main_kt_72_13_grad.kt (generated; not in src/) ---
// Generated by DxirFunction.toKotlinSource() from function 'grad_body_grad'.
// Each `val` is one DXIR op, bound to its certified `:core` host-twin call;
// the trailing comment names the SSA node it renders.
import io.tlaloc.core.*
import io.tlaloc.core.ops.*
fun grad_body_grad(x: DTensor<ScalarShape, F32>): DTensor<ScalarShape, F32> {
val v1: DTensor<ScalarShape, F32> = x.sigmoid() // %1 = SIGMOID(%0)
val v2: DTensor<ScalarShape, F32> = (x * v1) // %2 = MUL(%0, %1)
val v3: DTensor<ScalarShape, F32> = Tensors.f32Scalar(1.0f) // %3 = const : f32
val v5: DTensor<ScalarShape, F32> = x.exp() // %5 = EXP(%0)
val v6: DTensor<ScalarShape, F32> = (v3 + v5) // %6 = ADD(%3, %5)
val v7: DTensor<ScalarShape, F32> = v6.log() // %7 = LOG(%6)
val v8: DTensor<ScalarShape, F32> = (v3 + v7) // %8 = ADD(%3, %7)
val v9: DTensor<ScalarShape, F32> = v8.sqrt() // %9 = SQRT(%8)
val v12: DTensor<ScalarShape, F32> = (v3 / v9) // %12 = DIV(%3, %9)
val v13: DTensor<ScalarShape, F32> = (v9 * v9) // %13 = MUL(%9, %9)
val v14: DTensor<ScalarShape, F32> = (v2 / v13) // %14 = DIV(%2, %13)
val v16: DTensor<ScalarShape, F32> = v14.neg() // %16 = NEG(%14)
val v17: DTensor<ScalarShape, F32> = Tensors.f32Scalar(2.0f) // %17 = const : f32
val v19: DTensor<ScalarShape, F32> = (v17 * v9) // %19 = MUL(%17, %9)
val v20: DTensor<ScalarShape, F32> = (v16 / v19) // %20 = DIV(%16, %19)
val v21: DTensor<ScalarShape, F32> = (v20 / v6) // %21 = DIV(%20, %6)
val v23: DTensor<ScalarShape, F32> = (v21 * v5) // %23 = MUL(%21, %5)
val v24: DTensor<ScalarShape, F32> = (v12 * v1) // %24 = MUL(%12, %1)
val v25: DTensor<ScalarShape, F32> = (v12 * x) // %25 = MUL(%12, %0)
val v26: DTensor<ScalarShape, F32> = (v23 + v24) // %26 = ADD(%23, %24)
val v29: DTensor<ScalarShape, F32> = (v3 - v1) // %29 = SUB(%3, %1)
val v30: DTensor<ScalarShape, F32> = (v1 * v29) // %30 = MUL(%1, %29)
val v31: DTensor<ScalarShape, F32> = (v25 * v30) // %31 = MUL(%25, %30)
val v32: DTensor<ScalarShape, F32> = (v26 + v31) // %32 = ADD(%26, %31)
return v32
}
--- three answers that must agree ---
x compiled grad{} printed source central difference
-3.00 -0.08289663 -0.08289663 -0.08289664
-1.00 0.08714630 0.08714630 0.08714630
-0.25 0.31185716 0.31185716 0.31185718
0.00 0.38425776 0.38425776 0.38425776
0.50 0.49173251 0.49173251 0.49173249
1.75 0.50086808 0.50086808 0.50086807
4.00 0.29834056 0.29834056 0.29834050
compiled == printed: raw-bit identical at every point
compiled vs finite difference: max |gap| = 5.525e-08
readable-gradients OK
That block between the dashes is not a log line the example formatted. It is a
file — build/gradients/Main_kt_72_13_grad.kt, named after the source location
of the grad { } call it came from — that the Kotlin compiler wrote while
compiling Main.kt, and that a second compilation turned into the .class
file the third column was computed by.
Reading the gradient¶
It is worth spending sixty seconds on it, because this is the part no other framework lets you do.
The forward, v1–v9 — only the pieces the adjoints actually need:
v1 = σ(x), v2 = x·σ(x) |
the numerator, swish |
v5 = eˣ, v6 = 1+eˣ, v7 = log(1+eˣ) |
softplus, built up |
v8 = 1+s, v9 = √(1+s) |
the denominator, call it d |
Note what is missing: v2 / v9, the value of f itself, is never formed.
grad { } asks for the derivative only, so the final division is dead code and
the transform drops it. (valueAndGrad { } keeps it.)
The reverse, v12–v32, seeded with an upstream of 1:
v12 = 1/d |
quotient rule, ∂/∂numerator |
v13 = d², v14 = num/d², v16 = −num/d² |
quotient rule, ∂/∂denominator |
v17 = 2, v19 = 2d, v20 = v16/(2d) |
the sqrt rule, d√u = 1/(2√u) |
v21 = v20/(1+eˣ) |
the log rule, d log u = 1/u |
v23 = v21·eˣ |
the exp rule — the denominator's contribution to dx |
v24 = (1/d)·σ(x) |
product rule, first term |
v25 = (1/d)·x, v29 = 1−σ, v30 = σ(1−σ), v31 = v25·v30 |
product rule, second term, through the sigmoid rule σ′ = σ(1−σ) |
v26, v32 |
the two accumulations that add the paths back together |
Two things a careful reader will notice, and both are true:
v23/v21computeeˣ/(1+eˣ), which isσ(x)— already sitting inv1. The transform applies thelogandexprules literally rather than recognising the identity. Tlaloc does not pretend to be a computer algebra system that will out-clever you; it shows you what it did, and you can see the missed simplification for yourself. That is the trade this design makes.v3(the constant1.0) is used four times. The primal lowered two separateconst 1.0nodes (1.0f +appears twice in the source); the simplify pass merged them before the dump. The gradient you read is the simplified one — the same one that became bytecode.
The gaps in the numbering are not accidental either: %4, %10, %11, … are
nodes that existed and were then eliminated — merged as duplicates, or dropped
as dead. The renderer prints the surviving SSA names rather than renumbering,
so the file stays diffable against the IR it came from.
How the build does it¶
The whole mechanism is nine lines of build.gradle.kts, and it is worth
reading because the second half is the interesting one:
// 1. Ask the plugin to dump every gradient it synthesises, as Kotlin.
tasks.named<KotlinCompile>("compileKotlin") {
compilerOptions.freeCompilerArgs.addAll(
"-P", "plugin:io.tlaloc.plugin:dumpGradSourceDir=${gradientSourceDir.asFile.absolutePath}",
)
outputs.dir(gradientSourceDir)
}
// 2. Compile the dump in a source set that does NOT get the plugin.
sourceSets { create("printed") }
kotlin.sourceSets.named("printed") { kotlin.srcDir(gradientSourceDir) }
dependencies { "printedImplementation"("io.github.pedronahum:tlaloc-core:0.1.0-alpha03") }
Step 2 is the claim under test. printedImplementation gets :core — the host
tensor library — and nothing else. The compiler plugin is declared on
kotlinCompilerPluginClasspathMain, which the printed compilation does not
inherit, so the Tlaloc plugin is not present for that compilation. If the printed gradient compiles there, it is plain Kotlin;
if it then produces the same bits, the source you were shown is the code that
ran.
While you are at it, watch the build log. The plugin also reports the primal
it lowered, as a compiler warning at the grad { } call site:
w: file:///…/Main.kt:72:13 Tlaloc lowered lambda to dxir:
fn grad_body(%0: f32) -> f32 {
%1 = sigmoid(%0) : f32
%2 = mul(%0, %1) : f32
…
Forward IR in the log, reverse source on disk. Nothing about the derivation is hidden from you.
What the compiler refuses¶
The other half of "the compiler knows what you meant" is what it will not
compile. src/main/kotlin/Refusals.kt.disabled holds two gradients that cannot
exist. The file is named .disabled so Gradle never compiles it and
this project stays green; rename it to see the errors:
cd examples/readable-gradients
mv src/main/kotlin/Refusals.kt.disabled src/main/kotlin/Refusals.kt
./gradlew compileKotlin # fails
mv src/main/kotlin/Refusals.kt src/main/kotlin/Refusals.kt.disabled
(a) An axis error. A contraction between an operand indexed
[Batch, SeqLen] and one indexed [Hidden, Hidden] — there is no axis to sum
over. Verbatim, from the run above:
e: Refusals.kt:40:24 Tlaloc named-index mismatch: contract operands share no named axis: lhs=[Batch, SeqLen] rhs=[Hidden, Hidden]
Two independent layers catch this. The axis names are phantom type parameters,
so Kotlin's own type checker rejects the call as well (it follows with
Cannot infer type for type parameter 'NameN' and an argument-type mismatch
naming the full Named<…> types). Tlaloc's diagnostic is the one that says
which axes, by name, in one line.
(b) A differentiability error. grad { } over a body that returns a
tensor. There is no single gradient of a tensor-valued function:
e: Refusals.kt:63:24 Tlaloc cannot differentiate this body: DxirReverseTransform v1 requires a scalar return (got f32[-1,-1]) — tensor-returning primals need seedAsParam (the vjp pullback form)
Kotlin types this one happily — grad(f: (A) -> R) leaves R unconstrained —
so the refusal comes from Tlaloc running the real reverse transform at check
time, in the compiler, on the body you wrote. The message names the
transform, the offending type, and the surface you actually wanted (vjp, the
seeded pullback). The -1s are Tlaloc's symbolic extents: the dims are not
known until you call the function, which is precisely why the refusal is about
the return kind and not about sizes.
Neither of these is a runtime exception, a NaN, or a silently wrong number an hour into a training run. Both are red squiggles in the IDE.
Limitations¶
- The dump covers scalar
grad { }bodies. A tensorgrad { }lambda still compiles and still differentiates correctly, but its compile-time dump refuses loudly (aSKIPPEDmessage naming the reason) rather than printing source whose shape literals would be derived from symbolic-1extents. - Every op either renders or refuses by name, instead of emitting
plausible-looking wrong source. Not rendered:
ABS/RSQRT/GELU/SILU, rank ≥ 3 matmul, gather/scatter, control flow, and F64. - The
jvp/vjp/jacobian/hessianintrinsics do not print source.
The full statement of what is certified, and the named tests that pin it
(including a golden test that compiles printed gradients standalone and checks
them raw-bit-identical against the interpreter), is in
docs/READABLE_REVERSE.md. This example is
that document, made into something you can run.
What to read in the source¶
| Look at | For |
|---|---|
src/main/kotlin/Main.kt |
the grad { } call, the independent reference() used by the finite difference, and the reflection that loads the printed gradient |
src/main/kotlin/Refusals.kt.disabled |
the two gradients the compiler refuses, and why |
build.gradle.kts |
the dumpGradSourceDir flag, and the plugin-free printed source set that is the whole point |
build/gradients/ (after a build) |
the generated gradient itself |