Skip to content

Commit ed1fca4

Browse files
authored
Thread local generation stream (#1090)
1 parent 4f5cbd2 commit ed1fca4

6 files changed

Lines changed: 117 additions & 98 deletions

File tree

mlx_lm/generate.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ def setup_arg_parser():
223223

224224

225225
# A stream on the default device just for generation
226-
generation_stream = mx.new_stream(mx.default_device())
226+
generation_stream = mx.new_thread_local_stream(mx.default_device())
227227

228228

229229
@contextlib.contextmanager
@@ -1497,6 +1497,7 @@ class BatchGenerator:
14971497
def __init__(
14981498
self,
14991499
model: nn.Module,
1500+
*,
15001501
max_tokens: int = 128,
15011502
stop_tokens: Optional[Sequence[Sequence[int]]] = None,
15021503
sampler: Optional[Callable[[mx.array], mx.array]] = None,
@@ -1507,6 +1508,7 @@ def __init__(
15071508
prefill_batch_size: int = 8,
15081509
prefill_step_size: int = 2048,
15091510
max_kv_size: Optional[int] = None,
1511+
stream=None,
15101512
):
15111513
self.model = model
15121514
self.max_tokens = max_tokens
@@ -1518,6 +1520,8 @@ def __init__(
15181520
self.completion_batch_size = max(completion_batch_size, prefill_batch_size)
15191521
self.max_kv_size = max_kv_size
15201522

1523+
self._stream = stream or generation_stream
1524+
15211525
self._default_state_machine = SequenceStateMachine(
15221526
{"normal": [(seq, None) for seq in stop_tokens]} if stop_tokens else {},
15231527
initial="normal",
@@ -1544,9 +1548,13 @@ def __init__(
15441548
else:
15451549
self._old_wired_limit = None
15461550

1551+
@property
1552+
def stream(self):
1553+
return self._stream
1554+
15471555
def close(self):
15481556
if self._old_wired_limit is not None:
1549-
mx.synchronize(generation_stream)
1557+
mx.synchronize(self._stream)
15501558
mx.set_wired_limit(self._old_wired_limit)
15511559
self._old_wired_limit = None
15521560

@@ -1843,7 +1851,7 @@ def next(self):
18431851
Returns:
18441852
Tuple of prompt processing responses and generation responses.
18451853
"""
1846-
with mx.stream(generation_stream):
1854+
with mx.stream(self._stream):
18471855
return self._next()
18481856

18491857
def next_generated(self):
@@ -1853,7 +1861,7 @@ def next_generated(self):
18531861
Returns:
18541862
List of GenerationBatch.Response objects
18551863
"""
1856-
with mx.stream(generation_stream):
1864+
with mx.stream(self._stream):
18571865
while True:
18581866
prompt_responses, generation_responses = self._next()
18591867
if not generation_responses and prompt_responses:

mlx_lm/server.py

Lines changed: 95 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@
3636
from .generate import (
3737
BatchGenerator,
3838
SequenceStateMachine,
39-
generation_stream,
4039
stream_generate,
4140
)
4241
from .models.cache import (
@@ -279,8 +278,7 @@ def __next__(self):
279278
self._loops += 1
280279
self._time_spent += time.time() - self._start
281280
if self._loops % self._sync_frequency == 0:
282-
with mx.stream(generation_stream):
283-
loop_time = mx.distributed.all_sum(self._time_spent).item()
281+
loop_time = mx.distributed.all_sum(self._time_spent).item()
284282
avg_loop_time = loop_time / (
285283
mx.distributed.init().size() * self._sync_frequency
286284
)
@@ -308,94 +306,92 @@ def __init__(self, cli_args: argparse.Namespace):
308306
)
309307
self.is_distributed = group.size() > 1
310308

311-
# Preload the default model if it is provided
312-
self.default_model_map = {}
313-
if self.cli_args.model is not None:
314-
self.default_model_map[self.cli_args.model] = "default_model"
315-
self.load(self.cli_args.model, draft_model_path="default_model")
309+
# Maps model and adapter paths the actual paths to be used. Used to
310+
# map 'default_model' to the provided model by cli argument but could
311+
# be used for more in the future.
312+
self._model_map = {}
313+
self._adapter_map = {}
314+
self._draft_model_map = {}
315+
self._model_map["default_model"] = self.cli_args.model
316+
self._adapter_map["default_model"] = self.cli_args.adapter_path
317+
self._draft_model_map["default_model"] = self.cli_args.draft_model
318+
319+
# Build the tokenizer config for later use in load
320+
self._tokenizer_config = {
321+
"trust_remote_code": True if cli_args.trust_remote_code else None
322+
}
323+
if cli_args.chat_template:
324+
self._tokenizer_config["chat_template"] = cli_args.chat_template
316325

317-
# Added in adapter_path to load dynamically
318-
def load(self, model_path, adapter_path=None, draft_model_path=None):
319-
model_path = self.default_model_map.get(model_path, model_path)
320-
if self.model_key == (model_path, adapter_path, draft_model_path):
321-
return self.model, self.tokenizer
326+
def _load(self, model_path, adapter_path=None, draft_model_path=None):
327+
if self.is_distributed and (
328+
adapter_path is not None or draft_model_path is not None
329+
):
330+
raise ValueError(
331+
"Loading with adapters or draft models not supported in distributed mode"
332+
)
322333

323334
# Remove the old model if it exists.
335+
self.model_key = None
324336
self.model = None
325337
self.tokenizer = None
326-
self.model_key = None
327338
self.draft_model = None
328339

329-
# Building tokenizer_config
330-
tokenizer_config = {
331-
"trust_remote_code": True if self.cli_args.trust_remote_code else None
332-
}
333-
if self.cli_args.chat_template:
334-
tokenizer_config["chat_template"] = self.cli_args.chat_template
335-
336-
if model_path == "default_model":
337-
if self.cli_args.model is None:
338-
raise ValueError(
339-
"A model path has to be given as a CLI "
340-
"argument or in the HTTP request"
341-
)
342-
adapter_path = adapter_path or self.cli_args.adapter_path
343-
# TODO: Generalize distributed load
344-
if self.is_distributed:
345-
model, tokenizer = sharded_load(
346-
self.cli_args.model, self.pipeline_group, self.tensor_group
347-
)
348-
else:
349-
model, tokenizer = load(
350-
self.cli_args.model,
351-
adapter_path=adapter_path,
352-
tokenizer_config=tokenizer_config,
353-
)
340+
# Load the model and tokenizer
341+
if self.is_distributed:
342+
model, tokenizer = sharded_load(
343+
model_path,
344+
pipeline_group=self.pipeline_group,
345+
tensor_group=self.tensor_group,
346+
tokenizer_config=self._tokenizer_config,
347+
)
354348
else:
355-
# TODO: Generalize distributed load
356-
if self.is_distributed:
357-
model, tokenizer = sharded_load(
358-
model_path, self.pipeline_group, self.tensor_group
359-
)
360-
else:
361-
model, tokenizer = load(
362-
model_path,
363-
adapter_path=adapter_path,
364-
tokenizer_config=tokenizer_config,
365-
)
349+
model, tokenizer = load(
350+
model_path,
351+
adapter_path=adapter_path,
352+
tokenizer_config=self._tokenizer_config,
353+
)
366354

355+
# Use the default chat template if needed
367356
if self.cli_args.use_default_chat_template:
368357
if tokenizer.chat_template is None:
369358
tokenizer.chat_template = tokenizer.default_chat_template
370359

371-
self.model_key = (model_path, adapter_path, draft_model_path)
372-
self.model = model
373-
self.tokenizer = tokenizer
374-
375-
def validate_draft_tokenizer(draft_tokenizer):
376-
# Check if tokenizers are compatible
360+
# Load the draft model for speculative decoding
361+
draft_model = None
362+
if draft_model_path is not None:
363+
draft_model, draft_tokenizer = load(draft_model_path)
377364
if draft_tokenizer.vocab_size != tokenizer.vocab_size:
378365
logging.warning(
379366
"Draft model tokenizer does not match model tokenizer. "
380367
"Speculative decoding may not work as expected."
381368
)
382369

383-
# Load draft model if specified
384-
if (
385-
draft_model_path == "default_model"
386-
and self.cli_args.draft_model is not None
387-
):
388-
self.draft_model, draft_tokenizer = load(self.cli_args.draft_model)
389-
validate_draft_tokenizer(draft_tokenizer)
370+
# Compute batchability
371+
is_batchable = draft_model is None
372+
is_batchable = is_batchable and all(
373+
hasattr(c, "merge") for c in make_prompt_cache(model)
374+
)
375+
376+
# Update the member variables
377+
self.model_key = (model_path, adapter_path, draft_model_path)
378+
self.model = model
379+
self.tokenizer = tokenizer
380+
self.draft_model = draft_model
381+
self.is_batchable = is_batchable
390382

391-
elif draft_model_path is not None and draft_model_path != "default_model":
392-
self.draft_model, draft_tokenizer = load(draft_model_path)
393-
validate_draft_tokenizer(draft_tokenizer)
383+
def load_default(self):
384+
if self._model_map["default_model"] is not None:
385+
self.load("default_model", None, "default_model")
394386

395-
if self.draft_model is None:
396-
self.is_batchable = all(
397-
hasattr(c, "merge") for c in make_prompt_cache(self.model)
398-
)
387+
def load(self, model_path, adapter_path=None, draft_model_path=None):
388+
model_path = self._model_map.get(model_path, model_path)
389+
adapter_path = self._adapter_map.get(model_path, adapter_path)
390+
draft_model_path = self._draft_model_map.get(draft_model_path, draft_model_path)
391+
392+
model_key = (model_path, adapter_path, draft_model_path)
393+
if self.model_key != model_key:
394+
self._load(*model_key)
399395

400396
return self.model, self.tokenizer
401397

@@ -489,22 +485,21 @@ def _share_object(self, obj):
489485
if not self._is_distributed:
490486
return obj
491487

492-
with mx.stream(generation_stream):
493-
if self._rank == 0:
494-
if obj is None:
495-
mx.eval(mx.distributed.all_sum(0))
496-
return None
497-
data = mx.array(pickle.dumps(obj))
498-
mx.eval(mx.distributed.all_sum(data.size))
499-
mx.eval(mx.distributed.all_sum(data))
500-
return obj
501-
else:
502-
size = mx.distributed.all_sum(0).item()
503-
if size == 0:
504-
return None
505-
data = mx.zeros(size, dtype=mx.uint8)
506-
data = mx.distributed.all_sum(data)
507-
return pickle.loads(data)
488+
if self._rank == 0:
489+
if obj is None:
490+
mx.eval(mx.distributed.all_sum(0))
491+
return None
492+
data = mx.array(pickle.dumps(obj))
493+
mx.eval(mx.distributed.all_sum(data.size))
494+
mx.eval(mx.distributed.all_sum(data))
495+
return obj
496+
else:
497+
size = mx.distributed.all_sum(0).item()
498+
if size == 0:
499+
return None
500+
data = mx.zeros(size, dtype=mx.uint8)
501+
data = mx.distributed.all_sum(data)
502+
return pickle.loads(data)
508503

509504
def _share_request(self, request):
510505
if not self._is_distributed:
@@ -691,6 +686,14 @@ def _is_batchable(self, args):
691686
return self.model_provider.is_batchable and args.seed is None
692687

693688
def _generate(self):
689+
# Local thread stream that we 'll pass to the BatchGenerator to make
690+
# sure that all generation runs in the same stream as the
691+
# synchronization messages.
692+
generation_stream = mx.default_stream(mx.default_device())
693+
694+
# Load the default model if it is given
695+
self.model_provider.load_default()
696+
694697
current_model = None
695698
current_sampling = None
696699
current_tokenizer = None
@@ -820,6 +823,7 @@ def get_next_request(timeout=None):
820823
completion_batch_size=self.cli_args.decode_concurrency,
821824
prefill_batch_size=self.cli_args.prompt_concurrency,
822825
prefill_step_size=self.cli_args.prefill_step_size,
826+
stream=generation_stream,
823827
)
824828
unprocessed_requests.append((rqueue, request, args))
825829
continue
@@ -909,12 +913,11 @@ def get_next_request(timeout=None):
909913

910914
uids_to_remove = self._share_object(uids_to_remove)
911915
if uids_to_remove:
912-
with mx.stream(generation_stream):
913-
batch_generator.remove(uids_to_remove)
914-
for uid in uids_to_remove:
915-
# It may have already been removed during
916-
# generation
917-
batch_results.pop(uid, None)
916+
batch_generator.remove(uids_to_remove)
917+
for uid in uids_to_remove:
918+
# It may have already been removed during
919+
# generation
920+
batch_results.pop(uid, None)
918921

919922
def _serve_single(self, request):
920923
rqueue, request, args = request

mlx_lm/utils.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,8 @@ def sharded_load(
507507
pipeline_group: Optional[mx.distributed.Group] = None,
508508
tensor_group: Optional[mx.distributed.Group] = None,
509509
return_config: bool = False,
510+
*,
511+
tokenizer_config: Optional[Dict[str, Any]] = None,
510512
):
511513
# Get model path with everything but weight safetensors
512514
model_path = _download(
@@ -571,7 +573,7 @@ def sharded_load(
571573
# Load and shard the model, and load the weights
572574
tokenizer = load_tokenizer(
573575
model_path,
574-
{"trust_remote_code": True},
576+
tokenizer_config or {"trust_remote_code": True},
575577
eos_token_ids=config.get("eos_token_id", None),
576578
)
577579
model, _ = load_model(model_path, lazy=True, strict=False)

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from _version import __version__
1212

13-
MIN_MLX_VERSION = "0.30.4"
13+
MIN_MLX_VERSION = "0.31.2"
1414

1515
setup(
1616
name="mlx-lm",

tests/test_gguf.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ def test_convert_to_gguf(
3535
mock_tokenizer.get_vocab.return_value = {"<pad>": 0, "hello": 1, "world": 2}
3636
mock_tokenizer.all_special_tokens = ["<pad>"]
3737
mock_tokenizer.all_special_ids = [0]
38+
mock_tokenizer.bos_token_id = None
39+
mock_tokenizer.eos_token_id = None
40+
mock_tokenizer.unk_token_id = None
3841
mock_from_pretrained.return_value = mock_tokenizer
3942

4043
model_path = Path(self.test_dir)

tests/test_server.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ def load(self, model, adapter=None, draft_model=None):
6868
assert model in ["default_model", "chat_model"]
6969
return self.model, self.tokenizer
7070

71+
def load_default(self):
72+
return self.load("default_model", None, "default_model")
73+
7174

7275
class MockCache:
7376
def __init__(self, value, is_trimmable: bool = True):

0 commit comments

Comments
 (0)