[Feature]: exact (erf) GELU as a first-class op — CPU kernels and HLO lowering
Los mantenedores suelen responder en 1 día
Nadie ha tomado este issue todavía.
Evaluación
- Dificultad
- 5/5
- Tiempo estimado
- Más de una semana
- Aptitud para principiantes
- 12/100
- Tipo de issue
- Nueva funcionalidad
- Claridad
- Bastante claro
- Estado de actividad
- Activo
- Stack tecnológico
- kotlin
- Área
- compilers, machine-learning
Línea de trabajo
Start from DefaultCpuOps and the TensorOps interface, where the tanh GELU lives, and from RecordingExecution.kt around line 443, where gelu is delegated without recording a node. The issue spans CPU kernels, graph recording, and StableHLO lowering, so a newcomer should first pick one slice, such as a scalar erf kernel tested against a table within 2e-7. Done means that test passes and the exact-vs-tanh GELU difference is measured.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
🧠 D: DEFINE — Problem & Opportunity
TensorOps.gelu is the tanh approximation everywhere: DefaultCpuOps computes 0.5·x·(1 + tanh(√(2/π)·(x + 0.044715·x³))) and the HLO converter lowers gelu "using approximation". There is no erf op and no exact GELU.
Encoder checkpoints trained with hidden_act = "gelu" (ModernBERT, mmBERT, BERT-class models from Hugging Face, the Laya decision models) use the exact 0.5·x·(1 + erf(x/√2)). Measured on the Laya multilingual checkpoint while building skainet-transformers-inference-laya (transformers #487): with the engine's tanh GELU the decision-head outputs move by about 7.5e-4 against the reference; the parity gate for that model is 5e-5. The adapter therefore ships a workaround, LayaGelu, which copies the activation to a FloatArray, applies erf on the host and copies back — correct, but a host round trip per FFN per layer (22 layers × 2 per decision) on the eager path, outside every kernel tier and invisible to the compiled path.
🔍 A: ASSESS — Options
gelu(approximate = false)+erfinTensorOps(proposed): a boolean on the existing op plus a standaloneerf, default unchanged (tanh), so no existing model changes numerically.- A separate
geluExactop withouterf: smaller surface, buterfis useful on its own (normal CDF, GELU variants) and is what StableHLO/CHLO expose (chlo.erf). - Keep host-side workarounds in model modules: what Laya does today; every BERT-class consumer re-implements it and the compiled path cannot see it.
✅ R: RESOLVE — Decision
Option 1. Scope:
TensorOps:fun erf(x),fun gelu(x, approximate: Boolean = true);VoidTensorOps,RecordingExecution,ComputeGraphExecutorfollow.- CPU: scalar kernel (
kotlin.mathhas noerf; use the Abramowitz-Stegun 7.1.26 form with max error 1.5e-7, or theerfof the FFM/libm tier where available) and a Vector API variant; JNI/FFM tiers optional in the first PR. - Lowering: the node carries the
approximateflag as op metadata; the StableHLO emitter chooses between a decomposed form (chlo.erf) and astablehlo.compositethroughOpGranularityPolicy— see Lowering metadata below. - NN DSL:
GELU(approximate = …)module parameter; docs operator/kernel matrix row. - Tests:
erfagainst a table (|err| < 2e-7 in [-6, 6]), exact-vs-tanh GELU difference (max ≈ 2e-3 near |x| ≈ 2), parity ofGELU(approximate = false)with the host formula.
🧩 Lowering metadata — one op, exact on every path
The point of a native op is not the CPU kernel alone: gelu(approximate = false) and erf must survive direct execution, the recorded/optimized graph and every export with their semantics intact, and carry enough metadata that a lowering transformation can pick the best legal form for its target without re-deriving what the op means.
What travels with the op
-
Graph node:
Operation.parameters = { "approximate": Boolean }ongelu;erfis its own op name.Operation.parametersis already serialized byBaseOperation.serialize()and the JSON export, so the flag reaches every emitter for free. -
Recording path gap (must be fixed in the same PR):
RecordingExecution.geludelegates tobase.geluwithout recording a node (skainet-compile-core/.../tape/RecordingExecution.kt:443, the "unrecorded/less critical ops" block). A compiled graph therefore never sees GELU today — the activation is computed eagerly and vanishes. The PR records it likePowOperation(parameters = mapOf("scalar_exponent" to n)), andComputeGraphExecutordispatches"gelu" -> ops.gelu(x, approximate = node.parameters["approximate"]),"erf" -> ops.erf(x).DefaultExecutionTape.geluBackwardalready receivesattributesand has both derivative formulas in comments; it picks one from the flag. -
Lowering descriptor (new, declarative): one small record per fused-able op, registered next to its converter:
LoweringSpec( op = "gelu", compositeName = "skainet.gelu", // namespaced, as the StableHLO spec requires attributes = listOf("approximate"), // copied into composite_attributes version = 1, decomposition = GeluDecomposition, // the one "composite constructor" )GeluDecompositionis a single Kotlin builder that emits the formula as StableHLO primitives. The same builder writes the body of the composite's@decompositionfunction and the inline fallback, so eager semantics, composite and decomposed form cannot drift. Forapproximate = falsethe builder useschlo.erfwhen the target toolchain accepts CHLO, otherwise the engine's own rational approximation with the same coefficients as the CPU kernel (Abramowitz–Stegun 7.1.26), which keeps DIRECT and lowered output within the 2e-7 kernel bound. -
Source annotation (docs): the operator matrix is generated by the docs KSP processor from
@Backend/@InProgress. A@Lowering(composite = "skainet.gelu", decomposes = true)annotation on theTensorOpsfunction (same package as@TensorOp,@DslOp) lets the matrix grow a Lowering column (composite · decompose · native) without hand edits, and gives tooling one place to read what an op can lower to.
Emission rules (StableHLO)
Checked against the StableHLO spec and ChloOps.td on 2026-10-14: the core op set has no erf (the alphabetical list jumps from dynamic_update_slice to exp2); chlo.erf, chlo.erfc, chlo.erf_inv live in CHLO and chlo-legalize-to-stablehlo provides their polynomial expansion; stablehlo.composite takes name, composite_attributes, decomposition, version and by definition "can be replaced with its decomposition without changing program semantics"; stablehlo-legalize-composite-to-call does exactly that.
OpGranularityPolicy.keepFused("gelu") |
emitted form |
|---|---|
null policy / false (portable default, llvm-cpu) |
decomposed: approximate = true → the tanh chain the converter emits today; approximate = false → 0.5·x·(1 + chlo.erf(x·rsqrt(2))) (or the primitive-only rational form with --no-chlo) |
true (vendor target with a GELU/erf kernel, e.g. the torq allow-list) |
%y = stablehlo.composite "skainet.gelu" %x { composite_attributes = { approximate = false }, decomposition = @skainet_gelu_erf_f32_128x768, version = 1 : i32 } : (tensor<128x768xf32>) -> tensor<128x768xf32> plus one func.func private @skainet_gelu_erf_f32_128x768 per (dtype, shape) built by GeluDecomposition |
A vendor compiler pattern-matches the composite name + attributes and maps it to its kernel; everyone else inlines or runs legalize-composite-to-call and gets the exact formula back. erf follows the same two rows with "skainet.erf".
This is also the first real consumer of OpGranularityPolicy: the policy is threaded into ConversionContext.granularity but no converter calls keepFused yet. The GELU PR adds the generic ConversionContext.emitComposite(spec, operands, attrs) helper (composite op + deduplicated decomposition function) that layernorm and scaled-dot-product attention can then reuse, which is what the policy KDoc already promises.
Degrade visibly, never silently
Today the exact→tanh substitution happens implicitly on every path, which is how the 7.5e-4 Laya drift stayed invisible. After this change a target that has no erf anywhere (no CHLO, no composite match) gets an explicit TargetOptimization pass in skainet-compile-opt that rewrites gelu(approximate = false) → gelu(approximate = true) and records it on the node (parameters["legalizedFrom"] = "gelu(approximate=false)", parameters["maxAbsError"] ≈ 2e-3). The export report lists these rewrites, so a numerics regression is a visible legalization decision instead of a surprise.
Other emitters
- C codegen:
erff()from<math.h>;approximate = truekeeps the current tanh form. - Minerva:
MinervaCompatibilityValidator.activationOperationsisrelu, sigmoid, tanh; addgelu/erfor reject with a remediation that names the legalization pass above. - ONNX (if an exporter ever lands):
Gelu(opset 20) has the sameapproximateattribute ("none"/"tanh"),Erfis a core op — the metadata maps 1:1, which is the test that the descriptor is target-neutral.
🚀 C: COMMIT — Acceptance
-
TensorOps.erfandgelu(approximate = false)on the CPU path, scalar and Vector API -
RecordingExecutionrecordsgelu/erfwith theapproximateparameter;ComputeGraphExecutorandgeluBackwarddispatch on it; DIRECT vs OPTIMIZED on a Laya FFN block within 1e-6 - StableHLO: decomposed (
chlo.erf) and composite ("skainet.gelu"/"skainet.erf",version = 1) emission selected byOpGranularityPolicy;ConversionContext.emitCompositehelper; test runs the composite throughstablehlo-legalize-composite-to-calland compares with the decomposed module - One
GeluDecompositionbuilder feeds both the composite body and the inline fallback (asserted by a golden-text test) - Explicit exact→tanh legalization pass with recorded
legalizedFrom/maxAbsError, surfaced in the export report -
@Loweringannotation and the Lowering column in the generated operator matrix -
GELUmodule exposes the flag; operator matrix documents it - transformers:
LayaGelureplaced byctx.ops.gelu(x, approximate = false)with the 5e-5 parity gate still green (follow-up PR there, linked here)
Related: #1343 (SDPA key lengths — reuses the LoweringSpec / emitComposite seam defined here; together the first consumers of OpGranularityPolicy), SKaiNET-transformers#490.
Consumer evidence: transformers llm-inference/laya (LayaModules.kt, LayaGelu), measured 2026-10-12/13.
- Lenguaje dominante
- Kotlin
- Estrellas
- 52
- Forks
- 15
- Merge medio
- 1 d 8 h
- PR fusionados (30 d)
- 37
Preparar el entorno
- Sin Dockerfile ni archivo de Docker Compose
- Sin plantilla de pull request
- Leer la guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de SKaiNET-developers/SKaiNET
-
coding good first issue platform size:xs skill:js sub-issue
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
SKaiNET-developers/SKaiNET#1232 ·
Los mantenedores suelen responder en 1 día
-
compute-backend dsl/api enhancement size:m skill:kotlin-core skill:numerics
Dificultad 5/5 Más de una semana Aptitud para principiantes 18/100
SKaiNET-developers/SKaiNET#1343 ·
Los mantenedores suelen responder en 1 día
-
skeep tracking
Dificultad 5/5 Más de una semana Aptitud para principiantes 15/100
SKaiNET-developers/SKaiNET#1331 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
assessment size:s skill:review sub-issue
Dificultad 4/5 1-2 días Aptitud para principiantes 18/100
SKaiNET-developers/SKaiNET#1330 ·
Los mantenedores suelen responder en 1 día
-
documentation good first issue size:s skill:docs sub-issue
Dificultad 3/5 1-2 días Aptitud para principiantes 78/100
SKaiNET-developers/SKaiNET#1329 ·
Los mantenedores suelen responder en 1 día
Todos los issues de SKaiNET-developers/SKaiNET
Issues similares
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 65/100
Los mantenedores suelen responder en 2 días
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
rustyrazorblade/easy-db-lab#1017 ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 82/100
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
callstack/react-native-brownfield#492 · 1 comentario ·
Los mantenedores suelen responder en 4 días
-
fix: retain the best partial noise-correction solutionPosiblemente ocupada Un pull request vinculado a esta issue está abierto o ya se fusionó. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
world-federation-of-advertisers/cross-media-measurement#4653 ·
Los mantenedores suelen responder en 1 día