adirik commited on
Commit
4e235ae
·
1 Parent(s): 5a2d2ad

update stale files

Browse files
README.md CHANGED
@@ -4,7 +4,7 @@ license: apache-2.0
4
 
5
  # AttnVQ — Attention-Aware KV Cache Quantization
6
 
7
- Training-free **product vector quantization** of the KV cache for long-context LLMs. AttnVQ fits small per-subspace codebooks with LBG, but scores distortion by **attention-output error** (and key cosine / inner-product bias), not cache MSE. Calibration is light: **10–15 agent traces, ~15 s on GPU** — enough to capture the **model's** K/V geometry (data-aware, not corpus-dependent).
8
 
9
  Primary target: **Laguna-XS.2** (model-agnostic). Only the **10 full-attention layers** are compressed; 30 sliding-window layers stay fp16.
10
 
@@ -67,7 +67,7 @@ CODEBOOKS_PATH = "artifacts/codebooks.pt"
67
  codebooks = torch.load(CODEBOOKS_PATH, map_location="cuda", weights_only=False)
68
 
69
  # build cache
70
- quantizers, layers = codebooks["fitted"]["productvq-32x256-2b"], blob["meta"]["full_layers"]
71
  cache = VQQuantizedCache(quantizers, layers) # persists uint8 codebook indices
72
 
73
  # generate
 
4
 
5
  # AttnVQ — Attention-Aware KV Cache Quantization
6
 
7
+ Training-free **product vector quantization** of the KV cache for long-context LLMs. AttnVQ fits small per-subspace codebooks with **attention-weighted batched LBG** (centroids weighted by key attention mass from GQA causal attention), but scores distortion by **attention-output error** (and key cosine / inner-product bias), not cache MSE. Calibration is light: **10–15 agent traces, ~15 s on GPU** — enough to capture the **model's** K/V geometry (data-aware, not corpus-dependent).
8
 
9
  Primary target: **Laguna-XS.2** (model-agnostic). Only the **10 full-attention layers** are compressed; 30 sliding-window layers stay fp16.
10
 
 
67
  codebooks = torch.load(CODEBOOKS_PATH, map_location="cuda", weights_only=False)
68
 
69
  # build cache
70
+ quantizers, layers = codebooks["fitted"]["productvq-32x256-2b"], codebooks["meta"]["full_layers"]
71
  cache = VQQuantizedCache(quantizers, layers) # persists uint8 codebook indices
72
 
73
  # generate
__pycache__/benchmark.cpython-311.pyc ADDED
Binary file (55.5 kB). View file
 
__pycache__/generate.cpython-311.pyc ADDED
Binary file (1.79 kB). View file
 
benchmark.py CHANGED
@@ -27,7 +27,8 @@ import torch
27
  from vqkv.quantizers import (ScalarKV, KIVIScalarKV, ProductVQKV, RoPESplitVQKV,
28
  SignScalarKV, TernaryScalarKV)
29
  from vqkv.metrics import (key_cosine, cache_mse, inner_product_distortion,
30
- attention_output, attn_output_cosine, attn_output_error)
 
31
 
32
  MODEL_ID = os.environ.get("LAGUNA_ID", "poolside/Laguna-XS.2")
33
  ARTIFACT_DIR = os.environ.get("VQKV_ARTIFACTS", "./artifacts")
@@ -296,17 +297,39 @@ def stage_fit(only: list[str] | None = None):
296
  print(f"[fit] GPU available ({torch.cuda.get_device_name()}) -- fitting LBG on CUDA")
297
  n_workers = 1 if fit_device == "cuda" else min(len(layer_ids), os.cpu_count() or 1)
298
 
 
 
 
 
 
 
 
 
299
  for name, factory in configs:
300
  t0 = time.time()
301
 
302
  def _fit_layer(i, _factory=factory, _device=fit_device):
303
  if calib is not None and i in calib:
304
- kf = calib[i]["k"].reshape(-1, hd)[:200_000].to(_device)
305
- vf = calib[i]["v"].reshape(-1, hd)[:200_000].to(_device)
 
 
 
 
 
 
 
 
306
  else:
307
  kf = torch.zeros(1, hd, device=_device)
308
  vf = torch.zeros(1, hd, device=_device)
309
- q = _factory().fit(kf, vf)
 
 
 
 
 
 
310
  # Codebooks are saved to disk as CPU tensors; move back before returning.
