Skip to content

Commit 1d09c2c

Browse files
kmbandyclaude
andcommitted
feat(MAD-238): paged ml8-4 calibration pipeline + iter 5 PagedLinear
Layer-sequential ml8-4 calibration that runs the entire model through the WeightPager VRAM ring instead of loading every weight resident. Unlocks calibration of models larger than VRAM (35B-A3B target) on a single 32 GiB card. Pipeline (scripts/calibration/): calibrate_ml8_paged.py main driver: load HF model, swap nn.Linear with PagedLinear, run calibration data layer-by-layer with each layer's weights paged in on-demand and evicted after. awq.py AWQ rescaling (per-output-channel) layered over the centroid quantizer. kronecker_rotation.py Kronecker-product Hadamard rotation (small factor on rows, large factor on columns) for outlier mitigation. ml8_to_gguf.py blob -> GGUF tensor format converter. ml8_calibration_sweep.sh matrix-sweep harness (percdamp / Hadamard / group_size cells). MAD_238_DESIGN.md design doc. Plus per-module test_*.py covering awq, centroid snap, kronecker rotation, ml8_io round-trip, ml8_to_gguf format. Python bindings (python_bindings/wp/): wp_bindings.cpp pybind11 wrappers over the WeightPager runtime. paged_linear.py PagedLinear nn.Module: torch tensor wrapped over a pager-managed VRAM ptr; .weight getter calls pager.ensure() and caches the torch view until the slot pointer changes (zero-copy hot path, eager VRAM release on cache invalidate). wp_torch.py helpers: swap_linears_with_paged, page assignment from HF layer names. test_*.py unit + smoke tests for native and torch paths. Parity gate (Qwen3.5-4B, Cell E recipe, MLP-only): HF-loaded baseline : 8.318 PPL (100k tokens, wikitext-2) paged calibration : 8.328 PPL delta : +0.0099 (within ROCm GEMM non-determinism noise; root-cause confirmed via byte-identical HF safetensors vs bf16 GGUF for layer 0) Also lands miscellaneous in-flight artifacts: tests/wmma_rdna4_probe{,_v2}.cu Standalone probes for the RDNA4 WMMA layout investigation (parked until probe-driven mma.cuh fix). tests/perf-baseline/ Accumulated bench JSONs from May 19-23 paged-paths / NIAH-KV / PPL-KV runs. gguf_f16_to_bf16.py F16->BF16 GGUF recast utility built during 0.048 PPL drift investigation, then superseded by direct byte- comparison; kept for reuse. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 42c1825 commit 1d09c2c

41 files changed

Lines changed: 4010 additions & 17 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

