qmichalwarda commited on
Commit
fd083b9
·
verified ·
1 Parent(s): cc54b02

qev_fast: image input on the fast CUDA engine; qev.py / qev_server.py images; card speed numbers

Browse files
Files changed (8) hide show
  1. README.md +40 -17
  2. SHA256SUMS +7 -6
  3. qev.py +80 -16
  4. qev_fast/graphs.py +60 -4
  5. qev_fast/model.py +22 -2
  6. qev_fast/runner.py +140 -4
  7. qev_fast/vision.py +168 -0
  8. qev_server.py +67 -16
README.md CHANGED
@@ -71,7 +71,7 @@ every move is one forward pass of about 4 ms.
71
  | memory | 1.7 GB weights in bf16, 0.8 GB in 8-bit · **1.3 GB RAM** on a Mac in 8-bit |
72
  | also first on | JevExtra (72.0%, agent and safety skills), JevSpatial (74.2%, games and space), Rules-7 (63.7%, tied) |
73
  | output | calibrated probabilities for `choice`, `noul` (yes / no) and `score` questions, up to 255 options each |
74
- | images | zero-shot image input with `qev_image.py` (`<image:NAME>` in the state, ≤196 tokens per image): 0.759 on JevVision v0 vs 0.651 for its base · [details](#images) |
75
  | formats | PyTorch + fast CUDA engine (this repo), [MLX](https://huggingface.co/labqoat/qev-0.8b-MLX), [GGUF](https://huggingface.co/labqoat/qev-0.8b-GGUF) (also served by stock `llama-server -hf labqoat/qev-0.8b-GGUF:Q8_0` at `/v1/systemone`) |
76
  | license | Apache-2.0 |
77
 
@@ -275,11 +275,24 @@ MPS or CPU.
275
 
276
  ## Images
277
 
278
- qev can also read images. `qev_image.py` runs the same model with the vision tower it inherited from
279
- Qwen3.5-0.8B-Base: put `<image:NAME>` in the state where the image belongs and pass the image under that name. The
280
- prompt layout, the letter readout and the per-type temperatures are the same as in `qev.py`, and so is the answer
281
- format. A text-only request through `qev_image.py` gets the same answers as through `qev.py` (0 top-answer flips on 50
282
- requests, 107 questions).
 
 
 
 
 
 
 
 
 
 
 
 
 
283
 
284
  ```python
285
  from qev_image import QEVImage # qev_image.py in this repo (transformers; needs pillow and torchvision)
@@ -295,11 +308,13 @@ m.decide_batch([(state, questions, images), ...]) # images are optional per r
295
 
296
  An image the state does not mention is appended at the end of the state. Several `<image:...>` markers in one state
297
  work. Each image is resized (aspect kept) to at most `max_pixels`, 448×448 by default, which costs **at most 196
298
- tokens per image** (224×224: 49; 640×640: 400 with `max_pixels=640*640`). `decide_batch(..., usage=True)` reports the
299
- input and image tokens of each request.
 
300
 
301
- **HTTP.** `qev_image_server.py` serves the same System One API as `qev_server.py` (it reuses its request validation
302
- and server), plus an `"images"` field: `{name: base64 PNG or JPEG}`. The answer adds `usage.image_tokens`.
 
303
 
304
  ```sh
305
  python qev_image_server.py --model labqoat/qev-0.8b --port 8018
@@ -309,9 +324,17 @@ curl http://127.0.0.1:8018/v1/systemone -H 'Content-Type: application/json' -d '
309
  "questions": {"error": {"type": "noul", "instructions": "Does the screenshot show an error dialog?"}}}'
310
  ```
311
 
312
- **Speed.** Images go through the plain transformers path; the fast CUDA engine in `qev_fast/` is text-only. On one
313
- L40S at batch 1 that path takes 45 ms for a short text request and about 61–69 ms with one image (224×224 to
314
- 768×768). Over the 1,306 benchmark requests below, in batches of 24, it answered about 42 requests/s.
 
 
 
 
 
 
 
 
315
 
316
  ### JevVision v0 (zero-shot)
317
 
@@ -561,7 +584,7 @@ use of the weights is for the user to assess.
561
  no images in training. Boards are read close to chance (connect-four 0.38, chess 0.60, tic-tac-toe 0.59), counting
562
  real objects is weak (CLEVR 0.40), reading a value off a chart is 0.63, and real-photo yes/no questions are
563
  0.71–0.87. JevVision's rendered images are clean; screenshots and documents from the wild are untested. Image
564
- requests run only on the transformers path (`qev_image.py`), not on the fast engine, MLX or GGUF.
565
  - **Inherited behaviour.** The model inherits biases of the base model and of its data, part of which was labelled
566
  by larger models. Do not use it as the only decision maker for consequential decisions about people (medical,
567
  legal, credit, employment).
@@ -574,15 +597,15 @@ use of the weights is for the user to assess.
574
  | `config.json`, `tokenizer.json`, `tokenizer_config.json`, `vocab.json`, `merges.txt` | the Qwen3.5 config and tokenizer |
575
  | `qev_config.json` | temperatures, the 255 option-letter tokens, the prompt and readout description |
576
  | `qev.py`, `requirements.txt` | the runner (backends: fast on NVIDIA GPUs, transformers elsewhere) |
577
- | `qev_server.py` | an HTTP server with the System One API (`POST /v1/systemone`) |
578
- | `qev_fast/`, `requirements-cuda.txt` | the fast CUDA engine `qev.py` uses by default on NVIDIA GPUs |
579
  | `qev_image.py` | the image runner (`QEVImage`: `<image:NAME>` in the state; transformers) |
580
  | `qev_image_server.py` | the System One HTTP server with an `"images"` field |
581
  | `preprocessor_config.json`, `video_preprocessor_config.json` | the base model's image processor configs, unchanged |
582
  | `SHA256SUMS` | checksums |
583
 
584
  The checkpoint keeps the base's multimodal config and its vision tower and projector, unchanged (bit-identical to
585
- Qwen3.5-0.8B-Base's). `qev.py` does not use them; `qev_image.py` does.
586
 
587
  **Tokenizer fix in this revision.** The model was trained with the Qwen2 pre-tokenizer pattern, which splits words
588
  on letters only. The earlier `tokenizer.json` carried the newer Qwen3.5 pattern, which also groups combining marks.
 
71
  | memory | 1.7 GB weights in bf16, 0.8 GB in 8-bit · **1.3 GB RAM** on a Mac in 8-bit |
72
  | also first on | JevExtra (72.0%, agent and safety skills), JevSpatial (74.2%, games and space), Rules-7 (63.7%, tied) |
73
  | output | calibrated probabilities for `choice`, `noul` (yes / no) and `score` questions, up to 255 options each |
74
+ | images | zero-shot image input (`<image:NAME>` in the state, ≤196 tokens per image) on the fast CUDA engine or with `qev_image.py`: 0.759 on JevVision v0 vs 0.651 for its base · [details](#images) |
75
  | formats | PyTorch + fast CUDA engine (this repo), [MLX](https://huggingface.co/labqoat/qev-0.8b-MLX), [GGUF](https://huggingface.co/labqoat/qev-0.8b-GGUF) (also served by stock `llama-server -hf labqoat/qev-0.8b-GGUF:Q8_0` at `/v1/systemone`) |
76
  | license | Apache-2.0 |
77
 
 
275
 
276
  ## Images
277
 
278
+ qev can also read images, with the vision tower it inherited from Qwen3.5-0.8B-Base: put `<image:NAME>` in the state
279
+ where the image belongs and pass the image under that name. The prompt layout, the letter readout and the per-type
280
+ temperatures are the same as for text, and so is the answer format. On an NVIDIA GPU, `qev.py`'s fast engine
281
+ (`qev_fast/`) takes images directly; `qev_image.py` runs the same model on plain transformers (any device). A text-only
282
+ request through `qev_image.py` gets the same answers as through `qev.py` (0 top-answer flips on 50 requests, 107
283
+ questions).
284
+
285
+ ```python
286
+ from qev import QEV # the fast CUDA engine (requirements-cuda.txt); images need pillow and torchvision
287
+
288
+ m = QEV.from_pretrained("labqoat/qev-0.8b", images=True) # images=True: load the vision tower now (else on first use)
289
+ m.decide("A customer sent this screenshot: <image:shot>",
290
+ {"error": {"type": "noul", "instructions": "Does the screenshot show an error dialog?"}},
291
+ images={"shot": "screenshot.png"}) # a path or a PIL image
292
+ m.decide_batch([(state, questions, images), (state2, questions2)]) # images are optional per request
293
+ ```
294
+
295
+ On other devices (Apple MPS, CPU, CUDA without flash-linear-attention), use `qev_image.py`:
296
 
297
  ```python
298
  from qev_image import QEVImage # qev_image.py in this repo (transformers; needs pillow and torchvision)
 
308
 
309
  An image the state does not mention is appended at the end of the state. Several `<image:...>` markers in one state
310
  work. Each image is resized (aspect kept) to at most `max_pixels`, 448×448 by default, which costs **at most 196
311
+ tokens per image** (224×224: 49; 640×640: 400 with `max_pixels=640*640`; `QEV.from_pretrained(..., max_pixels=...)`
312
+ on the fast engine). `decide_batch(..., usage=True)` reports the input and image tokens of each request (on the fast
313
+ engine, the image tokens are in `last_image_tokens`).
314
 
315
+ **HTTP.** On the fast engine, `qev_server.py` accepts an `"images"` field, `{name: base64 PNG or JPEG}`, and the
316
+ answer adds `usage.image_tokens` (requests without images are unchanged). `qev_image_server.py` serves the same API
317
+ on the transformers path:
318
 
319
  ```sh
320
  python qev_image_server.py --model labqoat/qev-0.8b --port 8018
 
324
  "questions": {"error": {"type": "noul", "instructions": "Does the screenshot show an error dialog?"}}}'
325
  ```
326
 
327
+ **Speed.** On an NVIDIA GPU, `qev.py`'s fast engine runs images itself: the vision tower once per distinct image, the
328
+ image embeddings in the packed forward with 3-D rotary positions, CUDA graphs for small calls. On one L40S at batch 1,
329
+ a request with one image takes about 18–32 ms (224×224: 18 ms, 448×448: 20 ms, 768×768: 32 ms; median of 15), and
330
+ 5–18 ms when the same image comes again (image embeddings are cached). The plain transformers path (`qev_image.py`)
331
+ takes about 61–69 ms. A short text request still takes 3.5 ms. Over the 1,306 benchmark requests below, in batches of
332
+ 24, the fast engine answered about 73 requests/s and transformers about 59 requests/s in the same run (42 in our
333
+ earlier measurement). The first pass in a new process was slower, about 35 requests/s, because GPU kernels are
334
+ compiled once for new batch shapes. The fast engine's answers match `qev_image.py` up to bf16 round-off. On
335
+ JevVision v0 it scores 0.757 pooled accuracy and 0.115 ECE, against 0.758 and 0.113. The largest probability
336
+ difference is 0.05, about the same as between `qev_image.py`'s own batch-1 and batch-24 answers. Text requests are
337
+ unchanged, with the same probabilities bit for bit and the same speed.
338
 
339
  ### JevVision v0 (zero-shot)
340
 
 
584
  no images in training. Boards are read close to chance (connect-four 0.38, chess 0.60, tic-tac-toe 0.59), counting
585
  real objects is weak (CLEVR 0.40), reading a value off a chart is 0.63, and real-photo yes/no questions are
586
  0.71–0.87. JevVision's rendered images are clean; screenshots and documents from the wild are untested. Image
587
+ requests run on the fast CUDA engine (`qev.py`) and the transformers path (`qev_image.py`), not on MLX or GGUF.
588
  - **Inherited behaviour.** The model inherits biases of the base model and of its data, part of which was labelled
589
  by larger models. Do not use it as the only decision maker for consequential decisions about people (medical,
590
  legal, credit, employment).
 
597
  | `config.json`, `tokenizer.json`, `tokenizer_config.json`, `vocab.json`, `merges.txt` | the Qwen3.5 config and tokenizer |
598
  | `qev_config.json` | temperatures, the 255 option-letter tokens, the prompt and readout description |
599
  | `qev.py`, `requirements.txt` | the runner (backends: fast on NVIDIA GPUs, transformers elsewhere) |
600
+ | `qev_server.py` | an HTTP server with the System One API (`POST /v1/systemone`; `"images"` on the fast engine) |
601
+ | `qev_fast/`, `requirements-cuda.txt` | the fast CUDA engine `qev.py` uses by default on NVIDIA GPUs (text and images) |
602
  | `qev_image.py` | the image runner (`QEVImage`: `<image:NAME>` in the state; transformers) |
603
  | `qev_image_server.py` | the System One HTTP server with an `"images"` field |
604
  | `preprocessor_config.json`, `video_preprocessor_config.json` | the base model's image processor configs, unchanged |
605
  | `SHA256SUMS` | checksums |
606
 
607
  The checkpoint keeps the base's multimodal config and its vision tower and projector, unchanged (bit-identical to
608
+ Qwen3.5-0.8B-Base's). `qev.py`'s fast engine and `qev_image.py` use them for images; text requests do not.
609
 
610
  **Tokenizer fix in this revision.** The model was trained with the Qwen2 pre-tokenizer pattern, which splits words
611
  on letters only. The earlier `tokenizer.json` carried the newer Qwen3.5 pattern, which also groups combining marks.
SHA256SUMS CHANGED
@@ -1,4 +1,4 @@
1
- d9c53ac320026041a2f082b6b8bf9c878dabf884c3c3c128d213126075e0c850 README.md
2
  d9ab6f1698a46bbf04895d171fa0666816745e0d7209b9493d44d3df0caa1b5f config.json
3
  aff0dc4549575e81bc423fc4d6ef963eb938f1a0330a141da15fa6211e2c5fe2 figures/accuracy_vs_size.png
4
  95abf758a17186f4a04ce6ae4ebbf499a4bc68c67c4673c00b856ab85ee22567 figures/footprint_speed.png
@@ -11,19 +11,20 @@ f069d8b61331c9e0d845f070954770c4c011c42defb996eb01f7ff559770c724 figures/header
11
  a9d356d7bdf1ef4949e3e748e95b8e10ad9d4e2e838eddc38a0a7b6b94d1db8d merges.txt
12
  d33a719c0eedc34a530879e3840ddf0a0769b5060a987aed24e84e1170b8beb5 model.safetensors
13
  27225450ac9c6529872ee1924fcb0962ff5634834f817040f444118116f4e516 preprocessor_config.json
14
- 9259f1c924126a47f2ebcab3be555daa4ad0b03ea73c5269e7bca565114d910c qev.py
15
  5260608d19e1d02c7a59d7ee0351ab4a5d57cf40154132e3122eb9b147f6adb7 qev_config.json
16
  13de61af9869524aedf9920ea0a0cc0cae4b5f05eceb0d1383e415f3077e7d38 qev_fast/__init__.py
17
  42f113f0e51b4c94fdc60c753e7cde9bf3a45ec3a647cc1e545eaf9381a91052 qev_fast/encoder.py
18
  ed65a44a68aefcc6397068db52274433cd372a2c5fff6b1f7f901ef543429941 qev_fast/engine.py
19
- 60c19edd37fdce2bec875d926d4b75e228948bfb7b997daee3ba8ea0c52269d9 qev_fast/graphs.py
20
- daeab2668863c8e6c4d262b5b7af7017c82184076d5c5318749642289da84d76 qev_fast/model.py
21
  e9a6ef607a94a11f659f4d57fa1588c8b99b5288535ed9fb7db0a144fd817c55 qev_fast/prefix.py
22
- b4f7f3ba357539c1625597c0c6cee7a2b0d2aa37277e5c89f015fc6dc9749ae5 qev_fast/runner.py
23
  9a7e7a159b4b6c9c1755ccbaf6c11b433bac78f8ec67474833e61821d77999ef qev_fast/systemone.py
 
24
  e8d061404d312ca2cfbec5db8d5adbacb9fd2888e20997833f8080ed317e4184 qev_image.py
25
  1cfc5fbdc1b4317bd2df8f2e965ee466f95a33af5db8c949d157f86c93996d4e qev_image_server.py
26
- ae63ac8be0365edb19e677f3e72898c0ddf38df28acc8b8f85efdf2f44fe3ad4 qev_server.py
27
  d7f47d96a76f395a93bd9c04c78e5193d7390c162c86b9ea8ce7010ec78b7ac7 requirements-cuda.txt
28
  b60d5907148b1d4c9191a02b3910c485852af951a529527ebc1b30089939500f requirements.txt
29
  466f27fefa1f44a7bb82cb1b42163c761e01dc6f7f51fc4f3d0a499a5a9b4249 tokenizer.json
 
1
+ 9b2e8ee98eae68faabd10a78ca8930efa2ac735d12a42c1c410ee056742ce07a README.md
2
  d9ab6f1698a46bbf04895d171fa0666816745e0d7209b9493d44d3df0caa1b5f config.json
3
  aff0dc4549575e81bc423fc4d6ef963eb938f1a0330a141da15fa6211e2c5fe2 figures/accuracy_vs_size.png
4
  95abf758a17186f4a04ce6ae4ebbf499a4bc68c67c4673c00b856ab85ee22567 figures/footprint_speed.png
 
11
  a9d356d7bdf1ef4949e3e748e95b8e10ad9d4e2e838eddc38a0a7b6b94d1db8d merges.txt
12
  d33a719c0eedc34a530879e3840ddf0a0769b5060a987aed24e84e1170b8beb5 model.safetensors
13
  27225450ac9c6529872ee1924fcb0962ff5634834f817040f444118116f4e516 preprocessor_config.json
14
+ 080cba4669a1caa43e0f94ff5943c1bc00328770c96b1c394f2e8a6f64ea4798 qev.py
15
  5260608d19e1d02c7a59d7ee0351ab4a5d57cf40154132e3122eb9b147f6adb7 qev_config.json
16
  13de61af9869524aedf9920ea0a0cc0cae4b5f05eceb0d1383e415f3077e7d38 qev_fast/__init__.py
17
  42f113f0e51b4c94fdc60c753e7cde9bf3a45ec3a647cc1e545eaf9381a91052 qev_fast/encoder.py
18
  ed65a44a68aefcc6397068db52274433cd372a2c5fff6b1f7f901ef543429941 qev_fast/engine.py
19
+ 6bf88c5ce2089fbd603eef82680f3ede24acd8a949887e00c37fdb7e775b0479 qev_fast/graphs.py
20
+ 0986f7be0a5049ffdb0ad49b55ab7d84d2f7916b0a4c79d0d1f82c6ce6b265c3 qev_fast/model.py
21
  e9a6ef607a94a11f659f4d57fa1588c8b99b5288535ed9fb7db0a144fd817c55 qev_fast/prefix.py
22
+ c849a299772c7186b5b30feb9db777ef12673271e739051ce0968f6bbc741b27 qev_fast/runner.py
23
  9a7e7a159b4b6c9c1755ccbaf6c11b433bac78f8ec67474833e61821d77999ef qev_fast/systemone.py
24
+ 53c44b0101b889464a78cf7b8c8bfe518765610f39478df1b7a3e8f680cb02e4 qev_fast/vision.py
25
  e8d061404d312ca2cfbec5db8d5adbacb9fd2888e20997833f8080ed317e4184 qev_image.py
26
  1cfc5fbdc1b4317bd2df8f2e965ee466f95a33af5db8c949d157f86c93996d4e qev_image_server.py
27
+ 01239024d6bce903ae16a8bda08a56b6ecbf43d630b9bd590fb1a73e9c11e54c qev_server.py
28
  d7f47d96a76f395a93bd9c04c78e5193d7390c162c86b9ea8ce7010ec78b7ac7 requirements-cuda.txt
29
  b60d5907148b1d4c9191a02b3910c485852af951a529527ebc1b30089939500f requirements.txt
30
  466f27fefa1f44a7bb82cb1b42163c761e01dc6f7f51fc4f3d0a499a5a9b4249 tokenizer.json
qev.py CHANGED
@@ -8,6 +8,11 @@
8
  "urgent": {"type": "noul", "instructions": "Is this urgent?"}})