311
  if _device != "cpu" and hasattr(q, "to"):
312
  q.to("cpu")
 
27
  from vqkv.quantizers import (ScalarKV, KIVIScalarKV, ProductVQKV, RoPESplitVQKV,
28
  SignScalarKV, TernaryScalarKV)
29
  from vqkv.metrics import (key_cosine, cache_mse, inner_product_distortion,
30
+ attention_output, attn_output_cosine, attn_output_error,
31
+ calibration_sample_weights)
32
 
33
  MODEL_ID = os.environ.get("LAGUNA_ID", "poolside/Laguna-XS.2")
34
  ARTIFACT_DIR = os.environ.get("VQKV_ARTIFACTS", "./artifacts")
 
297
  print(f"[fit] GPU available ({torch.cuda.get_device_name()}) -- fitting LBG on CUDA")
298
  n_workers = 1 if fit_device == "cuda" else min(len(layer_ids), os.cpu_count() or 1)
299
 
300
+ attn_weighted = any(
301
+ isinstance(f(), (ProductVQKV, RoPESplitVQKV)) for _, f in configs
302
+ )
303
+ if attn_weighted:
304
+ print("[fit] ProductVQ / RoPESplit: attention-weighted LBG "
305
+ "(centroids weighted by key attention mass)")
306
+
307
+ n_q = meta.get("n_q_heads", 48)
308
  for name, factory in configs:
309
  t0 = time.time()
310
 
311
  def _fit_layer(i, _factory=factory, _device=fit_device):
312
  if calib is not None and i in calib:
313
+ k_struct = calib[i]["k"]
314
+ v_struct = calib[i]["v"]
315
+ if k_struct.shape[0] > 512:
316
+ k_struct = k_struct[-512:]
317
+ v_struct = v_struct[-512:]
318
+ w = calibration_sample_weights(k_struct, n_q)
319
+ kf = k_struct.reshape(-1, hd)[:200_000].to(_device)
320
+ vf = v_struct.reshape(-1, hd)[:200_000].to(_device)
321
+ if w is not None:
322
+ w = w[:kf.shape[0]].to(_device)
323
  else:
324
  kf = torch.zeros(1, hd, device=_device)
325
  vf = torch.zeros(1, hd, device=_device)
326
+ w = k_struct = None
327
+ q = _factory()
328
+ if isinstance(q, (ProductVQKV, RoPESplitVQKV)):
329
+ q.fit(kf, vf, sample_weights=w, n_q_heads=n_q,
330
+ k_struct=k_struct.to(_device) if k_struct is not None else None)
331
+ else:
332
+ q.fit(kf, vf)
333
  # Codebooks are saved to disk as CPU tensors; move back before returning.
334
  if _device != "cpu" and hasattr(q, "to"):
335
  q.to("cpu")
generate.py CHANGED
@@ -13,7 +13,7 @@ CODEBOOKS_PATH = "artifacts/codebooks.pt"
13
  codebooks = torch.load(CODEBOOKS_PATH, map_location="cuda", weights_only=False)
14
 
15
  # build cache
16
- quantizers, layers = codebooks["fitted"]["productvq-32x256-2b"], blob["meta"]["full_layers"]
17
  cache = VQQuantizedCache(quantizers, layers) # persists uint8 codebook indices
18
 
19
  # generate
 
13
  codebooks = torch.load(CODEBOOKS_PATH, map_location="cuda", weights_only=False)
14
 
15
  # build cache
16
+ quantizers, layers = codebooks["fitted"]["productvq-32x256-2b"], codebooks["meta"]["full_layers"]
17
  cache = VQQuantizedCache(quantizers, layers) # persists uint8 codebook indices
18
 
19
  # generate
vqkv/__pycache__/metrics.cpython-311.pyc ADDED
Binary file (12.8 kB). View file
 
vqkv/__pycache__/quantizers.cpython-311.pyc ADDED
Binary file (40.3 kB). View file
 
vqkv/metrics.py CHANGED
@@ -114,6 +114,53 @@ def key_cosine(k_ref, k_hat):
114
  return torch.nn.functional.cosine_similarity(a, b, dim=-1, eps=1e-8).mean().item()
115
 
