Repository navigation
gpu: geglu_tanh, the GELU-gated unit over f32 rows - #237
Conversation
There was a problem hiding this comment.
Verdict: MUST_FIX: The new gate duplicates the existing rounding owner.
Must fix
1. Reuse the shared rounding definitions
crates/gpu/src/gelu.rs:43 duplicates rounding::gamma in crates/gpu-gates/src/rounding.rs:17. Its local U also duplicates that module's unit roundoff. Both implement the same f32 error model (R14).
Fixing this now removes two definitions with one test dependency, before other bounds depend on the new copies.
Append to crates/gpu/Cargo.toml:
[dev-dependencies]
bloomery-gpu-gates = { path = "../gpu-gates" }Replace the test imports and local U/gamma definitions with:
use super::*;
use crate::Gpu;
use bloomery_gpu_gates::rounding::{U, gamma};
use cuda_core::DeviceBuffer;Non-blocking
⚠ Supply the change-class proof: crates/gpu-vision/src/gelu_tanh.rs:64 moves a live device body; the elementwise module also gains an entry.
The body has no base/head PTX comparison, pre-pin gate-ptx-spill failure, or owning-gate output for the existing vision callers.
Supply the move/add proofs from AGENTS.md: existing entries unchanged, exactly the new GEGLU entries added, spill FAIL-first, and owning gates green. No timed A/B is required for these classes.
⚠ Isolate the short-buffer cases: crates/gpu/src/gelu.rs:160 makes all three buffers short together.
Deleting any one length check at elem.rs:1994 still passes because the other two return the expected error.
Keep the same gate and add cases with only gate, only up, and only y short. Each dropped check must fail the named-error assertion.
Checked
Duplicates: 12 added or moved function/constant declarations checked; U/gamma duplicate the existing owner. SWIGLU/GEGLU share launch shape but apply different rules. Dryer reported 0 pairs.
Complexity: crapper flagged no functions; no qualifying growth.
Silent failure: 1 max site followed to printed diagnostics only. Empty/short inputs return Shape; NaN inputs propagate through the final multiply to y[i].
Tests: static mutation check: elem.rs:1021 >= to > fails the tail assertion; operand swap at :1029 or lost x at gelu.rs:29 fails the numeric bound; saturated-gate zeroing fails the NaN-index assertion; dropping n == 0 fails the named-error assertion. Individual length-check mutants survive.
Gates: unsafe count 1769 to 1770 has a dated PIN and matches the added block; no existing numeric gate relaxed or removed.
Performance: shared vision arithmetic retains its operation order and inline core; GEGLU has no engine caller and adds no engine-step launches or copies.
Escape catalog: C1–C4 do not match. No ledger lessons are in force.
Notes
Static review only; CUDA gates were not run here. Reported card runs lack the nvidia-smi line, the base/head commit pair, and just gate-* recipe output required for gpu.evidence=true.
Card check: needs-gpu. This pull request changes crates/gpu-vision/src/gelu_tanh.rs, crates/gpu-vision/src/minicpmv/host.rs, crates/gpu/src/elem.rs … and carries no gate run on a card (CONTRIBUTING's evidence list); a maintainer adds gpu-verified after one.
🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)
Filed outside this PR: #239
gelu_tanh_dev is vis_gelu_tanh's f32 arithmetic as a device core outside any cuda_module, and gelu_tanh_f32 with its two constants moves beside it. vis_gelu_tanh keeps its bf16 widen and narrow and calls the core; gelu_tanh_ref and the minicpmv host twin read the moved host rule. A move: no entry's PTX changes.
ElemKernels::enqueue_geglu computes y = gelu_tanh(gate) * up elementwise, llama.cpp's ggml_geglu_split at op_gelu: the tanh rule on the gate value, then one rounded multiply by the up value. A GEGLU feed-forward (Gemma's embedding models) takes it after its gate and up projections. The entry sits beside swiglu in elem's module and calls bloomery_gpu::gelu's core. hw_geglu_is_the_rule_within_its_bound holds every value finite and within a first-order bound of the exact unit, a NaN gate or up value NaN at its own index alone over gate values of both signs, and an empty launch or a short buffer refused by name.
b689bc9 to
6cdf0b6
Compare
There was a problem hiding this comment.
Verdict: CLEAN: No must-fix finding remains.
Non-blocking
⚠ Complete the owner-gate evidence: crates/gpu/src/elem.rs:1117. The new family gate passes, but the PR supplies no passing engine owner-gate run. The base contract's “add (no engine caller yet)” class also requires owning gates of entries engine bins gained; gate_e2e gains this entry. Identify which owners gate-gpu-lib covers and supply the remaining applicable output.
Checked
Duplicates: the moved host rule and extracted device rule have one production owner. Dryer reported 0 pairs. The short swiglu/geglu launch scaffolding shares its module and element view; another abstraction is not justified.
Complexity: crapper flagged no changed function.
Silent failure: no new defaults or ignored Results. Empty and short-buffer cases return named errors; launch_u32 rejects oversized counts; the arithmetic propagates NaNs.
Tests: the new ignored hardware gate checks numeric bounds, finite outputs, the output tail, NaN locations, and named empty/short-buffer refusals. The PR reports FAIL-first failures for the bound-check, operand-order, polynomial, NaN and refusal mutants; the corresponding assertions exist.
Gates: no numeric band relaxed. The unsafe count gains the new block with a dated PIN and reason. The moved host function and constants are identical, and explicit device rounding order is preserved. The PR reports unchanged existing-entry PTX and new-entry spill FAIL-first results.
Adversarial checks: the kernel guards partial blocks; the launcher checks empty/short buffers and narrowing. The test covers saturated negative gates with NaN up values. No engine path calls enqueue_geglu yet.
Escape catalog: C1–C4 do not match the changed code.
Notes
CUDA gates and PTX scans were not rerun here; their results come from the PR body.
Card check: gpu-evidence. This pull request changes crates/gpu-vision/src/gelu_tanh.rs, crates/gpu-vision/src/minicpmv/host.rs, crates/gpu/src/elem.rs … and carries a gate run on the author's card, which counts as verified.
🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)
|
The review is done and the contribution check passed, so I'm merging this. |
Part of #59 (plan and PR order in #190), E4: the f32 GELU-gated entry. The head-512 prefill entries and the prefill window are separate PRs.
What and why
Release notes: Added: an f32 GELU-gated kernel for EmbeddingGemma 2's feed-forward; no engine path calls it yet.
EmbeddingGemma's feed-forward is a GELU-gated linear unit: llama.cpp builds it with
build_ffn(..., LLM_FFN_GELU, LLM_FFN_PAR), which callsggml_geglu_split(gate, up), and its CUDA kernel computesgelu_tanh(gate) * upwithop_gelu. The engine had the tanh GELU only as a bf16 in-place kernel insidegpu-vision(vis_gelu_tanh). This PR adds the f32 gated unit tobloomery-gpuas a kernel,geglu_tanh, so a text model's FFN can use it.The PR has two commits:
crates/gpu-vision/src/gelu_tanh.rsinto a newcrates/gpu/src/gelu.rs: the host functiongelu_tanh_f32and the device coregelu_tanh_dev(a core outside#[cuda_module], docs/gpu-design.md decision 6).vis_gelu_tanhand the MiniCPM-V host path now import them frombloomery_gpu::gelu. No kernel changes.elem.rs's module gains one entry besideswiglu,geglu_tanh(gate, up, n, y), andElemKernelsgainsenqueue_geglu. The rule on the gate value isgelu_tanh_dev, then one rounded multiply by the up value, the same order asunary_gated_op_kernelatop_gelu. An empty launch or a short gate, up or y buffer is refused by name. The gate test lives ingelu.rs, beside the rule it holds the kernel to.No engine path calls the kernel yet. The E5 body of #190 (
gpu-gemma-embedding2) is its first caller; it reaches the entry through theElemKernelstheGpualready loads.Code shape
bloomery_gpu::gelu.gpu-visionkeeps only its bf16 in-place kernel, which calls the shared core.enqueue_geglurefusesn == 0and short buffers.tools/unsafe-ratchet.txtmovesgpufrom main's 1787 to 1788 blocks with aPIN(2026-10-15):line: the new kernel'sElem2::new, with its SAFETY line.cargo fmtandcargo clippyare clean on what I touched; the lint count did not go up.Duplication found or removed (
path:line):crates/gpu-vision/src/gelu_tanh.rs(the f32 rule, its two constants and the host twin) now uses the copy incrates/gpu/src/gelu.rs.geglu_tanhandenqueue_geglu(crates/gpu/src/elem.rs:1117,:2177) followswigluandenqueue_swiglu(:1094,:2148) line for line except the activation, as the module's other elementwise entries do. They share the module, its load and its unsafe load block; a body generic over the activation would add more than the two short copies cost.Remaining: the gate test's
Uandgamma(n)(crates/gpu/src/gelu.rs:41) repeatcrates/gpu-gates/src/rounding.rs.bloomery-gpucannot takebloomery-gpu-gatesas a dev-dependency:bloomery-gpu-gatesdepends onbloomery-gpu, and with the reverse edgetools/recipes.py opensrefuses every gate list (recipes.py opens: bloomery-gpu depend on bloomery-gpu-gates), which turnscheck-recipesred. One owner would mean movingroundingbelowbloomery-gpu, a change to every gate that imports it.What I ran
The branch is now
6cdf0b69e2678b642a4c7460fe79c04354b7d923on top ofd0cbd4bbd109d92a7a0c5a0001029a91ce488a57. That rebase changed none of this PR's own lines:tools/unsafe-ratchet.txttakesgpufrom main's 1787 blocks to 1788, and the third commit adds the gates main's new code reaches to thecrates/gpu/src/gelu.rsrow oftools/gate-paths.tsv. At this head I reran the gate andjust mac-static(below). The other runs are at the head before the rebase,618aec3f1da4d5525df9c56b537ab200dcf57194, on top ofe98d6c43f95bc75475cadaf1043c1735962dcc5d, unless a line names another commit. In the Rust sources and manifests this PR changes, the head is the same as30f18e63(the branch before its rebase ontoe98d6c43), differs fromb689bc93only ingelu.rs's test module (one short-buffer case there, three at the head), and from0eb4a353only in that module'sUandgammaand the dev-dependency incrates/gpu/Cargo.tomlandCargo.lock. The card runs used an RTX 5090 (x86_64 Linux) on the host and a DGX Spark (aarch64 Linux) in the project's dev container. The static checks ran on an Apple Silicon Mac.just contrib-check --tier t0on the RTX 5090:The one red item is a self-test of the recipe checks,
FAIL stall-term: got [post 0 rc=124], want [post 0 rc=143]. The basee98d6c43fails the same self-test, and nothing else, on the same machine (DONE tier=t0 total=82 red=1 wall=218s red: check-recipes).just mac-staticon the Mac at6cdf0b69: every step passes except lint,mac-check: lint ^warning: 44, above the ratchet 41 in tools/lint-ratchet.txt. Main atd0cbd4bbgives the same line on the same Mac, with lint its only red step, so the three extra warnings come from main and this PR adds none. Before the rebase, at618aec3f, lint was at the ratchet (mac-check: lint ^warning: 41 = the ratchet 41 in tools/lint-ratchet.txt).just gate-gpu-libat30f18e63over its baseb3153d77, on the RTX 5090 (BLOOMERY_ARCH=sm_120 BLOOMERY_BOX_LOCAL=1 just gate-gpu-lib), which runs the new test with the rest ofbloomery-gpu's library tests:Gate.
hw_geglu_is_the_rule_within_its_boundincrates/gpu/src/gelu.rs, a testgate-gpu-libruns. At6cdf0b69I ran it alone on the RTX 5090 (test gelu::tests::hw_geglu_is_the_rule_within_its_bound ... ok):BLOOMERY_ARCH=sm_120 bash tools/gate.sh --oxide -p bloomery-gpu --release --lib -- --ignored --nocapture gelu::tests::hw_geglu_is_the_rule_within_its_bound.i/256foriin-4096..=4096, plus±20, ±37.5, ±100, ±1e3, ±1e4(past wheretanhsaturates); up values seeded in[-2, 2], every eighth exactly 1.tanhby1 - T², libdevicetanhf's 2 ulp, the1 + trounding and the two products. The bound is scaled by|0.5 * gate * up|, not by|y|, because1 + tanhcancels toward zero for large negative gates.n, and the guard must come back untouched.n == 0is refused, and so isn == 9with one buffer 8 long and the other two 9 long, for each of gate, up and y in turn; each error namesenqueue_geglu.The gate prints how many outputs equal the host rule bit for bit. That count depends on the host's
tanhf, so the gate prints it and does not assert it. On the RTX 5090 (BLOOMERY_ARCH=sm_120):The same test on the DGX Spark at
b689bc93(BLOOMERY_ARCH=sm_121), whose kernel and rule are the head's:Move and add proof. At
0eb4a353, whose kernel and rule are the head's, on the RTX 5090 against its baseb3153d77:ptx-scanatsm_120and atsm_86ofgenerate_ds41,gate_e2eandgate_qwen3vl_tower: every entry ofb3153d77has the same PTX at0eb4a353(302, 235 and 251 entries), none is changed or removed, and the one added entry isgeglu_tanh. The moved body,vis_gelu_tanhingate_qwen3vl_tower, has the same MD5 atb3153d77and0eb4a353on both arches (474c92764f801105a737191ba2841d13), 13 registers, no spill and, through the RTX 5090's JIT, no local memory.geglu_tanh's rows left out of the shape tables,ptx-spillfails ongenerate_ds41andgate_e2e(geglu_tanh unpinned spill=0 jit_local=0); with them, all three bins pass. Thesm_86rows come fromptxas, with the stack frame standing in for the 3090 JIT's local bytes.gate-paths.tsvroutesgelu_tanh.rsto (the MiniCPM tower gate among them, which also ownsminicpmv/host.rs) end with the same rc at0eb4a353and atb3153d77. None of them passes on this machine, which lacks their model files or cards, so they are not evidence that the vision path is green.gate-gpu-vision-lib, which holds the shared host rule, passes its 8 tests on both sides.Gates
just affectedselects and I did not run.tools/affected-gates.sh e98d6c43..618aec3f --no-box, the script behindjust affected, selects 114 of 162 gate recipes, throughcrates/gpu/src/elem.rs,gelu.rsand the vision files. At the head I ran the new test alone;gate-gpu-liband the owner gates ran at the commits named above. I did not run the others: at0eb4a353no existing entry ofb3153d77changed its PTX, and no engine path callsgeglu_tanhyet, so by the change-class table the runtime gates are the new test and the moved body's owners.FAIL-first. Each mutant was a temporary patch, applied alone and reverted after its run. The short-buffer cases, at the head on the RTX 5090: with each of
enqueue_geglu's three length checks removed alone, the test fails on that buffer's case (a short gate buffer,a short up buffer,a short y buffer), and the restored tree passes.The output guard and the saturated-gate NaN case, at
b689bc93, whose test had one short-buffer case where the head has three:i > ninstead ofi >= n, so it writesy[n]geglu: output past n must stay untouchedgeglu: a NaN input is NaN at its index alone: NaN at[100, 700], not[0, 100, 700]The other clauses at
36f2175a, where the kernel is the same and the test lacked the two clauses above:geglu: gate 3.90625e-3 up 1.8667641e0: NaN(the finiteness check)gelu_tanh(up)by the gate (operands swapped)geglu: values past their bound, 8201 valuesx(1 + 0.044715 * x, ingelu.rs)geglu: values past their bound, 5924 valuesg.max(-3e38)) before the rulegeglu: a NaN input is NaN at its index aloneenqueue_gegludrops itsn == 0checkan empty launch: geglu_tanh: Grid.X must be nonzero, the driver's error, not the named refusalHardware (if the change is for a machine)
RTX 5090, on the host:
DGX Spark, dev container
nvidia/cuda:13.3.1-devel-ubuntu24.04:What I ran into
None of this blocks the PR.
tools/ref/ptx-shapes.tsvforgeglu_tanhcomes fromptxas -arch=sm_86 -vand was not read on a 3090. If a reading there differs, the row changes to it (model: EmbeddingGemma and EmbeddingGemma 2 on a side seat #190, open question 1).tanhfdiffers. The count is now printed only.AI help
AI 코딩 에이전트가 코드를 작성한 후 설명을 썼고, 테스트를 마쳤습니다. diff 리뷰와 PR 초안 리뷰는 제가 직접 했습니다.