Skip to content

Commit 3af9a82

Browse files
Continue AirMLX port: generate integration and e2e tests
- Auto-split via ensure_streaming_layout in load_streaming - --streaming flags on mlx_lm.generate CLI - Fix StreamingModelWrapper recursion and layer weight keys - Add benchmark CLI, get_stats, generate_step e2e tests
1 parent 0b93d66 commit 3af9a82

11 files changed

Lines changed: 43164 additions & 56 deletions

File tree

mlx-lm-full-repo.md

Lines changed: 42877 additions & 0 deletions
Large diffs are not rendered by default.

mlx_lm/generate.py

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,23 @@ def setup_arg_parser():
256256
default=42,
257257
help="Base seed for TurboQuant per-layer rotations.",
258258
)
259+
parser.add_argument(
260+
"--streaming",
261+
action="store_true",
262+
help="Enable layer-streaming inference for models larger than RAM.",
263+
)
264+
parser.add_argument(
265+
"--max-memory-gb",
266+
type=float,
267+
default=20.0,
268+
help="Memory budget (GB) for layer window when --streaming is set.",
269+
)
270+
parser.add_argument(
271+
"--streaming-window",
272+
type=int,
273+
default=None,
274+
help="Fixed layer window size (auto-computed if omitted).",
275+
)
259276
return parser
260277

261278

@@ -2114,13 +2131,29 @@ def main():
21142131
)
21152132
model_path = model_path or DEFAULT_MODEL
21162133

2117-
model, tokenizer = load(
2118-
model_path,
2119-
adapter_path=args.adapter_path,
2120-
tokenizer_config=tokenizer_config,
2121-
model_config={"quantize_activations": args.quantize_activations},
2122-
trust_remote_code=args.trust_remote_code,
2123-
)
2134+
if args.streaming:
2135+
from mlx_lm.streaming import StreamingConfig, load_streaming
2136+
2137+
streaming_config = StreamingConfig(
2138+
max_memory_gb=args.max_memory_gb,
2139+
window_size=args.streaming_window,
2140+
verbose=args.verbose,
2141+
)
2142+
model, tokenizer, _ = load_streaming(
2143+
model_path,
2144+
streaming_config=streaming_config,
2145+
tokenizer_config=tokenizer_config,
2146+
)
2147+
if args.verbose:
2148+
print(model.get_stats())
2149+
else:
2150+
model, tokenizer = load(
2151+
model_path,
2152+
adapter_path=args.adapter_path,
2153+
tokenizer_config=tokenizer_config,
2154+
model_config={"quantize_activations": args.quantize_activations},
2155+
trust_remote_code=args.trust_remote_code,
2156+
)
21242157
for eos_token in args.extra_eos_token:
21252158
tokenizer.add_eos_token(eos_token)
21262159

mlx_lm/streaming/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
from .config import StreamingConfig
66
from .layer_loader import RollingWindowLoader
77
from .load import load_streaming
8-
from .split_model import split_model_by_layers
8+
from .split_model import ensure_streaming_layout, split_model_by_layers
99
from .wrapper import StreamingModelWrapper
1010

1111
__all__ = [
1212
"StreamingConfig",
1313
"RollingWindowLoader",
1414
"load_streaming",
1515
"split_model_by_layers",
16+
"ensure_streaming_layout",
1617
"StreamingModelWrapper",
1718
]

