aither-kvcache 2.0.1__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/PKG-INFO +1 -1
  2. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/vllm/backend.py +1 -1
  3. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/vllm/engine.py +26 -2
  4. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/vllm/hooks.py +67 -49
  5. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/vllm/plugin.py +20 -2
  6. aither_kvcache-2.2.0/aither_kvcache/vllm/tenant_isolation.py +364 -0
  7. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache.egg-info/PKG-INFO +1 -1
  8. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache.egg-info/SOURCES.txt +1 -0
  9. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/pyproject.toml +1 -1
  10. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/turboquant/__init__.py +3 -0
  11. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/turboquant/codebook.py +60 -0
  12. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/turboquant/fused_attention.py +27 -18
  13. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/turboquant/quantizer.py +24 -1
  14. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/LICENSE +0 -0
  15. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/README.md +0 -0
  16. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/__init__.py +0 -0
  17. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/bench.py +0 -0
  18. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/block_selector.py +0 -0
  19. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/codebook.py +0 -0
  20. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/eviction_advisor.py +0 -0
  21. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/fused_attention.py +0 -0
  22. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/fused_kv_update.py +0 -0
  23. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/hybrid_quantizer.py +0 -0
  24. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/kvcache_graph.py +0 -0
  25. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/packing.py +0 -0
  26. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/quantizer.py +0 -0
  27. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/rotation.py +0 -0
  28. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/triattention/__init__.py +0 -0
  29. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/triattention/attention.py +0 -0
  30. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/triattention/cache.py +0 -0
  31. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/triattention/calibration.py +0 -0
  32. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/triattention/config.py +0 -0
  33. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/triattention/encoder.py +0 -0
  34. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/triattention/scorer.py +0 -0
  35. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/triattention/spectral.py +0 -0
  36. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/triton_ops.py +0 -0
  37. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/vllm/__init__.py +0 -0
  38. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/vllm/cache.py +0 -0
  39. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/vllm/eviction_plugin.py +0 -0
  40. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache/vllm/sitecustomize.py +0 -0
  41. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache.egg-info/dependency_links.txt +0 -0
  42. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache.egg-info/entry_points.txt +0 -0
  43. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache.egg-info/requires.txt +0 -0
  44. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/aither_kvcache.egg-info/top_level.txt +0 -0
  45. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/setup.cfg +0 -0
  46. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/tests/test_core.py +0 -0
  47. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/tests/test_fused.py +0 -0
  48. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/tests/test_hybrid_hooks.py +0 -0
  49. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/tests/test_kvcache_graph.py +0 -0
  50. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/tests/test_triattention.py +0 -0
  51. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/turboquant/bench.py +0 -0
  52. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/turboquant/packing.py +0 -0
  53. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/turboquant/rotation.py +0 -0
  54. {aither_kvcache-2.0.1 → aither_kvcache-2.2.0}/turboquant/triton_ops.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aither-kvcache
3
- Version: 2.0.1
3
+ Version: 2.2.0
4
4
  Summary: Near-optimal KV cache compression for LLM inference — TurboQuant (vector quantization) + TriAttention (spectral compression)
5
5
  Author: Aitherium
6
6
  License-Expression: CC-BY-4.0
@@ -306,7 +306,7 @@ def _make_backend_class():
306
306
 
307
307
  @staticmethod
308
308
  def get_supported_head_sizes():
309
- return [64, 96, 128, 256]
309
+ return [64, 96, 128, 256, 512]
310
310
 
311
311
  return TurboQuantBackend
312
312
 
@@ -133,6 +133,27 @@ def _tq_dim_for_head(head_size: int) -> int:
133
133
  return packed_size(head_size, _TQ_BITS) + 4
134
134
 
135
135
 
136
+ def _align_page_size(raw_bytes: int) -> int:
137
+ """Round page size up to the next power of 2 for cross-layer divisibility.
138
+
139
+ vLLM's unify_kv_cache_spec_page_size() requires all layer page sizes to be
140
+ divisible by the max page size. With heterogeneous head dims (e.g. Gemma 4:
141
+ 256 + 512), the smaller layers produce page sizes that don't divide the
142
+ larger ones. Padding to next power-of-2 ensures any layer's padded page
143
+ size divides any other's (both are powers of 2 -> larger divides by smaller).
144
+
145
+ For uniform models, this is a no-op if page size is already a power of 2,
146
+ and at worst wastes a few bytes per block (acceptable for correctness).
147
+ """
148
+ if raw_bytes <= 0:
149
+ return raw_bytes
150
+ # Next power of 2 >= raw_bytes
151
+ p = 1
152
+ while p < raw_bytes:
153
+ p <<= 1
154
+ return p
155
+
156
+
136
157
  def _patch_page_size() -> bool:
137
158
  """
138
159
  Patch real_page_size_bytes on both AttentionSpec and FullAttentionSpec.
@@ -148,8 +169,11 @@ def _patch_page_size() -> bool:
148
169
 
149
170
  @property
150
171
  def _tq_real_page_size_bytes(self):
151
- """Return TQ-compressed page size (handles both uniform and hybrid)."""
152
- return _tq_page_size_bytes(self.block_size, self.num_kv_heads, self.head_size)
172
+ """Return TQ-compressed page size (handles both uniform and hybrid).
173
+ Pads to ensure cross-layer divisibility for heterogeneous head dims
174
+ (e.g. Gemma 4: head_dim=256 local + head_dim=512 global)."""
175
+ raw = _tq_page_size_bytes(self.block_size, self.num_kv_heads, self.head_size)
176
+ return _align_page_size(raw)
153
177
 
154
178
  # Patch BOTH classes -- FullAttentionSpec shadows parent's property
155
179
  AttentionSpec.real_page_size_bytes = _tq_real_page_size_bytes
@@ -29,7 +29,7 @@ Set AITHER_TQ_DEBUG_STEPS=N to log the first N forward calls per layer-0
29
29
  import logging
30
30
  import os
31
31
  import sys
32
- from typing import Optional
32
+ from typing import Dict, Optional, Tuple
33
33
 
34
34
  import torch
35
35
 
@@ -83,8 +83,9 @@ _hooks_applied = False
83
83
  _original_forward = None # Saved reference to TritonAttentionImpl.forward
84
84
 
85
85
  # TQ state shared across all layers (class-level in the patched impl)
86
- _tq_quantizer: Optional[object] = None
87
- _tq_packed_dim: int = 0 # packed bytes per head (derived from quantizer)
86
+ # Per-head-dim quantizers for heterogeneous models (e.g. Gemma 4: 256 + 512)
87
+ _tq_quantizers: Dict[int, object] = {} # head_dim -> TurboQuant instance
88
+ _tq_packed_dims: Dict[int, int] = {} # head_dim -> packed bytes per head
88
89
  _tq_fused_attn: dict = {} # layer_idx -> TQPagedAttention instance
89
90
  _primary_k_norms: Optional[torch.Tensor] = None # None for hybrid (norms embedded)
90
91
  _primary_v_norms: Optional[torch.Tensor] = None
@@ -96,6 +97,18 @@ _prefill_k_buf: Optional[torch.Tensor] = None
96
97
  _prefill_v_buf: Optional[torch.Tensor] = None
97
98
  _PREFILL_BUF_BLOCKS = 512
98
99
 
100
+ def _get_tq(head_size: int) -> Tuple[object, int]:
101
+ """Get quantizer and packed_dim for a given head_size."""
102
+ return _tq_quantizers[head_size], _tq_packed_dims[head_size]
103
+
104
+
105
+ def _any_tq() -> Optional[object]:
106
+ """Get any initialized quantizer (for operations that don't care about head_dim)."""
107
+ if _tq_quantizers:
108
+ return next(iter(_tq_quantizers.values()))
109
+ return None
110
+
111
+
99
112
  # Custom op flag -- set by _register_custom_ops()
100
113
  _USE_CUSTOM_OP = False
101
114
 
@@ -135,10 +148,9 @@ def _register_custom_ops():
135
148
  num_actual: int,
136
149
  ) -> torch.Tensor:
137
150
  """Encode new token + fused TQ attention. No graph break.
138
- Accesses module globals (_tq_quantizer, _tq_fused_attn) in body --
151
+ Accesses module globals (_tq_quantizers, _tq_fused_attn) in body --
139
152
  these are initialized during vLLM warmup before CUDA graph capture."""
140
- tq = _tq_quantizer
141
- packed_dim = head_size // 2
153
+ tq, packed_dim = _get_tq(head_size)
142
154
  key_cache = kv_cache[:, 0]
143
155
  value_cache = kv_cache[:, 1]
144
156
  block_size = key_cache.shape[1]
@@ -260,8 +272,7 @@ def _register_hybrid_custom_ops():
260
272
  """
261
273
  global _hybrid_dk_buf, _hybrid_dv_buf
262
274
 
263
- tq = _tq_quantizer
264
- packed_dim = _tq_packed_dim
275
+ tq, packed_dim = _get_tq(head_size)
265
276
  key_cache = kv_cache[:, 0]
266
277
  value_cache = kv_cache[:, 1]
267
278
  block_size = key_cache.shape[1]
@@ -369,32 +380,38 @@ def _tq_init_layer(self_impl):
369
380
 
370
381
  @torch.compiler.disable
371
382
  def _ensure_quantizer(device, head_size, num_heads, num_kv_heads):
372
- """Lazy-init TQ quantizer and fused attention on first real forward."""
373
- global _tq_quantizer, _tq_packed_dim, _num_layers
374
- if _tq_quantizer is not None:
375
- return
383
+ """Lazy-init TQ quantizer for a given head_size. Creates/caches per unique head_dim.
384
+ Supports heterogeneous head dimensions (e.g. Gemma 4: 256 local + 512 global)."""
385
+ global _num_layers
386
+ if head_size in _tq_quantizers:
387
+ return # Already initialized for this head_dim
376
388
 
377
- _num_layers = _layer_counter
389
+ if not _tq_quantizers: # First quantizer -- snapshot layer count
390
+ _num_layers = _layer_counter
378
391
 
379
392
  if _TQ_IS_HYBRID:
380
393
  from ..hybrid_quantizer import HybridTurboQuant
381
- _tq_quantizer = HybridTurboQuant(
394
+ tq = HybridTurboQuant(
382
395
  head_dim=head_size, mode=_TQ_MODE, device=str(device),
383
396
  num_kv_heads=num_kv_heads)
384
- _tq_quantizer.calibrate_uniform()
385
- _tq_packed_dim = _tq_quantizer.layout.packed_dim
397
+ tq.calibrate_uniform()
398
+ pd = tq.layout.packed_dim
399
+ _tq_quantizers[head_size] = tq
400
+ _tq_packed_dims[head_size] = pd
386
401
  logger.info("TQ hybrid quantizer initialized: head_dim=%d, mode=%s, "
387
- "packed_dim=%d, %d layers",
388
- head_size, _TQ_MODE, _tq_packed_dim, _num_layers)
402
+ "packed_dim=%d, %d layers (total quantizers: %d)",
403
+ head_size, _TQ_MODE, pd, _num_layers, len(_tq_quantizers))
389
404
  else:
390
405
  from ..quantizer import TurboQuant
391
406
  from ..packing import packed_size
392
- _tq_quantizer = TurboQuant(
407
+ tq = TurboQuant(
393
408
  head_dim=head_size, bits=_TQ_BITS, device=str(device))
394
- _tq_packed_dim = packed_size(head_size, _TQ_BITS)
409
+ pd = packed_size(head_size, _TQ_BITS)
410
+ _tq_quantizers[head_size] = tq
411
+ _tq_packed_dims[head_size] = pd
395
412
  logger.info("TQ quantizer initialized: head_dim=%d, bits=%d, "
396
- "packed_dim=%d, %d layers",
397
- head_size, _TQ_BITS, _tq_packed_dim, _num_layers)
413
+ "packed_dim=%d, %d layers (total quantizers: %d)",
414
+ head_size, _TQ_BITS, pd, _num_layers, len(_tq_quantizers))
398
415
 
399
416
 
400
417
  @torch.compiler.disable
@@ -420,12 +437,11 @@ def _ensure_norms(num_layers, num_blocks, block_size, num_kv_heads, device):
420
437
  def _tq_encode_phase(layer_idx, key, value, kv_cache, slot_mapping):
421
438
  """Encode new K,V tokens into TQ primary cache. Runs eagerly.
422
439
  Supports both uniform (separate norms) and hybrid (norms embedded) modes."""
423
- global _tq_quantizer, _primary_k_norms, _primary_v_norms
424
- tq = _tq_quantizer
425
- if tq is None:
440
+ global _primary_k_norms, _primary_v_norms
441
+ head_size = key.shape[-1]
442
+ if head_size not in _tq_quantizers:
426
443
  return
427
-
428
- packed_dim = _tq_packed_dim
444
+ tq, packed_dim = _get_tq(head_size)
429
445
 
430
446
  key_cache = kv_cache[:, 0] # [blocks, block_size, kv_heads, tq_dim]
431
447
  value_cache = kv_cache[:, 1]
@@ -508,18 +524,17 @@ def _tq_decompress_active(layer_idx, kv_cache, block_table, num_kv_heads,
508
524
  """Decompress active TQ blocks into a temporary fp8/bf16 buffer.
509
525
  Returns a standard-shaped kv_cache tensor for TritonAttention.
510
526
  Runs eagerly."""
511
- global _tq_quantizer, _primary_k_norms, _primary_v_norms
527
+ global _primary_k_norms, _primary_v_norms
512
528
  global _prefill_k_buf, _prefill_v_buf
513
529
 
514
- tq = _tq_quantizer
515
- if tq is None:
530
+ if head_size not in _tq_quantizers:
516
531
  return kv_cache # Fallback: return original (will fail but shouldn't happen)
532
+ tq, packed_dim = _get_tq(head_size)
517
533
 
518
534
  key_cache = kv_cache[:, 0]
519
535
  value_cache = kv_cache[:, 1]
520
536
  num_blocks = key_cache.shape[0]
521
537
  block_size = key_cache.shape[1]
522
- packed_dim = head_size // 2
523
538
 
524
539
  # Allocate or reuse decompress buffers
525
540
  need_realloc = (
@@ -576,15 +591,16 @@ def _tq_fused_decode(layer_idx, query, kv_cache, attn_metadata, output,
576
591
  num_heads, num_kv_heads, head_size, scale):
577
592
  """Fused TQ decode using TQPagedAttention kernel. No decompress needed.
578
593
  Runs eagerly (graph break)."""
579
- global _tq_fused_attn, _tq_quantizer, _primary_k_norms, _primary_v_norms
594
+ global _tq_fused_attn, _primary_k_norms, _primary_v_norms
595
+
596
+ tq, packed_dim = _get_tq(head_size)
580
597
 
581
598
  # Lazy-init fused attention for this layer
582
599
  if layer_idx not in _tq_fused_attn:
583
600
  from ..fused_attention import TQPagedAttention
584
- _tq_fused_attn[layer_idx] = TQPagedAttention(_tq_quantizer, num_heads)
601
+ _tq_fused_attn[layer_idx] = TQPagedAttention(tq, num_heads)
585
602
 
586
603
  fused = _tq_fused_attn[layer_idx]
587
- packed_dim = head_size // 2
588
604
 
589
605
  key_cache = kv_cache[:, 0]
590
606
  value_cache = kv_cache[:, 1]
@@ -609,8 +625,7 @@ def _encode_inline(layer_idx, key, value, kv_cache, slot_mapping):
609
625
  """Encode K/V into TQ cache. No decorator -- called from _tq_decode_step.
610
626
  Branchless for decode: no .any() CPU-GPU syncs. Works for any N tokens.
611
627
  Supports both uniform and hybrid modes."""
612
- tq = _tq_quantizer
613
- packed_dim = _tq_packed_dim
628
+ tq, packed_dim = _get_tq(key.shape[-1])
614
629
  key_cache = kv_cache[:, 0]
615
630
  value_cache = kv_cache[:, 1]
616
631
  block_size = key_cache.shape[1]
@@ -658,14 +673,14 @@ def _tq_decode_step(layer_idx, query, key, value, kv_cache, attn_metadata,
658
673
  _encode_inline(layer_idx, key, value, kv_cache,
659
674
  attn_metadata.slot_mapping)
660
675
 
676
+ tq, packed_dim = _get_tq(head_size)
677
+
661
678
  # Fused attention -- dict lookup for cached TQPagedAttention
662
679
  fused = _tq_fused_attn.get(layer_idx)
663
680
  if fused is None:
664
681
  from ..fused_attention import TQPagedAttention
665
- fused = TQPagedAttention(_tq_quantizer, num_heads)
682
+ fused = TQPagedAttention(tq, num_heads)
666
683
  _tq_fused_attn[layer_idx] = fused
667
-
668
- packed_dim = _tq_packed_dim
669
684
  key_cache = kv_cache[:, 0]
670
685
  value_cache = kv_cache[:, 1]
671
686
 
@@ -715,7 +730,12 @@ def _tq_prefill_sdpa(layer_idx, query, key, value, kv_cache, attn_metadata,
715
730
  Manual GQA expansion. is_causal=True for prefill, is_causal=False for decode
716
731
  (single query attends to all prior positions -- no mask needed)."""
717
732
  import torch.nn.functional as F
718
- global _tq_quantizer, _primary_k_norms, _primary_v_norms
733
+ global _primary_k_norms, _primary_v_norms
734
+
735
+ if head_size not in _tq_quantizers:
736
+ output.fill_(0)
737
+ return output
738
+ tq_inst, packed_dim = _get_tq(head_size)
719
739
 
720
740
  num_actual = attn_metadata.num_actual_tokens
721
741
  cu_seqlens = attn_metadata.query_start_loc
