Skip to content

Commit 19bc7df

Browse files
committed
Add progress and fixes
- Fix access to per sample logprobs - Compute more than 1 token at a time in server batch generate
1 parent 68bb76e commit 19bc7df

2 files changed

Lines changed: 136 additions & 42 deletions

File tree

mlx_lm/generate.py

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ def generate_step(
308308
kv_bits: Optional[int] = None,
309309
kv_group_size: int = 64,
310310
quantized_kv_start: int = 0,
311-
prompt_progress_callback: Optional[Callable[[int], int]] = None,
311+
prompt_progress_callback: Optional[Callable[[int, int], None]] = None,
312312
input_embeddings: Optional[mx.array] = None,
313313
) -> Generator[Tuple[mx.array, mx.array], None, None]:
314314
"""
@@ -334,7 +334,7 @@ def generate_step(
334334
kv_group_size (int): Group size for KV cache quantization. Default: ``64``.
335335
quantized_kv_start (int): Step to begin using a quantized KV cache.
336336
when ``kv_bits`` is non-None. Default: ``0``.
337-
prompt_progress_callback (Callable[[int], int]): A call-back which takes the
337+
prompt_progress_callback (Callable[[int, int], None]): A call-back which takes the
338338
prompt tokens processed so far and the total number of prompt tokens.
339339
input_embeddings (mx.array, optional): Input embeddings to use instead of or in
340340
conjunction with prompt tokens. Default: ``None``.
@@ -846,18 +846,18 @@ def __len__(self):
846846

847847
def filter(self, keep_idx: List[int]):
848848
self.uids = [self.uids[k] for k in keep_idx]
849+
self.logprobs = [self.logprobs[k] for k in keep_idx]
849850
self.max_tokens = [self.max_tokens[k] for k in keep_idx]
850851
self.num_tokens = [self.num_tokens[k] for k in keep_idx]
851852
keep_idx = mx.array(keep_idx, mx.int32)
852853
self.y = self.y[keep_idx]
853-
self.logprobs = self.logprobs[keep_idx]
854854
for c in self.cache:
855855
c.filter(keep_idx)
856856

857857
def extend(self, other):
858858
self.uids.extend(other.uids)
859859
self.y = mx.concatenate([self.y, other.y])
860-
self.logprobs = mx.concatenate([self.logprobs, other.logprobs])
860+
self.logprobs.extend(other.logprobs)
861861
self.num_tokens.extend(other.num_tokens)
862862
self.max_tokens.extend(other.max_tokens)
863863
for c, o in zip(self.cache, other.cache):
@@ -930,6 +930,9 @@ def __init__(
930930
completion_batch_size: int = 32,
931931
prefill_batch_size: int = 8,
932932
prefill_step_size: int = 2048,
933+
prompt_progress_callback: Optional[
934+
Callable[[List[Tuple[int, int, int]]], None]
935+
] = None,
933936
):
934937
self.model = model
935938
self.unprocessed_prompts = []
@@ -940,6 +943,7 @@ def __init__(
940943
self.prefill_step_size = prefill_step_size
941944
self.prefill_batch_size = prefill_batch_size
942945
self.completion_batch_size = max(completion_batch_size, prefill_batch_size)
946+
self.prompt_progress_callback = prompt_progress_callback or (lambda *_: None)
943947
self._stats = BatchStats()
944948

945949
self.active_batch = None
@@ -968,6 +972,20 @@ def insert(
968972
)
969973
return uids
970974

975+
def remove(self, uids: List[int]):
976+
uids = set(uids)
977+
if self.active_batch is not None:
978+
batch = self.active_batch
979+
keep_idx = [e for e, uid in enumerate(batch.uids) if uid not in uids]
980+
if len(keep_idx) > 0:
981+
batch.filter(keep_idx)
982+
else:
983+
self.active_batch = None
984+
985+
for i in reversed(range(len(self.unprocessed_prompts))):
986+
if self.unprocessed_prompts[i][0] in uids:
987+
self.unprocessed_prompts.pop(i)
988+
971989
def _process_prompts(self, prompts):
972990
uids, inputs, max_tokens, caches = zip(*prompts)
973991

@@ -979,6 +997,8 @@ def _process_prompts(self, prompts):
979997

980998
self._stats.prompt_tokens += sum(lengths)
981999

1000+
processed_tokens = 0
1001+
9821002
# New prompts so
9831003
# 1. Left-pad the inputs
9841004
# 2. Process
@@ -991,6 +1011,13 @@ def _process_prompts(self, prompts):
9911011
self.model(inputs[:, :n_to_process], cache=prompt_cache)
9921012
mx.eval([c.state for c in prompt_cache])
9931013
inputs = inputs[:, n_to_process:]
1014+
processed_tokens += n_to_process
1015+
self.prompt_progress_callback(
1016+
[
1017+
(uid, processed_tokens, length)
1018+
for uid, length in zip(uids, lengths)
1019+
]
1020+
)
9941021
mx.clear_cache()
9951022

9961023
# Further prompt processing so we need to
@@ -1011,6 +1038,13 @@ def _process_prompts(self, prompts):
10111038
self.model(inputs[:, :n_to_process], cache=prompt_cache)
10121039
mx.eval([c.state for c in prompt_cache])
10131040
inputs = inputs[:, n_to_process:]
1041+
processed_tokens += n_to_process
1042+
self.prompt_progress_callback(
1043+
[
1044+
(uid, processed_tokens, length)
1045+
for uid, length in zip(uids, lengths)
1046+
]
1047+
)
10141048
mx.clear_cache()
10151049

10161050
for c in prompt_cache:
@@ -1030,7 +1064,7 @@ def _step(self, input_tokens: mx.array, prompt_cache: List[Any]):
10301064
logits = logits[:, -1, :]
10311065
logprobs = logits - mx.logsumexp(logits, axis=-1, keepdims=True)
10321066
sampled = self.sampler(logprobs)
1033-
return sampled, logprobs
1067+
return sampled, list(logprobs)
10341068

10351069
def stats(self):
10361070
self._stats.prompt_tps = self._stats.prompt_tokens / self._stats.prompt_time

mlx_lm/server.py

Lines changed: 97 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from threading import Condition, Lock, Thread
1919
from typing import (
2020
Any,
21+
Callable,
2122
Dict,
2223
List,
2324
Literal,
@@ -357,6 +358,11 @@ class GenerationContext:
357358
stop_token_sequences: List[List[int]]
358359
prompt: List[int]
359360

361+
_should_stop: bool = False
362+
363+
def stop(self):
364+
self._should_stop = True
365+
360366

361367
@dataclass
362368
class Response:
@@ -512,9 +518,14 @@ def get_next_request():
512518
except QueueEmpty:
513519
return None
514520

521+
def progress_callback(info):
522+
for uid, processed, total in info:
523+
if uid in batch_results:
524+
batch_results[uid]["rqueue"].put((min(processed, total), total))
525+
515526
while True:
516527
request = None
517-
if not drain_batch and len(batch_results) < 100:
528+
if not drain_batch:
518529
request = get_next_request()
519530

520531
# We got a request
@@ -554,6 +565,7 @@ def get_next_request():
554565
[rest], args.max_tokens, caches=[cache]
555566
)
556567
batch_results[uid] = {
568+
"ctx": ctx,
557569
"cache_key": prompt[:],
558570
"rqueue": rqueue,
559571
"detokenizer": tokenizer.detokenizer,
@@ -596,6 +608,7 @@ def get_next_request():
596608
tokenizer.encode("\n"),
597609
],
598610
),
611+
prompt_progress_callback=progress_callback,
599612
)
600613
unprocessed_requests.append((rqueue, request, args))
601614
continue
@@ -619,43 +632,63 @@ def get_next_request():
619632
drain_batch = False
620633
continue
621634

622-
responses = batch_generator.next()
623-
for r in responses:
624-
result = batch_results[r.uid]
625-
result["cache_key"].append(r.token)
626-
result["detokenizer"].add_token(r.token)
627-
628-
top_tokens = None
629-
if args.logprobs > 0:
630-
sorted_indices = mx.argpartition(
631-
-gen.logprobs, kth=args.logprobs - 1
632-
)
633-
top_indices = sorted_indices[: args.logprobs]
634-
top_logprobs = gen.logprobs[top_indices]
635-
top_token_info = zip(
636-
top_indices.tolist(), top_logprobs.tolist()
637-
)
638-
top_tokens = tuple(top_token_info)
639-
result["rqueue"].put(
640-
Response(
641-
result["detokenizer"].last_segment,
642-
r.token,
643-
r.logprobs[r.token].item(),
644-
r.finish_reason,
645-
top_tokens,
635+
uids_to_remove = []
636+
time_budget = 0.5
637+
start = time.time()
638+
while True:
639+
if time.time() - start > time_budget:
640+
break
641+
642+
responses = batch_generator.next()
643+
if not responses:
644+
break
645+
646+
for r in responses:
647+
result = batch_results[r.uid]
648+
result["cache_key"].append(r.token)
649+
result["detokenizer"].add_token(r.token)
650+
651+
top_tokens = None
652+
if args.logprobs > 0:
653+
sorted_indices = mx.argpartition(
654+
-gen.logprobs, kth=args.logprobs - 1
655+
)
656+
top_indices = sorted_indices[: args.logprobs]
657+
top_logprobs = gen.logprobs[top_indices]
658+
top_token_info = zip(
659+
top_indices.tolist(), top_logprobs.tolist()
660+
)
661+
top_tokens = tuple(top_token_info)
662+
result["rqueue"].put(
663+
Response(
664+
result["detokenizer"].last_segment,
665+
r.token,
666+
r.logprobs[r.token].item(),
667+
r.finish_reason,
668+
top_tokens,
669+
)
646670
)
647-
)
648671

649-
if r.finish_reason is not None:
650-
result["rqueue"].put(None)
651-
self.prompt_cache.insert_cache(
652-
current_model_key, result["cache_key"], r.prompt_cache
653-
)
654-
del batch_results[r.uid]
672+
if r.finish_reason is not None:
673+
result["rqueue"].put(None)
674+
self.prompt_cache.insert_cache(
675+
current_model_key, result["cache_key"], r.prompt_cache
676+
)
677+
del batch_results[r.uid]
678+
679+
if result["ctx"]._should_stop:
680+
uids_to_remove.append(r.uid)
681+
682+
if uids_to_remove:
683+
batch_generator.remove(uids_to_remove)
655684

656685
def _serve_single(self, request):
657686
rqueue, request, args = request
658687

688+
# Define the progress callback
689+
def progress(tokens_processed, tokens_total):
690+
rqueue.put((tokens_processed, tokens_total))
691+
659692
try:
660693
# Load the model and tokenizer
661694
model, tokenizer = self.model_provider.load(
@@ -724,7 +757,7 @@ def _serve_single(self, request):
724757
prompt_cache=cache,
725758
draft_model=draft_model,
726759
num_draft_tokens=args.num_draft_tokens,
727-
# TODO: prompt progress callback
760+
prompt_progress_callback=progress,
728761
):
729762
top_tokens = None
730763
if args.logprobs > 0:
@@ -746,6 +779,10 @@ def _serve_single(self, request):
746779
)
747780
)
748781
cache_key.append(gen.token)
782+
783+
if ctx._should_stop:
784+
break
785+
749786
rqueue.put(None)
750787

751788
# Save the KV cache again
@@ -760,6 +797,7 @@ def generate(
760797
self,
761798
request: CompletionRequest,
762799
generation_args: GenerationArguments,
800+
progress_callback: Optional[Callable[[int, int], None]] = None,
763801
):
764802
response_queue = Queue()
765803
self.requests.put((response_queue, request, generation_args))
@@ -771,6 +809,10 @@ def _inner():
771809
break
772810
if isinstance(response, Exception):
773811
raise response
812+
if isinstance(response, tuple):
813+
if progress_callback is not None:
814+
progress_callback(*response)
815+
continue
774816
yield response
775817

776818
ctx = response_queue.get()
@@ -1097,9 +1139,29 @@ def handle_completion(self, request: CompletionRequest, stop_words: List[str]):
10971139
seed=self.seed,
10981140
)
10991141

1142+
# Create keepalive callback to send SSE comments during long prompt processing
1143+
def keepalive_callback(processed_tokens, total_tokens):
1144+
logging.info(
1145+
f"Prompt processing progress: {processed_tokens}/{total_tokens}"
1146+
)
1147+
if self.stream:
1148+
try:
1149+
# Send SSE comment for keepalive - invisible to clients but keeps connection alive
1150+
self.wfile.write(
1151+
f": keepalive {processed_tokens}/{total_tokens}\n\n".encode()
1152+
)
1153+
self.wfile.flush()
1154+
except (BrokenPipeError, ConnectionResetError, OSError):
1155+
# Client disconnected, ignore
1156+
pass
1157+
11001158
# Create the token generator
11011159
try:
1102-
ctx, response = self.response_generator.generate(request, args)
1160+
ctx, response = self.response_generator.generate(
1161+
request,
1162+
args,
1163+
progress_callback=keepalive_callback,
1164+
)
11031165
except Exception as e:
11041166
self._set_completion_headers(404)
11051167
self.end_headers()
@@ -1160,14 +1222,12 @@ def handle_completion(self, request: CompletionRequest, stop_words: List[str]):
11601222
top_tokens.append(gen.top_tokens)
11611223

11621224
# Check if we should stop early
1163-
# TODO: This doesn't actually stop generation in the generation
1164-
# thread we should probably have a way to do that via the ctx
1165-
# object
11661225
stop_condition = stopping_criteria(
11671226
tokens, ctx.stop_token_sequences, stop_words, ctx.eos_token_id
11681227
)
11691228
if stop_condition.stop_met:
11701229
finish_reason = "stop"
1230+
ctx.stop()
11711231
tokens = tokens[: len(tokens) - stop_condition.trim_length]
11721232
text = text[: len(text) - stop_condition.trim_text_length]
11731233
segment = ""

0 commit comments

Comments
 (0)