metal

package module
v0.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 5, 2026 License: BSD-3-Clause Imports: 3 Imported by: 0

README

metal

Go Reference License Pure Go

Compute kernels on a Mac's GPU, from pure Go, with no cgo.

A Mac has a large general-purpose processor sitting idle in it. A Go program that grinds through pixels on the CPU is not merely slower than it could be — it is taking cores away from everything else the machine is doing, which on a laptop that is also syncing files and drawing a browser is the part a person actually notices.

Measured on an M4 Max, one 4K frame of image work (a depth pass, three separable blurs, and two synthesised views):

per frame processor time per frame
CPU, sixteen cores 65 ms 82 ms
GPU, this package 4 ms 0.16 ms

Sixteen times faster, and five hundred times cheaper in CPU. The second column is the one that matters: the work stops competing with the rest of the machine almost entirely.

Kernels are compiled at run time

There is no build step and no toolchain to install. A kernel is Metal Shading Language in a Go string, handed to the system compiler when the program starts, and a kernel that will not compile answers with the compiler's own message — program_source:4:9: error: use of undeclared identifier, not "it did not work".

const source = `
#include <metal_stdlib>
using namespace metal;
struct P { uint n; uchar add; };
kernel void bump(device uchar *d [[buffer(0)]], constant P &p [[buffer(1)]],
                 uint i [[thread_position_in_grid]]) {
    if (i >= p.n) return;
    d[i] = d[i] + p.add;
}`

dev, err := metal.Default()
defer dev.Close()

lib, err := dev.Compile(source)
pipe, err := lib.Pipeline("bump")
buf, err := dev.NewBuffer(4096)

copy(buf.Bytes(), input)

p := struct {
    N   uint32
    Add uint8
    _   [3]uint8
}{N: 4096, Add: 7}

err = dev.Run(func(e *metal.Encoder) {
    e.Use(pipe)
    e.Buffer(0, buf)
    metal.Constant(e, 1, &p)
    e.Dispatch(4096)
})

fmt.Println(buf.Bytes()[0]) // input[0] + 7

Nothing is uploaded or downloaded

On a chip with unified memory — every Apple Silicon Mac — Buffer.Bytes() hands back a Go slice over the very bytes the GPU reads. Writing into it is the upload; reading out of it after Run is the download. The copy that usually eats a GPU's advantage on small work never happens at all.

The scope is the API

A Metal command buffer and its encoder are autoreleased objects with an order they must be used in — encode, end encoding, commit, wait — and every way of getting it wrong is a leak or a hang. Run owns all of it, including the autorelease pool, and there is no way to keep either object past the call.

Whatever the closure gets wrong — dispatching before choosing a kernel, binding a closed buffer, asking for four axes — is reported as an error from Run, and the first complaint is the one you get, because the later ones are usually its consequences.

Submitting without waiting

Run is Submit followed by Wait. Submit(fn) encodes and commits, then returns a *Pending at once, so the CPU can prepare the next frame while the GPU draws this one; work on one device runs in submission order. Pending.Done() polls without blocking; Pending.Wait() blocks, releases the command buffer and returns the GPU's verdict (a second Wait returns the same verdict). Every Pending must be waited on, and memory the work touches must not be written, nor read as a result, until its Wait has returned.

Drawing on screen

A Layer is a CAMetalLayer. Give l.ID() to a view (-[NSView setLayer:] with wantsLayer) and the window server shows whatever is presented to it:

l, err := dev.NewLayer(w, h)             // drawable size in pixels
p, err := dev.Present(l, func(e *metal.Encoder, t *metal.Texture) {
    e.Use(toScreen)
    e.Buffer(0, frame)
    e.Texture(0, t)                      // the drawable about to be shown
    e.Dispatch(w, h)
})
err = p.Wait()
  • Wait and commit: Present waits for the layer's next drawable (CoreAnimation blocks up to a second), records the function with that drawable's texture, and commits with the drawable to be shown when the work is done.
  • Pixel order: drawables are 8-bit BGRA. A kernel writes float4 colours in RGBA order to a texture2d<float, access::write>, and Metal stores them in the layer's order.
  • Exact bytes: a test writes every byte value in every channel through a drawable and reads it back, unchanged.
  • Settings that differ from CoreAnimation's defaults:
    • the device is set, since on macOS it starts nil;
    • framebufferOnly is off, since a framebuffer-only texture cannot be written by a compute kernel.
  • Resizing: Resize changes the drawable size, up to MaxLayerSide (16384) per side.
  • Errors: a drawable that never comes is ErrNoDrawable.

A frame allocates little

