mirror of
https://github.com/hanzoai/papers.git
synced 2026-10-22 17:20:17 +00:00
papers: split hanzo-kernel DSL into a pedagogical Paper 07 series
Restructure the 12-page monolith into one paper per concept, each self- contained and teachable (concrete failure -> concept -> repo code -> eval for that concept only), with hanzo-kernel-dsl.tex slimmed to a ~5-page umbrella that cites the six rather than inlining their depth. Each fact lives in exactly one paper (DRY). Shared preamble in shared/kernel-preamble.tex. 07 hanzo-kernel-dsl umbrella: thesis + guided tour 07.1 hanzo-fusion-is-composition functor law, fences, fused_interp, DAG 07.2 hanzo-comptime-autotune schedule-as-value, byte-identical variants 07.3 hanzo-intrinsic-islands oracle-anchored escape hatch vs forks 07.4 hanzo-bitexact-oracle CPU oracle law + nightly 3-backend CI 07.5 hanzo-int8-mmq-negative-result opaque WMMA fragment, sync-bound proof 07.6 hanzo-collapse-methodology ledger + verify-then-delete + migrations All 7 build via the Makefile (auto-discovered) with zero undefined refs.
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,182 @@
|
||||
\documentclass[11pt]{article}
|
||||
\input{shared/kernel-preamble.tex}
|
||||
|
||||
\title{\textbf{One Oracle, Every Backend:\\ The CPU Bit-Exactness Law and How It Is Enforced}}
|
||||
\author{Hanzo AI --- ML Systems}
|
||||
\date{hanzo-kernel Paper 07.4 --- part of the \emph{One Source, Every Backend} series}
|
||||
|
||||
\begin{document}
|
||||
\maketitle
|
||||
|
||||
\begin{abstract}
|
||||
The most expensive bugs in a multi-backend inference engine are not crashes; they are silent numeric
|
||||
divergences, where the same model produces coherent text on one GPU and degenerate repetition on another
|
||||
from byte-identical weights. This paper isolates the cause --- ``the same operation, a different kernel
|
||||
per backend, therefore a different numeric behavior'' --- and states the law that makes the class
|
||||
structurally impossible: \textbf{the CPU runtime is the single bit-exact reference for every backend.} It
|
||||
is not an accelerator target; it is the \emph{oracle}. Every operation ships with a plain-Rust reference
|
||||
whose scalar semantics the device code mirrors line-for-line; because the CPU executes a pointwise
|
||||
operation in program order, its result is the \emph{definition}, and a GPU backend may differ only by
|
||||
floating-point re-association where a reduction reorders a sum --- a bounded, explained difference, not a
|
||||
bug to hunt. We show how the law is anchored (a mirror between host and device code), why it holds
|
||||
(index-locality plus in-order execution), and how it is enforced continuously: a nightly three-backend
|
||||
GPU CI on self-hosted NVIDIA, AMD, and Apple machines that runs the same source against the oracle and
|
||||
fails on any divergence. The cross-backend bit-exactness results --- including a single attention
|
||||
implementation that is the structural cure for the repetition collapse --- are the evidence that the law
|
||||
holds in practice.
|
||||
\end{abstract}
|
||||
|
||||
\section{The pain: coherent on Metal, collapsing on CUDA}
|
||||
\label{sec:pain}
|
||||
|
||||
A concrete failure names the whole problem. Qwen3-8B-Q4\_K\_M, a single quantized model file, generates
|
||||
coherent text on Apple Metal --- ``The capital of France is \textbf{Paris}'' --- and, from the
|
||||
byte-identical file (SHA-256 verified) with the same sampler, \emph{collapses into repeated-token
|
||||
degeneration} on CUDA and Vulkan during long generation. Nothing about the model changed. What changed is
|
||||
the kernel: flash attention's accumulated numeric behavior on the CUDA/Vulkan tensor-core path differs
|
||||
from the Metal path, and only the former drifts into the repetition attractor. The same operation
|
||||
(attention) has three implementations and therefore three numeric behaviors, and one of them is wrong in
|
||||
a way no unit test on that backend alone would flag as ``wrong'' --- it produces finite, plausible
|
||||
numbers that steer generation into a ditch.
|
||||
|
||||
This is the defining bug class of a multi-backend engine, and it has three properties that make it
|
||||
brutal: it is \emph{silent} (no crash, no NaN), it is \emph{backend-specific} (passes on the box you
|
||||
tested), and it is \emph{emergent} (visible only in long generation, not in a single forward). You cannot
|
||||
test four copies of a kernel \emph{into} agreement; agreement has to be a structural property of having
|
||||
one definition.
|
||||
|
||||
\section{The idea: the CPU is the definition, not a backend}
|
||||
\label{sec:idea}
|
||||
|
||||
The law is one sentence: \textbf{the CPU runtime is the single bit-exact reference every backend is
|
||||
measured against.} The CPU is not ``another target that also happens to work.'' It is the \emph{oracle} ---
|
||||
the place where the operation's meaning is defined --- and every GPU backend is correct exactly insofar as
|
||||
it reproduces the oracle's bits.
|
||||
|
||||
Why can the CPU play this role, and why bit-\emph{exact} rather than merely close? Because a pointwise
|
||||
(Map) operation is \emph{index-local} --- output element $i$ depends only on inputs at index $i$ --- and
|
||||
the CPU runs it in program order. There is no reordering, no re-association, no fused-multiply-add
|
||||
surprise: the CPU result is the arithmetic the source literally specifies. A GPU backend running the same
|
||||
source computes the same index-local arithmetic and must agree \emph{to the bit}. The only sanctioned
|
||||
source of difference is a \textbf{reduction}: a \code{sum} over a row, which a GPU computes with a
|
||||
tree-reduction across threads and therefore in a different \emph{order} than the CPU's left fold. Float
|
||||
addition is not associative, so the reordered sum can differ in the last bits. That difference is
|
||||
\emph{bounded} (it is f32 re-association noise, and a small-$k$ control confirms it does not grow with the
|
||||
reduction length --- the signature of per-element rounding, not a decode defect) and \emph{explained}
|
||||
(it lives only at reductions, nowhere else). Everything that is index-local is bit-identical; everything
|
||||
that reduces is identical up to a characterized rounding. Nothing is unexplained.
|
||||
|
||||
\section{The mechanism: a mirror, held by test}
|
||||
\label{sec:mech}
|
||||
|
||||
The law is anchored by a discipline: for every op, the device code is a \emph{line-for-line mirror} of a
|
||||
plain-Rust reference. The fusion engine's device morphism table mirrors its host table; every island's
|
||||
\code{default} arm \emph{is} the CPU semantics (\cite{island}); every autotune variant is checked against
|
||||
the oracle (\cite{tune}). Listing~\ref{lst:mirror} shows the shape --- the device \code{apply\_un\_dev}
|
||||
and the host \code{apply\_un} are the same expressions --- and that identity is \emph{why} a fused kernel
|
||||
equals its reference bit-exactly rather than approximately.
|
||||
|
||||
\begin{lstlisting}[language=Rust,caption={The mirror. The device morphism table (left, a \code{\#[device]}
|
||||
fn) and the host reference (right, plain Rust) are the same arithmetic written twice. Bit-exactness is a
|
||||
consequence of the mirror, not a tolerance that happens to pass.},label={lst:mirror}]
|
||||
// device (#[device]) // host reference (plain Rust)
|
||||
UnOp::Silu => x * dsigmoid::<F>(x), UnOp::Silu => x / (1.0 + (-x).exp()),
|
||||
UnOp::Gelu => { let c = F::new(0.797_884_56); UnOp::Gelu => { const C: f32 = 0.797_884_56;
|
||||
let inner = c*(x + F::new(0.044715)*x*x*x); 0.5*x*(1.0 + (C*(x + 0.044715*x*x*x)).tanh()) }
|
||||
F::new(0.5)*x*(F::new(1.0)+inner.tanh()) }
|
||||
\end{lstlisting}
|
||||
|
||||
The oracle is enforced across the whole op library --- \code{rms\_norm}, \code{layer\_norm}, \code{rope}
|
||||
(both the NeoX half-split and GPT-J interleaved conventions), \code{sdpa} (online/flash softmax), the GDN
|
||||
\code{conv1d}/\code{gating}/\code{scan}, the quantized matvecs, and the fusion chains --- across 36 CPU
|
||||
tests. Each asserts the device result equals the plain-Rust reference within the characterized reduction
|
||||
tolerance (and, for index-local ops, to the bit).
|
||||
|
||||
\section{Enforcement: a nightly three-backend GPU CI}
|
||||
\label{sec:ci}
|
||||
|
||||
A law nobody checks is a hope. The oracle is enforced continuously by a GitHub Actions workflow that runs
|
||||
a three-backend matrix on \emph{self-hosted} boxes --- one runner label per backend
|
||||
(Listing~\ref{lst:ci}): \code{cuda} $\to$ spark (NVIDIA GB10, sm\_121), \code{rocm} $\to$ evo (AMD Strix
|
||||
Halo, gfx1151, ROCm 7.x), \code{metal} $\to$ dbc (Apple M4 Max). Each runner builds the engine for its
|
||||
backend and runs the same bit-exactness tests against the same CPU oracle.
|
||||
|
||||
\begin{lstlisting}[language=Rust,caption={The nightly matrix (\code{.github/workflows/gpu-tests.yml}). One
|
||||
self-hosted runner per backend; the same tests, the same oracle. Serial execution keeps a single GPU
|
||||
consumer at a time --- a necessity, not fastidiousness (\S\ref{sec:ci}).},label={lst:ci}]
|
||||
on: { workflow_dispatch: {}, schedule: [ { cron: '0 8 * * *' } ] } // nightly + on demand
|
||||
jobs:
|
||||
test:
|
||||
strategy: { matrix: { backend: [cuda, rocm, metal] } } // cuda->spark rocm->evo metal->dbc
|
||||
runs-on: [self-hosted, "${{ matrix.backend }}"]
|
||||
steps:
|
||||
- uses: ./.github/actions/gpu-guard // one GPU consumer at a time
|
||||
- run: cargo test -p hanzo-ml --features ${{ matrix.backend }} -- --test-threads 1
|
||||
\end{lstlisting}
|
||||
|
||||
Two operational details are load-bearing. The workflow runs \emph{nightly and on demand}, not per push,
|
||||
because these boxes double as development and benchmark machines. And it runs \code{--test-threads 1}
|
||||
behind a GPU-guard step because concurrent GPU initializations on the ROCm box race in the kernel-fusion
|
||||
driver (\code{kfd}) and wedge it until reboot --- so ``one GPU consumer at a time'' is a hard
|
||||
serialization requirement, enforced by both the guard and the concurrency group. The practical result is
|
||||
that ``the same op has the same numbers on every backend'' is a \emph{checked invariant}: a divergence
|
||||
introduced on any backend fails the next nightly run against the oracle.
|
||||
|
||||
\section{Evaluation: the same source, every runtime, bit-exact}
|
||||
\label{sec:eval}
|
||||
|
||||
Table~\ref{tab:bitexact} is the evidence that the law holds where it counts --- not merely that the DSL
|
||||
\emph{compiles} for each backend, but that it \emph{dispatches} through each backend's real runtime and
|
||||
matches the oracle. The ROCm figure is the falsifiable end-to-end proof that the toolchain seam to ROCm
|
||||
7.13 works: the same \code{rms\_norm} source, lowered through cubecl-hip and dispatched on gfx1151 (device
|
||||
query, module compile, launch, readback), matches the CPU oracle to $\text{max\_rel}=2.43\times10^{-7}$ ---
|
||||
well under the $2\times10^{-3}$ reduction-tolerance gate, i.e. pure f32-reorder noise.
|
||||
|
||||
\begin{table}[h]
|
||||
\centering\small
|
||||
\setlength{\tabcolsep}{5pt}
|
||||
\begin{tabular}{@{}lll@{}}
|
||||
\toprule
|
||||
\textbf{Path} & \textbf{Op(s)} & \textbf{Result} \\
|
||||
\midrule
|
||||
CPU oracle & rms/layer\_norm, rope$\times$2, sdpa, gdn, quant, fuse & 10/10 bit-exact (36 tests) \\
|
||||
DSL $\to$ Vulkan (gfx1151) & rms\_norm, softmax, matvec, sdpa & bit-exact, real dispatch \\
|
||||
DSL $\to$ Metal (M4 Max) & rms\_norm, layer\_norm, rope, sdpa & bit-exact ($2.35\mathrm{e}{-}7$) \\
|
||||
DSL $\to$ ROCm (gfx1151) & rms\_norm & $\text{max\_rel}=2.43\mathrm{e}{-}7$ \\
|
||||
\bottomrule
|
||||
\end{tabular}
|
||||
\caption{The same source, dispatched through each backend's real runtime, matches the CPU oracle. Values
|
||||
are reduction-order f32 noise, well under the $2\mathrm{e}{-}3$ gate.}
|
||||
\label{tab:bitexact}
|
||||
\end{table}
|
||||
|
||||
The last row of the story returns to \S\ref{sec:pain}. Because attention is written \emph{once} in the
|
||||
DSL --- one \code{sdpa} kernel with an online (flash-style) softmax, lowered to every backend --- the
|
||||
``flash vs.\ eager vs.\ Metal, three numeric behaviors'' fork \emph{cannot occur}. There is nothing to
|
||||
diverge. The repetition collapse is not a bug to fix on CUDA and Vulkan; under one implementation it is a
|
||||
state that is unreachable. That is the difference between testing four kernels toward agreement and
|
||||
having one definition: the first is endless, the second is structural.
|
||||
|
||||
\section{The takeaway}
|
||||
|
||||
A multi-backend engine must hold one \emph{numerically vetted} implementation per operation, because ``the
|
||||
same op, a different kernel per backend'' is a correctness hazard first and a performance question second.
|
||||
The CPU oracle is what makes ``numerically vetted'' concrete: the reference is the CPU's in-order
|
||||
arithmetic, the device code is its mirror, the only sanctioned difference is characterized
|
||||
reduction-order rounding, and a nightly three-backend CI turns the whole thing from a principle into a
|
||||
gate. Fusion (\cite{fuse}), autotuning (\cite{tune}), and islands (\cite{island}) all rest on this one
|
||||
foundation --- and the collapse method (\cite{method}) uses it as the bit-exact half of every
|
||||
verify-then-delete decision.
|
||||
|
||||
\begin{thebibliography}{9}
|
||||
\small
|
||||
\bibitem{umbrella} Hanzo AI. \emph{One Source, Every Backend: The hanzo-kernel DSL.} Paper 07 (umbrella).
|
||||
\bibitem{fuse} Hanzo AI. \emph{Fusion Is Composition: Kernel Fusion as the Functor Law.} Paper 07.1.
|
||||
\bibitem{tune} Hanzo AI. \emph{The Schedule Is a Value: Comptime-Specialized Autotuning.} Paper 07.2.
|
||||
\bibitem{island} Hanzo AI. \emph{Intrinsic Islands: An Oracle-Anchored Escape Hatch.} Paper 07.3.
|
||||
\bibitem{oracle} Hanzo AI. \emph{One Oracle, Every Backend: The CPU Bit-Exactness Law.} Paper 07.4 (this paper).
|
||||
\bibitem{mmq} Hanzo AI. \emph{When the Product Won't Collapse: An int8 Tensor-Core MMQ Negative Result.} Paper 07.5.
|
||||
\bibitem{method} Hanzo AI. \emph{Verify, Then Delete: Collapsing a Kernel Cartesian Product.} Paper 07.6.
|
||||
\end{thebibliography}
|
||||
|
||||
\end{document}
|
||||
Binary file not shown.
@@ -0,0 +1,185 @@
|
||||
\documentclass[11pt]{article}
|
||||
\input{shared/kernel-preamble.tex}
|
||||
|
||||
\title{\textbf{Verify, Then Delete:\\ A Method for Collapsing a Kernel Cartesian Product}}
|
||||
\author{Hanzo AI --- ML Systems}
|
||||
\date{hanzo-kernel Paper 07.6 --- part of the \emph{One Source, Every Backend} series}
|
||||
|
||||
\begin{document}
|
||||
\maketitle
|
||||
|
||||
\begin{abstract}
|
||||
The other papers in this series build the machinery to write a GPU kernel once and lower it to every
|
||||
backend. This paper is about the migration: how you drain $52{,}345$ lines of hand-written kernel
|
||||
arithmetic across five backend trees down toward a single source \emph{without} a big-bang rewrite and
|
||||
without ever shipping a slower engine. The method is a discipline we call \textbf{verify-then-delete}:
|
||||
never retire a hand-tuned kernel on faith, and never all at once. A hand-written kernel is deleted only
|
||||
when its single-source twin is (a) bit-exact against the shared CPU oracle \emph{and} (b) at least as fast
|
||||
on a kernel-only benchmark --- in practice $\ge 97\%$ of the incumbent's throughput. A
|
||||
per-\code{(op, type, backend)} registry flips one cell to the DSL the moment its twin passes both gates,
|
||||
so the product drains to a sum one measured cell at a time, with a working, faster-or-equal engine at
|
||||
every intermediate state. We give the ledger, the protocol, and the measured outcomes --- including a
|
||||
migration that was \emph{7.1$\times$ faster} than the shader it replaced and bit-exact, and, just as
|
||||
importantly, the migrations the gate correctly \emph{declined}.
|
||||
\end{abstract}
|
||||
|
||||
\section{The ledger: what there is to collapse}
|
||||
\label{sec:ledger}
|
||||
|
||||
Table~\ref{tab:ledger} is the denominator. It is the hand-written kernel arithmetic in the Hanzo ML and
|
||||
engine repositories: the same $\sim$20 operations, specialized over $\sim$22 GGUF quantization formats,
|
||||
re-implemented once per backend across five trees and five shading languages.
|
||||
|
||||
\begin{table}[h]
|
||||
\centering
|
||||
\begin{tabular}{llr}
|
||||
\toprule
|
||||
\textbf{Tree} & \textbf{Language} & \textbf{Lines} \\
|
||||
\midrule
|
||||
CUDA (ML) & CUDA C++ & 13{,}123 \\
|
||||
ROCm & HIP C++ & 6{,}725 \\
|
||||
Metal & Metal Shading Language & 3{,}530 \\
|
||||
Vulkan & GLSL $\rightarrow$ SPIR-V & 7{,}105 \\
|
||||
CUDA (engine) & CUDA C++ & 21{,}862 \\
|
||||
\midrule
|
||||
\textbf{Total} & 5 trees & \textbf{52{,}345} \\
|
||||
\bottomrule
|
||||
\end{tabular}
|
||||
\caption{The collapse ledger: hand-written, non-generated, non-vendored kernel arithmetic.\protect\footnotemark{}
|
||||
Roughly 85\% is a deletable target --- the duplicated arithmetic --- as opposed to the genuinely peak,
|
||||
genuinely per-backend kernels (block-quant tensor-core MMQ, \cite{mmq}) that stay behind islands.}
|
||||
\label{tab:ledger}
|
||||
\end{table}
|
||||
\footnotetext{These are the \emph{deletable} lines. A na\"ive \code{wc -l} over all
|
||||
\code{*.cu/*.cuh/*.hip/*.hpp/*.metal/*.comp} is roughly $2\x$ larger ($\sim$112k) because it also counts
|
||||
C++ headers, a \code{target/package/} build-artifact copy, machine-generated codebook tables (the i-quant
|
||||
grids), and vendored upstream kernels (FlashInfer, Marlin, GPTQ) --- none of which are hand-written
|
||||
duplication or a DSL collapse target.}
|
||||
|
||||
The product is not merely a maintenance cost; it is the breeding ground for the divergence bugs the oracle
|
||||
paper catalogs (\cite{oracle}). Draining it is therefore a correctness project as much as a code-size one.
|
||||
But it must be drained safely: a rewrite that regresses performance, or that lands as one enormous
|
||||
untestable change, is worse than the duplication it removes.
|
||||
|
||||
\section{The protocol: verify, then delete}
|
||||
\label{sec:protocol}
|
||||
|
||||
The method is three rules, applied per cell of the $\text{types}\times\text{backends}$ matrix.
|
||||
|
||||
\begin{enumerate}[leftmargin=1.6em,itemsep=2pt]
|
||||
\item \textbf{Coverage first.} The DSL fills the entire matrix from one source. Many cells were previously
|
||||
\emph{missing} or buggy; those gain correct coverage immediately, before any deletion --- a strict
|
||||
improvement with nothing to retire.
|
||||
\item \textbf{The two-part gate.} A hand-tuned kernel is retired for its single-source twin \emph{only}
|
||||
when the twin is both:
|
||||
\begin{itemize}[leftmargin=1.4em,itemsep=0pt]
|
||||
\item \textbf{bit-exact} against the shared CPU oracle (the correctness half; \cite{oracle}), and
|
||||
\item \textbf{at least as fast} on a kernel-only benchmark --- buffers created once, $N$ dispatches
|
||||
enqueued back-to-back, one final sync, so the number reflects the kernel and not the host round-trip. In
|
||||
practice the bar is $\ge 97\%$ of the incumbent's throughput.
|
||||
\end{itemize}
|
||||
The number decides, per quant type, per backend. Not a human's confidence --- the benchmark.
|
||||
\item \textbf{Delete on parity.} When a twin passes both gates, its hand-written copies are deleted and a
|
||||
per-\code{(op, type, backend)} registry flips that cell to the DSL, keyed by the gate result. Until it
|
||||
passes, the cell keeps its hand-tuned kernel. The registry is the seam that lets the product drain to a
|
||||
sum one measured cell at a time, with a working, faster-or-equal engine at every intermediate state.
|
||||
\end{enumerate}
|
||||
|
||||
The protocol is reversible and never a downgrade by construction. It is a scoreboard with a gate, not a
|
||||
rewrite.
|
||||
|
||||
\section{Measured migrations: the wins}
|
||||
\label{sec:wins}
|
||||
|
||||
The method is not hypothetical; cells have flipped on \code{main}. All figures below are kernel-only
|
||||
benchmarks on the named hardware.\footnote{Throughput and latency are session benchmarks on evo (AMD Strix
|
||||
Halo, gfx1151; RADV/Vulkan and ROCm 7.13). The bit-exactness each accompanies is enforced by the committed
|
||||
tests of the oracle paper (\cite{oracle}).}
|
||||
|
||||
\paragraph{Portability costs nothing (\dpa{} matvec).} The central fear of any portable DSL is a
|
||||
performance downgrade. It does not materialize on the bandwidth-bound matvec that dominates decode. A tuned
|
||||
single-source \dpa{} Q8 matvec --- \code{Vector<i8,4>}$\to$\code{OpSDot}, one thread block per output row
|
||||
with threads striding the groups so adjacent threads read adjacent packed weights (coalesced 256-byte
|
||||
transactions), partials tree-reduced in shared memory --- measures \textbf{196~GB/s} on evo ($8192^2$,
|
||||
cache-busting), which is \textbf{118\% of the $\sim$166~GB/s hand-tuned kernel} it replaces and $\sim$77\%
|
||||
of the DRAM roofline, bit-exact. The na\"ive one-thread-per-row version that started at $\sim$17~GB/s was
|
||||
memory-\emph{uncoalesced}, not DSL-limited; the fix was two standard steps --- coalescing and reduction ---
|
||||
both expressed in the DSL. The DSL reaches the same hardware integer-dot instruction and, with standard
|
||||
coalescing, \emph{beats} the hand-written code.
|
||||
|
||||
\paragraph{A migration that improved performance (\code{add\_rmsnorm}).} Two hand-written Vulkan kernels
|
||||
were drained on \code{main}: the DSL block-per-row \code{rms\_norm} (270~GB/s) and \code{softmax}
|
||||
(268~GB/s) replaced na\"ive \code{.comp} shaders, each now the production kernel with its \code{.comp}
|
||||
deleted. The sharpest case is the fused residual-add\,+\,RMSNorm: the DSL \code{add\_rmsnorm} twin ran
|
||||
\textbf{7.1$\times$ faster} than the incumbent shader ($166.5\to23.4$~ms at $4096^2$ on RADV) \emph{and}
|
||||
was bit-exact ($s=x{+}\text{res}$ byte-identical, $y$ within $4.8\times10^{-7}$). The ``uniformity''
|
||||
change was also a large speedup, because the incumbent was na\"ive and the DSL block kernel is coalesced.
|
||||
Table~\ref{tab:migr} collects the flipped cells.
|
||||
|
||||
\begin{table}[h]
|
||||
\centering\small
|
||||
\setlength{\tabcolsep}{6pt}
|
||||
\begin{tabular}{@{}llll@{}}
|
||||
\toprule
|
||||
\textbf{Kernel} & \textbf{Backend} & \textbf{Outcome} & \textbf{Correctness} \\
|
||||
\midrule
|
||||
\dpa{} Q8 matvec & Vulkan (gfx1151) & 196~GB/s, 118\% of hand-tuned & bit-exact \\
|
||||
\code{rms\_norm} & Vulkan (gfx1151) & 270~GB/s, \code{.comp} deleted & bit-exact \\
|
||||
\code{softmax} & Vulkan (gfx1151) & 268~GB/s, \code{.comp} deleted & bit-exact \\
|
||||
\code{add\_rmsnorm} & Vulkan (RADV) & 7.1$\times$ ($166.5\to23.4$~ms) & bit-exact \\
|
||||
\bottomrule
|
||||
\end{tabular}
|
||||
\caption{Cells flipped to the DSL on \code{main}. Every deletion cleared both gates: bit-exact against the
|
||||
oracle and at least as fast (here, faster). The engine is faster-or-equal at every step.}
|
||||
\label{tab:migr}
|
||||
\end{table}
|
||||
|
||||
\section{The discipline cuts both ways: the declines}
|
||||
\label{sec:declines}
|
||||
|
||||
A gate that never says no is not a gate. Where a hand-tuned kernel is \emph{already optimal} --- the
|
||||
Vulkan quant matvec, Metal's \code{simd\_sum} reductions, the fused \code{rope\_norm} epilogue --- a
|
||||
bit-exact DSL twin was built, measured, and \emph{correctly not swapped}. A uniformity-only change with
|
||||
zero speedup and real regression risk is churn, not migration. Such a twin is kept as the portable
|
||||
\emph{fallback} for backends that lack the tuned version, and the tuned peak stays the specialized fast
|
||||
path. ``One source'' is the goal, but the gate serves it only by also refusing the swaps that would trade
|
||||
speed for tidiness. We only ever add coverage and correctness.
|
||||
|
||||
\section{An engineering note: opening a backend so it can flip}
|
||||
\label{sec:rocm}
|
||||
|
||||
A cell cannot flip on a backend that does not build. Shipping the DSL on AMD required fixing two
|
||||
independent upstream defects \emph{without} advancing the lowering engine past its newest published
|
||||
release (no version escape exists for either). First, the HIP FFI bindings are selected from
|
||||
\code{hipconfig --version}; ROCm 7.13 reports a patch past the newest shipped binding, so the upstream
|
||||
build emitted a feature with no bindings module and every symbol went undefined. The fix is a vendored
|
||||
\code{[patch.crates-io]} fork whose \code{build.rs} clamps an unknown-newer patch to the latest shipped
|
||||
binding --- ABI-safe because AMD versions every changed symbol (R0600) and 7.13 still maps
|
||||
\code{hipGetDeviceProperties} to that revision. Second, the HIP server uses a \code{MultiStream} gated
|
||||
behind a \code{std}-only configuration the \code{hip}/\code{rocm} features do not pull, so the ROCm feature
|
||||
is declared to include \code{cubecl/std}. Both are recorded because a portable DSL lives or dies on the
|
||||
least-loved backend building cleanly --- and only a backend that builds can have its cells verified and
|
||||
its hand-written kernels deleted.
|
||||
|
||||
\section{The takeaway}
|
||||
|
||||
Collapsing a Cartesian product of kernels is a migration problem, and migrations fail when they are
|
||||
faith-based or big-bang. Verify-then-delete makes both failure modes structurally impossible: the CPU
|
||||
oracle supplies an objective correctness gate (\cite{oracle}), the kernel-only benchmark supplies an
|
||||
objective performance gate, and the per-cell registry supplies incrementality. The result is an engine
|
||||
that is faster-or-equal at every intermediate state while its five hand-written kernel trees drain toward
|
||||
one source --- and a boundary, honestly drawn, where one op (\cite{mmq}) stays hand-written behind an
|
||||
island. The method is repeatable: point it at the next cell, run both gates, let the number decide.
|
||||
|
||||
\begin{thebibliography}{9}
|
||||
\small
|
||||
\bibitem{umbrella} Hanzo AI. \emph{One Source, Every Backend: The hanzo-kernel DSL.} Paper 07 (umbrella).
|
||||
\bibitem{fuse} Hanzo AI. \emph{Fusion Is Composition: Kernel Fusion as the Functor Law.} Paper 07.1.
|
||||
\bibitem{tune} Hanzo AI. \emph{The Schedule Is a Value: Comptime-Specialized Autotuning.} Paper 07.2.
|
||||
\bibitem{island} Hanzo AI. \emph{Intrinsic Islands: An Oracle-Anchored Escape Hatch.} Paper 07.3.
|
||||
\bibitem{oracle} Hanzo AI. \emph{One Oracle, Every Backend: The CPU Bit-Exactness Law.} Paper 07.4.
|
||||
\bibitem{mmq} Hanzo AI. \emph{When the Product Won't Collapse: An int8 Tensor-Core MMQ Negative Result.} Paper 07.5.
|
||||
\bibitem{method} Hanzo AI. \emph{Verify, Then Delete: Collapsing a Kernel Cartesian Product.} Paper 07.6 (this paper).
|
||||
\end{thebibliography}
|
||||
|
||||
\end{document}
|
||||
Binary file not shown.
@@ -0,0 +1,204 @@
|
||||
\documentclass[11pt]{article}
|
||||
\input{shared/kernel-preamble.tex}
|
||||
|
||||
\title{\textbf{The Schedule Is a Value:\\ Comptime-Specialized Autotuning Without Forks}}
|
||||
\author{Hanzo AI --- ML Systems}
|
||||
\date{hanzo-kernel Paper 07.2 --- part of the \emph{One Source, Every Backend} series}
|
||||
|
||||
\begin{document}
|
||||
\maketitle
|
||||
|
||||
\begin{abstract}
|
||||
A single-source kernel DSL promises to kill the ``one operation, $N$ backends, $N$ numeric behaviors''
|
||||
bug class by writing each op once. But there is a subtler place the same bug hides: the \emph{schedule}
|
||||
--- the tile size, vector width, unroll factor, and thread-block dimension a kernel is tuned with, which
|
||||
differs per device. The usual fix is to fork a hand-tuned kernel per device, and a fork is exactly what
|
||||
reintroduces the bug: two schedules, drifting independently, one of them one day reordering an
|
||||
accumulation and changing the numbers. This paper shows how to autotune without forking. The schedule is
|
||||
exposed as a set of compile-time (\code{\#[comptime]}) knobs on the \emph{one} source; each knob value is
|
||||
a distinct compiled kernel the lowering engine monomorphizes; a small set of these variants is timed once
|
||||
per \code{(device, op, shape)} and the winner cached to disk. Because the knobs never touch the
|
||||
arithmetic, \emph{every variant is byte-identical} --- the tuner buys speed and can never spend
|
||||
correctness. We give the three-value algebra (\code{Variant}, \code{Tuned}, \code{Tuner}), the
|
||||
per-device on-disk cache, the two shipped variant tables, and the offline tests that prove
|
||||
select/cache/reload works without a GPU.
|
||||
\end{abstract}
|
||||
|
||||
\section{The pain: a fork that drifts}
|
||||
\label{sec:pain}
|
||||
|
||||
Suppose you have written a matvec kernel once, in a portable DSL, and it lowers correctly to every
|
||||
backend. You are not done, because ``correct everywhere'' is not ``fast everywhere.'' The fastest
|
||||
schedule for an AMD iGPU is not the fastest for an NVIDIA datacenter part: they differ in warp/subgroup
|
||||
width, in how much shared memory pays off, in the ideal number of output elements a thread should carry
|
||||
(the unroll factor), in the block size that fills the machine. The reflex is to fork --- keep a
|
||||
\code{matvec\_amd} and a \code{matvec\_nvidia}, each hand-tuned.
|
||||
|
||||
That reflex is the original sin the DSL exists to prevent, wearing a schedule's clothes. Two forks are
|
||||
two kernels, maintained by two hands, drifting apart. The day someone reorders a loop in one fork ``to
|
||||
help the unroll,'' its accumulation order changes, its rounding changes, and now the same operation
|
||||
produces different bits on two backends --- the precise failure a single-source DSL was supposed to make
|
||||
impossible (\cite{oracle}). Worse, the drift is invisible until a model degenerates on one device and not
|
||||
another.
|
||||
|
||||
We want the performance a per-device schedule buys \emph{without} the second kernel that buys the bug.
|
||||
|
||||
\section{The idea: a schedule is a compile-time value}
|
||||
\label{sec:idea}
|
||||
|
||||
The DSL already gives one source lowered to every backend. The remaining fork --- the schedule --- can be
|
||||
folded into that one source if we treat the schedule not as \emph{code} but as a \emph{value}. In \hk{},
|
||||
a kernel parameter marked \code{\#[comptime]} is resolved at compile time, and the lowering engine
|
||||
\emph{monomorphizes} the kernel on it: each distinct comptime value produces a distinct compiled kernel,
|
||||
specialized as if you had hand-written it. So we lift the schedule knobs to \code{\#[comptime]}
|
||||
parameters of the one kernel (Listing~\ref{lst:tunedkernel}). A ``variant'' is then not a forked file but
|
||||
a \emph{choice of comptime tuple}. There is one source; only the chosen tuple differs per device.
|
||||
|
||||
\begin{lstlisting}[language=Rust,caption={One autotuned source. The unroll factor \code{rpt}
|
||||
(rows-per-thread) is a comptime knob; the block size is the host's knob. Every \code{(rpt, block)} is a
|
||||
distinct compiled kernel yet bit-identical to the reference: the per-row fold is the same order, so only
|
||||
the thread$\to$row mapping and occupancy differ.},label={lst:tunedkernel}]
|
||||
#[kernel(targets(cuda, metal, vulkan, webgpu, cpu), unchecked)]
|
||||
pub fn rms_norm_tuned<F: Float>(x: &Array<F>, w: &Array<F>, out: &mut Array<F>,
|
||||
eps: &Array<F>, #[comptime] n: usize, #[comptime] rpt: usize) {
|
||||
let nrows = out.len() / n;
|
||||
let base = ABSOLUTE_POS * rpt;
|
||||
#[unroll]
|
||||
for r in 0..rpt {
|
||||
let row = base + r;
|
||||
if row < nrows {
|
||||
let b = row * n;
|
||||
let mut ss = F::new(0.0);
|
||||
for i in 0..n { let v = x[b + i]; ss += v * v; } // same fold, same order, every variant
|
||||
let denom = (ss / F::cast_from(n as u32) + eps[0]).sqrt();
|
||||
for i in 0..n { out[b + i] = x[b + i] / denom * w[i]; }
|
||||
}
|
||||
}
|
||||
}
|
||||
\end{lstlisting}
|
||||
|
||||
The critical invariant is stated in the comment and enforced by test: the knobs change the
|
||||
thread-to-work mapping and the occupancy, \emph{not} the arithmetic. The per-row sum-of-squares is the
|
||||
same left fold in the same order for every \code{(rpt, block)}. Therefore all variants are
|
||||
\textbf{byte-identical} to each other and to the reference. The autotuner is choosing among numerically
|
||||
indistinguishable kernels; it cannot pick a ``faster but slightly wrong'' one, because there is no wrong
|
||||
one to pick.
|
||||
|
||||
\section{The mechanism: three orthogonal values}
|
||||
\label{sec:mech}
|
||||
|
||||
Autotuning decomposes into three plain values, each testable in isolation (Listing~\ref{lst:tune}).
|
||||
|
||||
\begin{itemize}[leftmargin=1.4em,itemsep=2pt]
|
||||
\item A \textbf{\code{Variant}} is a named schedule: a closure \code{f(iters) -> (output, ms)} that
|
||||
launches the schedule \code{iters} times after a fixed warmup and returns its output and mean
|
||||
milliseconds per dispatch. The same closure both \emph{benchmarks} (many iters) and \emph{executes} (one
|
||||
iter) --- this is the crate's established \code{*\_bench} shape, so no separate ``run'' and ``time'' paths
|
||||
can disagree.
|
||||
\item A \textbf{\code{Tuned}} is the fluent builder: collect the variants for one
|
||||
\code{(op, shape-key)}, then \code{run}. It reads like the fusion builder --- fluent, no macro, no
|
||||
ceremony.
|
||||
\item A \textbf{\code{Tuner}} is the cache: an in-memory \code{(device, op, key) -> winner} map backed by
|
||||
a per-device on-disk file. It is a \emph{plain value} constructed over an explicit cache root, so it is
|
||||
fully unit-testable offline --- no GPU, no environment, no global state. The process-wide default roots
|
||||
at the XDG cache directory.
|
||||
\end{itemize}
|
||||
|
||||
\begin{lstlisting}[language=Rust,caption={The production surface. One source per op, autotuned per device,
|
||||
no forks. On a cache hit only the winner launches (once, to produce its output); the losers are never
|
||||
run.},label={lst:tune}]
|
||||
let y = Tuned::new("rms_norm", format!("rows={rows},n={n}"))
|
||||
.variant("b64_r1", |it| rms_norm_tuned_bench(&c, x, w, rows, n, eps, 1, 64, it))
|
||||
.variant("b128_r1", |it| rms_norm_tuned_bench(&c, x, w, rows, n, eps, 1, 128, it))
|
||||
.variant("b256_r1", |it| rms_norm_tuned_bench(&c, x, w, rows, n, eps, 1, 256, it))
|
||||
.variant("b64_r2", |it| rms_norm_tuned_bench(&c, x, w, rows, n, eps, 2, 64, it))
|
||||
.run(&c); // times each once per (device,op,shape), caches the winner, thereafter runs it
|
||||
\end{lstlisting}
|
||||
|
||||
The selection logic is deliberately small. On a cache \emph{miss}, every variant is timed
|
||||
\code{TUNE\_ITERS} times (25), the fastest is kept, its winning name is recorded, and its
|
||||
already-computed output is returned --- no redundant re-run. On a cache \emph{hit}, only the winner is
|
||||
launched, once, to produce its output; the losers are never launched. If a cached winner name is no
|
||||
longer among the current variants (the set changed), that is treated as a miss and re-tuned rather than
|
||||
trusting a stale name.
|
||||
|
||||
\paragraph{The cache is a value, not a database.} The on-disk format is one TSV file per device ---
|
||||
\code{\$XDG\_CACHE\_HOME/hanzo-kernel/autotune/<device>.tsv} --- each line \code{op\textbackslash tkey\textbackslash twinner}.
|
||||
No serde, no new dependency, no schema migration. Cross-process writes append and are idempotent (the
|
||||
loader keeps the last line for a key), which is exactly how an advisory autotune cache should behave: a
|
||||
hint that is safe to lose and safe to race on.
|
||||
|
||||
\section{Evaluation: the variant tables and the offline proof}
|
||||
\label{sec:eval}
|
||||
|
||||
Two ops ship autotuned (Table~\ref{tab:variants}). Both variant sets are proven on the CPU runtime, which
|
||||
needs no GPU to establish the two things under test: that the variants are numerically indistinguishable,
|
||||
and that the select/cache machinery works.
|
||||
|
||||
\begin{table}[h]
|
||||
\centering\small
|
||||
\setlength{\tabcolsep}{5pt}
|
||||
\begin{tabular}{@{}lll@{}}
|
||||
\toprule
|
||||
\textbf{Op} & \textbf{Knobs} & \textbf{Variants (all byte-identical)} \\
|
||||
\midrule
|
||||
\code{rms\_norm} & cube dim $\{64,128,256\}\times$ unroll $\{1,2\}$ & \code{b64\_r1 b128\_r1 b256\_r1 b64\_r2} \\
|
||||
\code{matvec\_q8\_dp4a} & cube dim $\{64,128,256\}\times$ vec.\ width $\{1,2,4,8\}$ & \code{b64\_v1 b64\_v4 b128\_v2 b256\_v8} \\
|
||||
\bottomrule
|
||||
\end{tabular}
|
||||
\caption{The autotune variant sets (\hk{} 0.2.17). A knob value is a distinct compiled kernel; the tuner
|
||||
caches the winner per \code{(device, op, shape)}. For \dpa{} matvec the vector-width knob is the ILP
|
||||
factor (groups per unrolled step); the per-group int8 dot is accumulated in the same order for every
|
||||
width, so all four are byte-identical.}
|
||||
\label{tab:variants}
|
||||
\end{table}
|
||||
|
||||
The committed tests assert, for both ops:
|
||||
\begin{enumerate}[leftmargin=1.5em,itemsep=1pt]
|
||||
\item \textbf{Byte-identity.} Every schedule's output has identical bits to the reference (RMSNorm:
|
||||
exactly equal; \dpa{}: byte-identical across variants and within f32-reorder tolerance
|
||||
$2\times10^{-3}$ of the per-element oracle). The knobs do not touch the numerics.
|
||||
\item \textbf{Select and cache.} A first call is a miss that benchmarks all four variants
|
||||
(\code{benched == 4}) and records the winner. A second call is a hit that benchmarks \emph{zero}
|
||||
(\code{benched == 0}) and returns the winner's output. A fresh \code{Tuner} over the same directory
|
||||
reloads the winner \emph{from disk} and skips timing. A distinct shape-key is an independent decision. A
|
||||
shrunk variant set (winner absent) re-tunes rather than trusting a stale name.
|
||||
\end{enumerate}
|
||||
Timing on the CPU proves the select/cache mechanism; a GPU run additionally makes the \emph{winner}
|
||||
meaningful, since on the CPU the schedules are near-equivalent and the chosen winner is
|
||||
arbitrary-but-stable. The mechanism, not the specific winner, is what the offline tests pin down.
|
||||
|
||||
\section{Why first-party, not the engine's autotuner}
|
||||
|
||||
The lowering engine (CubeCL) ships an autotuner; it was studied and declined for three concrete reasons,
|
||||
not out of not-invented-here. First, it is re-exported only from \code{cubecl\_runtime}, so adopting it
|
||||
would force a \emph{second} engine crate into a facade architected so that only one line of one
|
||||
\code{Cargo.toml} names the engine at all --- a deliberate ``values, not places'' boundary the whole DSL
|
||||
is built to preserve. Second, its persistent cache lives under \code{target/} (via an MD5 checksum and
|
||||
serde), not the XDG path this layer is required to use. Third, its generic-associated-type input
|
||||
machinery is a large surface for what is, in the end, ``pick the fastest of $N$ named closures.'' The
|
||||
concepts above --- variant, tuned set, cache keyed by device and shape --- are \emph{deliberately} the
|
||||
same as the engine's; they are sized to this crate and pointed at XDG. A thin analog, not a new
|
||||
philosophy.
|
||||
|
||||
\section{The takeaway}
|
||||
|
||||
The schedule was the last place a backend forked a kernel, and the fork was the last hiding place for the
|
||||
divergence bug. Making the schedule a compile-time value folds that fork into the one source: the tuner
|
||||
enumerates numerically-identical variants, times them per device, and caches the winner, so the engine is
|
||||
autotuned and single-source at once. Speed is chosen; correctness is never spent. Combined with fusion as
|
||||
composition (\cite{fuse}) and the oracle law (\cite{oracle}), the schedule stops being a place where two
|
||||
implementations can disagree, because there is only ever one.
|
||||
|
||||
\begin{thebibliography}{9}
|
||||
\small
|
||||
\bibitem{umbrella} Hanzo AI. \emph{One Source, Every Backend: The hanzo-kernel DSL.} Paper 07 (umbrella).
|
||||
\bibitem{fuse} Hanzo AI. \emph{Fusion Is Composition: Kernel Fusion as the Functor Law.} Paper 07.1.
|
||||
\bibitem{tune} Hanzo AI. \emph{The Schedule Is a Value: Comptime-Specialized Autotuning.} Paper 07.2 (this paper).
|
||||
\bibitem{island} Hanzo AI. \emph{Intrinsic Islands: An Oracle-Anchored Escape Hatch.} Paper 07.3.
|
||||
\bibitem{oracle} Hanzo AI. \emph{One Oracle, Every Backend: The CPU Bit-Exactness Law.} Paper 07.4.
|
||||
\bibitem{mmq} Hanzo AI. \emph{When the Product Won't Collapse: An int8 Tensor-Core MMQ Negative Result.} Paper 07.5.
|
||||
\bibitem{method} Hanzo AI. \emph{Verify, Then Delete: Collapsing a Kernel Cartesian Product.} Paper 07.6.
|
||||
\end{thebibliography}
|
||||
|
||||
\end{document}
|
||||
Binary file not shown.
@@ -0,0 +1,283 @@
|
||||
\documentclass[11pt]{article}
|
||||
\input{shared/kernel-preamble.tex}
|
||||
|
||||
\title{\textbf{Fusion Is Composition:\\ Kernel Fusion as the Functor Law}}
|
||||
\author{Hanzo AI --- ML Systems}
|
||||
\date{hanzo-kernel Paper 07.1 --- concept core of the \emph{One Source, Every Backend} series}
|
||||
|
||||
\begin{document}
|
||||
\maketitle
|
||||
|
||||
\begin{abstract}
|
||||
Kernel fusion is usually presented as a compiler optimization: a pass that inspects a graph and, when
|
||||
a cost model approves, merges adjacent operations to avoid writing an intermediate array to memory. This
|
||||
paper takes the opposite stance --- fusion is not a pass, it is an \emph{algebraic identity}. An
|
||||
elementwise GPU kernel is the lift of a scalar function to every array index, and such lifts obey the
|
||||
functor law $\mathrm{map}(g)\circ\mathrm{map}(f)=\mathrm{map}(g\circ f)$. Read right-to-left, that law
|
||||
\emph{is} fusion: one kernel computing the composed function, no intermediate. Reduce and Scan are the
|
||||
points where the law stops applying --- they are algebraic \emph{fences}, decidable from the operation's
|
||||
class, not from a pattern matcher. We show how this single idea becomes a working, one-launch fusion
|
||||
engine in \hk{}: a tiny op algebra whose fusibility rule is a total function of op class; one generic
|
||||
kernel that interprets any composed chain as straight-line arithmetic; and the same algebra lifted from a
|
||||
line to an arbitrary DAG. Every claim is gated bit-exactly on a CPU reference. The concrete payoff is
|
||||
measured on a real model pattern --- the per-layer Gated-DeltaNet chain fuses from 9 kernel launches to
|
||||
5, byte-for-byte identical to the unfused path.
|
||||
\end{abstract}
|
||||
|
||||
\section{The pain: an intermediate nobody wanted}
|
||||
\label{sec:pain}
|
||||
|
||||
Consider the tail of a Transformer feed-forward block, after its two matmuls have produced two arrays
|
||||
\code{gate} and \code{up}, each of length $n$. The SwiGLU activation is
|
||||
\[
|
||||
y \;=\; \mathrm{silu}(\code{gate}) \cdot \code{up}, \qquad \mathrm{silu}(x)=x\cdot\sigma(x).
|
||||
\]
|
||||
A \emph{kernel} is a function the GPU runs once per array element in parallel; a kernel \emph{launch} is
|
||||
one dispatch of that function over the whole array. The obvious way to compute $y$ is two kernels: one
|
||||
computes $t=\mathrm{silu}(\code{gate})$, writing $n$ floats to device memory (DRAM); a second reads $t$
|
||||
and \code{up} back from DRAM and writes $y$. That middle array $t$ is \emph{materialized} --- written to
|
||||
DRAM and immediately read back --- for no reason but that we wrote two kernels. On a decode step, where
|
||||
the whole workload is memory-bound (you are limited by how fast you can move bytes, not by arithmetic),
|
||||
that round-trip is pure waste.
|
||||
|
||||
Now scale the pattern. Qwen3.5 and Qwen3.6 are hybrid models whose bulk layers are Gated-DeltaNet
|
||||
(GDN) linear attention, and each GDN layer emits a short elementwise chain ---
|
||||
$\mathrm{rms\_norm}(x)\cdot w\cdot\mathrm{silu}(g)$ and its siblings --- as a \emph{storm} of tiny kernel
|
||||
launches: roughly \textbf{9 launches per layer} on the ops-composed CUDA path. Each launch reads its
|
||||
inputs from DRAM, does a trivial amount of arithmetic, and writes its output back to DRAM for the next
|
||||
launch to read. Multiply by dozens of layers and every decoded token pays for a parade of intermediates.
|
||||
This is not a corner case; it is a measurable part of why an ops-composed engine trails a hand-fused one.
|
||||
|
||||
The question this paper answers is: \emph{when may two adjacent kernels be merged into one, and how do
|
||||
you do it once, correctly, for every backend?} The usual answer is ``run a fusion pass with a pattern
|
||||
matcher and a cost model.'' Ours is simpler and total.
|
||||
|
||||
\section{The idea: an elementwise kernel is a lifted function}
|
||||
\label{sec:idea}
|
||||
|
||||
Strip a pointwise (elementwise) kernel to its essence and it is a scalar function $f:F\to F$ applied
|
||||
independently at every index $i$:
|
||||
\[
|
||||
\mathrm{map}(f)\,:\;\text{Array}\to\text{Array},\qquad \mathrm{map}(f)(A)[i]=f(A[i]).
|
||||
\]
|
||||
The word \emph{map} is exact: it is the same map a Rust programmer writes as \code{v.iter().map(f)}. Two
|
||||
such kernels in sequence apply $f$, store the result, then apply $g$. But applying $g$ after $f$ at every
|
||||
index is, by definition, applying $g\circ f$ at every index. That is the \textbf{functor law}:
|
||||
\[
|
||||
\boxed{\;\mathrm{map}(g)\circ\mathrm{map}(f)\;=\;\mathrm{map}(g\circ f)\;}
|
||||
\]
|
||||
Read left-to-right, the equation is two kernels with a materialized array between them. Read
|
||||
right-to-left, it is \emph{one} kernel computing the composed function $g\circ f$ with \emph{no}
|
||||
intermediate. \textbf{Kernel fusion is nothing but that law, read right-to-left.}
|
||||
|
||||
If this feels familiar it is the transducer insight in disguise: \code{map(f)} and \code{map(g)} are
|
||||
transformations that do not mention the collection they run over, so you may compose the
|
||||
\emph{transformations} and apply the composite to the collection \emph{once}, never allocating the
|
||||
intermediate collection. Here the collection is a GPU array and ``once'' is a single kernel launch.
|
||||
Categorically, each Map is a morphism $\text{Array}\to\text{Array}$ and \emph{composition of morphisms is
|
||||
fusion}. There is no separate ``fusion pass'' to invent; there is only the associativity of $\circ$.
|
||||
|
||||
\section{The class decides fusibility}
|
||||
\label{sec:class}
|
||||
|
||||
Not every op is a Map, and the ones that are not are exactly the ones that cannot be fused this way. The
|
||||
whole surface is two classes:
|
||||
|
||||
\begin{itemize}[leftmargin=1.4em,itemsep=2pt]
|
||||
\item \textbf{Map} (a pointwise morphism): output element $i$ depends only on input(s) at index $i$
|
||||
(plus compile-time constants). It is shape-preserving and \emph{index-local}. Maps compose freely: any
|
||||
run of adjacent Maps is one Map, by the functor law. This is the fusible family ---
|
||||
\code{Add Sub Mul Div Neg Recip Abs Exp Rsqrt Sigmoid Tanh Silu Gelu}. (\code{Silu} and \code{Gelu} are
|
||||
not special cases: they are \emph{defined} as compositions of the primitives, so they are already one
|
||||
composed morphism.)
|
||||
\item \textbf{Reduce / Scan} (a contraction): output element $i$ depends on a \emph{whole row or axis}
|
||||
--- \code{sum}, \code{max}, \code{mean}, and the operations built on them (RMSNorm, softmax, matmul).
|
||||
This is \emph{not} index-local. It cannot be absorbed into an adjacent Map by the same lift, because it
|
||||
needs cross-lane communication: threads must exchange partial results (through shared memory or a
|
||||
subgroup reduction) to compute one output. A Reduce is therefore a \textbf{fence}.
|
||||
\end{itemize}
|
||||
|
||||
The decision ``can these two adjacent ops fuse?'' is then a total function of their class --- no pattern
|
||||
matcher, no cost model:
|
||||
\[
|
||||
\mathrm{fusible}(a,b)\;:=\;\mathrm{is\_map}(a)\wedge\mathrm{is\_map}(b).
|
||||
\]
|
||||
At a fence there is still fusion, just attached at the fence's \emph{ports}: a Map run \emph{feeding} the
|
||||
reduction fuses into its input read (a \emph{prologue}), and a Map run \emph{consuming} the reduction's
|
||||
result fuses into its output write (an \emph{epilogue}). The reduction kernel itself is the seam where
|
||||
the pattern changes. RMSNorm is the canonical shape: \code{square}(Map, prologue) $\to$ \code{sum}(fence)
|
||||
$\to$ \code{scale-by-rsqrt}(Map, epilogue) --- three regions, the two Map runs each collapsed to one.
|
||||
|
||||
\section{The mechanism: reify, fold, interpret}
|
||||
\label{sec:mech}
|
||||
|
||||
Three small steps turn the algebra into a running engine. All of it lives in one crate module and is
|
||||
proven on a CPU reference (\S\ref{sec:eval}).
|
||||
|
||||
\paragraph{Reify.} A pointwise expression is recorded as a tree of scalar morphisms rather than computed
|
||||
(Listing~\ref{lst:expr}). \code{Cur} is the value flowing down the pipeline at the current element;
|
||||
\code{In(id)} is a side-input array read at the current index; \code{Const} is a compile-time scalar.
|
||||
Operator overloading makes building one read like the math it denotes. Building an \code{Expr} computes
|
||||
nothing --- it records the trace.
|
||||
|
||||
\begin{lstlisting}[language=Rust,caption={The op algebra. The whole fusible surface is a tree of unary
|
||||
and binary pointwise morphisms over the current value, side inputs, and constants.},label={lst:expr}]
|
||||
enum Expr { Cur, In(usize), Const(f32), Un(UnOp, Box<Expr>), Bin(BinOp, Box<Expr>, Box<Expr>) }
|
||||
|
||||
// Morphism composition: substitute `inner` for every `Cur` leaf. This is the ONLY operation
|
||||
// the fusion pass needs -- map(g) . map(f) becomes map(g[Cur := f]).
|
||||
fn compose_cur(&self, inner: &Expr) -> Expr {
|
||||
match self {
|
||||
Expr::Cur => inner.clone(),
|
||||
Expr::Un(op, a) => Expr::Un(*op, Box::new(a.compose_cur(inner))),
|
||||
Expr::Bin(op,a,b)=> Expr::Bin(*op, Box::new(a.compose_cur(inner)),
|
||||
Box::new(b.compose_cur(inner))),
|
||||
leaf => leaf.clone(),
|
||||
}
|
||||
}
|
||||
\end{lstlisting}
|
||||
|
||||
\paragraph{Fold.} The fusion pass is one fold over the chain of steps: accumulate adjacent Maps by
|
||||
composing their expressions (\code{compose\_cur} \emph{is} $g[\code{Cur}:=f]$), and cut a new region at
|
||||
every Reduce. That is the entire pass (Listing~\ref{lst:fold}). Its output is a list of regions; the
|
||||
number of regions is the number of kernels, and it is strictly fewer than the number of steps whenever
|
||||
any two Maps were adjacent --- that gap \emph{is} the fusion.
|
||||
|
||||
\begin{lstlisting}[language=Rust,caption={The fusion pass: fold adjacent Maps by composing their
|
||||
expressions; cut at every Reduce. No matcher, no cost model --- just the class.},label={lst:fold}]
|
||||
fn fuse(&self) -> Vec<Region> {
|
||||
let (mut regions, mut acc) = (Vec::new(), None);
|
||||
for step in &self.steps {
|
||||
match step {
|
||||
Step::Map(e) => acc = Some(match acc.take() { // g after f ==> g[Cur := f]
|
||||
None => e.clone(), Some(prev) => e.compose_cur(&prev),
|
||||
}),
|
||||
Step::Reduce(r) => { if let Some(e) = acc.take() { regions.push(Region::Map(e)); }
|
||||
regions.push(Region::Reduce(*r)); }
|
||||
}
|
||||
}
|
||||
if let Some(e) = acc.take() { regions.push(Region::Map(e)); }
|
||||
regions
|
||||
}
|
||||
\end{lstlisting}
|
||||
|
||||
\paragraph{Interpret.} A fused Map region is lowered to a small linear ``op program'' carried as a
|
||||
compile-time value, and \emph{one} generic kernel walks that program in a compile-time-\emph{unrolled}
|
||||
loop (Listing~\ref{lst:interp}). Because the loop and the branch are resolved at compile time (the DSL's
|
||||
lowering engine monomorphizes on the program value), each instruction becomes straight-line arithmetic
|
||||
in one kernel body: the input is read once, threaded through every op, and written once. There are no
|
||||
intermediate arrays and exactly one launch, whatever the chain length. This is not a hand-written fixed
|
||||
kernel --- any linear elementwise chain runs through this one \code{fused\_interp}.
|
||||
|
||||
\begin{lstlisting}[language=Rust,caption={The one fused kernel. \code{prog} rides as a compile-time
|
||||
value, so \code{\#[unroll]} plus a comptime \code{match} lower the whole chain to straight-line code:
|
||||
one launch, zero intermediates.},label={lst:interp}]
|
||||
#[kernel(targets(cuda, metal, vulkan, webgpu, cpu), unchecked)]
|
||||
pub fn fused_interp<F: Float>(x: &Array<F>, sides: &Sequence<Array<F>>,
|
||||
out: &mut Array<F>, #[comptime] prog: Program) {
|
||||
let i = ABSOLUTE_POS;
|
||||
if i < out.len() {
|
||||
let mut acc = x[i];
|
||||
#[unroll]
|
||||
for step in 0..prog.instrs.len() {
|
||||
match comptime!(prog.instrs[step]) {
|
||||
Instr::Un(op) => acc = apply_un_dev::<F>(op, acc),
|
||||
Instr::BinIn(op, slot) => acc = apply_bin_dev::<F>(op, acc, sides.index(slot)[i]),
|
||||
Instr::BinConst(op, b) => { let c = comptime!(f32::from_bits(b));
|
||||
acc = apply_bin_dev::<F>(op, acc, F::new(c)) }
|
||||
}
|
||||
}
|
||||
out[i] = acc;
|
||||
}
|
||||
}
|
||||
\end{lstlisting}
|
||||
|
||||
The ergonomic surface is a builder that reads like the composition it denotes:
|
||||
\code{Fuse::new(a).\allowbreak{}mul(w).\allowbreak{}add(b).\allowbreak{}silu().\allowbreak{}run(c)}
|
||||
appends one instruction per call, compiles them to one program, and dispatches exactly one
|
||||
\code{fused\_interp}. The constant leaf is stored as \code{f32::to\_bits} so the program is
|
||||
\code{Eq + Hash} --- the lowering engine keys one compiled kernel per distinct fused chain.
|
||||
|
||||
\section{From a line to a DAG}
|
||||
\label{sec:dag}
|
||||
|
||||
A real forward pass is not a straight line. A value \emph{fans out} (is consumed by several later ops)
|
||||
and a binary op \emph{fans in} (reads two independently computed values). The expression
|
||||
$\mathrm{silu}(a)\cdot b + a$ reuses $a$ twice; a linear chain cannot express it in one region, but a
|
||||
DAG can, and it still fuses to \emph{one} kernel. The same algebra lifts from a path to a graph with no
|
||||
new ideas:
|
||||
\begin{enumerate}[leftmargin=1.5em,itemsep=1pt]
|
||||
\item \textbf{Trace.} A \code{NodeId}-taping newtype records an arbitrary pointwise DAG as a
|
||||
\code{Vec<Node>} (operator overloads push nodes instead of computing). The node algebra is the \emph{same}
|
||||
\code{UnOp}/\code{BinOp}/\code{Red} as the line case --- one IR, reused.
|
||||
\item \textbf{Partition.} Flood-fill maximal Map-only connected regions; cut every edge into a fence
|
||||
(a Reduce, or any non-elementwise op --- matmul, gather, conv). This is the identical \code{Class}
|
||||
predicate from \S\ref{sec:class}, applied to graph edges instead of list-adjacency.
|
||||
\item \textbf{Lower.} Each Map region compiles to a small DAG program over a per-element \emph{slot}
|
||||
array, and one generic \code{dag\_interp} kernel interprets it. Fan-out is a slot read by several later
|
||||
instructions; fan-in is a binary reading two slots. This generalizes \code{fused\_interp}'s single
|
||||
accumulator (a line) to $n$ accumulators (a graph): one launch, zero intermediates, any DAG shape.
|
||||
\end{enumerate}
|
||||
|
||||
\section{Evaluation: the bit-exact gate}
|
||||
\label{sec:eval}
|
||||
|
||||
Fusion must not change the numbers. Every fused chain is checked three ways on the CPU runtime, which
|
||||
executes a Map in program order and is therefore the definitional reference (the companion oracle paper,
|
||||
\cite{oracle}, makes this law and its cross-backend enforcement precise):
|
||||
\begin{enumerate}[leftmargin=1.5em,itemsep=1pt]
|
||||
\item the fused \emph{one} kernel,
|
||||
\item the na\"ive \emph{$N$}-kernel pipeline that materializes $N{-}1$ device intermediates,
|
||||
\item a plain-Rust reference.
|
||||
\end{enumerate}
|
||||
The device morphism table (\code{apply\_un\_dev}) is a line-for-line mirror of the host table
|
||||
(\code{apply\_un}) --- which is \emph{why} fused equals reference bit-exactly, not merely within
|
||||
tolerance. The committed tests exercise the two patterns the llama.cpp fusion audit named plus a stress
|
||||
case (Table~\ref{tab:fuse}). The SwiGLU tail and the 3-op chain are \emph{byte-identical} fused-versus-
|
||||
na\"ive; a 7-op mixed chain runs in one launch and matches its host oracle. And on the real engine
|
||||
pattern that motivated \S\ref{sec:pain}, the DAG path fuses the per-GDN-layer chain
|
||||
$\mathrm{rms\_norm}(x)\cdot w\cdot\mathrm{silu}(g)$ from \textbf{9 launches to 5}, bit-exact
|
||||
($\text{max}=2.98\times10^{-8}$) against the unfused per-op path --- draining the launch storm directly.
|
||||
|
||||
\begin{table}[h]
|
||||
\centering\small
|
||||
\setlength{\tabcolsep}{6pt}
|
||||
\begin{tabular}{@{}lll@{}}
|
||||
\toprule
|
||||
\textbf{Chain} & \textbf{Launches (na\"ive $\to$ fused)} & \textbf{Result vs na\"ive / reference} \\
|
||||
\midrule
|
||||
$\mathrm{silu}(a)\cdot b$ (SwiGLU tail) & $2 \to 1$ & byte-identical / $\le 4.8\mathrm{e}{-}7$ \\
|
||||
$\mathrm{silu}(a\cdot w + b)$ & $3 \to 1$ & byte-identical / $\le 4.8\mathrm{e}{-}7$ \\
|
||||
7-op mixed chain (all families) & $7 \to 1$ & matches host oracle \\
|
||||
$\mathrm{rms\_norm}(x)\cdot w\cdot\mathrm{silu}(g)$ (GDN, DAG) & $9 \to 5$ & bit-exact ($2.98\mathrm{e}{-}8$) \\
|
||||
\bottomrule
|
||||
\end{tabular}
|
||||
\caption{The fusion gate. Fused one-kernel $=$ na\"ive $N$-kernel $=$ plain-Rust reference on the CPU
|
||||
runtime. The launch-count drop is the fusion; the byte-identity is the guarantee that it changed nothing
|
||||
but the launch count.}
|
||||
\label{tab:fuse}
|
||||
\end{table}
|
||||
|
||||
\section{Why this is the right base}
|
||||
|
||||
Fusibility here is a property of the operation's \emph{type}, computed once, not a fact a matcher
|
||||
rediscovers for each new op combination. Adding an op adds one algebra variant and its device mirror; it
|
||||
adds \emph{zero} fusion rules. And the fusion composes \emph{with} the peak kernels rather than competing
|
||||
with them: the hand-tuned reduction and matmul kernels stay, and the Map regions attach to them as
|
||||
read-prologue and write-epilogue at the fence. That is the whole point of making Reduce a first-class
|
||||
fence rather than something a fusion pass must be taught to stop at. The elementwise fabric of an entire
|
||||
model --- the SwiGLU tails, the norm epilogues, the GDN gating --- collapses to a minimal set of kernels
|
||||
by one identity, and every step is proven to change only the launch count, never the result.
|
||||
|
||||
\begin{thebibliography}{9}
|
||||
\small
|
||||
\bibitem{umbrella} Hanzo AI. \emph{One Source, Every Backend: The hanzo-kernel DSL.} Paper 07 (umbrella).
|
||||
\bibitem{fuse} Hanzo AI. \emph{Fusion Is Composition: Kernel Fusion as the Functor Law.} Paper 07.1 (this paper).
|
||||
\bibitem{tune} Hanzo AI. \emph{The Schedule Is a Value: Comptime-Specialized Autotuning.} Paper 07.2.
|
||||
\bibitem{island} Hanzo AI. \emph{Intrinsic Islands: An Oracle-Anchored Escape Hatch.} Paper 07.3.
|
||||
\bibitem{oracle} Hanzo AI. \emph{One Oracle, Every Backend: The CPU Bit-Exactness Law.} Paper 07.4.
|
||||
\bibitem{mmq} Hanzo AI. \emph{When the Product Won't Collapse: An int8 Tensor-Core MMQ Negative Result.} Paper 07.5.
|
||||
\bibitem{method} Hanzo AI. \emph{Verify, Then Delete: Collapsing a Kernel Cartesian Product.} Paper 07.6.
|
||||
\end{thebibliography}
|
||||
|
||||
\end{document}
|
||||
Binary file not shown.
@@ -0,0 +1,139 @@
|
||||
\documentclass[11pt]{article}
|
||||
\input{shared/kernel-preamble.tex}
|
||||
|
||||
\title{\textbf{When the Product Won't Collapse:\\ A Negative Result on int8 Tensor-Core MMQ}}
|
||||
\author{Hanzo AI --- ML Systems}
|
||||
\date{hanzo-kernel Paper 07.5 --- part of the \emph{One Source, Every Backend} series}
|
||||
|
||||
\begin{document}
|
||||
\maketitle
|
||||
|
||||
\begin{abstract}
|
||||
A single-source kernel DSL collapses the backends$\times$ops$\times$types product for the decode matvecs,
|
||||
norms, RoPE, attention, and the whole elementwise fabric of a model. This paper reports, in full, the one
|
||||
operation that resists: block-quantized integer matrix--matrix multiply on the tensor cores (MMQ), the
|
||||
kernel that governs prefill throughput. The result is negative and structural, and we report it as such
|
||||
because it is more useful than a success story. Through the portable high-level WMMA (cooperative-matrix)
|
||||
API, MMQ is capped at roughly one-tenth of the hand-tuned kernel --- not for want of tuning, but because
|
||||
the API's accumulator fragment is \emph{opaque}, forcing the per-32-block quantization scale to be applied
|
||||
via a store$\to$synchronize$\to$reload round-trip through shared memory once per block. The proof that
|
||||
this serialization is the wall, not occupancy, is that a \emph{better-tiled} version is \emph{slower}. The
|
||||
higher-ceiling route --- manual \code{mma.sync} with an exposed fragment-to-lane map, so the scale is
|
||||
applied in register --- is viable but per-backend by nature (the DSL's int8 matrix-core intrinsic is
|
||||
CUDA-only), so it does not collapse the product; it writes one more backend-specific kernel, in Rust.
|
||||
The honest conclusion: MMQ stays hand-written behind benchmark gates, held by an intrinsic island.
|
||||
\end{abstract}
|
||||
|
||||
\section{The target: the prefill gap is an MMQ gap}
|
||||
\label{sec:target}
|
||||
|
||||
Decode (generating one token at a time) is memory-bound and, on this stack, at parity with the hand-tuned
|
||||
baseline on every backend. Prefill (processing a prompt) is compute-bound, and there the DSL-composed
|
||||
engine trails llama.cpp by a consistent margin (dense $0.6$--$0.9\x$). That gap has a single name:
|
||||
tensor-core MMQ. llama.cpp quantizes the activation to \code{q8\_1} (int8) and contracts block-quantized
|
||||
weights (Q8\_0, Q4\_K) against it on the hardware \emph{matrix cores} --- the units that do a small
|
||||
matrix multiply as one instruction. Closing prefill means expressing that kernel well. The question this
|
||||
paper answers is whether it can be expressed \emph{once}, in the DSL, and lowered to the matrix cores of
|
||||
every backend --- the same collapse the rest of the series achieves for other ops.
|
||||
|
||||
\section{The obstruction: an opaque accumulator}
|
||||
\label{sec:obstruction}
|
||||
|
||||
The DSL exposes two routes to the int8 matrix cores, both registered by the lowering engine on NVIDIA:
|
||||
\begin{itemize}[leftmargin=1.4em,itemsep=2pt]
|
||||
\item \textbf{High-level WMMA} (\code{cmma::Matrix}, i8$\to$i32 at $16{\times}16{\times}16$): fragments are
|
||||
loaded and stored automatically, one tile at a time. Ergonomic and portable --- but the fragment layout
|
||||
is \emph{opaque} to the programmer.
|
||||
\item \textbf{Manual MMA} (\code{cmma::MmaDefinition}, \code{mma.sync} at $16{\times}8{\times}32$): the
|
||||
fragment element-to-lane map \emph{is} exposed (\code{position\_of\_nth}), so values can be manipulated in
|
||||
register.
|
||||
\end{itemize}
|
||||
|
||||
Now the arithmetic MMQ actually needs. For a Q8\_0 weight against a q8\_1 activation, the contraction is
|
||||
\[
|
||||
\code{out}[m,n]\;=\;\sum_{\text{32-blocks }kb}\big(\code{xs}[m,kb]\cdot \code{wd}[n,kb]\big)\cdot
|
||||
\sum_{k\in kb}\code{xq}[m,k]\cdot\code{wq}[n,k],
|
||||
\]
|
||||
where the inner \code{int8} dot is the tensor-core op and the per-block \code{f32} scale
|
||||
$\code{xs}\cdot\code{wd}$ is applied \emph{once per 32-element block}. This is not a kernel choice: int8
|
||||
accumulation is only valid \emph{within} one constant-scale block, so the scale must be applied at every
|
||||
block boundary, inherent to the format.
|
||||
|
||||
Here is the collision. With the high-level WMMA API the accumulator fragment is opaque, so the only place
|
||||
to apply the per-block scale is to \code{cmma::store} the integer accumulator to shared memory,
|
||||
synchronize, reload it as f32, and scale --- \textbf{once per 32-block}. That store$\to$sync$\to$reload is
|
||||
a full block-wide serialization inserted into the inner loop. It is the wall.
|
||||
|
||||
\section{The proof: better tiling is slower}
|
||||
\label{sec:proof}
|
||||
|
||||
A cap could be an artifact of a na\"ive kernel structure --- too few warps, poor operand reuse --- rather
|
||||
than the API. To separate ``API limit'' from ``na\"ive structure,'' we built the \emph{fair} ceiling: a
|
||||
$32{\times}64$ output tile per block, 8 warps, with A and B staged to shared memory once per 32-block and
|
||||
reused across the tile (A across four N-subtiles, B across two M-subtiles). Occupancy and operand reuse
|
||||
are now fixed; the \emph{only} remaining WMMA-API tax is exactly the per-block scale round-trip through
|
||||
the opaque fragment (Listing~\ref{lst:tiled}).
|
||||
|
||||
\begin{lstlisting}[language=Rust,caption={The fair high-level-WMMA ceiling (\code{mmq\_q8\_wmma\_blk}). A
|
||||
$32{\times}64$ tile, 8 warps, operands staged once per block and reused. Everything that would help a
|
||||
na\"ive kernel is already done; the residual cost is the opaque-fragment scale round-trip. Note the
|
||||
target gate: the DSL's int8 matrix-core intrinsic is CUDA-only.},label={lst:tiled}]
|
||||
#[kernel(targets(cuda), unchecked)] // <-- CUDA-ONLY: int8 MMA is not portable
|
||||
pub fn mmq_q8_wmma_blk(xq: &Array<i8>, xs: &Array<f32>, wq: &Array<i8>, wd: &Array<f32>,
|
||||
out: &mut Array<f32>, #[comptime] m: usize, #[comptime] n: usize,
|
||||
#[comptime] k: usize) {
|
||||
let (warp, lane) = (UNIT_POS as usize / 32, UNIT_POS as usize % 32); // 8 warps, 2x4 subtiles
|
||||
// ... stage A,B to shared once per 32-block, reuse across the tile ...
|
||||
// ... per block: cmma::execute (i8->i32), then store->sync->reload->scale (the wall) ...
|
||||
}
|
||||
\end{lstlisting}
|
||||
|
||||
The measurement is the result. On GB10/Blackwell, the high-level WMMA MMQ reaches only
|
||||
$\sim$\textbf{7.6 TFLOP/s}, roughly \textbf{one-tenth} of the hand-tuned kernel --- and the better-tiled
|
||||
version above is \emph{slower} than the na\"ive one.\footnote{The structural argument (opaque fragment
|
||||
$\Rightarrow$ per-block shared-memory scale round-trip; manual MMA with an exposed lane map as the
|
||||
higher-ceiling path) is documented in the committed \code{mmq.rs} probe kernels. The TFLOP/s figure and
|
||||
the tiled-is-slower comparison are session measurements on spark (GB10); the probe commit records CUDA
|
||||
verify/bench as pending, so we report them as measured, not as a committed test.} That is the diagnostic
|
||||
signature of a \emph{sync-bound} kernel: adding tiling adds output per block, which adds
|
||||
scale-round-trips, which adds synchronization --- so ``more efficient tiling'' buys more of the exact cost
|
||||
that dominates. If the kernel were occupancy- or compute-bound, better tiling would help; it hurts, which
|
||||
proves the bottleneck is the serialization the opaque fragment forces.
|
||||
|
||||
\section{The viable route is per-backend anyway}
|
||||
\label{sec:viable}
|
||||
|
||||
The higher-ceiling path is manual MMA (\code{mma.sync}), where the fragment element-to-lane map is
|
||||
exposed, so the block scale can be applied \emph{in register} with no shared-memory round-trip --- exactly
|
||||
the serialization removed. But that path is per-backend by nature: the DSL's int8 matrix-core intrinsic
|
||||
is CUDA-only (the probe kernels are literally \code{\#[kernel(targets(cuda))]}, as in
|
||||
Listing~\ref{lst:tiled}). Writing it therefore does \emph{not} collapse the product; it produces one more
|
||||
backend-specific kernel, just authored in Rust instead of CUDA C++. The portable-API collapse and the
|
||||
high-ceiling implementation are, for this one operation, mutually exclusive.
|
||||
|
||||
\section{The honest conclusion, and the seam that holds it}
|
||||
\label{sec:conclusion}
|
||||
|
||||
\textbf{Block-quantized MMQ stays hand-written behind benchmark gates.} This is a boundary of the
|
||||
``one source, every backend'' program, not a refutation of it. The decode matvecs, the norms, RoPE,
|
||||
attention, GDN, and the entire elementwise fabric --- the bulk of the hand-written kernel ledger and the
|
||||
whole of the divergence-bug surface (\cite{method}, \cite{oracle}) --- do collapse. The one op that
|
||||
resists is exactly the one the series already built a seam for: an \emph{intrinsic island} (\cite{island})
|
||||
holds the hand-written tensor-core arm behind a normative, CPU-checkable \code{default}, so even the
|
||||
un-collapsed kernel keeps one signature, one oracle, one test. A negative result that names its boundary
|
||||
precisely --- \emph{which} op, \emph{why} (the opaque fragment), \emph{how you know} (better tiling is
|
||||
slower), and \emph{what contains it} (the island) --- is worth more than a portability claim that quietly
|
||||
excludes the hardest kernel. We report it in full so the boundary is known and the seam is used.
|
||||
|
||||
\begin{thebibliography}{9}
|
||||
\small
|
||||
\bibitem{umbrella} Hanzo AI. \emph{One Source, Every Backend: The hanzo-kernel DSL.} Paper 07 (umbrella).
|
||||
\bibitem{fuse} Hanzo AI. \emph{Fusion Is Composition: Kernel Fusion as the Functor Law.} Paper 07.1.
|
||||
\bibitem{tune} Hanzo AI. \emph{The Schedule Is a Value: Comptime-Specialized Autotuning.} Paper 07.2.
|
||||
\bibitem{island} Hanzo AI. \emph{Intrinsic Islands: An Oracle-Anchored Escape Hatch.} Paper 07.3.
|
||||
\bibitem{oracle} Hanzo AI. \emph{One Oracle, Every Backend: The CPU Bit-Exactness Law.} Paper 07.4.
|
||||
\bibitem{mmq} Hanzo AI. \emph{When the Product Won't Collapse: An int8 Tensor-Core MMQ Negative Result.} Paper 07.5 (this paper).
|
||||
\bibitem{method} Hanzo AI. \emph{Verify, Then Delete: Collapsing a Kernel Cartesian Product.} Paper 07.6.
|
||||
\end{thebibliography}
|
||||
|
||||
\end{document}
|
||||
Binary file not shown.
@@ -0,0 +1,191 @@
|
||||
\documentclass[11pt]{article}
|
||||
\input{shared/kernel-preamble.tex}
|
||||
|
||||
\title{\textbf{Intrinsic Islands:\\ An Oracle-Anchored Escape Hatch for a Portable Kernel}}
|
||||
\author{Hanzo AI --- ML Systems}
|
||||
\date{hanzo-kernel Paper 07.3 --- part of the \emph{One Source, Every Backend} series}
|
||||
|
||||
\begin{document}
|
||||
\maketitle
|
||||
|
||||
\begin{abstract}
|
||||
Every portable kernel DSL eventually meets a loop that wants a target-specific instruction: the hardware
|
||||
4-wide int8 dot on one backend, a subgroup shuffle on another. The tempting response is to fork a whole
|
||||
per-backend kernel for that one line --- which rebuilds, in miniature, the very Cartesian product the DSL
|
||||
exists to collapse. This paper presents a disciplined alternative: the \emph{intrinsic island}, a scoped,
|
||||
target-gated block written \emph{inside} the one kernel. The kernel keeps one signature, one launch
|
||||
contract, one reference, and one test; only the marked inner expression differs per target. The design
|
||||
rests on two rules. First, selection is a \emph{compile-time} \code{match} on a target tag the launch
|
||||
layer fills in and the caller never sees, so exactly one arm is lowered per backend and nothing else
|
||||
compiles. Second, the \code{default} arm is \emph{normative}: it \emph{is} the reference semantics, the
|
||||
CPU always takes it, and every accelerated arm must equal it byte-for-byte on the CPU oracle before it can
|
||||
ship. An island is thus an escape hatch that cannot escape correctness --- the accelerated instruction is
|
||||
never trusted, only proven equivalent. We show the mechanism, the one-kernel-one-oracle test, and why an
|
||||
island beats a fork on exactly the axis that matters.
|
||||
\end{abstract}
|
||||
|
||||
\section{The pain: one line wants a special instruction}
|
||||
\label{sec:pain}
|
||||
|
||||
A quantized matvec spends its inner loop on a 4-wide integer dot product: four \code{int8} weights times
|
||||
four \code{int8} activations, summed to an \code{i32}. On NVIDIA, AMD, Apple, and Vulkan hardware this is
|
||||
a \emph{single instruction} (\dpa{} / \code{OpSDotAccSat}) --- an order of magnitude cheaper than four
|
||||
scalar multiply-adds. A portable kernel that emits four scalar MACs leaves that instruction on the table;
|
||||
one that emits the hardware dot cannot run on a target without it (the CPU reference has no such op).
|
||||
|
||||
The reflex is to fork: a hand-written \code{matvec\_cuda} that uses the intrinsic, and a portable
|
||||
\code{matvec\_fallback} that does not. But a fork for one inner line drags the entire kernel with it ---
|
||||
its signature, its launch bounds, its indexing, its reference, its test --- all now duplicated and free to
|
||||
drift. That is the Cartesian product (backends $\times$ ops) reappearing at the granularity of a single
|
||||
expression. We want the hardware instruction where it exists and the portable arithmetic where it does
|
||||
not, \emph{without} a second copy of the kernel.
|
||||
|
||||
\section{The idea: gate the expression, not the kernel}
|
||||
\label{sec:idea}
|
||||
|
||||
An island moves the fork inward, from the whole kernel to the one expression that actually differs
|
||||
(Listing~\ref{lst:island}). The kernel is written once. At the one spot that needs a target-specific
|
||||
idiom, an \code{island!} block lists accelerated arms by backend and a mandatory \code{default} arm. The
|
||||
rest of the kernel --- the loop, the indexing, the accumulation, the bounds --- is shared verbatim.
|
||||
|
||||
\begin{lstlisting}[language=Rust,caption={An island in a \dpa{} matvec. The accelerated arm issues the
|
||||
hardware 4-wide int8 dot; the \code{default} arm --- the normative oracle, taken by the CPU and any
|
||||
unlisted target --- computes the identical contraction with portable scalar lane MACs. Same math, two
|
||||
idioms.},label={lst:island}]
|
||||
let dp = island! {
|
||||
// Accelerated: the hardware 4-wide int8 dot (dp4a / OpSDot), one instruction.
|
||||
cuda | rocm | metal | vulkan => { w.dot(x) }
|
||||
// NORMATIVE oracle: the identical integer contraction, portable scalar lane MACs.
|
||||
default => { w[0]*x[0] + w[1]*x[1] + w[2]*x[2] + w[3]*x[3] }
|
||||
};
|
||||
\end{lstlisting}
|
||||
|
||||
Two properties make this safe rather than merely convenient, and both are structural.
|
||||
|
||||
\paragraph{The branch site names backends, not a target.} The author writes \code{cuda | rocm | metal |
|
||||
vulkan => ...}; they never write ``if I am on CUDA.'' The \emph{tag} that selects the arm is filled in
|
||||
elsewhere (\S\ref{sec:mech}), so a kernel author cannot accidentally couple the math to a runtime string.
|
||||
|
||||
\paragraph{\code{default} is the definition.} The accelerated arms are optimizations \emph{of} the
|
||||
\code{default} arm. \code{default} is not a fallback in the sense of ``lower quality if unlucky'' --- it
|
||||
is the \emph{normative} semantics that the accelerated arms must reproduce. This is the inversion that
|
||||
makes an island trustworthy: the portable arithmetic is the truth, and the fast instruction has to match
|
||||
it.
|
||||
|
||||
\section{The mechanism: target selection at compile time}
|
||||
\label{sec:mech}
|
||||
|
||||
The subtlety is that the lowering engine compiles the same target-agnostic IR for every backend; at
|
||||
kernel-expansion time there is no ``which backend am I'' string available inside the kernel, only a
|
||||
capability set. But the backend brand \emph{is} known on the \emph{host}, from the runtime's name
|
||||
(\code{"cpu"}, \code{"cuda"}, \code{"hip"}, \code{"wgpu<spirv>"}, \code{"wgpu<msl>"}). So an island is
|
||||
selected by a compile-time tag threaded in from the host (Listing~\ref{lst:target}):
|
||||
|
||||
\begin{lstlisting}[language=Rust,caption={The target tag is derived on the host from the runtime name and
|
||||
threaded into the kernel as a \code{\#[comptime]} parameter. The \code{\#[kernel]} macro rewrites
|
||||
\code{island!\{...\}} into a comptime \code{match target}. Unknown names fall to \code{Cpu} --- fail
|
||||
safe, never a wrong accelerated arm.},label={lst:target}]
|
||||
pub enum Target { Cpu, Cuda, Rocm, Metal, Vulkan, WebGpu } // #[comptime] tag: Eq + Hash + Copy
|
||||
|
||||
pub fn from_runtime_name(name: &str) -> Target {
|
||||
match name {
|
||||
"cuda" => Target::Cuda,
|
||||
"hip" => Target::Rocm,
|
||||
n if n.contains("spirv") => Target::Vulkan,
|
||||
n if n.contains("msl") => Target::Metal,
|
||||
n if n.starts_with("wgpu") => Target::WebGpu,
|
||||
_ => Target::Cpu, // fail safe -> the oracle arm
|
||||
}
|
||||
}
|
||||
\end{lstlisting}
|
||||
|
||||
Because \code{target} is a \code{\#[comptime]} value, the \code{match} it drives is a comptime match over
|
||||
a constant scrutinee: \textbf{only the selected arm is lowered.} Every backend compiles exactly its own
|
||||
arm and nothing else --- there is no runtime branch, no dead code for other targets in the shipped kernel.
|
||||
The tag is kept out of the authoring surface twice over: the branch site lists backends (not a target),
|
||||
and the host launch wrapper fills the tag (\code{Target::of::<R>}), so callers pass only data. One
|
||||
signature, one launch, every backend.
|
||||
|
||||
\section{The oracle contract, tested}
|
||||
\label{sec:oracle}
|
||||
|
||||
Here is the rule that makes an island an escape hatch that cannot escape correctness. The CPU runtime is
|
||||
\emph{always} tagged \code{Target::Cpu} --- a variant no accelerated arm ever claims --- so on the CPU
|
||||
every island resolves to \code{default}. The CPU runtime is therefore the single bit-exact oracle for
|
||||
every island (the general form of this law, and its cross-backend enforcement, is the companion oracle
|
||||
paper, \cite{oracle}). The test states the contract directly and mechanically: run the kernel on the CPU
|
||||
with each accelerated tag \emph{forced}, and it must equal the \code{default} result byte-for-byte
|
||||
(Listing~\ref{lst:oracletest}).
|
||||
|
||||
\begin{lstlisting}[language=Rust,caption={The oracle-equivalence test. Every accelerated arm, executed on
|
||||
the CPU runtime with its tag forced, must be byte-identical to the \code{default} arm and to the
|
||||
plain-Rust reference. An accelerated instruction is never trusted --- it is proven equal to the normative
|
||||
arm.},label={lst:oracletest}]
|
||||
// The default arm (CPU is tagged Cpu -> default) must equal the plain-Rust oracle.
|
||||
let default = matvec_island_run_with::<CpuRuntime>(&c, &wq, &xq, &wd, rows, k, Target::Cpu);
|
||||
assert_eq!(bits(&default), bits(&matvec_island_ref(&wq, &xq, &wd, rows, k)));
|
||||
|
||||
// Every accelerated arm, forced onto the CPU runtime, must equal the oracle byte-for-byte.
|
||||
for accel in [Target::Cuda, Target::Rocm, Target::Metal, Target::Vulkan, Target::WebGpu] {
|
||||
let out = matvec_island_run_with::<CpuRuntime>(&c, &wq, &xq, &wd, rows, k, accel);
|
||||
assert_eq!(bits(&out), bits(&default), "island arm {accel:?} diverged from default");
|
||||
}
|
||||
\end{lstlisting}
|
||||
|
||||
A separate test asserts the \emph{production} launch --- the one that derives the tag from the runtime and
|
||||
hides it --- resolves to the oracle arm on the CPU without the caller naming a target. So the property is
|
||||
proven both under forced tags (every arm equals \code{default}) and under the real launch path (the
|
||||
caller-facing API does the right thing by default).
|
||||
|
||||
\section{Why an island beats a fork}
|
||||
\label{sec:vsfork}
|
||||
|
||||
The comparison is not aesthetic; it is about what can drift and what can be verified
|
||||
(Table~\ref{tab:vsfork}). A fork gives two kernels: two signatures to keep in sync, two references to
|
||||
trust, two tests to keep honest, and no mechanical guarantee that the fast kernel computes what the
|
||||
portable one does --- the equivalence lives only in a human's intention. An island gives one kernel: one
|
||||
signature, one reference (the \code{default} arm), one test that \emph{proves} every accelerated arm
|
||||
equals it. The accelerated instruction is contained, not trusted.
|
||||
|
||||
\begin{table}[h]
|
||||
\centering\small
|
||||
\setlength{\tabcolsep}{7pt}
|
||||
\begin{tabular}{@{}lll@{}}
|
||||
\toprule
|
||||
& \textbf{Fork (per-backend kernel)} & \textbf{Island (scoped block)} \\
|
||||
\midrule
|
||||
Signatures & one per backend, drift-prone & one, shared \\
|
||||
Reference & one per fork, trust-based & one \code{default} arm, normative \\
|
||||
Equivalence & human intention only & proven byte-for-byte on the CPU oracle \\
|
||||
What differs & the whole kernel & the one gated expression \\
|
||||
New backend & another full kernel & another arm, or falls to \code{default} \\
|
||||
\bottomrule
|
||||
\end{tabular}
|
||||
\caption{An island localizes the difference to the expression that actually differs and anchors it to a
|
||||
CPU-checkable normative arm. A fork localizes nothing and anchors nothing.}
|
||||
\label{tab:vsfork}
|
||||
\end{table}
|
||||
|
||||
\section{The designed seam}
|
||||
|
||||
An island is not a licence to sprinkle intrinsics; it is the \emph{one} sanctioned place a per-backend
|
||||
idiom may live, precisely because it comes with the oracle contract attached. That matters most for the
|
||||
operation this series shows does \emph{not} collapse into portable form: block-quantized tensor-core MMQ,
|
||||
whose viable implementation is per-backend by nature (\cite{mmq}). Even there, an island holds the
|
||||
hand-written tensor-core arm behind a normative, CPU-checkable \code{default}, so the one un-collapsed
|
||||
kernel still keeps one signature, one oracle, one test. The escape hatch exists so that the rare
|
||||
irreducibly-per-backend kernel does not force a return to forks --- it is the boundary condition of the
|
||||
whole ``one source, every backend'' program, made safe.
|
||||
|
||||
\begin{thebibliography}{9}
|
||||
\small
|
||||
\bibitem{umbrella} Hanzo AI. \emph{One Source, Every Backend: The hanzo-kernel DSL.} Paper 07 (umbrella).
|
||||
\bibitem{fuse} Hanzo AI. \emph{Fusion Is Composition: Kernel Fusion as the Functor Law.} Paper 07.1.
|
||||
\bibitem{tune} Hanzo AI. \emph{The Schedule Is a Value: Comptime-Specialized Autotuning.} Paper 07.2.
|
||||
\bibitem{island} Hanzo AI. \emph{Intrinsic Islands: An Oracle-Anchored Escape Hatch.} Paper 07.3 (this paper).
|
||||
\bibitem{oracle} Hanzo AI. \emph{One Oracle, Every Backend: The CPU Bit-Exactness Law.} Paper 07.4.
|
||||
\bibitem{mmq} Hanzo AI. \emph{When the Product Won't Collapse: An int8 Tensor-Core MMQ Negative Result.} Paper 07.5.
|
||||
\bibitem{method} Hanzo AI. \emph{Verify, Then Delete: Collapsing a Kernel Cartesian Product.} Paper 07.6.
|
||||
\end{thebibliography}
|
||||
|
||||
\end{document}
|
||||
Binary file not shown.
+150
-596
@@ -1,170 +1,38 @@
|
||||
\documentclass[11pt]{article}
|
||||
\usepackage[margin=1in]{geometry}
|
||||
\usepackage{booktabs}
|
||||
\usepackage{amsmath}
|
||||
\usepackage{amssymb}
|
||||
\usepackage{graphicx}
|
||||
\usepackage{xcolor}
|
||||
\usepackage{hyperref}
|
||||
\hypersetup{colorlinks=true, linkcolor=blue, urlcolor=blue, citecolor=blue}
|
||||
\usepackage{enumitem}
|
||||
\usepackage{listings}
|
||||
\usepackage{array}
|
||||
\input{shared/kernel-preamble.tex}
|
||||
|
||||
\definecolor{kw}{rgb}{0.13,0.29,0.53}
|
||||
\definecolor{cmt}{rgb}{0.40,0.40,0.40}
|
||||
\definecolor{str}{rgb}{0.16,0.50,0.16}
|
||||
\lstset{
|
||||
basicstyle=\ttfamily\footnotesize,
|
||||
keywordstyle=\color{kw}\bfseries,
|
||||
commentstyle=\color{cmt}\itshape,
|
||||
stringstyle=\color{str},
|
||||
showstringspaces=false,
|
||||
breaklines=true,
|
||||
columns=fullflexible,
|
||||
frame=single,
|
||||
framesep=4pt,
|
||||
}
|
||||
\lstdefinelanguage{Rust}{
|
||||
morekeywords={fn,let,mut,pub,struct,impl,trait,enum,match,if,else,for,while,loop,return,use,mod,crate,self,super,async,await,where,type,const,static,unsafe,ref,move,dyn,as,true,false},
|
||||
morecomment=[l]{//}, morecomment=[s]{/*}{*/}, morestring=[b]", sensitive=true,
|
||||
}
|
||||
|
||||
\newcommand{\x}{\ensuremath{\times}}
|
||||
\newcommand{\dpa}{\texttt{dp4a}}
|
||||
\newcommand{\hk}{\texttt{hanzo-kernel}}
|
||||
\newcommand{\code}[1]{\texttt{#1}}
|
||||
|
||||
% Absorb minor prose overfulls without rivers; keep line-breaking sane.
|
||||
\tolerance=800
|
||||
\emergencystretch=3em
|
||||
|
||||
\title{\textbf{Fusion Is Composition:\\
|
||||
A Category-Theoretic Kernel DSL that Collapses the\\
|
||||
Backends\,\x\,Ops\,\x\,Types Product}}
|
||||
\title{\textbf{One Source, Every Backend:\\ The \hk{} DSL --- Thesis and Guided Tour}}
|
||||
\author{Hanzo AI --- ML Systems}
|
||||
\date{Paper 07 --- companion to Paper 06 (\emph{When Does a Kernel Win Surface?})}
|
||||
\date{Paper 07 (umbrella) --- companion to Paper 06 (\emph{When Does a Kernel Win Surface?})}
|
||||
|
||||
\begin{document}
|
||||
\maketitle
|
||||
|
||||
\begin{abstract}
|
||||
A multi-backend inference engine hand-writes the same $\sim$20 operations, specialized over $\sim$22
|
||||
GGUF quantization formats, once per GPU backend --- a Cartesian product of five kernel trees and
|
||||
$\sim$52{,}000 lines that is not merely a maintenance cost but the breeding ground for
|
||||
divergence bugs, because one operation with five implementations has five numeric behaviors. We
|
||||
present \hk{}, a first-party Rust GPU-kernel DSL that collapses this product to a sum, built on one
|
||||
organizing idea: \textbf{fusion is composition} --- the functor law $\mathrm{map}(g)\circ\mathrm{map}(f)
|
||||
= \mathrm{map}(g\circ f)$ made mechanical, with Reduce/Scan as algebraic fences, so ``can these two
|
||||
ops fuse?'' is a total function of the op's \emph{class}, not a pattern matcher or a cost model.
|
||||
On this base sit three mechanisms that each attack one axis of the product: \emph{comptime-specialized
|
||||
autotune} (one kernel source, its schedule exposed as compile-time knobs the tuner picks per device,
|
||||
every variant byte-identical); \emph{intrinsic islands} (scoped target-gated blocks inside one kernel
|
||||
whose \code{default} arm is the normative oracle); and \emph{a CPU runtime as the bit-exact oracle}
|
||||
for every backend. We report the evidence: 10/10 op families bit-exact against the CPU oracle;
|
||||
DSL$\to$Vulkan and DSL$\to$ROCm dispatch bit-exact on real hardware (rms\_norm on gfx1151,
|
||||
$\text{max\_rel}=2.43\times10^{-7}$); a single-source \dpa{} matvec at \textbf{118\% of the hand-tuned
|
||||
kernel} it replaces (196~GB/s, gfx1151); a shipped Vulkan migration where the DSL twin was
|
||||
\textbf{7.1\x{} faster} than the hand-written shader \emph{and} bit-exact; and a nightly three-backend
|
||||
GPU CI (self-hosted CUDA/ROCm/Metal) that enforces the bit-exactness law. We also report a
|
||||
scientifically load-bearing \emph{negative} result: int8 tensor-core MMQ via the high-level WMMA API is
|
||||
structurally sync-bound (the opaque accumulator fragment forces a store$\to$sync$\to$reload$\to$scale per
|
||||
32-element block; a better-tiled version is \emph{slower}), so block-quantized GEMM stays hand-written
|
||||
behind benchmark gates --- which is exactly what islands are designed to hold. None of Triton, XLA,
|
||||
torch.compile, TVM, Halide, Burn, or Mojo models fusion as morphism composition; we state precisely
|
||||
what is and is not novel.
|
||||
The kernel layer of a multi-backend LLM engine is a Cartesian product: the same $\sim$20 operations,
|
||||
specialized over $\sim$22 quantization formats, hand-written once per GPU backend --- five kernel trees
|
||||
and $\sim$52{,}000 lines, and the breeding ground for the divergence bugs that cost the most to chase,
|
||||
because one operation with five implementations has five numeric behaviors. This umbrella paper states the
|
||||
thesis that collapses the product to a sum and gives a guided tour of the six companion papers that make
|
||||
it concrete. The thesis rests on one idea --- \textbf{fusion is composition}, the functor law made
|
||||
mechanical --- extended by three mechanisms that each attack one axis of the product: comptime-specialized
|
||||
autotune (the schedule as a value, not a fork), intrinsic islands (a per-backend idiom held behind a
|
||||
normative oracle arm), and a CPU runtime that is the bit-exact reference for every backend. We describe the
|
||||
facade that ties them together (a first-party namespace over the CubeCL lowering engine, plugged in as a
|
||||
code generator rather than a second runtime), summarize the evidence --- portability that costs nothing, a
|
||||
shipped migration that improved performance, an honest structural negative result --- and place the work
|
||||
against Triton, XLA, torch.compile, TVM, Halide, Burn, and Mojo. Each fact in this series lives in exactly
|
||||
one paper; here we connect them.
|
||||
\end{abstract}
|
||||
|
||||
\section{Introduction}
|
||||
\section{The thesis: a product that should be a sum}
|
||||
\label{sec:thesis}
|
||||
|
||||
The kernel layer of a multi-backend LLM engine is a product. Table~\ref{tab:dup} is the current
|
||||
inventory in the Hanzo ML and engine repositories: five hand-written kernel trees --- CUDA C++, HIP
|
||||
C++, Metal Shading Language, GLSL$\to$SPIR-V, and the engine's own CUDA --- carrying on the order of
|
||||
$52{,}000$ lines of hand-written kernel arithmetic. The same $\sim$20 operations (matvec, matmul,
|
||||
flash attention, RMSNorm, RoPE, softmax, the Gated-DeltaNet linear-attention ops) are re-implemented in
|
||||
each, and within each they are specialized over $\sim$22 GGUF quantization formats. Three axes ---
|
||||
\emph{backends}, \emph{ops}, \emph{types} --- are multiplied by hand.
|
||||
|
||||
That product is where the correctness bugs breed, and Paper~06 catalogs the class concretely: a
|
||||
Qwen3-8B model that generates coherently on Metal collapses into token repetition under CUDA
|
||||
flash-attention, byte-identical weights and sampler, because \emph{the same operation has a different
|
||||
kernel per backend and therefore a different numeric behavior}. Uniform numeric behavior across
|
||||
backends is not something four or five copies can be tested \emph{into}; it is a structural property of
|
||||
having exactly one implementation. The engineering goal, then, is not ``write the kernels more
|
||||
carefully'' but ``write each operation once and lower it everywhere,'' turning the product into a sum:
|
||||
\[
|
||||
\underbrace{\text{backends} \x \text{types} \x \text{ops}}_{\text{hand-written today}}
|
||||
\;\longrightarrow\;
|
||||
\underbrace{\text{backends} + \text{types} + \text{ops}}_{\text{one source, lowered}}.
|
||||
\]
|
||||
|
||||
\begin{table}[h]
|
||||
\centering
|
||||
\begin{tabular}{llrr}
|
||||
\toprule
|
||||
\textbf{Tree} & \textbf{Language} & \textbf{Files} & \textbf{Lines} \\
|
||||
\midrule
|
||||
CUDA (ML) & CUDA C++ & -- & 13{,}123 \\
|
||||
ROCm & HIP C++ & 19 & 6{,}725 \\
|
||||
Metal & Metal Shading Language & -- & 3{,}530 \\
|
||||
Vulkan & GLSL $\rightarrow$ SPIR-V & 121 & 7{,}105 \\
|
||||
CUDA (engine) & CUDA C++ & -- & 21{,}862 \\
|
||||
\midrule
|
||||
\textbf{Total} & 5 trees & & \textbf{52{,}345} \\
|
||||
\bottomrule
|
||||
\end{tabular}
|
||||
\caption{The collapse ledger: hand-written kernel arithmetic across the five backend trees.\protect\footnotemark{}
|
||||
The DSL's job is to drain this to op-specs $+$ type-specs $+$ backend lowerings.}
|
||||
\label{tab:dup}
|
||||
\end{table}
|
||||
\footnotetext{These are hand-written, non-generated, non-vendored kernel lines --- the \emph{deletable}
|
||||
duplication. A na\"ive \code{wc -l} over all \code{*.cu/*.cuh/*.hip/*.hpp/*.metal/*.comp} is roughly
|
||||
$2\x$ larger ($\sim$112k) because it also counts C++ headers, a \code{target/package/} build-artifact
|
||||
copy, machine-generated codebook tables (e.g. the i-quant grids), and vendored upstream kernels
|
||||
(FlashInfer, Marlin, GPTQ) that are neither hand-written duplication nor a DSL collapse target.}
|
||||
|
||||
\paragraph{Thesis and contributions.}
|
||||
This paper argues that the collapse is not an engineering slog of retyping kernels into a portable
|
||||
syntax but follows from one algebraic idea, applied three ways. The idea is that \textbf{kernel fusion
|
||||
is morphism composition}: an elementwise kernel is the lift of a scalar function to every array index,
|
||||
and the functor law $\mathrm{map}(g)\circ\mathrm{map}(f)=\mathrm{map}(g\circ f)$ read right-to-left
|
||||
\emph{is} fusion --- one kernel computing the composite, no intermediate array. Reduce/Scan are the
|
||||
\emph{fences} where the pattern changes, decidable from the op's class, not from a pattern matcher.
|
||||
Concretely, we contribute:
|
||||
\begin{enumerate}[leftmargin=1.6em,itemsep=1pt]
|
||||
\item \textbf{Fusion as composition} (\S\ref{sec:fuse}): an op algebra in which legal fusion is a total
|
||||
function of op \emph{class}, one fused-interpreter kernel that lowers any composed chain to a single
|
||||
launch with zero intermediates, and its generalization from a line to an arbitrary DAG --- all
|
||||
bit-exact-gated on the CPU (fused one-kernel $=$ na\"ive $N$-kernel $=$ plain-Rust reference).
|
||||
\item \textbf{Comptime-specialized autotune} (\S\ref{sec:tune}): the schedule of a single-source kernel
|
||||
exposed as \code{\#[comptime]} knobs the engine monomorphizes over; a small variant set timed once per
|
||||
$(\text{device},\text{op},\text{shape})$ and cached to XDG; every variant byte-identical, so the tuner
|
||||
picks speed without touching numerics --- the ``one op, $N$ device forks'' bug class killed.
|
||||
\item \textbf{Intrinsic islands} (\S\ref{sec:island}): a target-gated block \emph{inside} one kernel
|
||||
whose \code{default} arm is normative and whose accelerated arms are proven byte-for-byte equal to it
|
||||
on the CPU oracle --- the disciplined seam for the rare instruction that truly needs a per-backend idiom.
|
||||
\item \textbf{The CPU oracle law and its CI} (\S\ref{sec:oracle}): the CPU runtime is the single
|
||||
bit-exact reference for every backend, enforced nightly on self-hosted CUDA/ROCm/Metal runners.
|
||||
\item \textbf{Evidence, positive and negative} (\S\ref{sec:eval}--\ref{sec:negative}): cross-backend
|
||||
bit-exactness, a portable kernel that \emph{beats} the hand-tuned one, a shipped migration that improved
|
||||
performance, and an honest structural negative result on int8 tensor-core MMQ.
|
||||
\end{enumerate}
|
||||
|
||||
We adopt CubeCL as the lowering engine and wrap it in a first-party facade so that kernel source names
|
||||
only \code{hanzo\_kernel::*}; the string \code{cubecl} appears in exactly one line of one
|
||||
\code{Cargo.toml}. That is ``values, not places'': CubeCL is the implementation \emph{value};
|
||||
\hk{} is the stable \emph{namespace} we build against, upgradable or forkable without editing a kernel.
|
||||
|
||||
\section{Design: the facade and the three axes}
|
||||
\label{sec:design}
|
||||
|
||||
\subsection{Three orthogonal axes, one source}
|
||||
The kernel code braids three independent concerns. Pulled apart, each is written once and they compose
|
||||
(Table~\ref{tab:axes}). The transformation is not new in spirit --- ROCm already decomplected two of
|
||||
the three axes, writing its kernels as \code{qmatvec\_core<WTYPE>}: one contraction, the quantization
|
||||
type injected as a compile-time template parameter. The DSL is exactly that pattern with the
|
||||
\emph{backend} as the other compile-time axis. We are generalizing a decomplect already trusted in
|
||||
production, not inventing an untested one.
|
||||
Table~\ref{tab:axes} is the disease. A multi-backend engine braids three independent concerns into one
|
||||
hand-multiplied product: the operations (the math), the quantization types (the bit layout), and the
|
||||
backends (the lowering). Written by hand, that is $\sim$20 $\times$ 22 $\times$ 5 kernels; in the Hanzo
|
||||
repositories it is $52{,}345$ lines across five trees and five shading languages (the ledger and its
|
||||
audit live in the collapse-method paper, \cite{method}).
|
||||
|
||||
\begin{table}[h]
|
||||
\centering
|
||||
@@ -177,29 +45,39 @@ Quant types & $\sim$22 & the bit layout: Q4\_K, Q6\_K, Q8\_0, the IQ codebooks \
|
||||
Backends & 5 & the lowering: threads, memory, tensor cores, per shading language \\
|
||||
\bottomrule
|
||||
\end{tabular}
|
||||
\caption{The three axes the hand-written product multiplies. The DSL makes each a summand.}
|
||||
\caption{Three orthogonal axes, multiplied by hand today. The DSL's job is to make each a summand.}
|
||||
\label{tab:axes}
|
||||
\end{table}
|
||||
|
||||
\subsection{A code generator, not a second runtime}
|
||||
\hk{} lowers a \code{\#[kernel]} to SPIR-V for Vulkan, to CUDA/PTX for CUDA, to MSL for Metal, and
|
||||
through cubecl-hip for ROCm. Crucially it is a \emph{code generator}, not a competing GPU runtime: the
|
||||
engine's Vulkan path is already \code{include\_bytes!(<name>.spv)} $\to$ \code{create\_shader\_module}
|
||||
$\to$ dispatch, and CubeCL emits exactly those bytes. A DSL RMSNorm dumped and inspected is a
|
||||
\code{GLCompute} entry point with buffers on descriptor bindings $0,1,2$ --- byte-for-byte the shape
|
||||
the engine's hand-written \code{.comp}$\to$\code{.spv} shaders already take. So the DSL plugs into each
|
||||
backend's \emph{existing} dispatch machinery; there is no dual runtime and no cross-runtime copy. The
|
||||
one bounded integration step is a three-line SPIR-V post-pass (rename the entry point to \code{main};
|
||||
strip CubeCL's unused info-uniform at binding~3 via \code{spirv-opt --remove-unused-interface-variables};
|
||||
dispatch), automated by a reusable emitter.
|
||||
That product is not merely a maintenance cost. It is where correctness bugs breed: a single quantized
|
||||
model that generates coherent text on one backend and collapses into token repetition on another, from
|
||||
byte-identical weights, because the same operation has a different kernel --- and therefore a different
|
||||
numeric behavior --- per backend (the bug and its cure are the subject of the oracle paper,
|
||||
\cite{oracle}). Uniform numeric behavior across backends cannot be tested \emph{into} five copies; it is a
|
||||
structural property of having one implementation. So the goal is to turn the product into a sum:
|
||||
\[
|
||||
\underbrace{\text{backends} \x \text{types} \x \text{ops}}_{\text{hand-written today}}
|
||||
\;\longrightarrow\;
|
||||
\underbrace{\text{backends} + \text{types} + \text{ops}}_{\text{one source, lowered}}.
|
||||
\]
|
||||
This is a decomplect we already trust in miniature: the ROCm tree writes \code{qmatvec\_core<WTYPE>} --- one
|
||||
contraction, the quant type injected as a compile-time parameter. The DSL generalizes exactly that pattern
|
||||
with the \emph{backend} as the other compile-time axis.
|
||||
|
||||
\subsection{The kernel-authoring surface}
|
||||
Listing~\ref{lst:names} shows the facade's first-principles vocabulary: \code{\#[kernel]} says what it
|
||||
is (a GPU kernel), not \code{cube} (a brand); \code{Grid} and \code{Block} name the launch shape
|
||||
directly. A kernel's only import is \code{use hanzo\_kernel::prelude::*;}.
|
||||
\section{The facade: a namespace, and a code generator}
|
||||
\label{sec:facade}
|
||||
|
||||
\begin{lstlisting}[language=Rust,caption={The Q8\_0 matvec: one source, no backend in it. The decode
|
||||
(\code{wd*wq}) and the contraction are the arithmetic each backend hand-writes today.},label={lst:names}]
|
||||
\hk{} is a first-party Rust DSL: a kernel is written once, in Rust, and lowered to CUDA, ROCm, Vulkan, and
|
||||
Metal. It is a \emph{facade} over the CubeCL lowering engine --- kernel source names only
|
||||
\code{hanzo\_kernel::*}, and the string \code{cubecl} appears in exactly one line of one \code{Cargo.toml}.
|
||||
That is ``values, not places'': CubeCL is the implementation \emph{value}; \hk{} is the stable
|
||||
\emph{namespace} we build against, upgradable or forkable without editing a kernel. The authoring surface
|
||||
uses first-principles names --- \code{\#[kernel]} says what a thing is (a GPU kernel), not \code{cube} (a
|
||||
brand); \code{Grid} and \code{Block} name the launch shape directly (Listing~\ref{lst:matvec}).
|
||||
|
||||
\begin{lstlisting}[language=Rust,caption={A quantized matvec: one source, no backend in it. The decode
|
||||
(\code{wd*wq}) and the contraction are the arithmetic each backend hand-writes today. This one function
|
||||
lowers to CUDA, ROCm, Vulkan, Metal, and CPU.},label={lst:matvec}]
|
||||
use hanzo_kernel::prelude::*;
|
||||
|
||||
#[kernel(targets(cuda, metal, vulkan, webgpu, cpu), unchecked)]
|
||||
@@ -215,446 +93,122 @@ pub fn matvec_q8<F: Float>(wd: &Array<F>, wq: &Array<i32>, x: &Array<F>,
|
||||
}
|
||||
\end{lstlisting}
|
||||
|
||||
The perf primitives the hand-tuned kernels rely on are all reachable and each lowers to the native
|
||||
instruction: \code{dot} on a vectorized \code{Line<i8>} $\to$ \dpa{}\,/\,\code{OpSDotAccSat};
|
||||
\code{cmma} $\to$ WMMA\,/\,cooperative-matrix\,/\,simdgroup; \code{SharedMemory}, the \code{plane\_*}
|
||||
subgroup reductions, \code{Atomic}, and barriers. The DSL is a superset of what we express by hand, so
|
||||
nothing about portability forces a performance downgrade --- a claim we make good on in \S\ref{sec:eval}
|
||||
and qualify honestly in \S\ref{sec:negative}.
|
||||
Crucially the DSL is a \emph{code generator}, not a competing GPU runtime. The engine's Vulkan path is
|
||||
already ``load SPIR-V bytes, create a shader module, dispatch,'' and the DSL \emph{emits} exactly those
|
||||
bytes: a compiled kernel is a \code{GLCompute} entry point with buffers on descriptor bindings $0,1,2$ ---
|
||||
byte-for-byte the shape the hand-written \code{.comp}$\to$\code{.spv} shaders already take. So the DSL plugs
|
||||
into each backend's \emph{existing} dispatch machinery; there is no dual runtime and no cross-runtime copy.
|
||||
The one bounded integration step is a three-line SPIR-V post-pass (rename the entry point to \code{main};
|
||||
strip the engine's unused info-uniform; dispatch). The perf primitives the hand-tuned kernels rely on are
|
||||
all reachable and each lowers to the native instruction --- \code{dot} on a vectorized \code{Line<i8>}
|
||||
$\to$ \dpa{}, \code{cmma} $\to$ tensor cores, \code{SharedMemory} and the \code{plane\_*} subgroup
|
||||
reductions --- so portability does not, by construction, force a performance downgrade.
|
||||
|
||||
\section{Mechanism I: fusion is composition}
|
||||
\label{sec:fuse}
|
||||
\section{A guided tour of the mechanisms}
|
||||
\label{sec:tour}
|
||||
|
||||
\subsection{The functor law, read right-to-left}
|
||||
An elementwise (pointwise) kernel is the lift of a scalar morphism $f:F\to F$ applied at every index.
|
||||
Pointwise lifts obey the functor law
|
||||
\[
|
||||
\mathrm{map}(g)\circ\mathrm{map}(f) \;=\; \mathrm{map}(g\circ f).
|
||||
\]
|
||||
Read left-to-right this is two kernels with a materialized intermediate array between them; read
|
||||
right-to-left it is \emph{one} kernel computing the composed scalar function with \emph{no}
|
||||
intermediate. Kernel fusion is nothing but that law read right-to-left. It is exactly the transducer
|
||||
story: \code{map(f)} and \code{map(g)} are transformations independent of the collection, so you compose
|
||||
the \emph{transformations} and apply the composite to the collection once. Here the collection is a GPU
|
||||
array and ``once'' is one launch. Categorically the Maps are morphisms $\mathrm{Array}\to\mathrm{Array}$
|
||||
and composition of morphisms \emph{is} fusion --- there is no separate ``fusion pass'' concept, only the
|
||||
associativity of $\circ$.
|
||||
The collapse is not a syntactic retyping of kernels; it follows from one algebraic idea applied three
|
||||
ways. Each is developed in full in its own companion paper; here is the map.
|
||||
|
||||
\subsection{The class decides fusibility}
|
||||
The whole surface is a tiny op algebra, and legal fusion falls out of the op's \emph{class}, not from a
|
||||
pattern matcher over concrete ops:
|
||||
\begin{itemize}[leftmargin=1.4em,itemsep=1pt]
|
||||
\item \textbf{Map} (pointwise morphism): output element $i$ depends only on input(s) at index $i$.
|
||||
Shape-preserving, index-local. Maps compose freely: any run of Maps is one Map (the functor law). This
|
||||
is the fusible family --- \code{Add Sub Mul Div Neg Recip Abs Exp Rsqrt Sigmoid Tanh Silu Gelu}.
|
||||
\item \textbf{Reduce\,/\,Scan} (contraction): output $i$ depends on a whole row/axis
|
||||
(\code{sum}, \code{max}, \code{mean} $\to$ RMSNorm, softmax, matmul). Not index-local, so it cannot be
|
||||
absorbed into an adjacent Map by the same lift; it needs cross-lane communication. A Reduce is therefore
|
||||
a \emph{fence}: a preceding Map run fuses into its input \emph{read} (prologue) and a following Map run
|
||||
fuses into its output \emph{write} (epilogue), but the reduction is the seam where the kernel pattern
|
||||
changes.
|
||||
\end{itemize}
|
||||
The decision ``can these two adjacent ops fuse?'' reduces to
|
||||
$\mathrm{fusible}(a,b) := \mathrm{is\_map}(a)\wedge\mathrm{is\_map}(b)$, plus prologue/epilogue
|
||||
attachment at a Reduce's ports. Nothing else. The fusion pass is one fold: walk the chain, compose
|
||||
adjacent Maps by substituting the previous expression for the current-value leaf (\code{compose\_cur},
|
||||
which \emph{is} $g[\,\code{Cur}:=f\,]$), and cut a new region at every Reduce.
|
||||
\paragraph{Fusion is composition (\cite{fuse}).} An elementwise kernel is the lift of a scalar function to
|
||||
every index, and such lifts obey the functor law $\mathrm{map}(g)\circ\mathrm{map}(f)=\mathrm{map}(g\circ
|
||||
f)$. Read right-to-left, that law \emph{is} fusion: one kernel computing the composite, no intermediate
|
||||
array. Reduce and Scan are algebraic \emph{fences} where the pattern changes, decidable from the op's
|
||||
class rather than a pattern matcher. This is the theory core: the fusibility of two ops is a total function
|
||||
of their type, one fused-interpreter kernel lowers any composed chain to a single launch, and the same
|
||||
algebra lifts from a line to an arbitrary DAG --- draining, for instance, a per-layer Gated-DeltaNet chain
|
||||
from 9 kernel launches to 5, bit-exact.
|
||||
|
||||
\subsection{One kernel interprets the whole chain}
|
||||
The fused Map region is lowered to a small linear ``op program'' carried as a \code{\#[comptime]} value
|
||||
and walked in a comptime-\emph{unrolled} loop, so each op becomes straight-line arithmetic in one kernel
|
||||
body --- no runtime interpreter, no intermediate arrays, no extra launches
|
||||
(Listing~\ref{lst:fused}). It is not a hand-written fixed kernel: any linear elementwise chain the
|
||||
builder produces runs through this one \code{fused\_interp}, whatever its length.
|
||||
\paragraph{The schedule is a value (\cite{tune}).} The last place a backend forks a kernel is the schedule
|
||||
--- tile size, vector width, unroll, block dimension. A fork is exactly what reintroduces the divergence
|
||||
bug. Autotune folds the fork into the one source by exposing the schedule as compile-time knobs the engine
|
||||
monomorphizes; a small variant set is timed per device and the winner cached to disk. Because the knobs
|
||||
never touch the arithmetic, every variant is byte-identical --- the tuner buys speed and can never spend
|
||||
correctness.
|
||||
|
||||
\begin{lstlisting}[language=Rust,caption={The one fused kernel. It reads the primary input once,
|
||||
threads it through the comptime program (unrolled to straight-line code), writes once. Every
|
||||
\code{Instr} is inlined; one launch, zero intermediates.},label={lst:fused}]
|
||||
#[kernel(targets(cuda, metal, vulkan, webgpu, cpu), unchecked)]
|
||||
pub fn fused_interp<F: Float>(x: &Array<F>, sides: &Sequence<Array<F>>,
|
||||
out: &mut Array<F>, #[comptime] prog: Program) {
|
||||
let i = ABSOLUTE_POS;
|
||||
if i < out.len() {
|
||||
let mut acc = x[i];
|
||||
#[unroll]
|
||||
for step in 0..prog.instrs.len() {
|
||||
match comptime!(prog.instrs[step]) {
|
||||
Instr::Un(op) => acc = apply_un_dev::<F>(op, acc),
|
||||
Instr::BinIn(op, slot) => acc = apply_bin_dev::<F>(op, acc, sides.index(slot)[i]),
|
||||
Instr::BinConst(op, b) => { let c = comptime!(f32::from_bits(b));
|
||||
acc = apply_bin_dev::<F>(op, acc, F::new(c)) }
|
||||
}
|
||||
}
|
||||
out[i] = acc;
|
||||
}
|
||||
}
|
||||
\end{lstlisting}
|
||||
\paragraph{Intrinsic islands (\cite{island}).} When one inner line wants a target-specific instruction, you
|
||||
do not fork the kernel; you write a scoped, target-gated block \emph{inside} it. Selection is a
|
||||
compile-time \code{match} on a tag the launch layer fills in, so exactly one arm is lowered per backend.
|
||||
The \code{default} arm is \emph{normative} --- the CPU always takes it, and every accelerated arm is proven
|
||||
byte-for-byte equal to it on the CPU oracle before it ships. An escape hatch that cannot escape correctness.
|
||||
|
||||
The ergonomic surface is a builder that reads like the composition it denotes:
|
||||
\code{Fuse::new(a).\allowbreak{}mul(w).\allowbreak{}add(b).\allowbreak{}silu().\allowbreak{}run(c)} appends one \code{Instr} per call, compiles
|
||||
them to one \code{Program}, and dispatches exactly one \code{fused\_interp}. Because the \code{Program}
|
||||
rides as a comptime value, the constant leaf is stored as \code{f32::to\_bits} so the program stays
|
||||
\code{Eq + Hash} --- the lowering engine keys the compiled kernel on it, one compiled kernel per distinct
|
||||
fused chain.
|
||||
\paragraph{One oracle, every backend (\cite{oracle}).} The foundation under all three: the CPU runtime is
|
||||
the single bit-exact reference for every backend. Every op's device code mirrors a plain-Rust reference
|
||||
line-for-line; because the CPU runs pointwise ops in program order, its result is the definition, and a GPU
|
||||
differs only by bounded reduction-order rounding. The law is enforced nightly by a three-backend GPU CI on
|
||||
self-hosted NVIDIA, AMD, and Apple machines --- so ``the same op has the same numbers everywhere'' is a
|
||||
checked invariant, and the repetition collapse becomes an unreachable state rather than a bug to fix.
|
||||
|
||||
\subsection{From a line to a DAG}
|
||||
A real forward pass is not a line: a value fans out (used by several ops) and a binary fans in (reads two
|
||||
independently computed values). \code{silu(a)*b + a} reuses $a$ twice and a linear chain cannot express
|
||||
it in one region; a DAG can, and it still fuses to one kernel. The same algebra lifts from a path to a
|
||||
graph: a \code{NodeId}-taping newtype records an arbitrary pointwise DAG as a \code{Vec<Node>} (operator
|
||||
overloads push nodes instead of computing); a flood-fill partitions maximal Map-only connected regions,
|
||||
cutting every edge into a fence --- the identical \code{Class} predicate applied to graph edges instead
|
||||
of list-adjacency; and one generic \code{dag\_interp} kernel interprets a per-region DAG program over a
|
||||
per-element slot array (fan-out $=$ a slot read by several later instructions; fan-in $=$ a binary
|
||||
reading two slots), generalizing \code{fused\_interp}'s single accumulator to $n$ accumulators. The
|
||||
concrete payoff is measured on the real engine pattern: the per-GDN-layer chain
|
||||
\code{rms\_norm(x)*w*silu(g)} fuses from \textbf{9 launches to 5}, bit-exact
|
||||
($\text{max}=2.98\times10^{-8}$) against the na\"ive per-op path --- directly draining the prefill
|
||||
launch storm these hybrid models suffer.
|
||||
\paragraph{Where the product won't collapse (\cite{mmq}).} Honesty is part of the thesis. Block-quantized
|
||||
tensor-core MMQ --- the kernel that governs prefill --- does \emph{not} collapse through the portable API:
|
||||
its accumulator fragment is opaque, forcing a per-block scale round-trip that caps it near one-tenth of
|
||||
hand-tuned (proven sync-bound because better tiling is \emph{slower}). The viable route is per-backend
|
||||
anyway. So MMQ stays hand-written behind exactly the island seam built for it --- a named boundary, not a
|
||||
quiet exclusion.
|
||||
|
||||
\subsection{The bit-exact gate}
|
||||
Every fused chain is checked three ways on the CPU runtime: (i) the fused one kernel, (ii) the na\"ive
|
||||
$N$-kernel pipeline that materializes $N{-}1$ device intermediates, and (iii) a plain-Rust reference ---
|
||||
all byte-for-byte identical. The tests exercise the two patterns the llama.cpp fusion audit named plus a
|
||||
stress case: the SwiGLU tail \code{silu(a)*b} (2 kernels $\to$ 1) and \code{silu(a*w+b)} (3 $\to$ 1) are
|
||||
\emph{byte-identical} fused-versus-na\"ive with reference error $\le 4.8\times10^{-7}$, and a 7-op mixed
|
||||
chain (\code{gelu((|2a{-}1|+b)/sigmoid(w)).tanh()}) runs in one launch, matching its host oracle. The
|
||||
device morphism table \code{apply\_un\_dev} is a line-for-line mirror of the host \code{apply\_un} ---
|
||||
which is \emph{why} fused equals oracle bit-exactly, not merely within tolerance.
|
||||
\paragraph{Verify, then delete (\cite{method}).} The migration method ties it together: a hand-written
|
||||
kernel is retired only when its single-source twin is both bit-exact against the oracle and at least as
|
||||
fast on a kernel-only benchmark, cell by cell through a per-\code{(op,type,backend)} registry, so the
|
||||
engine is faster-or-equal at every step. Cells have already flipped on \code{main} --- including a Vulkan
|
||||
\code{add\_rmsnorm} twin that was \emph{7.1$\times$ faster} than the shader it replaced and bit-exact.
|
||||
|
||||
\paragraph{Why this is the right base.} Fusibility is a property of the op's \emph{type}, computed once,
|
||||
not rediscovered by a matcher for each new op combination. Adding an op adds one algebra variant and its
|
||||
device mirror; it does not add fusion rules. The reduction and matmul kernels stay hand-tuned and the
|
||||
Map regions attach to them as prologue/epilogue --- the fusion composes \emph{with} the peak kernels
|
||||
rather than competing with them.
|
||||
\section{Evidence, in one breath}
|
||||
\label{sec:evidence}
|
||||
|
||||
\section{Mechanism II: comptime-specialized autotune}
|
||||
\label{sec:tune}
|
||||
|
||||
\subsection{The collapse, closed on the schedule}
|
||||
The DSL already gives one source lowered to every backend. Autotune closes the loop on the last place a
|
||||
backend used to fork a kernel: the \emph{schedule}. Hand-tuning a schedule per device --- tile size,
|
||||
vector width, unroll factor, cube dimension --- is the ``same op, $N$ forks, $N$ drifting numeric
|
||||
behaviors'' bug class in a subtler dress. The fix is to expose the schedule as \code{\#[comptime]} knobs
|
||||
on the \emph{one} source. Each knob value is a distinct compiled kernel (CubeCL monomorphizes on comptime
|
||||
state), so a small variant set is enumerated, timed once per $(\text{device},\text{op},\text{shape})$,
|
||||
and the winner cached. There are no forks: the source is one; only the chosen comptime tuple differs per
|
||||
device. Crucially the knobs never change the numerics --- the per-row reduction is the same fold in the
|
||||
same order for every schedule --- so \emph{every variant is byte-identical}, and the tuner buys speed
|
||||
without ever risking a numeric change.
|
||||
|
||||
\subsection{The algebra: Variant, Tuned, Tuner}
|
||||
Three orthogonal values (Listing~\ref{lst:tune}): a \code{Variant} is a named schedule --- a closure
|
||||
\code{f(iters) -> (output, ms)} that both benchmarks (many iters) and executes (one iter); \code{Tuned}
|
||||
is the fluent builder collecting variants for one $(\text{op},\text{shape-key})$; \code{Tuner} is the
|
||||
cache, an in-memory $(\text{device},\text{op},\text{key})\to\text{winner}$ map backed by a per-device
|
||||
on-disk file. The \code{Tuner} is a plain value over an explicit cache root, so it is fully unit-testable
|
||||
offline --- no GPU, no env, no globals; the process-wide default roots at the XDG cache dir and the disk
|
||||
format is one TSV line per key (no serde, no new dependency).
|
||||
|
||||
\begin{lstlisting}[language=Rust,caption={The production surface. One source per op, autotuned per
|
||||
device, no forks. On a cache hit only the winner launches (once, to produce its output); the losers are
|
||||
never run.},label={lst:tune}]
|
||||
let y = Tuned::new("rms_norm", format!("rows={rows},n={n}"))
|
||||
.variant("b64_r1", |it| rms_norm_tuned_bench(&c, x, w, rows, n, eps, 1, 64, it))
|
||||
.variant("b128_r1", |it| rms_norm_tuned_bench(&c, x, w, rows, n, eps, 1, 128, it))
|
||||
.variant("b256_r1", |it| rms_norm_tuned_bench(&c, x, w, rows, n, eps, 1, 256, it))
|
||||
.variant("b64_r2", |it| rms_norm_tuned_bench(&c, x, w, rows, n, eps, 2, 64, it))
|
||||
.run(&c); // times each once per (device,op,shape), caches the winner, thereafter runs it
|
||||
\end{lstlisting}
|
||||
|
||||
\subsection{The variant tables and their proof}
|
||||
Two ops ship autotuned (Table~\ref{tab:variants}). Both variant sets are proven on the CPU runtime:
|
||||
every schedule is bit-identical to the oracle (rms\_norm: byte-for-byte equal, the fold order is
|
||||
invariant; \dpa{}: byte-identical across variants, within $2\times10^{-3}$ f32-reorder of the
|
||||
per-element oracle), and the cache mechanism is exercised directly --- a miss benchmarks all four and
|
||||
records the winner, a second call is a hit that benchmarks zero, a fresh \code{Tuner} over the same
|
||||
directory reloads the winner from disk and skips timing, and a changed variant set is treated as a miss
|
||||
and re-tuned rather than trusting a stale name. Timing on the CPU proves the select/cache machinery; a
|
||||
GPU run additionally makes the \emph{winner} meaningful.
|
||||
|
||||
\begin{table}[h]
|
||||
\centering\small
|
||||
\setlength{\tabcolsep}{5pt}
|
||||
\begin{tabular}{@{}lll@{}}
|
||||
\toprule
|
||||
\textbf{Op} & \textbf{Knobs} & \textbf{Variants (all byte-identical)} \\
|
||||
\midrule
|
||||
\code{rms\_norm} & cube dim $\{64,128,256\}\times$ unroll $\{1,2\}$ & \code{b64\_r1 b128\_r1 b256\_r1 b64\_r2} \\
|
||||
\code{matvec\_q8\_dp4a} & cube dim $\{64,128,256\}\times$ vec.\ width $\{1,2,4,8\}$ & \code{b64\_v1 b64\_v4 b128\_v2 b256\_v8} \\
|
||||
\bottomrule
|
||||
\end{tabular}
|
||||
\caption{The autotune variant sets (\hk{} 0.2.17). A knob value is a distinct compiled kernel;
|
||||
the tuner caches the winner per $(\text{device},\text{op},\text{shape})$ to
|
||||
\code{\$XDG\_CACHE\_HOME/hanzo-kernel/autotune/<device>.tsv}.}
|
||||
\label{tab:variants}
|
||||
\end{table}
|
||||
|
||||
\subsection{Why first-party}
|
||||
CubeCL ships an autotuner; it was studied and declined for three concrete reasons, not NIH. It is
|
||||
re-exported only from \code{cubecl\_runtime}, so adopting it would force a \emph{second} engine crate
|
||||
into a facade architected so only \code{Cargo.toml} names the engine; its persistent cache lives under
|
||||
\code{target/} rather than the XDG path this layer requires; and its generic-associated-type input
|
||||
machinery is a large surface for ``pick the fastest of $N$ named closures.'' The concepts above are
|
||||
deliberately the same as CubeCL's, sized to this crate and pointed at XDG --- a thin analog, not a new
|
||||
philosophy.
|
||||
|
||||
\section{Mechanism III: intrinsic islands}
|
||||
\label{sec:island}
|
||||
|
||||
\subsection{Killing the whole-kernel escape hatch}
|
||||
Sometimes one inner loop needs a target-specific instruction or idiom. The wrong response is to fork a
|
||||
parallel per-backend kernel --- that is the product, back again. The right response is a scoped,
|
||||
target-gated \code{island!} block \emph{inside} the one DSL kernel (Listing~\ref{lst:island}). The kernel
|
||||
keeps one signature, one launch contract, one CPU oracle, one test. The \code{default} arm is the
|
||||
normative semantics; accelerated arms are optimizations that must equal it.
|
||||
|
||||
\begin{lstlisting}[language=Rust,caption={An island in a \dpa{} matvec. The accelerated arm issues the
|
||||
hardware 4-wide int8 dot; \code{default} --- the normative oracle, taken by CPU and any unlisted target
|
||||
--- computes the identical contraction with portable scalar lane MACs.},label={lst:island}]
|
||||
let dp = island! {
|
||||
cuda | rocm | metal | vulkan => { w.dot(x) } // hardware dp4a / OpSDot
|
||||
default => { w[0]*x[0] + w[1]*x[1] + w[2]*x[2] + w[3]*x[3] } // NORMATIVE oracle
|
||||
};
|
||||
\end{lstlisting}
|
||||
|
||||
\subsection{The mechanism: target selection at comptime}
|
||||
CubeCL compiles the same target-agnostic IR for every backend; at \code{\#[cube]} expand time there is no
|
||||
``which backend am I'' string, only a capability set. But the backend brand \emph{is} known on the host,
|
||||
via the runtime name. So an island is selected by a \code{\#[comptime] target: Target} tag the launch
|
||||
layer derives from the runtime (\code{Target::of::<R>}) and threads in; the \code{\#[kernel]} macro
|
||||
rewrites \code{island!\{...\}} into a comptime \code{match target}. A comptime match over a const
|
||||
scrutinee lowers \emph{only} the selected arm --- every backend compiles exactly its own arm, nothing
|
||||
else. The tag is out of the authoring surface twice over: the branch site names backends, not a target,
|
||||
and the launch wrapper fills the tag, so callers pass only data.
|
||||
|
||||
\subsection{The oracle contract, tested}
|
||||
The CPU runtime is always tagged \code{Target::Cpu} --- a variant no accelerated arm claims --- so on CPU
|
||||
every island resolves to \code{default}. This makes the CPU runtime the single bit-exact oracle for every
|
||||
island, and the test proves it directly: run the kernel on CPU with each accelerated tag \emph{forced},
|
||||
and it must equal the \code{default} result byte-for-byte. The shipped test drives all five accelerated
|
||||
tags (\code{Cuda, Rocm, Metal, Vulkan, WebGpu}) through the CPU runtime and asserts each is byte-identical
|
||||
to \code{default} and to the plain-Rust reference, and separately asserts the production launch (which
|
||||
derives the tag from the runtime) resolves to the oracle on CPU without the caller naming a target. An
|
||||
accelerated arm is thus never trusted; it is proven equivalent to the normative arm before it can ship.
|
||||
This is the designed seam for the one case the rest of the paper cannot make portable ---
|
||||
block-quantized tensor-core GEMM (\S\ref{sec:negative}).
|
||||
|
||||
\section{The CPU runtime as bit-exact oracle, and its CI}
|
||||
\label{sec:oracle}
|
||||
|
||||
The three mechanisms share one foundation: \textbf{the CPU runtime is the single bit-exact reference for
|
||||
every backend.} It is not an accelerator target --- it is the oracle. Every op ships with a plain-Rust
|
||||
reference whose scalar semantics the device code mirrors line-for-line; the fusion engine's device
|
||||
morphism table mirrors its host table; every island's \code{default} arm \emph{is} the CPU semantics; and
|
||||
every autotune variant is checked against the oracle. Because a Map's arithmetic is index-local and the
|
||||
CPU runs it in program order, the CPU result is the definition, and a GPU backend differs only by f32
|
||||
re-association where a reduction reorders a sum --- a difference bounded and explained, not a bug to
|
||||
hunt. The op library --- \code{rms\_norm}, \code{layer\_norm}, \code{rope} (both NeoX and GPT-J
|
||||
conventions), \code{sdpa} (online/flash softmax), the GDN \code{conv1d}/\code{gating}/\code{scan}, the
|
||||
quant matvecs, and the fusion chains --- is gated this way across 36 CPU tests.
|
||||
|
||||
This law is enforced continuously. A nightly GitHub Actions workflow
|
||||
(\code{.github/\allowbreak{}workflows/\allowbreak{}gpu-tests.yml}) runs a three-backend matrix on self-hosted boxes --- one runner
|
||||
label per backend: \code{cuda} $\to$ spark (NVIDIA GB10, sm\_121), \code{rocm} $\to$ evo (AMD Strix Halo
|
||||
gfx1151, ROCm~7.x), \code{metal} $\to$ dbc (Apple M4~Max) --- each running
|
||||
\code{cargo test -p hanzo-ml} \code{-{}-features <backend>} \code{-{}-test-threads 1} behind a
|
||||
GPU-guard step. Serial
|
||||
execution and the guard keep a single GPU consumer at a time per backend, which is not fastidiousness but
|
||||
necessity: concurrent ROCm inits race in the kernel-fusion driver and wedge it until reboot. The
|
||||
practical result is that ``the same op has the same numbers on every backend'' is a checked invariant,
|
||||
not an aspiration.
|
||||
|
||||
\section{Evaluation}
|
||||
\label{sec:eval}
|
||||
|
||||
We separate what is a committed, reproducible test (bit-exactness) from what is a measured benchmark on
|
||||
named hardware.\footnote{Bit-exactness figures are enforced by the committed CPU/GPU tests cited.
|
||||
Throughput and latency figures are session benchmarks on the named machines: evo (AMD Strix Halo,
|
||||
gfx1151, RADV/Vulkan and ROCm~7.13), spark (NVIDIA GB10, sm\_121), dbc (Apple M4~Max, Metal).
|
||||
Benchmarks are kernel-only (buffers created once, $N$ dispatches enqueued back-to-back, one final
|
||||
sync).}
|
||||
|
||||
\subsection{Cross-backend bit-exactness}
|
||||
The op library is bit-exact against the CPU oracle for every family it targets, and --- the part that
|
||||
matters --- the DSL \emph{dispatches} bit-exact through real backend runtimes, not merely compiles
|
||||
(Table~\ref{tab:bitexact}). The ROCm figure is the falsifiable end-to-end proof that the
|
||||
cubecl-hip$\to$ROCm~7.13 seam works: the same \code{rms\_norm} source lowered through cubecl-hip and
|
||||
dispatched on gfx1151 (device query via the R0600 bindings $+$ HIP-RTC module compile $+$ launch $+$
|
||||
readback) matches the CPU oracle to $\text{max\_rel}=2.43\times10^{-7}$, well under the $2\times10^{-3}$
|
||||
gate --- pure f32-reorder noise, not a decode defect.
|
||||
|
||||
\begin{table}[h]
|
||||
\centering\small
|
||||
\setlength{\tabcolsep}{5pt}
|
||||
\begin{tabular}{@{}lll@{}}
|
||||
\toprule
|
||||
\textbf{Path} & \textbf{Op(s)} & \textbf{Result} \\
|
||||
\midrule
|
||||
CPU oracle & rms/layer\_norm, rope$\times$2, sdpa, gdn, quant, fuse & 10/10 bit-exact (36 tests) \\
|
||||
DSL $\to$ Vulkan (gfx1151) & rms\_norm, softmax, matvec, sdpa & bit-exact, real dispatch \\
|
||||
DSL $\to$ Metal (M4 Max) & rms\_norm, layer\_norm, rope, sdpa & bit-exact ($2.35\mathrm{e}{-}7$) \\
|
||||
DSL $\to$ ROCm (gfx1151) & rms\_norm & $\text{max\_rel}=2.43\mathrm{e}{-}7$ \\
|
||||
\bottomrule
|
||||
\end{tabular}
|
||||
\caption{The same source, dispatched through each backend's real runtime, matches the CPU oracle. The
|
||||
\code{sdpa} online-softmax being a \emph{single} implementation is the structural cure for the 8B
|
||||
repetition-collapse: with one attention kernel there is no flash-vs-eager-vs-Metal fork to diverge.}
|
||||
\label{tab:bitexact}
|
||||
\end{table}
|
||||
|
||||
\subsection{Portability costs nothing: the DSL matches and beats hand-tuned}
|
||||
The central risk of any portable DSL is a performance downgrade. It does not materialize on the
|
||||
bandwidth-bound kernels that dominate decode. A tuned single-source \dpa{} Q8 matvec ---
|
||||
\code{Vector<i8,4>}$\to$\code{OpSDot}, one thread block per output row with its threads striding the
|
||||
$k/4$ groups so adjacent threads read adjacent 4-byte packed weights (coalesced 256-byte transactions),
|
||||
partials tree-reduced in shared memory --- measures \textbf{196~GB/s} on evo (gfx1151, Vulkan, $8192^2$,
|
||||
cache-busting), which is \textbf{118\% of the $\sim$166~GB/s hand-tuned \dpa{} kernel} it would replace
|
||||
and $\sim$77\% of the $\sim$256~GB/s DRAM roofline, bit-exact against the CPU oracle. The na\"ive
|
||||
one-thread-per-row kernel that started at $\sim$17~GB/s was memory-\emph{uncoalesced}, not DSL-limited;
|
||||
the fix was two standard decomplected steps --- coalescing and reduction --- both expressed in the DSL.
|
||||
The DSL reaches the same hardware integer-dot instruction and, with standard coalescing, \emph{beats} the
|
||||
hand-written code.
|
||||
|
||||
\subsection{A migration that improved performance}
|
||||
Retirement is real, not hypothetical, and it can be a strict win. Two hand-written Vulkan kernels were
|
||||
drained on \code{main}: the DSL block-per-row \code{rms\_norm} (270~GB/s) and \code{softmax} (268~GB/s)
|
||||
replaced na\"ive hand-written \code{.comp} shaders, each now the production kernel with its \code{.comp}
|
||||
deleted, dispatching bit-exact through the engine's real Vulkan pipeline. The sharpest example is the
|
||||
fused residual-add\,+\,RMSNorm: the DSL \code{add\_rmsnorm} twin ran \textbf{7.1\x{} faster} than the
|
||||
incumbent hand-written shader ($166.5\to23.4$~ms at $4096^2$ on RADV) \emph{and} was bit-exact ---
|
||||
$s=x{+}\text{res}$ byte-identical, $y$ within $4.8\times10^{-7}$. A ``uniformity'' change turned out to
|
||||
also be a large speedup, because the incumbent was na\"ive and the DSL block kernel is coalesced. The
|
||||
migration is faster-or-equal at every step by construction: a kernel is retired only when its DSL twin is
|
||||
bit-exact \emph{and} at least as fast.
|
||||
|
||||
\subsection{The discipline cuts both ways}
|
||||
The gate also \emph{declines} migrations. Where a hand-tuned kernel is already optimal --- the Vulkan
|
||||
quant matvec, Metal's \code{simd\_sum} reductions, the fused \code{rope\_norm} epilogue --- agents built
|
||||
bit-exact DSL twins, measured, and correctly did \emph{not} swap: a uniformity-only change with zero
|
||||
speedup and real regression risk is churn, not migration. ``One source'' is the goal; a bit-exact,
|
||||
no-faster twin is kept as the portable fallback for backends that lack the tuned version, and the tuned
|
||||
peak stays the specialized fast path. We only ever add coverage and correctness.
|
||||
|
||||
\subsection{The ROCm-7 seam, as an engineering note}
|
||||
Shipping the DSL on AMD required fixing two independent upstream defects without bumping CubeCL (0.10 is
|
||||
the newest published; no version escapes either). First, cubecl-hip-sys selects its FFI bindings from
|
||||
\code{hipconfig --version}; ROCm~7.13 reports patch 99004, past the newest shipped binding, so upstream
|
||||
emitted a feature with no bindings module and every symbol went undefined. The fix is a vendored
|
||||
\code{[patch.crates-io]} fork whose \code{build.rs} clamps an unknown-newer patch to the latest shipped
|
||||
binding --- ABI-safe because AMD versions every changed symbol (R0600) and 7.13 still maps
|
||||
\code{hipGetDeviceProperties} to that revision. Second, cubecl-hip's server uses a \code{MultiStream}
|
||||
gated behind a \code{std}-only cfg the \code{hip}/\code{rocm} features do not pull, so the ROCm feature is
|
||||
declared \code{rocm = ["cubecl/hip", "cubecl/rocm", "cubecl/std"]}. Both are recorded because a portable
|
||||
DSL lives or dies on the least-loved backend building cleanly.
|
||||
|
||||
\section{A negative result: int8 tensor-core MMQ}
|
||||
\label{sec:negative}
|
||||
|
||||
Not everything collapses. The universal prefill gap Paper~06 documents (dense $0.6$--$0.9\x$ of
|
||||
llama.cpp on every backend) is a tensor-core-MMQ gap: llama quantizes the activation to q8\_1 (int8) and
|
||||
contracts block-quantized weights against it on the matrix cores. We asked whether that kernel, too, can
|
||||
be written once in \hk{} and lowered to the hardware int8 matrix cores instead of hand-written four times
|
||||
(CUDA \code{fast\_mmq}, Vulkan \code{mul\_mm\_q8\_mmq}, ROCm \code{qmmq}, Metal simdgroup). The answer,
|
||||
honestly, is \emph{not through the portable API}, and the reason is structural rather than a missing
|
||||
optimization.
|
||||
|
||||
\subsection{The structural cap}
|
||||
CubeCL exposes two routes to the int8 matrix cores. The high-level WMMA API (\code{cmma::Matrix},
|
||||
$16{\times}16{\times}16$, i8$\to$i32) loads and stores accumulator fragments automatically, one tile at a
|
||||
time --- but \emph{the fragment layout is opaque}. Block-quantized GEMM needs a per-32-block f32 scale
|
||||
applied to the integer accumulator, and int8 accumulation is only valid \emph{within} one constant-scale
|
||||
32-block (this is inherent to the format, not a kernel choice). With an opaque fragment the only place to
|
||||
apply the scale is a store$\to$sync$\to$reload round-trip through shared memory, \emph{once per block}.
|
||||
That serialization is the wall. Measured on GB10/Blackwell, the high-level WMMA MMQ reaches only
|
||||
$\sim$7.6~TFLOP/s, roughly one-tenth of the hand-tuned kernel, and --- the diagnostic that proves it is
|
||||
sync-bound, not occupancy-bound --- a \emph{better-tiled} version (a $32{\times}64$ output tile, 8 warps,
|
||||
operands staged to shared memory once per block and reused across the tile, which fixes occupancy and
|
||||
operand reuse) is \emph{slower}, because the only remaining tax is exactly the per-block scale round-trip
|
||||
through the opaque fragment, and better tiling adds more of them.\footnote{The structural argument (opaque
|
||||
fragment $\Rightarrow$ per-block shared-memory scale round-trip; manual MMA with exposed lane map as the
|
||||
higher-ceiling path) is documented in the committed \code{mmq.rs} probe. The TFLOP/s figures and the
|
||||
tiled-is-slower comparison are session measurements on spark (GB10); the probe commit records CUDA
|
||||
verify/bench as pending, so we report them as measured, not as a committed test.}
|
||||
|
||||
\subsection{The viable route is per-backend anyway}
|
||||
The higher-ceiling path is manual MMA (\code{cmma::MmaDefinition}, \code{mma.sync} at
|
||||
$16{\times}8{\times}32$), where the fragment element-to-lane map \emph{is} exposed, so the block scale can
|
||||
be applied in register with no shared-memory round-trip. But that path is per-backend by nature ---
|
||||
CubeCL 0.10's int8 MMA is CUDA-only (the probe kernels are literally \code{\#[kernel(targets(cuda))]}) ---
|
||||
so writing it does not collapse the product; it writes one more backend-specific kernel, just in Rust.
|
||||
The honest conclusion is therefore: \textbf{block-quantized MMQ stays hand-written behind benchmark
|
||||
gates.} This is not a defeat of the thesis but a boundary the thesis anticipates. The decode matvecs,
|
||||
norms, RoPE, attention, GDN, and the entire elementwise fabric --- the bulk of the ledger and the whole
|
||||
of the divergence-bug surface --- collapse. The one op that resists is exactly the one for which
|
||||
\S\ref{sec:island} built the seam: an island holds the hand-written tensor-core arm behind a normative,
|
||||
CPU-checkable \code{default}, so even the un-collapsed kernel keeps one signature, one oracle, one test.
|
||||
The negative result names the boundary and the mechanism that contains it.
|
||||
|
||||
\section{The collapse ledger}
|
||||
\label{sec:ledger}
|
||||
|
||||
Table~\ref{tab:dup} is the denominator; this section is the protocol that drains it. Roughly 85\% of the
|
||||
$52{,}345$ lines are a deletable target --- the hand-written arithmetic duplicated across backends, as
|
||||
opposed to the genuinely peak, genuinely per-backend kernels (the MMQ GEMMs of \S\ref{sec:negative}) that
|
||||
stay behind islands. Draining is never all-or-nothing and never a downgrade, because it is governed by a
|
||||
\textbf{verify-then-delete protocol}:
|
||||
\begin{enumerate}[leftmargin=1.6em,itemsep=1pt]
|
||||
\item \textbf{Coverage first.} The DSL fills the entire $22\,\text{types}\x5\,\text{backends}$ matrix from
|
||||
one source. Cells that were missing or buggy become uniform immediately.
|
||||
\item \textbf{The gate.} A hand-tuned kernel is retired for its DSL twin \emph{only} when the twin is
|
||||
(a)~bit-exact against the shared CPU oracle and (b)~within performance noise on a kernel-only benchmark
|
||||
--- in practice $\ge 97\%$ of the incumbent's throughput. The number decides, per type, per backend.
|
||||
\item \textbf{Delete on parity.} As each twin passes, its hand-written copies are deleted and a
|
||||
per-$(\text{op},\text{type},\text{backend})$ registry flips that cell to the DSL, keyed by the gate
|
||||
result. The product drains to a sum one measured cell at a time, with a working, faster-or-equal engine
|
||||
at every intermediate state.
|
||||
\end{enumerate}
|
||||
Executed instances already on \code{main}: the Vulkan \code{rms\_norm}, \code{softmax}, and
|
||||
\code{add\_rmsnorm} shaders deleted in favor of bit-exact DSL twins (\S\ref{sec:eval}, the last a 7.1\x{}
|
||||
win); the ROCm-7 seam opened so gfx1151 dispatches from the same source; and the DAG fusion cutting the
|
||||
GDN launch storm 9$\to$5. Instances correctly \emph{not} executed: the Vulkan quant matvec and Metal
|
||||
\code{simd\_sum}, where the DSL twin was bit-exact but not faster. The ledger is a scoreboard with a
|
||||
gate, not a rewrite.
|
||||
The claims the tour makes are backed by measurement, detailed in the companion papers: the op library is
|
||||
bit-exact against the CPU oracle and dispatches bit-exact through real Vulkan, Metal, and ROCm runtimes
|
||||
(rms\_norm on gfx1151 to $2.43\times10^{-7}$; \cite{oracle}); a single-source \dpa{} matvec runs at 118\%
|
||||
of the hand-tuned kernel it replaces (196~GB/s on gfx1151; \cite{method}); a shipped migration was
|
||||
7.1$\times$ faster \emph{and} bit-exact (\cite{method}); the autotune variants are byte-identical with a
|
||||
per-device cached winner (\cite{tune}); and the one op that resists is reported in full as a structural
|
||||
negative result (\cite{mmq}). Portability costs nothing where it counts, the collapse is real and
|
||||
reversible, and the boundary is drawn honestly.
|
||||
|
||||
\section{Related work}
|
||||
\label{sec:related}
|
||||
|
||||
Portable kernel authoring and kernel fusion are both well-trodden; the specific claim we make is narrow
|
||||
and we state it precisely. \textbf{Triton} gives a tile-level Python DSL that lowers to GPU code, but
|
||||
fusion is the programmer's job (you write a fused kernel) or the compiler's inside a larger stack; it has
|
||||
no notion of fusion as an algebraic law over an op class. \textbf{XLA} fuses HLO graphs via
|
||||
producer-consumer heuristics and a cost model; \textbf{torch.compile}/TorchInductor captures an FX graph
|
||||
and fuses by a scheduler's heuristics. Both fuse by \emph{analysis over a graph}, not by a fusibility
|
||||
Portable kernel authoring and kernel fusion are both well-trodden; our specific claim is narrow, and we
|
||||
state it precisely. \textbf{Triton} gives a tile-level Python DSL, but fusion is the programmer's job or a
|
||||
larger stack's; it has no notion of fusion as an algebraic law over an op class. \textbf{XLA} fuses HLO
|
||||
graphs by producer-consumer heuristics and a cost model; \textbf{torch.compile}/TorchInductor captures a
|
||||
graph and fuses by a scheduler's heuristics --- both fuse by \emph{analysis over a graph}, not by a
|
||||
predicate that is a function of the op's type. \textbf{Halide} and \textbf{TVM} pioneered separating the
|
||||
\emph{compute} from the \emph{schedule} and autotuning the latter --- this is the nearest neighbor of our
|
||||
autotune mechanism (\S\ref{sec:tune}), and we claim no novelty for schedule/compute separation itself;
|
||||
what differs is that our schedule knobs are \code{\#[comptime]} monomorphizations of one kernel proven
|
||||
byte-identical, so autotuning is guaranteed not to perturb numerics. \textbf{Burn}'s \code{burn-fusion}
|
||||
is the closest relative to our fusion engine: it records an op stream and JITs fused elementwise kernels
|
||||
over the \emph{same} CubeCL substrate. The difference is where fusibility comes from --- \code{burn-fusion}
|
||||
traces Burn tensor ops and applies its own cut/epilogue policy, coupling to Burn's tensor abstraction;
|
||||
ours is the functor law over an op \emph{class}, one predicate, on our own tensor type, composing with our
|
||||
hand-tuned peak kernels. \textbf{Mojo} is a language for kernels, not a fusion-as-composition model.
|
||||
Across all of these, we are unaware of a system that presents kernel fusion \emph{as} morphism composition
|
||||
--- the functor law $\mathrm{map}(g)\circ\mathrm{map}(f)=\mathrm{map}(g\circ f)$ as the mechanical rule
|
||||
and Reduce/Scan as the algebraic fences --- with a bit-exact CPU oracle as the definitional semantics.
|
||||
That framing, and the three mechanisms it organizes, is the contribution; the substrate (CubeCL) and the
|
||||
individual ideas (portable IR, autotuning, oracle testing) are prior art we build on and cite.
|
||||
\emph{compute} from the \emph{schedule} and autotuning the latter; this is the nearest neighbor of our
|
||||
autotune mechanism (\cite{tune}), and we claim no novelty for schedule/compute separation itself --- what
|
||||
differs is that our schedule knobs are compile-time monomorphizations of one kernel proven byte-identical,
|
||||
so autotuning cannot perturb numerics. \textbf{Burn}'s \code{burn-fusion} is the closest relative to our
|
||||
fusion engine: it records an op stream and JITs fused elementwise kernels over the \emph{same} CubeCL
|
||||
substrate, but it traces Burn tensor ops with its own cut/epilogue policy, coupling to Burn's tensor
|
||||
abstraction; ours is the functor law over an op \emph{class}, one predicate, on our own tensor type,
|
||||
composing with our hand-tuned peak kernels. \textbf{Mojo} is a language for kernels, not a
|
||||
fusion-as-composition model. Across all of these we are unaware of a system that presents kernel fusion
|
||||
\emph{as} morphism composition --- the functor law as the mechanical rule, Reduce/Scan as the algebraic
|
||||
fences --- anchored by a bit-exact CPU oracle as the definitional semantics. That framing, and the three
|
||||
mechanisms it organizes, is the contribution; the substrate (CubeCL) and the individual ideas (portable
|
||||
IR, autotuning, oracle testing) are prior art we build on and cite.
|
||||
|
||||
\section{Conclusion}
|
||||
|
||||
The kernel layer is a Cartesian product of backends, quantization types, and operations, hand-written in
|
||||
five shading languages, and it is the breeding ground for the divergence bugs that cost the most to
|
||||
chase. \hk{} collapses that product to a sum on one idea --- fusion is composition --- extended by three
|
||||
mechanisms that each attack an axis: autotune folds the schedule fork into comptime knobs proven
|
||||
byte-identical; islands hold the rare per-backend idiom behind a normative, CPU-checkable arm; and the CPU
|
||||
runtime is the single bit-exact oracle every backend is measured against, nightly. The evidence is that
|
||||
portability costs nothing where it counts (a single-source \dpa{} matvec at 118\% of hand-tuned, a shipped
|
||||
migration 7.1\x{} faster and bit-exact), that the collapse is real and reversible (a verify-then-delete
|
||||
ledger with kernels already deleted on \code{main}), and --- stated as plainly as the wins --- that one
|
||||
op resists: block-quantized tensor-core MMQ is structurally sync-bound through the portable WMMA API and
|
||||
stays hand-written behind exactly the island seam we built for it. What remains is a disciplined,
|
||||
measured migration whose engine is faster-or-equal at every step.
|
||||
five shading languages, and it is the breeding ground for the divergence bugs that cost the most to chase.
|
||||
\hk{} collapses that product to a sum on one idea --- fusion is composition (\cite{fuse}) --- extended by
|
||||
three mechanisms that each attack an axis: autotune folds the schedule fork into byte-identical comptime
|
||||
knobs (\cite{tune}); islands hold the rare per-backend idiom behind a normative, CPU-checkable arm
|
||||
(\cite{island}); and the CPU runtime is the single bit-exact oracle every backend is measured against,
|
||||
nightly (\cite{oracle}). Portability costs nothing where it counts, the collapse is real and reversible via
|
||||
a verify-then-delete method (\cite{method}), and --- stated as plainly as the wins --- one op resists and
|
||||
stays hand-written behind an island (\cite{mmq}). Read the six companion papers for the depth; this one is
|
||||
the map.
|
||||
|
||||
\begin{thebibliography}{9}
|
||||
\small
|
||||
\bibitem{umbrella} Hanzo AI. \emph{One Source, Every Backend: The hanzo-kernel DSL.} Paper 07 (this umbrella).
|
||||
\bibitem{fuse} Hanzo AI. \emph{Fusion Is Composition: Kernel Fusion as the Functor Law.} Paper 07.1.
|
||||
\bibitem{tune} Hanzo AI. \emph{The Schedule Is a Value: Comptime-Specialized Autotuning.} Paper 07.2.
|
||||
\bibitem{island} Hanzo AI. \emph{Intrinsic Islands: An Oracle-Anchored Escape Hatch.} Paper 07.3.
|
||||
\bibitem{oracle} Hanzo AI. \emph{One Oracle, Every Backend: The CPU Bit-Exactness Law.} Paper 07.4.
|
||||
\bibitem{mmq} Hanzo AI. \emph{When the Product Won't Collapse: An int8 Tensor-Core MMQ Negative Result.} Paper 07.5.
|
||||
\bibitem{method} Hanzo AI. \emph{Verify, Then Delete: Collapsing a Kernel Cartesian Product.} Paper 07.6.
|
||||
\end{thebibliography}
|
||||
|
||||
\end{document}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
% Shared preamble for the hanzo-kernel Paper 07 series.
|
||||
% One style, one vocabulary, inputted by every paper so the series reads as one voice.
|
||||
\usepackage[margin=1in]{geometry}
|
||||
\usepackage{booktabs}
|
||||
\usepackage{amsmath}
|
||||
\usepackage{amssymb}
|
||||
\usepackage{graphicx}
|
||||
\usepackage{xcolor}
|
||||
\usepackage{hyperref}
|
||||
\hypersetup{colorlinks=true, linkcolor=blue, urlcolor=blue, citecolor=blue}
|
||||
\usepackage{enumitem}
|
||||
\usepackage{listings}
|
||||
\usepackage{array}
|
||||
|
||||
\definecolor{kw}{rgb}{0.13,0.29,0.53}
|
||||
\definecolor{cmt}{rgb}{0.40,0.40,0.40}
|
||||
\definecolor{str}{rgb}{0.16,0.50,0.16}
|
||||
\lstset{
|
||||
basicstyle=\ttfamily\footnotesize,
|
||||
keywordstyle=\color{kw}\bfseries,
|
||||
commentstyle=\color{cmt}\itshape,
|
||||
stringstyle=\color{str},
|
||||
showstringspaces=false,
|
||||
breaklines=true,
|
||||
columns=fullflexible,
|
||||
frame=single,
|
||||
framesep=4pt,
|
||||
}
|
||||
\lstdefinelanguage{Rust}{
|
||||
morekeywords={fn,let,mut,pub,struct,impl,trait,enum,match,if,else,for,while,loop,return,use,mod,crate,self,super,async,await,where,type,const,static,unsafe,ref,move,dyn,as,true,false},
|
||||
morecomment=[l]{//}, morecomment=[s]{/*}{*/}, morestring=[b]", sensitive=true,
|
||||
}
|
||||
|
||||
\newcommand{\x}{\ensuremath{\times}}
|
||||
\newcommand{\dpa}{\texttt{dp4a}}
|
||||
\newcommand{\hk}{\texttt{hanzo-kernel}}
|
||||
\newcommand{\code}[1]{\texttt{#1}}
|
||||
|
||||
% Absorb minor prose overfulls without rivers; keep line-breaking sane.
|
||||
\tolerance=800
|
||||
\emergencystretch=3em
|
||||
Reference in New Issue
Block a user