import math
from functools import lru_cache
from typing import Callable, Optional, Union
import mlx.core as mx
from mlx.nn.layers.base import Module
from mlx.nn.layers.linear import Linear
from mlx.nn.layers.quantized import QuantizedLinear
from mlx.utils import tree_map_with_path
@lru_cache
def sum_gradients(group):
if group.size() == 1:
return lambda x: x
@mx.custom_function
def f(x):
return x
@f.vjp
def f(x, dx, _):
return mx.distributed.all_sum(dx, group=group)
return f
def _split(weight, segments, axis):
if isinstance(segments, int) or isinstance(segments[0], int):
return mx.split(weight, segments, axis=axis)
N = weight.shape[axis]
indices = [int(s * N) for s in segments]
return mx.split(weight, indices, axis=axis)
def _shard(
parameters: dict,
sharding_predicate: Callable,
group: Optional[mx.distributed.Group] = None,
):
group = group or mx.distributed.init()
N = group.size()
r = group.rank()
def _shard_fn(path, weight):
if not isinstance(weight, mx.array):
return weight
s = sharding_predicate(path, weight)
if s is None:
return weight
axis = None
segments = 1
if isinstance(s, int):
axis = s
elif isinstance(s, tuple):
axis, segments = s
else:
raise ValueError(
"The sharding function should return int or tuple[int, list]"
)
return mx.contiguous(
mx.concatenate(
[_split(part, N, axis)[r] for part in _split(weight, segments, axis)],
axis=axis,
)
)
return tree_map_with_path(_shard_fn, parameters)
def _all_to_sharded(segments):
def _shard_fn(path, weight):
if path.endswith("bias"):
return -1, segments
return max(weight.ndim - 2, 0), segments
return _shard_fn
def _sharded_to_all(segments):
def _shard_fn(path, weight):
if path.endswith("bias"):
return None
return -1, segments
return _shard_fn
def _check_sharding(sharding):
if sharding not in ("all-to-sharded", "sharded-to-all"):
raise ValueError(
(
f"Sharding type {sharding=} not supported, "
"choose one of 'all-to-sharded' or 'sharded-to-all'"
)
)
def shard_inplace(
module: Module,
sharding: Union[str, Callable],
*,
segments: Union[int, list] = 1,
group: Optional[mx.distributed.Group] = None,
):
if isinstance(sharding, str):
_check_sharding(sharding)
sharding = (
_all_to_sharded(segments)
if sharding == "all-to-sharded"
else _sharded_to_all(segments)
)
module.update(_shard(module.parameters(), sharding, group))
def shard_linear(
module: Module,
sharding: str,
*,
segments: Union[int, list] = 1,
group: Optional[mx.distributed.Group] = None,
):
_check_sharding(sharding)
fns = {
("all-to-sharded", True): AllToShardedLinear.from_linear,
("all-to-sharded", False): QuantizedAllToShardedLinear.from_quantized_linear,
("sharded-to-all", True): ShardedToAllLinear.from_linear,
("sharded-to-all", False): QuantizedShardedToAllLinear.from_quantized_linear,
}
return fns[sharding, isinstance(module, Linear)](
module, segments=segments, group=group
)
class AllToShardedLinear(Module):
def __init__(
self,
input_dims: int,
output_dims: int,
bias: bool = True,
group: Optional[mx.distributed.Group] = None,
):
super().__init__()
scale = math.sqrt(1.0 / input_dims)
self.group = group or mx.distributed.init()
N = self.group.size()
if (output_dims % N) != 0:
raise ValueError(
f"Cannot shard the output of size {output_dims} across {N} devices."
)
self.weight = mx.random.uniform(
low=-scale,
high=scale,
shape=(output_dims // N, input_dims),
)
if bias:
self.bias = mx.random.uniform(
low=-scale,
high=scale,
shape=(output_dims // N,),
)
def _extra_repr(self) -> str:
out_dims, in_dims = self.weight.shape
N = self.group.size()
out_dims *= N
return f"input_dims={in_dims}, output_dims={out_dims}, bias={'bias' in self}"
def __call__(self, x: mx.array) -> mx.array:
x = sum_gradients(self.group)(x)
if "bias" in self:
x = mx.addmm(self["bias"], x, self["weight"].T)
else:
x = x @ self["weight"].T
return x
@classmethod
def from_linear(
cls,
linear_layer: Module,
*,
segments: Union[int, list] = 1,
group: Optional[mx.distributed.Group] = None,
):
group = group or mx.distributed.init()
output_dims, input_dims = linear_layer.weight.shape
sl = cls(input_dims, output_dims, hasattr(linear_layer, "bias"), group)
sl.update(_shard(linear_layer.parameters(), _all_to_sharded(segments), group))
return sl
class ShardedToAllLinear(Module):
def __init__(
self,
input_dims: int,
output_dims: int,
bias: bool = True,
group: Optional[mx.distributed.Group] = None,
):
super().__init__()
scale = math.sqrt(1.0 / input_dims)
self.group = group or mx.distributed.init()
N = self.group.size()
if (input_dims % N) != 0:
raise ValueError(
f"The input of size {input_dims} cannot be sharded across {N} devices."
)
self.weight = mx.random.uniform(
low=-scale,
high=scale,
shape=(output_dims, input_dims // N),
)
if bias:
self.bias = mx.random.uniform(
low=-scale,
high=scale,
shape=(output_dims,),
)
def _extra_repr(self) -> str:
N = self.group.size()
out_dims, in_dims = self.weight.shape
in_dims *= N
return f"input_dims={in_dims}, output_dims={out_dims}, bias={'bias' in self}"
def __call__(self, x: mx.array) -> mx.array:
x = x @ self["weight"].T
x = mx.distributed.all_sum(x, group=self.group)
if "bias" in self:
x = x + self["bias"]
return x
@classmethod
def from_linear(
cls,
linear_layer: Module,
*,
segments: Union[int, list] = 1,
group: Optional[mx.distributed.Group] = None,
):
group = group or mx.distributed.init()
output_dims, input_dims = linear_layer.weight.shape
sl = cls(input_dims, output_dims, hasattr(linear_layer, "bias"), group)
sl.update(_shard(linear_layer.parameters(), _sharded_to_all(segments), group))
return sl
class QuantizedAllToShardedLinear(Module):
def __init__(
self,
input_dims: int,
output_dims: int,
bias: bool = True,
group_size: int = 64,
bits: int = 4,
mode: str = "affine",
group: Optional[mx.distributed.Group] = None,
):
super().__init__()
self.group_size = group_size
self.bits = bits
self.mode = mode
scale = math.sqrt(1.0 / input_dims)
self.group = group or mx.distributed.init()
N = self.group.size()
if (output_dims % N) != 0:
raise ValueError(
f"Cannot shard the output of size {output_dims} across {N} devices."
)
weight = mx.random.uniform(
low=-scale,
high=scale,
shape=(output_dims // N, input_dims),
)
self.weight, self.scales, *biases = mx.quantize(
weight, group_size, bits, mode=mode
)
self.biases = biases[0] if biases else None
if bias:
self.bias = mx.zeros((output_dims // N,))
self.freeze()
def unfreeze(self, *args, **kwargs):
super().unfreeze(*args, **kwargs)
self.freeze(recurse=False)
def _extra_repr(self) -> str:
out_dims, in_dims = self.weight.shape
in_dims = (in_dims * 32) // self.bits
out_dims *= self.group.size()
return (
f"input_dims={in_dims}, output_dims={out_dims}, bias={'bias' in self}, "
f"group_size={self.group_size}, bits={self.bits}, mode={self.mode}"
)
def __call__(self, x: mx.array) -> mx.array:
x = sum_gradients(self.group)(x)
x = mx.quantized_matmul(
x,
self["weight"],
scales=self["scales"],
biases=self.get("biases"),
transpose=True,
group_size=self.group_size,
bits=self.bits,
mode=self.mode,
)
if "bias" in self:
x = x + self["bias"]
return x
@classmethod
def from_quantized_linear(
cls,
quantized_linear_layer: Module,
*,
segments: Union[int, list] = 1,
group: Optional[mx.distributed.Group] = None,
):
group = group or mx.distributed.init()
output_dims, input_dims = quantized_linear_layer.weight.shape
input_dims = (input_dims * 32) // quantized_linear_layer.bits
sl = cls(
input_dims,
output_dims,
hasattr(quantized_linear_layer, "bias"),
group_size=quantized_linear_layer.group_size,
bits=quantized_linear_layer.bits,
mode=getattr(quantized_linear_layer, "mode", "affine"),
group=group,
)
sl.update(
_shard(
quantized_linear_layer.parameters(),
_all_to_sharded(segments),
group,
)
)
return sl
class QuantizedShardedToAllLinear(Module):
def __init__(
self,
input_dims: int,
output_dims: int,
bias: bool = True,
group_size: int = 64,
bits: int = 4,
mode: str = "affine",
group: Optional[mx.distributed.Group] = None,
):
super().__init__()
self.group_size = group_size
self.bits = bits
self.mode = mode
scale = math.sqrt(1.0 / input_dims)
self.group = group or mx.distributed.init()
N = self.group.size()
if (input_dims % N) != 0:
raise ValueError(
f"The input of size {input_dims} cannot be sharded across {N} devices."
)
weight = mx.random.uniform(
low=-scale,
high=scale,
shape=(output_dims, input_dims // N),
)
self.weight, self.scales, *biases = mx.quantize(
weight, group_size, bits, mode=mode
)
self.biases = biases[0] if biases else None
if bias:
self.bias = mx.zeros((output_dims,))
self.freeze()
def unfreeze(self, *args, **kwargs):
super().unfreeze(*args, **kwargs)
self.freeze(recurse=False)
def _extra_repr(self) -> str:
out_dims, in_dims = self.weight.shape
in_dims = (in_dims * 32) // self.bits * self.group.size()
return (
f"input_dims={in_dims}, output_dims={out_dims}, bias={'bias' in self}, "
f"group_size={self.group_size}, bits={self.bits}, mode={self.mode}"
)
def __call__(self, x: mx.array) -> mx.array:
x = mx.quantized_matmul(
x,
self["weight"],
scales=self["scales"],
biases=self.get("biases"),
transpose=True,
group_size=self.group_size,
bits=self.bits,
mode=self.mode,
)
x = mx.distributed.all_sum(x, group=self.group)
if "bias" in self:
x = x + self["bias"]
return x
@classmethod
def from_quantized_linear(
cls,
quantized_linear_layer: Module,
*,
segments: Union[int, list] = 1,
group: Optional[mx.distributed.Group] = None,
):
group = group or mx.distributed.init()
output_dims, input_dims = quantized_linear_layer.weight.shape
input_dims = (input_dims * 32) // quantized_linear_layer.bits
sl = cls(
input_dims,
output_dims,
hasattr(quantized_linear_layer, "bias"),
group_size=quantized_linear_layer.group_size,
bits=quantized_linear_layer.bits,
mode=getattr(quantized_linear_layer, "mode", "affine"),
group=group,
)
sl.update(
_shard(
quantized_linear_layer.parameters(),
_sharded_to_all(segments),
group,
)
)
return sl