One Run (command buffer, encoder, pipeline, a buffer, a constant, a dispatch, commit and wait) costs 22 allocations, down from 100. Every message sent per frame goes through go-macos/objc's Msg0–Msg3, which cost one allocation each, rather than ID.Send's reflective path, which costs five to seven. TestAFrameAllocatesLittle holds the count.

Kernels must check their own bounds

A grid is dispatched in whole threadgroups, so the last group along an axis runs past the end of the work whenever the group size does not divide the grid. Every kernel begins by comparing its thread position against the real size and returning early. This is how Metal is written anyway; it is stated here because the alternative is memory corruption that only appears at unusual image sizes.

The threadgroup shape is not guessed. It comes from the compiled pipeline's own threadExecutionWidth and maxTotalThreadsPerThreadgroup, so a kernel that uses many registers — and is therefore given a smaller budget — is dispatched accordingly.

Everywhere else

On any platform that is not macOS, every constructor returns ErrUnsupported and the rest are no-ops. A program that offers a GPU path and a portable one cross-compiles without build tags of its own.

ErrNoDevice is separate, and real: some virtual machines run macOS with no Metal device at all.

What this is not

Compute only. There is no rendering, no textures, no command-buffer pipelining across frames — those are worth adding when something here needs them, and not before.

Install

go get github.com/go-macos/metal

CGO_ENABLED=0. The only dependencies are purego and go-macos/objc.

Documentation

Overview

Package metal runs compute kernels on a Mac's GPU from pure Go, with no cgo.

A Mac has a large, idle, general-purpose processor in it, and a program that grinds through pixels on the CPU is not merely slower — it is taking cores away from everything else the machine is doing. That is the case this package answers. The shape of it, on an M4 Max, for a 4K frame of image work:

CPU, sixteen cores   65 ms per frame, 82 ms of processor time
GPU, this package     4 ms per frame,  0.16 ms of processor time

Sixteen times faster, and five hundred times cheaper in CPU. The second number is the one that matters on a machine that is also syncing files and drawing a browser.

Kernels are Metal Shading Language, compiled at RUN TIME by the system compiler — there is no build step, no toolchain to install, and nothing shipped but the source string.

dev, err := metal.Default()
lib, err := dev.Compile(source)
pipe, err := lib.Pipeline("addOne")
buf, err := dev.NewBuffer(4096)
err = dev.Run(func(e *metal.Encoder) {
    e.Use(pipe)
    e.Buffer(0, buf)
    e.Dispatch(4096)
})
fmt.Println(buf.Bytes()[0])

On a chip with unified memory — every Apple Silicon Mac — a buffer's bytes are the same bytes the GPU reads. Buffer.Bytes hands back a Go slice over them, so there is no upload and no download, and the copy that usually eats a GPU's advantage never happens.

Kernels must check their own bounds

A grid is dispatched in whole threadgroups, so the last group can run past the end of the work. Every kernel here must begin by comparing its thread position against the real size and returning early, exactly as it would if it were written against Metal directly.

Everywhere else

On a platform that is not macOS every constructor returns ErrUnsupported and the rest are no-ops, so a program that offers a GPU path and a portable one cross-compiles without build tags of its own.

Index

Constants

View Source
const MaxLayerSide = 16384

MaxLayerSide is the largest drawable width or height Resize accepts: the largest 2D texture an Apple GPU makes.

Variables

View Source
var ErrNoDevice = errors.New("metal: no GPU on this machine")

ErrNoDevice is returned when macOS reports no usable GPU. It happens: some virtual machines present no Metal device at all.

View Source
var ErrNoDrawable = errors.New("metal: the layer has no drawable free")

ErrNoDrawable is returned by Present when the layer has no drawable to give: all of them are still on their way to the screen after a second's wait, or the layer's settings are not ones CoreAnimation accepts.

View Source
var ErrUnsupported = errors.New("metal: only macOS has Metal")

ErrUnsupported is returned by every constructor on a platform that has no Metal, so that a caller can fall back rather than fail to build.

Functions

func Constant

func Constant[T any](e *Encoder, index int, v *T)

Constant passes a small value straight into the kernel — the sizes, strides and coefficients that change every frame and would be silly to allocate a buffer for. It is a function rather than a method because Go has no generic methods, and the alternative is unsafe.Sizeof at every call site.

The Go type must match the kernel struct field for field: Go and Metal agree on the layout of fixed-width scalars, and agree on nothing else.

Types

type Buffer

type Buffer struct {
	// contains filtered or unexported fields
}

Buffer is memory both processors can read.

func (*Buffer) Bytes

func (b *Buffer) Bytes() []byte

