Skip to content

metal: multisample textures allocated with Shared storage on integrated Intel GPUs — MTLTextureDescriptor validation abort #271

Description

@AnyCPU

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions