468 lines
16 KiB
Python
468 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
SplitLinear: Drop-in replacement for nn.Linear / nn.QuantizedLinear.
|
|
Splits GEMM along output channels — ANE does ~65%, GPU does ~35%, concurrent.
|
|
|
|
Key optimization: same-input projections (Q/K/V, Gate/Up) share a single
|
|
input preparation via _InputGroup, eliminating redundant transpose+eval+numpy.
|
|
|
|
Usage:
|
|
from split_linear import patch_model
|
|
bridge = patch_model(model, seq=512)
|
|
|
|
API:
|
|
patch_model(model, seq) → high-level one-liner
|
|
SplitLinear(layer, bridge, seq) → single layer replacement
|
|
ANEBridge() → ANE private API wrapper
|
|
"""
|
|
import os, ctypes
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
import numpy as np
|
|
import mlx.core as mx
|
|
import mlx.nn as nn
|
|
|
|
LIB_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
SPLIT_ALIGN = 64
|
|
MIN_SEQ_FOR_SPLIT = 192 # Below this, split overhead > benefit
|
|
|
|
|
|
# ─── ANE Bridge ───
|
|
|
|
class ANEBridge:
|
|
"""Thin wrapper around ANE private API."""
|
|
_instance = None
|
|
|
|
def __init__(self):
|
|
lib = ctypes.CDLL(os.path.join(LIB_DIR, 'libane_bridge_v6.dylib'))
|
|
lib.ane_init.restype = ctypes.c_int
|
|
lib.ane_load_model.restype = ctypes.c_int
|
|
lib.ane_load_model.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_int,
|
|
ctypes.POINTER(ctypes.c_float)]
|
|
lib.ane_run.restype = ctypes.c_int
|
|
lib.ane_run.argtypes = [ctypes.c_int, ctypes.POINTER(ctypes.c_float),
|
|
ctypes.POINTER(ctypes.c_float)]
|
|
lib.ane_run_rowmajor.restype = ctypes.c_int
|
|
lib.ane_run_rowmajor.argtypes = [ctypes.c_int, ctypes.POINTER(ctypes.c_float),
|
|
ctypes.c_int, ctypes.POINTER(ctypes.c_float)]
|
|
lib.ane_model_count.restype = ctypes.c_int
|
|
lib.ane_surface_count.restype = ctypes.c_int
|
|
assert lib.ane_init() == 0, "ANE init failed"
|
|
self.lib = lib
|
|
|
|
def load(self, ic, oc, seq, w_fp32):
|
|
w = np.ascontiguousarray(w_fp32, dtype=np.float32)
|
|
h = self.lib.ane_load_model(ic, oc, seq,
|
|
w.ctypes.data_as(ctypes.POINTER(ctypes.c_float)))
|
|
assert h >= 0, f"ANE load failed: {ic}→{oc}, seq={seq}"
|
|
return h
|
|
|
|
def run(self, h, inp, out):
|
|
self.lib.ane_run(h,
|
|
inp.ctypes.data_as(ctypes.POINTER(ctypes.c_float)),
|
|
out.ctypes.data_as(ctypes.POINTER(ctypes.c_float)))
|
|
|
|
def run_rowmajor(self, h, inp_rm, L, out_rm):
|
|
"""ANE compute with row-major I/O. Uses vDSP for transpose.
|
|
inp_rm: [L, IC] row-major float32
|
|
out_rm: [L, OC] row-major float32 (pre-allocated)
|
|
"""
|
|
self.lib.ane_run_rowmajor(h,
|
|
inp_rm.ctypes.data_as(ctypes.POINTER(ctypes.c_float)),
|
|
L,
|
|
out_rm.ctypes.data_as(ctypes.POINTER(ctypes.c_float)))
|
|
|
|
@property
|
|
def model_count(self):
|
|
return self.lib.ane_model_count()
|
|
|
|
@classmethod
|
|
def shared(cls):
|
|
if cls._instance is None:
|
|
cls._instance = cls()
|
|
return cls._instance
|
|
|
|
|
|
# Single ANE worker thread
|
|
_ane_pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix='ane')
|
|
|
|
|
|
# ─── Helpers ───
|
|
|
|
def _extract_weight(layer):
|
|
"""Extract FP32 weight [OC, IC] from nn.Linear or nn.QuantizedLinear."""
|
|
if isinstance(layer, nn.QuantizedLinear):
|
|
mx.eval(layer.weight, layer.scales)
|
|
b = getattr(layer, 'biases', None)
|
|
if b is not None:
|
|
mx.eval(b)
|
|
w = mx.dequantize(layer.weight, layer.scales, b,
|
|
layer.group_size, layer.bits)
|
|
else:
|
|
w = layer.weight
|
|
mx.eval(w)
|
|
return np.array(w, dtype=np.float32)
|
|
|
|
|
|
class _InputGroup:
|
|
"""
|
|
Shared input preparation for same-input projections (Q/K/V or Gate/Up).
|
|
|
|
Zero-copy path: eval x as FP32 row-major, share numpy view across group.
|
|
No GPU transpose needed — ane_run_rowmajor handles transpose in C via vDSP.
|
|
"""
|
|
__slots__ = ('_np', '_x2d', '_L', '_seq')
|
|
|
|
def __init__(self, ic=0, seq=0):
|
|
self._np = None
|
|
self._x2d = None
|
|
self._L = 0
|
|
self._seq = seq
|
|
|
|
def prepare(self, x):
|
|
"""Prepare input. Called by first proj in group.
|
|
Returns (inp_np_rowmajor [L, IC], x_2d, L)."""
|
|
x_2d = x.reshape(-1, x.shape[-1]) if x.ndim == 3 else x
|
|
L = x_2d.shape[0]
|
|
|
|
# Cast to FP32 contiguous row-major, eval, then zero-copy numpy view
|
|
x_f32 = x_2d.astype(mx.float32) if x_2d.dtype != mx.float32 else x_2d
|
|
x_f32 = mx.contiguous(x_f32)
|
|
mx.eval(x_f32)
|
|
self._np = np.array(x_f32, copy=False) # [L, IC] row-major, zero-copy
|
|
|
|
self._L = L
|
|
self._x2d = x_2d
|
|
return self._np, x_2d, L
|
|
|
|
def get(self):
|
|
"""Get cached input. Called by subsequent projs.
|
|
Returns (inp_np_rowmajor [L, IC], x_2d, L)."""
|
|
return self._np, self._x2d, self._L
|
|
|
|
|
|
# ─── SplitLinear ───
|
|
|
|
class SplitLinear:
|
|
"""
|
|
Drop-in nn.Linear replacement with ANE+GPU tensor parallelism.
|
|
|
|
Two modes controlled externally via set_prefill(True/False):
|
|
- prefill: ANE ~65% + GPU ~35% concurrent
|
|
- decode: original nn.Linear on GPU → zero overhead
|
|
"""
|
|
_prefill_mode = False # class-level flag
|
|
|
|
def __init__(self, layer, bridge, seq, ane_frac=None, name="",
|
|
input_group=None, is_first=True):
|
|
self.name = name
|
|
self._orig = layer # always keep original for decode
|
|
w_np = _extract_weight(layer)
|
|
oc, ic = w_np.shape
|
|
self.ic = ic
|
|
self.oc = oc
|
|
self.seq = seq
|
|
self._is_first = is_first
|
|
|
|
# Auto-detect split fraction
|
|
if ane_frac is None:
|
|
if seq < MIN_SEQ_FOR_SPLIT:
|
|
ane_frac = 0.0
|
|
elif ic > oc * 2:
|
|
ane_frac = 0.0 # Wide→narrow: ANE inefficient (down_proj)
|
|
elif oc < SPLIT_ALIGN * 2:
|
|
ane_frac = 0.0
|
|
else:
|
|
ane_frac = 0.65
|
|
|
|
self.ane_frac = ane_frac
|
|
|
|
if ane_frac <= 0:
|
|
self.mode = 'gpu'
|
|
return
|
|
|
|
self.mode = 'split'
|
|
self.ane = bridge
|
|
|
|
self.ane_oc = (int(oc * ane_frac) // SPLIT_ALIGN) * SPLIT_ALIGN
|
|
self.gpu_oc = oc - self.ane_oc
|
|
|
|
if self.ane_oc < SPLIT_ALIGN or self.gpu_oc < 1:
|
|
self.mode = 'gpu'
|
|
return
|
|
|
|
self.h_ane = bridge.load(ic, self.ane_oc, seq, w_np[:self.ane_oc, :])
|
|
self.buf_ane = np.empty((seq, self.ane_oc), dtype=np.float32) # ANE FP32 output
|
|
self.buf_ane_f16 = np.empty((seq, self.ane_oc), dtype=np.float16) # FP16 cast buffer
|
|
|
|
# GPU weight for split path
|
|
self._is_quantized = isinstance(layer, nn.QuantizedLinear)
|
|
if self._is_quantized:
|
|
# Re-quantize GPU portion as QuantizedLinear → native quantized_matmul
|
|
w_gpu_fp32 = mx.array(w_np[self.ane_oc:, :]) # [gpu_oc, ic] float32
|
|
w_q, scales, biases = mx.quantize(w_gpu_fp32,
|
|
group_size=layer.group_size,
|
|
bits=layer.bits)
|
|
self._gpu_layer = nn.QuantizedLinear(
|
|
ic, self.gpu_oc, bias=False,
|
|
group_size=layer.group_size, bits=layer.bits)
|
|
self._gpu_layer.weight = w_q
|
|
self._gpu_layer.scales = scales
|
|
self._gpu_layer.biases = biases
|
|
mx.eval(self._gpu_layer.parameters())
|
|
self._w_gpu = None # not used for quantized path
|
|
else:
|
|
self._gpu_layer = None
|
|
self._w_gpu = None
|
|
|
|
if input_group is not None:
|
|
self._grp = input_group
|
|
else:
|
|
self._grp = _InputGroup(ic, seq)
|
|
self._is_first = True
|
|
|
|
@classmethod
|
|
def set_prefill(cls, enabled):
|
|
cls._prefill_mode = enabled
|
|
|
|
def __call__(self, x):
|
|
# decode / gpu-only / short seq: use original nn.Linear
|
|
if self.mode == 'gpu' or not SplitLinear._prefill_mode:
|
|
return self._orig(x)
|
|
|
|
L = x.shape[-2] if x.ndim == 3 else x.shape[0]
|
|
if L < MIN_SEQ_FOR_SPLIT:
|
|
return self._orig(x)
|
|
|
|
# ── prefill split path (zero-copy + concurrent ANE/GPU) ──
|
|
orig_shape = x.shape
|
|
|
|
# Input preparation (shared or fresh) — row-major [L, IC] FP32
|
|
if self._is_first:
|
|
inp_np, x_2d, L = self._grp.prepare(x)
|
|
else:
|
|
inp_np, x_2d, L = self._grp.get()
|
|
|
|
# ANE: launch in worker thread (row-major path, vDSP transpose in C)
|
|
out_buf = self.buf_ane[:L] # [L, ane_oc] view
|
|
fut = _ane_pool.submit(self.ane.run_rowmajor, self.h_ane, inp_np, L, out_buf)
|
|
|
|
# GPU: matmul with GPU portion weight
|
|
if self._gpu_layer is not None:
|
|
# Quantized: use native quantized_matmul via QuantizedLinear
|
|
gpu_out = self._gpu_layer(x_2d)
|
|
elif self._w_gpu is not None:
|
|
gpu_out = x_2d @ self._w_gpu.T
|
|
else:
|
|
w_gpu = self._orig.weight[self.ane_oc:, :]
|
|
gpu_out = x_2d @ w_gpu.T
|
|
mx.eval(gpu_out) # sync GPU — enables concurrent ANE execution
|
|
|
|
# Wait for ANE
|
|
fut.result()
|
|
|
|
# Merge: numpy FP32→FP16 cast + lazy concat
|
|
np.copyto(self.buf_ane_f16[:L], out_buf, casting='same_kind')
|
|
ane_out = mx.array(self.buf_ane_f16[:L]) # [L, ane_oc] FP16
|
|
merged = mx.concatenate([ane_out, gpu_out], axis=-1)
|
|
|
|
if len(orig_shape) == 3:
|
|
merged = merged.reshape(orig_shape[0], orig_shape[1], -1)
|
|
return merged
|
|
|
|
def __repr__(self):
|
|
if self.mode == 'gpu':
|
|
return f"SplitLinear({self.name}, gpu_only, {self.ic}→{self.oc})"
|
|
return (f"SplitLinear({self.name}, {self.ane_oc}ane+{self.gpu_oc}gpu, "
|
|
f"{self.ic}→{self.oc}, frac={self.ane_frac:.0%})")
|
|
|
|
|
|
# ─── Tree Walk ───
|
|
|
|
def _find_linears(module, prefix=""):
|
|
"""Walk MLX model tree, yield (parent, key, full_name, linear)."""
|
|
if not hasattr(module, 'children'):
|
|
return
|
|
for attr_name, child in module.children().items():
|
|
full_name = f"{prefix}.{attr_name}" if prefix else attr_name
|
|
if isinstance(child, (nn.Linear, nn.QuantizedLinear)):
|
|
yield (module, attr_name, full_name, child)
|
|
elif isinstance(child, nn.Module):
|
|
yield from _find_linears(child, full_name)
|
|
elif isinstance(child, list):
|
|
for i, v in enumerate(child):
|
|
fname = f"{full_name}.{i}"
|
|
if isinstance(v, (nn.Linear, nn.QuantizedLinear)):
|
|
yield (child, i, fname, v)
|
|
elif hasattr(v, 'children'):
|
|
yield from _find_linears(v, fname)
|
|
elif isinstance(child, dict):
|
|
for k, v in child.items():
|
|
fname = f"{full_name}.{k}"
|
|
if isinstance(v, (nn.Linear, nn.QuantizedLinear)):
|
|
yield (module, k, fname, v)
|
|
elif hasattr(v, 'children'):
|
|
yield from _find_linears(v, fname)
|
|
elif hasattr(child, 'children'):
|
|
yield from _find_linears(child, full_name)
|
|
|
|
|
|
def _get_input_dims(layer):
|
|
"""Get true input dimensions for nn.Linear or nn.QuantizedLinear."""
|
|
if isinstance(layer, nn.QuantizedLinear):
|
|
return layer.weight.shape[-1] * 32 // layer.bits
|
|
return layer.weight.shape[-1]
|
|
|
|
|
|
def patch_model(model, seq, bridge=None, verbose=True):
|
|
"""
|
|
Patch all linear layers with SplitLinear.
|
|
|
|
Same-input projections share an InputGroup:
|
|
- Q/K/V share one (same hidden_state input)
|
|
- Gate/Up share one (same hidden_state after attn)
|
|
- O and Down each get their own (unique inputs)
|
|
|
|
Returns: bridge instance.
|
|
"""
|
|
if bridge is None:
|
|
bridge = ANEBridge.shared()
|
|
|
|
lang = model.language_model
|
|
lm = lang.model
|
|
N = len(lm.layers)
|
|
n_split = n_gpu = 0
|
|
|
|
for li in range(N):
|
|
la = lm.layers[li]
|
|
attn = la.self_attn
|
|
mlp = la.mlp
|
|
|
|
ic_attn = _get_input_dims(attn.q_proj)
|
|
ic_mlp = _get_input_dims(mlp.gate_proj)
|
|
|
|
# Q/K/V share input group
|
|
qkv_grp = _InputGroup(ic_attn, seq)
|
|
for i, name in enumerate(('q_proj', 'k_proj', 'v_proj')):
|
|
orig = getattr(attn, name)
|
|
sl = SplitLinear(orig, bridge, seq,
|
|
name=f"layer.{li}.{name}",
|
|
input_group=qkv_grp,
|
|
is_first=(i == 0))
|
|
setattr(attn, name, sl)
|
|
n_split += 1 if sl.mode == 'split' else 0
|
|
n_gpu += 1 if sl.mode == 'gpu' else 0
|
|
|
|
# O proj — own input
|
|
sl = SplitLinear(attn.o_proj, bridge, seq,
|
|
name=f"layer.{li}.o_proj")
|
|
attn.o_proj = sl
|
|
n_split += 1 if sl.mode == 'split' else 0
|
|
n_gpu += 1 if sl.mode == 'gpu' else 0
|
|
|
|
# Gate/Up share input group
|
|
gu_grp = _InputGroup(ic_mlp, seq)
|
|
for i, name in enumerate(('gate_proj', 'up_proj')):
|
|
orig = getattr(mlp, name)
|
|
sl = SplitLinear(orig, bridge, seq,
|
|
name=f"layer.{li}.{name}",
|
|
input_group=gu_grp,
|
|
is_first=(i == 0))
|
|
setattr(mlp, name, sl)
|
|
n_split += 1 if sl.mode == 'split' else 0
|
|
n_gpu += 1 if sl.mode == 'gpu' else 0
|
|
|
|
# Down proj — own input (auto GPU-only due to IC>OC*2)
|
|
sl = SplitLinear(mlp.down_proj, bridge, seq,
|
|
name=f"layer.{li}.down_proj")
|
|
mlp.down_proj = sl
|
|
n_split += 1 if sl.mode == 'split' else 0
|
|
n_gpu += 1 if sl.mode == 'gpu' else 0
|
|
|
|
if verbose:
|
|
print(f"[SplitLinear] {N} layers: {n_split} split, {n_gpu} gpu-only")
|
|
print(f"[SplitLinear] ANE models: {bridge.model_count}")
|
|
|
|
return bridge
|
|
|
|
|
|
# ─── Self-test ───
|
|
if __name__ == '__main__':
|
|
import sys, time
|
|
from mlx_vlm.utils import load as vlm_load
|
|
from mlx_vlm.models.cache import KVCache
|
|
from mlx.utils import tree_flatten
|
|
|
|
SEQ = int(sys.argv[1]) if len(sys.argv) > 1 else 512
|
|
N_W = 3; N_B = 10
|
|
FP16_MODEL = '~/Downloads/weights/mlx/Qwen3-VL-2B-Instruct-16bit'
|
|
|
|
print(f"\n{'='*60}")
|
|
print(f" SplitLinear Self-Test (seq={SEQ})")
|
|
print(f"{'='*60}\n")
|
|
|
|
model, _ = vlm_load(FP16_MODEL)
|
|
# Cast to true FP16
|
|
flat = tree_flatten(model.trainable_parameters())
|
|
fp16 = [(k, v.astype(mx.float16)) for k, v in flat]
|
|
model.load_weights(fp16)
|
|
mx.eval(model.parameters())
|
|
|
|
lang = model.language_model
|
|
N = lang.args.num_hidden_layers
|
|
ids = mx.ones((1, SEQ), dtype=mx.int32)
|
|
pos = mx.broadcast_to(mx.arange(SEQ).reshape(1, SEQ)[None, :, :], (3, 1, SEQ))
|
|
mx.eval(ids, pos)
|
|
|
|
# GPU baseline
|
|
print("[1/3] GPU FP16 baseline")
|
|
for _ in range(N_W):
|
|
c = [KVCache() for _ in range(N)]
|
|
mx.eval(lang(ids, cache=c, position_ids=pos).logits)
|
|
ts = []
|
|
for _ in range(N_B):
|
|
c = [KVCache() for _ in range(N)]
|
|
t0 = time.perf_counter()
|
|
mx.eval(lang(ids, cache=c, position_ids=pos).logits)
|
|
ts.append((time.perf_counter()-t0)*1000)
|
|
bl = float(np.median(ts))
|
|
print(f" {bl:.1f}ms\n")
|
|
|
|
# Reference logits (FP32 for cos_sim)
|
|
c_ref = [KVCache() for _ in range(N)]
|
|
ref = np.array(lang(ids, cache=c_ref, position_ids=pos).logits.astype(mx.float32))
|
|
|
|
# Patch
|
|
print("[2/3] Patch + benchmark")
|
|
bridge = patch_model(model, SEQ)
|
|
SplitLinear.set_prefill(True)
|
|
|
|
for _ in range(N_W):
|
|
c = [KVCache() for _ in range(N)]
|
|
mx.eval(lang(ids, cache=c, position_ids=pos).logits)
|
|
|
|
# Accuracy
|
|
c_hyb = [KVCache() for _ in range(N)]
|
|
hyb = np.array(lang(ids, cache=c_hyb, position_ids=pos).logits.astype(mx.float32))
|
|
cos = float(np.dot(ref.flatten(), hyb.flatten()) /
|
|
(np.linalg.norm(ref.flatten()) * np.linalg.norm(hyb.flatten()) + 1e-12))
|
|
top1 = float((ref.argmax(-1) == hyb.argmax(-1)).mean() * 100)
|
|
print(f" cos={cos:.6f}, top1={top1:.1f}%")
|
|
|
|
# Benchmark
|
|
ts = []
|
|
for i in range(N_B):
|
|
c = [KVCache() for _ in range(N)]
|
|
t0 = time.perf_counter()
|
|
mx.eval(lang(ids, cache=c, position_ids=pos).logits)
|
|
t = (time.perf_counter()-t0)*1000
|
|
ts.append(t)
|
|
print(f" Run {i+1}: {t:.1f}ms")
|
|
med = float(np.median(ts))
|
|
|
|
print(f"\n{'='*60}")
|
|
print(f" GPU FP16: {bl:.1f}ms")
|
|
print(f" SplitLinear: {med:.1f}ms ({bl/med:.3f}x)")
|
|
print(f" cos={cos:.6f} top1={top1:.1f}%")
|
|
print(f" delta: {med - bl:+.1f}ms")
|
|
print(f"{'='*60}")
|