9
  m.decide_batch([(state, questions), ...])
10
 
 
 
 
 
 
11
  python qev.py --model labqoat/qev-0.8b --state '{"ticket": "..."}' --questions '{"urgent": {"type": "noul", "instructions": "Is this urgent?"}}'
12
 
13
  How a request is answered (no text is generated):
@@ -24,8 +29,13 @@ Backends: "fast" (the default on CUDA when flash-linear-attention is installed:
24
  with CUDA graphs and a shared-prefix per-question path; pip install -r requirements-cuda.txt) or "transformers"
25
  (plain transformers forward; CPU, Apple MPS or CUDA). backend="auto" picks fast when it can.
26
 
 
 
 
 
 
27
  Requires: torch, transformers (Qwen3.5 support), decider-ai==1.9.0 (Apache-2.0; prompt layout and answer format);
28
- for the fast backend also flash-linear-attention (requirements-cuda.txt).
29
  """
30
 
31
  from __future__ import annotations
@@ -196,6 +206,7 @@ class QEV:
196
  self.fast = fast # a qev_fast.FastQEV (backend "fast"), else the transformers forward below
197
  self.backend = "fast" if fast is not None else "transformers"
198
  self.temps = dict(config["temperatures"])
 
199
  if fast is not None:
200
  return
201
  letters = config.get("letter_token_ids") or letter_ids(tok)
@@ -206,11 +217,13 @@ class QEV:
206
  @classmethod
207
  def from_pretrained(cls, path: str = "labqoat/qev-0.8b", device: str | None = None, dtype=None,
208
  config: str | dict | None = None, revision: str | None = None, backend: str = "auto",
209
- precapture: bool = True) -> "QEV":
 
210
  """path: a local directory or a Hugging Face repo id. backend: "auto" (fast on CUDA with
211
  flash-linear-attention, else transformers), "fast" or "transformers". dtype (transformers backend): bf16 on
