-
Notifications
You must be signed in to change notification settings - Fork 53
feat(ws1): NativeLMHeadOp pure-PyTorch ground-truth reference + numerical contract tests #170
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Flink-ddd
merged 8 commits into
RL-Align:main
from
maxiaosong1124:feat/ws1-lm-head-pytorch-op
Jun 30, 2026
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
5ae1c4b
feat(ws1): add NativeLMHeadOp pure-PyTorch reference
maxiaosong1124 e405f68
test(ws1): address CodeRabbit review on lm_head
maxiaosong1124 a3fb370
update lm_head.py
maxiaosong1124 517b12d
fix(ws1): make lm_head forward_fp32 a strict fp32 reference
maxiaosong1124 8366e7b
Merge remote-tracking branch 'upstream/main' into feat/ws1-lm-head-py…
maxiaosong1124 407200a
test(ws1): address PR #170 review — random-cotangent backward check (…
maxiaosong1124 f9db98e
Merge branch 'main' into feat/ws1-lm-head-pytorch-op
maxiaosong1124 d2f39d8
fix(lint): remove trailing whitespace in registry.py
maxiaosong1124 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| # 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 | ||
| ```python | ||
| 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 global `allow_tf32` flag 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 `matmul` op**: lm_head takes the weight in HF `[out, in]` | ||
| > layout and transposes it internally (`weight.t()`); the `matmul` op computes a bare | ||
| > `a @ b` with 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`): | ||
|
|
||
| ```python | ||
| out = hidden.float() @ weight.float().t() | ||
| if bias is not None: | ||
| out = out + bias.float() | ||
| ``` | ||
|
|
||
| - **Ground truth**: `forward_fp32` accumulates 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**: `forward` runs the projection in the input dtype. Because this is a | ||
| reduction over `hidden`, low-precision accumulation **drifts** from the fp32 reference | ||
| (unlike the lossless embedding gather). Unlike `forward_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=4096` reduction length, | ||
| bf16 drifts ~0.3–0.4% of peak and fp16 ~0.05%. Elementwise `rtol` is 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 | ||
| the `hidden` reduction across threads by the `M = batch*seq` dimension, which silently | ||
| breaks bitwise batch invariance for large `hidden`; the tests pin a single thread to fix | ||
| the order. On GPU, cuBLAS likewise splits K by `M`, so a bitwise batch-invariant GEMM is | ||
| a downstream kernel concern, not a free property of `torch.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 | ||
|
|
||
| ```bash | ||
| python -m pytest tests/test_lm_head.py -v | ||
| ``` | ||
|
|
||
| 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.py` | ||
| - `rl_engine/kernels/registry.py` | ||
| - `tests/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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # Copyright (c) 2026 RL-Kernel Contributors | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from contextlib import contextmanager | ||
| from typing import Optional | ||
|
|
||
| import torch | ||
|
|
||
|
|
||
| class NativeLMHeadOp: | ||
| """ | ||
| Pure PyTorch native language-model-head reference. | ||
| out = hidden @ weight.t() (+ bias) | ||
|
|
||
| Projects hidden states back to vocabulary logits -- the final layer of the | ||
| Qwen3/Llama stack. 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 (``weight.t()``). This is the | ||
| one difference from the bare ``matmul`` op (which computes ``a @ b`` with no | ||
| transpose) -- do not use them interchangeably. The lm_head weight is | ||
| *independent* from the embedding table (``tie_word_embeddings=false``), and | ||
| Qwen3 has no bias (pass ``bias=None``). | ||
|
|
||
| Unlike embedding (a lossless row gather), this is a reduction over the | ||
| ``hidden`` dimension: the low-precision ``forward`` path accumulates in the | ||
| input dtype and therefore drifts from the fp32 ``forward_fp32`` ground | ||
| truth. Axis-B accuracy uses a tolerance (``torch.allclose``), not bitwise | ||
| equality. Axis-A batch invariance still holds bitwise within a single dtype | ||
| (each output row reduces over ``hidden`` independently of the batch). | ||
| """ | ||
|
|
||
| def __init__(self) -> None: | ||
| """No state; the op is a pure function over (hidden, weight, bias).""" | ||
|
|
||
| def __call__( | ||
| self, | ||
| hidden: torch.Tensor, | ||
| weight: torch.Tensor, | ||
| *, | ||
| bias: Optional[torch.Tensor] = None, | ||
| ) -> torch.Tensor: | ||
| """Alias for ``forward`` so the op is callable like a module.""" | ||
| return self.forward(hidden, weight, bias=bias) | ||
|
|
||
| def forward( | ||
| self, | ||
| hidden: torch.Tensor, | ||
| weight: torch.Tensor, | ||
| *, | ||
| bias: Optional[torch.Tensor] = None, | ||
| ) -> torch.Tensor: | ||
| """ | ||
| Canonical entry: project in the input dtype, output the input dtype. | ||
| This is the dtype-behavior path used as the Axis-B accuracy candidate. | ||
| """ | ||
| return self._lm_head( | ||
| hidden, weight, bias, compute_dtype=hidden.dtype, output_dtype=hidden.dtype | ||
| ) | ||
|
|
||
| def forward_fp32( | ||
| self, | ||
| hidden: torch.Tensor, | ||
| weight: torch.Tensor, | ||
| *, | ||
| bias: Optional[torch.Tensor] = None, | ||
| ) -> torch.Tensor: | ||
| """Ground truth: upcast to fp32, accumulate in fp32, force fp32 output. | ||
|
|
||
| The matmul is wrapped to disable autocast and TF32 so this stays a true | ||
| fp32 reference regardless of the caller's ambient precision context. | ||
| """ | ||
| return self._lm_head( | ||
| hidden, | ||
| weight, | ||
| bias, | ||
| compute_dtype=torch.float32, | ||
| output_dtype=torch.float32, | ||
| strict_fp32=True, | ||
| ) | ||
|
|
||
| # ------------------------------------------------------------------ # | ||
| # Helpers | ||
| # ------------------------------------------------------------------ # | ||
| @staticmethod | ||
| def _lm_head( | ||
| hidden: torch.Tensor, | ||
| weight: torch.Tensor, | ||
| bias: Optional[torch.Tensor], | ||
| *, | ||
| compute_dtype: torch.dtype, | ||
| output_dtype: torch.dtype, | ||
| strict_fp32: bool = False, | ||
| ) -> torch.Tensor: | ||
| """Core matmul: cast to ``compute_dtype``, project, optionally add bias, cast out. | ||
|
|
||
| When ``strict_fp32`` is set, the matmul runs with autocast disabled and | ||
| CUDA TF32 turned off so the fp32 reference path is not silently downcast | ||
| by the caller's ambient autocast/TF32 settings. | ||
| """ | ||
| h = hidden.to(compute_dtype) | ||
| w = weight.to(compute_dtype) | ||
| # [..., hidden] @ [hidden, vocab] -> [..., vocab]; weight is [vocab, hidden] (HF [out, in]). | ||
| if strict_fp32: | ||
| with NativeLMHeadOp._strict_fp32_matmul(h.device.type): | ||
| out = h @ w.t() | ||
| else: | ||
| out = h @ w.t() | ||
| if bias is not None: | ||
| out = out + bias.to(compute_dtype) | ||
| return out.to(output_dtype) | ||
|
|
||
| @staticmethod | ||
| @contextmanager | ||
| def _strict_fp32_matmul(device_type: str): | ||
| """Disable autocast and TF32 for a true fp32 matmul, restoring state after.""" | ||
| prev_tf32 = torch.backends.cuda.matmul.allow_tf32 | ||
| torch.backends.cuda.matmul.allow_tf32 = False | ||
| try: | ||
| with torch.autocast(device_type=device_type, enabled=False): | ||
| yield | ||
| finally: | ||
| torch.backends.cuda.matmul.allow_tf32 = prev_tf32 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One precision nit: forward_fp32() casts the inputs to fp32, but the matmul can still run under autocast or CUDA TF32 settings, so it may not be a true fp32 golden reference. Since downstream kernels will compare against this path, could we explicitly disable autocast/TF32 around the matmul, or document that required precision context?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, @inaniloquentee .
forward_fp32() now disables autocast and CUDA TF32 around the matmul, while saving/restoring the previous TF32 setting so global state does not leak. The regular forward() path is unchanged and still
follows the ambient precision context.
I also added regression coverage for this:
Docs were updated to note the precision-context behavior.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM!