The same C-level call, three Python flavors
Most of the time you don't write CUDA / Metal — you write Python that triggers a CUDA / Metal call somewhere underneath. This lesson maps the Python expression to the BLAS routine that runs.
| Python expression | BLAS call | Where |
|---|---|---|
np.dot(x, y) | sdot / ddot (BLAS-1) | OpenBLAS or MKL on CPU |
np.dot(A, x) | sgemv / dgemv (BLAS-2) | OpenBLAS or MKL on CPU |
np.dot(A, B) or A @ B | sgemm / dgemm (BLAS-3) | OpenBLAS or MKL on CPU |
torch.matmul(A, B) on CUDA tensors | cublasSgemm / cublasGemmEx | cuBLAS |
torch.matmul(A, B) on MPS tensors | MPSMatrixMultiplication encode/dispatch | MPS |
mx.matmul(A, B); mx.eval(C) | MPSMatrixMultiplication through MLX runtime | MPS |
Why Python doesn't slow it down
The Python interpreter is involved exactly once per BLAS call: parsing your expression, dispatching to the C extension. The actual GEMM runs entirely in C/CUDA/Metal land. For a 4096³ GEMM that takes 1.5 ms of GPU time, Python overhead is microseconds — ~0.1%. That's why A @ B in PyTorch gives you the same throughput as a hand-written C++ wrapper.
What does slow you down: many small calls. Each A @ B dispatches separately. If you do 1000 small matmuls instead of 1 big one, Python overhead adds up to milliseconds. torch.compile, jax.jit, and MLX's lazy evaluation all exist to fuse small calls into bigger ones.