212
  CUDA / MPS, fp32 on CPU by default. precapture (fast): capture the common CUDA graphs now, the rest in the
213
- background."""
 
214
  import sys
215
 
216
  import torch
@@ -251,8 +264,11 @@ class QEV:
251
  from qev_fast import FastQEV
252
 
253
  fast = FastQEV(path)
 
 
 
254
  if precapture:
255
- fast.precapture(background=True)
256
  return cls(None, fast.tok, cfg, torch.device("cuda"), torch.bfloat16, fast)
257
  tok = use_training_pretokenizer(AutoTokenizer.from_pretrained(path))
258
  model = AutoModelForCausalLM.from_pretrained(path, dtype=dtype).to(dev).eval()
@@ -292,8 +308,11 @@ class QEV:
292
  return [encode(self.tok, state, questions, mo, ms)]
293
 
294
  def _fast_probs(self, requests: list, per_question: bool) -> list:
295
- """Fast backend: [(state, questions)] -> per request [(row encoding, tempered probabilities)]."""
296
  f = self.fast
 
 
 
297
  if per_question:
298
  res = []
299
  for s, q in requests:
@@ -316,50 +335,91 @@ class QEV:
316
  encs.append(e)
317
  return [[(e, tempered_from_slot_probs(e, p, self.temps))] for e, p in zip(encs, f.slot_probs(encs), strict=True)]
318
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  def distributions_batch(self, requests: list, per_question: bool = False) -> list:
320
- """[(state, questions)] -> per request {question id: {option name: probability}} (tempered, unrounded)."""
 
 
321
  if self.fast is not None:
322
  out = []
323
- for rs in self._fast_probs(requests, per_question):
324
  d = {}
325
  for r, p in rs:
326
  d.update(flat(r, p))
327
  out.append(d)
328
  return out
329
  rows, owner = [], []
330
- for i, (s, q) in enumerate(requests):
331
  for r in self._rows(s, q, per_question):
332
  rows.append(r)
333
  owner.append(i)
 
334
  res = [{} for _ in requests]
335
  for i, r, lg in zip(owner, rows, self._logits(rows), strict=True):
336
  res[i].update(flat(r, probabilities(r, lg, self.temps)))
337
  return res
338
 
339
  def decide_batch(self, requests: list, per_question: bool = False, usage: bool = False) -> list:
340
- """[(state, questions)] -> per request {question id: answer} (decider's /v1/systemone answer format).
341
- usage=True: per request (answers, input tokens read)."""
 
 
342
  if self.fast is not None:
343
  out = []
344
- for rs in self._fast_probs(requests, per_question):
345
  d = {}
346
  for r, p in rs:
347
  d.update(answers(r, p))
348
  out.append((d, sum(len(r["ids"]) for r, _ in rs)) if usage else d)
349
  return out
350
  rows, owner = [], []
351
- for i, (s, q) in enumerate(requests):
352
  for r in self._rows(s, q, per_question):
353
  rows.append(r)
354
  owner.append(i)
 
355
  res, toks = [{} for _ in requests], [0] * len(requests)
356
  for i, r, lg in zip(owner, rows, self._logits(rows), strict=True):
357
  res[i].update(answers(r, probabilities(r, lg, self.temps)))
358
  toks[i] += len(r["ids"])
359
  return list(zip(res, toks, strict=True)) if usage else res
360
 
361
- def decide(self, state, questions: dict, per_question: bool = False) -> dict:
362
- return self.decide_batch([(state, questions)], per_question)[0]
 
 
363
 
364
 
365
  def main():
@@ -371,8 +431,11 @@ def main():
371
  ap.add_argument("--per-question", action="store_true", help="one prompt row per question")
372
  ap.add_argument("--device", default=None)
373
  ap.add_argument("--backend", default="auto", choices=["auto", "fast", "transformers"])
 
 
374
  a = ap.parse_args()
375
- m = QEV.from_pretrained(a.model, device=a.device, backend=a.backend)
 
376
  if a.requests:
377
  reqs = [json.loads(line) for line in open(a.requests) if line.strip()]
378
  for r in m.decide_batch([(r["state"], r["questions"]) for r in reqs], a.per_question):
@@ -384,7 +447,8 @@ def main():
384
  state = json.loads(a.state)
385
  except json.JSONDecodeError:
386
  state = a.state
387
- print(json.dumps(m.decide(state, json.loads(a.questions), a.per_question), indent=1, ensure_ascii=False))
 
388
 
389
 
390
  if __name__ == "__main__":
 
8
  "urgent": {"type": "noul", "instructions": "Is this urgent?"}})
9
  m.decide_batch([(state, questions), ...])
10
 
11
+ # images (fast CUDA backend; pillow + torchvision): "<image:NAME>" in the state, or appended at the end
12
+ m.decide("Customer screenshot: <image:shot>", {"err": {"type": "noul", "instructions": "Is an error shown?"}},
13
+ images={"shot": "screenshot.png"}) # PIL image or path
14
+ m.decide_batch([(state, questions, {"shot": img}), (state2, questions2)])
15
+
16
  python qev.py --model labqoat/qev-0.8b --state '{"ticket": "..."}' --questions '{"urgent": {"type": "noul", "instructions": "Is this urgent?"}}'
17
 
18
  How a request is answered (no text is generated):
 
29
  with CUDA graphs and a shared-prefix per-question path; pip install -r requirements-cuda.txt) or "transformers"
30
  (plain transformers forward; CPU, Apple MPS or CUDA). backend="auto" picks fast when it can.
31
 
32
+ Images: the fast backend reads images as qev_image.QEVImage does (same resize to at most max_pixels, default 448*448
33
+ = 196 image tokens, same placeholders and prompt layout, same readout and temperatures): the checkpoint's vision tower
34
+ once per distinct image, its embeddings at the image placeholders, 3-D M-RoPE positions. The transformers backend of
35
+ this file is text-only (use qev_image.py there).
36
+
37
  Requires: torch, transformers (Qwen3.5 support), decider-ai==1.9.0 (Apache-2.0; prompt layout and answer format);
38
+ for the fast backend also flash-linear-attention (requirements-cuda.txt); for images pillow and torchvision.
39
  """
40
 
41
  from __future__ import annotations
 
206
  self.fast = fast # a qev_fast.FastQEV (backend "fast"), else the transformers forward below
207
  self.backend = "fast" if fast is not None else "transformers"
208
  self.temps = dict(config["temperatures"])
209
+ self.last_image_tokens: list = [] # per request of the last decide_batch / distributions_batch call
210
  if fast is not None:
211
  return
212
  letters = config.get("letter_token_ids") or letter_ids(tok)
 
217
  @classmethod
218
  def from_pretrained(cls, path: str = "labqoat/qev-0.8b", device: str | None = None, dtype=None,
219
  config: str | dict | None = None, revision: str | None = None, backend: str = "auto",
220
+ precapture: bool = True, images: bool = False, max_pixels: int = 448 * 448,
221
+ min_pixels: int = 64 * 64) -> "QEV":
222
  """path: a local directory or a Hugging Face repo id. backend: "auto" (fast on CUDA with
223
  flash-linear-attention, else transformers), "fast" or "transformers". dtype (transformers backend): bf16 on
224
  CUDA / MPS, fp32 on CPU by default. precapture (fast): capture the common CUDA graphs now, the rest in the
225
+ background. images (fast): load the vision tower now (else on the first request with images) and capture
226
+ the image graphs with the others; max_pixels / min_pixels bound the resized image (as qev_image.py)."""
227
  import sys
228
 
229
  import torch
 
264
  from qev_fast import FastQEV
265
 
266
  fast = FastQEV(path)
267
+ fast.max_pixels, fast.min_pixels = max_pixels, min_pixels
268
+ if images:
269
+ fast.enable_images(max_pixels, min_pixels)
270
  if precapture:
271
+ fast.precapture(background=True, images=images)
272
  return cls(None, fast.tok, cfg, torch.device("cuda"), torch.bfloat16, fast)
273
  tok = use_training_pretokenizer(AutoTokenizer.from_pretrained(path))
274
  model = AutoModelForCausalLM.from_pretrained(path, dtype=dtype).to(dev).eval()
 
308
  return [encode(self.tok, state, questions, mo, ms)]
309
 
310
  def _fast_probs(self, requests: list, per_question: bool) -> list:
311
+ """Fast backend: [(state, questions[, images])] -> per request [(row encoding, tempered probabilities)]."""
312
  f = self.fast
313
+ if any(len(r) > 2 and r[2] for r in requests):
314
+ return self._fast_image_probs(requests, per_question)
315
+ requests = [tuple(r[:2]) for r in requests]
316
  if per_question:
317
  res = []
318
  for s, q in requests:
 
335
  encs.append(e)
336
  return [[(e, tempered_from_slot_probs(e, p, self.temps))] for e, p in zip(encs, f.slot_probs(encs), strict=True)]
337
 
338
+ def _fast_image_probs(self, requests: list, per_question: bool) -> list:
339
+ """Fast backend, a call with images: rows from FastQEV.encode_requests (image placeholders expanded)."""
340
+ f = self.fast
341
+ for r in requests:
342
+ if not isinstance(r[1], dict) or not r[1]:
343
+ raise ValueError("questions: a non-empty {id: question} object")
344
+ if per_question:
345
+ res = []
346
+ for r in requests:
347
+ s, q, imgs = r[0], r[1], (r[2] if len(r) > 2 else None)
348
+ rows = []
349
+ for e in f.encode_requests([(s, {k: v}, imgs) for k, v in q.items()] if len(q) > 1 else [(s, q, imgs)]):
350
+ if isinstance(e, Exception):
351
+ raise e
352
+ rows.append(e)
353
+ probs = f.per_question_probs(rows) if len(rows) > 1 else f.slot_probs(rows)
354
+ res.append([(r_, tempered_from_slot_probs(r_, p, self.temps)) for r_, p in zip(rows, probs, strict=True)])
355
+ return res
356
+ encs = f.encode_requests(requests)
357
+ for e in encs:
358
+ if isinstance(e, Exception):
359
+ raise e
360
+ return [[(e, tempered_from_slot_probs(e, p, self.temps))] for e, p in zip(encs, f.slot_probs(encs), strict=True)]
361
+
362
+ def _check_images(self, requests: list):
363
+ if self.fast is None and any(len(r) > 2 and r[2] for r in requests):
364
+ raise ValueError("images need the fast CUDA backend of qev.py; on other devices use qev_image.py")
365
+
366
+ def _fast_results(self, requests: list, per_question: bool) -> list:
367
+ res = self._fast_probs(requests, per_question)
368
+ self.last_image_tokens = [sum(r.get("image_tokens", 0) for r, _ in rs) for rs in res]
369
+ return res
370
+
371
  def distributions_batch(self, requests: list, per_question: bool = False) -> list:
372
+ """[(state, questions[, images])] -> per request {question id: {option name: probability}} (tempered,
373
+ unrounded). images: {name: PIL image or path} (fast backend)."""
374
+ self._check_images(requests)
375
  if self.fast is not None:
376
  out = []
377
+ for rs in self._fast_results(requests, per_question):
378
  d = {}
379
  for r, p in rs:
380
  d.update(flat(r, p))
381
  out.append(d)
382
  return out
383
  rows, owner = [], []
384
+ for i, (s, q, *_) in enumerate(requests):
385
  for r in self._rows(s, q, per_question):
386
  rows.append(r)
387
  owner.append(i)
388
+ self.last_image_tokens = [0] * len(requests)
389
  res = [{} for _ in requests]
390
  for i, r, lg in zip(owner, rows, self._logits(rows), strict=True):
391
  res[i].update(flat(r, probabilities(r, lg, self.temps)))
392
  return res
393
 
394
  def decide_batch(self, requests: list, per_question: bool = False, usage: bool = False) -> list:
395
+ """[(state, questions[, images])] -> per request {question id: answer} (decider's /v1/systemone answer
396
+ format). images: {name: PIL image or path} (fast backend). usage=True: per request (answers, input tokens
397
+ read, image tokens included); the image tokens per request are in last_image_tokens."""
398
+ self._check_images(requests)
399
  if self.fast is not None:
400
  out = []
401
+ for rs in self._fast_results(requests, per_question):
402
  d = {}
403
  for r, p in rs:
404
  d.update(answers(r, p))
405
  out.append((d, sum(len(r["ids"]) for r, _ in rs)) if usage else d)
406
  return out
407
  rows, owner = [], []
408
+ for i, (s, q, *_) in enumerate(requests):
409
  for r in self._rows(s, q, per_question):
410
  rows.append(r)
411
  owner.append(i)
412
+ self.last_image_tokens = [0] * len(requests)
413
  res, toks = [{} for _ in requests], [0] * len(requests)
414
  for i, r, lg in zip(owner, rows, self._logits(rows), strict=True):
415
  res[i].update(answers(r, probabilities(r, lg, self.temps)))
416
  toks[i] += len(r["ids"])
417
  return list(zip(res, toks, strict=True)) if usage else res
418
 
419
+ def decide(self, state, questions: dict, per_question: bool = False, images: dict | None = None) -> dict:
420
+ """images: {name: PIL image or path} (fast backend); "<image:NAME>" in the state places one, the others go at
421
+ the end of the context."""
422
+ return self.decide_batch([(state, questions, images) if images else (state, questions)], per_question)[0]
423
 
424
 
425
  def main():
 
431
  ap.add_argument("--per-question", action="store_true", help="one prompt row per question")
432
  ap.add_argument("--device", default=None)
433
  ap.add_argument("--backend", default="auto", choices=["auto", "fast", "transformers"])
434
+ ap.add_argument("--image", action="append", default=[], help="NAME=PATH (repeatable; fast backend)")
435
+ ap.add_argument("--max-pixels", type=int, default=448 * 448)
436
  a = ap.parse_args()
437
+ m = QEV.from_pretrained(a.model, device=a.device, backend=a.backend, max_pixels=a.max_pixels)
438
+ imgs = dict(x.split("=", 1) for x in a.image) or None
439
  if a.requests:
440
  reqs = [json.loads(line) for line in open(a.requests) if line.strip()]
441
  for r in m.decide_batch([(r["state"], r["questions"]) for r in reqs], a.per_question):
 
447
  state = json.loads(a.state)
448
  except json.JSONDecodeError:
449
  state = a.state
450
+ print(json.dumps(m.decide(state, json.loads(a.questions), a.per_question, images=imgs), indent=1,
451
+ ensure_ascii=False))
452
 
453
 
454
  if __name__ == "__main__":
qev_fast/graphs.py CHANGED
@@ -17,6 +17,10 @@ captured on first use, replays the whole forward:
17
  - the letter logits at the answer slots, masked to each slot's options, softmax -> probabilities [rows * max_slots, L].
18
 
19
  Rows longer than the largest bucket, more rows or more slots than the limits: the caller's eager path.
 
 
 
 
20
  """
21
 
22
  import time
@@ -60,6 +64,7 @@ class GraphSociety:
60
  "uncertain": spec.get("k", 0)}[spec["kind"]] if self.E else 0
61
  self.graphs: dict = {}
62
  self.max_tokens = MAX_GRAPH_TOKENS
 
63
  self.pool = torch.cuda.graph_pool_handle()
64
  self.stats = {"graph": 0, "captures": 0, "capture_s": 0.0, "capture_failed": []}
65
 
@@ -128,13 +133,18 @@ class GraphSociety:
128
  def _forward(self, st):
129
  fs, fm = self.fs, self.fs.fm
130
  n, T, b = st["n"], st["T"], st["b"]
131
- rope = M.rope_tables(b.pos, fm.inv_freq, fm.embed.weight.dtype)
 
 
 
132
  fm.m_tok = None
133
  fm.lora_gather = None
134
  if st.get("h6") is not None: # the hidden state at router_layer given (prefix-shared per-question rows)
135
  h = st["h6"]
136
  else:
137
  h = fm.embed(b.ids)
 
 
138
  for i in range(fs.router_layer):
139
  h = fm.layer(i, h, b, rope)
140
  mask = st["mask"] # [n, T] float32, 1 on real tokens
@@ -167,7 +177,15 @@ class GraphSociety:
167
  float("-inf"))
168
  return torch.softmax(logits, -1)
169
 
170
- def _capture(self, n, T, upper: bool = False):
 
 
 
 
 
 
 
 
171
  t0 = time.perf_counter()
172
  b = Batch([[self.pad_id] * T for _ in range(n)], [[0] * self.max_slots for _ in range(n)], "cuda")
173
  if n > 1: # equal-length rows as a real batch: no cu_seqlens (fla's varlen kernels are not graph-safe)
@@ -176,6 +194,9 @@ class GraphSociety:
176
  st = {"n": n, "T": T, "b": b, "mask": torch.ones(n, T, device=dev), "count": torch.full((n,), float(T), device=dev),
177
  "nopts": torch.ones(n * self.max_slots, dtype=torch.long, device=dev),
178
  "slot_row": torch.arange(n, device=dev).repeat_interleave(self.max_slots)}
 
 
 
179
  if upper:
180
  st["h6"] = torch.zeros(n * T, self.fs.fm.embed.weight.shape[1], device=dev, dtype=self.fs.fm.embed.weight.dtype)
181
  s = torch.cuda.Stream()
@@ -215,6 +236,21 @@ class GraphSociety:
215
  "gpu_reserved_gb": round(torch.cuda.memory_reserved() / 1e9, 2),
216
  "failed": self.stats["capture_failed"][:3]}
217
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
  def warm(self, shapes):
219
  for n, T in shapes:
220
  key = (self._bucket(n, self.row_buckets), self._bucket(T, self.len_buckets))
@@ -282,9 +318,10 @@ class GraphSociety:
282
  """Society-style encodings (eligible) -> per row its slot probabilities (list of [nopts] lists)."""
283
  n = self._bucket(len(encs), self.row_buckets)
284
  T = self._bucket(max(len(e[0]["ids"]) for e in encs), self.len_buckets)
285
- key = (n, T)
 
286
  if key not in self.graphs:
287
- self.graphs[key] = self._capture(n, T)
288
  hit = self.graphs[key]
289
  if hit is None:
290
  return None # not capturable: the caller's eager path
@@ -310,6 +347,25 @@ class GraphSociety:
310
  st["mask"].copy_(mask, non_blocking=True)
311
  st["count"].copy_(count, non_blocking=True)
312
  st["nopts"].copy_(nopts.view(-1), non_blocking=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  g.replay()
314
  self.stats["graph"] += 1
315
  p = out.view(n, S, -1)[: len(encs)].cpu()
 
17
  - the letter logits at the answer slots, masked to each slot's options, softmax -> probabilities [rows * max_slots, L].
18
 
19
  Rows longer than the largest bucket, more rows or more slots than the limits: the caller's eager path.
20
+
21
+ Image rows (encodings with "img_emb", "img_rows", "pos3"; qev_fast.runner) use their own graphs, keyed ("i", rows,
22
+ length): the token embeddings of the image rows replaced from a static embedding buffer and the rotary tables built
23
+ from static 3-D M-RoPE positions. The text graphs are not changed by them.
24
  """
25
 
26
  import time
 
64
  "uncertain": spec.get("k", 0)}[spec["kind"]] if self.E else 0
