Programming Apple GPUs through Go and Metal Shading Language
Investigating Go, Cgo, Metal Shading Language, Metal Performance Shaders, and benchmarking different approaches to matrix multiplication

Below I'll describe the process of using cgo to interface between Go and native C, how this can be used to interface with Objective-C bindings for Apple's Metal Performance Shaders framework, how to interface with custom GPU code (shaders) written in the Metal Shading Language, and finally benchmarking all of that against hand-written and OpenBLAS Go-based matrix multiplication operations. This was written to run on my M2 MacBook.
The layout of the source, available here on GitHub, looks like this:

That's a lot, so I'll break it down here into these sections, or feel free to just jump right to the benchmarks.
GPUs and Floating-Point Parallelism
I assume most people at this point are intuitively familiar with the concept that GPUs are incredibly powerful at certain kinds of computational tasks; especially some which support machine learning. It wasn't until I started playing around with Metal that I understood first-hand how much more powerful they can be than CPUs.
GPUs by design are extremely efficient at massively parallel floating-point operations which demand high memory bandwidth. My MacBook M2 has 8 CPU cores and 8 GPU cores, but for comparison, the Nvidia RTX 4090 contains 16384 cores and the H100 contains 16896 CUDA cores with hundreds of additional specialized tensor cores. GPUs usually support SIMD processing, allowing them to execute the same instruction simultaneously across multiple data points.
Outside of graphics, matrix multiplication and linear algebraic tasks in general benefit from this concurrency thanks to their highly parallelizable algorithms. This in turns supports core machine learning workloads like training and inference [1] [2].
CUDA is probably the most well-known GPU programming platform, which specific to Nvidia hardware. There are also mathematical frameworks available for OpenGL. Frameworks like TensorFlow and PyTorch can integrate easily and reasonably transparently with GPU hardware. This was an interesting writeup about the performance improvements of supporting Metal-based GPU frameworks into the spaCy NLP library.
Metal GPU Basics
Programming direct GPU computation is not as simple as writing code for on-device CPUs. When working with Apple's Metal framework, a rough series of operations for executing code on the GPU looks like:
Find an appropriate GPU device
Create a queue for executing commands (ie the MTLCommandQueue)
Wrap pointers to data arrays in a structured buffer; if data is executable code, then a pipeline state, otherwise a regular buffer. Apple GPUs use a unified memory space, meaning we don't need to actually copy any data into GPU-specific physical memory
Commit the command buffer for execution, and either wait for results or set an event handler on completion
Extract bytes out of a response buffer and format locally with CPU program code
Raw GPU programming uses an async model.
Metal Shading Language
Metal Shading Language is a derivative of C++14 which can be used to compose custom logic (called "shaders") to run on Metal-compatible GPUs. In general, and if possible, you'd probably be better off using the MPS framework (discussed later) for equivalent functionality when possible - it tends to be highly-optimized for common classes of GPU-aligned use cases (like matrix multiplication or neural networks).
Debugging of MSL code is pretty difficult. You can use the Shader Debugger through Xcode, but if you want to inspect or print intermediate values without Xcode, you need to write data to a response debug buffer, and parse the primitives in your C++ or Objective-C wrapper.
MSL functions are exposed as public interfaces through the kernel designation. The Metal framework passes IDs for the current calling thread context, or thread group, which can be used to ensure non-overlapping writes. Threads can be represented by a three-dimensional ID system; the dimensions of this thread space are configured in the wrapper code.
The following is an implementation of the naive matrix multiplication algorithm, combined with some loop unrolling which surprisingly improved its performance significantly. This is just for comparison purposes; normally the MPSMatrixMultiplication functionality from MPS would be more suitable.
I implemented a naive-transpose function as well in MSL for comparison. Given a transposed matrix, this is a trivial adjustment to the logic above, whose inner loop runs over B's rows rather than down its columns:
I discussed this approach in an earlier blog post as a pretty easy way to improve the scalar performance of the naive algorithm, at least, on CPUs. More on that later.
Objective-C Bindings
The Metal framework provides the ability to compile a library from Metal source code. Once the file contents are loaded, the binding code looks for the kernel functions by name, and initializes a new MTLComputePipelineState representing the compiled function code.
To actually call the native Metal code, the thread configuration needs to be set, and the GPU buffers initialized.
This is a lot, so I'll illustrate the relationships here:

