Skip to content

Commit 1fe1b3c

Browse files
authored
Support distributed inference in the server (ml-explore#741)
1 parent 8a0f378 commit 1fe1b3c

3 files changed

Lines changed: 197 additions & 30 deletions

File tree

mlx_lm/generate.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1014,10 +1014,16 @@ def insert(
10141014
)
10151015
return uids
10161016

1017-
def remove(self, uids: List[int]):
1017+
def remove(self, uids: List[int], return_prompt_caches: bool = False):
1018+
caches = {}
10181019
uids = set(uids)
10191020
if self.active_batch is not None:
10201021
batch = self.active_batch
1022+
if return_prompt_caches:
1023+
for e, uid in enumerate(batch.uids):
1024+
if uid not in uids:
1025+
continue
1026+
caches[uid] = batch.extract_cache(e)
10211027
keep_idx = [e for e, uid in enumerate(batch.uids) if uid not in uids]
10221028
if len(keep_idx) > 0:
10231029
batch.filter(keep_idx)
@@ -1028,6 +1034,9 @@ def remove(self, uids: List[int]):
10281034
if self.unprocessed_prompts[i][0] in uids:
10291035
self.unprocessed_prompts.pop(i)
10301036

1037+
if return_prompt_caches:
1038+
return caches
1039+
10311040
def _process_prompts(self, prompts):
10321041
uids, inputs, max_tokens, caches, samplers, logits_processors = zip(*prompts)
10331042

mlx_lm/server.py

Lines changed: 185 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import copy
55
import json
66
import logging
7+
import pickle
78
import platform
89
import socket
910
import time
@@ -15,7 +16,7 @@
1516
from pathlib import Path
1617
from queue import Empty as QueueEmpty
1718
from queue import Queue
18-
from threading import Condition, Lock, Thread
19+
from threading import Thread
1920
from typing import (
2021
Any,
2122
Callable,
@@ -33,14 +34,14 @@
3334
from huggingface_hub import scan_cache_dir
3435

3536
from ._version import __version__
36-
from .generate import BatchGenerator, stream_generate
37+
from .generate import BatchGenerator, generation_stream, stream_generate
3738
from .models.cache import (
3839
can_trim_prompt_cache,
3940
make_prompt_cache,
4041
trim_prompt_cache,
4142
)
4243
from .sample_utils import make_logits_processors, make_sampler
43-
from .utils import load
44+
from .utils import load, sharded_load
4445

4546

4647
def 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+
387428
class 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+
15591698
def 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"]

tests/test_server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ def __init__(self, with_draft=False):
4141
"max_tokens": 512,
4242
"chat_template_args": {},
4343
"model": None,
44+
"decode_concurrency": 32,
45+
"prompt_concurrency": 8,
4446
},
4547
)
4648

0 commit comments

Comments
 (0)