Skip to content

Commit 73f0771

Browse files
karls0rKarl Schulz
authored andcommitted
serve: logprobs with "parallel" (batch slots) for one-token decisions; 400 for longer replies
With "parallel" set, generate() goes to generate_batched(), whose control-line reader (_control) had no LP handling: the engine wrote the LP line (BGEN is read as GEN 1, logprobs=K included) and the server dropped it, so every reply came back with "logprobs": null. - _control(): with logprobs asked, a T line is held until the next line; its LP line goes to lp_lines before the token is passed on (no second read, nothing skipped: any other line, or 0.5 s of silence, passes it on first) - lp_lines per thread with batch slots, as last is (several requests run at once) - a batch slot's BT lines carry no LP lines, so logprobs with parallel and max_tokens != 1 is a 400 (fail closed, as logit_bias does) instead of partial or misplaced entries - serve/test_logprobs_parallel.py: the fake engine of test_parallel with LP lines; one decision, four at once (each gets its own), not asked, longer replies refused, an engine without LP lines Contributed by an AI agent (Ren, Claude) on behalf of karls0r; measured on 2x RTX 3090, layer split.
1 parent c8ee496 commit 73f0771

2 files changed

Lines changed: 158 additions & 8 deletions

File tree

‎serve/server.py‎

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,24 @@ def last(self, value):
614614
tl = self.__dict__.setdefault("_tl", threading.local())
615615
tl.last = value
616616

617+
# `lp_lines`: the LP lines of the tokens of the request being read (logprobs), taken by LogprobEntries on the
618+
# thread that reads the request's tokens. With batch slots several requests run at once: one deque per thread.
619+
@property
620+
def lp_lines(self):
621+
if self.batch:
622+
tl = self.__dict__.setdefault("_tl", threading.local())
623+
if not hasattr(tl, "lp_lines"):
624+
tl.lp_lines = collections.deque()
625+
return tl.lp_lines
626+
return self.__dict__.setdefault("_lp_lines", collections.deque())
627+
628+
@lp_lines.setter
629+
def lp_lines(self, value):
630+
if self.batch:
631+
self.__dict__.setdefault("_tl", threading.local()).lp_lines = value
632+
else:
633+
self.__dict__["_lp_lines"] = value
634+
617635
def __init__(self, exe: str, args: list[str], cwd: str | None = None, log: str | None = None,
618636
env: dict | None = None, lazy: bool = False, cpus: list[int] | None = None):
619637
self.spawn = (exe, list(args), cwd, log, env, False, cpus) # to start it again after it died (issue #27)
@@ -1035,18 +1053,34 @@ def _send(self, text: str):
10351053
except (OSError, AttributeError): # the pipe is gone (or a restart's close() took the process):
10361054
raise EngineDied(f"the engine stopped unexpectedly (exit code {self.exit_code()})") from None
10371055

