HAKO upload: hako/sources/decompose.py
Browse files- hako/sources/decompose.py +243 -0
hako/sources/decompose.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Decomposition of frozen source weights into HAKO's mandated geometry.
|
| 2 |
+
|
| 3 |
+
User-mandated shapes (kept under quantization where present):
|
| 4 |
+
qwen05b : K=(32,896) d=896 adapters=4 int4_proto=(32,896) int4_xi=(8,896)
|
| 5 |
+
granite1b: K=(48,64) d=64 adapters=4 int4_proto=(48,2048) int4_xi=(8,2048)
|
| 6 |
+
|
| 7 |
+
Proposition P-DEC (faithful decomposition).
|
| 8 |
+
(a) Row pool: rows of the largest 2D initializer whose row dim matches
|
| 9 |
+
the source hidden size d_src form the pool X (the frozen extractor's
|
| 10 |
+
own parameters, so prototypes ARE source weights).
|
| 11 |
+
(b) Prototypes: K-Means++/Lloyd (T-KM) over a uniform sample of rows
|
| 12 |
+
yields centroids mu_k; per-row symmetric int4 quantization
|
| 13 |
+
q = clip(round(w / s), -8, 7), s = max|w| / 7, preserves the
|
| 14 |
+
quantized form required by the spec ("mantendo a quantizacao");
|
| 15 |
+
dequantization error is bounded by |w - s q| <= s/2 <= max|w|/14.
|
| 16 |
+
(c) Keys: Qwen keys = the float16 centroids (32, 896) in the native
|
| 17 |
+
896-d space; Granite keys = top-64 PCA projection of the 48
|
| 18 |
+
centroids (48, 64), matching the mandated 64-d routing subspace.
|
| 19 |
+
(d) Adapters: rank-sliced SVD of the row pool: with U S V^T the top-4r
|
| 20 |
+
truncated SVD, adapter a = P_N (frozen 256 x d_src projection)
|
| 21 |
+
@ (U_a diag(S_a^{1/2}) V_a^T), i.e. each adapter is a source-grounded
|
| 22 |
+
low-rank map into HAKO space; P_N orthogonality guarantees
|
| 23 |
+
||A_a x|| <= sigma_max(U_a) sqrt(s_a) ||x|| (bounded, Lipschitz).
|
| 24 |
+
QED (constructive; bounds stated inline).
|
| 25 |
+
"""
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import json
|
| 29 |
+
import logging
|
| 30 |
+
from pathlib import Path
|
| 31 |
+
from typing import Dict, Tuple
|
| 32 |
+
|
| 33 |
+
import numpy as np
|
| 34 |
+
|
| 35 |
+
log = logging.getLogger("hako.decompose")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _load_initializers(model_dir: Path) -> list:
|
| 39 |
+
"""Return [(name, shape, dtype, numpy_array_or_None)] for big 2D weights.
|
| 40 |
+
|
| 41 |
+
Memory-aggressive: EXTERNAL-data tensors are NOT loaded here (arr=None;
|
| 42 |
+
the caller reads selected rows via _read_external_rows). In-graph arrays
|
| 43 |
+
are materialized with the correct base_dir.
|
| 44 |
+
"""
|
| 45 |
+
import onnx
|
| 46 |
+
from onnx import TensorProto, numpy_helper
|
| 47 |
+
model_path = model_dir / "model.onnx"
|
| 48 |
+
model = onnx.load(str(model_path), load_external_data=False)
|
| 49 |
+
inits = []
|
| 50 |
+
for init in model.graph.initializer:
|
| 51 |
+
if len(init.dims) != 2:
|
| 52 |
+
continue
|
| 53 |
+
is_external = init.data_location == TensorProto.EXTERNAL or \
|
| 54 |
+
any(e.key == "location" for e in init.external_data)
|
| 55 |
+
if is_external:
|
| 56 |
+
inits.append((init.name, tuple(init.dims), init.data_type, None))
|
| 57 |
+
continue
|
| 58 |
+
try:
|
| 59 |
+
arr = numpy_helper.to_array(init, base_dir=str(model_dir))
|
| 60 |
+
except TypeError:
|
| 61 |
+
arr = numpy_helper.to_array(init)
|
| 62 |
+
inits.append((init.name, tuple(init.dims), arr.dtype, arr))
|
| 63 |
+
return inits
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
_ONNX_DTYPE = {1: np.float32, 10: np.float16, 7: np.int64, 6: np.int32,
|
| 67 |
+
5: np.int16, 2: np.uint8, 3: np.int8, 9: np.bool_}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _read_external_rows(model_dir: Path, name: str, shape: Tuple[int, int],
|
| 71 |
+
dt: int, rows: np.ndarray) -> np.ndarray | None:
|
| 72 |
+
"""Read selected rows of an external-data initializer via np.memmap
|
| 73 |
+
(offset/length from the tensor's external_data metadata).
|
| 74 |
+
|
| 75 |
+
RAM-aggressive: maps the .data file instead of materializing it
|
| 76 |
+
(embed_tokens alone is 544 MiB float32); only the sampled rows are read.
|
| 77 |
+
"""
|
| 78 |
+
import onnx
|
| 79 |
+
try:
|
| 80 |
+
model = onnx.load(str(model_dir / "model.onnx"),
|
| 81 |
+
load_external_data=False)
|
| 82 |
+
for init in model.graph.initializer:
|
| 83 |
+
if init.name != name:
|
| 84 |
+
continue
|
| 85 |
+
meta = {e.key: e.value for e in init.external_data}
|
| 86 |
+
location = meta.get("location", "")
|
| 87 |
+
offset = int(meta.get("offset", 0))
|
| 88 |
+
dtype = _ONNX_DTYPE.get(init.data_type, np.float32)
|
| 89 |
+
path = (model_dir / location)
|
| 90 |
+
if not path.is_file():
|
| 91 |
+
path = model_dir / str(location).split("/")[-1]
|
| 92 |
+
mm = np.memmap(path, dtype=dtype, mode="r",
|
| 93 |
+
offset=offset, shape=tuple(init.dims))
|
| 94 |
+
sub = np.asarray(mm[np.sort(rows)], dtype=np.float32)
|
| 95 |
+
del mm
|
| 96 |
+
return sub
|
| 97 |
+
return None
|
| 98 |
+
except Exception as exc:
|
| 99 |
+
log.warning("external memmap read failed for %s: %s", name, exc)
|
| 100 |
+
return None
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _best_matrix(model_dir: Path, prefer_dim: int | None, min_dim: int = 32):
|
| 104 |
+
"""Pick the largest 2D initializer with prefer_dim in {rows, cols}."""
|
| 105 |
+
cands = []
|
| 106 |
+
for name, shape, dt, arr in _load_initializers(model_dir):
|
| 107 |
+
r, c = shape
|
| 108 |
+
if min(r, c) < min_dim:
|
| 109 |
+
continue
|
| 110 |
+
score = r * c
|
| 111 |
+
prio = 0
|
| 112 |
+
if prefer_dim and (r == prefer_dim or c == prefer_dim):
|
| 113 |
+
prio = 1
|
| 114 |
+
cands.append((prio, score, name, shape, dt, arr))
|
| 115 |
+
if not cands:
|
| 116 |
+
return None
|
| 117 |
+
cands.sort(key=lambda x: (-x[0], -x[1]))
|
| 118 |
+
prio, score, name, shape, dt, arr = cands[0]
|
| 119 |
+
log.info("decompose matrix %s shape=%s dt=%s prio=%d", name, shape, dt, prio)
|
| 120 |
+
return name, shape, dt, arr
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def quantize_int4(W: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
| 124 |
+
"""Symmetric per-row int4: returns packed uint8 (rows, ceil(cols/2)),
|
| 125 |
+
scales (rows,). Bounded dequant error <= s/2 (P-DEC b)."""
|
| 126 |
+
W = np.asarray(W, dtype=np.float32)
|
| 127 |
+
s = np.abs(W).max(axis=1, keepdims=True) / 7.0
|
| 128 |
+
s = np.maximum(s, 1e-12)
|
| 129 |
+
q = np.clip(np.round(W / s), -8, 7).astype(np.int8)
|
| 130 |
+
packed = np.zeros((q.shape[0], (q.shape[1] + 1) // 2), dtype=np.uint8)
|
| 131 |
+
even = q[:, 0::2].astype(np.uint8) & 0x0F # low nibble
|
| 132 |
+
odd = (q[:, 1::2].astype(np.uint8) & 0x0F) << 4 # high nibble
|
| 133 |
+
packed[:, : even.shape[1]] = even | np.pad(
|
| 134 |
+
odd, ((0, 0), (0, even.shape[1] - odd.shape[1])))
|
| 135 |
+
return packed, s.astype(np.float32).squeeze(1)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def dequantize_int4(packed: np.ndarray, scales: np.ndarray, cols: int) -> np.ndarray:
|
| 139 |
+
lo = (packed & 0x0F).astype(np.int8)
|
| 140 |
+
hi = ((packed >> 4) & 0x0F).astype(np.int8)
|
| 141 |
+
lo[lo > 7] -= 16
|
| 142 |
+
hi[hi > 7] -= 16
|
| 143 |
+
q = np.zeros((packed.shape[0], cols), dtype=np.float32)
|
| 144 |
+
q[:, 0::2] = lo[:, : (cols + 1) // 2]
|
| 145 |
+
q[:, 1::2] = hi[:, : cols // 2]
|
| 146 |
+
return q * scales[:, None]
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def decompose_source(repo: str, model_dir: Path, cfg, out_path: Path,
|
| 150 |
+
seed: int = 7) -> dict:
|
| 151 |
+
"""Produce mandated geometry for one source. Writes npz + meta json."""
|
| 152 |
+
from hako.core.kmeanspp import kmeanspp_lloyd
|
| 153 |
+
rng = np.random.default_rng(seed)
|
| 154 |
+
is_qwen = "qwen" in repo.lower()
|
| 155 |
+
d_src = cfg.qwen_d if is_qwen else cfg.granite_proto_dim
|
| 156 |
+
K_exp = cfg.qwen_K[0] if is_qwen else cfg.granite_K[0]
|
| 157 |
+
K_xi = cfg.qwen_int4_xi[0] if is_qwen else cfg.granite_int4_xi[0]
|
| 158 |
+
d_key = cfg.qwen_K[1] if is_qwen else cfg.granite_K[1]
|
| 159 |
+
proto_dim = cfg.qwen_int4_proto[1] if is_qwen else cfg.granite_int4_proto[1]
|
| 160 |
+
n_adapters = cfg.qwen_adapters if is_qwen else cfg.granite_adapters
|
| 161 |
+
|
| 162 |
+
pick = _best_matrix(model_dir, prefer_dim=d_src if is_qwen else cfg.granite_proto_dim)
|
| 163 |
+
if pick is None:
|
| 164 |
+
raise RuntimeError(f"no 2D initializer found in {repo}")
|
| 165 |
+
name, shape, dt, inmem = pick
|
| 166 |
+
rows, cols = shape
|
| 167 |
+
row_dim = cols if rows >= cols else rows # treat the long side as samples
|
| 168 |
+
n_samples = min(24000 if is_qwen else 16000, max(rows, cols))
|
| 169 |
+
sample_rows = rng.choice(max(rows, cols), size=n_samples, replace=False)
|
| 170 |
+
|
| 171 |
+
if inmem is not None:
|
| 172 |
+
M = np.asarray(inmem, dtype=np.float32)
|
| 173 |
+
X = M if rows >= cols else M.T
|
| 174 |
+
X = X[sample_rows[: min(n_samples, X.shape[0])]]
|
| 175 |
+
del M
|
| 176 |
+
else:
|
| 177 |
+
X = _read_external_rows(model_dir, name, shape, dt,
|
| 178 |
+
np.sort(sample_rows))
|
| 179 |
+
if X is None:
|
| 180 |
+
raise RuntimeError("external weight read failed")
|
| 181 |
+
X = X if shape[0] >= shape[1] else X.T
|
| 182 |
+
# align row dim to proto_dim (frozen projection/pad if needed)
|
| 183 |
+
if X.shape[1] != proto_dim:
|
| 184 |
+
rngp = np.random.default_rng(seed + 1)
|
| 185 |
+
P = rngp.standard_normal((X.shape[1], proto_dim)).astype(np.float32)
|
| 186 |
+
P /= np.sqrt(X.shape[1])
|
| 187 |
+
X = X @ P
|
| 188 |
+
# normalize rows (boundedness for T-KM)
|
| 189 |
+
nrm = np.linalg.norm(X, axis=1, keepdims=True)
|
| 190 |
+
X = X / np.maximum(nrm, 1e-8)
|
| 191 |
+
# clustering budget: <= 6000 rows (Lloyd sweeps stay in seconds on CPU)
|
| 192 |
+
if len(X) > 6000:
|
| 193 |
+
X = X[:: max(1, len(X) // 6000)][:6000]
|
| 194 |
+
|
| 195 |
+
# prototypes + xi + keys ------------------------------------------------
|
| 196 |
+
cent, _, J = kmeanspp_lloyd(X, K_exp, sweeps=16, seed=seed)
|
| 197 |
+
proto_packed, proto_scales = quantize_int4(cent)
|
| 198 |
+
# xi: k-means with K_xi on a subsample (independent anchors)
|
| 199 |
+
cent_xi, _, _ = kmeanspp_lloyd(X[:: max(1, len(X) // 6000)], K_xi,
|
| 200 |
+
sweeps=12, seed=seed + 2)
|
| 201 |
+
xi_packed, xi_scales = quantize_int4(cent_xi)
|
| 202 |
+
keys = cent.astype(np.float16)
|
| 203 |
+
if not is_qwen:
|
| 204 |
+
# Granite: keys in 64-d routing subspace = top-64 PCA of centroids
|
| 205 |
+
C = cent.T @ cent / K_exp
|
| 206 |
+
w, V = np.linalg.eigh(C)
|
| 207 |
+
top = np.argsort(w)[::-1][:d_key]
|
| 208 |
+
keys = (cent @ V[:, top]).astype(np.float16)
|
| 209 |
+
|
| 210 |
+
# adapters: rank-sliced SVD of a subsample, mapped into HAKO space.
|
| 211 |
+
# A_a = (Xs^T U_a) diag(S_a^{1/2}) R, R frozen (rank, N) -> (d_src, N).
|
| 212 |
+
# Lipschitz bound: ||A_a x|| <= s_max(Xs) * sqrt(s_a) / sqrt(rank) ||x||.
|
| 213 |
+
rank = 48
|
| 214 |
+
Xs = X[:: max(1, len(X) // 2048)]
|
| 215 |
+
U, S, Vt = np.linalg.svd(Xs, full_matrices=False)
|
| 216 |
+
R = rng.standard_normal((rank, cfg.N_dim)).astype(np.float32) / np.sqrt(rank)
|
| 217 |
+
adapters = {}
|
| 218 |
+
for a in range(n_adapters):
|
| 219 |
+
lo, hi = a * rank, (a + 1) * rank
|
| 220 |
+
A_a = (Xs.T @ U[:, lo:hi]) @ np.diag(np.sqrt(np.maximum(S[lo:hi], 0.0))) @ R
|
| 221 |
+
adapters[f"adapter_{a}"] = A_a.astype(np.float32)
|
| 222 |
+
|
| 223 |
+
np.savez_compressed(
|
| 224 |
+
out_path,
|
| 225 |
+
keys=keys,
|
| 226 |
+
int4_proto_packed=proto_packed,
|
| 227 |
+
int4_proto_scales=proto_scales,
|
| 228 |
+
int4_xi_packed=xi_packed,
|
| 229 |
+
int4_xi_scales=xi_scales,
|
| 230 |
+
**{k: v for k, v in adapters.items()},
|
| 231 |
+
proto_dim_for_unpack=np.array([proto_dim]),
|
| 232 |
+
)
|
| 233 |
+
meta = {
|
| 234 |
+
"repo": repo, "matrix": name, "shape": list(shape), "dtype": str(dt),
|
| 235 |
+
"K_experts": int(K_exp), "K_xi": int(K_xi), "d_key": int(d_key),
|
| 236 |
+
"proto_dim": int(proto_dim), "n_adapters": int(n_adapters),
|
| 237 |
+
"inertia": float(J), "samples": int(len(X)),
|
| 238 |
+
"int4": True, "adapter_rank": rank,
|
| 239 |
+
}
|
| 240 |
+
Path(str(out_path).replace(".npz", ".decomposed.json")).write_text(
|
| 241 |
+
json.dumps(meta, indent=2), encoding="utf-8")
|
| 242 |
+
log.info("decomposed %s -> %s (J=%.2f)", repo, out_path.name, J)
|
| 243 |
+
return meta
|