116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  def inner_product_distortion(q, k_ref, k_hat, n_pairs=4096):
118
  """Relative error in the q.k inner products that drive attention logits.
119
 
@@ -135,4 +182,4 @@ def inner_product_distortion(q, k_ref, k_hat, n_pairs=4096):
135
  ip_hat = (qd[qi] * kh[ki]).sum(-1)
136
  rel = ((ip_hat - ip_ref).abs() / ip_ref.abs().clamp_min(1e-6)).mean().item()
137
  bias = (ip_hat - ip_ref).mean().item()
138
- return {"ip_rel_err": round(rel, 5), "ip_bias": round(bias, 6)}
 
114
  return torch.nn.functional.cosine_similarity(a, b, dim=-1, eps=1e-8).mean().item()
115
 
116
 
117
+ def key_attention_mass(k, n_q_heads, q=None, seed=0):
118
+ """Per-(token, kv-head) importance for attention-weighted codebook fitting.
119
+
120
+ k: (T, n_kv_heads, head_dim). Uses synthetic unit-norm queries (fixed seed)
121
+ when ``q`` is None — same idea as the cheap-metrics window.
122
+
123
+ Returns (T, n_kv_heads) non-negative masses; recent keys get more mass under
124
+ causal attention because more queries can attend to them.
125
+ """
126
+ T, n_kv, head_dim = k.shape
127
+ groups = n_q_heads // n_kv
128
+ if q is None:
129
+ g = torch.Generator(device=k.device).manual_seed(seed)
130
+ q = torch.randn(T, n_q_heads, head_dim, generator=g, device=k.device, dtype=k.dtype)
131
+ q = q / q.norm(dim=-1, keepdim=True).clamp_min(1e-8)
132
+
133
+ k_exp = k.repeat_interleave(groups, dim=1)
134
+ qh = q.transpose(0, 1)
135
+ kh = k_exp.transpose(0, 1)
136
+ scores = torch.matmul(qh, kh.transpose(-1, -2)) * (head_dim ** -0.5)
137
+ causal = torch.tril(torch.ones(T, T, dtype=torch.bool, device=k.device))
138
+ scores = scores.masked_fill(~causal, float("-inf"))
139
+ attn = F.softmax(scores, dim=-1) # (n_q_heads, T, T)
140
+
141
+ mass = torch.zeros(T, n_kv, device=k.device, dtype=attn.dtype)
142
+ for h in range(n_kv):
143
+ sl = slice(h * groups, (h + 1) * groups)
144
+ mass[:, h] = attn[sl, :, :].sum(dim=(0, 1))
145
+ return mass
146
+
147
+
148
+ def calibration_sample_weights(k, n_q_heads, seed=0, max_tokens=512):
149
+ """Flattened row weights for ``k.reshape(-1, head_dim)`` LBG fitting.
150
+
151
+ k: (T, n_kv_heads, head_dim). Optionally truncates to the last ``max_tokens``
152
+ rows (matches the cheap-metrics attention window). Weights are normalized
153
+ to mean 1 over kept rows.
154
+ """
155
+ if k.dim() == 2:
156
+ return None
157
+ if k.shape[0] > max_tokens:
158
+ k = k[-max_tokens:]
159
+ mass = key_attention_mass(k, n_q_heads, seed=seed)
160
+ w = mass.reshape(-1)
161
+ return w / w.mean().clamp_min(1e-8)
162
+
163
+
164
  def inner_product_distortion(q, k_ref, k_hat, n_pairs=4096):
165
  """Relative error in the q.k inner products that drive attention logits.
166
 
 
182
  ip_hat = (qd[qi] * kh[ki]).sum(-1)
183
  rel = ((ip_hat - ip_ref).abs() / ip_ref.abs().clamp_min(1e-6)).mean().item()
184
  bias = (ip_hat - ip_ref).mean().item()
185
+ return {"ip_rel_err": round(rel, 5), "ip_bias": round(bias, 6)}
vqkv/quantizers.py CHANGED
@@ -1,10 +1,11 @@
1
  """
2
  vqkv.quantizers — KV-cache quantizers for AttnVQ.
3
 
4
- ScalarKV, KIVIScalarKV, ProductVQKV (LBG product VQ), RoPESplitVQKV,
5
  SignScalarKV, TernaryScalarKV. Each exposes fit() + roundtrip_k/v() and
6
- bits_per_element(). Distortion is evaluated via vqkv.metrics (attention-output
7
- error, not cache MSE alone).
 
8
  """
9
 
10
  from __future__ import annotations
@@ -88,15 +89,18 @@ class KIVIScalarKV:
88
  # LBG / k-means codebook (the 1980 algorithm, the engine of the project)
