Isomorphism is a hardware-agnostic C++ tensor math library with a pluggable backend architecture. Write your mathematical logic once using a unified DSL and deploy it on any hardware — Apple Silicon, CPU, or GPU — by selecting a backend at compile time.
The Pimpl pattern keeps the public API entirely decoupled from the backend. Swapping from MLX to PyTorch is a single word change in target_link_libraries, with zero modifications to application code.
Each backend is a separate formula. Add the tap once, then install the formula that matches your hardware:
brew tap c0rmac/homebrew-isomorphism
brew trust c0rmac/isomorphism # Homebrew 7 and later ask this of any third-party tapApple MLX — recommended on Apple Silicon (M1/M2/M3/M4), uses the Metal GPU:
brew install isomorphism-mlxqr, eigh, svd, cholesky, solve, inv, det and
solve_triangular come from
metal-linalg (2.19 or later)
(c0rmac/metal-linalg/metal-linalg, pulled in as a dependency): on the GPU
device it routes each shape to its Metal kernels or to the CPU by a policy
measured per device, and on the CPU device it keeps to its CPU paths (LAPACK
through Accelerate, a batch over every core), which outrun MLX's own CPU
linear algebra (see Linear algebra speed). Its tap
has to be trusted too: brew trust c0rmac/metal-linalg.
Eigen — lightweight CPU-only, no large framework dependency:
brew install isomorphism-eigenLibTorch — for LibTorch / PyTorch users:
brew install isomorphism-torchOn Apple Silicon the same decompositions and solves of CPU and MPS tensors come from metal-linalg as above, MPS tensors used in place; float64 and CUDA tensors keep torch's own (see Linear algebra speed).
git clone https://github.com/c0rmac/isomorphism.git
cd isomorphism
cmake -S . -B build -DUSE_MLX=ON -DCMAKE_BUILD_TYPE=Release # or USE_EIGEN / USE_TORCH
cmake --build build
cmake --install buildMLX backend — needs mlx (brew install mlx; pass
-DCMAKE_PREFIX_PATH=/opt/homebrew if CMake does not find it) and
metal-linalg, which needs no separate
install step (the Torch backend on Apple Silicon uses it too, built without
MLX). CMake takes the first of:
-DMETAL_LINALG_SOURCE_DIR=/path/to/metal-linalg, built in from source;- a metal-linalg checkout (2.19 or later) in the same parent folder as
this project (
../metal-linalg), built in from source, so that its shaders and per-device routing tables are the checkout's; - an installed metal-linalg 2.19 or later, e.g. from Homebrew
(
brew tap c0rmac/metal-linalg,brew trust c0rmac/metal-linalg, thenbrew install metal-linalg); - otherwise metal-linalg is downloaded from GitHub (
METAL_LINALG_GIT_REPOSITORYatMETAL_LINALG_GIT_TAG, defaultmain) and built in, automatically.
The configure step says which it used (Isomorphism: metal-linalg from ...).
-DMETAL_LINALG_USE_INSTALLED=ON insists on an installed copy, which is what
the isomorphism-mlx and isomorphism-torch formulas do, since a Homebrew
build has no network.
PyTorch backend — point CMake at your LibTorch installation:
cmake -S . -B build \
-DUSE_TORCH=ON \
-DCMAKE_PREFIX_PATH=/path/to/libtorch \
-DCMAKE_BUILD_TYPE=ReleaseOn macOS with Homebrew, abseil must also be installed (brew install abseil) so that LibTorch's protobuf dependency resolves correctly.
On Apple Silicon it finds or builds metal-linalg as the MLX backend does;
-DISOMORPHISM_TORCH_METAL_LINALG=OFF leaves everything to torch.
find_package(isomorphism REQUIRED)
add_executable(my_app main.cpp)
target_link_libraries(my_app PRIVATE isomorphism::mlx) # or ::eigen or ::torchEach backend is a separate named target. Switching backend means changing one word:
# Use Eigen on this machine
target_link_libraries(my_app PRIVATE isomorphism::eigen)
# Switch to Torch — no other changes needed
target_link_libraries(my_app PRIVATE isomorphism::torch)Multiple backends can be installed simultaneously and a single project can link different backends into different executables:
target_link_libraries(fast_app PRIVATE isomorphism::mlx)
target_link_libraries(portable_app PRIVATE isomorphism::eigen)No backend flag is needed in your project's CMake. The correct compile definition (USE_MLX, USE_EIGEN, USE_TORCH) is baked into each library at build time and propagated automatically to any target that links it.
If you don't want to hard-code a backend name, use the isomorphism_DEFAULT_TARGET variable set by find_package:
find_package(isomorphism REQUIRED)
# isomorphism_DEFAULT_TARGET = whichever backend was compiled first (e.g. isomorphism::mlx)
target_link_libraries(my_app PRIVATE ${isomorphism_DEFAULT_TARGET})All operations live in the isomorphism::math namespace and operate on the opaque isomorphism::Tensor handle.
math::add(a, b) math::subtract(a, b) math::multiply(a, b)
math::divide(a, b) math::pow(a, exp) math::clamp(a, lo, hi)
math::floor(a) math::ceil(a) math::round(a)
math::minimum(a, b) math::mean(a)math::matmul(a, b)
math::transpose(a, axes)
math::reshape(a, shape)
math::broadcast_to(a, shape)
math::expand_dims(a, axes)
math::squeeze(a, axes)
math::eye(d, dtype)
math::full(shape, value, dtype)
math::stack(tensors, axis)
math::concatenate(tensors, axis)
math::slice(a, start, end, axis)
math::gather(a, indices, axis)math::matmul(a, b) // batched matrix multiply
math::solve(A, b) // AX = B (LU); b [..., n, k] or [..., n]
math::cholesky(A) // A = L Lᵀ; cholesky(A, true): A = Uᵀ U
math::solve_triangular(L, b) // L X = B; (U, b, true) for upper, unit_diagonal 4th
auto [U, S, Vh] = math::svd(a) // singular value decomposition
auto [Q, R] = math::qr(a) // QR decomposition
math::inv(a) // matrix inverse
math::det(a) // determinant
math::trace(a) // sum of diagonal
math::matrix_exp(a) // matrix exponential
math::diag_embed(v) // 1D vector → diagonal matrix
math::diag_extract(a) // diagonal matrix → 1D vectormath::sum(a, axes) math::prod(a, axes) math::mean(a)
math::min(a) math::max(a) math::argmax(a, axis)
math::cumsum(a, axis) math::all(a, axes) math::any(a, axes)math::exp(a) math::log(a) math::sqrt(a) math::square(a)
math::abs(a) math::sign(a) math::sin(a) math::cos(a)
math::tan(a) math::asin(a) math::acos(a) math::atan(a)
math::atan2(y, x)math::where(cond, x, y)
math::equal(a, b) math::not_equal(a, b)
math::greater(a, b) math::greater_equal(a, b)
math::less(a, b) math::less_equal(a, b)
math::logical_and(a, b) math::logical_or(a, b)math::random_normal(shape, dtype)
math::random_uniform(shape, dtype)math::rfft(a, n, axis)
math::irfft(a, n, axis)double math::to_double(a)
int math::to_int(a)
std::vector<float> math::to_float_vector(a)
void math::eval(a) // no-op on eager backends
void math::set_default_device_cpu()
void math::set_default_device_gpu()#include <isomorphism/math.hpp>
#include <isomorphism/tensor.hpp>
#include <iostream>
namespace iso = isomorphism;
using namespace iso::math;
int main() {
// Create a batch of 4 random 3×3 matrices
iso::Tensor A = random_normal({4, 3, 3}, iso::DType::Float32);
iso::Tensor I = eye(3, iso::DType::Float32);
// Batched matmul — shape stays {4, 3, 3}
iso::Tensor result = matmul(A, broadcast_to(I, {4, 3, 3}));
// Pull a scalar to CPU
std::cout << "trace[0] = " << to_double(slice(trace(result), 0, 1, 0)) << "\n";
return 0;
}If you already hold a native tensor from your backend, use the interop headers to pass it into isomorphism and retrieve results without any data copy.
#include <isomorphism/interop/mlx.hpp>
#include <mlx/mlx.h>
namespace iso_mlx = isomorphism::interop::mlx;
// Your existing MLX array — stays on the GPU, zero-copy
mlx::core::array my_arr = mlx::core::random::normal({4, 3, 3});
// Wrap: mlx::core::array → isomorphism::Tensor
isomorphism::Tensor A = iso_mlx::wrap(my_arr);
// Use the full isomorphism DSL
isomorphism::Tensor result = isomorphism::math::matmul(A, A);
isomorphism::math::eval(result);
// Unwrap: isomorphism::Tensor → mlx::core::array (zero-copy view)
mlx::core::array out = iso_mlx::unwrap(result);#include <isomorphism/interop/torch.hpp>
#include <torch/torch.h>
namespace iso_torch = isomorphism::interop::torch;
// Your existing torch tensor
torch::Tensor my_t = torch::randn({4, 3, 3});
// Wrap: torch::Tensor → isomorphism::Tensor (shares LibTorch storage)
isomorphism::Tensor A = iso_torch::wrap(my_t);
isomorphism::Tensor result = isomorphism::math::matmul(A, A);
// Unwrap: isomorphism::Tensor → torch::Tensor (zero-copy)
torch::Tensor out = iso_torch::unwrap(result);#include <isomorphism/interop/eigen.hpp>
#include <Eigen/Dense>
namespace iso_eigen = isomorphism::interop::eigen;
// Your existing Eigen matrix — copied into isomorphism's pooled storage
Eigen::MatrixXf M = Eigen::MatrixXf::Random(3, 3);
isomorphism::Tensor A = iso_eigen::wrap(M);
isomorphism::Tensor result = isomorphism::math::matmul(A, A);
// Unwrap as a zero-copy Eigen::Map (contiguous tensors only)
auto out_map = iso_eigen::to_matrix_map(result); // Eigen::Map<MatrixXf>
// Or copy into a new MatrixXf
Eigen::MatrixXf out = iso_eigen::to_matrix(result);What metal-linalg brings, against each backend's own linear algebra,
float32, median ms (before → after), on an M5 Pro (set_default_device_cpu() / _gpu()):
| MLX, CPU device | Torch, CPU tensors | Torch, MPS tensors | |
|---|---|---|---|
| cholesky, 4096 × 32×32 | 5.5 → 0.28 | 2.0 → 0.23 | 2.0 → 0.48 |
| cholesky, one 2048×2048 | 18 → 2.8 | 14 → 2.7 | 5.7 → 3.6 |
| solve, one 2048×2048, 64 right-hand sides | 41 → 7.4 | 12 → 7.2 | 34 → 5.4 |
| solve, 4096 × 8×8 | 3.1 → 0.16 | 0.50 → 0.13 | 970 → 0.30 |
| inv, 1024 × 64×64 | 19 → 2.0 | 5.9 → 2.0 | 794 → 2.5 |
| det, 4096 × 8×8 | 0.34 → 0.22 | 0.34 → 0.20 | 886 → 0.34 |
| solve_triangular, 2048, 1024 right-hand sides | 8.3 → 3.2 | 7.6 → 3.2 | 5.8 → 1.8 |
| svd, 1024 × 16×16 | 16 → 1.3 | 16 → 1.3 | 19 → 0.96 |
| eigh, 1024 × 32×32 | 33 → 2.3 | 32 → 2.3 | 36 → 1.5 |
| qr, 1024 × 64×64 | 28 → 2.7 | 25 → 2.7 | 32 → 1.6 |
Torch is conda-forge's 2.13 with its CPU LAPACK from Accelerate, as pip's wheels have it (with OpenBLAS instead, the CPU column's left side is up to 9x slower). On the MLX backend's GPU device these calls were metal-linalg's already, and are unchanged.
| Backend | Flag | Hardware | Dependency |
|---|---|---|---|
| MLX | -DUSE_MLX=ON |
Apple Silicon (Metal/GPU) | mlx |
| Eigen | -DUSE_EIGEN=ON |
CPU (any platform) | Eigen3 |
| Torch | -DUSE_TORCH=ON |
CPU / CUDA / MPS | LibTorch |
One or more backends can be built simultaneously. Each installs as its own library (libisomorphism_mlx, libisomorphism_eigen, libisomorphism_torch) and exports as a separate CMake target (isomorphism::mlx, isomorphism::eigen, isomorphism::torch).
include/isomorphism/
tensor.hpp # Public Tensor handle (Pimpl)
math.hpp # Full DSL — backend-agnostic
src/backends/
mlx/ # Apple Silicon (MLX/Metal)
eigen/ # CPU (Eigen + OpenMP)
torch/ # PyTorch (LibTorch)
sycl/ # PC GPU (SYCL / oneMKL) — in progress
Releases are automatic. Every update to main that changes the library
(anything beyond Markdown files and .github/) makes the
Release workflow publish the next version: a
vX.Y.Z tag, a GitHub release with its source tarball, and every formula in
c0rmac/homebrew-isomorphism
pointed at it. The version in CMakeLists.txt's project() is released as it
is if it has no tag yet; otherwise the patch number goes up by one and the
workflow commits it. To start a minor or major version, set it there by hand.
Updating the tap needs a token that can push to it, kept in this repository as
the secret HOMEBREW_TAP_TOKEN: a fine-grained personal access token with
access to the tap repository only and the permission Contents: Read and
write, stored with gh secret set HOMEBREW_TAP_TOKEN --repo c0rmac/isomorphism.
Without it, releases are still made, and each run's summary gives the formula
lines to change by hand.
MIT