1038-
def _control(self, cancel, on_token, stop_when=None):
1056+
def _control(self, cancel, on_token, stop_when=None, want_lp=False):
10391057
"""Reads the control lines of the request on them (GEN / BGEN), yielding None heartbeats. Calls on_token(id)
10401058
for each `T`; returns ("done", None) at DONE, or ("badm", continues) at BADM (after DONE). `stop_when()`
1041-
true sends STOP once (the request is then read to its DONE)."""
1059+
true sends STOP once (the request is then read to its DONE). `want_lp` (logprobs): a `T` is held until the
1060+
next line, and its `LP` line (the engine writes it right after the token, in the same flush) goes to lp_lines
1061+
before the token is passed on, so whoever takes the token finds it; any other line, or 0.5 s of silence,
1062+
passes the token on first (an engine without LP lines)."""
10421063
stopped = False
10431064
beat = time.monotonic()
1065+
held = None
1066+
1067+
def pass_on(t):
1068+
nonlocal stopped
1069+
on_token(t)
1070+
if not stopped and (cancel.is_set() or (stop_when is not None and stop_when())):
1071+
self._send("STOP")
1072+
stopped = True
10441073
while True:
10451074
try:
10461075
# A client can leave before the first PP/token. Poll its Event as often as _take_control does;
10471076
# the ten-second HTTP heartbeat is independent of how promptly STOP reaches the engine.
10481077
line = self.lines.get(timeout=0.5)
10491078
except queue.Empty:
1079+
if held is not None:
1080+
t, held = held, None
1081+
pass_on(t)
1082+
yield False
1083+
continue
10501084
if cancel.is_set() and not stopped:
10511085
self._send("STOP")
10521086
stopped = True
@@ -1059,11 +1093,19 @@ def _control(self, cancel, on_token, stop_when=None):
10591093
raise EngineDied(f"the engine stopped unexpectedly (exit code {self.exit_code()})")
10601094
if not line.startswith(("PP ", "INFO")):
10611095
btrace("ctl<", self._ctl_mode, line.strip()[:60])
1096+
if held is not None:
1097+
t, held = held, None
1098+
if line.startswith("LP "):
1099+
self.lp_lines.append((t, line))
1100+
pass_on(t)
1101+
yield False # a token is pending (not a heartbeat)
1102+
if line.startswith("LP "):
1103+
continue
10621104
if line.startswith("T "):
1063-
on_token(int(line[2:]))
1064-
if not stopped and (cancel.is_set() or (stop_when is not None and stop_when())):
1065-
self._send("STOP")
1066-
stopped = True
1105+
if want_lp:
1106+
held = int(line[2:])
1107+
continue
1108+
pass_on(int(line[2:]))
10671109
yield False # a token is pending (not a heartbeat)
10681110
elif line.startswith("PP "):
10691111
f = line.split()
@@ -1255,6 +1297,8 @@ def generate_batched(self, ids, max_new, sampling, cancel, embeddings=None):
12551297
self.progress, self.progress_ms, self.reused = None, 0, 0
12561298
born = self.gen # the engine process this request is sent to
12571299
keys = self.sampling_keys(sampling or {})
1300+
want_lp = top_logprobs(sampling or {}) is not None
1301+
self.lp_lines = collections.deque() # this request's (per thread with batch slots)
12581302
out: list[int] = []
12591303
pending: list[int] = []
12601304
prompt, left = list(ids), int(max_new)
@@ -1287,7 +1331,7 @@ def generate_batched(self, ids, max_new, sampling, cancel, embeddings=None):
12871331
def others():
12881332
with self.slot_cv:
12891333
return self.waiting > 0
1290-
for x in self._control(cancel, pending.append, stop_when=others):
1334+
for x in self._control(cancel, pending.append, stop_when=others, want_lp=want_lp):
12911335
while pending:
12921336
t = pending.pop(0)
12931337
out.append(t)
@@ -1392,7 +1436,7 @@ def others():
13921436
self._ctl_mode, self._ctl_result, self._yielded = "batch", None, None
13931437
self._ctl_erred = False
13941438
asked = False
1395-
for x in self._control(cancel, pending.append):
1439+
for x in self._control(cancel, pending.append, want_lp=want_lp):
13961440
while pending:
13971441
t = pending.pop(0)
13981442
out.append(t)
@@ -5370,6 +5414,11 @@ def _openai(self, req):
53705414
svc.load()
53715415
max_req = max_new = int(req.get("max_completion_tokens") or req.get("max_tokens") or 0) # 0/-1: the rest
53725416
bias_api.validate_request(req, svc.engine, len(getattr(svc.tok, "tokens", ())) or None)
5417+
if top_logprobs(req) is not None and getattr(svc.engine, "batch", 0) and max_req != 1:
5418+
# a batch slot's BT lines carry no LP lines: past its first token a request would get partial or
5419+
# misplaced entries. One token (a typed decision) is read on the control lines and has its LP line.
5420+
raise ValueError('logprobs: with "parallel" (batch slots) only for max_tokens 1; '
5421+
'run without "parallel" for longer replies')
53735422
use_mcp = req.get("strata_mcp") is True and svc.mcp is not None # the web app's opt-in (serve/mcp.py)
53745423
own = {t.get("name") for t in tools or [] if isinstance(t, dict)} # #592: a second line of defence
53755424
if use_mcp:

‎serve/test_logprobs_parallel.py‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Logprobs with "parallel" (batch slots): python -m unittest serve.test_logprobs_parallel.
2+
3+
The fake engine of serve/test_parallel.py behind the real StrataEngine and Service, writing an LP line after each
4+
T line when the GEN / BGEN line asks for logprobs. A typed decision (max_tokens 1) is read on the control lines (an
5+
admission: BGEN, one T, DONE, BADM 0) and gets its logprobs; longer replies are refused with a 400, since the slot's
6+
BT lines carry none."""
7+
import json
8+
import threading
9+
import unittest
10+
import urllib.error
11+
import urllib.request
12+
from unittest import mock
13+
14+
import serve.test_parallel as tp
15+
16+
# LP after each T when asked: the token's log-probability is -(prompt length) / 1000 (so two requests can be told
17+
# apart) and "A" (65) is its one alternative at -1.5
18+
_T_LINE = ' print(f"T {t}", flush=True)\n'
19+
assert tp.FAKE_BATCH.count(_T_LINE) == 1
20+
FAKE_LP = tp.FAKE_BATCH.replace(_T_LINE, _T_LINE + (
21+
' if " logprobs=" in line:\n'
22+
' print(f"LP {-len(ids) / 1000:.6f} 65:-1.500000", flush=True)\n'))
23+
24+
25+
class LogprobsWithBatchSlots(unittest.TestCase):
26+
start, tearDown, get = tp.ParallelService.start, tp.ParallelService.tearDown, tp.ParallelService.get
27+
28+
def chat(self, text, max_tokens=1, logprobs=True):
29+
body = {"messages": [{"role": "user", "content": text}], "max_tokens": max_tokens,
30+
"reasoning_effort": "none"}
31+
if logprobs:
32+
body |= {"logprobs": True, "top_logprobs": 1}
33+
req = urllib.request.Request(self.base + "/v1/chat/completions", data=json.dumps(body).encode(),
34+
headers={"Content-Type": "application/json"})
35+
with urllib.request.urlopen(req, timeout=60) as r:
36+
return json.loads(r.read().decode())
37+
38+
def start_lp(self, fake=FAKE_LP):
39+
with mock.patch.object(tp, "FAKE_BATCH", fake):
40+
self.start(2)
41+
42+
@staticmethod
43+
def entries(reply):
44+
return ((reply["choices"][0].get("logprobs") or {}).get("content")) or []
45+
46+
def test_a_decision_gets_its_logprobs(self):
47+
self.start_lp()
48+
r = self.chat("Is it A or B?")
49+
self.assertEqual(r["choices"][0]["message"]["content"], "o")
50+
e = self.entries(r)
51+
self.assertEqual(len(e), 1)
52+
self.assertEqual(e[0]["token"], "o")
53+
self.assertLess(e[0]["logprob"], 0)
54+
self.assertEqual(e[0]["top_logprobs"], [{"token": "A", "logprob": -1.5, "bytes": [65]}])
55+
self.assertTrue(any(x.startswith("BGEN") for x in self.log.read_text().splitlines())) # an admission
56+
57+
def test_two_at_once_each_get_their_own(self):
58+
self.start_lp()
59+
short, long_ = "A or B?", "A or B? " + "x" * 300
60+
alone = {t: self.entries(self.chat(t))[0]["logprob"] for t in (short, long_)}
61+
self.assertNotEqual(alone[short], alone[long_])
62+
for _ in range(5):
63+
got, errs = {}, []
64+
65+
def run(t):
66+
try:
67+
got[t] = self.entries(self.chat(t))[0]["logprob"]
68+
except Exception as exc: # noqa: BLE001 - reported below
69+
errs.append(repr(exc))
70+
th = [threading.Thread(target=run, args=(t,)) for t in (short, long_, short, long_)]
71+
for x in th:
72+
x.start()
73+
for x in th:
74+
x.join(60)
75+
self.assertEqual(errs, [])
76+
self.assertEqual(got, alone)
77+
78+
def test_not_asked_nothing_changes(self):
79+
self.start_lp()
80+
r = self.chat("Is it A or B?", logprobs=False)
81+
self.assertEqual(r["choices"][0]["message"]["content"], "o")
82+
self.assertIsNone(r["choices"][0].get("logprobs"))
83+
84+
def test_longer_replies_are_refused_with_batch_slots(self):
85+
self.start_lp()
86+
with self.assertRaises(urllib.error.HTTPError) as cm:
87+
self.chat("Is it A or B?", max_tokens=8)
88+
self.assertEqual(cm.exception.code, 400)
89+
self.assertIn("max_tokens 1", cm.exception.read().decode())
90+
self.assertEqual(self.chat("still serving?", max_tokens=16, logprobs=False)["choices"][0]["message"]["content"],
91+
"ok, done.")
92+
93+
def test_an_engine_without_lp_lines(self):
94+
self.start_lp(fake=tp.FAKE_BATCH)
95+
r = self.chat("Is it A or B?")
96+
self.assertEqual(r["choices"][0]["message"]["content"], "o")
97+
self.assertEqual(self.entries(r), [])
98+
99+
100+
if __name__ == "__main__":
101+
unittest.main()

0 commit comments

Comments
 (0)