DWQ for very large models (#536)
* pipeline parallel mixin * Refactor pipeline parallel, add optional target saving to DWQ * preserve batch order * Fixes * fix glm4 pipeline * event timeout hack * use full targets for regular training
This commit is contained in:
@@ -17,71 +17,11 @@ https://ml-explore.github.io/mlx/build/html/usage/distributed.html).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import resource
|
||||
from pathlib import Path
|
||||
|
||||
import mlx.core as mx
|
||||
from huggingface_hub import snapshot_download
|
||||
from mlx.utils import tree_flatten
|
||||
|
||||
from mlx_lm import load, stream_generate
|
||||
from mlx_lm.utils import load_model, load_tokenizer
|
||||
|
||||
# Needed for 8 bit model
|
||||
resource.setrlimit(resource.RLIMIT_NOFILE, (2048, 4096))
|
||||
|
||||
|
||||
def download(repo: str, allow_patterns: list[str]) -> Path:
|
||||
return Path(
|
||||
snapshot_download(
|
||||
repo,
|
||||
allow_patterns=allow_patterns,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def shard_and_load(repo):
|
||||
# Get model path with everything but weight safetensors
|
||||
model_path = download(
|
||||
args.model,
|
||||
allow_patterns=["*.json", "*.py", "tokenizer.model", "*.tiktoken", "*.txt"],
|
||||
)
|
||||
|
||||
# Lazy load and shard model to figure out
|
||||
# which weights we need
|
||||
model, config = load_model(model_path, lazy=True, strict=False)
|
||||
|
||||
group = mx.distributed.init()
|
||||
rank = group.rank()
|
||||
model.model.pipeline(group)
|
||||
|
||||
# Figure out which files we need for the local shard
|
||||
with open(model_path / "model.safetensors.index.json", "r") as fid:
|
||||
weight_index = json.load(fid)["weight_map"]
|
||||
|
||||
local_files = set()
|
||||
for k, _ in tree_flatten(model.parameters()):
|
||||
local_files.add(weight_index[k])
|
||||
|
||||
# Download weights for local shard
|
||||
download(args.model, allow_patterns=local_files)
|
||||
|
||||
# Load and shard the model, and load the weights
|
||||
tokenizer = load_tokenizer(
|
||||
model_path,
|
||||
{"trust_remote_code": True},
|
||||
eos_token_ids=config.get("eos_token_id", None),
|
||||
)
|
||||
model, _ = load_model(model_path, lazy=True, strict=False)
|
||||
model.model.pipeline(group)
|
||||
mx.eval(model.parameters())
|
||||
|
||||
# Synchronize processes before generation to avoid timeout if downloading
|
||||
# model for the first time.
|
||||
mx.eval(mx.distributed.all_sum(mx.array(1.0), stream=mx.cpu))
|
||||
return model, tokenizer
|
||||
|
||||
from mlx_lm import stream_generate
|
||||
from mlx_lm.utils import pipeline_load
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="LLM pipelined inference example")
|
||||
@@ -112,7 +52,7 @@ if __name__ == "__main__":
|
||||
if rank == 0:
|
||||
print(*args, **kwargs)
|
||||
|
||||
model, tokenizer = shard_and_load(args.model)
|
||||
model, tokenizer = pipeline_load(args.model)
|
||||
|
||||
messages = [{"role": "user", "content": args.prompt}]
|
||||
prompt = tokenizer.apply_chat_template(messages, add_generation_prompt=True)
|
||||
|
||||
@@ -8,6 +8,7 @@ import mlx.core as mx
|
||||
import mlx.nn as nn
|
||||
|
||||
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
|
||||
from .pipeline import PipelineMixin
|
||||
from .switch_layers import SwitchGLU
|
||||
|
||||
|
||||
@@ -355,7 +356,7 @@ class DeepseekV2DecoderLayer(nn.Module):
|
||||
return out
|
||||
|
||||
|
||||
class DeepseekV2Model(nn.Module):
|
||||
class DeepseekV2Model(PipelineMixin, nn.Module):
|
||||
def __init__(self, config: ModelArgs):
|
||||
super().__init__()
|
||||
self.vocab_size = config.vocab_size
|
||||
@@ -364,32 +365,8 @@ class DeepseekV2Model(nn.Module):
|
||||
DeepseekV2DecoderLayer(config, idx)
|
||||
for idx in range(config.num_hidden_layers)
|
||||
]
|
||||
self.start_idx = 0
|
||||
self.end_idx = len(self.layers)
|
||||
self.num_layers = self.end_idx
|
||||
|
||||
self.norm = nn.RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
|
||||
|
||||
self.pipeline_rank = 0
|
||||
self.pipeline_size = 1
|
||||
|
||||
def pipeline(self, group):
|
||||
# Split layers in reverse so rank=0 gets the last layers and
|
||||
# rank=pipeline_size-1 gets the first
|
||||
self.pipeline_rank = group.rank()
|
||||
self.pipeline_size = group.size()
|
||||
layers_per_rank = len(self.layers) // self.pipeline_size
|
||||
extra = len(self.layers) - layers_per_rank * self.pipeline_size
|
||||
if self.pipeline_rank < extra:
|
||||
layers_per_rank += 1
|
||||
|
||||
self.start_idx = (self.pipeline_size - self.pipeline_rank - 1) * layers_per_rank
|
||||
self.end_idx = self.start_idx + layers_per_rank
|
||||
self.num_layers = layers_per_rank
|
||||
self.layers = self.layers[: self.end_idx]
|
||||
self.layers[: self.start_idx] = [None] * self.start_idx
|
||||
self.num_layers = len(self.layers) - self.start_idx
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
x: mx.array,
|
||||
@@ -401,15 +378,15 @@ class DeepseekV2Model(nn.Module):
|
||||
pipeline_size = self.pipeline_size
|
||||
|
||||
if cache is None:
|
||||
cache = [None] * self.num_layers
|
||||
cache = [None] * len(self.pipeline_layers)
|
||||
mask = create_attention_mask(h, cache[0])
|
||||
|
||||
# Receive from the previous process in the pipeline
|
||||
if pipeline_rank < pipeline_size - 1:
|
||||
h = mx.distributed.recv_like(h, (pipeline_rank + 1))
|
||||
|
||||
for i in range(self.num_layers):
|
||||
h = self.layers[self.start_idx + i](h, mask, cache[i])
|
||||
for l, c in zip(self.pipeline_layers, cache):
|
||||
h = l(h, mask, cache=c)
|
||||
|
||||
# Send to the next process in the pipeline
|
||||
if pipeline_rank != 0:
|
||||
@@ -454,4 +431,4 @@ class Model(nn.Module):
|
||||
|
||||
@property
|
||||
def layers(self):
|
||||
return self.model.layers[self.model.start_idx : self.model.end_idx]
|
||||
return self.model.pipeline_layers
|
||||
|
||||
@@ -9,6 +9,7 @@ import mlx.core as mx
|
||||
import mlx.nn as nn
|
||||
|
||||
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
|
||||
from .pipeline import PipelineMixin
|
||||
from .switch_layers import SwitchGLU
|
||||
|
||||
|
||||
@@ -389,7 +390,7 @@ class DeepseekV3DecoderLayer(nn.Module):
|
||||
return h + r
|
||||
|
||||
|
||||
class DeepseekV3Model(nn.Module):
|
||||
class DeepseekV3Model(PipelineMixin, nn.Module):
|
||||
def __init__(self, config: ModelArgs):
|
||||
super().__init__()
|
||||
self.vocab_size = config.vocab_size
|
||||
@@ -398,28 +399,7 @@ class DeepseekV3Model(nn.Module):
|
||||
DeepseekV3DecoderLayer(config, idx)
|
||||
for idx in range(config.num_hidden_layers)
|
||||
]
|
||||
self.start_idx = 0
|
||||
self.end_idx = len(self.layers)
|
||||
self.num_layers = self.end_idx
|
||||
|
||||
self.norm = nn.RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
|
||||
self.pipeline_rank = 0
|
||||
self.pipeline_size = 1
|
||||
|
||||
def pipeline(self, group):
|
||||
# Split layers in reverse so rank=0 gets the last layers and
|
||||
# rank=pipeline_size-1 gets the first
|
||||
self.pipeline_rank = group.rank()
|
||||
self.pipeline_size = group.size()
|
||||
layers_per_rank = len(self.layers) // self.pipeline_size
|
||||
extra = len(self.layers) - layers_per_rank * self.pipeline_size
|
||||
if self.pipeline_rank < extra:
|
||||
layers_per_rank += 1
|
||||
self.start_idx = (self.pipeline_size - self.pipeline_rank - 1) * layers_per_rank
|
||||
self.end_idx = self.start_idx + layers_per_rank
|
||||
self.layers = self.layers[: self.end_idx]
|
||||
self.layers[: self.start_idx] = [None] * self.start_idx
|
||||
self.num_layers = len(self.layers) - self.start_idx
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
@@ -432,16 +412,15 @@ class DeepseekV3Model(nn.Module):
|
||||
pipeline_size = self.pipeline_size
|
||||
|
||||
if cache is None:
|
||||
cache = [None] * self.num_layers
|
||||
cache = [None] * len(self.pipeline_layers)
|
||||
mask = create_attention_mask(h, cache[0])
|
||||
|
||||
# Receive from the previous process in the pipeline
|
||||
|
||||
if pipeline_rank < pipeline_size - 1:
|
||||
h = mx.distributed.recv_like(h, (pipeline_rank + 1))
|
||||
|
||||
for i in range(self.num_layers):
|
||||
h = self.layers[self.start_idx + i](h, mask, cache[i])
|
||||
for l, c in zip(self.pipeline_layers, cache):
|
||||
h = l(h, mask, cache=c)
|
||||
|
||||
# Send to the next process in the pipeline
|
||||
if pipeline_rank != 0:
|
||||
@@ -521,7 +500,7 @@ class Model(nn.Module):
|
||||
|
||||
@property
|
||||
def layers(self):
|
||||
return self.model.layers[self.model.start_idx : self.model.end_idx]
|
||||
return self.model.pipeline_layers
|
||||
|
||||
@property
|
||||
def cast_predicate(self):
|
||||
|
||||
@@ -9,6 +9,7 @@ import mlx.core as mx
|
||||
import mlx.nn as nn
|
||||
|
||||
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
|
||||
from .pipeline import PipelineMixin
|
||||
from .switch_layers import SwitchGLU
|
||||
|
||||
|
||||
@@ -243,7 +244,7 @@ class DecoderLayer(nn.Module):
|
||||
return h + r
|
||||
|
||||
|
||||
class LanguageModel(nn.Module):
|
||||
class LanguageModel(PipelineMixin, nn.Module):
|
||||
def __init__(self, config: ModelArgs):
|
||||
super().__init__()
|
||||
self.vocab_size = config.vocab_size
|
||||
@@ -264,13 +265,28 @@ class LanguageModel(nn.Module):
|
||||
) -> mx.array:
|
||||
h = self.embed_tokens(x)
|
||||
|
||||
if cache is None:
|
||||
cache = [None] * self.num_layers
|
||||
pipeline_rank = self.pipeline_rank
|
||||
pipeline_size = self.pipeline_size
|
||||
|
||||
if cache is None:
|
||||
cache = [None] * len(self.pipeline_layers)
|
||||
mask = create_attention_mask(h, cache[0])
|
||||
|
||||
for i in range(self.num_layers):
|
||||
h = self.layers[self.start_idx + i](h, mask, cache[i])
|
||||
# Receive from the previous process in the pipeline
|
||||
if pipeline_rank < pipeline_size - 1:
|
||||
h = mx.distributed.recv_like(h, (pipeline_rank + 1))
|
||||
|
||||
for l, c in zip(self.pipeline_layers, cache):
|
||||
h = l(h, mask, cache=c)
|
||||
|
||||
# Send to the next process in the pipeline
|
||||
if pipeline_rank != 0:
|
||||
h = mx.distributed.send(h, (pipeline_rank - 1) % pipeline_size)
|
||||
if cache[-1] is not None:
|
||||
cache[-1].keys = mx.depends(cache[-1].keys, h)
|
||||
|
||||
# Broadcast h while keeping it in the graph
|
||||
h = mx.distributed.all_gather(h)[: h.shape[0]]
|
||||
|
||||
return self.norm(h)
|
||||
|
||||
@@ -315,7 +331,7 @@ class Model(nn.Module):
|
||||
|
||||
@property
|
||||
def layers(self):
|
||||
return self.model.layers
|
||||
return self.model.pipeline_layers
|
||||
|
||||
@property
|
||||
def cast_predicate(self):
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Copyright © 2025 Apple Inc.
|
||||
|
||||
import mlx.core as mx
|
||||
|
||||
|
||||
class PipelineMixin:
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.pipeline_rank = 0
|
||||
self.pipeline_size = 1
|
||||
self.start_idx = 0
|
||||
self.end_idx = None
|
||||
|
||||
@property
|
||||
def pipeline_layers(self):
|
||||
return self.layers[self.start_idx : self.end_idx]
|
||||
|
||||
def pipeline(self, group):
|
||||
# Split layers in reverse so rank=0 gets the last layers and
|
||||
# rank=pipeline_size-1 gets the first
|
||||
self.pipeline_rank = group.rank()
|
||||
self.pipeline_size = group.size()
|
||||
layers_per_rank = len(self.layers) // self.pipeline_size
|
||||
extra = len(self.layers) - layers_per_rank * self.pipeline_size
|
||||
if self.pipeline_rank < extra:
|
||||
layers_per_rank += 1
|
||||
self.start_idx = (self.pipeline_size - self.pipeline_rank - 1) * layers_per_rank
|
||||
self.end_idx = self.start_idx + layers_per_rank
|
||||
self.layers = self.layers[: self.end_idx]
|
||||
# Keep the layer numbers the same for model loading
|
||||
self.layers[: self.start_idx] = [None] * self.start_idx
|
||||
+132
-23
@@ -4,6 +4,7 @@ import argparse
|
||||
import copy
|
||||
import time
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import mlx.core as mx
|
||||
import mlx.nn as nn
|
||||
@@ -18,19 +19,62 @@ from mlx_lm.tuner.trainer import grad_checkpoint, iterate_batches
|
||||
from mlx_lm.tuner.utils import print_trainable_parameters
|
||||
from mlx_lm.utils import (
|
||||
load,
|
||||
load_tokenizer,
|
||||
pipeline_load,
|
||||
quantize_model,
|
||||
save,
|
||||
)
|
||||
|
||||
|
||||
def compute_dwq_targets(
|
||||
model,
|
||||
save_dir,
|
||||
train_data,
|
||||
valid_data,
|
||||
batch_size,
|
||||
max_seq_length,
|
||||
seed,
|
||||
):
|
||||
rank = mx.distributed.init().rank()
|
||||
|
||||
def _compute_targets(data, path, split):
|
||||
|
||||
if rank == 0:
|
||||
path = path / split
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
for i, (batch, _) in (
|
||||
pbar := tqdm(
|
||||
enumerate(iterate_batches(data, batch_size, max_seq_length, seed=seed)),
|
||||
total=len(data) // batch_size,
|
||||
desc=f"Computing targets for {split}",
|
||||
disable=rank != 0,
|
||||
)
|
||||
):
|
||||
batch = batch[:, :-1]
|
||||
logits = model(batch)
|
||||
# Hack to make the last op pre-eval on the CPU to avoid even timeout
|
||||
logits = mx.stop_gradient(logits, stream=mx.cpu)
|
||||
mx.eval(logits)
|
||||
if rank == 0:
|
||||
idx = mx.argpartition(logits, kth=-1024, axis=-1)[..., -1024:]
|
||||
logits = mx.take_along_axis(logits, idx, axis=-1)
|
||||
|
||||
file = path / f"{i:010d}.safetensors"
|
||||
mx.save_safetensors(file, {"logits": logits, "indices": idx})
|
||||
|
||||
_compute_targets(valid_data, save_dir, "valid")
|
||||
_compute_targets(train_data, save_dir, "train")
|
||||
|
||||
|
||||
def dwq_quantize(
|
||||
model,
|
||||
q_model,
|
||||
target_fn,
|
||||
opt,
|
||||
train_data,
|
||||
valid_data,
|
||||
batch_size: int = 2,
|
||||
max_seq_length: int = 2048,
|
||||
batch_size,
|
||||
max_seq_length,
|
||||
seed,
|
||||
dtype: mx.Dtype = mx.bfloat16,
|
||||
gradient_checkpoint: bool = False,
|
||||
temperature: float = 2.0,
|
||||
@@ -52,18 +96,21 @@ def dwq_quantize(
|
||||
):
|
||||
m.unfreeze(keys=["scales", "biases"], recurse=False)
|
||||
|
||||
q_model.train()
|
||||
q_model.apply_to_modules(unfreeze)
|
||||
print_trainable_parameters(q_model)
|
||||
model.train()
|
||||
model.apply_to_modules(unfreeze)
|
||||
print_trainable_parameters(model)
|
||||
|
||||
if gradient_checkpoint:
|
||||
grad_checkpoint(q_model.layers[0])
|
||||
grad_checkpoint(model.layers[0])
|
||||
|
||||
scale = 1 / temperature
|
||||
|
||||
def loss_fn(params, x, targets, lengths):
|
||||
q_model.update(tree_map(lambda x: x.astype(dtype), params))
|
||||
logits = q_model(x)
|
||||
model.update(tree_map(lambda x: x.astype(dtype), params))
|
||||
logits = model(x)
|
||||
if isinstance(targets, tuple):
|
||||
targets, ids = targets
|
||||
logits = mx.take_along_axis(logits, ids, axis=-1)
|
||||
losses = kl_div_loss(scale * logits, scale * targets)
|
||||
mask = mx.arange(1, 1 + targets.shape[1]) < lengths[:, 1:]
|
||||
ntoks = mask.sum()
|
||||
@@ -81,14 +128,16 @@ def dwq_quantize(
|
||||
def validate(params, it):
|
||||
v_loss = 0.0
|
||||
v_tokens = 0
|
||||
for batch, lengths in tqdm(
|
||||
iterate_batches(valid_data, batch_size, max_seq_length),
|
||||
for i, (batch, lengths) in tqdm(
|
||||
enumerate(
|
||||
iterate_batches(valid_data, batch_size, max_seq_length, seed=seed)
|
||||
),
|
||||
total=len(valid_data) // batch_size,
|
||||
desc="Computing validation loss",
|
||||
leave=False,
|
||||
):
|
||||
batch = batch[:, :-1]
|
||||
targets = model(batch)
|
||||
targets = target_fn(batch, i, split="valid")
|
||||
mx.eval(targets)
|
||||
loss, ntoks = loss_fn(params, batch, targets, lengths)
|
||||
mx.eval(loss, ntoks)
|
||||
@@ -103,7 +152,7 @@ def dwq_quantize(
|
||||
# Accumulate learned weights in higher precision
|
||||
params = tree_map(
|
||||
lambda x: x.astype(mx.float32),
|
||||
q_model.trainable_parameters(),
|
||||
model.trainable_parameters(),
|
||||
)
|
||||
|
||||
total_loss = 0.0
|
||||
@@ -117,12 +166,14 @@ def dwq_quantize(
|
||||
|
||||
for it, (batch, lengths) in (
|
||||
pbar := tqdm(
|
||||
enumerate(iterate_batches(train_data, batch_size, max_seq_length)),
|
||||
enumerate(
|
||||
iterate_batches(train_data, batch_size, max_seq_length, seed=seed)
|
||||
),
|
||||
total=len(train_data) // batch_size,
|
||||
)
|
||||
):
|
||||
batch = batch[:, :-1]
|
||||
targets = model(batch)
|
||||
targets = target_fn(batch, it, split="train")
|
||||
mx.eval(targets)
|
||||
loss, ntoks, params = step(batch, targets, lengths, params)
|
||||
mx.eval(loss, params)
|
||||
@@ -155,7 +206,7 @@ def dwq_quantize(
|
||||
" Model quality will likely be degraded.\n❌❌❌"
|
||||
)
|
||||
|
||||
q_model.update(tree_map(lambda x: x.astype(dtype), params))
|
||||
model.update(tree_map(lambda x: x.astype(dtype), params))
|
||||
|
||||
|
||||
def load_data(
|
||||
@@ -196,10 +247,12 @@ def main():
|
||||
help="A model to distill from for DWQ. If `quantized-model` is not"
|
||||
" given the student model will be this model quantized according"
|
||||
" to `bits` and `group-size`.",
|
||||
type=str,
|
||||
required=True,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--quantized-model",
|
||||
type=str,
|
||||
default=None,
|
||||
help="An already quantized model (the student model) to improve with DWQ.",
|
||||
)
|
||||
@@ -236,27 +289,78 @@ def main():
|
||||
action="store_true",
|
||||
help="Use gradient checkpointing to reduce memory use.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--target-dir", type=str, default=None, help="Directory to save/load targets."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--targets-only", action="store_true", help="Compute the targets and exit."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pipeline",
|
||||
action="store_true",
|
||||
help="Use pipeline parallel instead of data parallel.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
group = mx.distributed.init()
|
||||
|
||||
num_samples = args.num_samples
|
||||
if num_samples % group.size() > 0:
|
||||
if not args.pipeline and num_samples % group.size() > 0:
|
||||
num_samples += group.size() - num_samples % group.size()
|
||||
|
||||
np.random.seed(args.seed)
|
||||
mx.random.seed(args.seed)
|
||||
|
||||
model, tokenizer, config = load(
|
||||
args.model,
|
||||
lazy=True,
|
||||
return_config=True,
|
||||
)
|
||||
if args.target_dir is not None:
|
||||
target_dir = Path(args.target_dir)
|
||||
has_targets = target_dir.exists()
|
||||
else:
|
||||
has_targets = False
|
||||
target_dir = None
|
||||
|
||||
tokenizer = load_tokenizer(args.model)
|
||||
|
||||
train_data, valid_data = load_data(
|
||||
tokenizer, args.data_path, args.num_samples, args.max_seq_length
|
||||
)
|
||||
|
||||
# Load the base model if we need it
|
||||
if not has_targets or args.quantized_model is None:
|
||||
if args.pipeline and group.size() > 1:
|
||||
model, _, config = pipeline_load(args.model, return_config=True)
|
||||
else:
|
||||
model, _, config = load(args.model, return_config=True, lazy=True)
|
||||
else:
|
||||
model = None
|
||||
|
||||
# Pre-compute the targets
|
||||
if not has_targets and target_dir is not None:
|
||||
compute_dwq_targets(
|
||||
model,
|
||||
target_dir,
|
||||
train_data,
|
||||
valid_data,
|
||||
batch_size=args.batch_size,
|
||||
max_seq_length=args.max_seq_length,
|
||||
seed=args.seed,
|
||||
)
|
||||
has_targets = True
|
||||
|
||||
if args.targets_only:
|
||||
exit(0)
|
||||
|
||||
if has_targets:
|
||||
|
||||
def target_fn(_, idx, split):
|
||||
targets = mx.load(target_dir / split / f"{idx:010d}.safetensors")
|
||||
return targets["logits"], targets["indices"]
|
||||
|
||||
else:
|
||||
|
||||
def target_fn(batch, idx, split):
|
||||
return model(batch)
|
||||
|
||||
if args.quantized_model is not None:
|
||||
q_model, tokenizer, config = load(
|
||||
args.quantized_model,
|
||||
@@ -274,19 +378,24 @@ def main():
|
||||
bits=args.bits,
|
||||
)
|
||||
|
||||
# Delete the base model if it's not needed
|
||||
if has_targets and model is not None:
|
||||
del model
|
||||
|
||||
if mx.metal.is_available():
|
||||
max_rec_size = mx.metal.device_info()["max_recommended_working_set_size"]
|
||||
mx.set_wired_limit(max_rec_size)
|
||||
|
||||
opt = optimizers.Adam(learning_rate=args.learning_rate, bias_correction=True)
|
||||
dwq_quantize(
|
||||
model,
|
||||
q_model,
|
||||
target_fn,
|
||||
opt,
|
||||
train_data,
|
||||
valid_data,
|
||||
batch_size=args.batch_size,
|
||||
max_seq_length=args.max_seq_length,
|
||||
seed=args.seed,
|
||||
gradient_checkpoint=args.grad_checkpoint,
|
||||
)
|
||||
save(
|
||||
|
||||
@@ -423,7 +423,7 @@ def _is_bpe_decoder(decoder):
|
||||
return isinstance(decoder, dict) and decoder.get("type", None) == "ByteLevel"
|
||||
|
||||
|
||||
def load_tokenizer(
|
||||
def load(
|
||||
model_path,
|
||||
tokenizer_config_extra: Optional[Dict[str, Any]] = None,
|
||||
return_tokenizer=True,
|
||||
@@ -438,6 +438,7 @@ def load_tokenizer(
|
||||
detokenizer_class = NaiveStreamingDetokenizer
|
||||
|
||||
tokenizer_file = model_path / "tokenizer.json"
|
||||
|
||||
if tokenizer_file.exists():
|
||||
with open(tokenizer_file, "r", encoding="utf-8") as fid:
|
||||
try:
|
||||
|
||||
+15
-6
@@ -92,7 +92,9 @@ def iterate_batches(
|
||||
dataset,
|
||||
batch_size,
|
||||
max_seq_length,
|
||||
train=False,
|
||||
loop=False,
|
||||
seed=None,
|
||||
comm_group=None,
|
||||
):
|
||||
# Sort by length:
|
||||
if isinstance(dataset, CacheDataset):
|
||||
@@ -108,8 +110,12 @@ def iterate_batches(
|
||||
|
||||
# If running in distributed mode (N machines) then each one should skip N-1
|
||||
# samples
|
||||
offset = mx.distributed.init().rank()
|
||||
step = mx.distributed.init().size()
|
||||
if comm_group is not None:
|
||||
offset = comm_group.rank()
|
||||
step = comm_group.size()
|
||||
else:
|
||||
offset = 0
|
||||
step = 1
|
||||
if batch_size % step != 0:
|
||||
raise ValueError("The batch size must be divisible by the number of workers")
|
||||
|
||||
@@ -118,7 +124,8 @@ def iterate_batches(
|
||||
idx[i + offset : i + offset + batch_size : step]
|
||||
for i in range(0, len(idx) - batch_size + 1, batch_size)
|
||||
]
|
||||
|
||||
if seed:
|
||||
np.random.seed(seed)
|
||||
while True:
|
||||
indices = np.random.permutation(len(batch_idx))
|
||||
for i in indices:
|
||||
@@ -151,7 +158,7 @@ def iterate_batches(
|
||||
batch = mx.array(batch_arr)
|
||||
yield batch, mx.array(list(zip(offsets, lengths)))
|
||||
|
||||
if not train:
|
||||
if not loop:
|
||||
break
|
||||
|
||||
|
||||
@@ -177,6 +184,7 @@ def evaluate(
|
||||
dataset=dataset,
|
||||
batch_size=batch_size,
|
||||
max_seq_length=max_seq_length,
|
||||
comm_group=mx.distributed.init(),
|
||||
),
|
||||
),
|
||||
desc="Calculating loss...",
|
||||
@@ -254,7 +262,8 @@ def train(
|
||||
dataset=train_dataset,
|
||||
batch_size=args.batch_size,
|
||||
max_seq_length=args.max_seq_length,
|
||||
train=True,
|
||||
loop=True,
|
||||
comm_group=world,
|
||||
),
|
||||
):
|
||||
tic = time.perf_counter()
|
||||
|
||||
+102
-14
@@ -7,6 +7,7 @@ import inspect
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import resource
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
@@ -14,6 +15,7 @@ from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
Type,
|
||||
@@ -31,11 +33,14 @@ if os.getenv("MLXLM_USE_MODELSCOPE", "False").lower() == "true":
|
||||
else:
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
# For large models with lots of files
|
||||
resource.setrlimit(resource.RLIMIT_NOFILE, (2048, 4096))
|
||||
|
||||
from mlx.utils import tree_flatten, tree_map, tree_reduce, tree_unflatten
|
||||
from transformers import PreTrainedTokenizer
|
||||
|
||||
# Local imports
|
||||
from .tokenizer_utils import TokenizerWrapper, load_tokenizer
|
||||
from .tokenizer_utils import TokenizerWrapper
|
||||
from .tokenizer_utils import load as _load_tokenizer
|
||||
|
||||
# Constants
|
||||
MODEL_REMAPPING = {
|
||||
@@ -94,7 +99,11 @@ def compute_bits_per_weight(model):
|
||||
return model_bytes * 8 / model_params
|
||||
|
||||
|
||||
def _download(path_or_hf_repo: str, revision: Optional[str] = None) -> Path:
|
||||
def _download(
|
||||
path_or_hf_repo: str,
|
||||
revision: Optional[str] = None,
|
||||
allow_patterns: List[str] = None,
|
||||
) -> Path:
|
||||
"""
|
||||
Ensures the model is available locally. If the path does not exist locally,
|
||||
it is downloaded from the Hugging Face Hub.
|
||||
@@ -109,21 +118,22 @@ def _download(path_or_hf_repo: str, revision: Optional[str] = None) -> Path:
|
||||
model_path = Path(path_or_hf_repo)
|
||||
|
||||
if not model_path.exists():
|
||||
allow_patterns = allow_patterns or [
|
||||
"*.json",
|
||||
"model*.safetensors",
|
||||
"*.py",
|
||||
"tokenizer.model",
|
||||
"*.tiktoken",
|
||||
"tiktoken.model",
|
||||
"*.txt",
|
||||
"*.jsonl",
|
||||
"*.jinja",
|
||||
]
|
||||
model_path = Path(
|
||||
snapshot_download(
|
||||
path_or_hf_repo,
|
||||
revision=revision,
|
||||
allow_patterns=[
|
||||
"*.json",
|
||||
"model*.safetensors",
|
||||
"*.py",
|
||||
"tokenizer.model",
|
||||
"*.tiktoken",
|
||||
"tiktoken.model",
|
||||
"*.txt",
|
||||
"*.jsonl",
|
||||
"*.jinja",
|
||||
],
|
||||
allow_patterns=allow_patterns,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -244,6 +254,28 @@ def load_adapters(model: nn.Module, adapter_path: str) -> nn.Module:
|
||||
return _load_adapters(model, adapter_path)
|
||||
|
||||
|
||||
def load_tokenizer(model_path, tokenizer_config_extra=None, eos_token_ids=None):
|
||||
"""Load a huggingface tokenizer and try to infer the type of streaming
|
||||
detokenizer to use.
|
||||
"""
|
||||
model_path = _download(
|
||||
model_path,
|
||||
allow_patterns=[
|
||||
"*.json",
|
||||
"*.py",
|
||||
"tokenizer.model",
|
||||
"*.tiktoken",
|
||||
"tiktoken.model",
|
||||
"*.txt",
|
||||
"*.jsonl",
|
||||
"*.jinja",
|
||||
],
|
||||
)
|
||||
return _load_tokenizer(
|
||||
model_path, tokenizer_config_extra, eos_token_ids=eos_token_ids
|
||||
)
|
||||
|
||||
|
||||
def load(
|
||||
path_or_hf_repo: str,
|
||||
tokenizer_config: Optional[Dict[str, Any]] = None,
|
||||
@@ -296,6 +328,62 @@ def load(
|
||||
return model, tokenizer
|
||||
|
||||
|
||||
def pipeline_load(repo, return_config=False):
|
||||
# Get model path with everything but weight safetensors
|
||||
model_path = _download(
|
||||
repo,
|
||||
allow_patterns=[
|
||||
"*.json",
|
||||
"*.py",
|
||||
"tokenizer.model",
|
||||
"*.tiktoken",
|
||||
"tiktoken.model",
|
||||
"*.txt",
|
||||
"*.jsonl",
|
||||
"*.jinja",
|
||||
],
|
||||
)
|
||||
|
||||
# Lazy load and shard model to figure out which weights we need
|
||||
model, config = load_model(model_path, lazy=True, strict=False)
|
||||
|
||||
group = mx.distributed.init()
|
||||
rank = group.rank()
|
||||
model.model.pipeline(group)
|
||||
|
||||
# Figure out which files we need for the local shard
|
||||
with open(model_path / "model.safetensors.index.json", "r") as fid:
|
||||
weight_index = json.load(fid)["weight_map"]
|
||||
|
||||
local_files = set()
|
||||
for k, _ in tree_flatten(model.parameters()):
|
||||
if file_name := weight_index.get(k, None) is None:
|
||||
raise ValueError(
|
||||
"Pipeline loading is only supported for MLX converted models."
|
||||
)
|
||||
local_files.add(weight_index[k])
|
||||
|
||||
# Download weights for local shard
|
||||
_download(repo, allow_patterns=local_files)
|
||||
|
||||
# Load and shard the model, and load the weights
|
||||
tokenizer = load_tokenizer(
|
||||
model_path,
|
||||
{"trust_remote_code": True},
|
||||
eos_token_ids=config.get("eos_token_id", None),
|
||||
)
|
||||
model, _ = load_model(model_path, lazy=True, strict=False)
|
||||
model.model.pipeline(group)
|
||||
mx.eval(model.parameters())
|
||||
|
||||
# Synchronize processes to avoid timeout
|
||||
mx.eval(mx.distributed.all_sum(mx.array(1.0), stream=mx.cpu))
|
||||
if return_config:
|
||||
return model, tokenizer, config
|
||||
else:
|
||||
return model, tokenizer
|
||||
|
||||
|
||||
def make_shards(weights: dict, max_file_size_gb: int = MAX_FILE_SIZE_GB) -> list:
|
||||
"""
|
||||
Splits the weights into smaller shards.
|
||||
|
||||
@@ -5,7 +5,7 @@ import sys
|
||||
import unittest
|
||||
from contextlib import contextmanager
|
||||
from io import StringIO
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import ANY, MagicMock
|
||||
|
||||
import mlx.core as mx
|
||||
import mlx.nn as nn
|
||||
@@ -405,6 +405,7 @@ class TestScheduleConfig(unittest.TestCase):
|
||||
dataset=mock_dataset,
|
||||
batch_size=2,
|
||||
max_seq_length=2048,
|
||||
comm_group=ANY,
|
||||
)
|
||||
self.assertEqual(mock_default_loss.call_count, 2)
|
||||
|
||||
@@ -441,6 +442,7 @@ class TestScheduleConfig(unittest.TestCase):
|
||||
dataset=mock_dataset,
|
||||
batch_size=2,
|
||||
max_seq_length=2048,
|
||||
comm_group=ANY,
|
||||
)
|
||||
self.assertEqual(mock_default_loss.call_count, 3)
|
||||
|
||||
|
||||
@@ -9,27 +9,12 @@ from mlx_lm.tokenizer_utils import (
|
||||
BPEStreamingDetokenizer,
|
||||
NaiveStreamingDetokenizer,
|
||||
SPMStreamingDetokenizer,
|
||||
load_tokenizer,
|
||||
)
|
||||
from mlx_lm.utils import load_tokenizer
|
||||
|
||||
|
||||
class TestTokenizers(unittest.TestCase):
|
||||
|
||||
def download_tokenizer(self, repo):
|
||||
path = Path(
|
||||
snapshot_download(
|
||||
repo_id=repo,
|
||||
allow_patterns=[
|
||||
"tokenizer.json",
|
||||
"tokenizer_config.json",
|
||||
"special_tokens_map.json",
|
||||
"tokenizer.model",
|
||||
"chat_template.jinja",
|
||||
],
|
||||
)
|
||||
)
|
||||
return load_tokenizer(path)
|
||||
|
||||
def check_tokenizer(self, tokenizer):
|
||||
def check(tokens):
|
||||
expected_text = tokenizer.decode(tokens)
|
||||
@@ -77,19 +62,19 @@ class TestTokenizers(unittest.TestCase):
|
||||
]
|
||||
for tokenizer_repo, expected_detokenizer in tokenizer_repos:
|
||||
with self.subTest(tokenizer=tokenizer_repo):
|
||||
tokenizer = self.download_tokenizer(tokenizer_repo)
|
||||
tokenizer = load_tokenizer(tokenizer_repo)
|
||||
tokenizer.decode([0, 1, 2])
|
||||
self.assertTrue(isinstance(tokenizer.detokenizer, expected_detokenizer))
|
||||
self.check_tokenizer(tokenizer)
|
||||
|
||||
# Try one with a naive detokenizer
|
||||
tokenizer = self.download_tokenizer("mlx-community/Llama-3.2-1B-Instruct-4bit")
|
||||
tokenizer = load_tokenizer("mlx-community/Llama-3.2-1B-Instruct-4bit")
|
||||
tokenizer._detokenizer = NaiveStreamingDetokenizer(tokenizer)
|
||||
self.check_tokenizer(tokenizer)
|
||||
|
||||
def test_special_tokens(self):
|
||||
tokenizer_repo = "mlx-community/DeepSeek-Coder-V2-Lite-Instruct-4bit-mlx"
|
||||
tokenizer = self.download_tokenizer(tokenizer_repo)
|
||||
tokenizer = load_tokenizer(tokenizer_repo)
|
||||
|
||||
detokenizer = tokenizer.detokenizer
|
||||
detokenizer.reset()
|
||||
@@ -100,18 +85,18 @@ class TestTokenizers(unittest.TestCase):
|
||||
|
||||
def test_tool_calling(self):
|
||||
tokenizer_repo = "mlx-community/Qwen3-4B-4bit"
|
||||
tokenizer = self.download_tokenizer(tokenizer_repo)
|
||||
tokenizer = load_tokenizer(tokenizer_repo)
|
||||
self.assertTrue(tokenizer.has_tool_calling)
|
||||
self.assertEqual(tokenizer.tool_call_start, "<tool_call>")
|
||||
self.assertEqual(tokenizer.tool_call_end, "</tool_call>")
|
||||
|
||||
tokenizer_repo = "mlx-community/Llama-3.2-1B-Instruct-4bit"
|
||||
tokenizer = self.download_tokenizer(tokenizer_repo)
|
||||
tokenizer = load_tokenizer(tokenizer_repo)
|
||||
self.assertFalse(tokenizer.has_tool_calling)
|
||||
|
||||
def test_thinking(self):
|
||||
tokenizer_repo = "mlx-community/Qwen3-4B-4bit"
|
||||
tokenizer = self.download_tokenizer(tokenizer_repo)
|
||||
tokenizer = load_tokenizer(tokenizer_repo)
|
||||
self.assertTrue(tokenizer.has_thinking)
|
||||
self.assertEqual(tokenizer.think_start, "<think>")
|
||||
self.assertEqual(tokenizer.think_end, "</think>")
|
||||
|
||||
+22
-34
@@ -19,47 +19,35 @@ class MockDistributedGroup:
|
||||
return self._size
|
||||
|
||||
|
||||
class MockDistributed:
|
||||
def __init__(self):
|
||||
self.rank = 0
|
||||
self.size = 1
|
||||
|
||||
def init(self):
|
||||
return MockDistributedGroup(self.rank, self.size)
|
||||
|
||||
|
||||
class TestTunerTrainer(unittest.TestCase):
|
||||
def test_iterate_batches_ddp(self):
|
||||
olddist = mx.distributed
|
||||
try:
|
||||
mx.distributed = MockDistributed()
|
||||
group = MockDistributedGroup(0, 1)
|
||||
|
||||
def run(rank, size, batch):
|
||||
mx.distributed.rank = rank
|
||||
mx.distributed.size = size
|
||||
def run(rank, size, batch):
|
||||
group._rank = rank
|
||||
group._size = size
|
||||
|
||||
data = mx.arange(128).reshape(-1, 1).tolist()
|
||||
data = [(d, 0) for d in data]
|
||||
data = mx.arange(128).reshape(-1, 1).tolist()
|
||||
data = [(d, 0) for d in data]
|
||||
|
||||
samples = set()
|
||||
for i, (b, l) in enumerate(iterate_batches(data, batch, 1)):
|
||||
samples.add(tuple(mx.flatten(b).tolist()))
|
||||
samples = set()
|
||||
for i, (b, l) in enumerate(
|
||||
iterate_batches(data, batch, 1, comm_group=group)
|
||||
):
|
||||
samples.add(tuple(mx.flatten(b).tolist()))
|
||||
|
||||
ref_batches = mx.arange(128).reshape(-1, batch).tolist()
|
||||
for b in ref_batches:
|
||||
self.assertTrue(tuple(b[rank::size]) in samples)
|
||||
ref_batches = mx.arange(128).reshape(-1, batch).tolist()
|
||||
for b in ref_batches:
|
||||
self.assertTrue(tuple(b[rank::size]) in samples)
|
||||
|
||||
run(0, 1, 4)
|
||||
run(0, 1, 8)
|
||||
run(0, 2, 8)
|
||||
run(1, 2, 8)
|
||||
run(0, 4, 8)
|
||||
run(1, 4, 8)
|
||||
run(2, 4, 8)
|
||||
run(3, 4, 8)
|
||||
|
||||
finally:
|
||||
mx.distributed = olddist
|
||||
run(0, 1, 4)
|
||||
run(0, 1, 8)
|
||||
run(0, 2, 8)
|
||||
run(1, 2, 8)
|
||||
run(0, 4, 8)
|
||||
run(1, 4, 8)
|
||||
run(2, 4, 8)
|
||||
run(3, 4, 8)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user