AlexWortega commited on
Commit
f004f37
·
verified ·
1 Parent(s): 4395b29

SGLang serving: external model package, launcher, client, benchmark; card section

Browse files
README.md CHANGED
@@ -64,7 +64,7 @@ a small MLP head on the last-token latent (`mlp_heads_35b/`, one head per task,
64
  * `modeling_qwen35_moe_seqcls.py` — `Qwen3_5MoeForSequenceClassification` for the 35B-A3B backbone.
65
  * `mlp_heads_35b/<task>/` — `head.pt` + `norm.npz` + `meta.json`, the 35B latent + MLP heads behind the second radar.
66
  * `code/` — everything used here: the trainer and data mixture builder, the evaluation harness, Flappy Bird, Doom
67
- (text and pixels), the Minecraft scaffold and bot, the radar.
68
  * `videos/` — Flappy Bird, Doom and Minecraft replays; `results/` — raw JSON for every run and the full report.
69
 
70
  ## Use it
@@ -104,4 +104,41 @@ text = model.config.nli_template.format(premise="...", hypothesis="...")
104
  Images go inside the premise as `<|vision_start|><|image_pad|>…<|vision_end|>` with `pixel_values` / `image_grid_thw`
105
  from the Qwen3.5 image processor; see `code/doom_vision.py` and `code/eval_image_nli.py`.
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  Reference point: [dleemiller's NLI cross-encoders](https://huggingface.co/blog/dleemiller/nli-xenc-ways-to-use). Licence MIT.
 
64
  * `modeling_qwen35_moe_seqcls.py` — `Qwen3_5MoeForSequenceClassification` for the 35B-A3B backbone.
65
  * `mlp_heads_35b/<task>/` — `head.pt` + `norm.npz` + `meta.json`, the 35B latent + MLP heads behind the second radar.
66
  * `code/` — everything used here: the trainer and data mixture builder, the evaluation harness, Flappy Bird, Doom
67
+ (text and pixels), the Minecraft scaffold and bot, the radar, the SGLang package / launcher / client / benchmark.
68
  * `videos/` — Flappy Bird, Doom and Minecraft replays; `results/` — raw JSON for every run and the full report.
69
 
70
  ## Use it
 
104
  Images go inside the premise as `<|vision_start|><|image_pad|>…<|vision_end|>` with `pixel_values` / `image_grid_thw`
105
  from the Qwen3.5 image processor; see `code/doom_vision.py` and `code/eval_image_nli.py`.
106
 
107
+ ## Serve it with SGLang
108
+
109
+ SGLang has no sequence-classification class for Qwen3.5, so `code/sglang_openjev/` is an external model package
110
+ that adds the `score` head to SGLang's `Qwen3_5ForConditionalGeneration` (hybrid cache, mrope and the vision tower
111
+ stay as they are). Text and images both work; `/classify` returns the three raw logits.
112
+
113
+ ```bash
114
+ hf download AlexWortega/openjev --include "qwen3.5-0.8b-nli-v2s-long/*" "code/*" --local-dir openjev
115
+ cd openjev/code && bash serve_sglang.sh ../qwen3.5-0.8b-nli-v2s-long 30000 # tested with sglang 0.5.19
116
+ ```
117
+
118
+ ```python
119
+ from sglang_client import OpenJevSGLang # code/sglang_client.py
120
+ jev = OpenJevSGLang("http://127.0.0.1:30000")
121
+ jev.predict([("A man is playing a guitar.", "Someone is making music.")]) # [[con, ent, neu]]
122
+ jev.predict([("A photograph of a scene:", "There is a dog.")], images=["dog.jpg"])
123
+ ```
124
+
125
+ Without the script: `SGLANG_EXTERNAL_MODEL_PACKAGE=sglang_openjev` with `code/` on `PYTHONPATH`, then
126
+ `python -m sglang.launch_server --model-path <dir> --is-embedding --json-model-override-args
127
+ '{"architectures": ["Qwen3_5ForConditionalGeneration"]}'`.
128
+
129
+ Same predictions as transformers, 1.5-3x the throughput. `qwen3.5-0.8b-nli-v2s-long` on one RTX A6000 that was
130
+ shared with another job (so absolute speed is a lower bound), transformers at batch 32 (`code/bench_sglang.py`):
131
+
132
+ | task | pairs | tokens / pair | acc SGLang | acc transformers | SGLang pairs/s | transformers pairs/s | speed-up |
133
+ |---|---|---|---|---|---|---|---|
134
+ | MNLI m+mm | 19647 | 44 | 86.63 | 86.64 | 321 | 152 | 2.1x |
135
+ | ANLI r1 | 1000 | 105 | 65.3 | 64.9 | 189 | 119 | 1.6x |
136
+ | ANLI r2 | 1000 | 103 | 50.6 | 50.8 | 181 | 110 | 1.6x |
137
+ | ANLI r3 | 1200 | 92 | 48.6 | 48.6 | 218 | 95 | 2.3x |
138
+ | WANLI | 5000 | 40 | 73.7 | 73.7 | 400 | 196 | 2.0x |
139
+ | SciTail | 2126 | 43 | 93.3 | 93.4 | 319 | 217 | 1.5x |
140
+ | ConTRoL (long) | 805 | 612 | 51.9 | 51.4 | 45 | 15 | 3.1x |
141
+ | ARC-Challenge (rerank) | 9374 | 49 | 49.2 | 49.2 | 334 | 197 | 1.7x |
142
+ | HellaSwag (rerank, 2k questions) | 16000 | 114 | 37.6 | 37.5 | 176 | 109 | 1.6x |
143
+
144
  Reference point: [dleemiller's NLI cross-encoders](https://huggingface.co/blog/dleemiller/nli-xenc-ways-to-use). Licence MIT.
code/bench_sglang.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Accuracy + throughput of an openjev checkpoint: transformers (eval.py's NLIScorer) vs the SGLang server.
2
+
3
+ ./serve_sglang.sh ckpt/qwen3.5-0.8b-nli-v2s-long &
4
+ python bench_sglang.py --ckpt ckpt/qwen3.5-0.8b-nli-v2s-long --out results/sglang/bench.json
5
+
6
+ Both backends go through the same eval.py task code; only `predict` is timed (no dataset loading)."""
7
+ import argparse
8
+ import json
9
+ import os
10
+ import time
11
+ from types import SimpleNamespace
12
+
13
+ import numpy as np
14
+ import torch
15
+
16
+ import eval as E
17
+ from sglang_client import OpenJevSGLang
18
+
19
+
20
+ class Timed:
21
+ def __init__(self, predict, tok, max_len):
22
+ self._predict, self.tok, self.max_len = predict, tok, max_len
23
+ self.reset()
24
+
25
+ def reset(self):
26
+ self.sec, self.pairs, self.tokens = 0.0, 0, 0
27
+
28
+ def predict(self, pairs):
29
+ t = time.perf_counter()
30
+ out = self._predict(pairs)
31
+ if torch.cuda.is_available():
32
+ torch.cuda.synchronize()
33
+ self.sec += time.perf_counter() - t
34
+ self.pairs += len(pairs)
35
+ texts = [E_TEMPLATE.format(premise=p.strip(), hypothesis=h.strip()) for p, h in pairs]
36
+ self.tokens += sum(min(len(x), self.max_len) for x in self.tok(texts)["input_ids"])
37
+ return out
38
+
39
+
40
+ def run(scorer, tasks, mc_items, mnli_n):
41
+ res = {}
42
+ for t in tasks:
43
+ scorer.reset()
44
+ if t == "mnli":
45
+ r = E.eval_mnli(scorer, mnli_n)
46
+ acc = float(np.mean([v["acc"] for v in r.values()]))
47
+ elif t in E.NLI_SETS:
48
+ acc = E.eval_nli_set(scorer, t)["acc"]
49
+ else:
50
+ acc = E.eval_mc(scorer, mc_items[t])["rerank_acc"]
51
+ res[t] = {"acc": acc, "pairs": scorer.pairs, "tokens": scorer.tokens, "sec": round(scorer.sec, 2),
52
+ "pairs_per_s": round(scorer.pairs / scorer.sec, 1), "tok_per_s": round(scorer.tokens / scorer.sec)}
53
+ print(t, res[t], flush=True)
54
+ return res
55
+
56
+
57
+ if __name__ == "__main__":
58
+ ap = argparse.ArgumentParser()
59
+ ap.add_argument("--ckpt", required=True)
60
+ ap.add_argument("--url", default="http://127.0.0.1:30000")
61
+ ap.add_argument("--out", required=True)
62
+ ap.add_argument("--tasks", nargs="+", default=["mnli", "anli_r1", "anli_r2", "anli_r3", "wanli", "scitail", "control",
63
+ "arc_challenge", "hellaswag"])
64
+ ap.add_argument("--backends", nargs="+", default=["sglang", "hf"])
65
+ ap.add_argument("--mnli-n", type=int, default=None)
66
+ ap.add_argument("--mc-n", type=int, default=2000, help="hellaswag / mmlu subsample")
67
+ ap.add_argument("--bs", type=int, default=32)
68
+ ap.add_argument("--max-len", type=int, default=4096)
69
+ ap.add_argument("--workers", type=int, default=16)
70
+ a = ap.parse_args()
71
+ from transformers import AutoConfig, AutoTokenizer
72
+
73
+ tok = AutoTokenizer.from_pretrained(a.ckpt)
74
+ E_TEMPLATE = AutoConfig.from_pretrained(a.ckpt).nli_template
75
+ margs = SimpleNamespace(mc_n=a.mc_n, chess_n=500, fewshot=5)
76
+ mc_items = {t: E.MC_TASKS[t](margs) for t in a.tasks if t in E.MC_TASKS}
77
+ results = {}
78
+ for b in a.backends:
79
+ print(f"\n===== {b}")
80
+ if b == "hf":
81
+ model = E.NLIScorer(a.ckpt, bs=a.bs, max_len=a.max_len)
82
+ predict = model.predict
83
+ else:
84
+ predict = OpenJevSGLang(a.url, template=E_TEMPLATE, bs=a.bs, workers=a.workers).predict
85
+ predict([("warm", "up")] * a.bs)
86
+ results[b] = run(Timed(predict, tok, a.max_len), a.tasks, mc_items, a.mnli_n)
87
+ os.makedirs(os.path.dirname(a.out) or ".", exist_ok=True)
88
+ json.dump(results, open(a.out, "w"), indent=2)
89
+ print("\n| task | pairs | avg tok | " + " | ".join(f"{b} acc | {b} pairs/s" for b in a.backends) + " | speedup |")
90
+ print("|---|---|---|" + "---|---|" * len(a.backends) + "---|")
91
+ for t in a.tasks:
92
+ r0 = results[a.backends[0]][t]
93
+ row = " | ".join(f"{results[b][t]['acc']:.4f} | {results[b][t]['pairs_per_s']}" for b in a.backends)
94
+ sp = r0["pairs_per_s"] / results[a.backends[-1]][t]["pairs_per_s"] if len(a.backends) > 1 else 1.0
95
+ print(f"| {t} | {r0['pairs']} | {r0['tokens'] // r0['pairs']} | {row} | {sp:.1f}x |")
code/serve_sglang.sh ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Serve an openjev checkpoint with SGLang. Usage: ./serve_sglang.sh [ckpt dir or HF snapshot dir] [port] [extra sglang args]
3
+ # Needs a local dir with config.json at its root (for the Hub repo: hf download AlexWortega/openjev --include
4
+ # "qwen3.5-0.8b-nli-v2s-long/*" --local-dir openjev_hf). Query with sglang_client.py.
5
+ # ATTN_BACKEND defaults to triton: flashinfer JIT-compiles with the system nvcc and fails on nvcc < 12.4.
6
+ # MEM_FRACTION is a share of the WHOLE card, other processes included: on a shared GPU set it close to
7
+ # 1 - (wanted headroom / total), e.g. 0.85 on a 48GB card that already has 32GB taken.
8
+ HERE=$(cd "$(dirname "$0")" && pwd)
9
+ MODEL=${1:-ckpt/qwen3.5-0.8b-nli-v2s-long}
10
+ PORT=${2:-30000}
11
+ PY=${PY:-$HOME/venvs/sglang/bin/python}
12
+ export PATH=$(dirname "$PY"):$PATH # ninja for sglang's JIT kernels
13
+ export PYTHONPATH=$HERE:$PYTHONPATH
14
+ export SGLANG_EXTERNAL_MODEL_PACKAGE=sglang_openjev
15
+ exec $PY -m sglang.launch_server --model-path "$MODEL" --port "$PORT" --host 0.0.0.0 \
16
+ --is-embedding --json-model-override-args '{"architectures": ["Qwen3_5ForConditionalGeneration"]}' \
17
+ --attention-backend ${ATTN_BACKEND:-triton} --served-model-name openjev ${MEM_FRACTION:+--mem-fraction-static $MEM_FRACTION} "${@:3}"
code/sglang_client.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Client for an openjev checkpoint served by SGLang (serve_sglang.sh). Same interface as
2
+ `OpenJevCrossEncoder.predict/rerank`, plus `--check` to compare the server against transformers.
3
+
4
+ python sglang_client.py --premise "A man is playing a guitar." --hypothesis "Someone is making music."
5
+ python sglang_client.py --check ckpt/qwen3.5-0.8b-nli-v2s-long # max |p_sglang - p_hf| on a few pairs
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import base64
11
+ from concurrent.futures import ThreadPoolExecutor
12
+
13
+ import numpy as np
14
+ import requests
15
+
16
+ CON, ENT, NEU = 0, 1, 2
17
+ LABELS = ["contradiction", "entailment", "neutral"]
18
+ DEFAULT_TEMPLATE = "Premise: {premise}\nHypothesis: {hypothesis}"
19
+ IMG_MARK = "<<IMG>>"
20
+ IMG_BLOCK = "<|vision_start|><|image_pad|><|vision_end|>" # SGLang expands the single pad to the patch count
21
+
22
+
23
+ class OpenJevSGLang:
24
+ def __init__(self, url: str = "http://127.0.0.1:30000", template: str = DEFAULT_TEMPLATE, bs: int = 32,
25
+ workers: int = 16):
26
+ # `workers` requests of `bs` pairs are in flight at once; the server batches across them
27
+ self.url, self.template, self.bs, self.workers = url.rstrip("/"), template, bs, workers
28
+
29
+ def _post(self, payload):
30
+ r = requests.post(f"{self.url}/classify", json=payload, timeout=600)
31
+ r.raise_for_status()
32
+ out = r.json()
33
+ return [o["embedding"] for o in (out if isinstance(out, list) else [out])]
34
+
35
+ def logits(self, pairs, images=None) -> np.ndarray:
36
+ """Raw [contradiction, entailment, neutral] logits. `images[i]` is a path / URL / bytes or None and is
37
+ spliced into premise i at the `<<IMG>>` marker (appended if the marker is absent)."""
38
+ payloads = []
39
+ for i in range(0, len(pairs), self.bs):
40
+ texts, imgs = [], []
41
+ for j, (p, h) in enumerate(pairs[i:i + self.bs]):
42
+ img = images[i + j] if images else None
43
+ if img is not None:
44
+ p = p.replace(IMG_MARK, IMG_BLOCK) if IMG_MARK in p else f"{p.rstrip()} {IMG_BLOCK}"
45
+ img = base64.b64encode(img).decode() if isinstance(img, bytes) else img
46
+ texts.append(self.template.format(premise=p.strip(), hypothesis=h.strip()))
47
+ imgs.append(img)
48
+ payload = {"text": texts}
49
+ if any(x is not None for x in imgs):
50
+ payload["image_data"] = imgs
51
+ payloads.append(payload)
52
+ with ThreadPoolExecutor(self.workers) as ex:
53
+ out = [z for part in ex.map(self._post, payloads) for z in part]
54
+ return np.asarray(out, np.float32)
55
+
56
+ def predict(self, pairs, images=None) -> np.ndarray:
57
+ """Softmax probabilities [contradiction, entailment, neutral] per pair."""
58
+ z = self.logits(pairs, images)
59
+ z = np.exp(z - z.max(-1, keepdims=True))
60
+ return z / z.sum(-1, keepdims=True)
61
+
62
+ def rerank(self, question: str, options, hyp_fmt: str = "The correct answer is: {}") -> int:
63
+ return int(self.predict([(question, hyp_fmt.format(o)) for o in options])[:, ENT].argmax())
64
+
65
+
66
+ CHECK_PAIRS = [
67
+ ("A man is playing a guitar.", "Someone is making music."),
68
+ ("A man is playing a guitar.", "The man is asleep."),
69
+ ("A man is playing a guitar.", "The man is a professional musician."),
70
+ ("The Eiffel Tower is in Paris. " * 40, "The Eiffel Tower is in Berlin."),
71
+ ("What is the capital of France?", "The correct answer is: Paris"),
72
+ ]
73
+
74
+ if __name__ == "__main__":
75
+ ap = argparse.ArgumentParser()
76
+ ap.add_argument("--url", default="http://127.0.0.1:30000")
77
+ ap.add_argument("--premise")
78
+ ap.add_argument("--hypothesis")
79
+ ap.add_argument("--image")
80
+ ap.add_argument("--check", metavar="CKPT", help="compare against transformers on this checkpoint")
81
+ a = ap.parse_args()
82
+ client = OpenJevSGLang(a.url)
83
+ if a.check:
84
+ from modeling_openjev import OpenJevCrossEncoder
85
+
86
+ ours, ref = client.predict(CHECK_PAIRS), OpenJevCrossEncoder(a.check).predict(CHECK_PAIRS)
87
+ for (p, h), x, y in zip(CHECK_PAIRS, ours, ref):
88
+ print(f"{h[:50]:52s} sglang {np.round(x, 4)} hf {np.round(y, 4)}")
89
+ print("max abs diff:", float(np.abs(ours - ref).max()), "| argmax agree:", bool((ours.argmax(1) == ref.argmax(1)).all()))
90
+ else:
91
+ p = client.predict([(a.premise, a.hypothesis)], [a.image] if a.image else None)[0]
92
+ print({l: round(float(v), 4) for l, v in zip(LABELS, p)})
code/sglang_openjev/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """SGLang external model package for openjev. Enable with SGLANG_EXTERNAL_MODEL_PACKAGE=sglang_openjev
2
+ (this directory's parent must be on PYTHONPATH); see serve_sglang.sh."""
code/sglang_openjev/qwen3_5_seqcls.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Qwen3_5ForSequenceClassification for SGLang (which ships no seq-cls head for Qwen3.5).
2
+
3
+ SGLang keys the hybrid GDN cache, the mrope setup and the Qwen-VL multimodal processor on the architecture
4
+ *name* `Qwen3_5ForConditionalGeneration`, so instead of registering a new name this module overrides that
5
+ entry (the registry loads external packages with overwrite=True) and the server is launched with
6
+ `--json-model-override-args '{"architectures": ["Qwen3_5ForConditionalGeneration"]}' --is-embedding`.
7
+ Generation is untouched; with get_embedding the last-token hidden state goes through the linear `score`
8
+ head, same pooling as transformers (last non-pad token), and the raw logits come back as the "embedding".
9
+ """
10
+ from typing import Optional
11
+
12
+ import torch
13
+ from torch import nn
14
+
15
+ from sglang.srt.layers.pooler import Pooler, PoolingType, score_and_pool
16
+ from sglang.srt.layers.quantization.base_config import QuantizationConfig
17
+ from sglang.srt.model_executor.forward_batch_info import ForwardBatch
18
+ from sglang.srt.models import qwen3_5 as _qwen3_5
19
+ from sglang.srt.models.qwen3_vl import general_mm_embed_routine
20
+
21
+
22
+ class Qwen3_5ForConditionalGeneration(_qwen3_5.Qwen3_5ForConditionalGeneration):
23
+ def __init__(self, config, quant_config: Optional[QuantizationConfig] = None, prefix: str = ""):
24
+ super().__init__(config, quant_config, prefix)
25
+ # self.config is the text config from here on; num_labels lives on the top-level one
26
+ self.score = nn.Linear(self.config.hidden_size, config.num_labels, bias=False)
27
+ self.pooler = Pooler(pooling_type=PoolingType.LAST, normalize=False)
28
+
29
+ @torch.no_grad()
30
+ def forward(self, input_ids: torch.Tensor, positions: torch.Tensor, forward_batch: ForwardBatch,
31
+ get_embedding: bool = False, pp_proxy_tensors=None):
32
+ if not get_embedding:
33
+ return super().forward(input_ids, positions, forward_batch, False, pp_proxy_tensors)
34
+ if self.is_mrope_enabled:
35
+ positions = forward_batch.mrope_positions
36
+ hidden_states = general_mm_embed_routine(
37
+ input_ids=input_ids,
38
+ forward_batch=forward_batch,
39
+ language_model=self.model,
40
+ multimodal_model=self,
41
+ positions=positions,
42
+ use_deepstack=self.use_deepstack,
43
+ pp_proxy_tensors=pp_proxy_tensors,
44
+ )
45
+ return score_and_pool(self.score, self.pooler, hidden_states, forward_batch, input_ids)
46
+
47
+
48
+ EntryClass = [Qwen3_5ForConditionalGeneration]