Read images and prompts up to 262,144 tokens
Browse files- README.md +20 -1
- requirements.txt +7 -2
- startlux_decision/check.py +17 -1
- startlux_decision/gguf_server.py +115 -0
- startlux_decision/mlx_int8.py +232 -0
- startlux_decision/mlx_model.py +166 -0
- startlux_decision/model.py +464 -34
- startlux_decision/server.py +71 -17
README.md
CHANGED
|
@@ -12,7 +12,8 @@ tags:
|
|
| 12 |
|
| 13 |
<p align="center"><img src="assets/hero.png" alt="StartLux-Decision: a probability for every option" width="100%"></p>
|
| 14 |
|
| 15 |
-
StartLux-Decision-27B answers typed questions about a state: pick one of several options, yes or no, or a rating on a scale.
|
|
|
|
| 16 |
question comes back with a probability for each option. Requests and responses use the TypeSafe `/v1/systemone`
|
| 17 |
format, so clients written for Jev work unchanged.
|
| 18 |
|
|
@@ -30,6 +31,7 @@ Sizes: [0.8B](https://huggingface.co/startlux-models/StartLux-Decision-0.8B) ·
|
|
| 30 |
| Intern-Decision, average accuracy over seven suites | 91.82 |
|
| 31 |
| Latency, one request with three questions | 102.3 ms |
|
| 32 |
| Latency, one yes/no question | 50.7 ms |
|
|
|
|
| 33 |
|
| 34 |
</div>
|
| 35 |
|
|
@@ -84,6 +86,15 @@ The folder ships its own inference package, `startlux_decision/`, which is the f
|
|
| 84 |
- For bulk work, `decide_batch` batches the questions of many requests together, which is several times faster than
|
| 85 |
sending them one at a time.
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
## Usage
|
| 88 |
|
| 89 |
```bash
|
|
@@ -119,6 +130,14 @@ answers, usage = m.decide(state, questions)
|
|
| 119 |
many = m.decide_batch([(state, questions), ...])
|
| 120 |
```
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
## License
|
| 123 |
|
| 124 |
The model weights are released under [CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/): free for
|
|
|
|
| 12 |
|
| 13 |
<p align="center"><img src="assets/hero.png" alt="StartLux-Decision: a probability for every option" width="100%"></p>
|
| 14 |
|
| 15 |
+
StartLux-Decision-27B answers typed questions about a state: pick one of several options, yes or no, or a rating on a scale. The
|
| 16 |
+
state can be text, JSON or images, up to 262,144 tokens (256K). Every
|
| 17 |
question comes back with a probability for each option. Requests and responses use the TypeSafe `/v1/systemone`
|
| 18 |
format, so clients written for Jev work unchanged.
|
| 19 |
|
|
|
|
| 31 |
| Intern-Decision, average accuracy over seven suites | 91.82 |
|
| 32 |
| Latency, one request with three questions | 102.3 ms |
|
| 33 |
| Latency, one yes/no question | 50.7 ms |
|
| 34 |
+
| Input | text, JSON or images, up to 262,144 tokens |
|
| 35 |
|
| 36 |
</div>
|
| 37 |
|
|
|
|
| 86 |
- For bulk work, `decide_batch` batches the questions of many requests together, which is several times faster than
|
| 87 |
sending them one at a time.
|
| 88 |
|
| 89 |
+
## Images and long inputs
|
| 90 |
+
|
| 91 |
+
The weights include a vision tower, and the inference package uses it: a request can carry images as part of its
|
| 92 |
+
evidence, `images=[...]` in Python (PIL images, file paths, encoded bytes, base64 strings or data URIs) or
|
| 93 |
+
`"images": [...]` over HTTP (base64 strings or data URIs). `<image>` in a string state marks where each image goes.
|
| 94 |
+
Prompts can run to 262,144 tokens (256K), the model's native context: a long state is read once, in chunks, and every
|
| 95 |
+
question of the request branches off it. The MLX backend and the GGUF files read text only. `confidence` follows
|
| 96 |
+
TypeSafe's definitions (for a choice, (p_max − 1/n) / (1 − 1/n)); the top probability is in `probabilities`.
|
| 97 |
+
|
| 98 |
## Usage
|
| 99 |
|
| 100 |
```bash
|
|
|
|
| 130 |
many = m.decide_batch([(state, questions), ...])
|
| 131 |
```
|
| 132 |
|
| 133 |
+
With images, from the same folder:
|
| 134 |
+
|
| 135 |
+
```python
|
| 136 |
+
answers, usage = m.decide("Photo taken at delivery: <image>",
|
| 137 |
+
{"damaged": {"type": "noul", "instructions": "Is the parcel damaged?"}},
|
| 138 |
+
images=["parcel.jpg"])
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
## License
|
| 142 |
|
| 143 |
The model weights are released under [CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/): free for
|
requirements.txt
CHANGED
|
@@ -3,9 +3,14 @@ torch>=2.8
|
|
| 3 |
transformers>=5.8
|
| 4 |
huggingface_hub>=0.34
|
| 5 |
safetensors>=0.4
|
|
|
|
|
|
|
|
|
|
| 6 |
# required on CUDA: without them transformers silently runs a plain torch path (>10x slower)
|
| 7 |
-
flash-linear-attention>=0.5.2
|
| 8 |
-
causal-conv1d>=1.7 # CUDA build; if pip compiles it, add --no-build-isolation
|
|
|
|
|
|
|
| 9 |
# fine-tuning
|
| 10 |
peft>=0.20
|
| 11 |
# evaluation (Typed Decisions parquet); the Decision Index kit is installed from its own repository
|
|
|
|
| 3 |
transformers>=5.8
|
| 4 |
huggingface_hub>=0.34
|
| 5 |
safetensors>=0.4
|
| 6 |
+
# image input: the checkpoints' image processor uses torchvision; install the build that matches your torch
|
| 7 |
+
pillow>=10
|
| 8 |
+
torchvision>=0.23
|
| 9 |
# required on CUDA: without them transformers silently runs a plain torch path (>10x slower)
|
| 10 |
+
flash-linear-attention>=0.5.2; sys_platform == "linux"
|
| 11 |
+
causal-conv1d>=1.7; sys_platform == "linux" # CUDA build; if pip compiles it, add --no-build-isolation
|
| 12 |
+
# Apple Silicon: the forward pass runs in MLX instead (startlux_decision/mlx_model.py)
|
| 13 |
+
mlx-lm>=0.31.3; sys_platform == "darwin" and platform_machine == "arm64"
|
| 14 |
# fine-tuning
|
| 15 |
peft>=0.20
|
| 16 |
# evaluation (Typed Decisions parquet); the Decision Index kit is installed from its own repository
|
startlux_decision/check.py
CHANGED
|
@@ -3,18 +3,34 @@
|
|
| 3 |
python -m startlux_decision.check /path/to/StartLux-Decision-4B
|
| 4 |
|
| 5 |
Exits with status 1 when flash-linear-attention or causal-conv1d is missing or not importable; transformers would then
|
| 6 |
-
fall back to a plain torch path that is more than ten times slower.
|
|
|
|
| 7 |
"""
|
|
|
|
|
|
|
| 8 |
import sys
|
| 9 |
|
| 10 |
from .model import fast_kernels_active
|
| 11 |
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
def main():
|
| 14 |
if len(sys.argv) != 2:
|
| 15 |
sys.exit(__doc__.strip())
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
ok = fast_kernels_active(sys.argv[1])
|
| 17 |
print("fast kernels: " + ("active" if ok else "NOT active, pip install flash-linear-attention causal-conv1d"))
|
|
|
|
| 18 |
sys.exit(0 if ok else 1)
|
| 19 |
|
| 20 |
|
|
|
|
| 3 |
python -m startlux_decision.check /path/to/StartLux-Decision-4B
|
| 4 |
|
| 5 |
Exits with status 1 when flash-linear-attention or causal-conv1d is missing or not importable; transformers would then
|
| 6 |
+
fall back to a plain torch path that is more than ten times slower. On Apple Silicon with mlx-lm installed the server
|
| 7 |
+
runs the model with MLX instead, which needs neither. Also says whether image input will work (torch backend only).
|
| 8 |
"""
|
| 9 |
+
import importlib.util
|
| 10 |
+
import os
|
| 11 |
import sys
|
| 12 |
|
| 13 |
from .model import fast_kernels_active
|
| 14 |
|
| 15 |
|
| 16 |
+
def images_ready(path):
|
| 17 |
+
""""yes", or what image input still needs for the model in `path`."""
|
| 18 |
+
if not os.path.exists(os.path.join(path, "preprocessor_config.json")):
|
| 19 |
+
return "no, the model folder has no preprocessor_config.json"
|
| 20 |
+
missing = [m for m in ("PIL", "torchvision") if importlib.util.find_spec(m) is None]
|
| 21 |
+
return f"no, pip install {' '.join('pillow' if m == 'PIL' else m for m in missing)}" if missing else "yes"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
def main():
|
| 25 |
if len(sys.argv) != 2:
|
| 26 |
sys.exit(__doc__.strip())
|
| 27 |
+
from .server import mlx_available
|
| 28 |
+
if mlx_available(): # Apple Silicon: mlx-lm has its own kernels for these layers
|
| 29 |
+
print("Apple Silicon: the server runs the model with MLX (text only); nothing else to install")
|
| 30 |
+
sys.exit(0)
|
| 31 |
ok = fast_kernels_active(sys.argv[1])
|
| 32 |
print("fast kernels: " + ("active" if ok else "NOT active, pip install flash-linear-attention causal-conv1d"))
|
| 33 |
+
print("images: " + images_ready(sys.argv[1]))
|
| 34 |
sys.exit(0 if ok else 1)
|
| 35 |
|
| 36 |
|
startlux_decision/gguf_server.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A /v1/systemone server for a StartLux-Decision GGUF file run by llama.cpp.
|
| 2 |
+
|
| 3 |
+
llama-server -m StartLux-Decision-4B-Q8_0.gguf -ngl 99 -c 16384 --parallel 4 --port 8081
|
| 4 |
+
python -m startlux_decision.gguf_server --model-dir StartLux-Decision-4B-GGUF --llama http://127.0.0.1:8081 --port 8090
|
| 5 |
+
|
| 6 |
+
It is StartLuxDecision with only the forward pass replaced by a llama-server call: prompt rendering, the option-letter
|
| 7 |
+
readout, the per-type temperatures and wide choices are the package's own code, so the answers match the original
|
| 8 |
+
model question by question up to the numerics of the GGUF file. MODEL_DIR holds the tokenizer files, config.json and
|
| 9 |
+
decision_config.json (the GGUF repositories ship them next to the .gguf files). llama-server returns the
|
| 10 |
+
log-probabilities of the whole vocabulary at the answer position; dividing them by the temperature and normalising over
|
| 11 |
+
the listed options is the same as doing it on the logits."""
|
| 12 |
+
import argparse
|
| 13 |
+
import json
|
| 14 |
+
import os
|
| 15 |
+
import urllib.request
|
| 16 |
+
import uuid
|
| 17 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 18 |
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
| 19 |
+
|
| 20 |
+
import torch
|
| 21 |
+
|
| 22 |
+
from . import jevfmt as J
|
| 23 |
+
from .model import StartLuxDecision
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class GGUFDecision(StartLuxDecision):
|
| 27 |
+
def __init__(self, path, llama, workers): # no torch model: llama-server runs the weights
|
| 28 |
+
from transformers import AutoTokenizer
|
| 29 |
+
cfg = json.load(open(os.path.join(path, "decision_config.json")))
|
| 30 |
+
self.tok = AutoTokenizer.from_pretrained(path)
|
| 31 |
+
self.letters = J.check_tokenizer(self.tok)
|
| 32 |
+
if self.letters != cfg["letter_token_ids"]:
|
| 33 |
+
raise ValueError("tokenizer letter ids differ from decision_config.json")
|
| 34 |
+
self.temperature = {k: float(v) for k, v in cfg["temperature_by_type"].items()}
|
| 35 |
+
wide = cfg.get("wide_choice", {})
|
| 36 |
+
self.group, self.keep, self.residual = wide.get("group", 25), wide.get("keep", 3), wide.get("residual", 1e-3)
|
| 37 |
+
self.max_length, self.graphs = 65536, {}
|
| 38 |
+
mc = json.load(open(os.path.join(path, "config.json")))
|
| 39 |
+
self.vocab = int(mc.get("text_config", mc).get("vocab_size", 262144))
|
| 40 |
+
self.url = llama.rstrip("/") + "/completion"
|
| 41 |
+
self.pool = ThreadPoolExecutor(workers)
|
| 42 |
+
|
| 43 |
+
def _letter_logprobs(self, ids, count):
|
| 44 |
+
"""Next-token log-probabilities of the option letters at the last prompt position (full-vocabulary softmax;
|
| 45 |
+
dividing by the temperature and normalising over the options afterwards equals doing it on the logits)."""
|
| 46 |
+
targets = self.letters[:count]
|
| 47 |
+
for n in (256, self.vocab):
|
| 48 |
+
body = json.dumps({"prompt": ids, "n_predict": 1, "temperature": -1, "n_probs": n, "cache_prompt": False}).encode()
|
| 49 |
+
with urllib.request.urlopen(urllib.request.Request(self.url, body, {"Content-Type": "application/json"}), timeout=900) as r:
|
| 50 |
+
out = json.loads(r.read())
|
| 51 |
+
rows = out.get("completion_probabilities", out.get("probs"))
|
| 52 |
+
got = {t["id"]: t["logprob"] for t in rows[0]["top_logprobs"]}
|
| 53 |
+
if all(t in got for t in targets):
|
| 54 |
+
return [got[t] for t in targets]
|
| 55 |
+
raise RuntimeError("option letters missing from llama-server's log-probabilities")
|
| 56 |
+
|
| 57 |
+
def _logits(self, rows):
|
| 58 |
+
jobs = []
|
| 59 |
+
for r in rows:
|
| 60 |
+
order = [o["id"] for o in r["options"]]
|
| 61 |
+
ids, _ = J.render_ids(r, self.tok, order, max_length=self.max_length)
|
| 62 |
+
jobs.append((ids, len(order)))
|
| 63 |
+
outs = list(self.pool.map(lambda j: self._letter_logprobs(*j), jobs))
|
| 64 |
+
return [torch.tensor(o, dtype=torch.float32) for o in outs], sum(len(i) for i, _ in jobs)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def main():
|
| 68 |
+
ap = argparse.ArgumentParser()
|
| 69 |
+
ap.add_argument("--model-dir", required=True)
|
| 70 |
+
ap.add_argument("--llama", required=True)
|
| 71 |
+
ap.add_argument("--port", type=int, required=True)
|
| 72 |
+
ap.add_argument("--workers", type=int, default=16)
|
| 73 |
+
ap.add_argument("--name", help="model name reported in responses (default: the GGUF directory name)")
|
| 74 |
+
a = ap.parse_args()
|
| 75 |
+
engine = GGUFDecision(a.model_dir, a.llama, a.workers)
|
| 76 |
+
a.name = a.name or os.path.basename(os.path.abspath(a.model_dir))
|
| 77 |
+
|
| 78 |
+
class Handler(BaseHTTPRequestHandler):
|
| 79 |
+
def _send(self, code, obj):
|
| 80 |
+
data = json.dumps(obj).encode()
|
| 81 |
+
self.send_response(code)
|
| 82 |
+
self.send_header("Content-Type", "application/json")
|
| 83 |
+
self.send_header("Content-Length", str(len(data)))
|
| 84 |
+
self.send_header("x-typesafe-request-id", uuid.uuid4().hex)
|
| 85 |
+
self.end_headers()
|
| 86 |
+
self.wfile.write(data)
|
| 87 |
+
|
| 88 |
+
def do_GET(self):
|
| 89 |
+
if self.path.rstrip("/") == "/v1/models":
|
| 90 |
+
return self._send(200, {"models": [{"name": a.name, "description": "StartLux-Decision typed decision model "
|
| 91 |
+
"(GGUF)", "release_date": "2026-10-01"}]})
|
| 92 |
+
self._send(200, {"status": "ok", "model": a.name})
|
| 93 |
+
|
| 94 |
+
def do_POST(self):
|
| 95 |
+
try:
|
| 96 |
+
req = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))))
|
| 97 |
+
answers, usage = engine.decide(req.get("state"), req["questions"])
|
| 98 |
+
self._send(200, {"answers": answers, "usage": usage, "model": a.name})
|
| 99 |
+
except (ValueError, KeyError) as e: # malformed request or one the model cannot answer
|
| 100 |
+
self._send(422, {"error": str(e), "detail": [{"loc": ["body"], "msg": str(e), "type": "value_error"}]})
|
| 101 |
+
except Exception as e: # surfaced to the client; suites.py stops on it
|
| 102 |
+
self._send(500, {"error": repr(e)})
|
| 103 |
+
|
| 104 |
+
def log_message(self, *args):
|
| 105 |
+
pass
|
| 106 |
+
|
| 107 |
+
class Server(ThreadingHTTPServer):
|
| 108 |
+
request_queue_size = 1024 # the default backlog of 5 resets connections under load
|
| 109 |
+
daemon_threads = True
|
| 110 |
+
|
| 111 |
+
Server(("127.0.0.1", a.port), Handler).serve_forever()
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
if __name__ == "__main__":
|
| 115 |
+
main()
|
startlux_decision/mlx_int8.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""int8 matmuls for MLXDecision(int8=True) on the neural accelerators of M5 and later GPUs.
|
| 2 |
+
|
| 3 |
+
The large projections (MLP, attention and linear-attention in/out projections) run as int8 x int8 -> int32 through Metal
|
| 4 |
+
4's MetalPerformancePrimitives matmul2d on the M5's neural accelerators. Activations are quantized per token on every
|
| 5 |
+
call, weights per output channel once at load. SmoothQuant (alpha 0.75) first moves
|
| 6 |
+
activation outliers into the weights, using activation ranges from the built-in CALIBRATION requests, which contain no
|
| 7 |
+
benchmark items. The embedding, the output head and projections too small for the kernel stay as they are.
|
| 8 |
+
|
| 9 |
+
The quantize and matmul kernels are adapted from JetBrains/mlx-vlm (branch feature/int8-prefill, mlx_vlm/int8_prefill.py),
|
| 10 |
+
released under the MIT License:
|
| 11 |
+
|
| 12 |
+
Copyright © 2025 Prince Canuma
|
| 13 |
+
|
| 14 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
| 15 |
+
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
| 16 |
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
|
| 17 |
+
persons to whom the Software is furnished to do so, subject to the following conditions:
|
| 18 |
+
|
| 19 |
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
| 20 |
+
Software.
|
| 21 |
+
|
| 22 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
| 23 |
+
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
| 24 |
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
| 25 |
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
| 26 |
+
"""
|
| 27 |
+
import re
|
| 28 |
+
|
| 29 |
+
import mlx.core as mx
|
| 30 |
+
import mlx.nn as nn
|
| 31 |
+
|
| 32 |
+
ALPHA = 0.75 # SmoothQuant migration strength
|
| 33 |
+
TILE = (64, 128, 4) # output tile rows, columns and simdgroups; requests are tens to hundreds of tokens
|
| 34 |
+
MIN_DIM = 1024 # projections smaller than this in either dimension stay as they are
|
| 35 |
+
|
| 36 |
+
_HEADER = """
|
| 37 |
+
#include <MetalPerformancePrimitives/MetalPerformancePrimitives.h>
|
| 38 |
+
using namespace mpp::tensor_ops;
|
| 39 |
+
using namespace metal;
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
# One threadgroup per row: absmax of x * inv_s over the row, then symmetric int8 with that row's scale.
|
| 43 |
+
_QUANT_SRC = """
|
| 44 |
+
constexpr int K = {K};
|
| 45 |
+
constexpr int NTH = 256;
|
| 46 |
+
uint row = threadgroup_position_in_grid.x;
|
| 47 |
+
uint tid = thread_position_in_threadgroup.x;
|
| 48 |
+
uint lane = tid % 32;
|
| 49 |
+
uint sg = tid / 32;
|
| 50 |
+
const device {T}* xrow = x + size_t(row) * K;
|
| 51 |
+
float amax = 0.0f;
|
| 52 |
+
for (int i = tid; i < K; i += NTH) amax = max(amax, fabs(float(xrow[i]) * inv_s[i]));
|
| 53 |
+
amax = simd_max(amax);
|
| 54 |
+
threadgroup float tg_max[NTH / 32];
|
| 55 |
+
if (lane == 0) tg_max[sg] = amax;
|
| 56 |
+
threadgroup_barrier(mem_flags::mem_threadgroup);
|
| 57 |
+
amax = tg_max[lane % (NTH / 32)];
|
| 58 |
+
amax = simd_max(amax);
|
| 59 |
+
float scale = max(amax, 1e-8f) / 127.0f;
|
| 60 |
+
float inv = 1.0f / scale;
|
| 61 |
+
if (tid == 0) xs[row] = scale;
|
| 62 |
+
device int8_t* qrow = xq + size_t(row) * K;
|
| 63 |
+
for (int i = tid; i < K; i += NTH) qrow[i] = int8_t(clamp(rint(float(xrow[i]) * inv_s[i] * inv), -127.0f, 127.0f));
|
| 64 |
+
"""
|
| 65 |
+
|
| 66 |
+
# One threadgroup per TM x TN output tile; matmul2d loops over K. Edge rows are bounds-checked, so any M works.
|
| 67 |
+
_GEMM_SRC = """
|
| 68 |
+
constexpr int N = {N};
|
| 69 |
+
constexpr int K = {K};
|
| 70 |
+
constexpr int TM = {TM};
|
| 71 |
+
constexpr int TN = {TN};
|
| 72 |
+
uint2 tgid = threadgroup_position_in_grid.xy;
|
| 73 |
+
const int M = m_dim[0];
|
| 74 |
+
constexpr auto desc = matmul2d_descriptor(TM, TN, static_cast<int>(dynamic_extent), false, true, true,
|
| 75 |
+
matmul2d_descriptor::mode::multiply_accumulate);
|
| 76 |
+
matmul2d<desc, execution_simdgroups<{NS}>> op;
|
| 77 |
+
auto A = tensor<device int8_t, dextents<int32_t, 2>, tensor_inline>((device int8_t*)xq, dextents<int32_t, 2>(K, M));
|
| 78 |
+
auto B = tensor<device int8_t, dextents<int32_t, 2>, tensor_inline>((device int8_t*)wq, dextents<int32_t, 2>(K, N));
|
| 79 |
+
auto tA = A.slice(0, int(tgid.y) * TM);
|
| 80 |
+
auto tB = B.slice(0, int(tgid.x) * TN);
|
| 81 |
+
auto cT = op.get_destination_cooperative_tensor<decltype(tA), decltype(tB), int32_t>();
|
| 82 |
+
#pragma unroll
|
| 83 |
+
for (uint16_t i = 0; i < cT.get_capacity(); ++i) if (cT.is_valid_element(i)) cT[i] = 0;
|
| 84 |
+
op.run(tA, tB, cT);
|
| 85 |
+
#pragma unroll
|
| 86 |
+
for (uint16_t i = 0; i < cT.get_capacity(); ++i) {{
|
| 87 |
+
if (cT.is_valid_element(i)) {{
|
| 88 |
+
auto idx = cT.get_multidimensional_index(i);
|
| 89 |
+
int n = int(tgid.x) * TN + idx[0];
|
| 90 |
+
int m = int(tgid.y) * TM + idx[1];
|
| 91 |
+
if (m < M && n < N) out[size_t(m) * N + n] = bfloat(float(cT[i]) * xs[m] * ws[n]);
|
| 92 |
+
}}
|
| 93 |
+
}}
|
| 94 |
+
"""
|
| 95 |
+
|
| 96 |
+
_kernels = {}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def supported():
|
| 100 |
+
"""True on GPUs with neural accelerators (Apple GPU family 17, the M5, and later)."""
|
| 101 |
+
info = mx.device_info() if hasattr(mx, "device_info") else mx.metal.device_info()
|
| 102 |
+
m = re.match(r"applegpu_g(\d+)", info.get("architecture", ""))
|
| 103 |
+
return bool(m) and int(m.group(1)) >= 17
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _quantize_rows(x, inv_s):
|
| 107 |
+
M, K = x.shape
|
| 108 |
+
key = ("q", K)
|
| 109 |
+
if key not in _kernels:
|
| 110 |
+
_kernels[key] = mx.fast.metal_kernel(name=f"sld_quant_{K}", input_names=["x", "inv_s"], output_names=["xq", "xs"],
|
| 111 |
+
source=_QUANT_SRC.format(K=K, T="bfloat"), header=_HEADER)
|
| 112 |
+
return _kernels[key](inputs=[x, inv_s], output_shapes=[(M, K), (M,)], output_dtypes=[mx.int8, mx.float32],
|
| 113 |
+
grid=(M * 256, 1, 1), threadgroup=(256, 1, 1))
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _matmul(xq, xs, wq, ws):
|
| 117 |
+
TM, TN, NS = TILE
|
| 118 |
+
M, K = xq.shape
|
| 119 |
+
N = wq.shape[0]
|
| 120 |
+
key = ("g", N, K)
|
| 121 |
+
if key not in _kernels:
|
| 122 |
+
_kernels[key] = mx.fast.metal_kernel(name=f"sld_i8mm_{N}_{K}", input_names=["xq", "xs", "wq", "ws", "m_dim"],
|
| 123 |
+
output_names=["out"], header=_HEADER,
|
| 124 |
+
source=_GEMM_SRC.format(N=N, K=K, TM=TM, TN=TN, NS=NS))
|
| 125 |
+
out, = _kernels[key](inputs=[xq, xs, wq, ws, mx.array([M], dtype=mx.int32)], output_shapes=[(M, N)],
|
| 126 |
+
output_dtypes=[mx.bfloat16], grid=((N // TN) * 32 * NS, (M + TM - 1) // TM, 1),
|
| 127 |
+
threadgroup=(32 * NS, 1, 1))
|
| 128 |
+
return out
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
class Int8Linear(nn.Module):
|
| 132 |
+
"""nn.Linear (or an mlx-lm QuantizedLinear) as a W8A8 matmul with SmoothQuant scales."""
|
| 133 |
+
|
| 134 |
+
def __init__(self, linear, act_max):
|
| 135 |
+
super().__init__()
|
| 136 |
+
w = linear.weight
|
| 137 |
+
if hasattr(linear, "scales"):
|
| 138 |
+
w = mx.dequantize(w, linear.scales, linear.biases, linear.group_size, linear.bits)
|
| 139 |
+
w = w.astype(mx.float32)
|
| 140 |
+
s = mx.clip(mx.power(mx.maximum(act_max, 1e-5), ALPHA) / mx.power(mx.maximum(mx.abs(w).max(axis=0), 1e-5), 1 - ALPHA),
|
| 141 |
+
1e-3, 1e3)
|
| 142 |
+
w = w * s[None, :]
|
| 143 |
+
self.inv_s = 1.0 / s
|
| 144 |
+
self.ws = mx.maximum(mx.abs(w).max(axis=1), 1e-8) / 127.0
|
| 145 |
+
self.wq = mx.clip(mx.round(w / self.ws[:, None]), -127, 127).astype(mx.int8)
|
| 146 |
+
self.bias = linear.bias if "bias" in linear else None
|
| 147 |
+
self.freeze()
|
| 148 |
+
|
| 149 |
+
def __call__(self, x):
|
| 150 |
+
shape = x.shape
|
| 151 |
+
x2 = x.reshape(-1, shape[-1]).astype(mx.bfloat16)
|
| 152 |
+
y = _matmul(*_quantize_rows(x2, self.inv_s), self.wq, self.ws)
|
| 153 |
+
if self.bias is not None:
|
| 154 |
+
y = y + self.bias
|
| 155 |
+
return y.reshape(*shape[:-1], self.wq.shape[0])
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _eligible(layer):
|
| 159 |
+
n, k = layer.weight.shape
|
| 160 |
+
k = k * 32 // layer.bits if hasattr(layer, "scales") else k
|
| 161 |
+
return n % TILE[1] == 0 and k % 32 == 0 and min(n, k) >= MIN_DIM
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _projections(body):
|
| 165 |
+
return [(name, mod, cname, child) for name, mod in body.named_modules() if not isinstance(mod, _Recorder)
|
| 166 |
+
for cname, child in mod.children().items()
|
| 167 |
+
if isinstance(child, (nn.Linear, nn.QuantizedLinear)) and _eligible(child)]
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
class _Recorder(nn.Module):
|
| 171 |
+
def __init__(self, inner):
|
| 172 |
+
super().__init__()
|
| 173 |
+
self.inner, self.amax = inner, None
|
| 174 |
+
|
| 175 |
+
def __call__(self, x):
|
| 176 |
+
m = mx.abs(x.reshape(-1, x.shape[-1]).astype(mx.float32)).max(axis=0)
|
| 177 |
+
self.amax = m if self.amax is None else mx.maximum(self.amax, m)
|
| 178 |
+
return self.inner(x)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def convert(body, sequences):
|
| 182 |
+
"""Replace the eligible projections of `body` by Int8Linear, calibrated on `sequences` (token id lists); returns
|
| 183 |
+
how many were replaced. Layers are converted one at a time, so a lazily loaded model never holds two copies."""
|
| 184 |
+
slots = [(mod, cname, _Recorder(child)) for _, mod, cname, child in _projections(body)]
|
| 185 |
+
for mod, cname, rec in slots:
|
| 186 |
+
setattr(mod, cname, rec)
|
| 187 |
+
for ids in sequences:
|
| 188 |
+
body(mx.array([ids]))
|
| 189 |
+
mx.eval([rec.amax for _, _, rec in slots])
|
| 190 |
+
for i, (mod, cname, rec) in enumerate(slots):
|
| 191 |
+
new = Int8Linear(rec.inner, rec.amax)
|
| 192 |
+
mx.eval(new.parameters())
|
| 193 |
+
setattr(mod, cname, new)
|
| 194 |
+
if i % 16 == 15:
|
| 195 |
+
mx.clear_cache()
|
| 196 |
+
mx.clear_cache()
|
| 197 |
+
return len(slots)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
CALIBRATION = [] # (state, questions) pairs of the kinds of requests the models serve
|
| 201 |
+
for _t in ("My parcel was marked delivered but never arrived.", "The app crashes when I open settings on Android 15.",
|
| 202 |
+
"I want a refund for the duplicate charge on invoice 7781.", "Can I change the shipping address after ordering?",
|
| 203 |
+
"Password reset emails never arrive.", "The discount code SPRING20 is rejected at checkout."):
|
| 204 |
+
CALIBRATION.append(({"ticket": _t}, {
|
| 205 |
+
"team": {"type": "choice", "instructions": "Which team should handle this ticket?",
|
| 206 |
+
"criteria": {"billing": "Payments, refunds and invoices", "shipping": "Delivery and tracking",
|
| 207 |
+
"technical": "App, login and account problems"}},
|
| 208 |
+
"urgent": {"type": "noul", "instructions": "Should this ticket be answered today?"},
|
| 209 |
+
"severity": {"type": "score", "instructions": "How severe is the impact?",
|
| 210 |
+
"criteria": ["cosmetic", "annoying", "blocks the customer"]}}))
|
| 211 |
+
for _n in (5, 20, 60):
|
| 212 |
+
CALIBRATION.append(({"page": " ".join(f"Item {i}: {['laptop', 'desk lamp', 'usb cable', 'office chair'][i % 4]} "
|
| 213 |
+
f"${(i * 37) % 900}.99, {'in stock' if i % 3 else 'backorder'}." for i in range(_n)),
|
| 214 |
+
"goal": "Buy the cheapest in-stock chair."},
|
| 215 |
+
{"next": {"type": "choice", "instructions": "Which item should be clicked next?",
|
| 216 |
+
"criteria": {f"item{i}": f"Item {i}" for i in range(min(_n, 12))}},
|
| 217 |
+
"done": {"type": "noul", "instructions": "Is the goal already met?"}}))
|
| 218 |
+
for _r in ("The hotel was spotless and the staff helpful.", "Food arrived cold and two items were missing.",
|
| 219 |
+
"Average experience, nothing special.", "Absolutely terrible support, never again!"):
|
| 220 |
+
CALIBRATION.append(({"review": _r}, {
|
| 221 |
+
"stars": {"type": "score", "instructions": "How satisfied is the customer?", "criteria": ["1", "2", "3", "4", "5"]},
|
| 222 |
+
"topic": {"type": "choice", "instructions": "What is the review mainly about?",
|
| 223 |
+
"criteria": {"service": "staff and support", "product": "food or room", "price": "cost",
|
| 224 |
+
"delivery": "delivery"}}}))
|
| 225 |
+
for _q, _o in (("Which planet is known as the Red Planet?", ["Venus", "Mars", "Jupiter", "Saturn"]),
|
| 226 |
+
("What is 17 * 3?", ["41", "51", "57", "61"]), ("Who wrote Hamlet?", ["Dickens", "Shakespeare", "Austen", "Twain"])):
|
| 227 |
+
CALIBRATION.append(({"quiz": _q}, {"answer": {"type": "choice", "instructions": _q, "criteria": {o.lower(): o for o in _o}}}))
|
| 228 |
+
CALIBRATION.append(({"log": "\n".join(f"2026-09-30 12:{i:02d} {'ERROR' if i % 7 == 0 else 'INFO'} worker-{i % 3} step {i}"
|
| 229 |
+
for i in range(40))},
|
| 230 |
+
{"failing": {"type": "noul", "instructions": "Is any worker failing repeatedly?"},
|
| 231 |
+
"worker": {"type": "choice", "instructions": "Which worker logs the most errors?",
|
| 232 |
+
"criteria": {"w0": "worker-0", "w1": "worker-1", "w2": "worker-2"}}}))
|
startlux_decision/mlx_model.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""StartLux-Decision on Apple Silicon: the forward pass in MLX, everything else unchanged.
|
| 2 |
+
|
| 3 |
+
python -m startlux_decision.server --model StartLux-Decision-4B # --backend auto picks MLX on a Mac
|
| 4 |
+
python -m startlux_decision.server --model StartLux-Decision-4B --int8 # M5 and later: int8 matmuls
|
| 5 |
+
|
| 6 |
+
MLXDecision is StartLuxDecision with the forward pass run by mlx-lm's Qwen3.5 implementation, which has Metal kernels
|
| 7 |
+
for the linear-attention layers (PyTorch on MPS runs reference code there). As in GGUFDecision, prompt rendering, the
|
| 8 |
+
option-letter readout, the per-type temperatures and wide choices are the package's own code.
|
| 9 |
+
|
| 10 |
+
All questions of a request run in one forward pass, one right-padded row per question. Every prompt starts with the
|
| 11 |
+
same system text: it runs once at start-up and its cache (attention keys and values, linear-attention conv and recurrent
|
| 12 |
+
states) is copied to every row. When the questions of a request share a long piece of evidence, the evidence runs once
|
| 13 |
+
too and is kept, so asking about the same evidence again runs only the questions. int8=True moves the large
|
| 14 |
+
projections to int8 matmuls on the GPU's neural accelerators (mlx_int8.py).
|
| 15 |
+
"""
|
| 16 |
+
import json
|
| 17 |
+
import os
|
| 18 |
+
|
| 19 |
+
import mlx.core as mx
|
| 20 |
+
import numpy as np
|
| 21 |
+
import torch
|
| 22 |
+
|
| 23 |
+
from . import jevfmt as J
|
| 24 |
+
from .model import StartLuxDecision
|
| 25 |
+
|
| 26 |
+
CACHE_LIMIT = 1 << 30 # MLX keeps freed buffers for reuse; unbounded, the cache grows with every new request shape
|
| 27 |
+
SHARE_MIN = 192 # evidence tokens a shared prefix saves, above which running it separately pays off
|
| 28 |
+
MAX_BATCH_TOKENS = 16384 # padded tokens per forward pass when many rows arrive at once (decide_batch)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class MLXDecision(StartLuxDecision):
|
| 32 |
+
backend = "mlx"
|
| 33 |
+
|
| 34 |
+
def __init__(self, path, int8=False, max_length=65536): # no torch model: MLX runs the weights
|
| 35 |
+
from mlx_lm import load
|
| 36 |
+
from transformers import AutoTokenizer
|
| 37 |
+
|
| 38 |
+
config = json.load(open(os.path.join(path, "config.json")))
|
| 39 |
+
if config.get("text_config", config).get("num_experts"):
|
| 40 |
+
raise NotImplementedError("mixture-of-experts checkpoints such as StartLux-Decision-35B-A3B run on the torch "
|
| 41 |
+
"backend: start the server with --backend torch")
|
| 42 |
+
cfg = json.load(open(os.path.join(path, "decision_config.json")))
|
| 43 |
+
self.tok = AutoTokenizer.from_pretrained(path)
|
| 44 |
+
self.letters = J.check_tokenizer(self.tok)
|
| 45 |
+
if self.letters != cfg["letter_token_ids"]:
|
| 46 |
+
raise ValueError("tokenizer letter ids differ from decision_config.json")
|
| 47 |
+
self.temperature = {k: float(v) for k, v in cfg["temperature_by_type"].items()}
|
| 48 |
+
wide = cfg.get("wide_choice", {})
|
| 49 |
+
self.group, self.keep, self.residual = wide.get("group", 25), wide.get("keep", 3), wide.get("residual", 1e-3)
|
| 50 |
+
self.max_length, self.graphs, self.fast_kernels = int(max_length), {}, True
|
| 51 |
+
self.pad = self.tok.pad_token_id if self.tok.pad_token_id is not None else 0
|
| 52 |
+
mx.set_cache_limit(CACHE_LIMIT)
|
| 53 |
+
self.model, _ = load(path, lazy=int8)
|
| 54 |
+
lm = getattr(self.model, "language_model", self.model)
|
| 55 |
+
self.body = lm.model
|
| 56 |
+
self.int8 = 0
|
| 57 |
+
if int8:
|
| 58 |
+
from . import mlx_int8
|
| 59 |
+
if not mlx_int8.supported():
|
| 60 |
+
raise RuntimeError("int8 needs the neural accelerators of an M5 or later GPU; run without --int8")
|
| 61 |
+
rows = [r for s, q in mlx_int8.CALIBRATION for _, r in self._split(s, q)[1]]
|
| 62 |
+
self.int8 = mlx_int8.convert(self.body, [t for t, _ in self._encode(rows)])
|
| 63 |
+
mx.eval(self.model.parameters())
|
| 64 |
+
head = lm.lm_head if hasattr(lm, "lm_head") else self.body.embed_tokens
|
| 65 |
+
idx = mx.array(self.letters)
|
| 66 |
+
w = head.weight[idx]
|
| 67 |
+
if hasattr(head, "scales"): # a quantized build: dequantize the letter rows only
|
| 68 |
+
w = mx.dequantize(w, head.scales[idx], head.biases[idx], head.group_size, head.bits)
|
| 69 |
+
self.letter_rows = w.astype(mx.float32)
|
| 70 |
+
self.snapshots = {} # token prefix -> prompt cache right after it
|
| 71 |
+
probes = (({"x": "a"}, {"q": {"type": "noul", "instructions": "?"}}),
|
| 72 |
+
({"y": 1}, {"q": {"type": "noul", "instructions": "!"}}))
|
| 73 |
+
a, b = (self._encode([r for _, r in self._split(s, q)[1]])[0][0] for s, q in probes)
|
| 74 |
+
n = 0
|
| 75 |
+
while n < min(len(a), len(b)) and a[n] == b[n]:
|
| 76 |
+
n += 1
|
| 77 |
+
self.preamble = tuple(a[:n]) # the system text every prompt starts with
|
| 78 |
+
self._prefill(self.preamble)
|
| 79 |
+
|
| 80 |
+
def warm_up(self):
|
| 81 |
+
"""Run a few request shapes once, so Metal compiles their kernels before the first real request."""
|
| 82 |
+
page = lambda n: {"page": " ".join(f"Row {i}: item {i}, qty {i % 7}." for i in range(n))}
|
| 83 |
+
yes_no = {"q": {"type": "noul", "instructions": "Is the item in stock?"}}
|
| 84 |
+
three = dict(yes_no,
|
| 85 |
+
c={"type": "choice", "instructions": "Which item?", "criteria": {"a": "first", "b": "second"}},
|
| 86 |
+
s={"type": "score", "instructions": "How full is the page?", "criteria": ["empty", "some", "full"]})
|
| 87 |
+
for n in (4, 40, 160):
|
| 88 |
+
self.decide(page(n), yes_no)
|
| 89 |
+
self.decide(page(n), three)
|
| 90 |
+
|
| 91 |
+
def _encode(self, rows):
|
| 92 |
+
out = []
|
| 93 |
+
for r in rows:
|
| 94 |
+
order = [o["id"] for o in r["options"]]
|
| 95 |
+
ids, _ = J.render_ids(r, self.tok, order, max_length=self.max_length)
|
| 96 |
+
out.append((ids, len(order)))
|
| 97 |
+
return out
|
| 98 |
+
|
| 99 |
+
def _forward(self, ids, last, cache=None):
|
| 100 |
+
h = self.body(ids, cache=cache)
|
| 101 |
+
return h[mx.arange(ids.shape[0]), last].astype(mx.float32) @ self.letter_rows.T
|
| 102 |
+
|
| 103 |
+
@staticmethod
|
| 104 |
+
def _copy(cache, rows):
|
| 105 |
+
"""`cache` repeated to `rows` rows; the stored cache itself is never written to."""
|
| 106 |
+
from mlx_lm.models.cache import ArraysCache, KVCache
|
| 107 |
+
out = []
|
| 108 |
+
for c in cache:
|
| 109 |
+
if isinstance(c, KVCache):
|
| 110 |
+
n = KVCache()
|
| 111 |
+
if c.keys is not None:
|
| 112 |
+
n.keys = mx.repeat(c.keys[..., :c.offset, :], rows, axis=0)
|
| 113 |
+
n.values = mx.repeat(c.values[..., :c.offset, :], rows, axis=0)
|
| 114 |
+
n.offset = c.offset
|
| 115 |
+
else:
|
| 116 |
+
n = ArraysCache(len(c.cache))
|
| 117 |
+
n.cache = [None if a is None else mx.repeat(a, rows, axis=0) for a in c.cache]
|
| 118 |
+
out.append(n)
|
| 119 |
+
return out
|
| 120 |
+
|
| 121 |
+
def _prefill(self, prefix):
|
| 122 |
+
"""The prompt cache right after `prefix`, resumed from the longest stored prefix it starts with, and stored."""
|
| 123 |
+
from mlx_lm.models.cache import make_prompt_cache
|
| 124 |
+
best = max((s for s in self.snapshots if prefix[:len(s)] == s), key=len, default=())
|
| 125 |
+
cache = self._copy(self.snapshots[best], 1) if best else make_prompt_cache(self.model)
|
| 126 |
+
if len(prefix) > len(best):
|
| 127 |
+
self.body(mx.array([prefix[len(best):]]), cache=cache)
|
| 128 |
+
mx.eval([c.state for c in cache])
|
| 129 |
+
self.snapshots[prefix] = cache
|
| 130 |
+
return cache
|
| 131 |
+
|
| 132 |
+
def _logits(self, rows):
|
| 133 |
+
enc = self._encode(rows)
|
| 134 |
+
toks = [t for t, _ in enc]
|
| 135 |
+
c = 0 # common prefix, leaving every row one token
|
| 136 |
+
while c < min(len(t) for t in toks) - 1 and all(t[c] == toks[0][c] for t in toks):
|
| 137 |
+
c += 1
|
| 138 |
+
prefix = tuple(toks[0][:c])
|
| 139 |
+
pre = list(self.preamble)
|
| 140 |
+
if prefix != self.preamble and prefix in self.snapshots: # the same evidence as before: questions only
|
| 141 |
+
groups = [(list(range(len(toks))), c, self.snapshots[prefix])]
|
| 142 |
+
elif (len(toks) - 1) * (c - len(pre)) >= SHARE_MIN: # long shared evidence: run it once, keep it
|
| 143 |
+
for s in [s for s in self.snapshots if s != self.preamble]:
|
| 144 |
+
del self.snapshots[s]
|
| 145 |
+
groups = [(list(range(len(toks))), c, self._prefill(prefix))]
|
| 146 |
+
else: # one pass from the stored system text
|
| 147 |
+
start = len(pre) if all(t[:len(pre)] == pre for t in toks) else 0
|
| 148 |
+
base = self.snapshots[self.preamble] if start else None
|
| 149 |
+
groups, batch = [], []
|
| 150 |
+
for j in sorted(range(len(toks)), key=lambda j: -len(toks[j])):
|
| 151 |
+
if batch and (len(batch) + 1) * (len(toks[batch[0]]) - start) > MAX_BATCH_TOKENS:
|
| 152 |
+
groups.append((batch, start, base))
|
| 153 |
+
batch = []
|
| 154 |
+
batch.append(j)
|
| 155 |
+
groups.append((batch, start, base))
|
| 156 |
+
out = [None] * len(toks)
|
| 157 |
+
for idx, start, cache in groups:
|
| 158 |
+
n = max(len(toks[j]) - start for j in idx)
|
| 159 |
+
ids = np.full((len(idx), n), self.pad, dtype=np.int32)
|
| 160 |
+
for r, j in enumerate(idx):
|
| 161 |
+
ids[r, :len(toks[j]) - start] = toks[j][start:]
|
| 162 |
+
z = np.array(self._forward(mx.array(ids), mx.array([len(toks[j]) - start - 1 for j in idx]),
|
| 163 |
+
self._copy(cache, len(idx)) if cache is not None else None))
|
| 164 |
+
for r, j in enumerate(idx):
|
| 165 |
+
out[j] = torch.from_numpy(z[r, :enc[j][1]].copy())
|
| 166 |
+
return out, sum(len(t) for t in toks)
|
startlux_decision/model.py
CHANGED
|
@@ -10,17 +10,41 @@ to four questions of up to 1024 tokens each. Replaying a graph removes the per-
|
|
| 10 |
the JevK5 runtime, Apache-2.0). Inputs are right-padded; every layer is causal and rows never mix, so padding after the
|
| 11 |
last prompt token never reaches the position that is read. Other requests run eagerly as one padded batch (inputs
|
| 12 |
padded to one of PAD_LENGTHS); decide_batch() batches questions across many requests.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
import importlib
|
|
|
|
| 15 |
import json
|
| 16 |
import os
|
|
|
|
|
|
|
| 17 |
import warnings
|
| 18 |
|
| 19 |
import torch
|
|
|
|
| 20 |
|
| 21 |
from . import jevfmt as J
|
| 22 |
|
| 23 |
-
__all__ = ["StartLuxDecision", "load_model", "fast_kernels_active"]
|
| 24 |
|
| 25 |
GRAPH_LENGTHS = (128, 192, 256, 320, 384, 512, 640, 768, 1024, 1536, 2048, 3072, 4096)
|
| 26 |
GRAPH_ROWS = (1, 2, 3, 4) # questions per request replayed as one graph
|
|
@@ -32,6 +56,64 @@ PAD_LENGTHS = (128, 192, 256, 320, 384, 448, 512, 640, 768, 896, 1024, 1280, 153
|
|
| 32 |
5120, 6144, 7168, 8192, 10240, 12288, 14336, 16384, 20480, 24576, 28672, 32768, 40960, 49152, 57344, 65536)
|
| 33 |
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
def padded_length(n):
|
| 36 |
"""The length a batch whose longest input has n tokens is padded to."""
|
| 37 |
for length in PAD_LENGTHS:
|
|
@@ -40,6 +122,125 @@ def padded_length(n):
|
|
| 40 |
return -(-n // 8192) * 8192
|
| 41 |
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
def fast_kernels_active(path):
|
| 44 |
"""True when transformers will use the fla / causal-conv1d kernels for the linear-attention layers of the model
|
| 45 |
in `path`."""
|
|
@@ -55,18 +256,26 @@ def fast_kernels_active(path):
|
|
| 55 |
|
| 56 |
def load_model(path, device):
|
| 57 |
"""The checkpoint's own transformers class in bf16 on `device`, and its text decoder (the stack without the
|
| 58 |
-
output head; checkpoints that also carry other towers keep the text decoder under .language_model).
|
|
|
|
|
|
|
| 59 |
import transformers
|
| 60 |
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
| 63 |
return model, getattr(model.model, "language_model", model.model)
|
| 64 |
|
| 65 |
|
| 66 |
class StartLuxDecision:
|
| 67 |
-
"""decide(state, questions) -> (answers, usage), answers in the TypeSafe /v1/systemone format.
|
| 68 |
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
from transformers import AutoTokenizer
|
| 71 |
|
| 72 |
if not os.path.isdir(path):
|
|
@@ -92,9 +301,26 @@ class StartLuxDecision:
|
|
| 92 |
self.body = body.eval()
|
| 93 |
head = model.get_output_embeddings().weight
|
| 94 |
self.letter_rows = head.index_select(0, torch.tensor(self.letters, device=head.device)).float()
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
self.pad = self.tok.pad_token_id
|
| 97 |
self.max_length, self.max_batch_tokens = int(max_length), int(max_batch_tokens)
|
|
|
|
|
|
|
|
|
|
| 98 |
self.graphs = {}
|
| 99 |
if graphs and self.device.type == "cuda" and os.environ.get("STARTLUX_GRAPHS", "1") != "0":
|
| 100 |
self._capture()
|
|
@@ -143,6 +369,87 @@ class StartLuxDecision:
|
|
| 143 |
res = out.cpu()
|
| 144 |
return [res[j, :c].clone() for j, (_, _, c) in enumerate(enc)]
|
| 145 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
# ---- readout
|
| 147 |
@torch.no_grad()
|
| 148 |
def _logits(self, rows):
|
|
@@ -150,15 +457,19 @@ class StartLuxDecision:
|
|
| 150 |
enc = []
|
| 151 |
for i, r in enumerate(rows):
|
| 152 |
order = [o["id"] for o in r["options"]]
|
| 153 |
-
|
| 154 |
-
enc.append((i, ids, len(order)))
|
| 155 |
out, tokens = [None] * len(rows), 0
|
| 156 |
-
n = self._graph_length(enc) if self.graphs and enc else None
|
| 157 |
if n is not None: # every question of the request in one replay
|
| 158 |
for (i, t, _), z in zip(enc, self._graph_logits(enc, n)):
|
| 159 |
out[i] = z
|
| 160 |
tokens += len(t)
|
| 161 |
return out, tokens
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
enc.sort(key=lambda x: -len(x[1]))
|
| 163 |
start = 0
|
| 164 |
while start < len(enc):
|
|
@@ -167,21 +478,125 @@ class StartLuxDecision:
|
|
| 167 |
batch = enc[start:start + n]
|
| 168 |
start += n
|
| 169 |
ids = torch.full((len(batch), longest), self.pad, dtype=torch.long)
|
| 170 |
-
attn = torch.zeros_like(ids)
|
| 171 |
for j, (_, t, _) in enumerate(batch):
|
| 172 |
ids[j, :len(t)] = torch.tensor(t)
|
| 173 |
-
attn[j, :len(t)] = 1
|
| 174 |
tokens += len(t)
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
pos = attn.sum(1) - 1
|
| 179 |
h = hidden[torch.arange(len(batch), device=self.device), pos].float()
|
| 180 |
logits = h @ self.letter_rows.T
|
| 181 |
for j, (i, _, c) in enumerate(batch):
|
| 182 |
out[i] = logits[j, :c].cpu()
|
| 183 |
return out, tokens
|
| 184 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
def _probs(self, rows):
|
| 186 |
logits, tokens = self._logits(rows)
|
| 187 |
return [torch.softmax(z / self.temperature.get(r["type"], 1.0), -1).tolist() for r, z in zip(rows, logits)], tokens
|
|
@@ -196,7 +611,7 @@ class StartLuxDecision:
|
|
| 196 |
try:
|
| 197 |
eager, _ = self._probs(rows)
|
| 198 |
finally:
|
| 199 |
-
self.graphs = graphs
|
| 200 |
return max(abs(a - b) for pg, pe in zip(graphed, eager) for a, b in zip(pg, pe))
|
| 201 |
|
| 202 |
def _wide(self, state, spec):
|
|
@@ -234,13 +649,13 @@ class StartLuxDecision:
|
|
| 234 |
return {"type": "noul", "noul": p[ids.index("true")]}
|
| 235 |
if row["type"] == "score":
|
| 236 |
levels = question.get("criteria") or []
|
| 237 |
-
levels =
|
| 238 |
-
return {"type": "score", "score": sum(i * v for i, v in enumerate(p)), "confidence":
|
| 239 |
"legend": {str(i): (levels[i] if i < len(levels) else str(i)) for i in range(len(p))},
|
| 240 |
"probabilities": {str(i): v for i, v in enumerate(p)}}
|
| 241 |
dist = dict(zip(ids, p))
|
| 242 |
best = max(dist, key=dist.get)
|
| 243 |
-
return {"type": "choice", "choice": best, "confidence":
|
| 244 |
|
| 245 |
def _split(self, state, questions):
|
| 246 |
"""-> (answers decided without the model, [(key, rendered row)], tokens spent on wide lists)"""
|
|
@@ -255,27 +670,42 @@ class StartLuxDecision:
|
|
| 255 |
p, n = self._wide(state, q)
|
| 256 |
tokens += n
|
| 257 |
best = max(p, key=p.get)
|
| 258 |
-
answers[k] = {"type": "choice", "choice": best, "confidence": p
|
|
|
|
| 259 |
else:
|
| 260 |
rows.append((k, J.from_systemone(state, q)))
|
| 261 |
return answers, rows, tokens
|
| 262 |
|
| 263 |
-
def decide(self, state, questions):
|
| 264 |
-
"""One request -> (answers, usage), answers in the TypeSafe /v1/systemone format.
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
return answers, {"input_tokens": tokens, "output_tokens": 0}
|
| 272 |
|
| 273 |
def decide_batch(self, requests):
|
| 274 |
-
"""[(state, questions), ...] -> [answers, ...]. Every question of every
|
| 275 |
-
set of padded forward passes (up to max_batch_tokens each), which is
|
| 276 |
-
when the requests are short
|
|
|
|
| 277 |
parts, flat = [], []
|
| 278 |
-
for state, questions in requests:
|
|
|
|
|
|
|
|
|
|
| 279 |
answers, rows, _ = self._split(state, questions)
|
| 280 |
parts.append((answers, rows, questions))
|
| 281 |
flat.extend(r for _, r in rows)
|
|
@@ -283,7 +713,7 @@ class StartLuxDecision:
|
|
| 283 |
try:
|
| 284 |
probs, _ = self._probs(flat) if flat else ([], 0)
|
| 285 |
finally:
|
| 286 |
-
self.graphs = graphs
|
| 287 |
out, i = [], 0
|
| 288 |
for answers, rows, questions in parts:
|
| 289 |
for k, row in rows:
|
|
|
|
| 10 |
the JevK5 runtime, Apache-2.0). Inputs are right-padded; every layer is causal and rows never mix, so padding after the
|
| 11 |
last prompt token never reaches the position that is read. Other requests run eagerly as one padded batch (inputs
|
| 12 |
padded to one of PAD_LENGTHS); decide_batch() batches questions across many requests.
|
| 13 |
+
|
| 14 |
+
Long prompts. The questions of one request repeat the same evidence, so their prompts begin with the same tokens. When
|
| 15 |
+
that shared beginning is long it runs once: the decoder reads it in chunks of prefill_chunk tokens and keeps what each
|
| 16 |
+
layer carries forward (the keys and values of the attention layers, the convolution and recurrent state of the
|
| 17 |
+
linear-attention layers), then the rest of every question runs as one batch on top of it. Chunks bound the memory for
|
| 18 |
+
activations, so a single question over 128K tokens runs the same way and needs little more than the keys and values of
|
| 19 |
+
its prompt. A chunk attends to the keys before it without a mask and to its own keys causally, two fused attention
|
| 20 |
+
calls whose outputs are combined by their log-sum-exp; the questions attend to the shared keys the same way, all rows
|
| 21 |
+
against the one stored copy. Prompts can be as long as the checkpoints' native context, 262,144 tokens.
|
| 22 |
+
|
| 23 |
+
Images. A request can carry images as part of its evidence. The checkpoint's image processor resizes each to at most
|
| 24 |
+
max_pixels pixels (1M by default) and cuts it into patches, the vision tower turns every 2x2 patches into one
|
| 25 |
+
embedding, and these take the places of the image tokens in each question's prompt, with the multimodal positions the
|
| 26 |
+
checkpoints use. The vision tower runs once per request; the questions then take the same paths as text questions
|
| 27 |
+
(one padded batch, or the shared beginning in chunks), without the graphs. In a string state "<image>" marks where each
|
| 28 |
+
image goes; without marks the images come first, then the state.
|
| 29 |
"""
|
| 30 |
+
import base64
|
| 31 |
+
import binascii
|
| 32 |
+
import contextlib
|
| 33 |
+
import functools
|
| 34 |
import importlib
|
| 35 |
+
import io
|
| 36 |
import json
|
| 37 |
import os
|
| 38 |
+
import re
|
| 39 |
+
import threading
|
| 40 |
import warnings
|
| 41 |
|
| 42 |
import torch
|
| 43 |
+
import torch.nn.functional as F
|
| 44 |
|
| 45 |
from . import jevfmt as J
|
| 46 |
|
| 47 |
+
__all__ = ["StartLuxDecision", "load_model", "load_image", "fast_kernels_active", "choice_confidence", "score_confidence"]
|
| 48 |
|
| 49 |
GRAPH_LENGTHS = (128, 192, 256, 320, 384, 512, 640, 768, 1024, 1536, 2048, 3072, 4096)
|
| 50 |
GRAPH_ROWS = (1, 2, 3, 4) # questions per request replayed as one graph
|
|
|
|
| 56 |
5120, 6144, 7168, 8192, 10240, 12288, 14336, 16384, 20480, 24576, 28672, 32768, 40960, 49152, 57344, 65536)
|
| 57 |
|
| 58 |
|
| 59 |
+
SHARE_MIN = 4096 # a shared beginning this long runs once for all questions
|
| 60 |
+
SHARE_STEP = 1024 # the shared part is cut to a multiple of this; the tokens after it run with each question
|
| 61 |
+
PREFILL_CHUNK = 32768 # default chunk: activations stay a few GiB, so the 35B-A3B reads 128K tokens on one 80 GB GPU
|
| 62 |
+
CAUSAL = "startlux_causal"
|
| 63 |
+
_SHARED = threading.local() # .kv while questions run on a shared part: {attention layer index: (keys, values)}
|
| 64 |
+
_LSE_KERNEL = [] # the fused attention here that also returns the log-sum-exp: "cudnn", "flash" or None
|
| 65 |
+
IMAGE = "<image>" # in a string state: where the next image goes
|
| 66 |
+
_SLOT = "\x00IMAGE\x00" # an image's place in a rendered prompt
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def load_image(x, paths=True):
|
| 70 |
+
"""A PIL image, encoded image bytes, a base64 string or data URI, or (paths=True) a file path -> RGB PIL image."""
|
| 71 |
+
from PIL import Image, UnidentifiedImageError
|
| 72 |
+
|
| 73 |
+
if isinstance(x, Image.Image):
|
| 74 |
+
return x.convert("RGB")
|
| 75 |
+
raw = x
|
| 76 |
+
if isinstance(x, str):
|
| 77 |
+
if paths and not x.startswith("data:") and os.path.isfile(x):
|
| 78 |
+
with open(x, "rb") as f:
|
| 79 |
+
raw = f.read()
|
| 80 |
+
else:
|
| 81 |
+
try:
|
| 82 |
+
raw = base64.b64decode(re.sub(r"\s+", "", x.split(",", 1)[1] if x.startswith("data:") else x),
|
| 83 |
+
validate=True)
|
| 84 |
+
except (binascii.Error, IndexError):
|
| 85 |
+
raise ValueError("an image must be base64 or a data URI" + (", a file path" if paths else "") +
|
| 86 |
+
" or bytes") from None
|
| 87 |
+
if not isinstance(raw, (bytes, bytearray)):
|
| 88 |
+
raise ValueError(f"unsupported image value of type {type(x).__name__}")
|
| 89 |
+
try:
|
| 90 |
+
return Image.open(io.BytesIO(raw)).convert("RGB")
|
| 91 |
+
except (UnidentifiedImageError, OSError) as e:
|
| 92 |
+
raise ValueError(f"not a readable image: {e}") from None
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class _Row(list):
|
| 96 |
+
"""The tokens of one prompt in a request with images, with each token's multimodal position (.pos, 3 x n) and the
|
| 97 |
+
index of the vision embedding each image token takes (.feature, -1 for text tokens)."""
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def choice_confidence(p):
|
| 101 |
+
"""TypeSafe's confidence of a Choice answer: how far the top probability sits above an even split, from 0 (even) to
|
| 102 |
+
1 (all on one option), (p_max - 1/n) / (1 - 1/n)."""
|
| 103 |
+
n = len(p)
|
| 104 |
+
return 1.0 if n < 2 else min(1.0, max(0.0, (n * max(p) - 1) / (n - 1)))
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def score_confidence(p):
|
| 108 |
+
"""TypeSafe's confidence of a Score answer: 1 minus the probability-weighted distance in levels from the most likely
|
| 109 |
+
level, relative to the same distance for an even spread measured from the middle level, floored at 0."""
|
| 110 |
+
n = len(p)
|
| 111 |
+
m = max(range(n), key=p.__getitem__)
|
| 112 |
+
spread = sum(v * abs(i - m) for i, v in enumerate(p))
|
| 113 |
+
even = sum(abs(i - (n - 1) / 2) for i in range(n)) / n
|
| 114 |
+
return max(0.0, 1 - spread / even) if even else 1.0
|
| 115 |
+
|
| 116 |
+
|
| 117 |
def padded_length(n):
|
| 118 |
"""The length a batch whose longest input has n tokens is padded to."""
|
| 119 |
for length in PAD_LENGTHS:
|
|
|
|
| 122 |
return -(-n // 8192) * 8192
|
| 123 |
|
| 124 |
|
| 125 |
+
def _attend(query, key, value, causal, scale):
|
| 126 |
+
"""softmax(q k^T) v and the log-sum-exp of each query's scores, from cuDNN or else the flash kernel; None when
|
| 127 |
+
neither runs here."""
|
| 128 |
+
if not _LSE_KERNEL:
|
| 129 |
+
for kind in ("cudnn", "flash", None):
|
| 130 |
+
try:
|
| 131 |
+
out = _attend_with(kind, query, key, value, causal, scale)
|
| 132 |
+
except RuntimeError:
|
| 133 |
+
continue
|
| 134 |
+
_LSE_KERNEL.append(kind)
|
| 135 |
+
return out
|
| 136 |
+
return _attend_with(_LSE_KERNEL[0], query, key, value, causal, scale)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _attend_with(kind, query, key, value, causal, scale):
|
| 140 |
+
if kind is None:
|
| 141 |
+
return None
|
| 142 |
+
if kind == "cudnn":
|
| 143 |
+
out = torch.ops.aten._scaled_dot_product_cudnn_attention(query, key, value, None, True, 0.0, causal, False,
|
| 144 |
+
scale=scale)
|
| 145 |
+
else:
|
| 146 |
+
groups = query.shape[1] // key.shape[1]
|
| 147 |
+
if groups > 1:
|
| 148 |
+
key, value = key.repeat_interleave(groups, 1), value.repeat_interleave(groups, 1)
|
| 149 |
+
out = torch.ops.aten._scaled_dot_product_flash_attention(query, key, value, 0.0, causal, False, scale=scale)
|
| 150 |
+
return out[0], out[1].reshape(query.shape[:3])
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def _merge(a, b):
|
| 154 |
+
"""Attention over two disjoint key sets, from each part's output and log-sum-exp."""
|
| 155 |
+
(out_a, lse_a), (out_b, lse_b) = a, b
|
| 156 |
+
top = torch.maximum(lse_a, lse_b)
|
| 157 |
+
wa, wb = (lse_a - top).exp().unsqueeze(-1), (lse_b - top).exp().unsqueeze(-1)
|
| 158 |
+
return ((out_a.float() * wa + out_b.float() * wb) / (wa + wb)).to(out_a.dtype)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _causal_attention(module, query, key, value, attention_mask, dropout=0.0, scaling=None, **kwargs):
|
| 162 |
+
"""Causal attention for queries at the end of the keys, without a mask: inputs on this path are right-padded, so
|
| 163 |
+
causality is the only mask needed. The first chunk is one causal call. A later chunk attends to the keys before
|
| 164 |
+
it without a mask and to its own keys causally; questions on a shared part attend to its stored keys (all rows
|
| 165 |
+
against the one copy) and to their own keys causally."""
|
| 166 |
+
from torch.nn.attention.bias import causal_lower_right
|
| 167 |
+
|
| 168 |
+
rows, heads, q, dim = query.shape
|
| 169 |
+
shared = getattr(_SHARED, "kv", None)
|
| 170 |
+
shared = shared.get(module.layer_idx) if shared else None
|
| 171 |
+
if shared is not None:
|
| 172 |
+
folded = query.transpose(0, 1).reshape(1, heads, rows * q, dim)
|
| 173 |
+
part = _attend(folded, shared[0], shared[1], False, scaling)
|
| 174 |
+
if part is not None:
|
| 175 |
+
out_a = part[0].reshape(heads, rows, q, dim).transpose(0, 1)
|
| 176 |
+
lse_a = part[1].reshape(heads, rows, q).transpose(0, 1)
|
| 177 |
+
out = _merge((out_a, lse_a), _attend(query, key, value, True, scaling))
|
| 178 |
+
return out.transpose(1, 2).contiguous(), None
|
| 179 |
+
key = torch.cat([shared[0].expand(rows, -1, -1, -1), key], dim=-2)
|
| 180 |
+
value = torch.cat([shared[1].expand(rows, -1, -1, -1), value], dim=-2)
|
| 181 |
+
k = key.shape[2]
|
| 182 |
+
if q == k:
|
| 183 |
+
out = F.scaled_dot_product_attention(query, key, value, is_causal=q > 1, scale=scaling, enable_gqa=True)
|
| 184 |
+
else:
|
| 185 |
+
before = _attend(query, key[:, :, :k - q], value[:, :, :k - q], False, scaling)
|
| 186 |
+
if before is not None:
|
| 187 |
+
out = _merge(before, _attend(query, key[:, :, k - q:], value[:, :, k - q:], True, scaling))
|
| 188 |
+
else:
|
| 189 |
+
out = F.scaled_dot_product_attention(query, key, value, attn_mask=causal_lower_right(q, k), scale=scaling,
|
| 190 |
+
enable_gqa=True)
|
| 191 |
+
return out.transpose(1, 2).contiguous(), None
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def _register_causal_attention():
|
| 195 |
+
from transformers import AttentionInterface
|
| 196 |
+
from transformers.masking_utils import AttentionMaskInterface
|
| 197 |
+
|
| 198 |
+
AttentionInterface.register(CAUSAL, _causal_attention)
|
| 199 |
+
AttentionMaskInterface.register(CAUSAL, lambda *args, **kwargs: None)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def _common_length(a, b):
|
| 203 |
+
n = min(len(a), len(b))
|
| 204 |
+
diff = (torch.tensor(a[:n]) != torch.tensor(b[:n])).nonzero()
|
| 205 |
+
return int(diff[0, 0]) if len(diff) else n
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
@functools.cache
|
| 209 |
+
def _shared_kv_layer():
|
| 210 |
+
from transformers.cache_utils import DynamicLayer
|
| 211 |
+
|
| 212 |
+
class SharedKV(DynamicLayer):
|
| 213 |
+
"""Keys and values of one attention layer over the shared tokens. The chunks of the shared part fill a buffer
|
| 214 |
+
sized for all of them; once frozen, nothing more is stored and a pass over the questions reads them from that
|
| 215 |
+
one copy."""
|
| 216 |
+
|
| 217 |
+
def __init__(self, length):
|
| 218 |
+
super().__init__()
|
| 219 |
+
self.length, self.end, self.frozen = length, 0, False
|
| 220 |
+
|
| 221 |
+
def update(self, key_states, value_states, *args, **kwargs):
|
| 222 |
+
if self.frozen: # the attention reads the shared keys through _SHARED
|
| 223 |
+
return key_states, value_states
|
| 224 |
+
if not self.is_initialized:
|
| 225 |
+
b, h, _, d = key_states.shape
|
| 226 |
+
self.keys = key_states.new_empty((b, h, self.length, d))
|
| 227 |
+
self.values = value_states.new_empty((b, h, self.length, d))
|
| 228 |
+
self.dtype, self.device, self.is_initialized = key_states.dtype, key_states.device, True
|
| 229 |
+
n = key_states.shape[-2]
|
| 230 |
+
self.keys[:, :, self.end:self.end + n] = key_states
|
| 231 |
+
self.values[:, :, self.end:self.end + n] = value_states
|
| 232 |
+
self.end += n
|
| 233 |
+
return self.keys[:, :, :self.end], self.values[:, :, :self.end]
|
| 234 |
+
|
| 235 |
+
def get_seq_length(self):
|
| 236 |
+
return self.end
|
| 237 |
+
|
| 238 |
+
def get_mask_sizes(self, query_length):
|
| 239 |
+
return self.end + query_length, 0
|
| 240 |
+
|
| 241 |
+
return SharedKV
|
| 242 |
+
|
| 243 |
+
|
| 244 |
def fast_kernels_active(path):
|
| 245 |
"""True when transformers will use the fla / causal-conv1d kernels for the linear-attention layers of the model
|
| 246 |
in `path`."""
|
|
|
|
| 256 |
|
| 257 |
def load_model(path, device):
|
| 258 |
"""The checkpoint's own transformers class in bf16 on `device`, and its text decoder (the stack without the
|
| 259 |
+
output head; checkpoints that also carry other towers keep the text decoder under .language_model). The experts
|
| 260 |
+
of a mixture-of-experts checkpoint run as grouped matrix multiplications: one kernel per projection for all
|
| 261 |
+
experts and no host synchronisation, so the CUDA graphs can record them."""
|
| 262 |
import transformers
|
| 263 |
|
| 264 |
+
config = transformers.AutoConfig.from_pretrained(path)
|
| 265 |
+
extra = {"experts_implementation": "grouped_mm"} if getattr(config.get_text_config(), "num_experts", 0) else {}
|
| 266 |
+
model = getattr(transformers, config.architectures[0]).from_pretrained(path, dtype=torch.bfloat16,
|
| 267 |
+
device_map={"": device}, **extra)
|
| 268 |
return model, getattr(model.model, "language_model", model.model)
|
| 269 |
|
| 270 |
|
| 271 |
class StartLuxDecision:
|
| 272 |
+
"""decide(state, questions, images=None) -> (answers, usage), answers in the TypeSafe /v1/systemone format.
|
| 273 |
|
| 274 |
+
images=False leaves the vision tower out (about 0.2 to 0.9 GB less GPU memory); max_pixels bounds the size of
|
| 275 |
+
each image after resizing."""
|
| 276 |
+
|
| 277 |
+
def __init__(self, path, device=None, max_length=262144, max_batch_tokens=65536, graphs=True,
|
| 278 |
+
prefill_chunk=PREFILL_CHUNK, images=True, max_pixels=1 << 20, min_pixels=65536):
|
| 279 |
from transformers import AutoTokenizer
|
| 280 |
|
| 281 |
if not os.path.isdir(path):
|
|
|
|
| 301 |
self.body = body.eval()
|
| 302 |
head = model.get_output_embeddings().weight
|
| 303 |
self.letter_rows = head.index_select(0, torch.tensor(self.letters, device=head.device)).float()
|
| 304 |
+
self.vision, self.image_processor, self.vision_note = None, None, "the checkpoint has no vision tower"
|
| 305 |
+
if not images:
|
| 306 |
+
self.vision_note = "the model was loaded with images=False"
|
| 307 |
+
elif getattr(model.model, "visual", None) is not None:
|
| 308 |
+
try:
|
| 309 |
+
from transformers import AutoImageProcessor
|
| 310 |
+
|
| 311 |
+
self.image_processor = AutoImageProcessor.from_pretrained(path)
|
| 312 |
+
self.image_processor.size = {"longest_edge": int(max_pixels), "shortest_edge": int(min_pixels)}
|
| 313 |
+
self.vision = model.model.eval() # vision tower, multimodal positions; its decoder is self.body
|
| 314 |
+
self.image_token = model.config.image_token_id
|
| 315 |
+
except (OSError, ImportError, ValueError) as e:
|
| 316 |
+
self.vision_note = f"no image processor ({str(e).splitlines()[0][:200]})"
|
| 317 |
+
del model # the decoder, the letter rows of the output head and the vision tower are used
|
| 318 |
+
self._media = None # the vision embeddings of the request being decided, when it has images
|
| 319 |
self.pad = self.tok.pad_token_id
|
| 320 |
self.max_length, self.max_batch_tokens = int(max_length), int(max_batch_tokens)
|
| 321 |
+
self.share, self.prefill_chunk = True, int(prefill_chunk)
|
| 322 |
+
self._kept = None # (tokens, cache) of the last shared part, for the rest of one request
|
| 323 |
+
_register_causal_attention()
|
| 324 |
self.graphs = {}
|
| 325 |
if graphs and self.device.type == "cuda" and os.environ.get("STARTLUX_GRAPHS", "1") != "0":
|
| 326 |
self._capture()
|
|
|
|
| 369 |
res = out.cpu()
|
| 370 |
return [res[j, :c].clone() for j, (_, _, c) in enumerate(enc)]
|
| 371 |
|
| 372 |
+
# ---- images
|
| 373 |
+
def _media_state(self, state, n):
|
| 374 |
+
"""The state as text with the places of n images: at its "<image>" marks, else first."""
|
| 375 |
+
text = J.state_text(state)
|
| 376 |
+
marks = text.count(IMAGE)
|
| 377 |
+
if marks == n:
|
| 378 |
+
return text.replace(IMAGE, _SLOT)
|
| 379 |
+
if marks:
|
| 380 |
+
raise ValueError(f"the state has {marks} {IMAGE} marks for {n} images")
|
| 381 |
+
head = _SLOT if n == 1 else "\n".join(f"Image {k + 1}: {_SLOT}" for k in range(n))
|
| 382 |
+
return head + ("\n\n" + text if text.strip() else "")
|
| 383 |
+
|
| 384 |
+
@torch.no_grad()
|
| 385 |
+
def _encode_images(self, images):
|
| 386 |
+
"""-> the vision embeddings of a request's images, and per image its (t, h, w) patch grid and token count."""
|
| 387 |
+
if getattr(self, "vision", None) is None: # also the MLX and GGUF backends, which read text only
|
| 388 |
+
raise ValueError(f"this model takes no images: {getattr(self, 'vision_note', 'this backend reads text only')}")
|
| 389 |
+
enc = self.image_processor(images=[load_image(x) for x in images], return_tensors="pt")
|
| 390 |
+
grid = enc["image_grid_thw"]
|
| 391 |
+
with torch.autocast(self.device.type, dtype=torch.bfloat16):
|
| 392 |
+
out = self.vision.get_image_features(enc["pixel_values"].to(self.device), grid.to(self.device),
|
| 393 |
+
return_dict=True)
|
| 394 |
+
merge = self.vision.visual.spatial_merge_size
|
| 395 |
+
return {"features": torch.cat(out.pooler_output, 0), "grid": grid, "tokens": (grid.prod(-1) // merge ** 2).tolist()}
|
| 396 |
+
|
| 397 |
+
def _render(self, row, order):
|
| 398 |
+
"""A rendered record -> its prompt tokens; in a request with images, each image's slot becomes its tokens."""
|
| 399 |
+
if self._media is None:
|
| 400 |
+
return J.render_ids(row, self.tok, order, max_length=self.max_length)[0]
|
| 401 |
+
msgs, _ = J.messages(row, order)
|
| 402 |
+
content, parts = [], msgs[1]["content"].split(_SLOT)
|
| 403 |
+
for k, part in enumerate(parts):
|
| 404 |
+
if part:
|
| 405 |
+
content.append({"type": "text", "text": part})
|
| 406 |
+
if k < len(parts) - 1:
|
| 407 |
+
content.append({"type": "image"})
|
| 408 |
+
chat = [{"role": "system", "content": [{"type": "text", "text": msgs[0]["content"]}]},
|
| 409 |
+
{"role": "user", "content": content}]
|
| 410 |
+
text = self.tok.apply_chat_template(chat, tokenize=False, add_generation_prompt=True, enable_thinking=False)
|
| 411 |
+
if not text.endswith(J.THINK_OFF_SUFFIX):
|
| 412 |
+
raise ValueError("chat template lacks the thinking-off assistant prefix")
|
| 413 |
+
counts, ids, k = self._media["tokens"], _Row(), 0
|
| 414 |
+
for t in self.tok.encode(text, add_special_tokens=False):
|
| 415 |
+
if t == self.image_token:
|
| 416 |
+
if k == len(counts):
|
| 417 |
+
raise ValueError("more image places than images")
|
| 418 |
+
ids.extend([t] * counts[k])
|
| 419 |
+
k += 1
|
| 420 |
+
else:
|
| 421 |
+
ids.append(t)
|
| 422 |
+
if k != len(counts):
|
| 423 |
+
raise ValueError("fewer image places than images")
|
| 424 |
+
if len(ids) > self.max_length:
|
| 425 |
+
raise ValueError(f"length {len(ids)} > {self.max_length}")
|
| 426 |
+
t = torch.tensor(ids)
|
| 427 |
+
image = t == self.image_token
|
| 428 |
+
pos, _ = self.vision.get_rope_index(t[None], mm_token_type_ids=image.int()[None],
|
| 429 |
+
image_grid_thw=self._media["grid"])
|
| 430 |
+
ids.pos = pos[:, 0]
|
| 431 |
+
ids.feature = torch.where(image, image.long().cumsum(0) - 1, -1)
|
| 432 |
+
return ids
|
| 433 |
+
|
| 434 |
+
def _forward(self, ids, rows, offset=0, **kwargs):
|
| 435 |
+
"""The decoder over ids (right-padded rows: tokens offset.. of each prompt in rows). In a request with images
|
| 436 |
+
the image tokens take their vision embeddings and every token its multimodal position."""
|
| 437 |
+
if self._media is None:
|
| 438 |
+
return self.body(input_ids=ids.to(self.device), **kwargs)
|
| 439 |
+
ids = ids.to(self.device)
|
| 440 |
+
embeds = self.body.get_input_embeddings()(ids)
|
| 441 |
+
features, n = self._media["features"], ids.shape[1]
|
| 442 |
+
pos = torch.empty((3, ids.shape[0], n), dtype=torch.long)
|
| 443 |
+
for j, t in enumerate(rows):
|
| 444 |
+
p = t.pos[:, offset:offset + n]
|
| 445 |
+
pos[:, j, :p.shape[1]] = p
|
| 446 |
+
pos[:, j, p.shape[1]:] = p[:, -1:] + torch.arange(1, n - p.shape[1] + 1)
|
| 447 |
+
f = t.feature[offset:offset + n]
|
| 448 |
+
at = (f >= 0).nonzero().squeeze(1)
|
| 449 |
+
if len(at):
|
| 450 |
+
embeds[j, at.to(self.device)] = features[f[at].to(self.device)].to(embeds.dtype)
|
| 451 |
+
return self.body(inputs_embeds=embeds, position_ids=pos.to(self.device), **kwargs)
|
| 452 |
+
|
| 453 |
# ---- readout
|
| 454 |
@torch.no_grad()
|
| 455 |
def _logits(self, rows):
|
|
|
|
| 457 |
enc = []
|
| 458 |
for i, r in enumerate(rows):
|
| 459 |
order = [o["id"] for o in r["options"]]
|
| 460 |
+
enc.append((i, self._render(r, order), len(order)))
|
|
|
|
| 461 |
out, tokens = [None] * len(rows), 0
|
| 462 |
+
n = self._graph_length(enc) if self.graphs and enc and self._media is None else None
|
| 463 |
if n is not None: # every question of the request in one replay
|
| 464 |
for (i, t, _), z in zip(enc, self._graph_logits(enc, n)):
|
| 465 |
out[i] = z
|
| 466 |
tokens += len(t)
|
| 467 |
return out, tokens
|
| 468 |
+
groups, enc = self._shared_groups(enc) if self.share else ([], enc)
|
| 469 |
+
for c, group in groups: # long prompts with a shared beginning
|
| 470 |
+
for (i, t, _), z in zip(group, self._shared_logits(group, c)):
|
| 471 |
+
out[i] = z
|
| 472 |
+
tokens += len(t)
|
| 473 |
enc.sort(key=lambda x: -len(x[1]))
|
| 474 |
start = 0
|
| 475 |
while start < len(enc):
|
|
|
|
| 478 |
batch = enc[start:start + n]
|
| 479 |
start += n
|
| 480 |
ids = torch.full((len(batch), longest), self.pad, dtype=torch.long)
|
|
|
|
| 481 |
for j, (_, t, _) in enumerate(batch):
|
| 482 |
ids[j, :len(t)] = torch.tensor(t)
|
|
|
|
| 483 |
tokens += len(t)
|
| 484 |
+
with torch.autocast(self.device.type, dtype=torch.bfloat16), self._causal():
|
| 485 |
+
hidden = self._forward(ids, [t for _, t, _ in batch], use_cache=False, return_dict=True).last_hidden_state
|
| 486 |
+
pos = torch.tensor([len(t) - 1 for _, t, _ in batch], device=self.device)
|
|
|
|
| 487 |
h = hidden[torch.arange(len(batch), device=self.device), pos].float()
|
| 488 |
logits = h @ self.letter_rows.T
|
| 489 |
for j, (i, _, c) in enumerate(batch):
|
| 490 |
out[i] = logits[j, :c].cpu()
|
| 491 |
return out, tokens
|
| 492 |
|
| 493 |
+
@contextlib.contextmanager
|
| 494 |
+
def _causal(self):
|
| 495 |
+
"""Attention through _causal_attention: right-padded rows, causality as the only mask, no mask tensor."""
|
| 496 |
+
config = self.body.config
|
| 497 |
+
before, config._attn_implementation = config._attn_implementation, CAUSAL
|
| 498 |
+
try:
|
| 499 |
+
yield
|
| 500 |
+
finally:
|
| 501 |
+
config._attn_implementation = before
|
| 502 |
+
|
| 503 |
+
# ---- long prompts: the shared beginning once, in chunks, then the questions on top of it
|
| 504 |
+
def _shared_groups(self, enc):
|
| 505 |
+
"""-> ([(c, rows that share their first c tokens)], other rows). Rows are grouped by their first SHARE_MIN
|
| 506 |
+
tokens; c is cut to a multiple of SHARE_STEP and leaves every row at least one token of its own. One row
|
| 507 |
+
alone is a group when it is longer than one chunk."""
|
| 508 |
+
by_start, rest = {}, []
|
| 509 |
+
for e in enc:
|
| 510 |
+
if len(e[1]) > SHARE_MIN:
|
| 511 |
+
by_start.setdefault(tuple(e[1][:SHARE_MIN]), []).append(e)
|
| 512 |
+
else:
|
| 513 |
+
rest.append(e)
|
| 514 |
+
groups = []
|
| 515 |
+
for group in by_start.values():
|
| 516 |
+
first = group[0][1]
|
| 517 |
+
c = min(len(t) for _, t, _ in group) - 1
|
| 518 |
+
for _, t, _ in group[1:]:
|
| 519 |
+
c = min(c, _common_length(first, t))
|
| 520 |
+
c = c // SHARE_STEP * SHARE_STEP
|
| 521 |
+
kept = self._kept[0] if self._kept else ()
|
| 522 |
+
if (kept and all(len(t) > len(kept) and tuple(t[:len(kept)]) == kept for _, t, _ in group)
|
| 523 |
+
and max(len(t) for _, t, _ in group) - len(kept) <= self.prefill_chunk):
|
| 524 |
+
c = len(kept) # a later round of the same request (wide choices) reuses it
|
| 525 |
+
if c >= SHARE_MIN and (len(group) > 1 or len(first) > self.prefill_chunk):
|
| 526 |
+
groups.append((c, group))
|
| 527 |
+
else:
|
| 528 |
+
rest.extend(group)
|
| 529 |
+
return groups, rest
|
| 530 |
+
|
| 531 |
+
def _rows_cache(self, shared, rows):
|
| 532 |
+
"""A cache for one pass of `rows` questions on top of the shared part: the attention layers read the shared
|
| 533 |
+
keys and values (one copy); each linear-attention layer starts every row from a copy of the shared state."""
|
| 534 |
+
from transformers import DynamicCache
|
| 535 |
+
from transformers.cache_utils import LinearAttentionLayer
|
| 536 |
+
|
| 537 |
+
cache = DynamicCache(config=self.body.config)
|
| 538 |
+
for i, layer in enumerate(shared.layers):
|
| 539 |
+
if not isinstance(layer, LinearAttentionLayer):
|
| 540 |
+
cache.layers[i] = layer
|
| 541 |
+
continue
|
| 542 |
+
copy = LinearAttentionLayer()
|
| 543 |
+
for name in ("conv_states", "recurrent_states"):
|
| 544 |
+
x = getattr(layer, name)
|
| 545 |
+
setattr(copy, name, x.repeat(rows, *[1] * (x.dim() - 1)))
|
| 546 |
+
copy.dtype, copy.device = layer.dtype, layer.device
|
| 547 |
+
copy.max_batch_size, copy.conv_kernel_size = rows, layer.conv_kernel_size
|
| 548 |
+
copy.is_conv_states_initialized = copy.is_recurrent_states_initialized = copy.has_previous_state = True
|
| 549 |
+
cache.layers[i] = copy
|
| 550 |
+
return cache
|
| 551 |
+
|
| 552 |
+
@torch.no_grad()
|
| 553 |
+
def _shared_logits(self, group, c):
|
| 554 |
+
"""Letter logits for rows (rendered prompts) that share their first c tokens."""
|
| 555 |
+
from transformers import DynamicCache
|
| 556 |
+
from transformers.cache_utils import LinearAttentionLayer
|
| 557 |
+
|
| 558 |
+
SharedKV = _shared_kv_layer()
|
| 559 |
+
prefix = tuple(group[0][1][:c])
|
| 560 |
+
out = []
|
| 561 |
+
try:
|
| 562 |
+
with torch.autocast(self.device.type, dtype=torch.bfloat16), self._causal():
|
| 563 |
+
if self._kept and self._kept[0] == prefix:
|
| 564 |
+
shared = self._kept[1]
|
| 565 |
+
else:
|
| 566 |
+
self._kept = None
|
| 567 |
+
shared = DynamicCache(config=self.body.config)
|
| 568 |
+
for i, layer in enumerate(shared.layers):
|
| 569 |
+
if not isinstance(layer, LinearAttentionLayer):
|
| 570 |
+
shared.layers[i] = SharedKV(c)
|
| 571 |
+
ids = torch.tensor([prefix])
|
| 572 |
+
for start in range(0, c, self.prefill_chunk):
|
| 573 |
+
self._forward(ids[:, start:start + self.prefill_chunk], [group[0][1]], start,
|
| 574 |
+
past_key_values=shared, use_cache=True)
|
| 575 |
+
del ids
|
| 576 |
+
self._kept = (prefix, shared)
|
| 577 |
+
_SHARED.kv = {}
|
| 578 |
+
for i, layer in enumerate(shared.layers):
|
| 579 |
+
if isinstance(layer, SharedKV):
|
| 580 |
+
layer.frozen = True
|
| 581 |
+
_SHARED.kv[i] = (layer.keys[:, :, :layer.end], layer.values[:, :, :layer.end])
|
| 582 |
+
tails = [(t, k) for _, t, k in group] # whole prompts; their tokens from c on run here
|
| 583 |
+
per_pass = max(1, self.max_batch_tokens // padded_length(max(len(t) - c for t, _ in tails)))
|
| 584 |
+
for start in range(0, len(tails), per_pass):
|
| 585 |
+
batch = tails[start:start + per_pass]
|
| 586 |
+
n = padded_length(max(len(t) - c for t, _ in batch))
|
| 587 |
+
ids = torch.full((len(batch), n), self.pad, dtype=torch.long)
|
| 588 |
+
for j, (t, _) in enumerate(batch):
|
| 589 |
+
ids[j, :len(t) - c] = torch.tensor(t[c:])
|
| 590 |
+
cache = self._rows_cache(shared, len(batch))
|
| 591 |
+
hidden = self._forward(ids, [t for t, _ in batch], c, past_key_values=cache,
|
| 592 |
+
use_cache=True).last_hidden_state
|
| 593 |
+
last = torch.tensor([len(t) - c - 1 for t, _ in batch], device=self.device)
|
| 594 |
+
logits = hidden[torch.arange(len(batch), device=self.device), last].float() @ self.letter_rows.T
|
| 595 |
+
out.extend(logits[j, :k].cpu() for j, (_, k) in enumerate(batch))
|
| 596 |
+
finally:
|
| 597 |
+
_SHARED.kv = None
|
| 598 |
+
return out
|
| 599 |
+
|
| 600 |
def _probs(self, rows):
|
| 601 |
logits, tokens = self._logits(rows)
|
| 602 |
return [torch.softmax(z / self.temperature.get(r["type"], 1.0), -1).tolist() for r, z in zip(rows, logits)], tokens
|
|
|
|
| 611 |
try:
|
| 612 |
eager, _ = self._probs(rows)
|
| 613 |
finally:
|
| 614 |
+
self.graphs, self._kept = graphs, None
|
| 615 |
return max(abs(a - b) for pg, pe in zip(graphed, eager) for a, b in zip(pg, pe))
|
| 616 |
|
| 617 |
def _wide(self, state, spec):
|
|
|
|
| 649 |
return {"type": "noul", "noul": p[ids.index("true")]}
|
| 650 |
if row["type"] == "score":
|
| 651 |
levels = question.get("criteria") or []
|
| 652 |
+
levels = [levels[k] for k in J.score_keys(levels)] if isinstance(levels, dict) else list(levels)
|
| 653 |
+
return {"type": "score", "score": sum(i * v for i, v in enumerate(p)), "confidence": score_confidence(p),
|
| 654 |
"legend": {str(i): (levels[i] if i < len(levels) else str(i)) for i in range(len(p))},
|
| 655 |
"probabilities": {str(i): v for i, v in enumerate(p)}}
|
| 656 |
dist = dict(zip(ids, p))
|
| 657 |
best = max(dist, key=dist.get)
|
| 658 |
+
return {"type": "choice", "choice": best, "confidence": choice_confidence(p), "probabilities": dist}
|
| 659 |
|
| 660 |
def _split(self, state, questions):
|
| 661 |
"""-> (answers decided without the model, [(key, rendered row)], tokens spent on wide lists)"""
|
|
|
|
| 670 |
p, n = self._wide(state, q)
|
| 671 |
tokens += n
|
| 672 |
best = max(p, key=p.get)
|
| 673 |
+
answers[k] = {"type": "choice", "choice": best, "confidence": choice_confidence(list(p.values())),
|
| 674 |
+
"probabilities": p}
|
| 675 |
else:
|
| 676 |
rows.append((k, J.from_systemone(state, q)))
|
| 677 |
return answers, rows, tokens
|
| 678 |
|
| 679 |
+
def decide(self, state, questions, images=None):
|
| 680 |
+
"""One request -> (answers, usage), answers in the TypeSafe /v1/systemone format. images: the request's
|
| 681 |
+
evidence images (PIL images, file paths, encoded bytes, base64 strings or data URIs), placed at the "<image>"
|
| 682 |
+
marks of a string state, or before the state."""
|
| 683 |
+
if images is not None and not isinstance(images, (list, tuple)):
|
| 684 |
+
images = [images]
|
| 685 |
+
try:
|
| 686 |
+
if images:
|
| 687 |
+
self._media = self._encode_images(images)
|
| 688 |
+
state = self._media_state(state, len(images))
|
| 689 |
+
answers, rows, tokens = self._split(state, questions)
|
| 690 |
+
if rows:
|
| 691 |
+
probs, n = self._probs([r for _, r in rows])
|
| 692 |
+
tokens += n
|
| 693 |
+
for (k, row), p in zip(rows, probs):
|
| 694 |
+
answers[k] = self._answer(row, p, questions[k])
|
| 695 |
+
finally:
|
| 696 |
+
self._kept = self._media = None
|
| 697 |
return answers, {"input_tokens": tokens, "output_tokens": 0}
|
| 698 |
|
| 699 |
def decide_batch(self, requests):
|
| 700 |
+
"""[(state, questions) or (state, questions, images), ...] -> [answers, ...]. Every question of every text
|
| 701 |
+
request goes through one length-sorted set of padded forward passes (up to max_batch_tokens each), which is
|
| 702 |
+
much faster than calling decide() in a loop when the requests are short; requests with images run one at a time.
|
| 703 |
+
Answers are the same as decide() up to bf16 rounding."""
|
| 704 |
parts, flat = [], []
|
| 705 |
+
for state, questions, *images in requests:
|
| 706 |
+
if images and images[0]:
|
| 707 |
+
parts.append((self.decide(state, questions, images[0])[0], [], questions))
|
| 708 |
+
continue
|
| 709 |
answers, rows, _ = self._split(state, questions)
|
| 710 |
parts.append((answers, rows, questions))
|
| 711 |
flat.extend(r for _, r in rows)
|
|
|
|
| 713 |
try:
|
| 714 |
probs, _ = self._probs(flat) if flat else ([], 0)
|
| 715 |
finally:
|
| 716 |
+
self.graphs, self._kept = graphs, None
|
| 717 |
out, i = [], 0
|
| 718 |
for answers, rows, questions in parts:
|
| 719 |
for k, row in rows:
|
startlux_decision/server.py
CHANGED
|
@@ -2,32 +2,64 @@
|
|
| 2 |
|
| 3 |
python -m startlux_decision.server --model /path/to/StartLux-Decision-4B --port 8090
|
| 4 |
|
| 5 |
-
POST /v1/systemone {"state": ..., "questions": {key: {"type", "instructions", "criteria"}}}
|
| 6 |
-> {"answers": {key: answer}, "usage": {"input_tokens", "output_tokens"}, "model": ...}
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
Requests are served one at a time on one GPU. At start-up one request runs through both the CUDA-graph path and the
|
| 10 |
eager path; if their probabilities differ by more than 0.02 the graphs are dropped.
|
|
|
|
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
import argparse
|
| 13 |
import json
|
| 14 |
import os
|
| 15 |
import threading
|
| 16 |
import time
|
|
|
|
| 17 |
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
| 18 |
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
def main():
|
| 21 |
ap = argparse.ArgumentParser()
|
| 22 |
ap.add_argument("--model", required=True, help="local StartLux-Decision directory")
|
| 23 |
ap.add_argument("--host", default="127.0.0.1")
|
| 24 |
ap.add_argument("--port", type=int, default=8090)
|
| 25 |
-
ap.add_argument("--device", help="cuda or cpu (default: cuda when available)")
|
|
|
|
|
|
|
|
|
|
| 26 |
ap.add_argument("--name", help="model name reported in responses (default: the directory name)")
|
|
|
|
|
|
|
|
|
|
| 27 |
a = ap.parse_args()
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
demo_state = {"ticket": "I was charged twice for order #4411 and the app still shows it as unpaid."}
|
| 32 |
demo_questions = {
|
| 33 |
"team": {"type": "choice", "instructions": "Which team should handle this ticket?",
|
|
@@ -46,6 +78,10 @@ def main():
|
|
| 46 |
engine.graphs = {}
|
| 47 |
lock = threading.Lock()
|
| 48 |
name = a.name or os.path.basename(os.path.normpath(a.model))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
class Handler(BaseHTTPRequestHandler):
|
| 51 |
def _send(self, code, obj):
|
|
@@ -53,42 +89,60 @@ def main():
|
|
| 53 |
self.send_response(code)
|
| 54 |
self.send_header("Content-Type", "application/json")
|
| 55 |
self.send_header("Content-Length", str(len(data)))
|
|
|
|
| 56 |
self.end_headers()
|
| 57 |
self.wfile.write(data)
|
| 58 |
|
|
|
|
|
|
|
|
|
|
| 59 |
def do_GET(self):
|
| 60 |
path = self.path.rstrip("/")
|
| 61 |
if path in ("/health", "/v1/health"):
|
| 62 |
-
return self._send(200, {"status": "ok", "model": name, "fast_kernels": engine.fast_kernels,
|
| 63 |
-
"cuda_graphs": len(engine.graphs)
|
|
|
|
| 64 |
if path == "/v1/models":
|
| 65 |
-
return self._send(200, {"models": [{"name": name, "description": "StartLux-Decision typed decision model"
|
| 66 |
-
|
|
|
|
| 67 |
|
| 68 |
def do_POST(self):
|
| 69 |
if self.path.rstrip("/") != "/v1/systemone":
|
| 70 |
-
return self.
|
| 71 |
try:
|
| 72 |
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)) or b"{}")
|
| 73 |
questions = body.get("questions")
|
| 74 |
if not isinstance(questions, dict) or not questions:
|
| 75 |
raise ValueError("questions must be a non-empty object")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
except ValueError as e:
|
| 77 |
-
return self.
|
| 78 |
t = time.perf_counter()
|
| 79 |
try:
|
| 80 |
with lock:
|
| 81 |
-
answers, usage = engine.decide(body.get("state"), questions)
|
|
|
|
| 82 |
except ValueError as e: # unknown question type, prompt over the context limit
|
| 83 |
-
return self.
|
| 84 |
self._send(200, {"answers": answers, "usage": usage, "model": name,
|
| 85 |
"latency_ms": round(1000 * (time.perf_counter() - t), 2)})
|
| 86 |
|
| 87 |
def log_message(self, *args):
|
| 88 |
pass
|
| 89 |
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
| 92 |
ThreadingHTTPServer((a.host, a.port), Handler).serve_forever()
|
| 93 |
|
| 94 |
|
|
|
|
| 2 |
|
| 3 |
python -m startlux_decision.server --model /path/to/StartLux-Decision-4B --port 8090
|
| 4 |
|
| 5 |
+
POST /v1/systemone {"state": ..., "questions": {key: {"type", "instructions", "criteria"}}, "images": [...]}
|
| 6 |
-> {"answers": {key: answer}, "usage": {"input_tokens", "output_tokens"}, "model": ...}
|
| 7 |
+
"images" is optional: base64 strings or data URIs, part of the evidence ("<image>" in a string state marks where
|
| 8 |
+
each goes, otherwise they come first); the torch backend only.
|
| 9 |
+
GET /health {"status", "model", "backend", "fast_kernels", "cuda_graphs", "images", "max_length"}
|
| 10 |
+
GET /v1/models {"models": [{"name", "description", "release_date"}]}
|
| 11 |
+
Every response carries an x-typesafe-request-id header; errors are {"error": message, "detail": [{"loc", "msg", "type"}]}
|
| 12 |
+
(400 for a malformed body, 422 for a request the model cannot answer). confidence follows TypeSafe's definitions.
|
| 13 |
Requests are served one at a time on one GPU. At start-up one request runs through both the CUDA-graph path and the
|
| 14 |
eager path; if their probabilities differ by more than 0.02 the graphs are dropped.
|
| 15 |
+
|
| 16 |
+
--backend auto (the default) runs the model with MLX on Apple Silicon when mlx-lm is installed (mlx_model.py) and with
|
| 17 |
+
PyTorch everywhere else; --int8 adds int8 matmuls on M5 and later Macs (mlx_int8.py).
|
| 18 |
"""
|
| 19 |
import argparse
|
| 20 |
import json
|
| 21 |
import os
|
| 22 |
import threading
|
| 23 |
import time
|
| 24 |
+
import uuid
|
| 25 |
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
| 26 |
|
| 27 |
|
| 28 |
+
def mlx_available():
|
| 29 |
+
"""True on Apple Silicon with mlx-lm installed (requirements.txt installs it there)."""
|
| 30 |
+
try:
|
| 31 |
+
import mlx.core as mx
|
| 32 |
+
import mlx_lm # noqa: F401
|
| 33 |
+
return mx.metal.is_available()
|
| 34 |
+
except ImportError:
|
| 35 |
+
return False
|
| 36 |
+
|
| 37 |
+
|
| 38 |
def main():
|
| 39 |
ap = argparse.ArgumentParser()
|
| 40 |
ap.add_argument("--model", required=True, help="local StartLux-Decision directory")
|
| 41 |
ap.add_argument("--host", default="127.0.0.1")
|
| 42 |
ap.add_argument("--port", type=int, default=8090)
|
| 43 |
+
ap.add_argument("--device", help="cuda or cpu for the torch backend (default: cuda when available)")
|
| 44 |
+
ap.add_argument("--backend", choices=("auto", "torch", "mlx"), default="auto",
|
| 45 |
+
help="auto: MLX on Apple Silicon when mlx-lm is installed, else torch")
|
| 46 |
+
ap.add_argument("--int8", action="store_true", help="MLX on M5 and later: int8 matmuls on the neural accelerators")
|
| 47 |
ap.add_argument("--name", help="model name reported in responses (default: the directory name)")
|
| 48 |
+
ap.add_argument("--max-length", type=int, default=262144, help="longest prompt in tokens (torch backend)")
|
| 49 |
+
ap.add_argument("--max-pixels", type=int, default=1 << 20, help="pixels per image after resizing (torch backend)")
|
| 50 |
+
ap.add_argument("--no-images", action="store_true", help="leave the vision tower out (torch backend)")
|
| 51 |
a = ap.parse_args()
|
| 52 |
+
backend = a.backend
|
| 53 |
+
if backend == "auto":
|
| 54 |
+
backend = "mlx" if a.device is None and mlx_available() else "torch"
|
| 55 |
+
if backend == "mlx":
|
| 56 |
+
from .mlx_model import MLXDecision
|
| 57 |
+
engine = MLXDecision(a.model, int8=a.int8)
|
| 58 |
+
engine.warm_up()
|
| 59 |
+
else:
|
| 60 |
+
from .model import StartLuxDecision
|
| 61 |
+
engine = StartLuxDecision(a.model, device=a.device, max_length=a.max_length, images=not a.no_images,
|
| 62 |
+
max_pixels=a.max_pixels)
|
| 63 |
demo_state = {"ticket": "I was charged twice for order #4411 and the app still shows it as unpaid."}
|
| 64 |
demo_questions = {
|
| 65 |
"team": {"type": "choice", "instructions": "Which team should handle this ticket?",
|
|
|
|
| 78 |
engine.graphs = {}
|
| 79 |
lock = threading.Lock()
|
| 80 |
name = a.name or os.path.basename(os.path.normpath(a.model))
|
| 81 |
+
try:
|
| 82 |
+
released = json.load(open(os.path.join(a.model, "decision_config.json"))).get("release_date", "2026-10-01")
|
| 83 |
+
except (OSError, ValueError):
|
| 84 |
+
released = "2026-10-01"
|
| 85 |
|
| 86 |
class Handler(BaseHTTPRequestHandler):
|
| 87 |
def _send(self, code, obj):
|
|
|
|
| 89 |
self.send_response(code)
|
| 90 |
self.send_header("Content-Type", "application/json")
|
| 91 |
self.send_header("Content-Length", str(len(data)))
|
| 92 |
+
self.send_header("x-typesafe-request-id", uuid.uuid4().hex)
|
| 93 |
self.end_headers()
|
| 94 |
self.wfile.write(data)
|
| 95 |
|
| 96 |
+
def _error(self, code, message):
|
| 97 |
+
self._send(code, {"error": message, "detail": [{"loc": ["body"], "msg": message, "type": "value_error"}]})
|
| 98 |
+
|
| 99 |
def do_GET(self):
|
| 100 |
path = self.path.rstrip("/")
|
| 101 |
if path in ("/health", "/v1/health"):
|
| 102 |
+
return self._send(200, {"status": "ok", "model": name, "backend": backend, "fast_kernels": engine.fast_kernels,
|
| 103 |
+
"cuda_graphs": len(engine.graphs), "images": getattr(engine, "vision", None) is not None,
|
| 104 |
+
"max_length": getattr(engine, "max_length", None)})
|
| 105 |
if path == "/v1/models":
|
| 106 |
+
return self._send(200, {"models": [{"name": name, "description": "StartLux-Decision typed decision model",
|
| 107 |
+
"release_date": released}]})
|
| 108 |
+
self._error(404, "not found")
|
| 109 |
|
| 110 |
def do_POST(self):
|
| 111 |
if self.path.rstrip("/") != "/v1/systemone":
|
| 112 |
+
return self._error(404, "not found")
|
| 113 |
try:
|
| 114 |
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)) or b"{}")
|
| 115 |
questions = body.get("questions")
|
| 116 |
if not isinstance(questions, dict) or not questions:
|
| 117 |
raise ValueError("questions must be a non-empty object")
|
| 118 |
+
images = body.get("images") or None
|
| 119 |
+
if images is not None:
|
| 120 |
+
if not isinstance(images, list) or not all(isinstance(x, str) for x in images):
|
| 121 |
+
raise ValueError("images must be a list of base64 strings or data URIs")
|
| 122 |
+
if backend != "torch":
|
| 123 |
+
raise ValueError("images need the torch backend")
|
| 124 |
+
from .model import load_image
|
| 125 |
+
images = [load_image(x, paths=False) for x in images]
|
| 126 |
except ValueError as e:
|
| 127 |
+
return self._error(400, str(e))
|
| 128 |
t = time.perf_counter()
|
| 129 |
try:
|
| 130 |
with lock:
|
| 131 |
+
answers, usage = (engine.decide(body.get("state"), questions, images=images) if images else
|
| 132 |
+
engine.decide(body.get("state"), questions))
|
| 133 |
except ValueError as e: # unknown question type, prompt over the context limit
|
| 134 |
+
return self._error(422, str(e))
|
| 135 |
self._send(200, {"answers": answers, "usage": usage, "model": name,
|
| 136 |
"latency_ms": round(1000 * (time.perf_counter() - t), 2)})
|
| 137 |
|
| 138 |
def log_message(self, *args):
|
| 139 |
pass
|
| 140 |
|
| 141 |
+
detail = (f"MLX, int8 projections: {engine.int8}" if backend == "mlx" else
|
| 142 |
+
f"fast kernels: {engine.fast_kernels}, cuda graphs: {len(engine.graphs)}, "
|
| 143 |
+
f"images: {'yes' if engine.vision is not None else 'no, ' + engine.vision_note}, "
|
| 144 |
+
f"max length: {engine.max_length}")
|
| 145 |
+
print(f"{name} serving on http://{a.host}:{a.port}/v1/systemone ({detail})", flush=True)
|
| 146 |
ThreadingHTTPServer((a.host, a.port), Handler).serve_forever()
|
| 147 |
|
| 148 |
|