@@ -729,7 +749,6 @@ def _tq_prefill_sdpa(layer_idx, query, key, value, kv_cache, attn_metadata,
729
749
  key_cache = kv_cache[:, 0]
730
750
  value_cache = kv_cache[:, 1]
731
751
  block_size = key_cache.shape[1]
732
- packed_dim = _tq_packed_dim
733
752
  block_table = attn_metadata.block_table
734
753
 
735
754
  for i in range(num_seqs):
@@ -746,7 +765,7 @@ def _tq_prefill_sdpa(layer_idx, query, key, value, kv_cache, attn_metadata,
746
765
  # First prefill: all context is in current K/V
747
766
  ki = k_raw[q_start:q_end]
748
767
  vi = v_raw[q_start:q_end]
749
- elif k_raw is not None and _tq_quantizer is not None:
768
+ elif k_raw is not None and tq_inst is not None:
750
769
  # Continuation: decompress prior blocks + append current
751
770
  prior_len = ctx_len - q_len
752
771
  prior_blocks = (prior_len + block_size - 1) // block_size
@@ -754,7 +773,7 @@ def _tq_prefill_sdpa(layer_idx, query, key, value, kv_cache, attn_metadata,
754
773
  bt = bt[bt >= 0]
755
774
 
756
775
  if bt.numel() > 0:
757
- tq = _tq_quantizer
776
+ tq = tq_inst
758
777
  H = num_kv_heads
759
778
 
760
779
  kp = key_cache[bt, :, :, :packed_dim]
@@ -843,7 +862,7 @@ def _make_tq_forward(original_fwd):
843
862
  # Runs during BOTH normal execution AND CUDA graph capture.
844
863
  # Falls through to slow path for: init, prefill, missing key/value.
845
864
  if (hasattr(self, '_tq_layer_idx')
846
- and _tq_quantizer is not None
865
+ and self.head_size in _tq_quantizers
847
866
  and attn_metadata is not None
848
867
  and output is not None
849
868
  and key is not None and value is not None
@@ -892,7 +911,7 @@ def _make_tq_forward(original_fwd):
892
911
  output_block_scale=output_block_scale)
893
912
 
894
913
  # --- Init quantizer + norms (guarded: no graph break after first call) ---
895
- if _tq_quantizer is None:
914
+ if self.head_size not in _tq_quantizers:
896
915
  _ensure_quantizer(query.device, self.head_size,
897
916
  self.num_heads, self.num_kv_heads)
898
917
  if not _TQ_IS_HYBRID and _primary_k_norms is None:
@@ -946,7 +965,7 @@ def _make_tq_forward(original_fwd):
946
965
  # --- Phase 2a: Decode (fused TQ attention for uniform, custom op for hybrid) ---
947
966
  # Hybrid mode uses tq::hybrid_decode_step custom op (zero graph breaks),
948
967
  # or falls through to encode+SDPA path if custom op not available.
949
- if is_decode and _tq_quantizer is not None and _TQ_IS_HYBRID:
968
+ if is_decode and self.head_size in _tq_quantizers and _TQ_IS_HYBRID:
950
969
  if _USE_HYBRID_CUSTOM_OP and key is not None and value is not None:
951
970
  num_actual = attn_metadata.num_actual_tokens
952
971
  try:
@@ -965,7 +984,7 @@ def _make_tq_forward(original_fwd):
965
984
  self._tq_layer_idx, e)
966
985
  # Fall through to graph-break SDPA
967
986
 
968
- if is_decode and _tq_quantizer is not None and not _TQ_IS_HYBRID:
987
+ if is_decode and self.head_size in _tq_quantizers and not _TQ_IS_HYBRID:
969
988
  num_actual = attn_metadata.num_actual_tokens
970
989
  try:
971
990
  if _TQ_DEBUG and self._tq_layer_idx == 0:
@@ -1119,8 +1138,7 @@ def _debug_prefill_sdpa(layer_idx, query, key, value, kv_cache, attn_metadata,
1119
1138
  file=sys.stderr, flush=True)
1120
1139
 
1121
1140
  # Decode first few vectors and check
1122
- tq = _tq_quantizer
1123
- packed_dim = _tq_packed_dim
1141
+ tq, packed_dim = _get_tq(head_size)
1124
1142
  kc = kv_cache[:, 0]
1125
1143
  kp0 = kc[block_ids[0], 0, 0, :packed_dim] # first slot, first head
1126
1144
  kn0 = _primary_k_norms[0, block_ids[0], 0, 0] # first slot, first head
@@ -1,5 +1,5 @@
1
1
  """
2
- vLLM plugin entrypoint.
2
+ vLLM plugin entrypoint for the aither-kvcache wheel.
3
3
 
4
4
  Registered via pyproject.toml entry_points under vllm.general_plugins.
5
5
  vLLM loads this at startup in every process (API server + engine workers).
@@ -7,6 +7,11 @@ vLLM loads this at startup in every process (API server + engine workers).
7
7
  Registers:
8
8
  1. TurboQuant CUSTOM attention backend (--attention-backend CUSTOM)
9
9
  2. Graph-aware eviction (replaces LRU with semantic scoring)
10
+ 3. Per-request tenant isolation (default OFF; AITHER_REQUEST_TENANT_ISOLATION=1)
11
+
12
+ CANONICAL SOURCE. Synced by .github/workflows/sync-kvcache.yml into the public
13
+ Aitherium/aitherkvcache repo as aither_kvcache/vllm/plugin.py, alongside
14
+ tenant_isolation.py. Edit here, not in the public repo.
10
15
  """
11
16
 
12
17
  import logging
@@ -24,8 +29,8 @@ def register():
24
29
  # 1. Register TurboQuant attention backend
25
30
  try:
26
31
  from vllm.v1.attention.backends.registry import (
27
- register_backend,
28
32
  AttentionBackendEnum,
33
+ register_backend,
29
34
  )
30
35
  register_backend(
31
36
  AttentionBackendEnum.CUSTOM,
@@ -51,3 +56,16 @@ def register():
51
56
  except Exception as e:
52
57
  print(f"[aither-kvcache] Eviction install failed: {e}",
53
58
  file=sys.stderr, flush=True)
59
+
60
+ # 3. Per-request tenant isolation (default OFF; AITHER_REQUEST_TENANT_ISOLATION=1).
61
+ # Rides this plugin so it loads in every process (API server + EngineCore
62
+ # workers). Non-fatal: a failure leaves the process on the instance tenant.
63
+ if os.environ.get("AITHER_REQUEST_TENANT_ISOLATION") == "1":
64
+ try:
65
+ from .tenant_isolation import install_tenant_isolation
66
+ install_tenant_isolation()
67
+ except ImportError:
68
+ pass
69
+ except Exception as e:
70
+ print(f"[aither-kvcache] Tenant isolation install failed: {e}",
71
+ file=sys.stderr, flush=True)
@@ -0,0 +1,364 @@
1
+ """
2
+ Per-request tenant isolation for the aither-kvcache vLLM plugin.
3
+
4
+ This is the SELF-CONTAINED, in-wheel port of AitherOS's per-request KV-cache
5
+ tenant isolation. It rides the ACTIVE path on the orchestrator: the aither-kvcache
6
+ `vllm.general_plugins` entrypoint (`plugin.register()`) loads in every vLLM
7
+ process (API server + EngineCore workers), so a hook installed here actually runs
8
+ — unlike the AitherOS `lib.gpu.turboquant` import hooks, which are not mounted
9
+ into the served image.
10
+
11
+ Three seams, all fail-closed and non-fatal (a wrong symbol degrades to the
12
+ instance tenant; it never crashes vLLM startup):
13
+
14
+ 1. Serving layer (API-server process): read the gateway-signed OpenAI ``user``
15
+ field, verify its HMAC, and publish the tenant into a ContextVar.
16
+ 2. Engine ``generate`` (API-server process): embed the verified tenant into the
17
+ real vLLM ``request_id`` (positional index 2 in vLLM 0.19.x), so it crosses
18
+ the frontend -> EngineCore process boundary verbatim.
19
+ 3. Block manager (EngineCore worker): on ``KVCacheManager.allocate_slots`` the
20
+ ``request.request_id`` carries the embedded tenant — tag the request's blocks
21
+ with it; untag on ``free``.
22
+
23
+ Self-contained by necessity: the wheel cannot import ``lib.gpu.turboquant``. The
24
+ crypto/transport helpers mirror ``request_tenant_registry.py`` byte-for-byte so
25
+ tokens the AitherOS gateway signs verify here.
26
+
27
+ Gate: AITHER_REQUEST_TENANT_ISOLATION=1 (default OFF). Key: AITHER_TENANT_SIGNING_KEY
28
+ (injected from AitherSecrets into the image env; empty => fail-closed).
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import contextvars
34
+ import hashlib
35
+ import hmac
36
+ import logging
37
+ import os
38
+ import sys
39
+ import threading
40
+ from typing import Any
41
+
42
+ logger = logging.getLogger("aither.kvcache.tenant")
43
+
44
+ _ENABLED = os.environ.get("AITHER_REQUEST_TENANT_ISOLATION", "0") == "1"
45
+
46
+ # Must match request_tenant_registry.py exactly (cross-repo token compatibility).
47
+ _TOKEN_SEP = "|"
48
+ _RID_PREFIX = "tqt1:"
49
+ _RID_SEP = "~"
50
+
51
+ _pending_tenant: contextvars.ContextVar[str | None] = contextvars.ContextVar(
52
+ "tq_pending_tenant", default=None
53
+ )
54
+
55
+ # block_id -> tenant_slug. Populated at allocate_slots, cleared at free. Guarded.
56
+ _block_tenant: dict[int, str] = {}
57
+ _block_lock = threading.Lock()
58
+
59
+ _isolation_status: dict[str, bool] = {
60
+ "enabled": _ENABLED,
61
+ "serving_patched": False,
62
+ "engine_patched": False,
63
+ "block_manager_patched": False,
64
+ "key_present": False,
65
+ "verified": False,
66
+ }
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Token crypto + request_id transport (mirror of request_tenant_registry.py)
71
+ # ---------------------------------------------------------------------------
72
+ def _signing_key() -> bytes:
73
+ return os.environ.get("AITHER_TENANT_SIGNING_KEY", "").encode("utf-8")
74
+
75
+
76
+ def verify_tenant_token(
77
+ token: str | None, *, now: float | None = None
78
+ ) -> str | None:
79
+ """Return the tenant slug iff ``token`` has a valid, unexpired gateway signature."""
80
+ import time
81
+ key = _signing_key()
82
+ if not token or not key:
83
+ return None
84
+ parts = token.split(_TOKEN_SEP)
85
+ if len(parts) != 3:
86
+ return None
87
+ tenant, exp_str, sig = parts
88
+ if not tenant or not exp_str or not sig:
89
+ return None
90
+ try:
91
+ exp = int(exp_str)
92
+ except (TypeError, ValueError):
93
+ return None
94
+ expected = hmac.new(
95
+ key, f"{tenant}{_TOKEN_SEP}{exp}".encode(), hashlib.sha256
96
+ ).hexdigest()
97
+ if not hmac.compare_digest(sig, expected):
98
+ logger.warning("Rejected tenant token with invalid signature")
99
+ return None
100
+ if exp < (now if now is not None else time.time()):
101
+ logger.warning("Rejected expired tenant token")
102
+ return None
103
+ return tenant
104
+
105
+
106
+ def embed_tenant_in_request_id(request_id: str, tenant_slug: str) -> str:
107
+ """Prefix a verified tenant onto a request_id (idempotent, boundary-safe)."""
108
+ if not request_id or not tenant_slug:
109
+ return request_id
110
+ rid = str(request_id)
111
+ if _RID_SEP in tenant_slug or rid.startswith(_RID_PREFIX):
112
+ return request_id
113
+ return f"{_RID_PREFIX}{tenant_slug}{_RID_SEP}{rid}"
114
+
115
+
116
+ def extract_tenant_from_request_id(
117
+ request_id: str | None,
118
+ ) -> tuple[str | None, str | None]:
119
+ """Inverse of embed_tenant_in_request_id -> (tenant_or_None, original_id)."""
120
+ if not request_id:
121
+ return None, request_id
122
+ rid = str(request_id)
123
+ if not rid.startswith(_RID_PREFIX):
124
+ return None, request_id
125
+ tenant, sep, original = rid[len(_RID_PREFIX):].partition(_RID_SEP)
126
+ if not sep or not tenant:
127
+ return None, request_id
128
+ return tenant, original
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Block -> tenant map
133
+ # ---------------------------------------------------------------------------
134
+ def tag_blocks(tenant_slug: str, block_ids) -> None:
135
+ if not tenant_slug or not block_ids:
136
+ return
137
+ with _block_lock:
138
+ for bid in block_ids:
139
+ _block_tenant[int(bid)] = tenant_slug
140
+
141
+
142
+ def untag_blocks(block_ids) -> None:
143
+ if not block_ids:
144
+ return
145
+ with _block_lock:
146
+ for bid in block_ids:
147
+ _block_tenant.pop(int(bid), None)
148
+
149
+
150
+ def block_tenant(block_id: int) -> str | None:
151
+ with _block_lock:
152
+ return _block_tenant.get(int(block_id))
153
+
154
+
155
+ def block_tenant_count() -> int:
156
+ with _block_lock:
157
+ return len(_block_tenant)
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # Status
162
+ # ---------------------------------------------------------------------------
163
+ def get_isolation_status() -> dict[str, bool]:
164
+ _isolation_status["enabled"] = _ENABLED
165
+ _isolation_status["key_present"] = bool(_signing_key())
166
+ _isolation_status["verified"] = (
167
+ _isolation_status["enabled"]
168
+ and _isolation_status["serving_patched"]
169
+ and _isolation_status["engine_patched"]
170
+ and _isolation_status["block_manager_patched"]
171
+ and _isolation_status["key_present"]
172
+ )
173
+ return dict(_isolation_status)
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Seam 1: serving-layer wrap (verified tenant -> ContextVar)
178
+ # ---------------------------------------------------------------------------
179
+ def _wrap_serving_method(cls: Any, method_name: str) -> bool:
180
+ orig = getattr(cls, method_name, None)
181
+ if orig is None:
182
+ return False
183
+ if getattr(orig, "_tq_tenant_wrapped", False):
184
+ return True
185
+
186
+ async def _tenant_serving_wrapper(self, request, *args, **kwargs):
187
+ token = None
188
+ try:
189
+ user = getattr(request, "user", None)
190
+ tenant = verify_tenant_token(user) if user else None
191
+ if tenant:
192
+ token = _pending_tenant.set(tenant)
193
+ except (AttributeError, TypeError, ValueError) as e:
194
+ logger.debug("tenant capture skipped: %s", e)
195
+ token = None
196
+ try:
197
+ return await orig(self, request, *args, **kwargs)
198
+ finally:
199
+ if token is not None:
200
+ _pending_tenant.reset(token)
201
+
202
+ _tenant_serving_wrapper._tq_tenant_wrapped = True
203
+ setattr(cls, method_name, _tenant_serving_wrapper)
204
+ return True
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # Seam 2: engine generate wrap (embed tenant into positional request_id)
209
+ # ---------------------------------------------------------------------------
210
+ # vLLM 0.19.x: AsyncLLM.generate(self, prompt, sampling_params, request_id, *, ...)
211
+ # request_id is the 3rd positional arg -> index 2 in the wrapper's *args.
212
+ _GENERATE_RID_POS = 2
213
+
214
+
215
+ def _wrap_engine_generate(cls: Any, rid_pos: int = _GENERATE_RID_POS) -> bool:
216
+ orig = getattr(cls, "generate", None)
217
+ if orig is None:
218
+ return False
219
+ if getattr(orig, "_tq_tenant_wrapped", False):
220
+ return True
221
+
222
+ async def _tenant_generate_wrapper(self, *args, **kwargs):
223
+ tenant = _pending_tenant.get(None)
224
+ if tenant:
225
+ rid = None
226
+ pos = None
227
+ if isinstance(kwargs.get("request_id"), str) and kwargs["request_id"]:
228
+ rid = kwargs["request_id"]
229
+ elif (len(args) > rid_pos and isinstance(args[rid_pos], str)
230
+ and args[rid_pos]):
231
+ pos = rid_pos
232
+ rid = args[pos]
233
+ if rid:
234
+ embedded = embed_tenant_in_request_id(rid, tenant)
235
+ if pos is not None:
236
+ args = args[:pos] + (embedded,) + args[pos + 1:]
237
+ else:
238
+ kwargs = dict(kwargs)
239
+ kwargs["request_id"] = embedded
240
+ async for out in orig(self, *args, **kwargs):
241
+ yield out
242
+
243
+ _tenant_generate_wrapper._tq_tenant_wrapped = True
244
+ cls.generate = _tenant_generate_wrapper
245
+ return True
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # Seam 3: block manager wrap (tag blocks by tenant from request.request_id)
250
+ # ---------------------------------------------------------------------------
251
+ def _wrap_block_manager(cls: Any) -> bool:
252
+ alloc = getattr(cls, "allocate_slots", None)
253
+ free = getattr(cls, "free", None)
254
+ if alloc is None:
255
+ return False
256
+ if getattr(alloc, "_tq_tenant_wrapped", False):
257
+ return True
258
+
259
+ def _flatten_block_ids(mgr, request_id) -> list:
260
+ try:
261
+ groups = mgr.get_block_ids(request_id)
262
+ except Exception:
263
+ return []
264
+ ids: list = []
265
+ for g in groups or ():
266
+ try:
267
+ ids.extend(int(b) for b in g)
268
+ except TypeError:
269
+ ids.append(int(g))
270
+ return ids
271
+
272
+ def _tenant_allocate_slots(self, request, *args, **kwargs):
273
+ result = alloc(self, request, *args, **kwargs)
274
+ if result is not None:
275
+ try:
276
+ rid = getattr(request, "request_id", None)
277
+ tenant, _orig = extract_tenant_from_request_id(rid)
278
+ if tenant:
279
+ tag_blocks(tenant, _flatten_block_ids(self, rid))
280
+ except Exception as e: # never break allocation for tagging
281
+ logger.debug("tenant block-tag skipped: %s", e)
282
+ return result
283
+
284
+ _tenant_allocate_slots._tq_tenant_wrapped = True
285
+ cls.allocate_slots = _tenant_allocate_slots
286
+
287
+ if free is not None and not getattr(free, "_tq_tenant_wrapped", False):
288
+ def _tenant_free(self, request, *args, **kwargs):
289
+ try:
290
+ rid = getattr(request, "request_id", None)
291
+ if rid:
292
+ untag_blocks(_flatten_block_ids(self, rid))
293
+ except Exception as e:
294
+ logger.debug("tenant block-untag skipped: %s", e)
295
+ return free(self, request, *args, **kwargs)
296
+
297
+ _tenant_free._tq_tenant_wrapped = True
298
+ cls.free = _tenant_free
299
+
300
+ return True
301
+
302
+
303
+ # ---------------------------------------------------------------------------
304
+ # Install
305
+ # ---------------------------------------------------------------------------
306
+ def install_tenant_isolation() -> bool:
307
+ """Install all three seams. Called from plugin.register() when enabled.
308
+
309
+ Returns True iff verified (serving + engine + block manager + key). Every
310
+ failure is non-fatal and leaves the process serving with the instance tenant.
311
+ """
312
+ if not _ENABLED:
313
+ logger.debug("tenant isolation disabled (AITHER_REQUEST_TENANT_ISOLATION!=1)")
314
+ return False
315
+
316
+ serving_targets = [
317
+ ("vllm.entrypoints.openai.chat_completion.serving", "OpenAIServingChat",
318
+ "create_chat_completion"),
319
+ ("vllm.entrypoints.openai.serving_chat", "OpenAIServingChat",
320
+ "create_chat_completion"),
321
+ ("vllm.entrypoints.openai.completion.serving", "OpenAIServingCompletion",
322
+ "create_completion"),
323
+ ("vllm.entrypoints.openai.serving_completion", "OpenAIServingCompletion",
324
+ "create_completion"),
325
+ ]
326
+ seen = set()
327
+ for modpath, clsname, method in serving_targets:
328
+ try:
329
+ mod = __import__(modpath, fromlist=[clsname])
330
+ cls = getattr(mod, clsname)
331
+ if cls in seen:
332
+ continue
333
+ if _wrap_serving_method(cls, method):
334
+ seen.add(cls)
335
+ _isolation_status["serving_patched"] = True
336
+ except Exception as e:
337
+ logger.debug("serving wrap skipped %s.%s: %s", clsname, method, e)
338
+
339
+ for modpath, clsname in [
340
+ ("vllm.v1.engine.async_llm", "AsyncLLM"),
341
+ ("vllm.engine.async_llm_engine", "AsyncLLMEngine"),
342
+ ]:
343
+ try:
344
+ mod = __import__(modpath, fromlist=[clsname])
345
+ if _wrap_engine_generate(getattr(mod, clsname)):
346
+ _isolation_status["engine_patched"] = True
347
+ except Exception as e:
348
+ logger.debug("engine wrap skipped %s: %s", clsname, e)
349
+
350
+ try:
351
+ from vllm.v1.core.kv_cache_manager import KVCacheManager
352
+ if _wrap_block_manager(KVCacheManager):
353
+ _isolation_status["block_manager_patched"] = True
354
+ except Exception as e:
355
+ logger.debug("block-manager wrap skipped: %s", e)
356
+
357
+ st = get_isolation_status()
358
+ print(
359
+ f"[aither-kvcache] tenant isolation: serving={st['serving_patched']} "
360
+ f"engine={st['engine_patched']} block_mgr={st['block_manager_patched']} "
361
+ f"key={st['key_present']} VERIFIED={st['verified']}",
362
+ file=sys.stderr, flush=True,
363
+ )
364
+ return st["verified"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aither-kvcache
3
- Version: 2.0.1
3
+ Version: 2.2.0
4
4
  Summary: Near-optimal KV cache compression for LLM inference — TurboQuant (vector quantization) + TriAttention (spectral compression)
5
5
  Author: Aitherium
6
6
  License-Expression: CC-BY-4.0
@@ -36,6 +36,7 @@ aither_kvcache/vllm/eviction_plugin.py
36
36
  aither_kvcache/vllm/hooks.py
37
37
  aither_kvcache/vllm/plugin.py
38
38
  aither_kvcache/vllm/sitecustomize.py
39
+ aither_kvcache/vllm/tenant_isolation.py
39
40
  tests/test_core.py
40
41
  tests/test_fused.py
41
42
  tests/test_hybrid_hooks.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aither-kvcache"
7
- version = "2.0.1"
7
+ version = "2.2.0"
8
8
  description = "Near-optimal KV cache compression for LLM inference — TurboQuant (vector quantization) + TriAttention (spectral compression)"
9
9
  readme = "README.md"
10
10
  license = "CC-BY-4.0"
@@ -22,6 +22,7 @@ Usage:
22
22
  """
23
23
 
24
24
  from .quantizer import TurboQuant, TurboQuantConfig
25
+ from .gb10_fp8_rescue import get_gb10_fp8_status, install_gb10_fp8_rescue
25
26
 
26
27
  __all__ = [
27
28
  "TurboQuant",
@@ -34,6 +35,8 @@ __all__ = [
34
35
  "get_tier_cache_bridge",
35
36
  "get_strata_cache_shadow",
36
37
  "get_graph_eviction_advisor",
38
+ "install_gb10_fp8_rescue",
39
+ "get_gb10_fp8_status",
37
40
  ]
38
41
  __version__ = "0.9.0"
39
42
 
@@ -82,6 +82,66 @@ def get_codebook(d: int, bits: int) -> Tuple[np.ndarray, np.ndarray, float]:
82
82
  return centroids, boundaries_inner, mse_total
83
83
 
84
84
 
85
+ def get_correction_factors(d: int, bits: int) -> np.ndarray:
86
+ """
87
+ Compute per-centroid inner-product bias correction factors.
88
+
89
+ For Lloyd-Max quantization on N(0, 1/d), the reconstructed inner product
90
+ <q, x_hat> is a biased estimator of <q, x>. The correction factor for
91
+ centroid i is: alpha_i = E[x | x in bin_i] / c_i.
92
+
93
+ For a perfect Lloyd-Max codebook this is ~1.0, but finite-precision
94
+ centroids and boundary discretization introduce small biases. Computing
95
+ these analytically and applying them removes the systematic IP bias.
96
+
97
+ Returns:
98
+ correction: array of shape [2^bits], values close to 1.0.
99
+ """
100
+ if bits not in _STANDARD_CODEBOOKS:
101
+ raise ValueError(f"Supported bit-widths: {list(_STANDARD_CODEBOOKS.keys())}. Got {bits}")
102
+
103
+ entry = _STANDARD_CODEBOOKS[bits]
104
+ std_centroids = entry["centroids"]
105
+ num_levels = 1 << bits
106
+ sigma = 1.0 / math.sqrt(d)
107
+
108
+ # Standard N(0,1) boundaries
109
+ boundaries = np.empty(num_levels + 1)
110
+ boundaries[0] = -10.0 # effectively -inf
111
+ boundaries[-1] = 10.0 # effectively +inf
112
+ for i in range(num_levels - 1):
113
+ boundaries[i + 1] = (std_centroids[i] + std_centroids[i + 1]) / 2
114
+
115
+ # Compute E[x | x in bin_i] for N(0,1)
116
+ # E[x | b_lo <= x < b_hi] = (phi(b_lo) - phi(b_hi)) / (Phi(b_hi) - Phi(b_lo))
117
+ # where phi = pdf, Phi = cdf of N(0,1)
118
+ from math import exp as _exp, pi as _pi
119
+
120
+ def _phi(x):
121
+ return (1.0 / math.sqrt(2 * _pi)) * _exp(-0.5 * x * x)
122
+
123
+ def _Phi(x):
124
+ # Abramowitz & Stegun approximation (accurate to 1e-7)
125
+ t = 1.0 / (1.0 + 0.2316419 * abs(x))
126
+ poly = t * (0.319381530 + t * (-0.356563782 + t * (
127
+ 1.781477937 + t * (-1.821255978 + t * 1.330274429))))
128
+ val = 1.0 - _phi(x) * poly
129
+ return val if x >= 0 else 1.0 - val
130
+
131
+ corrections = np.ones(num_levels, dtype=np.float64)
132
+ for i in range(num_levels):
133
+ b_lo = boundaries[i]
134
+ b_hi = boundaries[i + 1]
135
+ prob = _Phi(b_hi) - _Phi(b_lo)
136
+ if prob < 1e-15 or abs(std_centroids[i]) < 1e-15:
137
+ corrections[i] = 1.0
138
+ continue
139
+ cond_mean = (_phi(b_lo) - _phi(b_hi)) / prob
140
+ corrections[i] = cond_mean / std_centroids[i]
141
+
142
+ return corrections.astype(np.float32)
143
+
144
+
85
145
  def get_theory_bounds(bits: int) -> Tuple[float, float]:
86
146
  """Return (lower_bound, upper_bound) on MSE for b-bit quantization."""
87
147
  lower = 1.0 / (4 ** bits)
@@ -66,9 +66,11 @@ class TQPagedAttentionRef:
66
66
  head_dim: int,
67
67
  block_size: int,
68
68
  bits: int = 4,
69
+ k_centroids: Optional[torch.Tensor] = None,
69
70
  ):
70
71
  self.rotation = rotation # [head_dim, head_dim]
71
- self.centroids = centroids # [num_levels]
72
+ self.centroids = centroids # [num_levels] — used for V accumulation
73
+ self.k_centroids = k_centroids if k_centroids is not None else centroids # for K scoring
72
74
  self.scale = scale
73
75
  self.num_kv_heads = num_kv_heads
74
76
  self.num_query_heads = num_query_heads
@@ -81,12 +83,14 @@ class TQPagedAttentionRef:
81
83
  from .packing import unpack_4bit, unpack_3bit, unpack_2bit
82
84
  self._unpack_fns = {4: unpack_4bit, 3: unpack_3bit, 2: unpack_2bit}
83
85
 
84
- def _unpack_to_full(self, packed_vec: torch.Tensor) -> torch.Tensor:
86
+ def _unpack_to_full(self, packed_vec: torch.Tensor,
87
+ codebook: Optional[torch.Tensor] = None) -> torch.Tensor:
85
88
  """Unpack a single packed vector to full head_dim via codebook lookup."""
86
89
  # packed_vec: [packed_dim] uint8
90
+ cb = codebook if codebook is not None else self.centroids
87
91
  unpack_fn = self._unpack_fns.get(self.bits, self._unpack_fns[4])
88
92
  indices = unpack_fn(packed_vec.unsqueeze(0), self.head_dim).squeeze(0)
89
- return self.centroids[indices.long()]
93
+ return cb[indices.long()]
90
94
 
91
95
  def forward(
92
96
  self,
@@ -136,9 +140,9 @@ class TQPagedAttentionRef:
136
140
  n_valid = end - start
137
141
 
138
142
  for pos in range(n_valid):
139
- # Unpack K → full rotated vector via codebook
143
+ # Unpack K → full rotated vector via corrected codebook
140
144
  packed_k = k_packed[phys_block, pos, kv_head]
141
- k_full = self._unpack_to_full(packed_k)
145
+ k_full = self._unpack_to_full(packed_k, self.k_centroids)
142
146
  k_even = k_full[0::2]
143
147
  k_odd = k_full[1::2]
144
148
 
@@ -214,8 +218,9 @@ if HAS_TRITON:
214
218
  # Paged block indirection
215
219
  block_tables_ptr, # [num_seqs, max_blocks_per_seq] int32/int64
216
220
  context_lens_ptr, # [num_seqs] int32/int64
217
- # Codebook
218
- centroids_ptr, # [16] float32
221
+ # Codebooks (separate for K scoring and V accumulation)
222
+ k_centroids_ptr, # [16] float32 — corrected centroids for K scoring
223
+ v_centroids_ptr, # [16] float32 — raw centroids for V accumulation
219
224
  # Output accumulators (rotated domain, even/odd split)
220
225
  out_even_ptr, # [num_seqs, num_q_heads, HALF_D] float32
221
226
  out_odd_ptr, # [num_seqs, num_q_heads, HALF_D] float32
@@ -300,8 +305,8 @@ if HAS_TRITON:
300
305
  k_packed_ptr + kp_base + half_offs
301
306
  ).to(tl.int32)
302
307
 
303
- k_even = tl.load(centroids_ptr + ((kp >> 4) & 0xF))
304
- k_odd = tl.load(centroids_ptr + (kp & 0xF))
308
+ k_even = tl.load(k_centroids_ptr + ((kp >> 4) & 0xF))
309
+ k_odd = tl.load(k_centroids_ptr + (kp & 0xF))
305
310
 
306
311
  # ── Score ──
307
312
  k_norm = tl.load(
@@ -332,8 +337,8 @@ if HAS_TRITON:
332
337
  v_packed_ptr + vp_base + half_offs
333
338
  ).to(tl.int32)
334
339
 
335
- v_even = tl.load(centroids_ptr + ((vp >> 4) & 0xF))
336
- v_odd = tl.load(centroids_ptr + (vp & 0xF))
340
+ v_even = tl.load(v_centroids_ptr + ((vp >> 4) & 0xF))
341
+ v_odd = tl.load(v_centroids_ptr + (vp & 0xF))
337
342
  v_norm = tl.load(
338
343
  v_norms_ptr
339
344
  + phys_block * stride_kn_block
@@ -371,7 +376,7 @@ if HAS_TRITON:
371
376
  k_packed_ptr, k_norms_ptr,
372
377
  v_packed_ptr, v_norms_ptr,
373
378
  block_tables_ptr, context_lens_ptr,
374
- centroids_ptr,
379
+ k_centroids_ptr, v_centroids_ptr,
375
380
  # Partial outputs: [num_q_heads, num_seqs, NUM_SPLITS, HALF_D]
376
381
  part_even_ptr, part_odd_ptr,
377
382
  # Partial softmax state: [num_q_heads, num_seqs, NUM_SPLITS]
@@ -431,8 +436,8 @@ if HAS_TRITON:
431
436
  k_packed_ptr + kp_base + half_offs
432
437
  ).to(tl.int32)
433
438
 
434
- k_even = tl.load(centroids_ptr + ((kp >> 4) & 0xF))
435
- k_odd = tl.load(centroids_ptr + (kp & 0xF))
439
+ k_even = tl.load(k_centroids_ptr + ((kp >> 4) & 0xF))
440
+ k_odd = tl.load(k_centroids_ptr + (kp & 0xF))
436
441
 
437
442
  k_norm = tl.load(
438
443
  k_norms_ptr + phys_block * stride_kn_block
@@ -457,8 +462,8 @@ if HAS_TRITON:
457
462
  v_packed_ptr + vp_base + half_offs
458
463
  ).to(tl.int32)
459
464
 
460
- v_even = tl.load(centroids_ptr + ((vp >> 4) & 0xF))
461
- v_odd = tl.load(centroids_ptr + (vp & 0xF))
465
+ v_even = tl.load(v_centroids_ptr + ((vp >> 4) & 0xF))
466
+ v_odd = tl.load(v_centroids_ptr + (vp & 0xF))
462
467
  v_norm = tl.load(
463
468
  v_norms_ptr + phys_block * stride_kn_block
464
469
  + pos * stride_kn_pos + kv_head)
@@ -560,6 +565,9 @@ class TQPagedAttention:
560
565
  self.rotation = tq.rotation
561
566
  self.rotation_T = tq.rotation.T.contiguous() # pre-transposed, contiguous
562
567
  self.centroids = tq.centroids
568
+ # Corrected centroids for unbiased inner product scoring (K path only).
569
+ # V accumulation uses raw centroids (correction only affects score bias).
570
+ self.k_centroids = tq.centroids * tq.correction_factors
563
571
  self.scale = 1.0 / math.sqrt(tq.head_dim)
564
572
  self.head_dim = tq.head_dim
565
573
  self.num_levels = tq.num_levels
@@ -577,6 +585,7 @@ class TQPagedAttention:
577
585
  head_dim=tq.head_dim,
578
586
  block_size=16,
579
587
  bits=tq.bits,
588
+ k_centroids=self.k_centroids,
580
589
  )
581
590
 
582
591
  # Block selector for sparse attention (optional)
@@ -697,7 +706,7 @@ class TQPagedAttention:
697
706
  q_even, q_odd,
698
707
  k_packed, k_norms, v_packed, v_norms,
699
708
  block_tables, context_lens,
700
- self.centroids,
709
+ self.k_centroids, self.centroids,
701
710
  part_even, part_odd, part_m, part_l,
702
711
  q_even.stride(0), q_even.stride(1),
703
712
  k_packed.stride(0), k_packed.stride(1), k_packed.stride(2),
@@ -724,7 +733,7 @@ class TQPagedAttention:
724
733
  q_even, q_odd,
725
734
  k_packed, k_norms, v_packed, v_norms,
726
735
  block_tables, context_lens,
727
- self.centroids,
736
+ self.k_centroids, self.centroids,
728
737
  out_even, out_odd,
729
738
  q_even.stride(0), q_even.stride(1),
730
739
  k_packed.stride(0), k_packed.stride(1), k_packed.stride(2),
@@ -23,7 +23,7 @@ import torch
23
23
  from dataclasses import dataclass
24
24
  from typing import Optional, Tuple
25
25
 
26
- from .codebook import get_codebook, get_theory_bounds
26
+ from .codebook import get_codebook, get_theory_bounds, get_correction_factors
27
27
  from .rotation import random_orthogonal, randomized_hadamard_matrix
28
28
  from .packing import (
29
29
  pack_4bit, unpack_4bit,
@@ -102,6 +102,12 @@ class TurboQuant:
102
102
  # Theory bounds
103
103
  self.theory_lower, self.theory_upper = get_theory_bounds(config.bits)
104
104
 
105
+ # Per-centroid correction factors for unbiased inner product estimation
106
+ correction_np = get_correction_factors(config.head_dim, config.bits)
107
+ self.correction_factors = torch.tensor(
108
+ correction_np, dtype=torch.float32, device=config.device,
109
+ )
110
+
105
111
  # Pack/unpack dispatch
106
112
  self._pack_fn = {2: pack_2bit, 3: pack_3bit, 4: pack_4bit}[config.bits]
107
113
  self._unpack_fn = {2: unpack_2bit, 3: unpack_3bit, 4: unpack_4bit}[config.bits]
@@ -211,6 +217,12 @@ class TurboQuant:
211
217
  indices = self._unpack_fn(packed, self.head_dim)
212
218
  return self.centroids[indices.long()]
213
219
 
220
+ def _pytorch_decode_corrected(self, packed: torch.Tensor) -> torch.Tensor:
221
+ """Unpack + codebook lookup with per-centroid IP bias correction."""
222
+ indices = self._unpack_fn(packed, self.head_dim)
223
+ idx_long = indices.long()
224
+ return self.centroids[idx_long] * self.correction_factors[idx_long]
225
+
214
226
  # ================================================================
215
227
  # INTERNAL: Triton path (GPU only)
216
228
  # ================================================================
@@ -295,6 +307,16 @@ class TurboQuant:
295
307
  ip_mse = (true_ip - est_ip).pow(2).mean().item()
296
308
  ip_bias = (est_ip - true_ip).mean().item()
297
309
 
310
+ # Corrected decode: apply per-centroid bias correction factors
311
+ packed_flat = packed.reshape(-1, packed.shape[-1])
312
+ norms_flat = norms.reshape(-1).float()
313
+ y_hat_corr = self._pytorch_decode_corrected(packed_flat)
314
+ x_hat_corr = torch.matmul(y_hat_corr, self.rotation)
315
+ x_hat_corr = x_hat_corr * norms_flat.unsqueeze(-1)
316
+ x_hat_corr = x_hat_corr.reshape(x.shape)
317
+ est_ip_corr = (x_hat_corr[idx_a] * x[idx_b]).sum(dim=-1)
318
+ ip_bias_corrected = (est_ip_corr - true_ip).mean().item()
319
+
298
320
  # Max absolute error
299
321
  max_err = (x - x_hat).abs().max().item()
300
322
 
@@ -308,6 +330,7 @@ class TurboQuant:
308
330
  "mse_ratio_to_lower": mse / self.theory_lower if self.theory_lower > 0 else float("inf"),
309
331
  "ip_mse": ip_mse,
310
332
  "ip_bias": ip_bias,
333
+ "ip_bias_corrected": ip_bias_corrected,
311
334
  "max_abs_error": max_err,
312
335
  "compression_vs_fp16": self.compression_ratio(),
313
336
  "compression_vs_fp8": self.compression_ratio_vs_fp8(),
File without changes
File without changes
File without changes