python_bindings/wp/paged_linear.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
"""PagedLinear — nn.Linear whose .weight comes from a wp_native.WeightPager.
2+
3+
Design: at model-load time, swap each target nn.Linear in an HF transformers
4+
model with a PagedLinear. HF's forward code uses `self.q_proj(x)` and accesses
5+
`.weight` transparently — the paged page-fault is invisible to the layer's
6+
arithmetic.
7+
8+
Two weight paths:
9+
1. weight_override (post-quant): a torch tensor set by the calibration loop
10+
after quantizing this layer. While set, .weight returns the override and
11+
the pager is bypassed. Used so subsequent layers' forward pass sees the
12+
quantized output of this layer.
13+
2. paged (pre-quant): ensure_as_torch() reads from the pager slot. Used
14+
during forward passes that need the original weight (Hessian collection
15+
pass for THIS layer).
16+
17+
Lifetime: once weight_override is set, the pager slot is free to LRU-evict.
18+
19+
Caching: the paged path caches the materialized tensor keyed by the slot's
20+
src_ptr. Because GGUF pages are read-only, identical src_ptr → identical bytes,
21+
so cache reuse is safe. Each .weight access calls pager.ensure() (O(1) hash
22+
lookup when resident); if the returned ptr matches the cached one, the cached
23+
tensor is returned without re-memcpy. When eviction reassigns the slot to a
24+
different page, src_ptr changes and the cache invalidates automatically.
25+
"""
26+
from __future__ import annotations
27+
28+
from typing import Mapping, Optional
29+
30+
import torch
31+
import torch.nn as nn
32+
33+
34+
class PagedLinear(nn.Linear):
35+
"""nn.Linear with weight backed by a WeightPager (or a post-quant override)."""
36+
37+
def __init__(self, pager, page_idx: int,
38+
weight_shape, weight_dtype: torch.dtype,
39+
bias: bool = False):
40+
# weight_shape is (out_features, in_features) per HF convention.
41+
out_features, in_features = int(weight_shape[0]), int(weight_shape[1])
42+
super().__init__(in_features, out_features, bias=bias)
43+
44+
# Drop the auto-allocated weight Parameter — pager (or override) owns it.
45+
if "weight" in self._parameters:
46+
del self._parameters["weight"]
47+
48+
self.pager = pager
49+
self.page_idx = page_idx
50+
self.weight_shape = (out_features, in_features)
51+
self.weight_dtype = weight_dtype
52+
self.weight_override: Optional[torch.Tensor] = None
53+
# Device index for the paged path (only used when override is unset).
54+
self.device_idx = 0
55+
# Cache for the page-faulted weight. _cached_src_ptr=0 is the
56+
# "no cache" sentinel (real GPU pointers are always nonzero).
57+
self._cached_weight: Optional[torch.Tensor] = None
58+
self._cached_src_ptr: int = 0
59+
60+
def _materialize_weight(self, src_ptr: int) -> torch.Tensor:
61+
"""Allocate a fresh CUDA tensor and memcpy the slot bytes into it.
62+
63+
Extracted as a method so tests can override it without touching GPU.
64+
Subclasses MUST return a torch.Tensor with shape=self.weight_shape and
65+
dtype=self.weight_dtype.
66+
"""
67+
import wp_native
68+
out = torch.empty(self.weight_shape, dtype=self.weight_dtype,
69+
device=f"cuda:{self.device_idx}")
70+
nbytes = out.element_size() * out.numel()
71+
wp_native.device_memcpy(out.data_ptr(), src_ptr, nbytes)
72+
return out
73+
74+
@property
75+
def weight(self) -> torch.Tensor:
76+
"""Resolved weight tensor: override first, then cache, then materialize.
77+
78+
Caching: pager.ensure() returns the slot's src_ptr (O(1) when resident).
79+
If src_ptr matches what we last materialized from, the cached tensor is
80+
still valid (GGUF bytes are immutable) and we return it without copying.
81+
When eviction reassigns the slot to a different page, src_ptr changes
82+
and we re-materialize.
83+
"""
84+
if self.weight_override is not None:
85+
return self.weight_override
86+
if self.pager is None:
87+
raise RuntimeError(
88+
"PagedLinear.weight: no pager attached AND no weight_override set. "
89+
"Either wire a wp_native.WeightPager + page_idx, or assign weight_override."
90+
)
91+
src_ptr = self.pager.ensure(self.page_idx)
92+
if src_ptr == 0:
93+
raise RuntimeError(
94+
f"PagedLinear.weight: pager.ensure(page_idx={self.page_idx}) "
95+
f"returned null — page out of range or pager error."
96+
)
97+
if self._cached_weight is not None and self._cached_src_ptr == src_ptr:
98+
return self._cached_weight
99+
out = self._materialize_weight(src_ptr)
100+
self._cached_weight = out
101+
self._cached_src_ptr = src_ptr
102+
return out
103+
104+
@weight.setter
105+
def weight(self, value):
106+
"""Allow `layer.weight = some_tensor` to set the override.
107+
108+
Clears the paged-path cache. The override takes precedence in the getter,
109+
but releasing the cached tensor frees its VRAM eagerly — useful in the
110+
calibration loop where post-quant overrides are set per-layer."""
111+
self.weight_override = value
112+
self._cached_weight = None
113+
self._cached_src_ptr = 0
114+
115+
def forward(self, input: torch.Tensor) -> torch.Tensor:
116+
"""Compute in weight dtype, return in input dtype — fully transparent.
117+
118+
Page-loaded weights have fixed dtype (from the GGUF). The upstream
119+
layer may produce a different dtype (notably: HF's dtype inference
120+
sees the parameter-less PagedLinear and falls back to its model
121+
default). Cast input to the weight's dtype, do the matmul, then
122+
cast output back to the input's dtype so the next layer in the
123+
model gets what it expects.
124+
"""
125+
import torch.nn.functional as F
126+
w = self.weight
127+
in_dtype = input.dtype
128+
if in_dtype != w.dtype:
129+
input = input.to(w.dtype)
130+
out = F.linear(input, w, self.bias)
131+
if out.dtype != in_dtype:
132+
out = out.to(in_dtype)
133+
return out
134+
135+
136+
def swap_linears_with_paged(
137+
root: nn.Module,
138+
pager,
139+
name_map: Mapping[str, str],
140+
dtype: torch.dtype,
141+
device_idx: int = 0,
142+
) -> int:
143+
"""Walk root, replace each nn.Linear whose module path is in name_map with PagedLinear.
144+
145+
name_map: {module_path_in_root → catalog_name_in_pager}.
146+
e.g. {"model.layers.0.mlp.gate_proj": "blk.0.ffn_gate.weight"}.
147+
148+
Returns the number of modules replaced.
149+
"""
150+
n_swapped = 0
151+
# First pass: collect (parent_module, child_name, old_linear, catalog_name)
152+
# tuples so we don't mutate while iterating.
153+
to_swap = []
154+
for module_path, catalog_name in name_map.items():
155+
try:
156+
parent_path, _, child_name = module_path.rpartition(".")
157+
parent = root
158+
if parent_path:
159+
for part in parent_path.split("."):
160+
parent = getattr(parent, part) if not part.isdigit() else parent[int(part)]
161+
child = getattr(parent, child_name) if not child_name.isdigit() else parent[int(child_name)]
162+
except (AttributeError, IndexError):
163+
continue
164+
if not isinstance(child, nn.Linear):
165+
continue
166+
page_idx = pager.find_page(catalog_name)
167+
if page_idx < 0:
168+
continue
169+
to_swap.append((parent, child_name, child, page_idx))
170+
171+
# Second pass: replace
172+
for parent, child_name, old, page_idx in to_swap:
173+
new_layer = PagedLinear(
174+
pager=pager,
175+
page_idx=page_idx,
176+
weight_shape=old.weight.shape, # (out, in) for nn.Linear
177+
weight_dtype=dtype,
178+
bias=old.bias is not None,
179+
)
180+
new_layer.device_idx = device_idx
181+
if old.bias is not None:
182+
new_layer.bias = old.bias # keep existing bias parameter
183+
if child_name.isdigit():
184+
parent[int(child_name)] = new_layer
185+
else:
186+
setattr(parent, child_name, new_layer)
187+
n_swapped += 1
188+
return n_swapped
189+
190+
191+
__all__ = ["PagedLinear", "swap_linears_with_paged"]

