44import copy
55import json
66import logging
7+ import pickle
78import platform
89import socket
910import time
1516from pathlib import Path
1617from queue import Empty as QueueEmpty
1718from queue import Queue
18- from threading import Condition , Lock , Thread
19+ from threading import Thread
1920from typing import (
2021 Any ,
2122 Callable ,
3334from huggingface_hub import scan_cache_dir
3435
3536from ._version import __version__
36- from .generate import BatchGenerator , stream_generate
37+ from .generate import BatchGenerator , generation_stream , stream_generate
3738from .models .cache import (
3839 can_trim_prompt_cache ,
3940 make_prompt_cache ,
4041 trim_prompt_cache ,
4142)
4243from .sample_utils import make_logits_processors , make_sampler
43- from .utils import load
44+ from .utils import load , sharded_load
4445
4546
4647def get_system_fingerprint ():
@@ -384,6 +385,46 @@ class Response:
384385 top_tokens : Optional [Tuple [int , float ]]
385386
386387
388+ class TimeBudget :
389+ def __init__ (self , budget = 0.5 , iterations = 25 , sync_frequency = 10 ):
390+ self ._is_distributed = mx .distributed .init ().size () > 1
391+ self ._budget = budget
392+ self ._iterations = iterations
393+ self ._sync_frequency = sync_frequency
394+
395+ self ._start = None
396+ self ._current_iterations = None
397+ self ._loops = 0
398+ self ._time_spent = 0
399+
400+ def __iter__ (self ):
401+ self ._start = time .time ()
402+ self ._current_iterations = 0
403+ return self
404+
405+ def __next__ (self ):
406+ if not self ._is_distributed :
407+ if time .time () - self ._start > self ._budget :
408+ raise StopIteration ()
409+ return None
410+
411+ self ._current_iterations += 1
412+ if self ._current_iterations > self ._iterations :
413+ self ._loops += 1
414+ self ._time_spent += time .time () - self ._start
415+ if self ._loops % self ._sync_frequency == 0 :
416+ with mx .stream (generation_stream ):
417+ loop_time = mx .distributed .all_sum (self ._time_spent ).item ()
418+ avg_loop_time = loop_time / (
419+ mx .distributed .init ().size () * self ._sync_frequency
420+ )
421+ factor = self ._budget / avg_loop_time
422+ self ._iterations = max (round (self ._iterations * factor ), 1 )
423+ self ._loops = 0
424+ self ._time_spent = 0
425+ raise StopIteration ()
426+
427+
387428class ModelProvider :
388429 def __init__ (self , cli_args : argparse .Namespace ):
389430 """Load models on demand and persist them across the whole process."""
@@ -394,6 +435,13 @@ def __init__(self, cli_args: argparse.Namespace):
394435 self .draft_model = None
395436 self .is_batchable = False
396437
438+ group = mx .distributed .init ()
439+ self .pipeline_group = group if group .size () > 1 and cli_args .pipeline else None
440+ self .tensor_group = (
441+ group if group .size () > 1 and not cli_args .pipeline else None
442+ )
443+ self .is_distributed = group .size () > 1
444+
397445 # Preload the default model if it is provided
398446 self .default_model_map = {}
399447 if self .cli_args .model is not None :
@@ -426,15 +474,29 @@ def load(self, model_path, adapter_path=None, draft_model_path=None):
426474 "argument or in the HTTP request"
427475 )
428476 adapter_path = adapter_path or self .cli_args .adapter_path
429- model , tokenizer = load (
430- self .cli_args .model ,
431- adapter_path = adapter_path ,
432- tokenizer_config = tokenizer_config ,
433- )
477+ # TODO: Generalize distributed load
478+ if self .is_distributed :
479+ model , tokenizer = sharded_load (
480+ self .cli_args .model , self .pipeline_group , self .tensor_group
481+ )
482+ else :
483+ model , tokenizer = load (
484+ self .cli_args .model ,
485+ adapter_path = adapter_path ,
486+ tokenizer_config = tokenizer_config ,
487+ )
434488 else :
435- model , tokenizer = load (
436- model_path , adapter_path = adapter_path , tokenizer_config = tokenizer_config
437- )
489+ # TODO: Generalize distributed load
490+ if self .is_distributed :
491+ model , tokenizer = sharded_load (
492+ model_path , self .pipeline_group , self .tensor_group
493+ )
494+ else :
495+ model , tokenizer = load (
496+ model_path ,
497+ adapter_path = adapter_path ,
498+ tokenizer_config = tokenizer_config ,
499+ )
438500
439501 if self .cli_args .use_default_chat_template :
440502 if tokenizer .chat_template is None :
@@ -501,6 +563,9 @@ def __init__(self, model_provider: ModelProvider, prompt_cache: LRUPromptCache):
501563 self .prompt_cache = prompt_cache
502564 self .requests = Queue ()
503565
566+ self ._time_budget = TimeBudget ()
567+ self ._is_distributed = mx .distributed .init ().size () > 1
568+ self ._rank = mx .distributed .init ().rank ()
504569 self ._stop = False
505570 self ._generation_thread = Thread (target = self ._generate )
506571 self ._generation_thread .start ()
@@ -509,6 +574,57 @@ def stop_and_join(self):
509574 self ._stop = True
510575 self ._generation_thread .join ()
511576
577+ def join (self ):
578+ self ._generation_thread .join ()
579+
580+ def _next_request (self , timeout = None ):
581+ request = None
582+ if not self ._is_distributed or self ._rank == 0 :
583+ try :
584+ if timeout is not None :
585+ request = self .requests .get (timeout = timeout )
586+ else :
587+ request = self .requests .get_nowait ()
588+ except QueueEmpty :
589+ pass
590+
591+ return self ._share_request (request )
592+
593+ def _share_object (self , obj ):
594+ if not self ._is_distributed :
595+ return obj
596+
597+ with mx .stream (generation_stream ):
598+ if self ._rank == 0 :
599+ if obj is None :
600+ mx .eval (mx .distributed .all_sum (0 ))
601+ return None
602+ else :
603+ data = mx .array (pickle .dumps (obj ))
604+ mx .eval (mx .distributed .all_sum (data .size ))
605+ mx .eval (mx .distributed .all_sum (data ))
606+ return obj
607+ else :
608+ size = mx .distributed .all_sum (0 ).item ()
609+ if size == 0 :
610+ return None
611+ else :
612+ data = mx .zeros (size , dtype = mx .uint8 )
613+ data = mx .distributed .all_sum (data )
614+ return pickle .loads (data )
615+
616+ def _share_request (self , request ):
617+ if not self ._is_distributed :
618+ return request
619+
620+ shareable = request [1 :] if request is not None else None
621+ shareable = self ._share_object (shareable )
622+ if shareable is None :
623+ return None
624+
625+ rq = request [0 ] if request is not None else Queue ()
626+ return rq , * shareable
627+
512628 def _tokenize (self , tokenizer , request ):
513629 if request .request_type == "chat" :
514630 messages = request .messages
@@ -559,19 +675,17 @@ def get_next_request(timeout=None):
559675 if unprocessed_requests :
560676 return unprocessed_requests .pop ()
561677 else :
562- try :
563- if timeout is not None :
564- return self .requests .get (timeout = timeout )
565- else :
566- return self .requests .get_nowait ()
567- except QueueEmpty :
568- return None
678+ return self ._next_request (timeout )
569679
570680 def progress_callback (info ):
571681 for uid , processed , total in info :
572682 if uid in batch_results :
573683 batch_results [uid ]["rqueue" ].put ((min (processed , total ), total ))
574684
685+ if self ._is_distributed :
686+ seed = mx .distributed .all_sum (mx .random .state [0 ]).view (mx .uint64 ).item ()
687+ mx .random .seed (seed )
688+
575689 while not self ._stop :
576690 request = None
577691 if not drain_batch :
@@ -657,6 +771,8 @@ def progress_callback(info):
657771 batch_generator = BatchGenerator (
658772 model ,
659773 stop_tokens = tokenizer .eos_token_ids ,
774+ completion_batch_size = self .cli_args .decode_concurrency ,
775+ prefill_batch_size = self .cli_args .prompt_concurrency ,
660776 prompt_progress_callback = progress_callback ,
661777 )
662778 unprocessed_requests .append ((rqueue , request , args ))
@@ -683,12 +799,7 @@ def progress_callback(info):
683799 continue
684800
685801 uids_to_remove = []
686- time_budget = 0.5
687- start = time .time ()
688- while True :
689- if time .time () - start > time_budget :
690- break
691-
802+ for _ in self ._time_budget :
692803 responses = batch_generator .next ()
693804 if not responses :
694805 break
@@ -730,8 +841,20 @@ def progress_callback(info):
730841 if result ["ctx" ]._should_stop :
731842 uids_to_remove .append (r .uid )
732843
733- if uids_to_remove :
734- batch_generator .remove (uids_to_remove )
844+ uids_to_remove = self ._share_object (uids_to_remove )
845+ if uids_to_remove :
846+ with mx .stream (generation_stream ):
847+ caches = batch_generator .remove (
848+ uids_to_remove , return_prompt_caches = True
849+ )
850+ for uid , prompt_cache in caches .items ():
851+ if uid not in batch_results :
852+ continue
853+ result = batch_results [uid ]
854+ self .prompt_cache .insert_cache (
855+ current_model_key , result ["cache_key" ], prompt_cache
856+ )
857+ del batch_results [uid ]
735858
736859 def _serve_single (self , request ):
737860 rqueue , request , args = request
@@ -822,6 +945,8 @@ def progress(tokens_processed, tokens_total):
822945 cache_key .append (gen .token )
823946
824947 if ctx ._should_stop :
948+ if self ._is_distributed :
949+ raise NotImplementedError ()
825950 break
826951
827952 rqueue .put (None )
@@ -1522,15 +1647,14 @@ def probably_mlx_lm(repo):
15221647 self .wfile .flush ()
15231648
15241649
1525- def run (
1650+ def _run_http_server (
15261651 host : str ,
15271652 port : int ,
1528- model_provider : ModelProvider ,
1653+ response_generator ,
15291654 server_class = ThreadingHTTPServer ,
15301655 handler_class = APIHandler ,
15311656):
15321657 server_address = (host , port )
1533- response_generator = ResponseGenerator (model_provider , LRUPromptCache ())
15341658 infos = socket .getaddrinfo (
15351659 * server_address , type = socket .SOCK_STREAM , flags = socket .AI_PASSIVE
15361660 )
@@ -1556,6 +1680,21 @@ def run(
15561680 response_generator .stop_and_join ()
15571681
15581682
1683+ def run (
1684+ host : str ,
1685+ port : int ,
1686+ model_provider : ModelProvider ,
1687+ server_class = ThreadingHTTPServer ,
1688+ handler_class = APIHandler ,
1689+ ):
1690+ group = mx .distributed .init ()
1691+ response_generator = ResponseGenerator (model_provider , LRUPromptCache ())
1692+ if group .rank () == 0 :
1693+ _run_http_server (host , port , response_generator )
1694+ else :
1695+ response_generator .join ()
1696+
1697+
15591698def main ():
15601699 parser = argparse .ArgumentParser (description = "MLX Http Server." )
15611700 parser .add_argument (
@@ -1652,6 +1791,23 @@ def main():
16521791 help = """A JSON formatted string of arguments for the tokenizer's apply_chat_template, e.g. '{"enable_thinking":false}'""" ,
16531792 default = "{}" ,
16541793 )
1794+ parser .add_argument (
1795+ "--decode-concurrency" ,
1796+ type = int ,
1797+ default = 32 ,
1798+ help = "When a request is batchable then decode that many requests in parallel" ,
1799+ )
1800+ parser .add_argument (
1801+ "--prompt-concurrency" ,
1802+ type = int ,
1803+ default = 8 ,
1804+ help = "When a request is batchable then process that many prompts in parallel" ,
1805+ )
1806+ parser .add_argument (
1807+ "--pipeline" ,
1808+ action = "store_true" ,
1809+ help = "Use pipelining instead of tensor parallelism" ,
1810+ )
16551811 args = parser .parse_args ()
16561812 if mx .metal .is_available ():
16571813 wired_limit = mx .device_info ()["max_recommended_working_set_size" ]
0 commit comments