aris-server 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.
- aris_server/__init__.py +3 -0
- aris_server/budget.py +82 -0
- aris_server/cli.py +141 -0
- aris_server/config.py +119 -0
- aris_server/disk_cache.py +245 -0
- aris_server/engine.py +792 -0
- aris_server/http.py +278 -0
- aris_server/kv_quant.py +288 -0
- aris_server/pull.py +145 -0
- aris_server/service.py +80 -0
- aris_server/stream.py +177 -0
- aris_server/tool_stream.py +131 -0
- aris_server/translate.py +270 -0
- aris_server-0.1.0.dist-info/METADATA +132 -0
- aris_server-0.1.0.dist-info/RECORD +18 -0
- aris_server-0.1.0.dist-info/WHEEL +4 -0
- aris_server-0.1.0.dist-info/entry_points.txt +2 -0
- aris_server-0.1.0.dist-info/licenses/LICENSE +202 -0
aris_server/__init__.py
ADDED
aris_server/budget.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Think budget: close a model's reasoning on a complete thought once it has used its allowance."""
|
|
2
|
+
|
|
3
|
+
import mlx.core as mx
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ThinkBudget:
|
|
7
|
+
"""Logits processor. Counts generated tokens while thinking. Past `budget`, outside a code fence, a paragraph
|
|
8
|
+
break becomes a single newline and the end-of-thinking token is forced after the next newline; `overrun` tokens
|
|
9
|
+
later it closes regardless (an open fence, or a line that never ends: models can loop in place). Inert once
|
|
10
|
+
thinking has ended.
|
|
11
|
+
|
|
12
|
+
Ending on a single newline matters: chat templates re-render past reasoning as `text\\n</think>`, so a turn that
|
|
13
|
+
closed after a paragraph break (`text\\n\\n</think>`) no longer matches its own prompt cache, and the next turn
|
|
14
|
+
re-reads everything from that point."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, budget: int, end_id: int, newline_id: int, paragraph_ids: frozenset[int],
|
|
17
|
+
fence_counts: dict[int, int], overrun: int = 1024):
|
|
18
|
+
self.budget, self.end_id, self.newline_id, self.overrun = budget, end_id, newline_id, overrun
|
|
19
|
+
self.paragraph_ids, self.fence_counts = paragraph_ids, fence_counts
|
|
20
|
+
self.n = self.fences = 0
|
|
21
|
+
self.done = self.forced = False
|
|
22
|
+
self._paragraphs = None
|
|
23
|
+
|
|
24
|
+
def _only(self, token_id, logits):
|
|
25
|
+
mask = mx.arange(logits.shape[-1]) == token_id
|
|
26
|
+
return mx.broadcast_to(mx.where(mask, mx.array(0.0, logits.dtype), mx.array(-mx.inf, logits.dtype)),
|
|
27
|
+
logits.shape)
|
|
28
|
+
|
|
29
|
+
def __call__(self, tokens, logits):
|
|
30
|
+
if self.done or tokens is None or len(tokens) == 0:
|
|
31
|
+
return logits
|
|
32
|
+
last = int(tokens[-1].item())
|
|
33
|
+
if last == self.end_id:
|
|
34
|
+
self.done = True
|
|
35
|
+
return logits
|
|
36
|
+
self.n += 1
|
|
37
|
+
self.fences += self.fence_counts.get(last, 0)
|
|
38
|
+
if self.n < self.budget:
|
|
39
|
+
return logits
|
|
40
|
+
hard = self.n >= self.budget + self.overrun
|
|
41
|
+
if self.fences % 2 and not hard:
|
|
42
|
+
return logits
|
|
43
|
+
if last == self.newline_id:
|
|
44
|
+
self.done = self.forced = True
|
|
45
|
+
return self._only(self.end_id, logits)
|
|
46
|
+
if hard:
|
|
47
|
+
return self._only(self.newline_id, logits)
|
|
48
|
+
# A paragraph break the model would take becomes a single newline, which then closes thinking.
|
|
49
|
+
if self._paragraphs is None:
|
|
50
|
+
ids = sorted(self.paragraph_ids)
|
|
51
|
+
self._paragraphs = mx.array(ids) if ids else None
|
|
52
|
+
if self._paragraphs is None:
|
|
53
|
+
return logits
|
|
54
|
+
flat = logits.reshape(-1, logits.shape[-1])
|
|
55
|
+
merged = mx.logaddexp(flat[:, self.newline_id], mx.logsumexp(flat[:, self._paragraphs], axis=-1))
|
|
56
|
+
flat[:, self._paragraphs] = -mx.inf
|
|
57
|
+
flat[:, self.newline_id] = merged
|
|
58
|
+
return flat.reshape(logits.shape)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class BudgetTokens:
|
|
62
|
+
"""Per-tokenizer lookups the processor needs, computed once per model."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, tokenizer):
|
|
65
|
+
end = tokenizer.think_end_tokens if hasattr(tokenizer, "think_end_tokens") else None
|
|
66
|
+
self.end_id = end[0] if end and len(end) == 1 else None
|
|
67
|
+
newline = tokenizer.encode("\n", add_special_tokens=False) if hasattr(tokenizer, "encode") else []
|
|
68
|
+
self.newline_id = newline[0] if len(newline) == 1 else None
|
|
69
|
+
self.paragraph_ids, self.fence_counts = set(), {}
|
|
70
|
+
decode = tokenizer.decode
|
|
71
|
+
for i in range(len(tokenizer.vocab) if hasattr(tokenizer, "vocab") else tokenizer.vocab_size):
|
|
72
|
+
text = decode([i])
|
|
73
|
+
if text.endswith("\n\n"):
|
|
74
|
+
self.paragraph_ids.add(i)
|
|
75
|
+
if "```" in text:
|
|
76
|
+
self.fence_counts[i] = text.count("```")
|
|
77
|
+
self.paragraph_ids = frozenset(self.paragraph_ids)
|
|
78
|
+
|
|
79
|
+
def make(self, budget: int) -> ThinkBudget | None:
|
|
80
|
+
if budget <= 0 or self.end_id is None or self.newline_id is None:
|
|
81
|
+
return None
|
|
82
|
+
return ThinkBudget(budget, self.end_id, self.newline_id, self.paragraph_ids, self.fence_counts)
|
aris_server/cli.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""aris-server command line."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import dataclasses
|
|
5
|
+
import logging
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .config import builtin_models, home, load_models, resolve
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _wired_limit():
|
|
15
|
+
from mlx_lm.utils import maybe_set_recommended_wired_limit
|
|
16
|
+
|
|
17
|
+
maybe_set_recommended_wired_limit()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _quiet_dependencies(level):
|
|
21
|
+
"""mlx-lm logs through the root logger and httpx logs every Hugging Face request; keep only their warnings."""
|
|
22
|
+
for name in ("httpx", "huggingface_hub"):
|
|
23
|
+
logging.getLogger(name).setLevel(level)
|
|
24
|
+
|
|
25
|
+
def keep(record):
|
|
26
|
+
noisy = "mlx_lm" in record.pathname or record.name.startswith(("httpx", "huggingface_hub"))
|
|
27
|
+
return record.levelno >= level or not noisy
|
|
28
|
+
|
|
29
|
+
for handler in logging.getLogger().handlers:
|
|
30
|
+
handler.addFilter(keep)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main(argv=None):
|
|
34
|
+
ap = argparse.ArgumentParser(prog="aris-server", description="Open-weight models behind the Anthropic Messages API.")
|
|
35
|
+
ap.add_argument("--version", action="version", version=f"aris-server {__version__}")
|
|
36
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
37
|
+
s = sub.add_parser("serve", help="load a model and serve it")
|
|
38
|
+
s.add_argument("--model", help="configured model name, local MLX folder, or Hugging Face repo id")
|
|
39
|
+
s.add_argument("--adapter", help="LoRA adapter folder to apply on top of the model")
|
|
40
|
+
s.add_argument("--kv-bits", type=int, choices=[4, 8, 16],
|
|
41
|
+
help="bits per KV cache value (16 = full precision); overrides the model's kv_bits")
|
|
42
|
+
s.add_argument("--host", default="127.0.0.1")
|
|
43
|
+
s.add_argument("--port", type=int, default=8484)
|
|
44
|
+
s.add_argument("--api-key", help="require this key in x-api-key")
|
|
45
|
+
s.add_argument("--concurrency", type=int, default=4, help="sequences decoded at once")
|
|
46
|
+
s.add_argument("--prompt-cache-gb", type=float, help="memory for saved prompt caches (default: fit to the GPU)")
|
|
47
|
+
s.add_argument("--disk-cache-gb", type=float, default=20.0,
|
|
48
|
+
help="disk for saved prompt caches, all models together (0 turns it off)")
|
|
49
|
+
s.add_argument("--reserve-gb", type=float, default=6.0, help="GPU memory kept free for active sequences")
|
|
50
|
+
s.add_argument("--log-level", default="INFO")
|
|
51
|
+
sub.add_parser("models", help="list configured and built-in models")
|
|
52
|
+
p = sub.add_parser("pull", help="download the weights a model would load on this Mac, ahead of serving it")
|
|
53
|
+
p.add_argument("model", help="configured or built-in model name, or Hugging Face repo id")
|
|
54
|
+
p.add_argument("--json", action="store_true", help="report each step as a JSON line on stdout, for apps")
|
|
55
|
+
svc = sub.add_parser("service", help="run as a login service that restarts itself (macOS launchd)")
|
|
56
|
+
svc.add_argument("action", choices=["install", "uninstall", "status"])
|
|
57
|
+
svc.add_argument("serve_args", nargs=argparse.REMAINDER, help="arguments for serve, after --")
|
|
58
|
+
a = ap.parse_args(argv)
|
|
59
|
+
|
|
60
|
+
if a.cmd == "service":
|
|
61
|
+
from . import service
|
|
62
|
+
|
|
63
|
+
if a.action == "install":
|
|
64
|
+
return service.install([x for x in a.serve_args if x != "--"])
|
|
65
|
+
return service.uninstall() if a.action == "uninstall" else service.status()
|
|
66
|
+
|
|
67
|
+
models = load_models()
|
|
68
|
+
if a.cmd == "models":
|
|
69
|
+
if not models:
|
|
70
|
+
print(f"no models configured in {home() / 'models.toml'}")
|
|
71
|
+
for name, spec in models.items():
|
|
72
|
+
print(f"{name:24} {' or '.join(spec.choices) or spec.path}")
|
|
73
|
+
for name, spec in builtin_models().items():
|
|
74
|
+
if name not in models:
|
|
75
|
+
print(f"{name:24} {' or '.join(spec.choices) or spec.path} (built in)")
|
|
76
|
+
return 0
|
|
77
|
+
if a.cmd == "pull":
|
|
78
|
+
logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(message)s") # stdout is the report
|
|
79
|
+
_quiet_dependencies(logging.WARNING)
|
|
80
|
+
import mlx.core as mx
|
|
81
|
+
|
|
82
|
+
from .pull import pull
|
|
83
|
+
|
|
84
|
+
return pull(resolve(a.model, models), mx.device_info()["max_recommended_working_set_size"], as_json=a.json)
|
|
85
|
+
|
|
86
|
+
logging.basicConfig(level=getattr(logging, a.log_level.upper(), logging.INFO),
|
|
87
|
+
format="%(asctime)s %(levelname)s %(message)s")
|
|
88
|
+
_quiet_dependencies(logging.DEBUG if a.log_level.upper() == "DEBUG" else logging.WARNING)
|
|
89
|
+
name = a.model or next(iter(models), None)
|
|
90
|
+
if not name:
|
|
91
|
+
print("no model: pass --model or configure one in models.toml", file=sys.stderr)
|
|
92
|
+
return 2
|
|
93
|
+
if a.host not in ("127.0.0.1", "localhost", "::1") and not a.api_key:
|
|
94
|
+
logging.warning("serving on %s without --api-key: anyone who can reach this port can use the model", a.host)
|
|
95
|
+
|
|
96
|
+
import mlx.core as mx
|
|
97
|
+
|
|
98
|
+
from .engine import Engine, NotEnoughMemory, check_memory, choose_weights, weights_and_config
|
|
99
|
+
from .http import Server
|
|
100
|
+
|
|
101
|
+
_wired_limit()
|
|
102
|
+
spec = resolve(name, models)
|
|
103
|
+
if a.adapter:
|
|
104
|
+
spec = dataclasses.replace(spec, adapter=str(Path(a.adapter).expanduser().resolve()))
|
|
105
|
+
if a.kv_bits:
|
|
106
|
+
spec = dataclasses.replace(spec, kv_bits=None if a.kv_bits == 16 else a.kv_bits)
|
|
107
|
+
if spec.adapter and not (Path(spec.adapter) / "adapter_config.json").is_file():
|
|
108
|
+
print(f"no adapter_config.json in {spec.adapter}", file=sys.stderr)
|
|
109
|
+
return 2
|
|
110
|
+
try:
|
|
111
|
+
# Before loading, and for a Hugging Face repo before downloading: a model this Mac can't hold would swap it to
|
|
112
|
+
# a standstill, and under the service, again after every restart.
|
|
113
|
+
limit = mx.device_info()["max_recommended_working_set_size"]
|
|
114
|
+
spec = choose_weights(spec, limit)
|
|
115
|
+
if (sized := weights_and_config(spec.path, spec.adapter)) is not None:
|
|
116
|
+
check_memory(name, limit, sized[0])
|
|
117
|
+
engine = Engine(spec, decode_concurrency=a.concurrency, reserve_gb=a.reserve_gb,
|
|
118
|
+
disk_cache_gb=a.disk_cache_gb,
|
|
119
|
+
prompt_cache_bytes=int(a.prompt_cache_gb * (1 << 30)) if a.prompt_cache_gb else None)
|
|
120
|
+
except NotEnoughMemory as e:
|
|
121
|
+
logging.error("%s", e)
|
|
122
|
+
return 2
|
|
123
|
+
server = Server((a.host, a.port), engine, models, a.api_key)
|
|
124
|
+
logging.info("Aris Server %s serving %s on http://%s:%d", __version__, name, a.host, a.port)
|
|
125
|
+
|
|
126
|
+
def stop(*_): # a service manager stops with SIGTERM: finish the current step (and any cache write) first
|
|
127
|
+
raise KeyboardInterrupt
|
|
128
|
+
|
|
129
|
+
signal.signal(signal.SIGTERM, stop)
|
|
130
|
+
try:
|
|
131
|
+
server.serve_forever()
|
|
132
|
+
except KeyboardInterrupt:
|
|
133
|
+
pass
|
|
134
|
+
finally:
|
|
135
|
+
server.server_close()
|
|
136
|
+
engine.generator.stop_and_join() # the generation thread writes the prompt caches as it exits
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
sys.exit(main())
|
aris_server/config.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Model settings: which weights a model name means and how effort and thinking map onto it."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
EFFORTS = ("low", "medium", "high", "xhigh", "max")
|
|
9
|
+
KV_BITS_CHOICES = (4, 8)
|
|
10
|
+
DEFAULT_THINK_BUDGET = {"low": 1024, "medium": 4096, "high": 8192, "xhigh": 16384, "max": 0}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def home() -> Path:
|
|
14
|
+
return Path(os.environ.get("ARIS_SERVER_HOME", Path.home() / ".aris-server"))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# A model given several weights (`path` as a list, best first) loads the first that leaves this much context on the
|
|
18
|
+
# Mac it runs on: a coding agent's conversation outgrows less, and compacting it costs a long re-read each time.
|
|
19
|
+
DEFAULT_MIN_CONTEXT = 65536
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ModelSpec:
|
|
24
|
+
name: str
|
|
25
|
+
path: str
|
|
26
|
+
context: int | None = None
|
|
27
|
+
# Weights to choose from by memory, best first (`path` is the first): for example the same model at 8 and 4 bits.
|
|
28
|
+
choices: tuple[str, ...] = ()
|
|
29
|
+
min_context: int = DEFAULT_MIN_CONTEXT
|
|
30
|
+
adapter: str | None = None # a LoRA adapter folder (adapter_config.json + adapters.safetensors) on top of path
|
|
31
|
+
# Bits per value of the KV cache (8 or 4), None for full precision: 8 bits holds about twice the context in the
|
|
32
|
+
# same memory.
|
|
33
|
+
kv_bits: int | None = None
|
|
34
|
+
# Template variable that carries effort (e.g. "reasoning_effort"), and how request efforts map to its values.
|
|
35
|
+
effort_kwarg: str | None = None
|
|
36
|
+
effort_map: dict[str, str] = field(default_factory=dict)
|
|
37
|
+
# Thinking tokens allowed per effort before thinking is closed at the next paragraph break; 0 = unlimited.
|
|
38
|
+
think_budget: dict[str, int] = field(default_factory=lambda: dict(DEFAULT_THINK_BUDGET))
|
|
39
|
+
template_kwargs: dict = field(default_factory=dict)
|
|
40
|
+
# Share of the think budget for turns that continue the model's own tool loop: the plan exists by then, and
|
|
41
|
+
# overthinking models re-plan from scratch on every tool result.
|
|
42
|
+
followup_budget: float = 0.5
|
|
43
|
+
# Sampling when the request doesn't say (Claude Code sends none with thinking on). Qwen-family thinking defaults.
|
|
44
|
+
temperature: float = 0.6
|
|
45
|
+
top_p: float = 0.95
|
|
46
|
+
top_k: int = 20
|
|
47
|
+
|
|
48
|
+
def budget_for(self, effort: str) -> int:
|
|
49
|
+
return int(self.think_budget.get(effort, DEFAULT_THINK_BUDGET.get(effort, 0)))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Models the server knows by name with no models.toml entry; an entry there with the same name replaces one. Their
|
|
53
|
+
# weights are on the Hugging Face Hub, best first: the server picks what each Mac holds before downloading anything.
|
|
54
|
+
BUILTIN_MODELS = {
|
|
55
|
+
# Aris 3.8-27B: Arete-27B (Nous Research, on Qwen3.8-27B) trained on its own passing agent runs. The settings are
|
|
56
|
+
# the ones its evals ran with: Claude Code's effort onto Arete's trained effort levels, and a think budget each.
|
|
57
|
+
"aris-3.8-27b": {
|
|
58
|
+
"path": ["YourAris/Aris-3.8-27B-MLX-8bit", "YourAris/Aris-3.8-27B-MLX-4bit"],
|
|
59
|
+
"kv_bits": 8,
|
|
60
|
+
"effort_kwarg": "reasoning_effort",
|
|
61
|
+
"effort_map": {"low": "low", "medium": "medium", "high": "medium", "xhigh": "xhigh", "max": "xhigh"},
|
|
62
|
+
"think_budget": {"low": 1024, "medium": 3072, "high": 4096, "xhigh": 12288, "max": 0},
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_models(path: Path | None = None) -> dict[str, ModelSpec]:
|
|
68
|
+
"""The models configured in models.toml (not the built-in ones: see builtin_models)."""
|
|
69
|
+
path = path or home() / "models.toml"
|
|
70
|
+
if not path.is_file():
|
|
71
|
+
return {}
|
|
72
|
+
data = tomllib.loads(path.read_text())
|
|
73
|
+
return {name: _spec(name, raw, f"model {name!r} in {path}") for name, raw in (data.get("models") or {}).items()}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def builtin_models() -> dict[str, ModelSpec]:
|
|
77
|
+
return {name: _spec(name, raw, f"built-in model {name!r}") for name, raw in BUILTIN_MODELS.items()}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _spec(name: str, raw: dict, where: str) -> ModelSpec:
|
|
81
|
+
if "path" not in raw:
|
|
82
|
+
raise ValueError(f"{where} has no path")
|
|
83
|
+
paths = raw["path"] if isinstance(raw["path"], list) else [raw["path"]]
|
|
84
|
+
if not paths or not all(isinstance(p, str) and p for p in paths):
|
|
85
|
+
raise ValueError(f"{where}: path must be a folder or repo id, or a list of them")
|
|
86
|
+
paths = [_weights_path(p) for p in paths]
|
|
87
|
+
budget = dict(DEFAULT_THINK_BUDGET)
|
|
88
|
+
budget.update(raw.get("think_budget") or {})
|
|
89
|
+
if raw.get("kv_bits") not in (None, *KV_BITS_CHOICES):
|
|
90
|
+
raise ValueError(f"{where}: kv_bits must be one of {KV_BITS_CHOICES}")
|
|
91
|
+
return ModelSpec(
|
|
92
|
+
name=name,
|
|
93
|
+
path=paths[0],
|
|
94
|
+
choices=tuple(paths) if len(paths) > 1 else (),
|
|
95
|
+
min_context=int(raw.get("min_context", DEFAULT_MIN_CONTEXT)),
|
|
96
|
+
context=raw.get("context"),
|
|
97
|
+
adapter=str(Path(raw["adapter"]).expanduser()) if raw.get("adapter") else None,
|
|
98
|
+
kv_bits=raw.get("kv_bits"),
|
|
99
|
+
effort_kwarg=raw.get("effort_kwarg"),
|
|
100
|
+
effort_map=dict(raw.get("effort_map") or {}),
|
|
101
|
+
think_budget=budget,
|
|
102
|
+
template_kwargs=dict(raw.get("template_kwargs") or {}),
|
|
103
|
+
**{k: raw[k] for k in ("temperature", "top_p", "top_k", "followup_budget") if k in raw},
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _weights_path(p: str) -> str:
|
|
108
|
+
"""A local folder with its home expanded, or a Hugging Face repo id as given."""
|
|
109
|
+
return str(Path(p).expanduser()) if p.startswith(("~", "/", ".")) else p
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def resolve(name: str, models: dict[str, ModelSpec]) -> ModelSpec:
|
|
113
|
+
"""A configured model by name, then a built-in one, or else an unconfigured one by local path or Hugging Face repo
|
|
114
|
+
id."""
|
|
115
|
+
if name in models:
|
|
116
|
+
return models[name]
|
|
117
|
+
if name in BUILTIN_MODELS:
|
|
118
|
+
return builtin_models()[name]
|
|
119
|
+
return ModelSpec(name=name, path=_weights_path(name))
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Prompt caches on disk, so a restart, a crash or a reopened thread resumes in seconds instead of re-reading a long
|
|
2
|
+
conversation (minutes for a 27B model at 40k tokens). A conversation evicted from memory is also loaded back from
|
|
3
|
+
here. Each finished turn's cache and the shared system-prompt snapshot
|
|
4
|
+
are written as they enter the in-memory cache; a conversation's older snapshot is deleted when its next turn is saved.
|
|
5
|
+
|
|
6
|
+
Each setup (weights, adapter, KV precision) has a folder of its own, and one cap covers them all. A server reads only
|
|
7
|
+
its own folder, so the caches of setups no running server holds go first, then its own oldest. A running server holds
|
|
8
|
+
a shared lock on its folder; another server sharing the directory clears only folders nobody holds.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import fcntl
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import mlx.core as mx
|
|
21
|
+
from mlx_lm.models.cache import load_prompt_cache, save_prompt_cache
|
|
22
|
+
|
|
23
|
+
MIN_TOKENS = 1024 # shorter prompts are quick to re-read
|
|
24
|
+
LOCK = "lock" # shared by each server using the folder, exclusive for one clearing it
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def model_id(model_path: str, adapter: str | None = None, kv_bits: int | None = None) -> str:
|
|
28
|
+
"""Caches are only valid for the exact weights that made them (the base model and any adapter on it) and for the
|
|
29
|
+
precision of the KV cache they hold."""
|
|
30
|
+
parts = []
|
|
31
|
+
for folder in (model_path, adapter):
|
|
32
|
+
if folder is None:
|
|
33
|
+
continue
|
|
34
|
+
path = Path(folder)
|
|
35
|
+
parts.append(str(path))
|
|
36
|
+
if path.is_dir():
|
|
37
|
+
parts += [f"{p.name}:{p.stat().st_size}:{int(p.stat().st_mtime)}" for p in sorted(path.glob("*.safetensors"))]
|
|
38
|
+
if kv_bits:
|
|
39
|
+
parts.append(f"kv_bits={kv_bits}")
|
|
40
|
+
return hashlib.sha1("|".join(parts).encode()).hexdigest()[:16]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _stat(data: Path) -> tuple[float, int]:
|
|
44
|
+
"""A cache file's (mtime, size), or (0, 0) once it is gone."""
|
|
45
|
+
try:
|
|
46
|
+
s = data.stat()
|
|
47
|
+
except FileNotFoundError:
|
|
48
|
+
return 0.0, 0
|
|
49
|
+
return s.st_mtime, s.st_size
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _same_file(path: Path, f) -> bool:
|
|
53
|
+
"""Whether `path` is still the file `f` has open: not once the file, or its folder, is deleted."""
|
|
54
|
+
try:
|
|
55
|
+
return os.path.samestat(os.stat(path), os.fstat(f.fileno()))
|
|
56
|
+
except FileNotFoundError:
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _claim(folder: Path):
|
|
61
|
+
"""The folder's lock, held exclusively, or None while a server uses the folder or another is clearing it."""
|
|
62
|
+
try:
|
|
63
|
+
lock = open(folder / LOCK, "a")
|
|
64
|
+
except OSError:
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
68
|
+
except OSError:
|
|
69
|
+
lock.close()
|
|
70
|
+
return None
|
|
71
|
+
return lock
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _remove(data: Path):
|
|
75
|
+
"""Delete a cache and its metadata. One that can't be deleted (in a folder made read-only, say) stays, with a
|
|
76
|
+
warning: the disk cache must never stop the server."""
|
|
77
|
+
try:
|
|
78
|
+
data.unlink(missing_ok=True)
|
|
79
|
+
data.with_suffix(".json").unlink(missing_ok=True)
|
|
80
|
+
except OSError as e:
|
|
81
|
+
logging.warning("could not delete prompt cache %s: %s", data.name, e)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class DiskCache:
|
|
85
|
+
def __init__(self, root: Path, model_path: str, max_bytes: int, adapter: str | None = None,
|
|
86
|
+
kv_bits: int | None = None):
|
|
87
|
+
self.root = root
|
|
88
|
+
self.dir = root / model_id(model_path, adapter, kv_bits)
|
|
89
|
+
self.max_bytes = max_bytes
|
|
90
|
+
self._lock = self._hold()
|
|
91
|
+
self.index: dict[Path, tuple[tuple[int, ...], str]] = {}
|
|
92
|
+
for data in self.dir.glob("*.safetensors"):
|
|
93
|
+
if not data.with_suffix(".json").exists(): # a write cut off by a crash or a kill
|
|
94
|
+
_remove(data)
|
|
95
|
+
for f in self.dir.glob("*.json"):
|
|
96
|
+
data = f.with_suffix(".safetensors")
|
|
97
|
+
if not data.exists():
|
|
98
|
+
_remove(data)
|
|
99
|
+
continue
|
|
100
|
+
try:
|
|
101
|
+
meta = json.loads(f.read_text())
|
|
102
|
+
self.index[data] = (tuple(meta["tokens"]), meta["type"])
|
|
103
|
+
except (OSError, ValueError, KeyError, TypeError) as e: # cut off mid-write: no turn can match it
|
|
104
|
+
# Raised here, it stopped the server before it served, and under the service at every restart.
|
|
105
|
+
logging.warning("dropping prompt cache %s: its metadata can't be read: %s", data.name, e)
|
|
106
|
+
_remove(data)
|
|
107
|
+
self._trim() # started on another setup: what the last one left over the cap goes now
|
|
108
|
+
|
|
109
|
+
def _hold(self):
|
|
110
|
+
"""A shared lock on this folder for as long as the server runs."""
|
|
111
|
+
while True:
|
|
112
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
try:
|
|
114
|
+
lock = open(self.dir / LOCK, "a")
|
|
115
|
+
except FileNotFoundError: # another server removed the folder just now
|
|
116
|
+
continue
|
|
117
|
+
fcntl.flock(lock, fcntl.LOCK_SH)
|
|
118
|
+
if _same_file(self.dir / LOCK, lock):
|
|
119
|
+
return lock
|
|
120
|
+
lock.close() # the folder was cleared while this waited for it: make it again
|
|
121
|
+
|
|
122
|
+
def __del__(self):
|
|
123
|
+
lock = getattr(self, "_lock", None)
|
|
124
|
+
if lock is not None:
|
|
125
|
+
lock.close()
|
|
126
|
+
|
|
127
|
+
def has(self, tokens) -> bool:
|
|
128
|
+
"""Whether these tokens' cache is saved. One whose file is gone (deleted by hand, say) isn't: it is saved again."""
|
|
129
|
+
key = tuple(tokens)
|
|
130
|
+
return any(t == key and data.exists() for data, (t, _) in self.index.items())
|
|
131
|
+
|
|
132
|
+
def save(self, tokens, prompt_cache, cache_type: str):
|
|
133
|
+
if len(tokens) < MIN_TOKENS or cache_type not in ("system", "assistant"):
|
|
134
|
+
return
|
|
135
|
+
if not _same_file(self.dir / LOCK, self._lock): # the folder was deleted while serving: make it, hold it again
|
|
136
|
+
logging.warning("prompt cache folder %s was deleted while serving; making it again", self.dir.name)
|
|
137
|
+
lock = self._hold()
|
|
138
|
+
self._lock.close()
|
|
139
|
+
self._lock = lock
|
|
140
|
+
t0 = time.time()
|
|
141
|
+
key = tuple(tokens)
|
|
142
|
+
data = self.dir / f"{hashlib.sha1(json.dumps(key).encode()).hexdigest()[:20]}.safetensors"
|
|
143
|
+
save_prompt_cache(str(data), prompt_cache)
|
|
144
|
+
# The metadata file goes last: a file without it is an interrupted write and is ignored.
|
|
145
|
+
data.with_suffix(".json").write_text(json.dumps({"tokens": list(key), "type": cache_type}))
|
|
146
|
+
# Each turn saves the read prompt (what a retry of the same request needs) and then the finished answer (what
|
|
147
|
+
# the next turn continues). Of the same conversation's earlier snapshots, keep only the longest.
|
|
148
|
+
earlier = sorted((p for p, (t, kind) in self.index.items()
|
|
149
|
+
if kind != "system" and p != data and key[:len(t)] == t), key=lambda p: len(self.index[p][0]))
|
|
150
|
+
for other in earlier[:-1]:
|
|
151
|
+
self._delete(other)
|
|
152
|
+
self.index[data] = (key, cache_type)
|
|
153
|
+
size = data.stat().st_size
|
|
154
|
+
self._trim()
|
|
155
|
+
logging.info("saved %d-token %s cache to disk (%.0f MB, %.1fs)", len(key), cache_type, size / 1e6,
|
|
156
|
+
time.time() - t0)
|
|
157
|
+
|
|
158
|
+
def _delete(self, data: Path):
|
|
159
|
+
self.index.pop(data, None)
|
|
160
|
+
_remove(data)
|
|
161
|
+
|
|
162
|
+
def _trim(self):
|
|
163
|
+
"""Keep this setup's caches, with those of setups no running server holds, under the cap: the others' go first,
|
|
164
|
+
oldest first, since this server can't read them; then this setup's oldest."""
|
|
165
|
+
claimed = [(folder, lock) for folder in self.root.iterdir()
|
|
166
|
+
if folder != self.dir and folder.is_dir() and re.fullmatch(r"[0-9a-f]{16}", folder.name)
|
|
167
|
+
and (lock := _claim(folder)) is not None]
|
|
168
|
+
try:
|
|
169
|
+
spare = sorted((p for folder, _ in claimed for p in folder.glob("*.safetensors")), key=_stat)
|
|
170
|
+
own = sorted(self.index, key=_stat)
|
|
171
|
+
total = sum(_stat(p)[1] for p in spare + own)
|
|
172
|
+
freed = removed = 0
|
|
173
|
+
for data in spare:
|
|
174
|
+
if total <= self.max_bytes:
|
|
175
|
+
break
|
|
176
|
+
size = _stat(data)[1]
|
|
177
|
+
_remove(data)
|
|
178
|
+
total -= size
|
|
179
|
+
freed += size
|
|
180
|
+
removed += 1
|
|
181
|
+
for data in own:
|
|
182
|
+
if total <= self.max_bytes:
|
|
183
|
+
break
|
|
184
|
+
total -= _stat(data)[1]
|
|
185
|
+
self._delete(data)
|
|
186
|
+
if removed:
|
|
187
|
+
logging.info("disk cache: removed %d caches (%.1f GB) that other models or settings left", removed,
|
|
188
|
+
freed / (1 << 30))
|
|
189
|
+
for folder, _ in claimed:
|
|
190
|
+
if any(folder.glob("*.safetensors")):
|
|
191
|
+
continue
|
|
192
|
+
try:
|
|
193
|
+
for leftover in [*folder.glob("*.json"), folder / LOCK]:
|
|
194
|
+
leftover.unlink(missing_ok=True)
|
|
195
|
+
folder.rmdir()
|
|
196
|
+
except OSError: # something else is in it, or it can't be changed: leave the folder
|
|
197
|
+
pass
|
|
198
|
+
finally:
|
|
199
|
+
for _, lock in claimed:
|
|
200
|
+
lock.close()
|
|
201
|
+
|
|
202
|
+
def nearest(self, tokens, longer_than: int = 0) -> tuple[Path, int] | None:
|
|
203
|
+
"""The saved cache covering the longest proper prefix of `tokens`, if it covers more than `longer_than`. One
|
|
204
|
+
whose file is gone from disk (deleted by hand, say) is forgotten here, before the caller lets go of what memory
|
|
205
|
+
holds for the turn."""
|
|
206
|
+
key = tuple(tokens)
|
|
207
|
+
while True:
|
|
208
|
+
best = None
|
|
209
|
+
for data, (t, _) in self.index.items():
|
|
210
|
+
if longer_than < len(t) < len(key) and key[:len(t)] == t and (best is None or len(t) > best[1]):
|
|
211
|
+
best = (data, len(t))
|
|
212
|
+
if best is None or best[0].exists():
|
|
213
|
+
return best
|
|
214
|
+
logging.warning("prompt cache %s is gone from disk; forgetting it", best[0].name)
|
|
215
|
+
self._delete(best[0])
|
|
216
|
+
|
|
217
|
+
def load(self, data: Path):
|
|
218
|
+
"""The cache saved in `data`, or None if it can't be read (damaged, or gone): then it is dropped, so no later
|
|
219
|
+
turn tries it again."""
|
|
220
|
+
try:
|
|
221
|
+
cache = load_prompt_cache(str(data))
|
|
222
|
+
# Loading is lazy and bound to the loading thread's stream; generation may run on another thread.
|
|
223
|
+
mx.eval([c.state for c in cache])
|
|
224
|
+
except Exception as e: # noqa: BLE001 MLX raises RuntimeError for a damaged or missing file
|
|
225
|
+
logging.warning("dropping unreadable prompt cache %s: %s", data.name, e)
|
|
226
|
+
self._delete(data)
|
|
227
|
+
return None
|
|
228
|
+
return cache
|
|
229
|
+
|
|
230
|
+
def restore(self, insert, budget: int) -> int:
|
|
231
|
+
"""Load the most recent caches that fit in `budget` bytes through insert(tokens, cache, type)."""
|
|
232
|
+
loaded = 0
|
|
233
|
+
newest = sorted(self.index, key=lambda p: p.stat().st_mtime, reverse=True)
|
|
234
|
+
systems = [p for p in newest if self.index[p][1] == "system"][:1]
|
|
235
|
+
for data in systems + [p for p in newest if self.index[p][1] != "system"]:
|
|
236
|
+
size = data.stat().st_size
|
|
237
|
+
if loaded + size > budget:
|
|
238
|
+
continue
|
|
239
|
+
cache = self.load(data)
|
|
240
|
+
if cache is None: # damaged: dropped
|
|
241
|
+
continue
|
|
242
|
+
tokens, cache_type = self.index[data]
|
|
243
|
+
insert(list(tokens), cache, cache_type)
|
|
244
|
+
loaded += size
|
|
245
|
+
return loaded
|