lifeboatcache 0.1.0__py3-none-any.whl
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.
- lifeboat_cache/__init__.py +7 -0
- lifeboat_cache/compress/__init__.py +57 -0
- lifeboat_cache/compress/accuracy.py +53 -0
- lifeboat_cache/compress/base.py +229 -0
- lifeboat_cache/compress/calibration.py +81 -0
- lifeboat_cache/compress/demote.py +16 -0
- lifeboat_cache/compress/fp8.py +160 -0
- lifeboat_cache/compress/kvtc.py +140 -0
- lifeboat_cache/compress/live.py +284 -0
- lifeboat_cache/compress/oscar2.py +166 -0
- lifeboat_cache/compress/turboquant.py +131 -0
- lifeboat_cache/config/__init__.py +36 -0
- lifeboat_cache/config/cli.py +276 -0
- lifeboat_cache/config/loader.py +278 -0
- lifeboat_cache/config/paths.py +22 -0
- lifeboat_cache/config/schema.py +450 -0
- lifeboat_cache/control/__init__.py +54 -0
- lifeboat_cache/control/config_push.py +243 -0
- lifeboat_cache/control/controld.py +187 -0
- lifeboat_cache/control/membership.py +216 -0
- lifeboat_cache/control/placement.py +81 -0
- lifeboat_cache/control/router_export.py +87 -0
- lifeboat_cache/control/telemetry_agg.py +101 -0
- lifeboat_cache/core/__init__.py +59 -0
- lifeboat_cache/core/coalesce.py +100 -0
- lifeboat_cache/core/hashing.py +96 -0
- lifeboat_cache/core/header.py +158 -0
- lifeboat_cache/core/keys.py +57 -0
- lifeboat_cache/core/ring.py +52 -0
- lifeboat_cache/dashboard/__init__.py +334 -0
- lifeboat_cache/dashboard/static/index.html +582 -0
- lifeboat_cache/device/__init__.py +48 -0
- lifeboat_cache/device/base.py +75 -0
- lifeboat_cache/device/cpu.py +15 -0
- lifeboat_cache/device/cuda.py +51 -0
- lifeboat_cache/errors.py +31 -0
- lifeboat_cache/index/__init__.py +67 -0
- lifeboat_cache/index/backends.py +207 -0
- lifeboat_cache/index/entry.py +35 -0
- lifeboat_cache/index/hashring.py +104 -0
- lifeboat_cache/index/local.py +136 -0
- lifeboat_cache/index/sharded.py +101 -0
- lifeboat_cache/integrations/__init__.py +1 -0
- lifeboat_cache/integrations/host.py +104 -0
- lifeboat_cache/integrations/k8s/__init__.py +76 -0
- lifeboat_cache/integrations/lifeboat_store.py +386 -0
- lifeboat_cache/integrations/lmcache_adapter.py +395 -0
- lifeboat_cache/integrations/lmcache_defaults.py +54 -0
- lifeboat_cache/integrations/policies.py +39 -0
- lifeboat_cache/integrations/registration.py +60 -0
- lifeboat_cache/integrations/serde.py +109 -0
- lifeboat_cache/integrations/sglang_backend.py +187 -0
- lifeboat_cache/integrations/sglang_plugin.py +110 -0
- lifeboat_cache/integrations/vllm_connector.py +50 -0
- lifeboat_cache/integrations/vllm_cpu_patches.py +116 -0
- lifeboat_cache/integrations/vllm_plugin.py +80 -0
- lifeboat_cache/policy/__init__.py +98 -0
- lifeboat_cache/policy/accuracy_guard.py +105 -0
- lifeboat_cache/policy/admission.py +60 -0
- lifeboat_cache/policy/base.py +68 -0
- lifeboat_cache/policy/classic.py +205 -0
- lifeboat_cache/policy/episodic.py +289 -0
- lifeboat_cache/policy/predictive.py +89 -0
- lifeboat_cache/policy/reuse_score.py +100 -0
- lifeboat_cache/presets/base.yaml +16 -0
- lifeboat_cache/presets/dev_cpu.yaml +22 -0
- lifeboat_cache/presets/fleet_50k.yaml +53 -0
- lifeboat_cache/presets/max_accuracy.yaml +15 -0
- lifeboat_cache/presets/max_concurrency.yaml +15 -0
- lifeboat_cache/presets/single_node_gpu.yaml +18 -0
- lifeboat_cache/presets/storage_efficient.yaml +13 -0
- lifeboat_cache/registry.py +114 -0
- lifeboat_cache/session/__init__.py +142 -0
- lifeboat_cache/sim/__init__.py +110 -0
- lifeboat_cache/sim/fleet.py +148 -0
- lifeboat_cache/sim/trace.py +78 -0
- lifeboat_cache/storage/__init__.py +198 -0
- lifeboat_cache/storage/backpressure.py +89 -0
- lifeboat_cache/storage/circuit_breaker.py +89 -0
- lifeboat_cache/storage/coalesced.py +63 -0
- lifeboat_cache/storage/doca_memos.py +82 -0
- lifeboat_cache/storage/filesystem.py +177 -0
- lifeboat_cache/storage/manager.py +238 -0
- lifeboat_cache/storage/memory.py +42 -0
- lifeboat_cache/storage/ontap.py +385 -0
- lifeboat_cache/storage/rdma_nixl.py +100 -0
- lifeboat_cache/storage/s3.py +181 -0
- lifeboat_cache/storage/tier.py +75 -0
- lifeboat_cache/telemetry/__init__.py +89 -0
- lifeboat_cache/telemetry/metrics.py +86 -0
- lifeboat_cache/tenancy/__init__.py +98 -0
- lifeboat_cache/tenancy/qos.py +76 -0
- lifeboat_cache/tenancy/quota.py +73 -0
- lifeboatcache-0.1.0.dist-info/METADATA +121 -0
- lifeboatcache-0.1.0.dist-info/RECORD +97 -0
- lifeboatcache-0.1.0.dist-info/WHEEL +4 -0
- lifeboatcache-0.1.0.dist-info/entry_points.txt +9 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""CPU reference compression layer for Lifeboat Cache.
|
|
2
|
+
|
|
3
|
+
A set of KV-cache codecs behind one :class:`Codec` interface, driven by the
|
|
4
|
+
``compression`` config section. Every codec is pure-numpy (torch optional) and produces a
|
|
5
|
+
**self-describing blob** so ``decode`` needs no external arguments.
|
|
6
|
+
|
|
7
|
+
Codecs (each approximates a published method; see the module docstrings for exactly what is
|
|
8
|
+
reproduced vs. approximated):
|
|
9
|
+
|
|
10
|
+
* ``turboquant`` — group-wise affine int3/int4 with float16-kept layers.
|
|
11
|
+
* ``fp8`` — e4m3 / e5m2 emulation via an exact FP8 value grid.
|
|
12
|
+
* ``oscar`` — 2-bit middle + full-precision sink/recent tokens + optional rotation.
|
|
13
|
+
* ``kvtc`` — PCA + quantized coefficients/residual + zlib (ANS stand-in).
|
|
14
|
+
|
|
15
|
+
Importing this package registers all four codec factories, so
|
|
16
|
+
:func:`build_codecs` can instantiate any ladder from config.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from .accuracy import cosine, logit_kl, mse, tensor_relative_error
|
|
21
|
+
from .base import (
|
|
22
|
+
Codec,
|
|
23
|
+
build_codecs,
|
|
24
|
+
create_codec,
|
|
25
|
+
pack_arrays,
|
|
26
|
+
pack_blob,
|
|
27
|
+
register_codec,
|
|
28
|
+
unpack_arrays,
|
|
29
|
+
unpack_blob,
|
|
30
|
+
)
|
|
31
|
+
from .calibration import CalibrationProfile
|
|
32
|
+
from .demote import recompress
|
|
33
|
+
from .fp8 import FP8Codec
|
|
34
|
+
from .kvtc import KVTCCodec
|
|
35
|
+
from .oscar2 import Oscar2Codec
|
|
36
|
+
from .turboquant import TurboQuantCodec
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"Codec",
|
|
40
|
+
"register_codec",
|
|
41
|
+
"create_codec",
|
|
42
|
+
"build_codecs",
|
|
43
|
+
"pack_blob",
|
|
44
|
+
"unpack_blob",
|
|
45
|
+
"pack_arrays",
|
|
46
|
+
"unpack_arrays",
|
|
47
|
+
"TurboQuantCodec",
|
|
48
|
+
"FP8Codec",
|
|
49
|
+
"Oscar2Codec",
|
|
50
|
+
"KVTCCodec",
|
|
51
|
+
"CalibrationProfile",
|
|
52
|
+
"recompress",
|
|
53
|
+
"logit_kl",
|
|
54
|
+
"cosine",
|
|
55
|
+
"mse",
|
|
56
|
+
"tensor_relative_error",
|
|
57
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Accuracy metrics for the compression accuracy-guard.
|
|
2
|
+
|
|
3
|
+
The guard samples a fraction of writes, decodes them, and compares against the original to
|
|
4
|
+
decide whether a codec is degrading quality (config ``compression.accuracy_guard.metric``).
|
|
5
|
+
The headline metric is softmax KL on logits — the distributional distance that actually
|
|
6
|
+
matters for generation quality — plus cheaper tensor-space helpers.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
_EPS = 1e-12
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _log_softmax(z: np.ndarray) -> np.ndarray:
|
|
16
|
+
z = z - z.max(axis=-1, keepdims=True)
|
|
17
|
+
return z - np.log(np.exp(z).sum(axis=-1, keepdims=True) + _EPS)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def logit_kl(p_logits: np.ndarray, q_logits: np.ndarray) -> float:
|
|
21
|
+
"""Mean KL(softmax(p) || softmax(q)) over the last axis.
|
|
22
|
+
|
|
23
|
+
Zero iff the two logit tensors induce the same distribution (identical logits, or logits
|
|
24
|
+
differing only by a per-row additive constant), strictly positive otherwise. Computed in
|
|
25
|
+
log-space for numerical stability.
|
|
26
|
+
"""
|
|
27
|
+
lp = _log_softmax(np.asarray(p_logits, dtype=np.float64))
|
|
28
|
+
lq = _log_softmax(np.asarray(q_logits, dtype=np.float64))
|
|
29
|
+
p = np.exp(lp)
|
|
30
|
+
kl = (p * (lp - lq)).sum(axis=-1)
|
|
31
|
+
return float(np.maximum(kl, 0.0).mean())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def cosine(a: np.ndarray, b: np.ndarray) -> float:
|
|
35
|
+
"""Mean cosine similarity in [-1, 1] over flattened vectors (1.0 == identical)."""
|
|
36
|
+
a = np.asarray(a, dtype=np.float64).reshape(-1)
|
|
37
|
+
b = np.asarray(b, dtype=np.float64).reshape(-1)
|
|
38
|
+
denom = (np.linalg.norm(a) * np.linalg.norm(b)) + _EPS
|
|
39
|
+
return float(np.dot(a, b) / denom)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def mse(a: np.ndarray, b: np.ndarray) -> float:
|
|
43
|
+
"""Mean squared error between two arrays."""
|
|
44
|
+
a = np.asarray(a, dtype=np.float64)
|
|
45
|
+
b = np.asarray(b, dtype=np.float64)
|
|
46
|
+
return float(np.mean((a - b) ** 2))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def tensor_relative_error(a: np.ndarray, b: np.ndarray) -> float:
|
|
50
|
+
"""Relative reconstruction error ``||a - b|| / (||a|| + eps)`` (Frobenius norm)."""
|
|
51
|
+
a = np.asarray(a, dtype=np.float64)
|
|
52
|
+
b = np.asarray(b, dtype=np.float64)
|
|
53
|
+
return float(np.linalg.norm(a - b) / (np.linalg.norm(a) + _EPS))
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Codec base class, self-describing blob format, and the codec registry.
|
|
2
|
+
|
|
3
|
+
Every codec in this package is a *CPU reference* implementation: it uses only numpy so
|
|
4
|
+
the whole compression layer runs on a laptop with no CUDA. The on-GPU production codecs
|
|
5
|
+
(real TurboQuant / OSCAR / KVTC kernels) would plug in behind the same :class:`Codec`
|
|
6
|
+
interface — these reference versions exist to validate the format, the round-trip math,
|
|
7
|
+
and the ladder/demote plumbing.
|
|
8
|
+
|
|
9
|
+
Blob self-description
|
|
10
|
+
---------------------
|
|
11
|
+
``decode`` takes *only* the blob — no shape, dtype, or param arguments. We achieve that
|
|
12
|
+
with a tiny length-prefixed JSON header followed by a raw binary payload::
|
|
13
|
+
|
|
14
|
+
u32 little-endian json_len
|
|
15
|
+
json_len bytes UTF-8 JSON header (scalar params + an ``_arrays`` manifest)
|
|
16
|
+
rest concatenated raw array bytes (order = manifest order)
|
|
17
|
+
|
|
18
|
+
The ``_arrays`` manifest records each stored numpy array's ``dtype`` and ``shape`` so the
|
|
19
|
+
payload can be sliced back apart deterministically (dicts preserve insertion order, and
|
|
20
|
+
JSON preserves it too). This keeps each codec's serialization declarative: build a dict of
|
|
21
|
+
numpy arrays + a dict of scalar params, and the helpers do the rest.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import struct
|
|
27
|
+
from abc import ABC, abstractmethod
|
|
28
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING: # avoid a hard import cycle; only needed for type hints
|
|
33
|
+
from .calibration import CalibrationProfile
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# --------------------------------------------------------------------------- #
|
|
37
|
+
# blob (de)serialization
|
|
38
|
+
# --------------------------------------------------------------------------- #
|
|
39
|
+
def pack_blob(header: dict[str, Any], payload: bytes) -> bytes:
|
|
40
|
+
"""Serialize a JSON ``header`` + raw ``payload`` into one self-describing blob."""
|
|
41
|
+
hb = json.dumps(header, separators=(",", ":")).encode("utf-8")
|
|
42
|
+
return struct.pack("<I", len(hb)) + hb + payload
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def unpack_blob(blob: bytes) -> tuple[dict[str, Any], bytes]:
|
|
46
|
+
"""Inverse of :func:`pack_blob` — return (header dict, payload bytes)."""
|
|
47
|
+
(n,) = struct.unpack_from("<I", blob, 0)
|
|
48
|
+
header = json.loads(blob[4 : 4 + n].decode("utf-8"))
|
|
49
|
+
return header, blob[4 + n :]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def pack_arrays(arrays: dict[str, np.ndarray]) -> tuple[dict[str, Any], bytes]:
|
|
53
|
+
"""Concatenate ``arrays`` to bytes and return a manifest describing each one.
|
|
54
|
+
|
|
55
|
+
The manifest (dtype + shape per key) goes in the JSON header; the bytes become the
|
|
56
|
+
payload. Order is preserved so :func:`unpack_arrays` can slice them back out.
|
|
57
|
+
"""
|
|
58
|
+
meta: dict[str, Any] = {}
|
|
59
|
+
parts: list[bytes] = []
|
|
60
|
+
for key, arr in arrays.items():
|
|
61
|
+
a = np.ascontiguousarray(arr)
|
|
62
|
+
meta[key] = {"dtype": str(a.dtype), "shape": list(a.shape)}
|
|
63
|
+
parts.append(a.tobytes())
|
|
64
|
+
return meta, b"".join(parts)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def unpack_arrays(meta: dict[str, Any], payload: bytes) -> dict[str, np.ndarray]:
|
|
68
|
+
"""Inverse of :func:`pack_arrays` using the manifest produced there."""
|
|
69
|
+
out: dict[str, np.ndarray] = {}
|
|
70
|
+
off = 0
|
|
71
|
+
for key, m in meta.items():
|
|
72
|
+
dt = np.dtype(m["dtype"])
|
|
73
|
+
shape = tuple(int(s) for s in m["shape"])
|
|
74
|
+
nbytes = int(np.prod(shape)) * dt.itemsize if shape else dt.itemsize
|
|
75
|
+
buf = payload[off : off + nbytes]
|
|
76
|
+
out[key] = np.frombuffer(buf, dtype=dt).reshape(shape).copy()
|
|
77
|
+
off += nbytes
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# --------------------------------------------------------------------------- #
|
|
82
|
+
# bit packing (for sub-byte integer codes)
|
|
83
|
+
# --------------------------------------------------------------------------- #
|
|
84
|
+
def pack_bits(codes: np.ndarray, bits: int) -> np.ndarray:
|
|
85
|
+
"""Pack a 1-D array of small unsigned ints (0..2**bits-1) into a dense bit stream.
|
|
86
|
+
|
|
87
|
+
Each code contributes ``bits`` bits (LSB-first within the code); the stream is then
|
|
88
|
+
byte-packed with :func:`numpy.packbits`. Returned as a uint8 array so it can be stored
|
|
89
|
+
directly via :func:`pack_arrays`.
|
|
90
|
+
"""
|
|
91
|
+
codes = codes.astype(np.uint8).reshape(-1)
|
|
92
|
+
if bits == 8:
|
|
93
|
+
return codes.copy()
|
|
94
|
+
# (N, bits) matrix: column j is bit j of each code.
|
|
95
|
+
shifts = np.arange(bits, dtype=np.uint8)
|
|
96
|
+
bit_mat = ((codes[:, None] >> shifts) & 1).astype(np.uint8)
|
|
97
|
+
return np.packbits(bit_mat.reshape(-1))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def unpack_bits(packed: np.ndarray, n: int, bits: int) -> np.ndarray:
|
|
101
|
+
"""Inverse of :func:`pack_bits`: recover ``n`` codes each of width ``bits``."""
|
|
102
|
+
packed = np.asarray(packed, dtype=np.uint8)
|
|
103
|
+
if bits == 8:
|
|
104
|
+
return packed[:n].astype(np.uint8).copy()
|
|
105
|
+
flat = np.unpackbits(packed)[: n * bits].reshape(n, bits)
|
|
106
|
+
weights = (1 << np.arange(bits)).astype(np.uint16)
|
|
107
|
+
return (flat.astype(np.uint16) * weights).sum(axis=1).astype(np.uint8)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# --------------------------------------------------------------------------- #
|
|
111
|
+
# group-wise affine integer quantization (shared by turboquant / oscar / kvtc)
|
|
112
|
+
# --------------------------------------------------------------------------- #
|
|
113
|
+
def quantize_rows(x: np.ndarray, bits: int, group_size: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
114
|
+
"""Per-group affine quantize a 2-D array along its last axis.
|
|
115
|
+
|
|
116
|
+
For each row and each contiguous group of ``group_size`` columns we store a scale and
|
|
117
|
+
a zero-point (the group minimum): ``q = round((x - min) / scale)`` clipped to
|
|
118
|
+
``[0, 2**bits - 1]`` with ``scale = (max - min) / (2**bits - 1)``.
|
|
119
|
+
|
|
120
|
+
Because every value lies within its group's ``[min, max]``, no clipping is ever needed
|
|
121
|
+
and round-to-nearest bounds the reconstruction error by ``scale / 2`` per group — the
|
|
122
|
+
property the tests assert. A constant group gets ``scale = 1`` and quantizes exactly.
|
|
123
|
+
|
|
124
|
+
Returns ``(codes, scales, zeros)`` with ``codes`` shape ``(rows, D)`` uint8 and
|
|
125
|
+
``scales``/``zeros`` shape ``(rows, n_groups)`` float32.
|
|
126
|
+
"""
|
|
127
|
+
rows, D = x.shape
|
|
128
|
+
qmax = (1 << bits) - 1
|
|
129
|
+
n_groups = (D + group_size - 1) // group_size
|
|
130
|
+
codes = np.empty((rows, D), dtype=np.uint8)
|
|
131
|
+
scales = np.empty((rows, n_groups), dtype=np.float32)
|
|
132
|
+
zeros = np.empty((rows, n_groups), dtype=np.float32)
|
|
133
|
+
for g in range(n_groups):
|
|
134
|
+
s, e = g * group_size, min((g + 1) * group_size, D)
|
|
135
|
+
blk = x[:, s:e]
|
|
136
|
+
mn = blk.min(axis=1)
|
|
137
|
+
mx = blk.max(axis=1)
|
|
138
|
+
scale = (mx - mn) / qmax
|
|
139
|
+
# Constant group -> scale 0; set to 1 so (x-min)/scale == 0 and dequant is exact.
|
|
140
|
+
scale = np.where(scale > 0, scale, 1.0).astype(np.float32)
|
|
141
|
+
q = np.rint((blk - mn[:, None]) / scale[:, None])
|
|
142
|
+
codes[:, s:e] = np.clip(q, 0, qmax).astype(np.uint8)
|
|
143
|
+
scales[:, g] = scale
|
|
144
|
+
zeros[:, g] = mn.astype(np.float32)
|
|
145
|
+
return codes, scales, zeros
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def dequantize_rows(codes: np.ndarray, scales: np.ndarray, zeros: np.ndarray, group_size: int) -> np.ndarray:
|
|
149
|
+
"""Inverse of :func:`quantize_rows`."""
|
|
150
|
+
rows, D = codes.shape
|
|
151
|
+
n_groups = scales.shape[1]
|
|
152
|
+
out = np.empty((rows, D), dtype=np.float32)
|
|
153
|
+
for g in range(n_groups):
|
|
154
|
+
s, e = g * group_size, min((g + 1) * group_size, D)
|
|
155
|
+
out[:, s:e] = codes[:, s:e].astype(np.float32) * scales[:, g : g + 1] + zeros[:, g : g + 1]
|
|
156
|
+
return out
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def orthogonal_matrix(dim: int, seed: int) -> np.ndarray:
|
|
160
|
+
"""Deterministic random orthogonal matrix via QR (used for OSCAR rotations).
|
|
161
|
+
|
|
162
|
+
The sign of ``Q`` is pinned by the diagonal of ``R`` so the factorization is unique
|
|
163
|
+
and reproducible from ``seed`` alone — encode and decode regenerate the same matrix
|
|
164
|
+
with no need to store it.
|
|
165
|
+
"""
|
|
166
|
+
rng = np.random.default_rng(seed)
|
|
167
|
+
a = rng.standard_normal((dim, dim))
|
|
168
|
+
q, r = np.linalg.qr(a)
|
|
169
|
+
q *= np.sign(np.diag(r))
|
|
170
|
+
return q.astype(np.float32)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# --------------------------------------------------------------------------- #
|
|
174
|
+
# Codec interface
|
|
175
|
+
# --------------------------------------------------------------------------- #
|
|
176
|
+
class Codec(ABC):
|
|
177
|
+
"""A compression codec. Encode a numpy array to a self-describing blob and back.
|
|
178
|
+
|
|
179
|
+
``decode`` must not need any external arguments — everything required to reconstruct
|
|
180
|
+
(shape, dtype, params, scales, bases, rotation seeds, ...) lives in the blob header.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
#: Instance name (the key under which the codec appears in config, e.g. ``turboquant4``).
|
|
184
|
+
name: str = "codec"
|
|
185
|
+
|
|
186
|
+
@abstractmethod
|
|
187
|
+
def encode(self, arr: np.ndarray, *, layer_id: int = 0, profile: "CalibrationProfile | None" = None) -> bytes:
|
|
188
|
+
"""Compress ``arr`` to a blob. ``layer_id``/``profile`` tune per-layer behavior."""
|
|
189
|
+
|
|
190
|
+
@abstractmethod
|
|
191
|
+
def decode(self, blob: bytes) -> np.ndarray:
|
|
192
|
+
"""Reconstruct the array from a blob produced by :meth:`encode`."""
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# --------------------------------------------------------------------------- #
|
|
196
|
+
# registry
|
|
197
|
+
# --------------------------------------------------------------------------- #
|
|
198
|
+
# Factories are keyed by codec *type* (``turboquant``/``oscar``/``kvtc``/``fp8``) — the
|
|
199
|
+
# ``Codec.type`` field in config. A factory takes a config object (anything exposing the
|
|
200
|
+
# codec params as attributes) and returns a ready Codec instance.
|
|
201
|
+
_REGISTRY: dict[str, Callable[[Any], Codec]] = {}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def register_codec(name: str, factory: Callable[[Any], Codec]) -> None:
|
|
205
|
+
"""Register a codec factory under its type ``name`` (idempotent / overwriting)."""
|
|
206
|
+
_REGISTRY[name] = factory
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def create_codec(name: str, codec_cfg: Any) -> Codec:
|
|
210
|
+
"""Instantiate the codec registered under type ``name`` from ``codec_cfg``."""
|
|
211
|
+
try:
|
|
212
|
+
factory = _REGISTRY[name]
|
|
213
|
+
except KeyError as exc: # pragma: no cover - defensive
|
|
214
|
+
raise KeyError(f"no codec registered for type '{name}' (have {sorted(_REGISTRY)})") from exc
|
|
215
|
+
return factory(codec_cfg)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def build_codecs(compression_cfg: Any) -> dict[str, Codec]:
|
|
219
|
+
"""Instantiate every codec named in ``compression_cfg.codecs``.
|
|
220
|
+
|
|
221
|
+
Returns a mapping of config key (e.g. ``turboquant4``) -> Codec, with each codec's
|
|
222
|
+
``.name`` set to that key so blobs/telemetry can report which ladder entry produced them.
|
|
223
|
+
"""
|
|
224
|
+
out: dict[str, Codec] = {}
|
|
225
|
+
for key, cfg in compression_cfg.codecs.items():
|
|
226
|
+
codec = create_codec(cfg.type, cfg)
|
|
227
|
+
codec.name = key
|
|
228
|
+
out[key] = codec
|
|
229
|
+
return out
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Per-model calibration profile: cached state shared across encode calls.
|
|
2
|
+
|
|
3
|
+
A :class:`CalibrationProfile` is fit once per model (from a handful of representative KV
|
|
4
|
+
tensors) and then handed to ``Codec.encode`` to tune per-layer behavior. The reference
|
|
5
|
+
codecs are mostly self-calibrating per blob, so this profile stays deliberately small; it
|
|
6
|
+
carries:
|
|
7
|
+
|
|
8
|
+
* ``num_layers`` — so TurboQuant can resolve negative ``keep_bf16_layers`` indices.
|
|
9
|
+
* ``state`` — a dict of numpy arrays (e.g. a cached KVTC PCA basis + mean, per-codec scales).
|
|
10
|
+
* ``meta`` — JSON-serializable scalars (e.g. a global scale).
|
|
11
|
+
|
|
12
|
+
Persistence uses ``.npz`` (arrays) with the JSON metadata embedded as a byte array in the
|
|
13
|
+
same file — no pickle, so a profile file is safe to load from untrusted locations.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any, Sequence
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
_META_KEY = "__meta__"
|
|
24
|
+
_STATE_PREFIX = "state::"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class CalibrationProfile:
|
|
29
|
+
num_layers: int = 0
|
|
30
|
+
model_hash: str = ""
|
|
31
|
+
state: dict[str, np.ndarray] = field(default_factory=dict)
|
|
32
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
def fit(self, samples: "np.ndarray | Sequence[np.ndarray]") -> "CalibrationProfile":
|
|
35
|
+
"""Fit the profile from one or more representative tensors.
|
|
36
|
+
|
|
37
|
+
Computes a global magnitude scale and a cached PCA basis (mean + right singular
|
|
38
|
+
vectors over the last-axis feature vectors) that a KVTC codec could reuse instead
|
|
39
|
+
of recomputing SVD per blob. ``num_layers`` is inferred from the first sample's
|
|
40
|
+
leading axis when not already set.
|
|
41
|
+
"""
|
|
42
|
+
arrs = list(samples) if isinstance(samples, (list, tuple)) else [samples]
|
|
43
|
+
arrs = [np.asarray(a, dtype=np.float32) for a in arrs]
|
|
44
|
+
if not arrs:
|
|
45
|
+
return self
|
|
46
|
+
if not self.num_layers:
|
|
47
|
+
self.num_layers = int(arrs[0].shape[0])
|
|
48
|
+
|
|
49
|
+
D = arrs[0].shape[-1]
|
|
50
|
+
X = np.concatenate([a.reshape(-1, D) for a in arrs], axis=0)
|
|
51
|
+
self.meta["global_scale"] = float(np.abs(X).max())
|
|
52
|
+
mean = X.mean(axis=0)
|
|
53
|
+
Xc = X - mean
|
|
54
|
+
_, _, vt = np.linalg.svd(Xc, full_matrices=False)
|
|
55
|
+
self.state["kvtc_mean"] = mean.astype(np.float32)
|
|
56
|
+
self.state["kvtc_basis"] = vt.astype(np.float32)
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
# -- persistence --
|
|
60
|
+
def save(self, path: str) -> None:
|
|
61
|
+
meta = {"num_layers": self.num_layers, "model_hash": self.model_hash, "meta": self.meta}
|
|
62
|
+
meta_bytes = np.frombuffer(json.dumps(meta).encode("utf-8"), dtype=np.uint8)
|
|
63
|
+
payload = {_META_KEY: meta_bytes}
|
|
64
|
+
payload.update({f"{_STATE_PREFIX}{k}": v for k, v in self.state.items()})
|
|
65
|
+
np.savez(path, **payload)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def load(cls, path: str) -> "CalibrationProfile":
|
|
69
|
+
with np.load(path, allow_pickle=False) as data:
|
|
70
|
+
meta = json.loads(bytes(data[_META_KEY]).decode("utf-8"))
|
|
71
|
+
state = {
|
|
72
|
+
k[len(_STATE_PREFIX) :]: np.array(data[k])
|
|
73
|
+
for k in data.files
|
|
74
|
+
if k.startswith(_STATE_PREFIX)
|
|
75
|
+
}
|
|
76
|
+
return cls(
|
|
77
|
+
num_layers=meta["num_layers"],
|
|
78
|
+
model_hash=meta["model_hash"],
|
|
79
|
+
state=state,
|
|
80
|
+
meta=meta["meta"],
|
|
81
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Recompress-on-demote: move a chunk from one codec to another.
|
|
2
|
+
|
|
3
|
+
When a chunk is demoted down the storage ladder (e.g. GPU ``turboquant4`` -> archive
|
|
4
|
+
``kvtc``), it is re-encoded into the destination tier's codec. The reference path is simply
|
|
5
|
+
decode-then-encode through numpy; a production implementation might transcode directly in
|
|
6
|
+
the quantized domain, but decode/encode is always correct and is what the ladder relies on.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .base import Codec
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def recompress(blob: bytes, from_codec: Codec, to_codec: Codec, *, layer_id: int = 0) -> bytes:
|
|
14
|
+
"""Decode ``blob`` with ``from_codec`` and re-encode it with ``to_codec``."""
|
|
15
|
+
arr = from_codec.decode(blob)
|
|
16
|
+
return to_codec.encode(arr, layer_id=layer_id)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""FP8 emulation (e4m3 / e5m2) on CPU via an exact 256-entry value table.
|
|
2
|
+
|
|
3
|
+
Approximation note
|
|
4
|
+
------------------
|
|
5
|
+
Real hardware stores one byte per element in the OCP FP8 formats. We emulate that exactly
|
|
6
|
+
by enumerating all 256 bit patterns of the chosen format, decoding each to its float value
|
|
7
|
+
(this *is* the FP8 numeric grid, including subnormals), and encoding an input by snapping
|
|
8
|
+
each element to the nearest representable value (``searchsorted`` on the sorted grid).
|
|
9
|
+
Specials (e4m3 ``S.1111.111`` NaN; e5m2 ``exp==all-ones`` inf/NaN) are excluded from the
|
|
10
|
+
candidate set, so finite inputs never map to them. This reproduces true round-to-nearest
|
|
11
|
+
FP8 behavior with no hand-rolled mantissa bit-twiddling.
|
|
12
|
+
|
|
13
|
+
A per-tensor ``scale`` maps the tensor's peak magnitude to ~half the format's max before
|
|
14
|
+
quantization (and divides back out on decode). FP8's error is relative (the grid is roughly
|
|
15
|
+
log-uniform), so scaling chiefly prevents overflow and lifts tiny tensors out of the
|
|
16
|
+
subnormal region; it does not change the ~relative tolerance.
|
|
17
|
+
|
|
18
|
+
Tolerance: e4m3 has 3 mantissa bits -> max relative step ~2**-4 = 6.25% per element; e5m2
|
|
19
|
+
has 2 mantissa bits -> ~12.5%. Tensor-level relative error is well under those.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import functools
|
|
24
|
+
from typing import TYPE_CHECKING, Any
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
from .base import Codec, pack_arrays, pack_blob, register_codec, unpack_arrays, unpack_blob
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from .calibration import CalibrationProfile
|
|
32
|
+
|
|
33
|
+
_FORMATS = {"e4m3": (4, 3, 7), "e5m2": (5, 2, 15)}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@functools.lru_cache(maxsize=None)
|
|
37
|
+
def _fp8_grid(fmt: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
38
|
+
"""Return (all_values[256], finite_mask[256], sorted_finite_values) for ``fmt``."""
|
|
39
|
+
ebits, mbits, bias = _FORMATS[fmt]
|
|
40
|
+
vals = np.empty(256, dtype=np.float32)
|
|
41
|
+
finite = np.ones(256, dtype=bool)
|
|
42
|
+
exp_all_ones = (1 << ebits) - 1
|
|
43
|
+
for b in range(256):
|
|
44
|
+
s = (b >> 7) & 1
|
|
45
|
+
exp = (b >> mbits) & ((1 << ebits) - 1)
|
|
46
|
+
man = b & ((1 << mbits) - 1)
|
|
47
|
+
is_special = (fmt == "e4m3" and exp == exp_all_ones and man == (1 << mbits) - 1) or (
|
|
48
|
+
fmt == "e5m2" and exp == exp_all_ones
|
|
49
|
+
)
|
|
50
|
+
if is_special:
|
|
51
|
+
finite[b] = False
|
|
52
|
+
vals[b] = np.nan
|
|
53
|
+
continue
|
|
54
|
+
if exp == 0: # subnormal (or zero)
|
|
55
|
+
v = (man / (1 << mbits)) * 2.0 ** (1 - bias)
|
|
56
|
+
else: # normal
|
|
57
|
+
v = (1.0 + man / (1 << mbits)) * 2.0 ** (exp - bias)
|
|
58
|
+
vals[b] = (-1.0) ** s * v
|
|
59
|
+
return vals, finite, np.sort(vals[finite]).astype(np.float32)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@functools.lru_cache(maxsize=None)
|
|
63
|
+
def _torch_fp8(fmt: str):
|
|
64
|
+
"""(torch, fp8 dtype) when torch has native float8, else None (numpy grid path)."""
|
|
65
|
+
try:
|
|
66
|
+
import torch
|
|
67
|
+
except ImportError:
|
|
68
|
+
return None
|
|
69
|
+
dt = {"e4m3": "float8_e4m3fn", "e5m2": "float8_e5m2"}[fmt]
|
|
70
|
+
return (torch, getattr(torch, dt)) if hasattr(torch, dt) else None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class FP8Codec(Codec):
|
|
74
|
+
def __init__(self, fmt: str = "e4m3", name: str = "fp8") -> None:
|
|
75
|
+
if fmt not in _FORMATS:
|
|
76
|
+
raise ValueError(f"fp8 format must be one of {sorted(_FORMATS)}, got {fmt}")
|
|
77
|
+
self.fmt = fmt
|
|
78
|
+
self.name = name
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_cfg(cls, cfg: Any) -> "FP8Codec":
|
|
82
|
+
# Accept either `format` or `fmt`; default e4m3 (the common KV-cache choice).
|
|
83
|
+
return cls(fmt=getattr(cfg, "format", getattr(cfg, "fmt", "e4m3")))
|
|
84
|
+
|
|
85
|
+
def encode(self, arr: np.ndarray, *, layer_id: int = 0, profile: "CalibrationProfile | None" = None) -> bytes:
|
|
86
|
+
arr = np.asarray(arr, dtype=np.float32)
|
|
87
|
+
fast = _torch_fp8(self.fmt)
|
|
88
|
+
if fast is not None:
|
|
89
|
+
return self._encode_torch(arr, *fast)
|
|
90
|
+
vals, finite, sorted_vals = _fp8_grid(self.fmt)
|
|
91
|
+
max_val = float(np.nanmax(np.abs(vals)))
|
|
92
|
+
|
|
93
|
+
amax = float(np.abs(arr).max()) if arr.size else 0.0
|
|
94
|
+
target = max_val * 0.5
|
|
95
|
+
scale = amax / target if amax > 0 else 1.0
|
|
96
|
+
|
|
97
|
+
x = (arr.reshape(-1) / scale).astype(np.float32)
|
|
98
|
+
# Nearest representable value: searchsorted gives the right neighbor; compare both.
|
|
99
|
+
idx = np.searchsorted(sorted_vals, x)
|
|
100
|
+
idx = np.clip(idx, 1, len(sorted_vals) - 1)
|
|
101
|
+
left = sorted_vals[idx - 1]
|
|
102
|
+
right = sorted_vals[idx]
|
|
103
|
+
choose_left = np.abs(x - left) <= np.abs(x - right)
|
|
104
|
+
|
|
105
|
+
# Map chosen values back to their byte codes (value -> byte lookup).
|
|
106
|
+
finite_bytes = np.nonzero(finite)[0].astype(np.uint8)
|
|
107
|
+
order = np.argsort(vals[finite])
|
|
108
|
+
sorted_bytes = finite_bytes[order]
|
|
109
|
+
chosen_sorted_idx = np.where(choose_left, idx - 1, idx)
|
|
110
|
+
codes = sorted_bytes[chosen_sorted_idx].astype(np.uint8)
|
|
111
|
+
|
|
112
|
+
arr_meta, payload = pack_arrays({"codes": codes})
|
|
113
|
+
header = {
|
|
114
|
+
"codec": self.name,
|
|
115
|
+
"type": "fp8",
|
|
116
|
+
"fmt": self.fmt,
|
|
117
|
+
"scale": scale,
|
|
118
|
+
"shape": list(arr.shape),
|
|
119
|
+
"dtype": str(arr.dtype),
|
|
120
|
+
"_arrays": arr_meta,
|
|
121
|
+
}
|
|
122
|
+
return pack_blob(header, payload)
|
|
123
|
+
|
|
124
|
+
def _encode_torch(self, arr: np.ndarray, torch, fp8_dtype) -> bytes:
|
|
125
|
+
"""Same blob as the grid path (the stored byte *is* the e4m3/e5m2 bit pattern), via
|
|
126
|
+
torch's native round-to-nearest-even cast — orders of magnitude faster on big chunks.
|
|
127
|
+
The scale keeps |x| <= max/2, so the cast never saturates."""
|
|
128
|
+
max_val = float(torch.finfo(fp8_dtype).max)
|
|
129
|
+
amax = float(np.abs(arr).max()) if arr.size else 0.0
|
|
130
|
+
scale = amax / (max_val * 0.5) if amax > 0 else 1.0
|
|
131
|
+
x = torch.from_numpy(arr.reshape(-1)) / scale
|
|
132
|
+
codes = x.to(fp8_dtype).view(torch.uint8).numpy()
|
|
133
|
+
arr_meta, payload = pack_arrays({"codes": codes})
|
|
134
|
+
header = {
|
|
135
|
+
"codec": self.name,
|
|
136
|
+
"type": "fp8",
|
|
137
|
+
"fmt": self.fmt,
|
|
138
|
+
"scale": scale,
|
|
139
|
+
"shape": list(arr.shape),
|
|
140
|
+
"dtype": str(arr.dtype),
|
|
141
|
+
"_arrays": arr_meta,
|
|
142
|
+
}
|
|
143
|
+
return pack_blob(header, payload)
|
|
144
|
+
|
|
145
|
+
def decode(self, blob: bytes) -> np.ndarray:
|
|
146
|
+
header, payload = unpack_blob(blob)
|
|
147
|
+
arrays = unpack_arrays(header["_arrays"], payload)
|
|
148
|
+
fast = _torch_fp8(header["fmt"])
|
|
149
|
+
if fast is not None:
|
|
150
|
+
torch, fp8_dtype = fast
|
|
151
|
+
codes = torch.from_numpy(arrays["codes"])
|
|
152
|
+
recon = (codes.view(fp8_dtype).to(torch.float32) * float(header["scale"])).numpy()
|
|
153
|
+
return recon.reshape(tuple(header["shape"])).astype(np.dtype(header["dtype"]))
|
|
154
|
+
vals, _, _ = _fp8_grid(header["fmt"])
|
|
155
|
+
codes = arrays["codes"]
|
|
156
|
+
recon = vals[codes.astype(np.intp)] * np.float32(header["scale"])
|
|
157
|
+
return recon.reshape(tuple(header["shape"])).astype(np.dtype(header["dtype"]))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
register_codec("fp8", FP8Codec.from_cfg)
|