Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion mlx_lm/evaluate.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from .generate import batch_generate
from .models.cache import make_prompt_cache
from .sample_utils import make_sampler
from .utils import common_prefix_len, load
from .utils import load

DEFAULT_MAX_TOKENS = 8192

Expand Down
252 changes: 170 additions & 82 deletions mlx_lm/server.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
# Copyright © 2023-2024 Apple Inc.

import argparse
import copy
import json
import logging
import platform
import socket
import time
import uuid
import warnings
from collections import deque
from dataclasses import dataclass, field
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
Expand All @@ -30,7 +32,7 @@
from .generate import stream_generate
from .models.cache import can_trim_prompt_cache, make_prompt_cache, trim_prompt_cache
from .sample_utils import make_logits_processors, make_sampler
from .utils import common_prefix_len, load
from .utils import load


def get_system_fingerprint():
Expand Down Expand Up @@ -145,6 +147,146 @@ def process_message_content(messages):
message["content"] = ""


class LRUPromptCache:

@dataclass
class CacheEntry:
prompt_cache: List[Any]
count: int

@dataclass
class SearchResult:
model: Any
exact: List[int]
shorter: List[int]
longer: List[int]
common_prefix: int

def __init__(self, max_size: int = 10):
self.max_size = max_size
self._cache = {}
self._lru = deque()

def _search(self, model, tokens):
"""Search the cache for a prompt cache. Return exact or close match."""
if model not in self._cache:
return self.SearchResult(model, None, None, None, 0)

current = self._cache[model]
last_cache_index = -1
index = 0

while index < len(tokens) and tokens[index] in current:
current = current[tokens[index]]
if "cache" in current:
last_cache_index = index
index += 1

# Exact match no need to search for longer or shorter caches
if last_cache_index == len(tokens) - 1:
return self.SearchResult(model, tokens, None, None, 0)

# Find the shorter cache
shorter = None
if last_cache_index > 0:
shorter = tokens[: last_cache_index + 1]

# Check for caches that are longer
longer = None
common_prefix = index
Comment on lines +194 to +196

@awni awni Dec 2, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the purpose to check for longer if you found a shorter? Is the idea to use a different policy in the future? nvm I see the condition now.

if index > 0 and last_cache_index <= 0:
best = None
stack = [(current, [])]
while stack:
current, extra = stack.pop()
if "cache" in current:
if best is None or len(extra) < len(best):
best = extra
else:
for tok in current:
stack.append((current[tok], extra + [tok]))
longer = tokens[:index] + best
return self.SearchResult(model, None, shorter, longer, common_prefix)

def _get(self, model, tokens):
current = self._cache[model]
for tok in tokens:
current = current[tok]
return current["cache"]

def _delete(self, model, tokens):
path = [self._cache[model]]
for tok in tokens:
path.append(path[-1][tok])
del path[-1]["cache"]
for i in reversed(range(len(tokens))):
d_prev, d, t = path[i], path[i + 1], tokens[i]
if len(d) > 0:
break
del d_prev[t]

def _extract(self, model, tokens):
cache_entry = self._get(model, tokens)
if cache_entry.count == 1:
self._delete(model, tokens)
self._lru.remove((model, tokens))
return cache_entry

cache_entry.count -= 1
return self.CacheEntry(
copy.deepcopy(cache_entry.prompt_cache),
1,
)

def fetch_nearest_cache(self, model, tokens):
result = self._search(model, tokens)
if result.exact is not None:
cache_entry = self._extract(result.model, result.exact)
return cache_entry.prompt_cache, []

if result.shorter is not None:
cache_entry = self._extract(result.model, result.shorter)
prefix_len = len(result.shorter)
return cache_entry.prompt_cache, tokens[prefix_len:]

if result.longer is not None:
cache_entry = self._get(result.model, result.longer)
if can_trim_prompt_cache(cache_entry.prompt_cache):
cache_entry = self.CacheEntry(
copy.deepcopy(cache_entry.prompt_cache),
1,
)
prefix = min(len(tokens) - 1, result.common_prefix)
num_to_trim = len(result.longer) - prefix
trim_prompt_cache(cache_entry.prompt_cache, num_to_trim)
return cache_entry.prompt_cache, tokens[prefix:]

return None, tokens