65
  self.graphs: dict = {}
66
  self.max_tokens = MAX_GRAPH_TOKENS
67
+ self.img_buf = None # (embeddings [max_tokens, H], mask [max_tokens, 1], pos3 [3, max_tokens]): image graphs
68
  self.pool = torch.cuda.graph_pool_handle()
69
  self.stats = {"graph": 0, "captures": 0, "capture_s": 0.0, "capture_failed": []}
70
 
 
133
  def _forward(self, st):
134
  fs, fm = self.fs, self.fs.fm
135
  n, T, b = st["n"], st["T"], st["b"]
136
+ if "pos3" in st: # image rows: 3-D positions
137
+ rope = M.mrope_tables(st["pos3"], fm.inv_freq, fm.embed.weight.dtype, fm.mrope_section)
138
+ else:
139
+ rope = M.rope_tables(b.pos, fm.inv_freq, fm.embed.weight.dtype)
140
  fm.m_tok = None
141
  fm.lora_gather = None
142
  if st.get("h6") is not None: # the hidden state at router_layer given (prefix-shared per-question rows)
143
  h = st["h6"]
144
  else:
145
  h = fm.embed(b.ids)
146
+ if "img_mask" in st:
147
+ h = torch.where(st["img_mask"], st["img_emb"], h)
148
  for i in range(fs.router_layer):
149
  h = fm.layer(i, h, b, rope)
150
  mask = st["mask"] # [n, T] float32, 1 on real tokens
 
177
  float("-inf"))
178
  return torch.softmax(logits, -1)
179
 
180
+ def _image_buffers(self):
181
+ if self.img_buf is None:
182
+ w = self.fs.fm.embed.weight
183
+ self.img_buf = (torch.zeros(self.max_tokens, w.shape[1], device=w.device, dtype=w.dtype),
184
+ torch.zeros(self.max_tokens, 1, device=w.device, dtype=torch.bool),
185
+ torch.zeros(3, self.max_tokens, device=w.device, dtype=torch.long))
186
+ return self.img_buf
187
+
188
+ def _capture(self, n, T, upper: bool = False, image: bool = False):
189
  t0 = time.perf_counter()
