isomorphism

Isomorphism

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.


Installation via Homebrew

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 tap

Apple MLX — recommended on Apple Silicon (M1/M2/M3/M4), uses the Metal GPU:

brew install isomorphism-mlx

qr, 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-eigen

LibTorch — for LibTorch / PyTorch users:

brew install isomorphism-torch

On 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).


Building from Source

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 build

MLX 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:

  1. -DMETAL_LINALG_SOURCE_DIR=/path/to/metal-linalg, built in from source;
  2. 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;
  3. an installed metal-linalg 2.19 or later, e.g. from Homebrew (brew tap c0rmac/metal-linalg, brew trust c0rmac/metal-linalg, then brew install metal-linalg);
  4. otherwise metal-linalg is downloaded from GitHub (METAL_LINALG_GIT_REPOSITORY at METAL_LINALG_GIT_TAG, default main) 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=Release

On 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.


Integrating with Your Project

find_package(isomorphism REQUIRED)

add_executable(my_app main.cpp)
target_link_libraries(my_app PRIVATE isomorphism::mlx)    # or ::eigen  or ::torch

Each 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})

API Overview

All operations live in the isomorphism::math namespace and operate on the opaque isomorphism::Tensor handle.

Element-wise arithmetic

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)

Matrix operations

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)

Linear algebra

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 vector

Reductions

math::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)

Activation / transcendental

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)

Logical

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)

Random

math::random_normal(shape, dtype)
math::random_uniform(shape, dtype)

Spectral

math::rfft(a, n, axis)
math::irfft(a, n, axis)

CPU / GPU bridge

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()

Quick Example

#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;
}

Interop: wrapping and unwrapping native types

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.

MLX

#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);

PyTorch / LibTorch

#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);

Eigen

#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);

Linear algebra speed

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.


Backends

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).


Project Structure

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

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.


License

MIT