mlx_lm/streaming/benchmark.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Copyright © 2024 Apple Inc.
2+
3+
"""Benchmark layer-streaming generation throughput."""
4+
5+
import argparse
6+
import time
7+
8+
from mlx_lm.generate import generate_step
9+
from mlx_lm.sample_utils import make_sampler
10+
11+
from .config import StreamingConfig
12+
from .load import load_streaming
13+
14+
15+
def benchmark_streaming(
16+
model_path: str,
17+
prompt_tokens: list,
18+
max_tokens: int = 32,
19+
max_memory_gb: float = 20.0,
20+
verbose: bool = True,
21+
) -> dict:
22+
"""Run a streaming benchmark and return timing stats."""
23+
import mlx.core as mx
24+
25+
config = StreamingConfig(max_memory_gb=max_memory_gb, verbose=verbose)
26+
model, _tokenizer, _cfg = load_streaming(model_path, streaming_config=config)
27+
28+
prompt = mx.array([prompt_tokens])
29+
sampler = make_sampler(temp=0.0)
30+
31+
start = time.perf_counter()
32+
count = 0
33+
for _ in generate_step(prompt, model, max_tokens=max_tokens, sampler=sampler):
34+
count += 1
35+
elapsed = time.perf_counter() - start
36+
37+
stats = {
38+
"tokens": count,
39+
"elapsed_s": elapsed,
40+
"tok_per_s": count / elapsed if elapsed > 0 else 0.0,
41+
"ms_per_token": (elapsed / count * 1000) if count > 0 else 0.0,
42+
"streaming": model.get_stats(),
43+
}
44+
if verbose:
45+
print(f"Tokens: {stats['tokens']}")
46+
print(f"Throughput: {stats['tok_per_s']:.2f} tok/s")
47+
print(f"Window: {stats['streaming']['streaming']['window_size']} layers")
48+
return stats
49+
50+
51+
def main():
52+
parser = argparse.ArgumentParser(description="Benchmark layer-streaming inference")
53+
parser.add_argument("--model", required=True, help="Model path or HF repo")
54+
parser.add_argument("--max-tokens", type=int, default=32)
55+
parser.add_argument("--max-memory-gb", type=float, default=20.0)
56+
parser.add_argument("--prompt-ids", default="1,2,3,4", help="Comma-separated token ids")
57+
args = parser.parse_args()
58+
prompt_tokens = [int(x) for x in args.prompt_ids.split(",")]
59+
benchmark_streaming(
60+
args.model,
61+
prompt_tokens,
62+
max_tokens=args.max_tokens,
63+
max_memory_gb=args.max_memory_gb,
64+
)
65+
66+
67+
if __name__ == "__main__":
68+
main()

mlx_lm/streaming/layer_loader.py

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -109,18 +109,9 @@ def _load_from_monolithic(self, layer_idx: int) -> Dict[str, mx.array]:
109109
def load_layer(self, layer_idx: int, prefetch: bool = False) -> LayerWeights:
110110
layer_file = self._layer_files.get(layer_idx)
111111
if layer_file is None:
112-
raw = self._load_from_monolithic(layer_idx)
113-
# Prefix keys for mlx-lm load_weights on TransformerBlock
114-
weights = {
115-
f"{self.layer_key_prefix}.{layer_idx}.{k}": v
116-
for k, v in raw.items()
117-
}
112+
weights = self._load_from_monolithic(layer_idx)
118113
else:
119-
local = mx.load(str(layer_file))
120-
weights = {
121-
f"{self.layer_key_prefix}.{layer_idx}.{k}": v
122-
for k, v in local.items()
123-
}
114+
weights = mx.load(str(layer_file))
124115

125116
layer_weights = LayerWeights(weights, layer_idx)
126117
if prefetch and weights:
@@ -151,7 +142,7 @@ def get_layer(self, layer_idx: int) -> LayerWeights:
151142
if (
152143
layer_idx % self.streaming_config.clear_cache_every_n_layers == 0
153144
):
154-
mx.metal.clear_cache()
145+
mx.clear_cache()
155146

156147
layer_weights = self.load_layer(layer_idx, prefetch=False)
157148
mx.eval(list(layer_weights.weights.values()))
@@ -168,7 +159,17 @@ def get_layer(self, layer_idx: int) -> LayerWeights:
168159

169160
return layer_weights
170161

162+
def get_memory_usage(self) -> dict:
163+
total_bytes = sum(layer.memory_bytes for layer in self.layer_queue)
164+
return {
165+
"loaded_layers": len(self.loaded_layers),
166+
"layer_indices": sorted(list(self.loaded_layers)),
167+
"total_mb": total_bytes / 1e6,
168+
"total_gb": total_bytes / 1e9,
169+
"window_size": self.window_size,
170+
}
171+
171172
def clear(self):
172173
self.layer_queue.clear()
173174
self.loaded_layers.clear()
174-
mx.metal.clear_cache()
175+
mx.clear_cache()

