Compare commits

...

5 Commits

Author SHA1 Message Date
Awni Hannun ec3ab6bea9 bases model can't be quantized 2025-05-07 13:28:46 -07:00
Awni Hannun 1a10247842 add embedding 2025-05-07 13:28:46 -07:00
Awni Hannun bad7f99f0f qat 2025-05-07 13:28:46 -07:00
Awni Hannun 75c2d80360 Update defaults + layer selection 2025-05-07 13:27:51 -07:00
Awni Hannun 4783b20bce Add L1 activation penalty for DWQ 2025-05-07 13:27:51 -07:00
2 changed files with 392 additions and 15 deletions
+53 -15
View File
@@ -28,6 +28,16 @@ from mlx_lm.utils import (
)
class Catcher(nn.Module):
def __init__(self, module):
super().__init__()
self.module = module
def __call__(self, *args, **kwargs):
self.outputs = self.module(*args, **kwargs)
return self.outputs
def dwq_quantize(
model,
q_model,
@@ -35,7 +45,9 @@ def dwq_quantize(
data,
batch_size: int = 2,
max_seq_length: int = 2048,
temperature: float = 0.5,
temperature: float = 1.0,
activation_layer_step: float = 0.25,
activation_loss_weight: float = 1e-1,
dtype: mx.Dtype = mx.bfloat16,
):
group = mx.distributed.init()
@@ -49,22 +61,46 @@ def dwq_quantize(
q_model.apply_to_modules(unfreeze)
print_trainable_parameters(q_model)
layer_id_step = int(activation_layer_step * len(model.layers))
layer_ids = list(range(len(model.layers)))[layer_id_step::layer_id_step]
for lid in layer_ids:
model.layers[lid] = Catcher(model.layers[lid])
q_model.layers[lid] = Catcher(q_model.layers[lid])
def log_norm(x):
x = x * (1 / temperature)
if temperature != 1.0:
x = x * (1 / temperature)
return x - mx.logsumexp(x, axis=-1, keepdims=True)
def loss_fn(params, x, targets, lengths):
def forward(model, inputs):
logprobs = log_norm(model(inputs).astype(mx.float32))
extra_targets = [
model.layers[lid].outputs.astype(mx.float32) for lid in layer_ids
]
for lid in layer_ids:
model.layers[lid].outputs = None
return logprobs, extra_targets
def loss_fn(params, x, targets, extra_targets, lengths):
q_model.update(tree_map(lambda x: x.astype(dtype), params))
logits = q_model(x).astype(mx.float32)
losses = nn.losses.kl_div_loss(log_norm(logits), targets, reduction="none")
logprobs, q_extra_targets = forward(q_model, x)
losses = nn.losses.kl_div_loss(logprobs, targets, reduction="none")
mask = mx.arange(targets.shape[1]) < lengths[:, 1:]
ntoks = mask.sum()
loss = (mask * losses).sum() / ntoks
kl_loss = (mask * losses).sum() / ntoks
act_loss = mx.stack(
[
(mask * (qe - e).abs().mean(axis=-1)).sum() / ntoks
for qe, e in zip(q_extra_targets, extra_targets)
]
)
loss = kl_loss + activation_loss_weight * act_loss.mean()
return loss, ntoks
def step(inputs, targets, lengths, params):
def step(inputs, targets, extra_targets, lengths, params):
(loss, ntoks), grads = mx.value_and_grad(loss_fn)(
params, inputs, targets, lengths
params, inputs, targets, extra_targets, lengths
)
grads = nn.average_gradients(grads)
params = opt.apply_gradients(grads, params)
@@ -82,9 +118,9 @@ def dwq_quantize(
for it, (batch, lengths) in enumerate(
iterate_batches(data, batch_size, max_seq_length)
):
targets = log_norm(model(batch).astype(mx.float32))
mx.eval(targets)
loss, ntoks, params = step(batch, targets, lengths, params)
targets, extra_targets = forward(model, batch)
mx.eval(targets, extra_targets)
loss, ntoks, params = step(batch, targets, extra_targets, lengths, params)
mx.eval(loss, params)
loss = mx.distributed.all_sum(loss, stream=mx.cpu).item() / world_size
ntoks = mx.distributed.all_sum(ntoks, stream=mx.cpu).item()
@@ -97,6 +133,8 @@ def dwq_quantize(
flush=True,
)
q_model.update(tree_map(lambda x: x.astype(dtype), params))
for lid in layer_ids:
q_model.layers[lid] = q_model.layers[lid].module
def save_model(
@@ -139,7 +177,7 @@ def load_data(tokenizer, data_path: str, num_samples: int):
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--model", "-m", default="Qwen/Qwen3-1.7B")
parser.add_argument("--model", "-m", default="Qwen/Qwen3-4B")
parser.add_argument("--quantized-model", default=None)
parser.add_argument(
"--mlx-path", default="mlx_model", help="Path to save the quantized model."
@@ -161,8 +199,8 @@ def main():
)
parser.add_argument("--max-seq-length", type=int, default=2048)
parser.add_argument("--seed", type=int, default=123)
parser.add_argument("--learning-rate", type=float, default=1e-5)
parser.add_argument("--batch-size", type=int, default=8)
parser.add_argument("--learning-rate", type=float, default=1e-6)
parser.add_argument("--batch-size", type=int, default=4)
parser.add_argument(
"--data-path",
type=str,
@@ -172,7 +210,7 @@ def main():
parser.add_argument(
"--temperature",
type=float,
default=0.5,
default=1.0,
help="Temperature scaling for the loss.",
)
args = parser.parse_args()
+339
View File
@@ -0,0 +1,339 @@
# Copyright © 2025 Apple Inc.
import argparse
import copy
import glob
import shutil
import time
import types
from pathlib import Path
import mlx.core as mx
import mlx.nn as nn
import mlx.optimizers as optimizers
import numpy as np
from mlx.utils import tree_flatten, tree_map, tree_map_with_path
from mlx_lm.tokenizer_utils import TokenizerWrapper
from mlx_lm.tuner.datasets import load_dataset
from mlx_lm.tuner.trainer import iterate_batches
from mlx_lm.tuner.utils import print_trainable_parameters
from mlx_lm.utils import (
create_model_card,
fetch_from_hub,
get_model_path,
quantize_model,
save_config,
save_weights,
)
class StraightThroughQuantizedEmbedding(nn.Module):
def __init__(
self,
num_embeddings: int,
dims: int,
group_size: int = 64,
bits: int = 4,
):
super().__init__()
# Quantization config
self.group_size = group_size
self.bits = bits
# Initialize the quantized weight
self.weight = mx.zeros(shape=(num_embeddings, dims))
self.num_embeddings = num_embeddings
self.dims = dims
def __call__(self, x):
w, s, b = mx.quantize(self.weight, self.group_size, self.bits)
y = self.weight[x]
yq = mx.dequantize(
w[x],
scales=s[x],
biases=b[x],
group_size=self.group_size,
bits=self.bits,
)
return (y - mx.stop_gradient(y)) + mx.stop_gradient(yq)
def as_linear(self, x):
# Quantize and then matmul
w, s, b = mx.quantize(self.weight, self.group_size, self.bits)
y = x @ self.weight.T
yq = mx.quantized_matmul(
x,
w,
scales=s,
biases=b,
transpose=True,
group_size=self.group_size,
bits=self.bits,
)
return (y - mx.stop_gradient(y)) + mx.stop_gradient(yq)
@classmethod
def from_embedding(
cls, embedding_layer: nn.Module, group_size: int = 64, bits: int = 4
):
embedding_dims, dims = embedding_layer.weight.shape
ql = cls(embedding_dims, dims, group_size, bits)
ql.weight = embedding_layer.weight
return ql
class StraightThroughQuantizedLinear(nn.Module):
def __init__(
self,
input_dims: int,
output_dims: int,
bias: bool = True,
group_size: int = 64,
bits: int = 4,
):
super().__init__()
# Quantization config
self.group_size = group_size
self.bits = bits
self.weight = mx.zeros(shape=(output_dims, input_dims))
if bias:
self.bias = mx.zeros((output_dims,))
def __call__(self, x):
# Quantize and then matmul
w, s, b = mx.quantize(self.weight, self.group_size, self.bits)
y = x @ self.weight.T
yq = mx.quantized_matmul(
x,
w,
scales=s,
biases=b,
transpose=True,
group_size=self.group_size,
bits=self.bits,
)
x = (y - mx.stop_gradient(y)) + mx.stop_gradient(yq)
if "bias" in self:
x = x + self["bias"]
return x
@classmethod
def from_linear(cls, linear_layer: nn.Module, group_size: int = 64, bits: int = 4):
output_dims, input_dims = linear_layer.weight.shape
ql = cls(input_dims, output_dims, False, group_size, bits)
if "bias" in linear_layer:
ql.bias = linear_layer.bias
return ql
def quantize(
model: nn.Module,
group_size: int = 64,
bits: int = 4,
):
def _maybe_quantize(path, m):
if isinstance(m, nn.Linear):
return StraightThroughQuantizedLinear.from_linear(
m, group_size=group_size, bits=bits
)
elif isinstance(m, nn.Embedding):
return StraightThroughQuantizedEmbedding.from_embedding(
m, group_size=group_size, bits=bits
)
else:
return m
leaves = tree_map_with_path(
_maybe_quantize, model.leaf_modules(), is_leaf=nn.Module.is_module
)
model.update_modules(leaves)
def qat(
model,
opt,
data,
group_size: int = 64,
bits: int = 3,
batch_size: int = 2,
max_seq_length: int = 2048,
temperature: float = 0.5,
dtype: mx.Dtype = mx.bfloat16,
):
group = mx.distributed.init()
world_size = group.size()
rank = group.rank()
def log_norm(x):
x = x * (1 / temperature)
return x - mx.logsumexp(x, axis=-1, keepdims=True)
q_model = copy.deepcopy(model)
quantize(q_model, bits=bits, group_size=group_size)
def loss_fn(params, x, targets, lengths):
q_model.update(tree_map(lambda x: x.astype(dtype), params))
logits = q_model(x).astype(mx.float32)
losses = nn.losses.kl_div_loss(log_norm(logits), targets, reduction="none")
mask = mx.arange(targets.shape[1]) < lengths[:, 1:]
ntoks = mask.sum()
loss = (mask * losses).sum() / ntoks
return loss, ntoks
def step(inputs, targets, lengths, params):
(loss, ntoks), grads = mx.value_and_grad(loss_fn)(
params, inputs, targets, lengths
)
grads = nn.average_gradients(grads)
params = opt.apply_gradients(grads, params)
return loss, ntoks, params
# Accumulate learned weights in higher precision
params = tree_map(
lambda x: x.astype(mx.float32),
model.trainable_parameters(),
)
avg_loss = None
tokens = 0
tic = time.time()
for it, (batch, lengths) in enumerate(
iterate_batches(data, batch_size, max_seq_length)
):
targets = log_norm(model(batch).astype(mx.float32))
mx.eval(targets)
loss, ntoks, params = step(batch, targets, lengths, params)
mx.eval(loss, params)
loss = mx.distributed.all_sum(loss, stream=mx.cpu).item() / world_size
ntoks = mx.distributed.all_sum(ntoks, stream=mx.cpu).item()
tokens += ntoks
toks_per_sec = tokens / (time.time() - tic)
avg_loss = 0.95 * (avg_loss or loss) + 0.05 * loss
if rank == 0:
print(
f"{it=}, {loss=:.3f}, {avg_loss=:.4f}, {tokens=}, {toks_per_sec=:.3f}",
flush=True,
)
model.update(tree_map(lambda x: x.astype(dtype), params))
def save_model(
model: nn.Module,
tokenizer: TokenizerWrapper,
config,
model_path: Path,
mlx_path: str,
hf_path: str,
):
weights = dict(tree_flatten(model.parameters()))
mlx_path = Path(mlx_path)
save_weights(mlx_path, weights, donate_weights=True)
py_files = glob.glob(str(model_path / "*.py"))
for file in py_files:
shutil.copy(file, mlx_path)
tokenizer.save_pretrained(mlx_path)
save_config(config, config_path=mlx_path / "config.json")
create_model_card(mlx_path, hf_path)
def load_data(tokenizer, data_path: str, num_samples: int):
args = types.SimpleNamespace(
hf_dataset={
"path": data_path,
"train_split": f"train[:{num_samples}]",
"valid_split": "train[:1]",
},
train=True,
test=False,
)
dataset = load_dataset(args, tokenizer)[0]
return [dataset.process(d) for d in dataset]
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--model", "-m", default="Qwen/Qwen3-1.7B")
parser.add_argument(
"--mlx-path", default="mlx_model", help="Path to save the quantized model."
)
parser.add_argument(
"--bits",
type=int,
default=4,
help="Bits per weight for quantization.",
)
parser.add_argument(
"--group-size", type=int, default=64, help="Group size for quantization."
)
parser.add_argument(
"--num-samples",
type=int,
default=1024,
help="Number of samples to use for training.",
)
parser.add_argument("--max-seq-length", type=int, default=2048)
parser.add_argument("--seed", type=int, default=123)
parser.add_argument("--learning-rate", type=float, default=1e-5)
parser.add_argument("--batch-size", type=int, default=8)
parser.add_argument(
"--data-path",
type=str,
default="allenai/tulu-3-sft-mixture",
help="A Hugging Face dataset which is compatible with an mlx-lm dataset format.",
)
parser.add_argument(
"--temperature",
type=float,
default=0.5,
help="Temperature scaling for the loss.",
)
args = parser.parse_args()
group = mx.distributed.init()
num_samples = args.num_samples
if num_samples % group.size() > 0:
num_samples += group.size() - num_samples % group.size()
np.random.seed(args.seed)
mx.random.seed(args.seed)
model_path = get_model_path(args.model, revision=None)
model, config, tokenizer = fetch_from_hub(model_path, lazy=True)
if "quantization" in config:
raise ValueError("Teacher model for QAT training should not be quantized")
calibration_data = load_data(tokenizer, args.data_path, args.num_samples)
q_model = copy.deepcopy(model)
tree_flatten(q_model.parameters())
opt = optimizers.Adam(learning_rate=args.learning_rate, bias_correction=True)
qat(
model,
opt,
calibration_data,
bits=args.bits,
group_size=args.group_size,
batch_size=args.batch_size,
max_seq_length=args.max_seq_length,
temperature=args.temperature,
)
_, config = quantize_model(
model,
config,
q_group_size=args.group_size,
q_bits=args.bits,
)
save_model(model, tokenizer, config, model_path, args.mlx_path, args.model)
if __name__ == "__main__":
main()