Flexibility at every compile stage: exact GELU as the example
SKaiNET runs a model two ways. The eager path executes each op as it is called. The compiled path traces the model once and hands it through four stages, from Kotlin down to an IREE binary. Each of those stages has its own knobs, and a target plugin can turn some of them without the core ever naming a piece of hardware.
This page maps those knobs and then follows a single op through all of them:
gelu(x, approximate = false), the exact, erf-based GELU added in 0.59.0 (#1342). It is a
good example because the difference between its forms is small, measurable, and matters to
real models, so every stage has a reason to treat it carefully.
For the hands-on side (how to keep an op fused for your target, or how to register a pass), see the Fused ops and `stablehlo.composite` section of tutorials/hlo-getting-started.adoc. For how the eager path picks kernels, see Eager execution: backends and kernels.
The four stages and their knobs
| Stage | What can change | Who changes it |
|---|---|---|
① Kotlin DSL · trace |
|
op author, generated code, model author |
② Tape → DAG |
|
core, vendor ( |
③ DAG → StableHLO |
The converter registry (13 families) emits |
core, vendor ( |
④ IREE |
Backend and flags ( |
target, IREE |
A target plugs in through one interface in sk.ainet.compile.opt:
public interface TargetOptimizer {
public val target: String
public fun dagPasses(): List<GraphOptimizationPass> = emptyList()
public fun granularity(): OpGranularityPolicy? = null
}
CompilePhase declares three phases, TAPE, DAG and STABLE_HLO. Only the DAG phase is
wired today: dagPasses() runs after the core passes, and granularity() is read when the
graph is lowered. The tape and StableHLO phases are declared but have no call sites yet, and
iree-compile flags are still set per target by the caller rather than through a provider.
Why exact GELU needs care
Exact ( |
|
Tanh ( |
|
The two forms differ by at most 4.73e-4, at |x| ≈ 2.70. That sounds harmless, but encoders
trained with hidden_act = "gelu" (BERT-class models, the Laya decision models) mean the
exact form. In the Laya decision head the per-activation difference grows to a 7.5e-4 drift
against a 5e-5 parity gate. So approximate is part of the op’s meaning, not an
implementation detail, and every stage below has to preserve it or change it on purpose.
Before 0.59.0, gelu had only the tanh form and there was no erf op. Laya computed exact
GELU on the host instead, which broke the traced graph at every activation and cost 44 host
round trips per decision.
What adding the op touched
Making exact GELU a first-class op meant one change per layer of the stack (two PRs, #1344 and #1346):
-
Interface:
gelu(tensor, approximate = true)and a newerf(tensor)onTensorOps, annotated@Lowering(composite = "skainet.gelu")and@Lowering(composite = "skainet.erf"). This is a breaking change for everyTensorOpsimplementation. -
Code generation: the KSP tracing wrapper records
approximate; the network and graph DSL generators learned the default value, because KSP cannot read declared defaults. -
Tracing:
VoidTensorOps, tape recording, and the graph executor’s"gelu"and"erf"cases. -
Eager kernels:
ErfKernelandDefaultCpuOps.gelu/erf, plus a Vector API path inDefaultCpuOpsJvm. -
Autograd: the exact derivative
Φ(x) + x·φ(x)anderf′(x) = 2/√π · e^(−x²). -
Lowering:
GeluLowering,ErfLowering, and the activation and unary-math converters. -
Docs and tests: the operator reference, the coverage matrix, and recording, emission and pass tests.
One op, four ways down
All four start from the same traced node, gelu {approximate = false}. What differs is which
knobs the target turns.
A portable target: inline decomposition
With no granularity policy (for example llvm-cpu), the op is decomposed inline. StableHLO has
no erf op, so the converter emits chlo.erf and lets IREE’s chlo-legalize-to-stablehlo
expand it:
%c0 = stablehlo.constant dense<0.5> : tensor<1x4xf32>
%c1 = stablehlo.constant dense<1.0> : tensor<1x4xf32>
%c2 = stablehlo.constant dense<0.70710678118654752> : tensor<1x4xf32>
%s = stablehlo.multiply %arg0, %c2 : tensor<1x4xf32>
%e = chlo.erf %s : tensor<1x4xf32> -> tensor<1x4xf32>
%p = stablehlo.add %c1, %e : tensor<1x4xf32>
%h = stablehlo.multiply %c0, %arg0 : tensor<1x4xf32>
%r = stablehlo.multiply %h, %p : tensor<1x4xf32>
SKaiNET does not emit its own erf polynomial here. Owning that math would mean owning it for every target; IREE already does.
A target that fuses GELU: composite with a decomposition
A target with a native GELU kernel returns a policy that keeps the op:
FusedOpAllowList("my-npu", setOf("gelu"))
The converter then emits a stablehlo.composite and, once per (op, attributes, type), a
private function with the reference body:
%r = stablehlo.composite "skainet.gelu" %arg0 {
composite_attributes = {approximate = false},
decomposition = @skainet_gelu_approximate_false_1x4xf32,
version = 1 : i32 } : (tensor<1x4xf32>) -> tensor<1x4xf32>
...
func.func private @skainet_gelu_approximate_false_1x4xf32(%arg0: tensor<1x4xf32>) -> tensor<1x4xf32> {
// the same chlo.erf chain the inline form emits
}
A vendor compiler can match skainet.gelu and call its kernel. Any compiler that does not
know the name can inline the decomposition, which a test checks is identical to the inline
form apart from SSA names. The composite always carries its exact meaning with it.
A target without erf: a recorded legalization
A target that cannot compute erf opts in to the tanh form by registering a DAG pass:
TargetOptimizers.registerDagPasses("my-dsp") {
listOf(GeluApproximationLegalizationPass("my-dsp has no erf kernel"))
}
GeluApproximationLegalizationPass rewrites the node and records what it did, on the node and
in the pass diagnostics:
gelu { approximate = true,
legalizedFrom = "gelu(approximate=false)",
maxAbsError = 4.8E-4 }
GELU_APPROX_LEGALIZED node=…: gelu(approximate=false) -> gelu(approximate=true) [my-dsp has no erf kernel] maxAbsError=4.8E-4
The converter then emits the 13-op tanh chain, so the module contains no chlo.erf at all.
GELU_TANH_MAX_ABS_ERROR (4.8e-4) is the measured 4.73e-4 rounded up. The pass leaves a bare
erf node alone on purpose: there is no cheap substitute, and a loud failure in the emitter
is better than a quiet approximation.
Eager execution: no compiler at all
The eager path never builds a graph. DefaultCpuOpsJvm computes exact GELU directly, using
the Abramowitz–Stegun 7.1.26 erf (|ε| < 1.5e-7) in Vector API lanes with
VectorOperators.EXP. The scalar tail uses ErfKernel.erfFloat with the same operation
order, so lanes and tail agree to within 1e-6.
Eager and compiled are two implementations
One line of model code therefore has two implementations of erf: SKaiNET’s polynomial on the
eager path, and IREE’s expansion of chlo.erf on the compiled path. They have different error
bounds, owned by different projects. What is checked today:
-
Exact GELU matches the host formula within 2e-7 (bit-equal on the JVM).
-
Eager execution matches graph replay within 1e-6.
-
The exact–tanh difference stays between 4.5e-4 and 5.0e-4, with its maximum at |x| in 2.5–2.9.
What is not yet checked: eager A&S erf against IREE’s chlo.erf expansion, and an exact-GELU
case in the IREE conformance suite, whose reference oracles still use the tanh form.
The rules this example follows
-
Meaning travels with the op.
approximateis recorded at trace time and is visible on the DAG node, so later stages can respect it. -
Approximation is opt-in and recorded. Only a target that registers the legalization pass gets the tanh form, and the node says so.
-
A composite never loses its semantics. It always carries a decomposition that any compiler can inline.
-
The core never names hardware. Targets attach through
TargetOptimizer; the converters and passes stay target-neutral.
Status
As of 0.61.0 no shipped target registers GeluApproximationLegalizationPass or keeps gelu
fused; my-npu and my-dsp on this page are example names, and the mechanisms are exercised
by tests. The TAPE and STABLE_HLO hook phases are declared but not wired, and there is no
provider interface for iree-compile flags yet.
Related pages
-
tutorials/hlo-getting-started.adoc — exporting StableHLO, composites and granularity in practice
-
Graph Export Architecture — how the graph export is structured
-
Eager execution: backends and kernels — the eager path and its kernels
-
Reflective Documentation for SKaiNET Operators — how operator documentation and lowering metadata are generated