mlx_lm/streaming/load.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from .config import StreamingConfig
1515
from .layer_loader import RollingWindowLoader
16+
from .split_model import ensure_streaming_layout
1617
from .wrapper import StreamingModelWrapper
1718

1819

@@ -44,7 +45,8 @@ def load_streaming(
4445
streaming_config: Optional[StreamingConfig] = None,
4546
tokenizer_config: Optional[dict] = None,
4647
revision: Optional[str] = None,
47-
) -> Tuple[StreamingModelWrapper, TokenizerWrapper, Dict]:
48+
load_tokenizer: bool = True,
49+
) -> Tuple[StreamingModelWrapper, Optional[TokenizerWrapper], Dict]:
4850
"""
4951
Load a model for layer-streaming inference.
5052
@@ -67,22 +69,18 @@ def load_streaming(
6769
model_path = _download(path_or_hf_repo, revision=revision)
6870
config = load_config(model_path)
6971

72+
ensure_streaming_layout(model_path, verbose=streaming_config.verbose)
73+
7074
model_class, model_args_class = _get_classes(config)
7175
model_args = model_args_class.from_dict(config)
7276
model = model_class(model_args)
7377

7478
fixed_file = model_path / "fixed_weights.safetensors"
75-
if fixed_file.exists():
76-
fixed_weights = mx.load(str(fixed_file))
77-
if hasattr(model, "sanitize"):
78-
fixed_weights = model.sanitize(fixed_weights)
79-
model.load_weights(list(fixed_weights.items()), strict=False)
80-
mx.eval(model.parameters())
81-
else:
82-
raise FileNotFoundError(
83-
f"{fixed_file} not found. Run: python -m mlx_lm.streaming.split_model "
84-
f"<model.safetensors> {model_path}"
85-
)
79+
fixed_weights = mx.load(str(fixed_file))
80+
if hasattr(model, "sanitize"):
81+
fixed_weights = model.sanitize(fixed_weights)
82+
model.load_weights(list(fixed_weights.items()), strict=False)
83+
mx.eval(model.parameters())
8684

8785
layer_size = streaming_config.estimate_layer_memory(
8886
hidden_size=config["hidden_size"],
@@ -106,5 +104,7 @@ def load_streaming(
106104
wrapped = StreamingModelWrapper(model, loader)
107105
wrapped.eval()
108106

109-
tokenizer = load_tokenizer(model_path, tokenizer_config)
107+
tokenizer = (
108+
load_tokenizer(model_path, tokenizer_config) if load_tokenizer else None
109+
)
110110
return wrapped, tokenizer, config

mlx_lm/streaming/split_model.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
"""Split monolithic safetensors into per-layer files for streaming inference."""
44

55
import argparse
6+
import glob
67
import mlx.core as mx
78
from pathlib import Path
8-
from typing import Dict, List
9+
from typing import Dict, List, Optional
910

1011

1112
def split_model_by_layers(
@@ -55,6 +56,34 @@ def split_model_by_layers(
5556
print("Split complete.")
5657

5758

59+
def ensure_streaming_layout(
60+
model_dir: Path,
61+
layer_prefix: str = "model.layers",
62+
verbose: bool = False,
63+
) -> bool:
64+
"""
65+
Ensure a model directory has per-layer safetensors for streaming.
66+
67+
If ``fixed_weights.safetensors`` is missing, split the first ``model*.safetensors``
68+
file in place. Returns True if a split was performed.
69+
"""
70+
model_dir = Path(model_dir)
71+
if (model_dir / "fixed_weights.safetensors").exists():
72+
return False
73+
74+
weight_files = sorted(glob.glob(str(model_dir / "model*.safetensors")))
75+
if not weight_files:
76+
raise FileNotFoundError(
77+
f"No model*.safetensors in {model_dir}. "
78+
"Convert or download a model first, then enable streaming."
79+
)
80+
81+
if verbose:
82+
print(f"Preparing streaming layout in {model_dir}")
83+
split_model_by_layers(Path(weight_files[0]), model_dir, layer_prefix)
84+
return True
85+
86+
5887
def main():
5988
parser = argparse.ArgumentParser(
6089
description="Split a model into per-layer safetensors for streaming inference"

0 commit comments

Comments
 (0)