python_bindings/wp/setup.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Build the wp_native pybind11 module.
2+
3+
Usage:
4+
cd python_bindings/wp
5+
python3 setup.py build_ext --inplace
6+
7+
Produces wp_native*.so in this directory, importable as `wp_native`.
8+
9+
Links against the libllama.so built at <repo_root>/build-hip/bin/.
10+
"""
11+
import sys
12+
from pathlib import Path
13+
14+
from pybind11.setup_helpers import Pybind11Extension, build_ext
15+
from setuptools import setup
16+
17+
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
18+
BUILD_HIP_BIN = REPO_ROOT / "build-hip" / "bin"
19+
20+
assert REPO_ROOT.is_dir(), REPO_ROOT
21+
assert BUILD_HIP_BIN.is_dir(), f"missing build-hip/bin — did you cmake --build build-hip? ({BUILD_HIP_BIN})"
22+
assert (BUILD_HIP_BIN / "libllama.so").exists() or (BUILD_HIP_BIN / "libllama.so.0").exists(), \
23+
f"missing libllama.so in {BUILD_HIP_BIN}"
24+
25+
ROCM_INCLUDE = "/opt/rocm/include"
26+
ROCM_LIB = "/opt/rocm/lib"
27+
28+
ext = Pybind11Extension(
29+
"wp_native",
30+
sources=["wp_bindings.cpp"],
31+
include_dirs=[
32+
str(REPO_ROOT), # for "src/weight-pager/..." headers
33+
str(REPO_ROOT / "ggml" / "include"), # for forward decls of ggml types
34+
ROCM_INCLUDE, # hip/hip_runtime.h
35+
],
36+
library_dirs=[str(BUILD_HIP_BIN), ROCM_LIB],
37+
libraries=["llama", "ggml-hip", "ggml-base", "amdhip64"],
38+
runtime_library_dirs=[str(BUILD_HIP_BIN), ROCM_LIB],
39+
cxx_std=17,
40+
extra_compile_args=[
41+
"-O2", "-Wall",
42+
"-D__HIP_PLATFORM_AMD__=1", # required for hip_runtime.h
43+
],
44+
)
45+
46+
setup(
47+
name="wp_native",
48+
version="0.1.0",
49+
ext_modules=[ext],
50+
cmdclass={"build_ext": build_ext},
51+
zip_safe=False,
52+
)

0 commit comments

Comments
 (0)