Bytes is the buffer's memory as a Go slice. On a chip with unified memory these are the bytes the GPU reads: writing here IS uploading.

The slice is only valid until the buffer is closed.

func (*Buffer) Close

func (b *Buffer) Close()

Close does nothing.

type Device

type Device struct {
	// contains filtered or unexported fields
}

Device is one GPU and the queue of work sent to it.

func Default

func Default() (*Device, error)

Default reports that this platform has no Metal.

func (*Device) Close

func (d *Device) Close()

Close does nothing.

func (*Device) Compile

func (d *Device) Compile(string) (*Library, error)

Compile reports that this platform has no Metal.

func (*Device) Name

func (d *Device) Name() string

Name is what the GPU calls itself, such as "Apple M4 Max".

func (*Device) NewBuffer

func (d *Device) NewBuffer(int) (*Buffer, error)

NewBuffer reports that this platform has no Metal.

func (*Device) NewLayer added in v0.6.0

func (d *Device) NewLayer(int, int) (*Layer, error)

NewLayer reports that this platform has no Metal.

func (*Device) Present added in v0.6.0

func (d *Device) Present(*Layer, func(*Encoder, *Texture)) (*Pending, error)

Present reports that this platform has no Metal.

func (*Device) Run

func (d *Device) Run(func(*Encoder)) error

Run reports that this platform has no Metal.

func (*Device) Submit added in v0.4.0

func (d *Device) Submit(func(*Encoder)) (*Pending, error)

Submit reports that this platform has no Metal.

func (*Device) UnifiedMemory

func (d *Device) UnifiedMemory() bool

UnifiedMemory reports whether buffer memory is shared with the CPU rather than copied across a bus. It is true on every Apple Silicon Mac.

type Encoder

type Encoder struct {
	// contains filtered or unexported fields
}

Encoder records the work of a single submission.

func (*Encoder) Buffer

func (e *Encoder) Buffer(int, *Buffer)

Buffer does nothing.

func (*Encoder) Dispatch

func (e *Encoder) Dispatch(...int)

Dispatch does nothing.

func (*Encoder) Texture added in v0.6.0

func (e *Encoder) Texture(int, *Texture)

Texture does nothing.

func (*Encoder) Use

func (e *Encoder) Use(*Pipeline)

Use does nothing.

type Layer added in v0.6.0

type Layer struct {
	// contains filtered or unexported fields
}

Layer is a CAMetalLayer: a surface the window server shows, whose frames the GPU draws. Give ID to a view (-[NSView setLayer:]) to put it on screen.

Its drawables are 8-bit BGRA. A kernel writes them through a Texture with float4 colours in RGBA order and Metal stores them in the layer's order, so a kernel never swaps channels itself.

func (*Layer) Close added in v0.6.0

func (l *Layer) Close()

Close does nothing.

func (*Layer) ID added in v0.6.0

func (l *Layer) ID() uintptr

ID is the CAMetalLayer object, for a view to host.

func (*Layer) Resize added in v0.6.0

func (l *Layer) Resize(int, int) error

Resize reports that this platform has no Metal.

func (*Layer) Size added in v0.6.0

func (l *Layer) Size() (w, h int)

Size is the drawable size in pixels.

type Library

type Library struct {
	// contains filtered or unexported fields
}

Library is a set of kernels compiled together from one source string.

func (*Library) Close

func (l *Library) Close()

Close does nothing.

func (*Library) Pipeline

func (l *Library) Pipeline(string) (*Pipeline, error)

Pipeline reports that this platform has no Metal.

type Pending added in v0.4.0

type Pending struct {
	// contains filtered or unexported fields
}

Pending is work sent to the GPU by Submit that may still be running.

func (*Pending) Done added in v0.4.0

func (p *Pending) Done() bool

Done is true: there is never work in flight.

func (*Pending) Wait added in v0.4.0

func (p *Pending) Wait() error

Wait returns nil: there is never work in flight.

type Pipeline

type Pipeline struct {
	// contains filtered or unexported fields
}

Pipeline is one kernel, compiled and ready to dispatch. It carries the two numbers the hardware reports about itself — the width a group of threads executes in and the most threads a group may hold — which is what lets Dispatch choose a threadgroup shape without the caller guessing.

func (*Pipeline) Close

func (p *Pipeline) Close()

Close does nothing.

type Texture added in v0.6.0

type Texture struct {
	// contains filtered or unexported fields
}

Texture is an image on the GPU. Present hands one over: the drawable that is about to be shown. It is valid only inside that Present's function.

func (*Texture) Size added in v0.6.0

func (t *Texture) Size() (w, h int)

Size is the texture size in pixels.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL