Apple Silicon GPU acceleration for scikit-learn
Decorate any scikit-learn estimator with @skmetal.accelerate and fit()/predict() run on the GPU — no code changes. Leverages Apple Silicon's unified memory for zero-copy data sharing between numpy and Metal.
import skmetal
from sklearn.linear_model import LinearRegression
@skmetal.accelerate
def model():
return LinearRegression()
m = model()
m.fit(X_train, y_train)
m.predict(X_test)pip install skmetalmacOS 14+ and Apple Silicon (M1–M5) required. No Xcode needed — the pip package includes a pre-built dylib and Metal library.
git clone https://github.com/abderahmane-ai/skmetal.git
cd skmetal
pip install -e ".[dev]"
# Build Swift + Metal (required after any .metal or .swift change)
cd skmetal_bridge
bash compile_metal.sh
swift build --configuration release
cp .build/arm64-apple-macosx/release/libSkMetalBridge.dylib ../skmetal/
cd ..Measured on an M4 Air (16 GB). All data float32. KMeans: n_init=1, GPU random init matches k-means++ quality (0.1% inertia delta).
| Estimator | Data Size | CPU | GPU | Speedup | Notes |
|---|---|---|---|---|---|
LinearRegression |
200,000 × 500 | 1.15s | 0.12s | 10.0× | MPS GEMM + Cholesky solve |
StandardScaler |
1,000,000 × 100 | 0.29s | 0.03s | 9.5× | Fused Welford (1 dispatch) |
KMeans (MLX) |
500,000 × 100, k=50 | 3.29s | 0.42s | 7.8× | flash-kmeans-mlx mx.compile-d kernel |
TruncatedSVD |
100,000 × 500 | 0.27s | 0.09s | 3.1× | Randomized SVD on GPU |
MinMaxScaler |
1,000,000 × 100 | 0.04s | 0.03s | 1.6× | Threadgroup tree reduction |
LogisticRegression |
100,000 × 200 | 0.03s | 0.03s | 0.9× | CPU Accelerate framework wins at this size |
Ridge |
200,000 × 500 | 0.11s | 0.12s | 0.9× | CPU Accelerate framework wins at all sizes |
KMeans MLX requires pip install skmetal[mlx]. The MLX path uses flash-kmeans-mlx which fuses distance + argmin + update into a single compiled GPU kernel. GPU random init with 1 run matches sklearn k-means++ quality.
Run benchmarks locally:
pip install skmetal[mlx]
python benchmarks/run_compare.py # moderate data sizes
python -m benchmarks.benchmark_suite # large data (generates baseline.json)- Zero-copy GPU execution — numpy arrays passed directly to Metal via
bytesNoCopyon unified memory - Drop-in acceleration — decorate any estimator-returning function, wrap an existing instance, or use a context manager
- Smart dispatch — automatically routes to CPU for small datasets where GPU overhead dominates; configurable per-estimator thresholds
- GPU solvers — Cholesky, FISTA, L-BFGS, IRLS, KNN tile-then-merge, SIMD-group GEMM, flash-kmeans-mlx compiled kernels
- float4 vectorization — 6 kernel families use float4 loads/stores for 4× memory throughput
- Optional MLX acceleration — install
skmetal[mlx]for flash-kmeans-mlx GPU KMeans (3–30×) and TruncatedSVD (GPU SVD) - Transparent fallback — imports cleanly on non-Apple-Silicon machines; all operations fall back to scikit-learn CPU
- Verbose logging —
skmetal.set_verbose(True)prints why each estimator chose GPU or CPU
Wrap any function that returns an estimator:
import skmetal
from sklearn.linear_model import LogisticRegression
@skmetal.accelerate
def model():
return LogisticRegression(random_state=42)
clf = model()
clf.fit(X_train, y_train)
clf.predict(X_test)Works with pipelines too:
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
@skmetal.accelerate
def pipe():
return Pipeline([
("scaler", StandardScaler()),
("clf", LogisticRegression()),
])
p = pipe()
p.fit(X, y)model = skmetal.accelerate(LinearRegression())
model.fit(X, y)with skmetal.accelerate_context():
model = LinearRegression()
model.fit(X, y)import skmetal
if skmetal.METAL_AVAILABLE:
info = skmetal.device_info()
print(info)
# {'name': 'Apple M4 Max', ...,
# 'has_unified_memory': True,
# 'recommended_working_set_size_bytes': 68719476736}import skmetal
skmetal.set_device("cpu") # force CPU fallback globally
skmetal.set_verbose(True) # log dispatch decisions
skmetal.set_threshold(100_000) # global min n*d for GPU
skmetal.update_threshold("KMeans", # per-estimator override
min_rows=100_000, min_cols=50)
skmetal.reset_thresholds() # restore defaults
config = skmetal.get_config()
print(config)On non-Apple-Silicon machines skmetal imports cleanly and all estimators transparently fall back to scikit-learn CPU.
| Estimator | GPU Strategy |
|---|---|
LinearRegression |
Normal equations via MPS GEMM + Cholesky solve |
Ridge |
Fused centering + XTX + XTy (1 dispatch) |
LogisticRegression |
L-BFGS on GPU (full loop in Swift, fused kernels) |
Lasso |
FISTA with GPU residual updates |
ElasticNet |
FISTA with GPU residual updates |
KMeans |
flash-kmeans-mlx GPU kernel (MLX) or fused command buffer (Metal) |
DBSCAN |
GPU pairwise distance + per-point neighbor counting |
KNeighborsClassifier |
GPU pairwise distance + fused voting (weighted/unweighted) |
KNeighborsRegressor |
GPU pairwise distance + fused averaging |
NearestNeighbors |
GPU pairwise distance + index |
TruncatedSVD |
Randomized SVD (random projection + GPU GEMM) |
SVC |
GPU RBF kernel + precomputed kernel predict |
SVR |
GPU RBF kernel + precomputed kernel predict |
GaussianNB |
GPU mean/var per class |
StandardScaler |
Fused Welford mean/variance (1 dispatch) |
MinMaxScaler |
Column min/max with threadgroup tree reduction |
RobustScaler |
GPU quantile approximation |
HistGradientBoostingRegressor |
C++ HGBT from sklearn (CPU) |
HistGradientBoostingClassifier |
C++ HGBT from sklearn (CPU) |
Each estimator has per-estimator (min_rows, min_cols) thresholds. Below the threshold the estimator uses CPU. Override via skmetal.update_threshold() or force GPU with skmetal.set_device("gpu").
numpy array → np.ctypes.data → UnsafeMutableRawPointer → MTLBuffer(bytesNoCopy:) → Metal GPU
| |
+--------- same physical memory (unified) -----------+
Apple Silicon's unified memory enables zero-copy data sharing. The Swift bridge exposes @_cdecl functions callable from Python via ctypes. Each estimator's fit()/predict() calls the appropriate bridge function, which dispatches Metal Performance Shaders or custom compute kernels.
| Kernel file | Operations |
|---|---|
ReductionKernels.metal |
reduce_sum, norm2, max_abs_diff (float4 vectorized) |
CenterColumns.metal |
column_means, center_columns, column_means_and_center (fused) |
KMeansKernels.metal |
assign, accumulate, combine_normalize, inertia (float4 vectorized) |
KNNKernels.metal |
tile top-k, merge, negate distances, fused voting (float4 vectorized) |
IrlsKernels.metal |
compute_linear_irls, compute_error_scale (float4), l2_reg_irls, sigmoid, log_loss, multinomial_grad_l2 |
ElementWiseKernels.metal |
sigmoid, subtract, axpy, add_diagonal, softmax_residual, rbf_apply |
PairwiseDistKernels.metal |
pairwise_from_cross, row_norm_sq, distance_correct |
DistanceKernels.metal |
row_norm_sq, compute_mindists, distance_correct |
ExtraKernels.metal |
soft_threshold, column_transform, scale_f32, sv_init, sv_hook, sv_shortcut |
StandardScalerKernels.metal |
scaler_fit (fused Welford) |
MinMaxKernels.metal |
column_minmax (threadgroup tree reduction) |
TreeKernels.metal |
tree_predict_all |
SIMDGroupGEMM.metal |
simdgroup_gemm_f32, simdgroup_gemm_f16 |
| File | Domain |
|---|---|
Bridge.swift |
Device init, warmup, reduction ops |
LinearModelBridge.swift |
Ridge, FISTA, L-BFGS for logistic regression |
KMeansBridge.swift |
Single fused command buffer (all iterations on GPU) |
KNNBridge.swift |
Tile-based top-k selection + voting |
LinearAlgebraBridge.swift |
GEMM via MPS, SIMD-group GEMM, pairwise distance |
PreprocessingBridge.swift |
StandardScaler, MinMaxScaler |
MinMaxBridge.swift |
MinMax transform |
LogisticBridge.swift |
IRLS/L-BFGS GPU loop |
SVCBridge.swift |
SVC/SVR RBF predict |
SVTreeBridge.swift |
Union-find, tree predict |
skmetal/
skmetal/
__init__.py # public API: accelerate, config, device_info
_about.py # version
_bridge.py # ctypes → Swift @_cdecl exports (47 functions)
_config.py # Config dataclass, thresholds, device control
_dispatch.py # estimator registry + wrapping logic
accelerate.py # @accelerate decorator + context manager
estimators/
_base.py # BaseGPUEstimator with fallback logic
_registry.py # GPU_REGISTRY (19 estimators)
_mlx_registry.py # MLX detection
_mlx_svd.py # TruncatedSVD MLX backend
linear_model.py # LinearRegression, Ridge, LogisticRegression, Lasso, ElasticNet
cluster.py # KMeans, DBSCAN
decomposition.py # TruncatedSVD
ensemble.py # HistGradientBoosting
naive_bayes.py # GaussianNB
neighbors.py # KNeighbors, NearestNeighbors
preprocessing.py # StandardScaler, MinMaxScaler, RobustScaler
svm.py # SVC, SVR
skmetal_bridge/ # Swift + Metal SPM package
Sources/SkMetalBridge/
Bridge.swift
LinearModelBridge.swift
KMeansBridge.swift
KNNBridge.swift
LinearAlgebraBridge.swift
PreprocessingBridge.swift
MinMaxBridge.swift
LogisticBridge.swift
SVCBridge.swift
SVTreeBridge.swift
MetalContext.swift
Kernels/*.metal # 13 Metal kernel files
benchmarks/
run_compare.py # quick comparison benchmark
benchmark_suite.py # full benchmark suite (generates baseline)
baseline.json
tests/ # 263 tests across 11 files
pyproject.toml
LICENSE
.github/workflows/
ci.yml # build + ruff + pytest + benchmarks
release.yml # PyPI publish on v* tag
git clone https://github.com/abderahmane-ai/skmetal.git
cd skmetal
# Build Swift + Metal (required after any .metal or .swift change)
cd skmetal_bridge
bash compile_metal.sh
swift build --configuration release
cp .build/arm64-apple-macosx/release/libSkMetalBridge.dylib ../skmetal/
cd ..
# Install in editable mode
pip install -e ".[dev]"
# Lint
ruff check skmetal/ tests/
ruff format --check skmetal/ tests/
# Run tests (from skmetal/ dir)
cd skmetal && python3 -m pytest ../tests/ -q --tb=short
# Run benchmarks
python benchmarks/run_compare.py- Create
skmetal/estimators/my_model.pywithMetalMyModel(BaseGPUEstimator) - Implement
fit()/predict()calling the Swift bridge via_bridge_call() - Register in
estimators/_registry.py→GPU_REGISTRY - Add a parametrized test in
tests/test_correctness.py - Write a Metal kernel + Swift bridge function if needed
MIT