Metal Performance Shaders Framework
The MPS Framework is a high-performance library provided by Apple for use with its Metal family of GPUs. It offers functionality from image tasks to neural network support.
APIs are primarily available through Swift or Objective-C, though there is also a Metal-cpp library available for use.
The MPSMatrixMultiplication API is reasonably easy to use. As with the MSL code above, the MPS commands still need to be encoded into the MTLCommandBuffer and asynchronously committed for execution.
Go and cgo
I don't particularly like working with Objective-C, and the point of this program is to run code on the GPU originating from a Go program.
Cgo is a Go language feature which allows the Go compiler to understand compiler directives contained within comments related to native C code. It supports a version of foreign function interface.
Directive configuration is a little brittle, but any comments immediately preceding the line import "C" (called "the preamble") will be interpreted as header imports or compilation arguments when compiling referenced C code. For example:
Passes linking flags to the linker via command-line
LDFLAGSCompiles C code with the standard header
stdlib.hCompiles C code with the local project header
metal.h
It took some trial-and-error to get the right set of linker flags to work on MacOS.
Foundation: base librariesCoreGraphics: necessary on MacOS to interface with the GPUMetal: libraries and language support for Metal, including MSLMetalPerformanceShaders: libraries for MPS discussed above
It turns out that Apple bundles a BLAS implementation in its Accelerate framework, so in addition to installing OpenBLAS through brew, the location of the library needs to be provided when linking as well:
-L/opt/homebrew/opt/openblas/lib -lopenblas
The go:embed directive allows Go programs to include files at compile time, which is useful in this case when we want to pass the contents of the MSL source file (mm.metal) to the Metal framework, as discussed above, for compilation.
The references to C above are interfacing with C APIs through cgo, for example:
Note that this means that C is a reserved keyword and cannot be used as a variable name.
Go Implementation Baselines and OpenBLAS
I wanted to compare the performance of GPU-based matrix multiplication to both higher-level implementations, such as the Gonum library, as well as intuitive, hand-written (and comparatively inefficient) implementations.
I implemented a couple different algorithms in Go, including this parallel transpose naive algorithm, which naively divides the multiplication work across N goroutines:
Gonum BLAS is a pure Go library which implements BLAS interfaces. However, it can also be configured to divert algebraic operations to a native-code BLAS implementation like OpenBLAS through netlib.
I showed above how cgo can be configured to properly link to an OpenBLAS installation on MacOS. Within the application code, the preferred BLAS implementation can be set directly. From the benchmark code:
Results
My benchmarking code runs a few trials each of the following matrix multiplication implementations, and reports the average time each took to multiply two square matrices of gradually increasing dimensionality:
Benchmarking output looks like this (floats are ms):
Some quick plotting through matplotlib

As one might expect, my hand-written Go implementations are comparatively out of control. In fact, the other approaches are so fast, you can't even tell them apart in the graph. Here's the sliding histogram of GPU usage during this run

You can see the GPU isn't particularly busy, because time is mostly being spent on CPU operations. Here's another run, excluding the slowest three multiplication techniques:

Around 16M elements (4k x 4k), Gonum begins to degrade. You can see clearly here that the GPU-based and OpenBLAS operations outperform the pure Go implementations. Looking just at the GPU-based approaches:

A couple interesting notes here:
The Metal Performance Shaders library is amazingly fast
There's no real performance difference between the naive and transposed naive approaches
For the second point: this is unlike the performance characteristics of the Go-based pair of implementations above. It turns out that favorable cache access patterns for CPUs do not work the same way for GPUs and the way their SIMD groups (or warps) access memory. See GPU utilization here for comparison:

Now looking at just OpenBLAS and MPS alone - the two fastest approaches:

Around 35M elements, the OpenBLAS implementation begins to degrade, while MPS is holding steady. The difference here is pretty remarkable, with the latter completing the same 35M-element matrix multiplication operations in < 15% of the time. It's reasonable to assume that difference would continue to grow with matrix cardinality.
Now of course, there are probably algorithmic differences between these two approaches, so this is not a fair CPU-to-GPU comparison. If I plot the performance differences between my two hand-coded implementations, it looks like this:

Which is saying that the naive MSL-based implementation completes the multiplication of 5M elements in just 1% of the time of my Go implementation, and that ratio seems to be improving in the GPU's favor over time.








