Skip to content

Commit 0786571

Browse files
Add layer-streaming to server, convert, and verification
- --streaming / --max-memory-gb / --streaming-window on mlx_lm.server - --split-for-streaming on mlx_lm.convert - Forward and sampling mx.array tests for streaming path - scripts/verify_streaming_consumer.py for end-to-end MLX tensor checks
1 parent a33b347 commit 0786571

4 files changed

Lines changed: 182 additions & 1 deletion

File tree

mlx_lm/convert.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ def convert(
9797
Union[Callable[[str, nn.Module, dict], Union[bool, dict]], str]
9898
] = None,
9999
trust_remote_code: bool = False,
100+
split_for_streaming: bool = False,
100101
):
101102
# Check the save path is empty
102103
if isinstance(mlx_path, str):
@@ -172,6 +173,12 @@ def set_dtype(k, v):
172173
config,
173174
)
174175

176+
if split_for_streaming:
177+
from mlx_lm.streaming.split_model import ensure_streaming_layout
178+
179+
print("[INFO] Splitting weights for layer-streaming inference")
180+
ensure_streaming_layout(mlx_path, verbose=True)
181+
175182
if upload_repo is not None:
176183
upload_to_hub(mlx_path, upload_repo)
177184

@@ -251,6 +258,12 @@ def configure_parser() -> argparse.ArgumentParser:
251258
action="store_true",
252259
default=False,
253260
)
261+
parser.add_argument(
262+
"--split-for-streaming",
263+
help="Split saved weights into per-layer files for streaming inference.",
264+
action="store_true",
265+
default=False,
266+
)
254267
return parser
255268

256269

mlx_lm/server.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,23 @@ def _load(self, model_path, adapter_path=None, draft_model_path=None):
345345
tokenizer_config=self._tokenizer_config,
346346
trust_remote_code=self.cli_args.trust_remote_code,
347347
)
348+
elif getattr(self.cli_args, "streaming", False):
349+
if adapter_path is not None or draft_model_path is not None:
350+
raise ValueError(
351+
"Layer streaming does not support adapters or draft models"
352+
)
353+
from mlx_lm.streaming import StreamingConfig, load_streaming
354+
355+
streaming_config = StreamingConfig(
356+
max_memory_gb=self.cli_args.max_memory_gb,
357+
window_size=getattr(self.cli_args, "streaming_window", None),
358+
verbose=self.cli_args.log_level == "DEBUG",
359+
)
360+
model, tokenizer, _ = load_streaming(
361+
model_path,
362+
streaming_config=streaming_config,
363+
tokenizer_config=self._tokenizer_config,
364+
)
348365
else:
349366
model, tokenizer = load(
350367
model_path,
@@ -1947,6 +1964,23 @@ def main():
19471964
action="store_true",
19481965
help="Use pipelining instead of tensor parallelism",
19491966
)
1967+
parser.add_argument(
1968+
"--streaming",
1969+
action="store_true",
1970+
help="Enable layer-streaming inference for models larger than RAM.",
1971+
)
1972+
parser.add_argument(
1973+
"--max-memory-gb",
1974+
type=float,
1975+
default=20.0,
1976+
help="Memory budget (GB) for layer window when --streaming is set.",
1977+
)
1978+
parser.add_argument(
1979+
"--streaming-window",
1980+
type=int,
1981+
default=None,
1982+
help="Fixed layer window size (auto-computed if omitted).",
1983+
)
19501984
args = parser.parse_args()
19511985
if mx.metal.is_available():
19521986
wired_limit = mx.device_info()["max_recommended_working_set_size"]
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
#!/usr/bin/env python3
2+
# Copyright © 2024 Apple Inc.
3+
4+
"""Consumer verification for mlx-lm layer-streaming (MLX-first tensor paths)."""
5+
6+
import json
7+
import sys
8+
import tempfile
9+
from pathlib import Path
10+
11+
_REPO_ROOT = Path(__file__).resolve().parents[1]
12+
if str(_REPO_ROOT) not in sys.path:
13+
sys.path.insert(0, str(_REPO_ROOT))
14+
15+
import mlx.core as mx
16+
17+
from mlx_lm.generate import generate_step
18+
from mlx_lm.models.llama import Model, ModelArgs
19+
from mlx_lm.streaming import StreamingConfig, load_streaming
20+
from mlx_lm.streaming.split_model import ensure_streaming_layout, split_model_by_layers
21+
from mlx_lm.utils import save_model
22+
23+
24+
def _tiny_config():
25+
return {
26+
"model_type": "llama",
27+
"vocab_size": 128,
28+
"hidden_size": 64,
29+
"intermediate_size": 128,
30+
"num_hidden_layers": 2,
31+
"num_attention_heads": 4,
32+
"num_key_value_heads": 2,
33+
"rms_norm_eps": 1e-5,
34+
"tie_word_embeddings": True,
35+
}
36+
37+
38+
def _build_model_dir(tmp_path: Path) -> Path:
39+
config = _tiny_config()
40+
with open(tmp_path / "config.json", "w") as f:
41+
json.dump(config, f)
42+
args = ModelArgs.from_dict(config)
43+
model = Model(args)
44+
mx.eval(model.parameters())
45+
save_model(tmp_path, model)
46+
ensure_streaming_layout(tmp_path, verbose=False)
47+
return tmp_path
48+
49+
50+
def main() -> int:
51+
with tempfile.TemporaryDirectory() as tmp:
52+
model_dir = _build_model_dir(Path(tmp))
53+
54+
# Split I/O uses mx.load / mx.save_safetensors
55+
weights = mx.load(str(model_dir / "layer_0.safetensors"))
56+
assert all(isinstance(v, mx.array) for v in weights.values())
57+
print("SPLIT OK", isinstance(weights["self_attn.q_proj.weight"], mx.array))
58+
59+
model, _, _ = load_streaming(
60+
str(model_dir),
61+
StreamingConfig(window_size=1, verbose=False),
62+
load_tokenizer=False,
63+
)
64+
print("LOAD OK", type(model).__name__)
65+
66+
cache = model.make_cache()
67+
logits = model(mx.array([[1, 2, 3]]), cache=cache)
68+
mx.eval(logits)
69+
assert isinstance(logits, mx.array)
70+
print("FORWARD OK", isinstance(logits, mx.array), logits.shape)
71+
72+
prompt = mx.array([1, 2, 3])
73+
for token, logprobs in generate_step(prompt, model, max_tokens=2):
74+
assert isinstance(logprobs, mx.array)
75+
assert isinstance(token, (int, mx.integer_types if hasattr(mx, "integer_types") else int))
76+
print("SAMPLE OK", isinstance(logprobs, mx.array))
77+
78+
# Standalone split entry path
79+
mono = tmp + "/mono.safetensors"
80+
mx.save_safetensors(
81+
mono,
82+
{
83+
"model.layers.0.self_attn.q_proj.weight": mx.random.normal((8, 8)),
84+
"model.embed_tokens.weight": mx.random.normal((100, 8)),
85+
},
86+
)
87+
out = Path(tmp) / "split2"
88+
split_model_by_layers(Path(mono), out)
89+
assert (out / "layer_0.safetensors").exists()
90+
print("SPLIT_CLI OK", True)
91+
92+
print("ALL CONSUMER TESTS PASSED")
93+
return 0
94+
95+
96+
if __name__ == "__main__":
97+
sys.exit(main())