190
  b = Batch([[self.pad_id] * T for _ in range(n)], [[0] * self.max_slots for _ in range(n)], "cuda")
191
  if n > 1: # equal-length rows as a real batch: no cu_seqlens (fla's varlen kernels are not graph-safe)
 
194
  st = {"n": n, "T": T, "b": b, "mask": torch.ones(n, T, device=dev), "count": torch.full((n,), float(T), device=dev),
195
  "nopts": torch.ones(n * self.max_slots, dtype=torch.long, device=dev),
196
  "slot_row": torch.arange(n, device=dev).repeat_interleave(self.max_slots)}
197
+ if image: # views of the shared image buffers (static addresses)
198
+ emb, msk, p3 = self._image_buffers()
199
+ st["img_emb"], st["img_mask"], st["pos3"] = emb[: n * T], msk[: n * T], p3[:, : n * T]
200
  if upper:
201
  st["h6"] = torch.zeros(n * T, self.fs.fm.embed.weight.shape[1], device=dev, dtype=self.fs.fm.embed.weight.dtype)
202
  s = torch.cuda.Stream()
 
236
  "gpu_reserved_gb": round(torch.cuda.memory_reserved() / 1e9, 2),
237
  "failed": self.stats["capture_failed"][:3]}
238
 
239
+ def precapture_images(self, max_rows: int = 4, lengths=(192, 256, 320, 384, 448, 512, 640, 768, 1024),
240
+ lock=None) -> dict:
241
+ """Capture the image graph buckets (rows <= max_rows, these length buckets)."""
242
+ import contextlib
243
+
244
+ t0 = time.perf_counter()
245
+ for T in lengths:
246
+ for n in self.row_buckets:
247
+ if n <= max_rows and n * T <= self.max_tokens and ("i", n, T) not in self.graphs:
248
+ with lock if lock is not None else contextlib.nullcontext():
249
+ self.graphs[("i", n, T)] = self._capture(n, T, image=True)
250
+ torch.cuda.synchronize()
251
+ return {"image_graphs": sum(1 for k, g in self.graphs.items() if k[0] == "i" and g is not None),
252
+ "s": round(time.perf_counter() - t0, 1)}
253
+
254
  def warm(self, shapes):
255
  for n, T in shapes:
256
  key = (self._bucket(n, self.row_buckets), self._bucket(T, self.len_buckets))
 
318
  """Society-style encodings (eligible) -> per row its slot probabilities (list of [nopts] lists)."""
319
  n = self._bucket(len(encs), self.row_buckets)
320
  T = self._bucket(max(len(e[0]["ids"]) for e in encs), self.len_buckets)
321
+ image = any("pos3" in e[0] for e in encs)
322
+ key = ("i", n, T) if image else (n, T)
323
  if key not in self.graphs:
324
+ self.graphs[key] = self._capture(n, T, image=image)
325
  hit = self.graphs[key]
326
  if hit is None:
327
  return None # not capturable: the caller's eager path
 
347
  st["mask"].copy_(mask, non_blocking=True)
348
  st["count"].copy_(count, non_blocking=True)
349
  st["nopts"].copy_(nopts.view(-1), non_blocking=True)
350
+ if image:
351
+ import numpy as np
352
+
353
+ pos3 = torch.zeros(3, n, T, dtype=torch.long)
354
+ rows, embs = [], []
355
+ for j, e in enumerate(encs):
356
+ it = e[0]
357
+ L = len(it["ids"])
358
+ if "pos3" in it:
359
+ pos3[:, j, :L] = torch.from_numpy(it["pos3"])
360
+ rows.append(j * T + it["img_rows"])
361
+ embs.append(it["img_emb"])
362
+ else:
363
+ pos3[:, j, :L] = torch.arange(L)
364
+ st["pos3"].copy_(pos3.view(3, -1), non_blocking=True)
365
+ idx = torch.from_numpy(np.concatenate(rows)).to(st["img_mask"].device, non_blocking=True)
366
+ st["img_mask"].zero_()
367
+ st["img_mask"].index_fill_(0, idx, True)
368
+ st["img_emb"].index_copy_(0, idx, torch.cat(embs).to(st["img_emb"].dtype))
369
  g.replay()
370
  self.stats["graph"] += 1
371
  p = out.view(n, S, -1)[: len(encs)].cpu()
qev_fast/model.py CHANGED
@@ -56,6 +56,18 @@ def rope_tables(pos, inv_freq, dtype):
56
  return emb.cos().to(dtype), emb.sin().to(dtype)
57
 
58
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  def apply_rope(x, cos, sin):
60
  """x [T, H, D] with partial rotary on the first cos.shape[-1] dims (apply_rotary_pos_emb)."""
61
  r = cos.shape[-1]
@@ -113,7 +125,9 @@ def attend(q, k, v, cu, cu_list, max_len: int, scale: float, backend: str):
113
  # ---- the model -------------------------------------------------------------------------------------------------------
114
  class Batch:
115
  """A packed forward's inputs on the device: ids [T], cu [N+1] int32 (None for one sequence), pos [T], slots [S]
116
- (packed row of every answer slot), cu_cpu (host copy of cu), max_len."""
 
 
117
 
118
  def __init__(self, seqs: list, slots: list, device, pad_to: int | None = None, pad_id: int = 0):
119
  """seqs: token id lists; slots: per sequence, slot offsets in it. pad_to: one sequence right-padded to this
@@ -163,6 +177,7 @@ class FastQwen35(nn.Module):
163
  self.register_buffer("letters", torch.as_tensor(letters, dtype=torch.long, device=self.embed.weight.device),
164
  persistent=False)
165
  self.inv_freq = core.rotary_emb.inv_freq
 
166
  self.L = []
167
  for i, layer in enumerate(core.layers):
168
  d = {"in_norm": layer.input_layernorm.weight, "post_norm": layer.post_attention_layernorm.weight,
@@ -280,7 +295,12 @@ class FastQwen35(nn.Module):
280
  """-> final-norm hidden states at the slots [S, H]. ckpt: "none", "full" (recompute every layer in backward)
281
  or "attn" (recompute only the full-attention layers: the delta-net layers keep their activations)."""
282
  h = self.embed(b.ids)
283
- rope = rope_tables(b.pos, self.inv_freq, h.dtype)
 
 
 
 
 
284
  n = len(self.L)
285
  for i in range(n):
286
  rows = b.slots if i == n - 1 else None
 
56
  return emb.cos().to(dtype), emb.sin().to(dtype)
57
 
58
 
59
+ def mrope_tables(pos3, inv_freq, dtype, section):
60
+ """cos / sin [T, rot] of 3-D positions pos3 [3, T] (t, h, w): Qwen3.5's interleaved M-RoPE (frequency j takes the
61
+ h position for j = 1, 4, ... < 3 section[1], the w position for j = 2, 5, ... < 3 section[2], else t; the
62
+ transformers apply_interleaved_mrope). Equal rows give exactly rope_tables(pos3[0])."""
63
+ f = pos3.float()[:, :, None] * inv_freq.float()[None, None] # [3, T, rot / 2]
64
+ out = f[0].clone()
65
+ out[:, 1: 3 * section[1]: 3] = f[1][:, 1: 3 * section[1]: 3]
66
+ out[:, 2: 3 * section[2]: 3] = f[2][:, 2: 3 * section[2]: 3]
67
+ emb = torch.cat([out, out], -1)
68
+ return emb.cos().to(dtype), emb.sin().to(dtype)
69
+
70
+
71
  def apply_rope(x, cos, sin):
72
  """x [T, H, D] with partial rotary on the first cos.shape[-1] dims (apply_rotary_pos_emb)."""
73
  r = cos.shape[-1]
 
125
  # ---- the model -------------------------------------------------------------------------------------------------------
126
  class Batch:
127
  """A packed forward's inputs on the device: ids [T], cu [N+1] int32 (None for one sequence), pos [T], slots [S]
128
+ (packed row of every answer slot), cu_cpu (host copy of cu), max_len. Image rows (optional attributes, set by the
129
+ caller): img = (rows [R] long, embeddings [R, H]) replaces the token embeddings of those rows; pos3 [3, T] are the
130
+ M-RoPE positions (text-only batches have neither and run exactly as before)."""
131
 
132
  def __init__(self, seqs: list, slots: list, device, pad_to: int | None = None, pad_id: int = 0):
133
  """seqs: token id lists; slots: per sequence, slot offsets in it. pad_to: one sequence right-padded to this
 
177
  self.register_buffer("letters", torch.as_tensor(letters, dtype=torch.long, device=self.embed.weight.device),
178
  persistent=False)
179
  self.inv_freq = core.rotary_emb.inv_freq
180
+ self.mrope_section = list(getattr(core.rotary_emb, "mrope_section", None) or [11, 11, 10])
181
  self.L = []
182
  for i, layer in enumerate(core.layers):
