Summary
Running a simple example app on an Intel Mac with integrated graphics (Iris Plus 655), the process aborts on the very first frame with a Metal validation failure:
-[MTLTextureDescriptorInternal validateWithDevice:]:1405: failed assertion `Texture Descriptor Validation
MTLTextureDescriptor: Multisample textures cannot be allocated with MTLStorageModeShared or MTLStorageModeManaged on this device.
'
SIGABRT: abort
This happens during device/canvas setup, before anything renders — the library is unusable on this class of hardware.
Affected code
hal/metal/device.go, (*Device).CreateTexture, at v0.30.20 (current release), lines 214–226:
storageMode := MTLStorageModePrivate
isShared := false
if d.hasUnifiedMemory {
storageMode = MTLStorageModeShared
isShared = true
}
_ = MsgSend(texDesc, Sel("setStorageMode:"), uintptr(storageMode))
sampleCount (computed a few lines above, normalized 0→1) is never consulted here — only hasUnifiedMemory is.
Root cause
hasUnifiedMemory is set from [MTLDevice hasUnifiedMemory] at device creation (device.go:57), and the surrounding comment treats hasUnifiedMemory == YES as synonymous with "Apple Silicon":
// Detect Apple Silicon (UMA): hasUnifiedMemory returns YES on M-series chips.
But integrated Intel GPUs (like the Iris Plus 655) also report hasUnifiedMemory == YES — they share system RAM with the CPU too. So on Intel-integrated hardware, CreateTexture unconditionally picks MTLStorageModeShared for every texture, including multisample ones.
The actual Metal rule: Apple-GPU-family devices (Apple Silicon) tolerate Shared-storage multisample textures, but non-Apple-family Metal devices reject the combination outright — hence the assertion above. That's also why this reproduces only on Intel/AMD Macs and is invisible on Apple Silicon.
Expected vs. actual
- Expected: a 4x MSAA render target allocates normally regardless of GPU vendor.
- Actual: the texture descriptor fails Metal's validation and the process aborts.
Suggested fix
Force Private storage whenever sampleCount > 1, independent of hasUnifiedMemory:
func textureStorageMode(hasUnifiedMemory bool, sampleCount uint32) (MTLStorageMode, bool) {
if hasUnifiedMemory && sampleCount <= 1 {
return MTLStorageModeShared, true
}
return MTLStorageModePrivate, false
}
Private is valid on every device family, so this is a pure widening of an already-existing safe path — it's exactly what discrete-GPU Macs already do for every texture today. One trade-off worth flagging: on Apple Silicon, MSAA textures move from Shared to Private, forfeiting the setPurgeableState(empty) prompt-reclaim optimization for those specific allocations. That seems acceptable since MSAA render targets are typically pooled and reused, not churned per frame — but you'd know better than me whether that matters for a workload I'm not aware of.
Patch
hal-metal-msaa-storage-mode.diff
diff --git a/hal/metal/conv.go b/hal/metal/conv.go
index 8977d9f..ab83b87 100644
--- a/hal/metal/conv.go
+++ b/hal/metal/conv.go
@@ -120,6 +120,30 @@ func textureUsageToMTL(usage gputypes.TextureUsage) MTLTextureUsage {
return mtlUsage
}
+// textureStorageMode selects the Metal storage mode for a texture.
+//
+// Unified-memory GPUs (hasUnifiedMemory == YES — both Apple Silicon and
+// integrated Intel, which also shares system RAM) use Shared storage for
+// single-sample textures: physical memory is identical to Private, but Shared
+// permits direct CPU writes (replaceRegion:) and honours
+// setPurgeableState(empty) for prompt reclaim.
+//
+// Multisample textures (sampleCount > 1) are always Private, regardless of
+// hasUnifiedMemory. This is NOT "Metal forbids Shared MSAA everywhere" —
+// Apple-GPU-family devices (Apple Silicon) do allow it, which is why this bug
+// was invisible there. Non-Apple-family Metal devices (Intel/AMD Macs,
+// including integrated Intel iGPUs that still report hasUnifiedMemory==YES)
+// reject Shared/Managed storage for multisample textures outright
+// ("[MTLTextureDescriptor validateWithDevice:] ... cannot be allocated with
+// MTLStorageModeShared or MTLStorageModeManaged on this device"). Private is
+// valid on every device family, so it is used unconditionally for MSAA.
+func textureStorageMode(hasUnifiedMemory bool, sampleCount uint32) (MTLStorageMode, bool) {
+ if hasUnifiedMemory && sampleCount <= 1 {
+ return MTLStorageModeShared, true
+ }
+ return MTLStorageModePrivate, false
+}
+
// textureTypeFromDimension converts WebGPU texture dimension to Metal texture type.
func textureTypeFromDimension(dimension gputypes.TextureDimension, sampleCount, depth uint32) MTLTextureType {
switch dimension {
diff --git a/hal/metal/device.go b/hal/metal/device.go
index 66cabfa..f7df7cc 100644
--- a/hal/metal/device.go
+++ b/hal/metal/device.go
@@ -211,18 +211,7 @@ func (d *Device) CreateTexture(desc *hal.TextureDescriptor) (hal.Texture, error)
usage := textureUsageToMTL(desc.Usage)
_ = MsgSend(texDesc, Sel("setUsage:"), uintptr(usage))
- // On Apple Silicon (UMA), use Shared storage instead of Private.
- // Physical memory is identical on UMA — the only differences are:
- // (a) Shared supports direct CPU writes via replaceRegion: (no staging copy)
- // (b) Shared honours setPurgeableState(empty) which immediately returns
- // physical pages to the OS; Private silently ignores this call.
- // On discrete-GPU Macs, keep Private (VRAM-resident, no CPU penalty per frame).
- storageMode := MTLStorageModePrivate
- isShared := false
- if d.hasUnifiedMemory {
- storageMode = MTLStorageModeShared
- isShared = true
- }
+ storageMode, isShared := textureStorageMode(d.hasUnifiedMemory, sampleCount)
_ = MsgSend(texDesc, Sel("setStorageMode:"), uintptr(storageMode))
raw := MsgSend(d.raw, Sel("newTextureWithDescriptor:"), uintptr(texDesc))
diff --git a/hal/metal/msaa_storage_test.go b/hal/metal/msaa_storage_test.go
new file mode 100644
index 0000000..bf3b222
--- /dev/null
+++ b/hal/metal/msaa_storage_test.go
@@ -0,0 +1,44 @@
+// Copyright 2025 The GoGPU Authors
+// SPDX-License-Identifier: MIT
+
+//go:build darwin && !(js && wasm)
+
+package metal
+
+import "testing"
+
+// TestTextureStorageMode pins the storage-mode decision that fixes this bug:
+// on Metal, multisample textures must always be Private — Shared/Managed is
+// rejected on non-Apple-family devices (integrated Intel included, since it
+// also reports hasUnifiedMemory == YES), even though Apple Silicon tolerates
+// Shared MSAA storage. Single-sample textures keep the pre-existing
+// UMA-vs-discrete behavior unchanged.
+func TestTextureStorageMode(t *testing.T) {
+ tests := []struct {
+ name string
+ hasUnifiedMemory bool
+ sampleCount uint32
+ wantMode MTLStorageMode
+ wantIsShared bool
+ }{
+ {"UMA single-sample", true, 1, MTLStorageModeShared, true},
+ {"UMA multisample (regression guard)", true, 4, MTLStorageModePrivate, false},
+ {"discrete single-sample", false, 1, MTLStorageModePrivate, false},
+ {"discrete multisample", false, 4, MTLStorageModePrivate, false},
+ {"UMA zero-sample treated as single-sample", true, 0, MTLStorageModeShared, true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mode, isShared := textureStorageMode(tt.hasUnifiedMemory, tt.sampleCount)
+ if mode != tt.wantMode {
+ t.Errorf("textureStorageMode(%v, %d) mode = %v, want %v",
+ tt.hasUnifiedMemory, tt.sampleCount, mode, tt.wantMode)
+ }
+ if isShared != tt.wantIsShared {
+ t.Errorf("textureStorageMode(%v, %d) isShared = %v, want %v",
+ tt.hasUnifiedMemory, tt.sampleCount, isShared, tt.wantIsShared)
+ }
+ })
+ }
+}
git apply --check succeeds against a clean v0.30.20 checkout.
Verification
Added a small table test for the new helper (unified-memory × sample-count combinations, including a hasUnifiedMemory=true, sampleCount=4 → Private regression case) — pure logic, no GPU needed. Confirmed the example app that previously aborted immediately now runs stably and renders its window correctly on the same Intel Iris Plus 655 machine.
Environment
gogpu/wgpu v0.30.20
- Go 1.26.5,
CGO_ENABLED=0
- macOS 15.7.7,
darwin/amd64
- GPU: Intel(R) Iris(TM) Plus Graphics 655 (integrated)
Part of the fix registry: gogpu/ui#170
Summary
Running a simple example app on an Intel Mac with integrated graphics (Iris Plus 655), the process aborts on the very first frame with a Metal validation failure:
This happens during device/canvas setup, before anything renders — the library is unusable on this class of hardware.
Affected code
hal/metal/device.go,(*Device).CreateTexture, atv0.30.20(current release), lines 214–226:sampleCount(computed a few lines above, normalized 0→1) is never consulted here — onlyhasUnifiedMemoryis.Root cause
hasUnifiedMemoryis set from[MTLDevice hasUnifiedMemory]at device creation (device.go:57), and the surrounding comment treatshasUnifiedMemory == YESas synonymous with "Apple Silicon":// Detect Apple Silicon (UMA): hasUnifiedMemory returns YES on M-series chips.But integrated Intel GPUs (like the Iris Plus 655) also report
hasUnifiedMemory == YES— they share system RAM with the CPU too. So on Intel-integrated hardware,CreateTextureunconditionally picksMTLStorageModeSharedfor every texture, including multisample ones.The actual Metal rule: Apple-GPU-family devices (Apple Silicon) tolerate Shared-storage multisample textures, but non-Apple-family Metal devices reject the combination outright — hence the assertion above. That's also why this reproduces only on Intel/AMD Macs and is invisible on Apple Silicon.
Expected vs. actual
Suggested fix
Force
Privatestorage wheneversampleCount > 1, independent ofhasUnifiedMemory:Privateis valid on every device family, so this is a pure widening of an already-existing safe path — it's exactly what discrete-GPU Macs already do for every texture today. One trade-off worth flagging: on Apple Silicon, MSAA textures move from Shared to Private, forfeiting thesetPurgeableState(empty)prompt-reclaim optimization for those specific allocations. That seems acceptable since MSAA render targets are typically pooled and reused, not churned per frame — but you'd know better than me whether that matters for a workload I'm not aware of.Patch
hal-metal-msaa-storage-mode.diff
git apply --checksucceeds against a cleanv0.30.20checkout.Verification
Added a small table test for the new helper (unified-memory × sample-count combinations, including a
hasUnifiedMemory=true, sampleCount=4 → Privateregression case) — pure logic, no GPU needed. Confirmed the example app that previously aborted immediately now runs stably and renders its window correctly on the same Intel Iris Plus 655 machine.Environment
gogpu/wgpuv0.30.20CGO_ENABLED=0darwin/amd64Part of the fix registry: gogpu/ui#170