tests/test_streaming_generate.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,26 @@ def test_load_streaming_auto_split():
6060
assert config["num_hidden_layers"] == 2
6161

6262

63+
def test_streaming_forward_returns_mx_array():
64+
"""StreamingModelWrapper forward must return mx.array logits."""
65+
with tempfile.TemporaryDirectory() as tmp:
66+
model_dir = _build_tiny_model_dir(Path(tmp))
67+
model, _, _ = load_streaming(
68+
str(model_dir),
69+
StreamingConfig(window_size=1, verbose=False),
70+
load_tokenizer=False,
71+
)
72+
73+
cache = model.make_cache()
74+
inputs = mx.array([[1, 2, 3]])
75+
logits = model(inputs, cache=cache)
76+
mx.eval(logits)
77+
78+
assert isinstance(logits, mx.array)
79+
assert logits.ndim == 3
80+
assert logits.shape[-1] == _tiny_llama_config()["vocab_size"]
81+
82+
6383
def test_generate_step_on_streaming_model():
6484
with tempfile.TemporaryDirectory() as tmp:
6585
model_dir = _build_tiny_model_dir(Path(tmp))
@@ -77,4 +97,21 @@ def test_generate_step_on_streaming_model():
7797
else:
7898
tokens.append(int(token))
7999

80-
assert len(tokens) == 3
100+
assert len(tokens) == 3
101+
102+
103+
def test_generate_step_sampling_returns_mx_array_logprobs():
104+
"""generate_step logprobs must be mx.array through the streaming path."""
105+
with tempfile.TemporaryDirectory() as tmp:
106+
model_dir = _build_tiny_model_dir(Path(tmp))
107+
model, _, _ = load_streaming(
108+
str(model_dir),
109+
StreamingConfig(window_size=1, verbose=False),
110+
load_tokenizer=False,
111+
)
112+
113+
prompt = mx.array([1, 2, 3])
114+
for _token, logprobs in generate_step(prompt, model, max_tokens=2):
115+
assert isinstance(logprobs, mx.array)
116+
assert logprobs.ndim == 1
117+
assert logprobs.shape[0] == _tiny_llama_config()["vocab_size"]

0 commit comments

Comments
 (0)