89
  # ----------------------------------------------------------------------------
90
  def lbg_codebook(data: torch.Tensor, n_codes: int, iters: int = 25,
91
- seed: int = 0) -> torch.Tensor:
92
- """Linde-Buzo-Gray vector quantizer design (== Lloyd / k-means for MSE).
93
 
94
- data: (N, d) training vectors
95
- returns: (n_codes, d) codebook
96
  """
97
  g = torch.Generator().manual_seed(seed)
98
  N, d = data.shape
99
  dev = data.device
 
 
 
100
  idx = torch.randperm(N, generator=g)[:n_codes].to(dev)
101
  cb = data[idx].clone()
102
  ones = torch.ones(N, dtype=data.dtype, device=dev)
@@ -109,8 +113,9 @@ def lbg_codebook(data: torch.Tensor, n_codes: int, iters: int = 25,
109
  # Was: 256 Python iterations; now: 3 torch ops.
110
  new_cb = torch.zeros_like(cb)
111
  counts = torch.zeros(n_codes, dtype=data.dtype, device=dev)
112
- new_cb.scatter_add_(0, assign.unsqueeze(1).expand(-1, d), data)
113
- counts.scatter_add_(0, assign, ones)
 
114
  live = counts > 0
115
  new_cb[live] /= counts[live].unsqueeze(1)
116
  # Re-seed dead centroids (LBG splitting heuristic)
@@ -127,19 +132,24 @@ def lbg_codebook(data: torch.Tensor, n_codes: int, iters: int = 25,
127
 
128
 
129
  def lbg_codebook_batched(xb: torch.Tensor, n_codes: int, iters: int = 25,
130
- seed: int = 0) -> torch.Tensor:
131
- """Fit n_sub independent LBG codebooks in one batched pass.
 
132
 
133
  Mirrors the structure of ProductVQKV._roundtrip: replaces the Python loop
134
  over sub-blocks with a single bmm-based assignment and a scatter_add-based
135
  update, so n_sub sub-block fits become one operation at each Lloyd step.
136
 
137
  xb: (n_sub, N, sub_dim) training vectors, pre-normalized if needed
 
138
  returns: (n_sub, n_codes, sub_dim) codebooks, one per sub-block
139
  """
140
  g = torch.Generator().manual_seed(seed)
141
  n_sub, N, sub_dim = xb.shape
142
  dev = xb.device
 
 
 
143
 
144
  # Initialization: random subset per sub-block (same RNG sequence as serial)
145
  idx = torch.stack([torch.randperm(N, generator=g)[:n_codes]
@@ -155,12 +165,14 @@ def lbg_codebook_batched(xb: torch.Tensor, n_codes: int, iters: int = 25,
155
  cross = torch.bmm(xb, cb.transpose(1, 2)) # (n_sub, N, K)
156
  assign = (x_sq - 2 * cross + c_sq).argmin(dim=-1) # (n_sub, N)
157
 
158
- # Update — batched scatter_add
159
  new_cb = torch.zeros_like(cb)
160
  counts = torch.zeros(n_sub, n_codes, dtype=xb.dtype, device=dev)
161
  assign_exp = assign.unsqueeze(-1).expand(-1, -1, sub_dim)
162
- new_cb.scatter_add_(1, assign_exp, xb)
163
- counts.scatter_add_(1, assign, ones.unsqueeze(0).expand(n_sub, -1))
 
 
164
 
165
  live = counts > 0
166
  new_cb[live] /= counts[live].unsqueeze(-1)
@@ -236,7 +248,7 @@ class ProductVQKV:
236
  # x: (N, head_dim) -> list of (N, sub_dim)
237
  return list(torch.chunk(x, self.n_sub, dim=-1))
238
 
239
- def _fit_one(self, x):
240
  N, head_dim = x.shape
241
  sub_dim = head_dim // self.n_sub
242
  # (n_sub, N, sub_dim) -- same layout _roundtrip uses, so batching mirrors inference
@@ -251,13 +263,18 @@ class ProductVQKV:
251
  else:
252
  stats = [None] * self.n_sub
253
 
254
- cb_batched = lbg_codebook_batched(xb, self.n_codes, self.iters)
 
255
  cbs = list(cb_batched.unbind(dim=0)) # n_sub x (K, sub_dim)
256
  return cbs, stats
257
 
258
- def fit(self, k_calib, v_calib):
259
- self.k_codebooks, self._k_stats = self._fit_one(k_calib)
260
- self.v_codebooks, self._v_stats = self._fit_one(v_calib)
 
 
 
 
261
  return self
262
 
263
  def _stack(self, codebooks, stats):
@@ -439,17 +456,23 @@ class RoPESplitVQKV:
439
  _pass_vq: ProductVQKV = None
440
  _v_vq: ProductVQKV = None
441
 
442
- def fit(self, k_calib, v_calib):
 
 
 
 
 
 
443
  d = k_calib.shape[-1]
444
  cut = int(d * self.rotary_fraction)
445
  k_rope, k_pass = k_calib[..., :cut], k_calib[..., cut:]
446
  self._cut = cut
447
  self._rope_vq = ProductVQKV(self.n_sub_half, self.n_codes, self.iters,
448
- normalize=True).fit(k_rope, k_rope)
449
  self._pass_vq = ProductVQKV(self.n_sub_half, self.n_codes, self.iters,
450
- normalize=False).fit(k_pass, k_pass)
451
  self._v_vq = ProductVQKV(2 * self.n_sub_half, self.n_codes, self.iters,
452
- normalize=True).fit(v_calib, v_calib)
453
  return self
454
 
455
  def roundtrip_k(self, k):
 
1
  """
2
  vqkv.quantizers — KV-cache quantizers for AttnVQ.
3
 
4
+ ScalarKV, KIVIScalarKV, ProductVQKV (attention-weighted batched LBG), RoPESplitVQKV,
5
  SignScalarKV, TernaryScalarKV. Each exposes fit() + roundtrip_k/v() and
6
+ bits_per_element(). ProductVQ / RoPESplit Lloyd updates are weighted by key
7
+ attention mass (see vqkv.metrics.calibration_sample_weights); quality is
8
+ reported via attention-output error, not cache MSE alone.
9
  """
10
 
11
  from __future__ import annotations
 
89
  # LBG / k-means codebook (the 1980 algorithm, the engine of the project)
90
  # ----------------------------------------------------------------------------
91
  def lbg_codebook(data: torch.Tensor, n_codes: int, iters: int = 25,
92
+ seed: int = 0, sample_weights: torch.Tensor | None = None) -> torch.Tensor:
93
+ """Linde-Buzo-Gray / Lloyd design on sub-vectors.
94
 
95
+ Assignment: nearest centroid (squared L2). Update: weighted mean when
96
+ ``sample_weights`` (N,) is given — attention-weighted LBG for AttnVQ.
97
  """
98
  g = torch.Generator().manual_seed(seed)
99
  N, d = data.shape
100
  dev = data.device
101
+ w = sample_weights
102
+ if w is not None:
103
+ w = w.to(device=dev, dtype=data.dtype).clamp_min(1e-8)
104
  idx = torch.randperm(N, generator=g)[:n_codes].to(dev)
105
  cb = data[idx].clone()
106
  ones = torch.ones(N, dtype=data.dtype, device=dev)
 
113
  # Was: 256 Python iterations; now: 3 torch ops.
114
  new_cb = torch.zeros_like(cb)
115
  counts = torch.zeros(n_codes, dtype=data.dtype, device=dev)
116
+ pts = data if w is None else data * w.unsqueeze(1)
117
+ new_cb.scatter_add_(0, assign.unsqueeze(1).expand(-1, d), pts)
118
+ counts.scatter_add_(0, assign, ones if w is None else w)
119
  live = counts > 0
120
  new_cb[live] /= counts[live].unsqueeze(1)
121
  # Re-seed dead centroids (LBG splitting heuristic)
 
132
 
133
 
134
  def lbg_codebook_batched(xb: torch.Tensor, n_codes: int, iters: int = 25,
135
+ seed: int = 0,
136
+ sample_weights: torch.Tensor | None = None) -> torch.Tensor:
137
+ """Fit n_sub independent attention-weighted LBG codebooks in one batched pass.
138
 
139
  Mirrors the structure of ProductVQKV._roundtrip: replaces the Python loop
140
  over sub-blocks with a single bmm-based assignment and a scatter_add-based
141
  update, so n_sub sub-block fits become one operation at each Lloyd step.
142
 
143
  xb: (n_sub, N, sub_dim) training vectors, pre-normalized if needed
144
+ sample_weights: optional (N,) masses from key_attention_mass (AttnVQ fit)
145
  returns: (n_sub, n_codes, sub_dim) codebooks, one per sub-block
146
  """
147
  g = torch.Generator().manual_seed(seed)
148
  n_sub, N, sub_dim = xb.shape
149
  dev = xb.device
150
+ w = sample_weights
151
+ if w is not None:
152
+ w = w.to(device=dev, dtype=xb.dtype).clamp_min(1e-8)
153
 
154
  # Initialization: random subset per sub-block (same RNG sequence as serial)
155
  idx = torch.stack([torch.randperm(N, generator=g)[:n_codes]
 
165
  cross = torch.bmm(xb, cb.transpose(1, 2)) # (n_sub, N, K)
166
  assign = (x_sq - 2 * cross + c_sq).argmin(dim=-1) # (n_sub, N)
167
 
168
+ # Update — batched scatter_add (optionally attention-weighted)
169
  new_cb = torch.zeros_like(cb)
170
  counts = torch.zeros(n_sub, n_codes, dtype=xb.dtype, device=dev)
171
  assign_exp = assign.unsqueeze(-1).expand(-1, -1, sub_dim)
172
+ pts = xb if w is None else xb * w.view(1, N, 1)
173
+ new_cb.scatter_add_(1, assign_exp, pts)
174
+ cnt_src = ones if w is None else w
175
+ counts.scatter_add_(1, assign, cnt_src.unsqueeze(0).expand(n_sub, -1))
176
 
177
  live = counts > 0
178
  new_cb[live] /= counts[live].unsqueeze(-1)
 
248
  # x: (N, head_dim) -> list of (N, sub_dim)
249
  return list(torch.chunk(x, self.n_sub, dim=-1))
250
 
251
+ def _fit_one(self, x, sample_weights=None):
252
  N, head_dim = x.shape
253
  sub_dim = head_dim // self.n_sub
254
  # (n_sub, N, sub_dim) -- same layout _roundtrip uses, so batching mirrors inference
 
263
  else:
264
  stats = [None] * self.n_sub
265
 
266
+ cb_batched = lbg_codebook_batched(
267
+ xb, self.n_codes, self.iters, sample_weights=sample_weights)
268
  cbs = list(cb_batched.unbind(dim=0)) # n_sub x (K, sub_dim)
269
  return cbs, stats
270
 
271
+ def fit(self, k_calib, v_calib, sample_weights=None, n_q_heads=None,
272
+ k_struct=None):
273
+ if sample_weights is None and k_struct is not None and k_struct.dim() == 3:
274
+ from vqkv.metrics import calibration_sample_weights
275
+ sample_weights = calibration_sample_weights(k_struct, n_q_heads)
276
+ self.k_codebooks, self._k_stats = self._fit_one(k_calib, sample_weights)
277
+ self.v_codebooks, self._v_stats = self._fit_one(v_calib, sample_weights)
278
  return self
279
 
280
  def _stack(self, codebooks, stats):
 
456
  _pass_vq: ProductVQKV = None
457
  _v_vq: ProductVQKV = None
458
 
459
+ def fit(self, k_calib, v_calib, sample_weights=None, n_q_heads=None,
460
+ k_struct=None):
461
+ if sample_weights is None and k_struct is not None and k_struct.dim() == 3:
462
+ from vqkv.metrics import calibration_sample_weights
463
+ sample_weights = calibration_sample_weights(k_struct, n_q_heads)
464
+ fit_kw = dict(sample_weights=sample_weights, n_q_heads=n_q_heads,
465
+ k_struct=k_struct)
466
  d = k_calib.shape[-1]
467
  cut = int(d * self.rotary_fraction)
468
  k_rope, k_pass = k_calib[..., :cut], k_calib[..., cut:]
469
  self._cut = cut
470
  self._rope_vq = ProductVQKV(self.n_sub_half, self.n_codes, self.iters,
471
+ normalize=True).fit(k_rope, k_rope, **fit_kw)
472
  self._pass_vq = ProductVQKV(self.n_sub_half, self.n_codes, self.iters,
473
+ normalize=False).fit(k_pass, k_pass, **fit_kw)
474
  self._v_vq = ProductVQKV(2 * self.n_sub_half, self.n_codes, self.iters,
475
+ normalize=True).fit(v_calib, v_calib, **fit_kw)
476
  return self
477
 
478
  def roundtrip_k(self, k):