def insert_cache(self, model, tokens, prompt_cache):
if model not in self._cache:
self._cache[model] = {}
current = self._cache[model]
for tok in tokens:
if tok not in current:
current[tok] = {}
current = current[tok]

remove_first = False
if "cache" in current:
current["cache"].count += 1
remove_first = True
else:
current["cache"] = self.CacheEntry(prompt_cache, 1)

if remove_first:
self._lru.remove((model, tokens))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
remove_first = False
if "cache" in current:
current["cache"].count += 1
remove_first = True
else:
current["cache"] = self.CacheEntry(prompt_cache, 1)
if remove_first:
self._lru.remove((model, tokens))
if "cache" in current:
current["cache"].count += 1
self._lru.remove((model, tokens))
else:
current["cache"] = self.CacheEntry(prompt_cache, 1)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ha hilarious, what did I do there... You can tell it was a fix for trying to remove a non existent thing from the _lru which I didn't know that it throws instead of doing nothing.

self._lru.append((model, tokens))
if len(self._lru) > self.max_size:
model, tokens = self._lru.popleft()
self._delete(model, tokens)


Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can use radix tree techniques, which is featured in SGLang.

It accelerates in multi-round LLM, i.e. agentic LLM evrionment more efficiently.

@dataclass
class PromptCache:
cache: List[Any] = field(default_factory=list)
Expand Down Expand Up @@ -247,7 +389,7 @@ def __init__(
"""
self.created = int(time.time())
self.model_provider = model_provider
self.prompt_cache = prompt_cache or PromptCache()
self.prompt_cache = prompt_cache or LRUPromptCache()
self.system_fingerprint = system_fingerprint or get_system_fingerprint()
super().__init__(*args, **kwargs)

Expand Down Expand Up @@ -538,88 +680,33 @@ def parse_function(tool_text):

return response

def reset_prompt_cache(self, prompt):
"""Resets the prompt cache and associated state.

Args:
prompt (List[int]): The tokenized new prompt which will populate the
reset cache.
"""
logging.debug(f"*** Resetting cache. ***")
self.prompt_cache.model_key = self.model_provider.model_key
self.prompt_cache.cache = make_prompt_cache(self.model_provider.model)
if self.model_provider.draft_model is not None:
self.prompt_cache.cache += make_prompt_cache(
self.model_provider.draft_model
)
self.prompt_cache.tokens = list(prompt) # Cache the new prompt fully

def get_prompt_cache(self, prompt):
"""
Determines the portion of the prompt that needs processing by comparing
it to the cached prompt and attempting to reuse the common prefix.
Given the prompt find the closest KV cache that can be extended to the
passed in prompt.

This function updates the internal prompt cache state (tokens and model cache)
based on the comparison. If a common prefix exists, it attempts to trim
the model cache (if supported) to match the common prefix length, avoiding
recomputation.
If one couldn't be found then make a new one.

Args:
prompt (List[int]): The tokenized new prompt.

Returns:
List[int]: The suffix of the prompt that actually needs to be processed
by the model. This will be the full prompt if the cache is
reset or cannot be effectively used.
List[Any]: The prompt cache object
List[int]: The tokens that are in the returned object
List[int]: The remaining tokens to be added
"""
cache_len = len(self.prompt_cache.tokens)
prompt_len = len(prompt)
com_prefix_len = common_prefix_len(self.prompt_cache.tokens, prompt)

# Leave at least one token in the prompt
com_prefix_len = min(com_prefix_len, len(prompt) - 1)

# Condition 1: Model changed or no common prefix at all. Reset cache.
if (
self.prompt_cache.model_key != self.model_provider.model_key
or com_prefix_len == 0
):
self.reset_prompt_cache(prompt)

# Condition 2: Common prefix exists and matches cache length. Process suffix.
elif com_prefix_len == cache_len:
logging.debug(
f"*** Cache is prefix of prompt (cache_len: {cache_len}, prompt_len: {prompt_len}). Processing suffix. ***"
)
prompt = prompt[com_prefix_len:]
self.prompt_cache.tokens.extend(prompt)

# Condition 3: Common prefix exists but is shorter than cache length. Attempt trim.
elif com_prefix_len < cache_len:
logging.debug(
f"*** Common prefix ({com_prefix_len}) shorter than cache ({cache_len}). Attempting trim. ***"
)

if can_trim_prompt_cache(self.prompt_cache.cache):
num_to_trim = cache_len - com_prefix_len
logging.debug(f" Trimming {num_to_trim} tokens from cache.")
trim_prompt_cache(self.prompt_cache.cache, num_to_trim)
self.prompt_cache.tokens = self.prompt_cache.tokens[:com_prefix_len]
prompt = prompt[com_prefix_len:]
self.prompt_cache.tokens.extend(prompt)
else:
logging.debug(f" Cache cannot be trimmed. Resetting cache.")
self.reset_prompt_cache(prompt)
cache, rest = self.prompt_cache.fetch_nearest_cache(
self.model_provider.model_key, prompt
)
cache_key = prompt[: len(prompt) - len(rest)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
cache_key = prompt[: len(prompt) - len(rest)]
cache_key = prompt[: len(prompt) - len(rest)]
if len(rest) == 0 and len(cache_key) > 0:
rest = [cache_key[-1]]
cache_key = cache_key[:-1]

We probably need something like this like the old implementation had since stream_generate in generate.py has a len(prompt) == 0 check

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well it is the exact same behavior as before though. If we do indeed want exact cache matches to be used then we need to try trimming the cache by 1 if possible.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I probably should have been more clear. I ran into a crash and so this was one way to fix it. The original implementation had:

# Leave at least one token in the prompt
com_prefix_len = min(com_prefix_len, len(prompt) - 1)

You can reproduce with the following:

mlx_lm.server --model mlx-community/Qwen3-4B-Instruct-2507-4bit --host 0.0.0.0 --port 8080

Then do the following 2 times:

curl -X POST http://localhost:8080/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "default_model",
    "messages": [{"role": "user", "content": "Hello, how are you?"}],
    "max_tokens": 10
  }'

Result:

mlx_lm.server --model /Volumes/WD_EXTRA/models/catalyst/Qwen3-4B-Instruct-2507-8bit --host 0.0.0.0 --port 8080 --max-tokens 16384
/Users/optimus/repo/mlx-lm/mlx_lm/server.py:1052: UserWarning: mlx_lm.server is not recommended for production as it only implements basic security checks.
  warnings.warn(
2025-11-21 16:23:12,284 - INFO - Starting httpd at 0.0.0.0 on port 8080...
127.0.0.1 - - [21/Nov/2025 16:23:15] "POST /v1/chat/completions HTTP/1.1" 200 -
2025-11-21 16:23:15,421 - INFO - Prompt processing progress: 0/14
2025-11-21 16:23:15,507 - INFO - Prompt processing progress: 13/14
2025-11-21 16:23:15,540 - INFO - Prompt processing progress: 14/14
127.0.0.1 - - [21/Nov/2025 16:23:17] "POST /v1/chat/completions HTTP/1.1" 200 -
----------------------------------------
Exception occurred during processing of request from ('127.0.0.1', 64738)
Traceback (most recent call last):
  File "/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/socketserver.py", line 318, in _handle_request_noblock
    self.process_request(request, client_address)
  File "/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/socketserver.py", line 349, in process_request
    self.finish_request(request, client_address)
  File "/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/socketserver.py", line 362, in finish_request
    self.RequestHandlerClass(request, client_address, self)
  File "/Users/optimus/repo/mlx-lm/mlx_lm/server.py", line 1044, in <lambda>
    lambda *args, **kwargs: handler_class(
                            ^^^^^^^^^^^^^^
  File "/Users/optimus/repo/mlx-lm/mlx_lm/server.py", line 393, in __init__
    super().__init__(*args, **kwargs)
  File "/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/socketserver.py", line 761, in __init__
    self.handle()
  File "/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/http/server.py", line 436, in handle
    self.handle_one_request()
  File "/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/http/server.py", line 424, in handle_one_request
    method()
  File "/Users/optimus/repo/mlx-lm/mlx_lm/server.py", line 511, in do_POST
    self.handle_completion(prompt, stop_id_sequences)
  File "/Users/optimus/repo/mlx-lm/mlx_lm/server.py", line 779, in handle_completion
    for gen_response in stream_generate(
                        ^^^^^^^^^^^^^^^^
  File "/Users/optimus/repo/mlx-lm/mlx_lm/generate.py", line 698, in stream_generate
    for n, (token, logprobs, from_draft) in enumerate(token_generator):
                                            ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/optimus/repo/mlx-lm/mlx_lm/generate.py", line 688, in <genexpr>
    (token, logprobs, False) for token, logprobs in token_generator
                                                    ^^^^^^^^^^^^^^^
  File "/Users/optimus/repo/mlx-lm/mlx_lm/generate.py", line 355, in generate_step
    raise ValueError(
ValueError: Either input_embeddings or prompt (or both) must be provided.
----------------------------------------

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep I ran into that later and fixed it in a063c39 let me know if you run into more issues. I should probably add a test for that as well...


# This case should logically not be reached if com_prefix_len <= cache_len
else:
logging.error(
f"Unexpected cache state: com_prefix_len ({com_prefix_len}) > cache_len ({cache_len}). Resetting cache."
)
self.reset_prompt_cache(prompt)
# Make a new cache for the model
if cache is None:
cache = make_prompt_cache(self.model_provider.model)
if self.model_provider.draft_model is not None:
cache += make_prompt_cache(self.model_provider.draft_model)

logging.debug(f"Returning {len(prompt)} tokens for processing.")
return prompt
return cache, cache_key, rest

def handle_completion(
self,
Expand All @@ -645,7 +732,7 @@ def handle_completion(
token_logprobs = []
top_tokens = []

prompt = self.get_prompt_cache(prompt)
cache, cache_key, prompt = self.get_prompt_cache(prompt)

text = ""
tic = time.perf_counter()
Expand Down Expand Up @@ -688,14 +775,16 @@ def keepalive_callback(processed_tokens, total_tokens):
# Client disconnected, ignore
pass

cache_key += prompt
prompt_token_count = len(cache_key)
for gen_response in stream_generate(
model=self.model,
tokenizer=self.tokenizer,
prompt=prompt,
max_tokens=self.max_tokens,
sampler=sampler,
logits_processors=logits_processors,
prompt_cache=self.prompt_cache.cache,
prompt_cache=cache,
draft_model=self.model_provider.draft_model,
num_draft_tokens=self.num_draft_tokens,
prompt_progress_callback=keepalive_callback,
Expand All @@ -720,7 +809,7 @@ def keepalive_callback(processed_tokens, total_tokens):
token = gen_response.token
logprobs = gen_response.logprobs
tokens.append(token)
self.prompt_cache.tokens.append(token)
cache_key.append(token)

if self.logprobs > 0:
sorted_indices = mx.argpartition(-logprobs, kth=self.logprobs - 1)
Expand Down Expand Up @@ -777,11 +866,8 @@ def keepalive_callback(processed_tokens, total_tokens):
self.wfile.write(f"data: {json.dumps(response)}\n\n".encode())
self.wfile.flush()
if self.stream_options is not None and self.stream_options["include_usage"]:
original_prompt_length = (
len(self.prompt_cache.tokens) - len(tokens) + len(prompt)
)
response = self.completion_usage_response(
original_prompt_length, len(tokens)
prompt_token_count, len(tokens)
)
self.wfile.write(f"data: {json.dumps(response)}\n\n".encode())
self.wfile.flush()
Expand All @@ -791,7 +877,7 @@ def keepalive_callback(processed_tokens, total_tokens):
response = self.generate_response(
text,
finish_reason,
len(prompt),
prompt_token_count,
len(tokens),
token_logprobs=token_logprobs,
top_tokens=top_tokens,
Expand All @@ -808,6 +894,8 @@ def keepalive_callback(processed_tokens, total_tokens):
self.wfile.write(response_json)
self.wfile.flush()

self.prompt_cache.insert_cache(self.model_provider.model_key, cache_key, cache)

def completion_usage_response(
self,
prompt_token_count: Optional[int] = None,
Expand Down Expand Up @@ -947,7 +1035,7 @@ def run(
handler_class=APIHandler,
):
server_address = (host, port)
prompt_cache = PromptCache()
prompt_cache = LRUPromptCache()
infos = socket.getaddrinfo(
*server_address, type=socket.SOCK_STREAM, flags=socket.AI_PASSIVE
)
Expand Down
Loading
Loading