LM Head¶
The lm_head operator projects hidden states back to vocabulary logits — the final layer of the Qwen3/Llama stack. It is a WS1 ground-truth reference (issue #108): a pure-PyTorch definition of the "correct answer" that downstream fused CUDA/Triton kernels are validated against.
- LM Head (
NativeLMHeadOp):out = hidden @ weight.t() (+ bias).
For Qwen3-8B the weight is the output projection [vocab=151936, hidden=4096] in the
HF nn.Linear [out, in] convention, so it is transposed internally. It is
independent from the embedding table (tie_word_embeddings=false) — the two
weights are not shared — and Qwen3 has no bias (bias=None).
Entry Point¶
from rl_engine.kernels.registry import kernel_registry
lm_head = kernel_registry.get_op("lm_head")
logits = lm_head(hidden, weight) # [B, S, hidden], [vocab, hidden] -> [B, S, vocab]
logits = lm_head(hidden, weight, bias=b) # optional [vocab] bias
The op exposes the WS1 dual-path contract:
forward(...)— projects in the input dtype, returns the input dtype (Axis-B accuracy candidate / dtype-behavior path).forward_fp32(...)— upcasts to fp32, accumulates in fp32, returns fp32 (the ground-truth golden path). The matmul runs with autocast disabled and CUDA TF32 turned off, so it stays a true fp32 reference regardless of the caller's ambient precision context (the globalallow_tf32flag is saved and restored around it).
Backends¶
| Backend | Wrapper | Native symbol | Status |
|---|---|---|---|
| PyTorch fallback | NativeLMHeadOp |
None | fp32 ground-truth reference; CPU and any GPU. |
| CUDA / ROCm / Triton | — | — | Planned: downstream fused kernels validate against this reference. |
Tensor Contract¶
| Argument | Shape | Dtype | Requirements |
|---|---|---|---|
hidden |
[B, S, hidden] (any leading dims) |
float (fp16/bf16/fp32) | Hidden states (Qwen3-8B hidden=4096). |
weight |
[vocab, hidden] |
float | Output projection in HF [out, in] layout; transposed internally. Qwen3-8B [151936, 4096]. |
bias |
[vocab] or None |
float | Optional; Qwen3 has none (None). |
| output | hidden.shape[:-1] + (vocab,) |
forward: hidden dtype · forward_fp32: float32 |
Logits. |
Output dtype follows hidden. Pure function — no randomness, no in-place mutation,
device/dtype follow the inputs.
Difference from the bare
matmulop: lm_head takes the weight in HF[out, in]layout and transposes it internally (weight.t()); thematmulop computes a barea @ bwith no transpose. Do not use them interchangeably.
Dispatch Behavior¶
kernel_registry.get_op("lm_head") resolves through the OpBackend priority map. On
cuda / rocm / cpu the only registered backend today is the PyTorch native op
(PYTORCH_NATIVE_LM_HEAD), so every device dispatches to the fp32 reference. When fused
kernels land, they are prepended to the priority list and the native op becomes the fallback.
Accuracy¶
Reference semantics (forward_fp32):
- Ground truth:
forward_fp32accumulates in and returns fp32, with autocast and CUDA TF32 disabled so it is a true fp32 reference even if the caller has TF32 or autocast enabled. - Dtype path:
forwardruns the projection in the input dtype. Because this is a reduction overhidden, low-precision accumulation drifts from the fp32 reference (unlike the lossless embedding gather). Unlikeforward_fp32, this path intentionally follows the ambient precision context (it is the dtype-behavior path): with an fp32 input it is bitwise-equal to the ground truth when ambient TF32/autocast is off, but on a TF32-enabled GPU it tracks real hardware behavior and may drift. bf16/fp16 are always checked with a tolerance. - Axis-B — accuracy tolerance: measured as max absolute error relative to the output
peak magnitude. On a SMALL load point with the real
hidden=4096reduction length, bf16 drifts ~0.3–0.4% of peak and fp16 ~0.05%. Elementwisertolis not used: many logits are near zero while the accumulated error tracks the reduction length, not the output value. - Axis-A — batch invariance: a row's logits are independent of the rest of the batch,
so the output is bitwise-identical regardless of batch size or padding (
torch.equal,atol=0) — provided the reduction order is fixed. Multi-threaded CPU GEMM splits thehiddenreduction across threads by theM = batch*seqdimension, which silently breaks bitwise batch invariance for largehidden; the tests pin a single thread to fix the order. On GPU, cuBLAS likewise splits K byM, so a bitwise batch-invariant GEMM is a downstream kernel concern, not a free property oftorch.matmul.
Performance Notes¶
Reference operator — no fused kernel or benchmark yet. Downstream fused kernels carry their own benchmarks and are measured against this reference for correctness.
Tests¶
Covers: fp32 correctness vs naive matmul (bitwise, with ambient TF32 pinned off),
forward_fp32 precision-context safety (true fp32 under ambient autocast + restores the
global TF32 flag on CPU; numerically beats a TF32 matmul on GPU), bf16/fp16 dtype-path
accuracy (relative-to-peak tolerance, with bias), output shape, bias semantics, Axis-A
batch invariance (slice + padding, single-thread reduction, all dtypes), input purity,
gradient flow to hidden/weight (closed-form check), registry dispatch, and a GPU-only
smoke test at the real Qwen3-8B dims (vocab=151936, hidden=4096) that skips when CUDA or
GPU memory is unavailable.
Implementation Files¶
rl_engine/kernels/ops/pytorch/linear/lm_head.pyrl_engine/kernels/registry.pytests/test_lm_head.py
Known Limitations¶
- PyTorch fallback only; no fused CUDA/Triton backend yet (downstream work).
- Axis-A bitwise batch invariance holds only with a fixed reduction order (single-thread on CPU); a batch-invariant GEMM on GPU is a downstream concern.