183
  d = {"in_norm": layer.input_layernorm.weight, "post_norm": layer.post_attention_layernorm.weight,
 
295
  """-> final-norm hidden states at the slots [S, H]. ckpt: "none", "full" (recompute every layer in backward)
296
  or "attn" (recompute only the full-attention layers: the delta-net layers keep their activations)."""
297
  h = self.embed(b.ids)
298
+ img = getattr(b, "img", None)
299
+ if img is not None: # image rows: the vision tower's embeddings instead of the placeholder token's
300
+ h = h.index_copy(0, img[0], img[1].to(h.dtype))
301
+ pos3 = getattr(b, "pos3", None)
302
+ rope = (rope_tables(b.pos, self.inv_freq, h.dtype) if pos3 is None
303
+ else mrope_tables(pos3, self.inv_freq, h.dtype, self.mrope_section))
304
  n = len(self.L)
305
  for i in range(n):
306
  rows = b.slots if i == n - 1 else None
qev_fast/runner.py CHANGED
@@ -7,6 +7,12 @@
7
  state;
8
  - precapture(): the common graph buckets now, the rest in a background thread under the GPU lock.
9
 
 
 
 
 
 
 
10
  Outputs are each slot's option probabilities (softmax over its options, untempered); qev.py renormalises over the
11
  named options, tempers per type and formats the answers.
12
  """
@@ -45,6 +51,8 @@ def _softmax(xs):
45
  class FastQEV:
46
  def __init__(self, path: str, attn: str | None = None, graphs: bool = True, max_batch_tokens: int = 32768):
47
  t = time.perf_counter()
 
 
48
  torch.backends.cuda.enable_cudnn_sdp(False)
49
  lm, self.tok, letters = E.load_a1(path, None)
50
  self.attn = attn or _attn_backend()
@@ -60,15 +68,18 @@ class FastQEV:
60
  self.load_s = time.perf_counter() - t
61
 
62
  # ---- warm-up ---------------------------------------------------------------------------------------------------
63
- def precapture(self, background: bool = True, per_question: bool = False) -> dict:
64
  """Capture the common graph buckets (<= 4 rows, <= 2048 tokens) now; the rest in a background thread (or now,
65
- background=False). per_question: the prefix / suffix graphs too."""
 
66
  if self.graphs is None:
67
  return {}
68
  with self.lock:
69
  info = self.graphs.precapture(max_rows=4, max_len=2048)
70
 
71
  def rest():
 
 
72
  self.graphs.precapture(lock=self.lock)
73
  if per_question:
74
  with self.lock:
@@ -80,9 +91,134 @@ class FastQEV:
80
  rest()
81
  return info
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  # ---- slot probabilities -------------------------------------------------------------------------------------------
84
  def slot_probs(self, encs: list) -> list:
85
- """Prompt rows (encoder dicts) -> per row, per slot: softmax over the slot's options (untempered)."""
 
 
 
 
 
 
 
 
 
 
 
86
  out = [None] * len(encs)
87
  with self.lock, torch.no_grad():
88
  if encs and self.graphs is not None and len(encs) <= self.graphs.row_buckets[-1]:
@@ -112,7 +248,7 @@ class FastQEV:
112
  def per_question_probs(self, rows: list) -> list:
113
  """The per-question rows of one request: their longest common prefix computed once (exact up to bf16
114
  round-off), else as an ordinary batch."""
115
- if len(rows) > 1:
116
  m = min(len(e["ids"]) for e in rows)
117
  first = rows[0]["ids"]
118
  lcp = 0
 
7
  state;
8
  - precapture(): the common graph buckets now, the rest in a background thread under the GPU lock.
9
 
10
+ - images (enable_images(), qev_fast.vision): "<image:NAME>" placeholders expanded as qev_image.QEVImage does, the
11
+ checkpoint's vision tower run once per distinct image of a call, its embeddings scattered into the image rows and
12
+ 3-D M-RoPE positions; image rows run on their own CUDA graphs (a separate graph family with an embedding buffer, so
13
+ the text graphs and the text path are unchanged) or packed eagerly; per_question rows with images run as an
14
+ ordinary batch (no prefix sharing).
15
+
16
  Outputs are each slot's option probabilities (softmax over its options, untempered); qev.py renormalises over the
17
  named options, tempers per type and formats the answers.
18
  """
 
51
  class FastQEV:
52
  def __init__(self, path: str, attn: str | None = None, graphs: bool = True, max_batch_tokens: int = 32768):
53
  t = time.perf_counter()
54
+ self.path = path
55
+ self.vision = None # enable_images() loads the vision tower
56
  torch.backends.cuda.enable_cudnn_sdp(False)
57
  lm, self.tok, letters = E.load_a1(path, None)
58
  self.attn = attn or _attn_backend()
 
68
  self.load_s = time.perf_counter() - t
69
 
70
  # ---- warm-up ---------------------------------------------------------------------------------------------------
71
+ def precapture(self, background: bool = True, per_question: bool = False, images: bool = False) -> dict:
72
  """Capture the common graph buckets (<= 4 rows, <= 2048 tokens) now; the rest in a background thread (or now,
73
+ background=False). per_question: the prefix / suffix graphs too. images: the image graphs (<= 4 rows, 192 to
74
+ 1024 tokens) too (after enable_images())."""
75
  if self.graphs is None:
76
  return {}
77
  with self.lock:
78
  info = self.graphs.precapture(max_rows=4, max_len=2048)
79
 
80
  def rest():
81
+ if images and self.vision is not None:
82
+ self.graphs.precapture_images(lock=self.lock)
83
  self.graphs.precapture(lock=self.lock)
84
  if per_question:
85
  with self.lock:
 
91
  rest()
92
  return info
93
 
94
+ # ---- images -------------------------------------------------------------------------------------------------
95
+ def enable_images(self, max_pixels: int = 448 * 448, min_pixels: int = 64 * 64):
96
+ """Load the vision tower (once; needs pillow, torchvision and the checkpoint's preprocessor_config.json)."""
97
+ if self.vision is None:
98
+ from .vision import Vision
99
+
100
+ with self.lock:
101
+ self.vision = Vision(self.path)
102
+ self.marks = self.vision.tokens(self.tok)
103
+ self.max_pixels, self.min_pixels = max_pixels, min_pixels
104
+ return self
105
+
106
+ def encode_requests(self, requests: list) -> list:
107
+ """[(state, questions[, images])] -> per request its prompt row (encoder dict; with images also "images":
108
+ the Prepped images in placeholder order, "image_tokens") or an exception instance."""
109
+ from decider.systemone import render_state
110
+
111
+ plain, pre, errs = [], {}, {}
112
+ for i, r in enumerate(requests):
113
+ s, q, *rest = r
114
+ imgs = rest[0] if rest else None
115
+ if imgs:
116
+ if self.vision is None:
117
+ self.enable_images()
118
+ try:
119
+ cache: dict = {}
120
+
121
+ def prepped_of(k, imgs=imgs, cache=cache):
122
+ if k not in cache:
123
+ cache[k] = self.vision.prep(imgs[k], self.min_pixels, self.max_pixels)
124
+ return cache[k]
125
+
126
+ from .vision import expand
127
+
128
+ ctx, preps = expand(render_state(s), dict(imgs), prepped_of, self.marks)
129
+ pre[i] = preps
130
+ plain.append((ctx, q))
131
+ except (ValueError, KeyError, TypeError, OSError) as e:
132
+ errs[i] = e
133
+ plain.append((s, q))
134
+ else:
135
+ plain.append((s, q))
136
+ encs = self.encoder.encode_many(plain)
137
+ for i, e in errs.items():
138
+ encs[i] = e
139
+ for i, preps in pre.items():
140
+ e = encs[i]
141
+ if isinstance(e, Exception):
142
+ continue
143
+ want = sum(p.n for p in preps)
144
+ got = sum(1 for t in e["ids"] if t == self.vision.image_token_id)
145
+ if want != got:
146
+ encs[i] = RuntimeError(f"image placeholder tokens: expected {want}, the tokenizer produced {got}")
147
+ continue
148
+ e["images"], e["image_tokens"] = preps, want
149
+ return encs
150
+
151
+ def _attach_images(self, encs: list):
152
+ """Image rows: their embeddings (one tower forward for the call), image row offsets and M-RoPE positions."""
153
+ import numpy as np
154
+
155
+ embs = self.vision.embed([p for e in encs for p in e["images"]])
156
+ k = 0
157
+ for e in encs:
158
+ n = len(e["images"])
159
+ e["img_emb"] = torch.cat(embs[k: k + n]) if n > 1 else embs[k]
160
+ k += n
161
+ e["img_rows"] = np.nonzero(np.asarray(e["ids"]) == self.vision.image_token_id)[0]
162
+ e["pos3"] = self.vision.positions(e["ids"], e["images"])
163
+
164
+ def _image_probs(self, encs: list) -> list:
165
+ import numpy as np
166
+
167
+ out = [None] * len(encs)
168
+ with self.lock, torch.no_grad():
169
+ self._attach_images(encs)
170
+ if self.graphs is not None and len(encs) <= self.graphs.row_buckets[-1]:
171
+ for i, r in enumerate(self.graphs.probs_many([(e,) for e in encs])):
172
+ if r is not None:
173
+ out[i] = r
174
+ order = sorted((i for i in range(len(encs)) if out[i] is None), key=lambda i: len(encs[i]["ids"]))
175
+ group, tokens = [], 0
176
+
177
+ def flush():
178
+ from .model import Batch
179
+
180
+ b = Batch([encs[i]["ids"] for i in group], [encs[i]["slots"] for i in group], "cuda")
181
+ starts = np.concatenate([[0], np.cumsum([len(encs[i]["ids"]) for i in group])])
182
+ rows = np.concatenate([starts[j] + encs[i]["img_rows"] for j, i in enumerate(group)])
183
+ b.img = (torch.from_numpy(rows).to("cuda", non_blocking=True),
184
+ torch.cat([encs[i]["img_emb"] for i in group]))
185
+ b.pos3 = torch.from_numpy(np.concatenate([encs[i]["pos3"] for i in group], 1)).to("cuda",
186
+ non_blocking=True)
187
+ lg = self.model(b).cpu()
188
+ k = 0
189
+ for i in group:
190
+ ns = len(encs[i]["slots"])
191
+ rows_ = lg[k: k + ns].float().tolist()
192
+ k += ns
193
+ out[i] = [_softmax(rows_[s][: encs[i]["nopts"][s]]) for s in range(ns)]
194
+
195
+ for i in order:
196
+ n = len(encs[i]["ids"])
197
+ if group and tokens + n > self.max_batch_tokens:
198
+ flush()
199
+ group, tokens = [], 0
200
+ group.append(i)
201
+ tokens += n
202
+ if group:
203
+ flush()
204
+ for e in encs: # drop the device tensors (the vision cache keeps the embeddings)
205
+ e.pop("img_emb", None)
206
+ return out
207
+
208
  # ---- slot probabilities -------------------------------------------------------------------------------------------
209
  def slot_probs(self, encs: list) -> list:
210
+ """Prompt rows (encoder dicts) -> per row, per slot: softmax over the slot's options (untempered). Rows with
211
+ images ("images" from encode_requests) run on the image path, the others exactly as text-only calls."""
212
+ if any(e.get("images") for e in encs):
213
+ img = [i for i, e in enumerate(encs) if e.get("images")]
214
+ txt = [i for i, e in enumerate(encs) if not e.get("images")]
215
+ out = [None] * len(encs)
216
+ for i, r in zip(img, self._image_probs([encs[i] for i in img]), strict=True):
217
+ out[i] = r
218
+ if txt:
219
+ for i, r in zip(txt, self.slot_probs([encs[i] for i in txt]), strict=True):
220
+ out[i] = r
221
+ return out
222
  out = [None] * len(encs)
223
  with self.lock, torch.no_grad():
224
  if encs and self.graphs is not None and len(encs) <= self.graphs.row_buckets[-1]:
 
248
  def per_question_probs(self, rows: list) -> list:
249
  """The per-question rows of one request: their longest common prefix computed once (exact up to bf16
250
  round-off), else as an ordinary batch."""
251
+ if len(rows) > 1 and not any(e.get("images") for e in rows):
252
  m = min(len(e["ids"]) for e in rows)
253
  first = rows[0]["ids"]
254
  lcp = 0
qev_fast/vision.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Image input for the fast engine: the same images, prompt layout and positions as qev_image.QEVImage.
2
+
3
+ - `fit_size` / `prep`: an image (PIL or path) resized with aspect kept to at most max_pixels (default 448*448, 196
4
+ image tokens) and at least min_pixels, each side a multiple of 32, then the checkpoint's own image processor
5
+ (preprocessor_config.json, do_resize=False) -> pixel patches, grid (t, h, w) and N = h * w / 4 image tokens.
6
+ - `expand`: "<image:NAME>" markers in the rendered state replaced by <|vision_start|> + <|image_pad|> x N +
7
+ <|vision_end|>; an image the state does not mention is appended at the end of the context.
8
+ - `Vision`: the checkpoint's vision tower + merger (the `model.visual.*` tensors, the base model's) on the GPU, run
9
+ once per distinct image of a call (all images of the call packed in one tower forward), with a small cache keyed by
10
+ the image's pixel content.
11
+ - `positions`: the 3-D M-RoPE positions of a prompt row (transformers' Qwen3.5 get_rope_index): text tokens
12
+ advance t = h = w by one; an image of llm grid (gh, gw) starting at p takes t = p, h = p + row, w = p + column and
13
+ the text after it continues at p + max(gh, gw).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ import math
21
+ import os
22
+ import re
23
+ from collections import OrderedDict
24
+
25
+ import numpy as np
26
+ import torch
27
+
28
+ IMAGE_MARK = re.compile(r"<image:([^<>\s]+)>")
29
+ FACTOR = 32 # patch 16 x merge 2
30
+
31
+
32
+ def fit_size(w: int, h: int, min_pixels: int, max_pixels: int, factor: int = FACTOR) -> tuple[int, int]:
33
+ """(width, height) a multiple of `factor` each, aspect kept, min_pixels <= w*h <= max_pixels (qev_image.fit_size)."""
34
+ hh = max(factor, round(h / factor) * factor)
35
+ ww = max(factor, round(w / factor) * factor)
36
+ if hh * ww > max_pixels:
37
+ beta = math.sqrt(h * w / max_pixels)
38
+ hh, ww = max(factor, math.floor(h / beta / factor) * factor), max(factor, math.floor(w / beta / factor) * factor)
39
+ elif hh * ww < min_pixels:
40
+ beta = math.sqrt(min_pixels / (h * w))
41
+ hh, ww = math.ceil(h * beta / factor) * factor, math.ceil(w * beta / factor) * factor
42
+ return ww, hh
43
+
44
+
45
+ def _open(im):
46
+ from PIL import Image
47
+
48
+ if isinstance(im, (str, os.PathLike)):
49
+ im = Image.open(im)
50
+ return im.convert("RGB")
51
+
52
+
53
+ class Prepped:
54
+ __slots__ = ("key", "n", "pix", "thw")
55
+
56
+ def __init__(self, pix, thw, n, key):
57
+ self.pix, self.thw, self.n, self.key = pix, thw, n, key
58
+
59
+
60
+ class Vision:
61
+ """The vision tower + merger of a Qwen3.5 image-text checkpoint, and the image side of the prompt."""
62
+
63
+ def __init__(self, path: str, device="cuda", dtype=torch.bfloat16, cache_size: int = 256):
64
+ from transformers import AutoImageProcessor, AutoModelForImageTextToText
65
+
66
+ cfg = json.load(open(os.path.join(path, "config.json")))
67
+ self.image_token_id = cfg["image_token_id"]
68
+ self.start_id, self.end_id = cfg["vision_start_token_id"], cfg["vision_end_token_id"]
69
+ self.merge = cfg.get("vision_config", {}).get("spatial_merge_size", 2)
70
+ self.ip = AutoImageProcessor.from_pretrained(path)
71
+ # the full image-text model as qev_image loads it (same dtype handling); only its vision tower is kept
72
+ vlm = AutoModelForImageTextToText.from_pretrained(path, dtype=dtype)
73
+ self.visual = vlm.model.visual.to(device).eval()
74
+ for p in self.visual.parameters():
75
+ p.requires_grad_(False)
76
+ del vlm
77
+ self.device, self.dtype = torch.device(device), dtype
78
+ self.cache: OrderedDict = OrderedDict()
79
+ self.cache_size = cache_size
80
+ self.stats = {"images": 0, "cache_hits": 0, "tower_calls": 0}
81
+
82
+ def tokens(self, tok) -> tuple[str, str, str]:
83
+ c = tok.convert_ids_to_tokens
84
+ return c(self.start_id), c(self.image_token_id), c(self.end_id)
85
+
86
+ def prep(self, im, min_pixels: int, max_pixels: int) -> Prepped:
87
+ """PIL / path -> pixel patches [P, D] (CPU), grid [1, 3], image tokens, content key."""
88
+ im = _open(im)
89
+ w, h = fit_size(im.size[0], im.size[1], min_pixels, max_pixels)
90
+ im = im.resize((w, h), 3)
91
+ key = hashlib.blake2b(im.tobytes() + f"{w}x{h}".encode(), digest_size=16).hexdigest()
92
+ out = self.ip(images=[im], do_resize=False, return_tensors="pt")
93
+ thw = out["image_grid_thw"]
94
+ pix = out["pixel_values"]
95
+ return Prepped(pix, thw, int(thw.prod(-1).sum()) // (self.merge ** 2), key)
96
+
97
+ @torch.no_grad()
98
+ def embed(self, preps: list) -> list:
99
+ """Prepped images -> their merged embeddings [N_i, H] on the device (distinct uncached images in one tower
100
+ forward, packed by grid)."""
101
+ todo, seen = [], set()
102
+ for p in preps:
103
+ if p.key not in self.cache and p.key not in seen:
104
+ todo.append(p)
105
+ seen.add(p.key)
106
+ self.stats["images"] += len(preps)
107
+ self.stats["cache_hits"] += len(preps) - len(todo)
108
+ if todo:
109
+ pix = torch.cat([p.pix for p in todo]).to(self.device, self.visual.dtype)
110
+ thw = torch.cat([p.thw for p in todo]).to(self.device)
111
+ out = self.visual(pix, grid_thw=thw, return_dict=True).pooler_output
112
+ self.stats["tower_calls"] += 1
113
+ for p, e in zip(todo, torch.split(out, [p.n for p in todo]), strict=True):
114
+ self.cache[p.key] = e
115
+ while len(self.cache) > self.cache_size:
116
+ self.cache.popitem(last=False)
117
+ res = []
118
+ for p in preps:
119
+ self.cache.move_to_end(p.key)
120
+ res.append(self.cache[p.key])
121
+ return res
122
+
123
+ def positions(self, ids: list, preps: list) -> np.ndarray:
124
+ """[3, L] M-RoPE positions of one prompt row whose image runs are `preps` in order."""
125
+ ids = np.asarray(ids)
126
+ pos = np.empty((3, len(ids)), np.int64)
127
+ is_img = ids == self.image_token_id
128
+ cur, i, k, L = 0, 0, 0, len(ids)
129
+ while i < L:
130
+ if not is_img[i]:
131
+ j = i
132
+ while j < L and not is_img[j]:
133
+ j += 1
134
+ pos[:, i:j] = np.arange(cur, cur + j - i)[None]
135
+ cur += j - i
136
+ i = j
137
+ continue
138
+ p = preps[k]
139
+ k += 1
140
+ _, gh, gw = (int(x) for x in p.thw[0].tolist())
141
+ gh, gw = gh // self.merge, gw // self.merge
142
+ assert gh * gw == p.n and bool(is_img[i: i + p.n].all()), "image placeholder run does not match its image"
143
+ hh, ww = np.meshgrid(np.arange(gh), np.arange(gw), indexing="ij")
144
+ pos[0, i: i + p.n] = cur
145
+ pos[1, i: i + p.n] = cur + hh.reshape(-1)
146
+ pos[2, i: i + p.n] = cur + ww.reshape(-1)
147
+ cur += max(gh, gw)
148
+ i += p.n
149
+ assert k == len(preps), "more images than placeholder runs"
150
+ return pos
151
+
152
+
153
+ def expand(ctx: str, images: dict, prepped_of, marks: tuple[str, str, str]) -> tuple[str, list]:
154
+ """The rendered state with every image's placeholder run (qev_image.QEVImage._row) -> (context string, the
155
+ Prepped images in occurrence order). prepped_of(name) -> Prepped."""
156
+ named = []
157
+ for m in IMAGE_MARK.finditer(ctx):
158
+ if m.group(1) not in images:
159
+ raise ValueError(f"the state mentions <image:{m.group(1)}> but no such image was given")
160
+ named.append(m.group(1))
161
+ for k in images: # an image the state does not mention goes at the end
162
+ if k not in named:
163
+ ctx += f"\n<image:{k}>"
164
+ named.append(k)
165
+ prepped = {k: prepped_of(k) for k in dict.fromkeys(named)}
166
+ s, pad, e = marks
167
+ ctx = IMAGE_MARK.sub(lambda m: s + pad * prepped[m.group(1)].n + e, ctx)
168
+ return ctx, [prepped[k] for k in named]
qev_server.py CHANGED
@@ -14,8 +14,11 @@
14
 
15
  Endpoints:
16
  - POST /v1/systemone {"state": str | object | array, "questions": {id: question}, "model": optional, "encoding":
17
- optional "joint" | "per_question"} -> {"model", "answers", "usage"}. Code written for the
18
- System One API works unchanged; "model" in the request is accepted and ignored.
 
 
 
19
  - GET /health readiness, backend, encoding, model files' SHA-256 (from SHA256SUMS when present).
20
  - GET /v1/models the served model.
21
 
@@ -49,14 +52,14 @@ class Batcher:
49
  self.q: queue.Queue = queue.Queue()
50
  threading.Thread(target=self._loop, daemon=True).start()
51
 
52
- def submit(self, state, questions: dict, per_question: bool, timeout: float = 300.0):
53
  done, box = threading.Event(), {}
54
- self.q.put((state, questions, per_question, done, box))
55
  if not done.wait(timeout):
56
  raise TimeoutError("the model did not answer in time")
57
  if "error" in box:
58
  raise box["error"]
59
- return box["answers"], box["tokens"]
60
 
61
  def _loop(self):
62
  while True:
@@ -73,7 +76,7 @@ class Batcher:
73
 
74
  def _run(self, jobs: list, per_question: bool):
75
  try:
76
- res = self.decide_batch([(j[0], j[1]) for j in jobs], per_question)
77
  except Exception as e: # noqa: BLE001 - one bad request must not fail the others: answer each on its own
78
  if len(jobs) > 1:
79
  for j in jobs:
@@ -82,8 +85,9 @@ class Batcher:
82
  jobs[0][4]["error"] = e
83
  jobs[0][3].set()
84
  return
85
- for j, (ans, toks) in zip(jobs, res, strict=True):
86
- j[4]["answers"], j[4]["tokens"] = ans, toks
 
87
  j[3].set()
88
 
89
 
@@ -108,6 +112,33 @@ def _validate(req) -> tuple:
108
  return req["state"], qs, enc
109
 
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  def _file_hashes(path: str | None) -> dict:
112
  if path and os.path.exists(os.path.join(path, "SHA256SUMS")):
113
  out = {}
@@ -167,20 +198,26 @@ def make_handler(batcher: Batcher, info: dict, encoding: str, api_key: str | Non
167
  n = int(self.headers.get("Content-Length", 0))
168
  if n > MAX_BODY:
169
  return self._send(413, {"detail": f"the body is larger than {MAX_BODY} bytes"})
170
- state, qs, enc = _validate(json.loads(self.rfile.read(n) or b"{}"))
 
 
 
 
171
  except (ValueError, json.JSONDecodeError) as e:
172
  return self._send(422, {"detail": str(e)})
173
  t = time.perf_counter()
174
  try:
175
- ans, toks = batcher.submit(state, qs, (enc or encoding) == "per_question")
176
  except (ValueError, KeyError, TypeError, AssertionError) as e:
177
  return self._send(422, {"detail": str(e) or type(e).__name__})
178
  except TimeoutError as e:
179
  return self._send(503, {"detail": str(e)})
180
  except Exception as e: # noqa: BLE001
181
  return self._send(500, {"detail": f"{type(e).__name__}: {e}"})
182
- return self._send(200, {"model": info["model"], "answers": ans,
183
- "usage": {"input_tokens": toks, "output_tokens": 0},
 
 
184
  "latency_ms": round((time.perf_counter() - t) * 1000, 2)})
185
 
186
  return H
@@ -201,17 +238,27 @@ def main():
201
  ap.add_argument("--fast-start", action="store_true",
202
  help="fast backend: listen once the common CUDA graphs are captured and capture the rest in the "
203
  "background (requests during that time can be slow); default: capture all before listening")
 
 
 
204
  ap.add_argument("--verbose", action="store_true", help="log every request")
205
  a = ap.parse_args()
206
 
207
  from qev import QEV
208
 
209
  t0 = time.perf_counter()
210
- m = QEV.from_pretrained(a.model, device=a.device, backend=a.backend, precapture=False)
 
 
 
 
 
 
 
211
  if m.fast is not None: # CUDA graphs for every batch shape, so no live request waits for a capture
212
  print("qev_server: capturing CUDA graphs (about 2 min the first time, faster with a warm kernel cache)",
213
  flush=True)
214
- m.fast.precapture(background=a.fast_start, per_question=a.encoding == "per_question")
215
  m.decide("warm-up", {"q": {"type": "noul", "instructions": "Is this a warm-up?"}})
216
  local = a.model if os.path.isdir(a.model) else None
217
  if local is None:
@@ -222,9 +269,13 @@ def main():
222
  except Exception: # noqa: BLE001
223
  local = None
224
  info = {"model": "qev-0.8b", "source": a.model, "backend": m.backend, "device": str(m.device),
225
- "encoding": a.encoding, "load_s": round(time.perf_counter() - t0, 1), "sha256": _file_hashes(local),
226
  "verbose": a.verbose}
227
- batcher = Batcher(lambda reqs, pq: m.decide_batch(reqs, pq, usage=True), max_batch=a.max_batch)
 
 
 
 
228
  key = os.environ.get("QEV_API_KEY") or None
229
  httpd = Server((a.host, a.port), make_handler(batcher, info, a.encoding, key))
230
  print(f"qev_server: {info['backend']} backend on {info['device']}, ready in {info['load_s']} s; "
 
14
 
15
  Endpoints:
16
  - POST /v1/systemone {"state": str | object | array, "questions": {id: question}, "model": optional, "encoding":
17
+ optional "joint" | "per_question", "images": optional {name: base64 PNG / JPEG}} ->
18
+ {"model", "answers", "usage"}. Code written for the System One API works unchanged; "model" in
19
+ the request is accepted and ignored. Images (fast CUDA backend; pillow + torchvision): the
20
+ state places one with "<image:name>", an image it does not mention goes at the end of the
21
+ context; usage then also reports "image_tokens".
22
  - GET /health readiness, backend, encoding, model files' SHA-256 (from SHA256SUMS when present).
23
  - GET /v1/models the served model.
24
 
 
52
  self.q: queue.Queue = queue.Queue()
53
  threading.Thread(target=self._loop, daemon=True).start()
54
 
55
+ def submit(self, state, questions: dict, per_question: bool, timeout: float = 300.0, images: dict | None = None):
56
  done, box = threading.Event(), {}
57
+ self.q.put((state, questions, per_question, done, box, images))
58
  if not done.wait(timeout):
59
  raise TimeoutError("the model did not answer in time")
60
  if "error" in box:
61
  raise box["error"]
62
+ return box["answers"], box["tokens"], box.get("image_tokens", 0)
63
 
64
  def _loop(self):
65
  while True:
 
76
 
77
  def _run(self, jobs: list, per_question: bool):
78
  try:
79
+ res = self.decide_batch([(j[0], j[1], j[5]) if j[5] else (j[0], j[1]) for j in jobs], per_question)
80
  except Exception as e: # noqa: BLE001 - one bad request must not fail the others: answer each on its own
81
  if len(jobs) > 1:
82
  for j in jobs:
 
85
  jobs[0][4]["error"] = e
86
  jobs[0][3].set()
87
  return
88
+ for j, r in zip(jobs, res, strict=True):
89
+ j[4]["answers"], j[4]["tokens"] = r[0], r[1]
90
+ j[4]["image_tokens"] = r[2] if len(r) > 2 else 0
91
  j[3].set()
92
 
93
 
 
112
  return req["state"], qs, enc
113
 
114
 
115
+ def decode_images(raw) -> dict | None:
116
+ """The "images" field: {name: base64 string (a data: URL prefix is accepted)} -> {name: PIL image}."""
117
+ import base64
118
+ import binascii
119
+ import io
120
+
121
+ if raw is None or raw == {}:
122
+ return None
123
+ if not isinstance(raw, dict):
124
+ raise ValueError('"images" must be an object {name: base64 string}')
125
+ try:
126
+ from PIL import Image
127
+ except ImportError:
128
+ raise ValueError("images need pillow (pip install pillow torchvision)") from None
129
+ out = {}
130
+ for k, v in raw.items():
131
+ if not isinstance(v, str):
132
+ raise ValueError(f'image "{k}": a base64 string')
133
+ if v.startswith("data:"):
134
+ v = v.split(",", 1)[-1]
135
+ try:
136
+ out[k] = Image.open(io.BytesIO(base64.b64decode(v, validate=True))).convert("RGB")
137
+ except (binascii.Error, OSError) as e:
138
+ raise ValueError(f'image "{k}": not a decodable base64 image ({e})') from None
139
+ return out
140
+
141
+
142
  def _file_hashes(path: str | None) -> dict:
143
  if path and os.path.exists(os.path.join(path, "SHA256SUMS")):
144
  out = {}
 
198
  n = int(self.headers.get("Content-Length", 0))
199
  if n > MAX_BODY:
200
  return self._send(413, {"detail": f"the body is larger than {MAX_BODY} bytes"})
201
+ body = json.loads(self.rfile.read(n) or b"{}")
202
+ state, qs, enc = _validate(body)
203
+ imgs = decode_images(body.get("images"))
204
+ if imgs and not info.get("images"):
205
+ raise ValueError("this server does not read images (the fast CUDA backend is needed)")
206
  except (ValueError, json.JSONDecodeError) as e:
207
  return self._send(422, {"detail": str(e)})
208
  t = time.perf_counter()
209
  try:
210
+ ans, toks, itoks = batcher.submit(state, qs, (enc or encoding) == "per_question", images=imgs)
211
  except (ValueError, KeyError, TypeError, AssertionError) as e:
212
  return self._send(422, {"detail": str(e) or type(e).__name__})
213
  except TimeoutError as e:
214
  return self._send(503, {"detail": str(e)})
215
  except Exception as e: # noqa: BLE001
216
  return self._send(500, {"detail": f"{type(e).__name__}: {e}"})
217
+ usage = {"input_tokens": toks, "output_tokens": 0}
218
+ if imgs:
219
+ usage["image_tokens"] = itoks
220
+ return self._send(200, {"model": info["model"], "answers": ans, "usage": usage,
221
  "latency_ms": round((time.perf_counter() - t) * 1000, 2)})
222
 
223
  return H
 
238
  ap.add_argument("--fast-start", action="store_true",
239
  help="fast backend: listen once the common CUDA graphs are captured and capture the rest in the "
240
  "background (requests during that time can be slow); default: capture all before listening")
241
+ ap.add_argument("--no-images", action="store_true",
242
+ help="do not load the vision tower (requests with \"images\" then get HTTP 422)")
243
+ ap.add_argument("--max-pixels", type=int, default=448 * 448, help="images are resized to at most this many pixels")
244
  ap.add_argument("--verbose", action="store_true", help="log every request")
245
  a = ap.parse_args()
246
 
247
  from qev import QEV
248
 
249
  t0 = time.perf_counter()
250
+ m = QEV.from_pretrained(a.model, device=a.device, backend=a.backend, precapture=False, max_pixels=a.max_pixels)
251
+ images = False
252
+ if m.fast is not None and not a.no_images:
253
+ try:
254
+ m.fast.enable_images(a.max_pixels)
255
+ images = True
256
+ except Exception as e: # noqa: BLE001 - no pillow / torchvision / processor config: a text-only server
257
+ print(f"qev_server: images off ({type(e).__name__}: {e})", flush=True)
258
  if m.fast is not None: # CUDA graphs for every batch shape, so no live request waits for a capture
259
  print("qev_server: capturing CUDA graphs (about 2 min the first time, faster with a warm kernel cache)",
260
  flush=True)
261
+ m.fast.precapture(background=a.fast_start, per_question=a.encoding == "per_question", images=images)
262
  m.decide("warm-up", {"q": {"type": "noul", "instructions": "Is this a warm-up?"}})
263
  local = a.model if os.path.isdir(a.model) else None
264
  if local is None:
 
269
  except Exception: # noqa: BLE001
270
  local = None
271
  info = {"model": "qev-0.8b", "source": a.model, "backend": m.backend, "device": str(m.device),
272
+ "encoding": a.encoding, "images": images, "load_s": round(time.perf_counter() - t0, 1), "sha256": _file_hashes(local),
273
  "verbose": a.verbose}
274
+ def run(reqs, pq):
275
+ res = m.decide_batch(reqs, pq, usage=True)
276
+ return [(a_, t_, k_) for (a_, t_), k_ in zip(res, m.last_image_tokens, strict=True)]
277
+
278
+ batcher = Batcher(run, max_batch=a.max_batch)
279
  key = os.environ.get("QEV_API_KEY") or None
280
  httpd = Server((a.host, a.port), make_handler(batcher, info, a.encoding, key))
281
  print(f"qev_server: {info['backend']} backend on {info['device']}, ready in {info['load_s']} s; "