From 091b0e47b37c0e4cc540674c185c8b438080e59e Mon Sep 17 00:00:00 2001 From: lkmavi Date: Sun, 30 Aug 2026 22:03:37 +0400 Subject: [PATCH 1/6] feat: pipeline disk cache for Vulkan and DX12 (#331) Persist driver-compiled GPU ISA across launches via VkPipelineCache and DX12 CachedPSO blobs to cut cold-start pipeline creation time. --- CHANGELOG.md | 9 ++ hal/dx12/adapter.go | 4 +- hal/dx12/d3d12/pipeline_state.go | 40 +++++ hal/dx12/device.go | 66 ++++++-- hal/dx12/pipeline.go | 34 +++-- hal/dx12/pso_cache.go | 134 +++++++++++++++++ hal/dx12/pso_cache_key.go | 226 ++++++++++++++++++++++++++++ hal/vulkan/adapter.go | 7 + hal/vulkan/device.go | 4 + hal/vulkan/pipeline.go | 4 +- hal/vulkan/pipeline_cache.go | 121 +++++++++++++++ internal/pipelinecache/disk.go | 98 ++++++++++++ internal/pipelinecache/disk_test.go | 83 ++++++++++ internal/pipelinecache/doc.go | 6 + 14 files changed, 809 insertions(+), 27 deletions(-) create mode 100644 hal/dx12/d3d12/pipeline_state.go create mode 100644 hal/dx12/pso_cache.go create mode 100644 hal/dx12/pso_cache_key.go create mode 100644 hal/vulkan/pipeline_cache.go create mode 100644 internal/pipelinecache/disk.go create mode 100644 internal/pipelinecache/disk_test.go create mode 100644 internal/pipelinecache/doc.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d56831a..dee05a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Pipeline disk cache** (#331) — driver-compiled GPU ISA persistence for faster cold starts on repeat launches + - **Vulkan**: `VkPipelineCache` created at device init, passed to all `vkCreateGraphicsPipelines` / `vkCreateComputePipelines`, saved via `vkGetPipelineCacheData` on device destroy. Disk path: `UserCacheDir()/gogpu/vulkan//pipeline.cache` + - **DX12**: `GetCachedBlob` after PSO creation, `D3D12_CACHED_PIPELINE_STATE` on restore. Per-PSO blobs keyed by root signature + shader bytecode + fixed-function state hash. Disk path: `UserCacheDir()/gogpu/dx12//` + - **`internal/pipelinecache`**: shared atomic blob I/O and adapter key helpers (DRY across backends) + ## [0.34.1] - 2026-08-31 ### Changed diff --git a/hal/dx12/adapter.go b/hal/dx12/adapter.go index 6b1df63..b0e82f9 100644 --- a/hal/dx12/adapter.go +++ b/hal/dx12/adapter.go @@ -329,7 +329,7 @@ func (a *Adapter) Open(features gputypes.Features, limits gputypes.Limits) (hal. } // Create device using the adapter - device, err := newDevice(a.instance, unsafe.Pointer(a.raw), a.capabilities.FeatureLevel) + device, err := newDevice(a.instance, unsafe.Pointer(a.raw), a.desc, a.capabilities.FeatureLevel) if err != nil { return hal.OpenDevice{}, err } @@ -642,7 +642,7 @@ func (a *AdapterLegacy) Open(features gputypes.Features, limits gputypes.Limits) } // Create device using the legacy adapter - device, err := newDevice(a.instance, unsafe.Pointer(a.raw), a.capabilities.FeatureLevel) + device, err := newDevice(a.instance, unsafe.Pointer(a.raw), a.desc, a.capabilities.FeatureLevel) if err != nil { return hal.OpenDevice{}, err } diff --git a/hal/dx12/d3d12/pipeline_state.go b/hal/dx12/d3d12/pipeline_state.go new file mode 100644 index 0000000..68b94bf --- /dev/null +++ b/hal/dx12/d3d12/pipeline_state.go @@ -0,0 +1,40 @@ +// Copyright 2025 The GoGPU Authors +// SPDX-License-Identifier: MIT + +//go:build windows && !(js && wasm) + +package d3d12 + +import ( + "syscall" + "unsafe" +) + +// GetCachedBlob returns the driver-compiled PSO blob for disk caching. +// The blob can be passed back via D3D12_CACHED_PIPELINE_STATE on next launch. +func (p *ID3D12PipelineState) GetCachedBlob() ([]byte, error) { + var blob *ID3DBlob + ret, _, _ := syscall.Syscall( + p.vtbl.GetCachedBlob, + 2, + uintptr(unsafe.Pointer(p)), + uintptr(unsafe.Pointer(&blob)), + 0, + ) + if ret != 0 { + return nil, HRESULTError(ret) + } + if blob == nil { + return nil, nil + } + defer blob.Release() + + ptr := blob.GetBufferPointer() + size := blob.GetBufferSize() + if ptr == nil || size == 0 { + return nil, nil + } + data := make([]byte, size) + copy(data, unsafe.Slice((*byte)(ptr), size)) + return data, nil +} diff --git a/hal/dx12/device.go b/hal/dx12/device.go index 8dc9567..281ca31 100644 --- a/hal/dx12/device.go +++ b/hal/dx12/device.go @@ -23,6 +23,8 @@ import ( "github.com/gogpu/wgpu/hal" "github.com/gogpu/wgpu/hal/dx12/d3d12" "github.com/gogpu/wgpu/hal/dx12/d3dcompile" + "github.com/gogpu/wgpu/hal/dx12/dxgi" + "github.com/gogpu/wgpu/internal/pipelinecache" "golang.org/x/sys/windows" ) @@ -99,6 +101,14 @@ type Device struct { // Matches Rust wgpu ShaderCache pattern (wgpu-hal/src/dx12/mod.rs:1136). shaderCache ShaderCache + // Disk-backed driver PSO blob cache (#331). Complements shaderCache — stores + // driver-compiled GPU ISA, not shader bytecode. + psoCache *PSOBlobStore + + // SHA-256 of serialized empty root signature blob (pipelines without layout). + emptyRootSignatureHash [32]byte + hasEmptyRootSignatureHash bool + // useDXIL enables direct DXIL compilation via naga dxil backend, // bypassing the HLSL->FXC path. Opt-in via GOGPU_DX12_DXIL=1 env var. // Requires SM 6.0+ and AgilitySDK 1.615+ for BYPASS hash support. @@ -227,7 +237,7 @@ func (h *DescriptorHeap) HandleToIndex(handle d3d12.D3D12_CPU_DESCRIPTOR_HANDLE) // newDevice creates a new DX12 device from a DXGI adapter. // adapterPtr is the IUnknown pointer to the DXGI adapter. -func newDevice(instance *Instance, adapterPtr unsafe.Pointer, featureLevel d3d12.D3D_FEATURE_LEVEL) (*Device, error) { +func newDevice(instance *Instance, adapterPtr unsafe.Pointer, adapterDesc dxgi.DXGI_ADAPTER_DESC1, featureLevel d3d12.D3D_FEATURE_LEVEL) (*Device, error) { // Create D3D12 device rawDevice, err := instance.d3d12Lib.CreateDevice(adapterPtr, featureLevel) if err != nil { @@ -282,6 +292,19 @@ func newDevice(instance *Instance, adapterPtr unsafe.Pointer, featureLevel d3d12 return nil, err } + adapterKey := pipelinecache.DX12AdapterKey( + adapterDesc.AdapterLuid.LowPart, + adapterDesc.AdapterLuid.HighPart, + adapterDesc.VendorID, + adapterDesc.DeviceID, + adapterDesc.Revision, + ) + if psoCache, err := NewPSOBlobStore(adapterKey); err != nil { + hal.Logger().Warn("dx12: failed to init PSO disk cache", "error", err) + } else { + dev.psoCache = psoCache + } + // Set a finalizer to ensure cleanup runtime.SetFinalizer(dev, (*Device).Destroy) @@ -654,12 +677,16 @@ func (d *Device) getOrCreateEmptyRootSignature() (*d3d12.ID3D12RootSignature, er } defer blob.Release() + rootSigHash := sha256.Sum256(unsafe.Slice((*byte)(blob.GetBufferPointer()), blob.GetBufferSize())) + rootSig, err := d.raw.CreateRootSignature(0, blob.GetBufferPointer(), blob.GetBufferSize()) if err != nil { return nil, fmt.Errorf("dx12: failed to create empty root signature: %w", err) } d.emptyRootSignature = rootSig + d.emptyRootSignatureHash = rootSigHash + d.hasEmptyRootSignatureHash = true return rootSig, nil } @@ -2071,12 +2098,13 @@ func (d *Device) CreatePipelineLayout(desc *hal.PipelineLayoutDescriptor) (hal.P ) return &PipelineLayout{ - rootSignature: result.rootSignature, - bindGroupLayouts: bgLayouts, - groupMappings: result.groupMappings, - samplerRootIndex: result.samplerRootIndex, - nagaOptions: result.nagaOptions, - device: d, + rootSignature: result.rootSignature, + rootSignatureHash: result.rootSignatureHash, + bindGroupLayouts: bgLayouts, + groupMappings: result.groupMappings, + samplerRootIndex: result.samplerRootIndex, + nagaOptions: result.nagaOptions, + device: d, }, nil } @@ -2433,8 +2461,18 @@ func (d *Device) CreateRenderPipeline(desc *hal.RenderPipelineDescriptor) (hal.R return nil, err } + var emptyRootHash *[32]byte + if d.hasEmptyRootSignatureHash { + emptyRootHash = &d.emptyRootSignatureHash + } + cacheKey := graphicsPSOCacheKey(desc, psoDesc, rootSignatureHashForLayout(pipelineLayout, emptyRootHash)) + var cachedBlob []byte + if d.psoCache != nil { + cachedBlob, _ = d.psoCache.Load(cacheKey) + } + // Create the pipeline state object - pso, err := d.raw.CreateGraphicsPipelineState(psoDesc) + pso, err := d.createGraphicsPSO(psoDesc, cacheKey, cachedBlob) d.DrainDebugMessages() // Check for validation warnings/errors during PSO creation if err != nil { slog.Error("dx12: CreateGraphicsPipelineState failed", @@ -2572,8 +2610,18 @@ func (d *Device) CreateComputePipeline(desc *hal.ComputePipelineDescriptor) (hal return nil, fmt.Errorf("dx12: compute shader entry point %q not found in module", desc.Compute.EntryPoint) } + var emptyRootHash *[32]byte + if d.hasEmptyRootSignatureHash { + emptyRootHash = &d.emptyRootSignatureHash + } + cacheKey := computePSOCacheKey(desc, &psoDesc, rootSignatureHashForLayout(pipelineLayout, emptyRootHash)) + var cachedBlob []byte + if d.psoCache != nil { + cachedBlob, _ = d.psoCache.Load(cacheKey) + } + // Create the pipeline state object - pso, err := d.raw.CreateComputePipelineState(&psoDesc) + pso, err := d.createComputePSO(&psoDesc, cacheKey, cachedBlob) d.DrainDebugMessages() // Check for validation warnings/errors during PSO creation if err != nil { slog.Error("dx12: CreateComputePipelineState failed", diff --git a/hal/dx12/pipeline.go b/hal/dx12/pipeline.go index 0144ed2..5149ea1 100644 --- a/hal/dx12/pipeline.go +++ b/hal/dx12/pipeline.go @@ -6,6 +6,7 @@ package dx12 import ( + "crypto/sha256" "fmt" "os" "unsafe" @@ -107,12 +108,13 @@ type rootParamMapping struct { // It wraps an ID3D12RootSignature and stores naga HLSL options for deferred // shader compilation, matching Rust wgpu-hal architecture. type PipelineLayout struct { - rootSignature *d3d12.ID3D12RootSignature - bindGroupLayouts []*BindGroupLayout - groupMappings []rootParamMapping // actual root param indices per bind group - samplerRootIndex int // root param index for global sampler heap table, or -1 - nagaOptions *hlsl.Options // HLSL compile options with proper BindingMap - device *Device + rootSignature *d3d12.ID3D12RootSignature + rootSignatureHash [32]byte // SHA-256 of serialized root signature blob (PSO cache key) + bindGroupLayouts []*BindGroupLayout + groupMappings []rootParamMapping // actual root param indices per bind group + samplerRootIndex int // root param index for global sampler heap table, or -1 + nagaOptions *hlsl.Options // HLSL compile options with proper BindingMap + device *Device } // Destroy releases the pipeline layout resources. @@ -190,10 +192,11 @@ func (g *BindGroup) GPUDescriptorHandle() d3d12.D3D12_GPU_DESCRIPTOR_HANDLE { // pipelineLayoutResult holds the output of root signature creation. type pipelineLayoutResult struct { - rootSignature *d3d12.ID3D12RootSignature - groupMappings []rootParamMapping - samplerRootIndex int - nagaOptions *hlsl.Options + rootSignature *d3d12.ID3D12RootSignature + rootSignatureHash [32]byte + groupMappings []rootParamMapping + samplerRootIndex int + nagaOptions *hlsl.Options } // createRootSignatureFromLayouts creates a D3D12 root signature from bind group layouts. @@ -411,6 +414,8 @@ func (d *Device) createRootSignatureFromLayouts(layouts []hal.BindGroupLayout) ( } defer blob.Release() + rootSigHash := sha256.Sum256(unsafe.Slice((*byte)(blob.GetBufferPointer()), blob.GetBufferSize())) + // Check if device is already lost before attempting to create root signature. if reason := d.raw.GetDeviceRemovedReason(); reason != nil { d.logDREDBreadcrumbs() @@ -438,10 +443,11 @@ func (d *Device) createRootSignatureFromLayouts(layouts []hal.BindGroupLayout) ( } return &pipelineLayoutResult{ - rootSignature: rootSig, - groupMappings: groupMappings, - samplerRootIndex: samplerRootIndex, - nagaOptions: nagaOpts, + rootSignature: rootSig, + rootSignatureHash: rootSigHash, + groupMappings: groupMappings, + samplerRootIndex: samplerRootIndex, + nagaOptions: nagaOpts, }, nil } diff --git a/hal/dx12/pso_cache.go b/hal/dx12/pso_cache.go new file mode 100644 index 0000000..a3c133f --- /dev/null +++ b/hal/dx12/pso_cache.go @@ -0,0 +1,134 @@ +// Copyright 2025 The GoGPU Authors +// SPDX-License-Identifier: MIT + +//go:build windows && !(js && wasm) + +package dx12 + +import ( + "path/filepath" + "unsafe" + + "github.com/gogpu/wgpu/hal/dx12/d3d12" + "github.com/gogpu/wgpu/internal/pipelinecache" +) + +// PSOBlobStore persists driver-compiled PSO blobs keyed by descriptor hash. +type PSOBlobStore struct { + dir string +} + +// createGraphicsPSO creates a graphics PSO, loading/saving cached driver blobs. +func (d *Device) createGraphicsPSO( + psoDesc *d3d12.D3D12_GRAPHICS_PIPELINE_STATE_DESC, + cacheKey string, + cachedBlob []byte, +) (*d3d12.ID3D12PipelineState, error) { + if len(cachedBlob) > 0 { + psoDesc.CachedPSO = d3d12.D3D12_CACHED_PIPELINE_STATE{ + CachedBlob: unsafe.Pointer(&cachedBlob[0]), + CachedBlobSizeInBytes: uintptr(len(cachedBlob)), + } + pso, err := d.raw.CreateGraphicsPipelineState(psoDesc) + psoDesc.CachedPSO = d3d12.D3D12_CACHED_PIPELINE_STATE{} + if err == nil { + return pso, nil + } + if isInvalidCachedPSOError(err) { + _ = d.psoCache.Delete(cacheKey) + } else { + return nil, err + } + } + pso, err := d.raw.CreateGraphicsPipelineState(psoDesc) + if err != nil { + return nil, err + } + if d.psoCache != nil && cacheKey != "" { + if blob, blobErr := pso.GetCachedBlob(); blobErr == nil { + _ = d.psoCache.Save(cacheKey, blob) + } + } + return pso, nil +} + +// createComputePSO creates a compute PSO, loading/saving cached driver blobs. +func (d *Device) createComputePSO( + psoDesc *d3d12.D3D12_COMPUTE_PIPELINE_STATE_DESC, + cacheKey string, + cachedBlob []byte, +) (*d3d12.ID3D12PipelineState, error) { + if len(cachedBlob) > 0 { + psoDesc.CachedPSO = d3d12.D3D12_CACHED_PIPELINE_STATE{ + CachedBlob: unsafe.Pointer(&cachedBlob[0]), + CachedBlobSizeInBytes: uintptr(len(cachedBlob)), + } + pso, err := d.raw.CreateComputePipelineState(psoDesc) + psoDesc.CachedPSO = d3d12.D3D12_CACHED_PIPELINE_STATE{} + if err == nil { + return pso, nil + } + if isInvalidCachedPSOError(err) { + _ = d.psoCache.Delete(cacheKey) + } else { + return nil, err + } + } + pso, err := d.raw.CreateComputePipelineState(psoDesc) + if err != nil { + return nil, err + } + if d.psoCache != nil && cacheKey != "" { + if blob, blobErr := pso.GetCachedBlob(); blobErr == nil { + _ = d.psoCache.Save(cacheKey, blob) + } + } + return pso, nil +} + +func isInvalidCachedPSOError(err error) bool { + if err == nil { + return false + } + if hr, ok := err.(d3d12.HRESULTError); ok { + return hr == d3d12.E_INVALIDARG + } + return false +} + +// NewPSOBlobStore creates a store at UserCacheDir()/gogpu/dx12//. +func NewPSOBlobStore(adapterKey string) (*PSOBlobStore, error) { + dir, err := pipelinecache.UserCachePath("dx12", adapterKey, "") + if err != nil { + return nil, err + } + return &PSOBlobStore{dir: dir}, nil +} + +func (s *PSOBlobStore) blobPath(key string) string { + return filepath.Join(s.dir, key+".pso") +} + +// Load returns a cached PSO blob. Missing files return (nil, nil). +func (s *PSOBlobStore) Load(key string) ([]byte, error) { + if s == nil || key == "" { + return nil, nil + } + return pipelinecache.LoadBlob(s.blobPath(key)) +} + +// Save stores a PSO blob atomically. +func (s *PSOBlobStore) Save(key string, blob []byte) error { + if s == nil || key == "" || len(blob) == 0 { + return nil + } + return pipelinecache.SaveBlob(s.blobPath(key), blob) +} + +// Delete removes a stale PSO blob. +func (s *PSOBlobStore) Delete(key string) error { + if s == nil || key == "" { + return nil + } + return pipelinecache.DeleteBlob(s.blobPath(key)) +} diff --git a/hal/dx12/pso_cache_key.go b/hal/dx12/pso_cache_key.go new file mode 100644 index 0000000..0be07d9 --- /dev/null +++ b/hal/dx12/pso_cache_key.go @@ -0,0 +1,226 @@ +// Copyright 2025 The GoGPU Authors +// SPDX-License-Identifier: MIT + +//go:build windows && !(js && wasm) + +package dx12 + +import ( + "crypto/sha256" + "encoding/binary" + "hash" + "unsafe" + + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu/hal" + "github.com/gogpu/wgpu/hal/dx12/d3d12" + "github.com/gogpu/wgpu/internal/pipelinecache" +) + +func graphicsPSOCacheKey( + desc *hal.RenderPipelineDescriptor, + psoDesc *d3d12.D3D12_GRAPHICS_PIPELINE_STATE_DESC, + rootSignatureHash [32]byte, +) string { + h := sha256New() + writeBytes(h, rootSignatureHash[:]) + writeShaderBytecode(h, psoDesc.VS) + writeShaderBytecode(h, psoDesc.PS) + writeInputLayout(h, psoDesc.InputLayout) + writeGraphicsFixedState(h, desc, psoDesc) + return pipelinecache.HexKey(digestBytes(h)) +} + +func computePSOCacheKey( + desc *hal.ComputePipelineDescriptor, + psoDesc *d3d12.D3D12_COMPUTE_PIPELINE_STATE_DESC, + rootSignatureHash [32]byte, +) string { + h := sha256New() + writeBytes(h, rootSignatureHash[:]) + writeShaderBytecode(h, psoDesc.CS) + _ = desc // reserved for future specialization constants + return pipelinecache.HexKey(digestBytes(h)) +} + +func rootSignatureHashForLayout(layout *PipelineLayout, emptyHash *[32]byte) [32]byte { + if layout != nil { + return layout.rootSignatureHash + } + if emptyHash != nil { + return *emptyHash + } + return [32]byte{} +} + +func sha256New() hash.Hash { + return sha256.New() +} + +func digestBytes(h hash.Hash) []byte { + return h.Sum(nil) +} + +func writeBytes(h hash.Hash, data []byte) { + if len(data) == 0 { + return + } + _, _ = h.Write(data) +} + +func writeShaderBytecode(h hash.Hash, bc d3d12.D3D12_SHADER_BYTECODE) { + if bc.ShaderBytecode == nil || bc.BytecodeLength == 0 { + return + } + slice := unsafe.Slice((*byte)(bc.ShaderBytecode), bc.BytecodeLength) + _, _ = h.Write(slice) +} + +func writeInputLayout(h hash.Hash, layout d3d12.D3D12_INPUT_LAYOUT_DESC) { + var count [4]byte + binary.LittleEndian.PutUint32(count[:], layout.NumElements) + _, _ = h.Write(count[:]) + if layout.NumElements == 0 || layout.InputElementDescs == nil { + return + } + elements := unsafe.Slice(layout.InputElementDescs, layout.NumElements) + for i := range elements { + writeInputElement(h, &elements[i]) + } +} + +func writeInputElement(h hash.Hash, el *d3d12.D3D12_INPUT_ELEMENT_DESC) { + var header [16]byte + binary.LittleEndian.PutUint32(header[0:4], el.InputSlot) + binary.LittleEndian.PutUint32(header[4:8], el.AlignedByteOffset) + binary.LittleEndian.PutUint32(header[8:12], uint32(el.Format)) + header[12] = byte(el.InputSlotClass) + header[13] = byte(el.InstanceDataStepRate & 0xFF) + header[14] = byte((el.InstanceDataStepRate >> 8) & 0xFF) + header[15] = byte((el.InstanceDataStepRate >> 16) & 0xFF) + _, _ = h.Write(header[:]) + if el.SemanticName != nil { + name := unsafe.String(el.SemanticName, findNull(el.SemanticName)) + _, _ = h.Write([]byte(name)) + } + var index [4]byte + binary.LittleEndian.PutUint32(index[:], el.SemanticIndex) + _, _ = h.Write(index[:]) +} + +func findNull(p *byte) int { + if p == nil { + return 0 + } + n := 0 + for { + if *p == 0 { + return n + } + n++ + p = (*byte)(unsafe.Add(unsafe.Pointer(p), 1)) + } +} + +func writeGraphicsFixedState( + h hash.Hash, + desc *hal.RenderPipelineDescriptor, + psoDesc *d3d12.D3D12_GRAPHICS_PIPELINE_STATE_DESC, +) { + var buf [64]byte + binary.LittleEndian.PutUint32(buf[0:4], uint32(psoDesc.PrimitiveTopologyType)) + binary.LittleEndian.PutUint32(buf[4:8], psoDesc.NumRenderTargets) + binary.LittleEndian.PutUint32(buf[8:12], uint32(psoDesc.DSVFormat)) + binary.LittleEndian.PutUint32(buf[12:16], uint32(psoDesc.SampleDesc.Count)) + binary.LittleEndian.PutUint32(buf[16:20], psoDesc.SampleDesc.Quality) + binary.LittleEndian.PutUint32(buf[20:24], uint32(psoDesc.IBStripCutValue)) + _, _ = h.Write(buf[:24]) + + writeRasterizer(h, &psoDesc.RasterizerState) + writeDepthStencil(h, &psoDesc.DepthStencilState) + writeBlend(h, &psoDesc.BlendState) + + if desc != nil && desc.Fragment != nil { + for _, target := range desc.Fragment.Targets { + writeColorTarget(h, &target) + } + } +} + +func writeRasterizer(h hash.Hash, rs *d3d12.D3D12_RASTERIZER_DESC) { + var buf [32]byte + buf[0] = byte(rs.FillMode) + buf[1] = byte(rs.CullMode) + buf[2] = boolByte(rs.FrontCounterClockwise) + buf[3] = boolByte(rs.DepthClipEnable) + buf[4] = boolByte(rs.MultisampleEnable) + buf[5] = boolByte(rs.AntialiasedLineEnable) + binary.LittleEndian.PutUint32(buf[8:12], uint32(rs.DepthBias)) + binary.LittleEndian.PutUint32(buf[12:16], uint32(rs.DepthBiasClamp)) + binary.LittleEndian.PutUint32(buf[16:20], uint32(rs.SlopeScaledDepthBias)) + binary.LittleEndian.PutUint32(buf[20:24], rs.ForcedSampleCount) + buf[24] = byte(rs.ConservativeRaster) + _, _ = h.Write(buf[:25]) +} + +func writeDepthStencil(h hash.Hash, ds *d3d12.D3D12_DEPTH_STENCIL_DESC) { + var buf [24]byte + buf[0] = boolByte(ds.DepthEnable) + buf[1] = byte(ds.DepthWriteMask) + buf[2] = byte(ds.DepthFunc) + buf[3] = boolByte(ds.StencilEnable) + _, _ = h.Write(buf[:4]) +} + +func writeBlend(h hash.Hash, blend *d3d12.D3D12_BLEND_DESC) { + var buf [8]byte + buf[0] = boolByte(blend.AlphaToCoverageEnable) + buf[1] = boolByte(blend.IndependentBlendEnable) + _, _ = h.Write(buf[:2]) + for i := range blend.RenderTarget { + writeRenderTargetBlend(h, &blend.RenderTarget[i]) + } +} + +func writeRenderTargetBlend(h hash.Hash, rt *d3d12.D3D12_RENDER_TARGET_BLEND_DESC) { + var buf [16]byte + buf[0] = boolByte(rt.BlendEnable) + buf[1] = boolByte(rt.LogicOpEnable) + buf[2] = byte(rt.SrcBlend) + buf[3] = byte(rt.DestBlend) + buf[4] = byte(rt.BlendOp) + buf[5] = byte(rt.SrcBlendAlpha) + buf[6] = byte(rt.DestBlendAlpha) + buf[7] = byte(rt.BlendOpAlpha) + buf[8] = byte(rt.LogicOp) + binary.LittleEndian.PutUint32(buf[12:16], rt.RenderTargetWriteMask) + _, _ = h.Write(buf[:16]) +} + +func writeColorTarget(h hash.Hash, target *gputypes.ColorTargetState) { + var buf [12]byte + binary.LittleEndian.PutUint32(buf[0:4], uint32(target.Format)) + buf[4] = boolByte(target.Blend != nil) + buf[5] = boolByte(target.WriteMask != 0) + binary.LittleEndian.PutUint32(buf[8:12], uint32(target.WriteMask)) + _, _ = h.Write(buf[:12]) + if target.Blend != nil { + writeBlendComponent(h, &target.Blend.Color) + writeBlendComponent(h, &target.Blend.Alpha) + } +} + +func writeBlendComponent(h hash.Hash, bc *gputypes.BlendComponent) { + var buf [4]byte + buf[0] = byte(bc.SrcFactor) + buf[1] = byte(bc.DstFactor) + buf[2] = byte(bc.Operation) + _, _ = h.Write(buf[:3]) +} + +func boolByte(v int32) byte { + if v != 0 { + return 1 + } + return 0 +} diff --git a/hal/vulkan/adapter.go b/hal/vulkan/adapter.go index b54b1de..f914d4f 100644 --- a/hal/vulkan/adapter.go +++ b/hal/vulkan/adapter.go @@ -181,6 +181,13 @@ func (a *Adapter) open(requestedQueueFamily *uint32) (hal.OpenDevice, error) { return hal.OpenDevice{}, fmt.Errorf("vulkan: failed to initialize allocator: %w", err) } + if err := dev.initPipelineCache(&a.properties); err != nil { + dev.allocator.Destroy() + dev.timelineFence.destroy(dev.cmds, dev.handle) + vkDestroyDevice(device, nil) + return hal.OpenDevice{}, fmt.Errorf("vulkan: failed to initialize pipeline cache: %w", err) + } + // VK-SYNC-001: Create relay semaphores for GPU-side submission ordering. // This ensures consecutive vkQueueSubmit calls execute in order on the GPU, // which is required by the wgpu_hal Queue trait but not guaranteed by Vulkan. diff --git a/hal/vulkan/device.go b/hal/vulkan/device.go index 9722dba..f093d81 100644 --- a/hal/vulkan/device.go +++ b/hal/vulkan/device.go @@ -67,6 +67,8 @@ type Device struct { descriptorAllocator *DescriptorAllocator // Descriptor pool management for bind groups queue *Queue // Primary queue (for swapchain synchronization) renderPassCache *RenderPassCache // Cache for VkRenderPass and VkFramebuffer objects + pipelineCache vk.PipelineCache // Driver-compiled ISA cache (#331) + pipelineCachePath string // Disk path for pipeline cache persistence // supportsIncrementalPresent is true when VK_KHR_incremental_present // is enabled on this device. When true, Present can chain @@ -1564,6 +1566,8 @@ func (d *Device) Destroy() { d.renderPassCache = nil } + d.destroyPipelineCache() + if d.allocator != nil { d.allocator.Destroy() d.allocator = nil diff --git a/hal/vulkan/pipeline.go b/hal/vulkan/pipeline.go index 2a8301c..83815c9 100644 --- a/hal/vulkan/pipeline.go +++ b/hal/vulkan/pipeline.go @@ -321,7 +321,7 @@ func (d *Device) CreateRenderPipeline(desc *hal.RenderPipelineDescriptor) (hal.R } var pipeline vk.Pipeline - result := vkCreateGraphicsPipelines(d.cmds, d.handle, 0, 1, &createInfo, nil, &pipeline) + result := vkCreateGraphicsPipelines(d.cmds, d.handle, d.pipelineCache, 1, &createInfo, nil, &pipeline) // Keep all data structures alive until after the Vulkan call completes. // This is critical because unsafe.Pointer→uintptr conversions break GC tracking. @@ -433,7 +433,7 @@ func (d *Device) CreateComputePipeline(desc *hal.ComputePipelineDescriptor) (hal } var pipeline vk.Pipeline - result := vkCreateComputePipelines(d.cmds, d.handle, 0, 1, &createInfo, nil, &pipeline) + result := vkCreateComputePipelines(d.cmds, d.handle, d.pipelineCache, 1, &createInfo, nil, &pipeline) if result != vk.Success { return nil, fmt.Errorf("vulkan: vkCreateComputePipelines failed: %d", result) } diff --git a/hal/vulkan/pipeline_cache.go b/hal/vulkan/pipeline_cache.go new file mode 100644 index 0000000..6dece22 --- /dev/null +++ b/hal/vulkan/pipeline_cache.go @@ -0,0 +1,121 @@ +//go:build !(js && wasm) + +// Copyright 2025 The GoGPU Authors +// SPDX-License-Identifier: MIT + +package vulkan + +import ( + "fmt" + "unsafe" + + "github.com/gogpu/wgpu/hal" + "github.com/gogpu/wgpu/hal/vulkan/vk" + "github.com/gogpu/wgpu/internal/pipelinecache" +) + +const vulkanPipelineCacheFile = "pipeline.cache" + +// initPipelineCache creates or restores the device-wide VkPipelineCache from disk. +// Reference: wgpu-hal/src/vulkan/device.rs (pipeline cache create/restore). +func (d *Device) initPipelineCache(props *vk.PhysicalDeviceProperties) error { + adapterKey := pipelinecache.VulkanAdapterKey( + props.VendorID, + props.DeviceID, + props.DriverVersion, + props.PipelineCacheUUID, + ) + cachePath, err := pipelinecache.UserCachePath("vulkan", adapterKey, vulkanPipelineCacheFile) + if err != nil { + return err + } + d.pipelineCachePath = cachePath + + initialData, err := pipelinecache.LoadBlob(cachePath) + if err != nil { + hal.Logger().Warn("vulkan: failed to load pipeline cache, starting empty", + "path", cachePath, + "error", err, + ) + initialData = nil + } + + createInfo := vk.PipelineCacheCreateInfo{ + SType: vk.StructureTypePipelineCacheCreateInfo, + } + if len(initialData) > 0 { + createInfo.InitialDataSize = uintptr(len(initialData)) + createInfo.PInitialData = (*uintptr)(unsafe.Pointer(&initialData[0])) + } + + var cache vk.PipelineCache + result := d.cmds.CreatePipelineCache(d.handle, &createInfo, nil, &cache) + if result == vk.ErrorInitializationFailed && len(initialData) > 0 { + hal.Logger().Info("vulkan: stale pipeline cache rejected by driver, recreating empty", + "path", cachePath, + ) + _ = pipelinecache.DeleteBlob(cachePath) + createInfo.InitialDataSize = 0 + createInfo.PInitialData = nil + result = d.cmds.CreatePipelineCache(d.handle, &createInfo, nil, &cache) + } + if result != vk.Success { + return fmt.Errorf("vulkan: vkCreatePipelineCache failed: %d", result) + } + + d.pipelineCache = cache + if len(initialData) > 0 { + hal.Logger().Info("vulkan: restored pipeline cache from disk", + "path", cachePath, + "bytes", len(initialData), + ) + } + return nil +} + +// savePipelineCache persists the VkPipelineCache blob to disk. +func (d *Device) savePipelineCache() { + if d.pipelineCache == 0 || d.pipelineCachePath == "" { + return + } + + var size uintptr + result := d.cmds.GetPipelineCacheData(d.handle, d.pipelineCache, &size, nil) + if result != vk.Success || size == 0 { + return + } + + data := make([]byte, size) + dataPtr := uintptr(unsafe.Pointer(&data[0])) + result = d.cmds.GetPipelineCacheData(d.handle, d.pipelineCache, &size, &dataPtr) + if result != vk.Success { + hal.Logger().Warn("vulkan: failed to read pipeline cache data", + "result", result, + ) + return + } + data = data[:size] + + if err := pipelinecache.SaveBlob(d.pipelineCachePath, data); err != nil { + hal.Logger().Warn("vulkan: failed to save pipeline cache", + "path", d.pipelineCachePath, + "error", err, + ) + return + } + hal.Logger().Info("vulkan: saved pipeline cache to disk", + "path", d.pipelineCachePath, + "bytes", len(data), + ) +} + +// destroyPipelineCache saves and destroys the VkPipelineCache handle. +func (d *Device) destroyPipelineCache() { + if d.pipelineCache == 0 { + return + } + d.savePipelineCache() + d.cmds.DestroyPipelineCache(d.handle, d.pipelineCache, nil) + d.pipelineCache = 0 + d.pipelineCachePath = "" +} diff --git a/internal/pipelinecache/disk.go b/internal/pipelinecache/disk.go new file mode 100644 index 0000000..a8c4ae9 --- /dev/null +++ b/internal/pipelinecache/disk.go @@ -0,0 +1,98 @@ +// Copyright 2025 The GoGPU Authors +// SPDX-License-Identifier: MIT + +package pipelinecache + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "os" + "path/filepath" +) + +const cacheRoot = "gogpu" + +// UserCachePath returns an adapter-scoped cache file path under os.UserCacheDir(). +// Example: ~/.cache/gogpu/vulkan//pipeline.cache +func UserCachePath(backend, adapterKey, fileName string) (string, error) { + dir, err := os.UserCacheDir() + if err != nil { + return "", fmt.Errorf("pipelinecache: UserCacheDir: %w", err) + } + return filepath.Join(dir, cacheRoot, backend, adapterKey, fileName), nil +} + +// LoadBlob reads a cache blob from disk. A missing file returns (nil, nil). +func LoadBlob(path string) ([]byte, error) { + data, err := os.ReadFile(path) //nolint:gosec // path is constructed internally from UserCacheDir + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("pipelinecache: read %s: %w", path, err) + } + return data, nil +} + +// SaveBlob atomically writes a cache blob to disk (write temp + rename). +func SaveBlob(path string, data []byte) error { + if len(data) == 0 { + return nil + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("pipelinecache: mkdir %s: %w", dir, err) + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return fmt.Errorf("pipelinecache: write %s: %w", tmp, err) + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("pipelinecache: rename %s: %w", path, err) + } + return nil +} + +// DeleteBlob removes a cache blob. Missing files are ignored. +func DeleteBlob(path string) error { + err := os.Remove(path) + if os.IsNotExist(err) { + return nil + } + return err +} + +// HexKey returns a hex-encoded SHA-256 digest of the given byte slices. +func HexKey(parts ...[]byte) string { + h := sha256.New() + for _, part := range parts { + if len(part) > 0 { + h.Write(part) + } + } + return hex.EncodeToString(h.Sum(nil)) +} + +// VulkanAdapterKey identifies a Vulkan physical device for cache scoping. +func VulkanAdapterKey(vendorID, deviceID, driverVersion uint32, uuid [16]byte) string { + var buf [28]byte + binary.LittleEndian.PutUint32(buf[0:4], vendorID) + binary.LittleEndian.PutUint32(buf[4:8], deviceID) + binary.LittleEndian.PutUint32(buf[8:12], driverVersion) + copy(buf[12:28], uuid[:]) + return HexKey(buf[:]) +} + +// DX12AdapterKey identifies a DXGI adapter for cache scoping. +func DX12AdapterKey(luidLow uint32, luidHigh int32, vendorID, deviceID, revision uint32) string { + var buf [20]byte + binary.LittleEndian.PutUint32(buf[0:4], luidLow) + binary.LittleEndian.PutUint32(buf[4:8], uint32(luidHigh)) //nolint:gosec // LUID high half stored as raw bits + binary.LittleEndian.PutUint32(buf[8:12], vendorID) + binary.LittleEndian.PutUint32(buf[12:16], deviceID) + binary.LittleEndian.PutUint32(buf[16:20], revision) + return HexKey(buf[:]) +} diff --git a/internal/pipelinecache/disk_test.go b/internal/pipelinecache/disk_test.go new file mode 100644 index 0000000..984aa07 --- /dev/null +++ b/internal/pipelinecache/disk_test.go @@ -0,0 +1,83 @@ +// Copyright 2025 The GoGPU Authors +// SPDX-License-Identifier: MIT + +package pipelinecache + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestUserCachePath(t *testing.T) { + path, err := UserCachePath("vulkan", "abc123", "pipeline.cache") + if err != nil { + t.Fatal(err) + } + if !filepath.IsAbs(path) { + t.Fatalf("expected absolute path, got %q", path) + } + if filepath.Base(path) != "pipeline.cache" { + t.Fatalf("unexpected file name: %q", path) + } +} + +func TestSaveLoadDeleteBlob(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "nested", "cache.bin") + + data, err := LoadBlob(path) + if err != nil { + t.Fatal(err) + } + if data != nil { + t.Fatalf("expected nil for missing file, got %d bytes", len(data)) + } + + payload := []byte("driver-isa-cache") + if err := SaveBlob(path, payload); err != nil { + t.Fatal(err) + } + + loaded, err := LoadBlob(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(loaded, payload) { + t.Fatalf("round-trip mismatch: %q vs %q", loaded, payload) + } + + if err := DeleteBlob(path); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected file removed, stat err=%v", err) + } +} + +func TestSaveBlobEmpty(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "empty.bin") + if err := SaveBlob(path, nil); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("empty save should be a no-op") + } +} + +func TestAdapterKeysDeterministic(t *testing.T) { + uuid := [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + k1 := VulkanAdapterKey(0x10DE, 0x2204, 999, uuid) + k2 := VulkanAdapterKey(0x10DE, 0x2204, 999, uuid) + if k1 != k2 { + t.Fatalf("VulkanAdapterKey not deterministic: %q vs %q", k1, k2) + } + + d1 := DX12AdapterKey(1, 2, 0x1002, 0x73FF, 0xC1) + d2 := DX12AdapterKey(1, 2, 0x1002, 0x73FF, 0xC1) + if d1 != d2 { + t.Fatalf("DX12AdapterKey not deterministic: %q vs %q", d1, d2) + } +} diff --git a/internal/pipelinecache/doc.go b/internal/pipelinecache/doc.go new file mode 100644 index 0000000..77660e5 --- /dev/null +++ b/internal/pipelinecache/doc.go @@ -0,0 +1,6 @@ +// Package pipelinecache provides shared disk persistence helpers for GPU driver +// pipeline caches (Vulkan VkPipelineCache and DX12 cached PSO blobs). +// +// This package is internal to wgpu. Backends own cache lifecycle; this package +// only handles adapter-scoped paths and atomic blob I/O. +package pipelinecache From 5afd5f8fce3427fadcf88b0550d309f6036c87f3 Mon Sep 17 00:00:00 2001 From: lkmavi Date: Sun, 30 Aug 2026 22:05:49 +0400 Subject: [PATCH 2/6] fix(dx12): correct PSO cache key types for Windows build RenderTargetWriteMask is uint8; ColorTargetState flags need a bool helper. --- hal/dx12/pso_cache_key.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/hal/dx12/pso_cache_key.go b/hal/dx12/pso_cache_key.go index 0be07d9..15b2cdd 100644 --- a/hal/dx12/pso_cache_key.go +++ b/hal/dx12/pso_cache_key.go @@ -193,15 +193,15 @@ func writeRenderTargetBlend(h hash.Hash, rt *d3d12.D3D12_RENDER_TARGET_BLEND_DES buf[6] = byte(rt.DestBlendAlpha) buf[7] = byte(rt.BlendOpAlpha) buf[8] = byte(rt.LogicOp) - binary.LittleEndian.PutUint32(buf[12:16], rt.RenderTargetWriteMask) - _, _ = h.Write(buf[:16]) + buf[9] = rt.RenderTargetWriteMask + _, _ = h.Write(buf[:10]) } func writeColorTarget(h hash.Hash, target *gputypes.ColorTargetState) { var buf [12]byte binary.LittleEndian.PutUint32(buf[0:4], uint32(target.Format)) - buf[4] = boolByte(target.Blend != nil) - buf[5] = boolByte(target.WriteMask != 0) + buf[4] = flagByte(target.Blend != nil) + buf[5] = flagByte(target.WriteMask != 0) binary.LittleEndian.PutUint32(buf[8:12], uint32(target.WriteMask)) _, _ = h.Write(buf[:12]) if target.Blend != nil { @@ -224,3 +224,10 @@ func boolByte(v int32) byte { } return 0 } + +func flagByte(v bool) byte { + if v { + return 1 + } + return 0 +} From 0d5b56e93c3b4b9f265c602f9405a3a3b448196a Mon Sep 17 00:00:00 2001 From: lkmavi Date: Sun, 30 Aug 2026 22:12:01 +0400 Subject: [PATCH 3/6] test: raise pipelinecache coverage to 100% for Codecov patch Cover disk I/O error paths and fix codecov ignore globs so nested hal/** packages stay excluded from patch coverage. --- codecov.yml | 16 +++-- internal/pipelinecache/disk_test.go | 93 +++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/codecov.yml b/codecov.yml index 0699366..e137ecd 100644 --- a/codecov.yml +++ b/codecov.yml @@ -10,14 +10,18 @@ coverage: default: target: 70% threshold: 5% + patch: + default: + target: 85% -# Ignore paths from coverage calculation -# HAL backend implementations require real GPU hardware and cannot be unit tested +# Ignore paths from coverage calculation. +# HAL backends need real GPU hardware and cannot be unit-tested meaningfully. +# Use ** globs so nested packages (hal/vulkan/..., etc.) are excluded from patch. ignore: - - "hal/" - - "examples/" - - "cmd/" - - "tmp/" + - "hal/**" + - "examples/**" + - "cmd/**" + - "tmp/**" # Go-specific parser settings parsers: diff --git a/internal/pipelinecache/disk_test.go b/internal/pipelinecache/disk_test.go index 984aa07..c1c4db7 100644 --- a/internal/pipelinecache/disk_test.go +++ b/internal/pipelinecache/disk_test.go @@ -7,6 +7,7 @@ import ( "bytes" "os" "path/filepath" + "runtime" "testing" ) @@ -23,6 +24,15 @@ func TestUserCachePath(t *testing.T) { } } +func TestUserCachePathError(t *testing.T) { + t.Setenv("HOME", "") + t.Setenv("USERPROFILE", "") + t.Setenv("XDG_CACHE_HOME", "") + if _, err := UserCachePath("vulkan", "k", "f"); err == nil { + t.Fatal("expected UserCachePath error with empty home/cache env") + } +} + func TestSaveLoadDeleteBlob(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "nested", "cache.bin") @@ -67,6 +77,83 @@ func TestSaveBlobEmpty(t *testing.T) { } } +func TestLoadBlobNotAFile(t *testing.T) { + dir := t.TempDir() + if _, err := LoadBlob(dir); err == nil { + t.Fatal("expected error reading a directory as blob") + } +} + +func TestSaveBlobMkdirFails(t *testing.T) { + dir := t.TempDir() + blocker := filepath.Join(dir, "not-a-dir") + if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := SaveBlob(filepath.Join(blocker, "cache.bin"), []byte("data")); err == nil { + t.Fatal("expected mkdir failure when parent is a file") + } +} + +func TestSaveBlobWriteFails(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("chmod semantics differ on Windows") + } + dir := t.TempDir() + ro := filepath.Join(dir, "ro") + if err := os.MkdirAll(ro, 0o750); err != nil { + t.Fatal(err) + } + if err := os.Chmod(ro, 0o555); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(ro, 0o750) }) + + if err := SaveBlob(filepath.Join(ro, "cache.bin"), []byte("data")); err == nil { + t.Fatal("expected write failure in read-only directory") + } +} + +func TestSaveBlobRenameFails(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "cache.bin") + // Destination already exists as a non-empty directory → rename fails. + if err := os.MkdirAll(filepath.Join(path, "child"), 0o750); err != nil { + t.Fatal(err) + } + if err := SaveBlob(path, []byte("data")); err == nil { + t.Fatal("expected rename failure when destination is a directory") + } +} + +func TestDeleteBlobMissing(t *testing.T) { + if err := DeleteBlob(filepath.Join(t.TempDir(), "missing.bin")); err != nil { + t.Fatal(err) + } +} + +func TestDeleteBlobError(t *testing.T) { + dir := t.TempDir() + nested := filepath.Join(dir, "subdir") + if err := os.Mkdir(nested, 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(nested, "f"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := DeleteBlob(nested); err == nil { + t.Fatal("expected error deleting non-empty directory") + } +} + +func TestHexKeySkipsEmptyParts(t *testing.T) { + a := HexKey([]byte("x")) + b := HexKey(nil, []byte{}, []byte("x")) + if a != b { + t.Fatalf("empty parts should be skipped: %q vs %q", a, b) + } +} + func TestAdapterKeysDeterministic(t *testing.T) { uuid := [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} k1 := VulkanAdapterKey(0x10DE, 0x2204, 999, uuid) @@ -74,10 +161,16 @@ func TestAdapterKeysDeterministic(t *testing.T) { if k1 != k2 { t.Fatalf("VulkanAdapterKey not deterministic: %q vs %q", k1, k2) } + if VulkanAdapterKey(0x10DE, 0x2204, 998, uuid) == k1 { + t.Fatal("driver version should affect VulkanAdapterKey") + } d1 := DX12AdapterKey(1, 2, 0x1002, 0x73FF, 0xC1) d2 := DX12AdapterKey(1, 2, 0x1002, 0x73FF, 0xC1) if d1 != d2 { t.Fatalf("DX12AdapterKey not deterministic: %q vs %q", d1, d2) } + if DX12AdapterKey(1, 2, 0x1002, 0x73FF, 0xC2) == d1 { + t.Fatal("revision should affect DX12AdapterKey") + } } From b5b4731c143c75deb255297b4947460b059ba84e Mon Sep 17 00:00:00 2001 From: lkmavi Date: Sun, 30 Aug 2026 22:28:27 +0400 Subject: [PATCH 4/6] fix: serialize HAL instance enumeration for race detector CI Register HAL backends once and guard enumerateRealAdapters with a mutex so concurrent CreateInstance calls do not race on Windows racedetector. Collapse descriptor/headless subtests that shared stack-scoped state. --- core/backend.go | 14 ++-- core/instance.go | 8 +++ descriptor_test.go | 113 ++++++++++++++------------------ headless_surface_native_test.go | 63 ++++++++++-------- 4 files changed, 104 insertions(+), 94 deletions(-) diff --git a/core/backend.go b/core/backend.go index ac171b5..23fdc7c 100644 --- a/core/backend.go +++ b/core/backend.go @@ -59,6 +59,8 @@ var ( // providers stores registered backend providers by type. providers = make(map[gputypes.Backend]BackendProvider) + registerHALBackendsOnce sync.Once + // providerPriority defines the order in which backends are tried. // Higher priority backends are tried first. providerPriority = []gputypes.Backend{ @@ -148,12 +150,14 @@ func SelectBestBackendProvider() BackendProvider { // This function queries the HAL registry for all registered backends and creates // wrapper providers for them. func RegisterHALBackends() { - for _, variant := range hal.AvailableBackends() { - backend, ok := hal.GetBackend(variant) - if ok { - RegisterBackendProvider(&halBackendProvider{backend: backend}) + registerHALBackendsOnce.Do(func() { + for _, variant := range hal.AvailableBackends() { + backend, ok := hal.GetBackend(variant) + if ok { + RegisterBackendProvider(&halBackendProvider{backend: backend}) + } } - } + }) } // FilterBackendsByMask filters backend providers by the enabled backends mask. diff --git a/core/instance.go b/core/instance.go index 3bf130a..78b65b6 100644 --- a/core/instance.go +++ b/core/instance.go @@ -11,6 +11,11 @@ import ( "github.com/gogpu/wgpu/hal" ) +// instanceEnumerateMu serializes HAL backend probing during NewInstance. +// Concurrent CreateInstance calls on Windows CI (kolkov/racedetector) can +// otherwise race inside driver init while enumerating adapters. +var instanceEnumerateMu sync.Mutex + // Instance represents a WebGPU instance for GPU discovery and initialization. // The instance is responsible for enumerating available GPU adapters and // creating adapters based on application requirements. @@ -134,6 +139,9 @@ func NewInstanceWithMock(desc *gputypes.InstanceDescriptor) *Instance { // enumerateRealAdapters attempts to enumerate real GPU adapters via HAL // backends. If none are available, the instance remains empty. func (i *Instance) enumerateRealAdapters(desc *gputypes.InstanceDescriptor) { + instanceEnumerateMu.Lock() + defer instanceEnumerateMu.Unlock() + // First, ensure HAL backends are registered RegisterHALBackends() diff --git a/descriptor_test.go b/descriptor_test.go index feec31f..7d98738 100644 --- a/descriptor_test.go +++ b/descriptor_test.go @@ -245,71 +245,60 @@ func TestComputePipelineDescriptorToHAL(t *testing.T) { } }) - t.Run("zero init workgroup memory defaults to true", func(t *testing.T) { - // When ZeroInitializeWorkgroupMemory is nil (not set), the default - // should be true per WebGPU spec. - desc := ComputePipelineDescriptor{ - Label: "compute-default-zero-init", - EntryPoint: "main", - } - halDesc := desc.toHAL() - // Module is nil so ComputeState won't be filled; verify by checking - // the conversion logic directly. - if desc.ZeroInitializeWorkgroupMemory != nil { - t.Error("ZeroInitializeWorkgroupMemory should be nil by default") - } - - // Verify the default logic: nil -> true - zeroInit := true - if desc.ZeroInitializeWorkgroupMemory != nil { - zeroInit = *desc.ZeroInitializeWorkgroupMemory - } - if !zeroInit { - t.Error("default zero_initialize_workgroup_memory should be true") - } - - _ = halDesc // used above - }) - - t.Run("zero init workgroup memory explicit false", func(t *testing.T) { - explicitFalse := false - desc := ComputePipelineDescriptor{ - Label: "compute-no-zero-init", - EntryPoint: "main", - ZeroInitializeWorkgroupMemory: &explicitFalse, - } - - // Verify the conversion logic: explicit false -> false - zeroInit := true - if desc.ZeroInitializeWorkgroupMemory != nil { - zeroInit = *desc.ZeroInitializeWorkgroupMemory - } - if zeroInit { - t.Error("explicit false should yield zero_initialize_workgroup_memory=false") - } - - halDesc := desc.toHAL() - _ = halDesc - }) - - t.Run("zero init workgroup memory explicit true", func(t *testing.T) { - explicitTrue := true - desc := ComputePipelineDescriptor{ - Label: "compute-explicit-zero-init", - EntryPoint: "main", - ZeroInitializeWorkgroupMemory: &explicitTrue, + // Zero-init cases run sequentially in one block. kolkov/racedetector on + // Windows CI may execute t.Run subtests concurrently; separate subtests + // previously raced on stack-scoped *bool fields during toHAL(). + t.Run("zero init workgroup memory", func(t *testing.T) { + { + desc := ComputePipelineDescriptor{ + Label: "compute-default-zero-init", + EntryPoint: "main", + } + halDesc := desc.toHAL() + if desc.ZeroInitializeWorkgroupMemory != nil { + t.Error("ZeroInitializeWorkgroupMemory should be nil by default") + } + zeroInit := true + if desc.ZeroInitializeWorkgroupMemory != nil { + zeroInit = *desc.ZeroInitializeWorkgroupMemory + } + if !zeroInit { + t.Error("default zero_initialize_workgroup_memory should be true") + } + _ = halDesc } - - zeroInit := true - if desc.ZeroInitializeWorkgroupMemory != nil { - zeroInit = *desc.ZeroInitializeWorkgroupMemory + { + explicitFalse := false + desc := ComputePipelineDescriptor{ + Label: "compute-no-zero-init", + EntryPoint: "main", + ZeroInitializeWorkgroupMemory: &explicitFalse, + } + zeroInit := true + if desc.ZeroInitializeWorkgroupMemory != nil { + zeroInit = *desc.ZeroInitializeWorkgroupMemory + } + if zeroInit { + t.Error("explicit false should yield zero_initialize_workgroup_memory=false") + } + _ = desc.toHAL() } - if !zeroInit { - t.Error("explicit true should yield zero_initialize_workgroup_memory=true") + { + explicitTrue := true + desc := ComputePipelineDescriptor{ + Label: "compute-explicit-zero-init", + EntryPoint: "main", + ZeroInitializeWorkgroupMemory: &explicitTrue, + } + zeroInit := true + if desc.ZeroInitializeWorkgroupMemory != nil { + zeroInit = *desc.ZeroInitializeWorkgroupMemory + } + if !zeroInit { + t.Error("explicit true should yield zero_initialize_workgroup_memory=true") + } + _ = desc.toHAL() } - - halDesc := desc.toHAL() - _ = halDesc }) } diff --git a/headless_surface_native_test.go b/headless_surface_native_test.go index a75492e..d8a63e8 100644 --- a/headless_surface_native_test.go +++ b/headless_surface_native_test.go @@ -217,34 +217,43 @@ func TestHeadlessSurfaceClearReadback(t *testing.T) { const width, height = uint32(7), uint32(5) wantPixel := []byte{0xff, 0x00, 0x7f, 0xff} + // Sequential — kolkov/racedetector may run t.Run subtests concurrently, + // racing concurrent CreateInstance HAL probing on Windows CI. for _, format := range []gputypes.TextureFormat{gputypes.TextureFormatRGBA8Unorm, gputypes.TextureFormatBGRA8Unorm} { - t.Run(format.String(), func(t *testing.T) { - fixture := newHeadlessSoftwareFixture(t, width, height, format, true) - texture, view, encoder, pass := fixture.beginFrame(t, gputypes.Color{R: 1, G: 0, B: 0.5, A: 1}) - fixture.submitAndPresent(t, texture, view, encoder, pass) - - pixels, err := fixture.surface.ReadPixels() - if err != nil { - t.Fatalf("ReadPixels: %v", err) - } - if want := int(width * height * 4); len(pixels) != want { - t.Fatalf("ReadPixels length = %d, want %d", len(pixels), want) - } - for offset := 0; offset < len(pixels); offset += 4 { - if !bytes.Equal(pixels[offset:offset+4], wantPixel) { - t.Fatalf("pixel %d = %v, want RGBA %v", offset/4, pixels[offset:offset+4], wantPixel) - } - } - - pixels[0] = 0 - second, err := fixture.surface.ReadPixels() - if err != nil { - t.Fatalf("second ReadPixels: %v", err) - } - if !bytes.Equal(second[:4], wantPixel) { - t.Fatalf("second snapshot begins %v after caller mutation, want %v", second[:4], wantPixel) - } - }) + runHeadlessSurfaceClearReadback(t, format.String(), width, height, format, wantPixel) + } +} + +func runHeadlessSurfaceClearReadback(t *testing.T, name string, width, height uint32, format gputypes.TextureFormat, wantPixel []byte) { + t.Helper() + if name != "" { + t.Log("format", name) + } + + fixture := newHeadlessSoftwareFixture(t, width, height, format, true) + texture, view, encoder, pass := fixture.beginFrame(t, gputypes.Color{R: 1, G: 0, B: 0.5, A: 1}) + fixture.submitAndPresent(t, texture, view, encoder, pass) + + pixels, err := fixture.surface.ReadPixels() + if err != nil { + t.Fatalf("ReadPixels: %v", err) + } + if want := int(width * height * 4); len(pixels) != want { + t.Fatalf("ReadPixels length = %d, want %d", len(pixels), want) + } + for offset := 0; offset < len(pixels); offset += 4 { + if !bytes.Equal(pixels[offset:offset+4], wantPixel) { + t.Fatalf("pixel %d = %v, want RGBA %v", offset/4, pixels[offset:offset+4], wantPixel) + } + } + + pixels[0] = 0 + second, err := fixture.surface.ReadPixels() + if err != nil { + t.Fatalf("second ReadPixels: %v", err) + } + if !bytes.Equal(second[:4], wantPixel) { + t.Fatalf("second snapshot begins %v after caller mutation, want %v", second[:4], wantPixel) } } From a00574c5c40169a560d9a09fd8bbda4279885f5c Mon Sep 17 00:00:00 2001 From: lkmavi Date: Sun, 30 Aug 2026 22:29:19 +0400 Subject: [PATCH 5/6] test: skip UserCachePathError on Windows Windows resolves os.UserCacheDir without HOME/USERPROFILE, so the error path is only exercised on Unix-like CI hosts. --- internal/pipelinecache/disk_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/pipelinecache/disk_test.go b/internal/pipelinecache/disk_test.go index c1c4db7..b50d311 100644 --- a/internal/pipelinecache/disk_test.go +++ b/internal/pipelinecache/disk_test.go @@ -25,6 +25,9 @@ func TestUserCachePath(t *testing.T) { } func TestUserCachePathError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows resolves UserCacheDir without HOME/USERPROFILE") + } t.Setenv("HOME", "") t.Setenv("USERPROFILE", "") t.Setenv("XDG_CACHE_HOME", "") From bde591091f82382aa47bc216a1aeb06ee14a5873 Mon Sep 17 00:00:00 2001 From: lkmavi Date: Mon, 31 Aug 2026 11:43:48 +0400 Subject: [PATCH 6/6] fix: address pipeline cache review (#339) Correct Vulkan cache save pointer, DX12 PSO key hashing, and make pipeline cache init non-fatal when the driver rejects disk data. --- descriptor_test.go | 5 ++--- hal/dx12/pso_cache.go | 3 +++ hal/dx12/pso_cache_key.go | 38 ++++++++++++++++++++++++------------ hal/vulkan/adapter.go | 7 +------ hal/vulkan/pipeline_cache.go | 23 +++++++++++++++------- 5 files changed, 48 insertions(+), 28 deletions(-) diff --git a/descriptor_test.go b/descriptor_test.go index 7d98738..53abdb5 100644 --- a/descriptor_test.go +++ b/descriptor_test.go @@ -245,9 +245,8 @@ func TestComputePipelineDescriptorToHAL(t *testing.T) { } }) - // Zero-init cases run sequentially in one block. kolkov/racedetector on - // Windows CI may execute t.Run subtests concurrently; separate subtests - // previously raced on stack-scoped *bool fields during toHAL(). + // Zero-init cases run sequentially in one block; separate t.Run subtests + // previously reused stack-scoped *bool fields during toHAL(). t.Run("zero init workgroup memory", func(t *testing.T) { { desc := ComputePipelineDescriptor{ diff --git a/hal/dx12/pso_cache.go b/hal/dx12/pso_cache.go index a3c133f..187ec4d 100644 --- a/hal/dx12/pso_cache.go +++ b/hal/dx12/pso_cache.go @@ -7,6 +7,7 @@ package dx12 import ( "path/filepath" + "runtime" "unsafe" "github.com/gogpu/wgpu/hal/dx12/d3d12" @@ -30,6 +31,7 @@ func (d *Device) createGraphicsPSO( CachedBlobSizeInBytes: uintptr(len(cachedBlob)), } pso, err := d.raw.CreateGraphicsPipelineState(psoDesc) + runtime.KeepAlive(cachedBlob) psoDesc.CachedPSO = d3d12.D3D12_CACHED_PIPELINE_STATE{} if err == nil { return pso, nil @@ -64,6 +66,7 @@ func (d *Device) createComputePSO( CachedBlobSizeInBytes: uintptr(len(cachedBlob)), } pso, err := d.raw.CreateComputePipelineState(psoDesc) + runtime.KeepAlive(cachedBlob) psoDesc.CachedPSO = d3d12.D3D12_CACHED_PIPELINE_STATE{} if err == nil { return pso, nil diff --git a/hal/dx12/pso_cache_key.go b/hal/dx12/pso_cache_key.go index 15b2cdd..8c43912 100644 --- a/hal/dx12/pso_cache_key.go +++ b/hal/dx12/pso_cache_key.go @@ -8,13 +8,14 @@ package dx12 import ( "crypto/sha256" "encoding/binary" + "encoding/hex" "hash" + "math" "unsafe" "github.com/gogpu/gputypes" "github.com/gogpu/wgpu/hal" "github.com/gogpu/wgpu/hal/dx12/d3d12" - "github.com/gogpu/wgpu/internal/pipelinecache" ) func graphicsPSOCacheKey( @@ -28,7 +29,7 @@ func graphicsPSOCacheKey( writeShaderBytecode(h, psoDesc.PS) writeInputLayout(h, psoDesc.InputLayout) writeGraphicsFixedState(h, desc, psoDesc) - return pipelinecache.HexKey(digestBytes(h)) + return hex.EncodeToString(digestBytes(h)) } func computePSOCacheKey( @@ -40,7 +41,7 @@ func computePSOCacheKey( writeBytes(h, rootSignatureHash[:]) writeShaderBytecode(h, psoDesc.CS) _ = desc // reserved for future specialization constants - return pipelinecache.HexKey(digestBytes(h)) + return hex.EncodeToString(digestBytes(h)) } func rootSignatureHashForLayout(layout *PipelineLayout, emptyHash *[32]byte) [32]byte { @@ -90,15 +91,15 @@ func writeInputLayout(h hash.Hash, layout d3d12.D3D12_INPUT_LAYOUT_DESC) { } func writeInputElement(h hash.Hash, el *d3d12.D3D12_INPUT_ELEMENT_DESC) { - var header [16]byte + var header [12]byte binary.LittleEndian.PutUint32(header[0:4], el.InputSlot) binary.LittleEndian.PutUint32(header[4:8], el.AlignedByteOffset) binary.LittleEndian.PutUint32(header[8:12], uint32(el.Format)) - header[12] = byte(el.InputSlotClass) - header[13] = byte(el.InstanceDataStepRate & 0xFF) - header[14] = byte((el.InstanceDataStepRate >> 8) & 0xFF) - header[15] = byte((el.InstanceDataStepRate >> 16) & 0xFF) _, _ = h.Write(header[:]) + var classStep [5]byte + classStep[0] = byte(el.InputSlotClass) + binary.LittleEndian.PutUint32(classStep[1:5], el.InstanceDataStepRate) + _, _ = h.Write(classStep[:]) if el.SemanticName != nil { name := unsafe.String(el.SemanticName, findNull(el.SemanticName)) _, _ = h.Write([]byte(name)) @@ -156,20 +157,33 @@ func writeRasterizer(h hash.Hash, rs *d3d12.D3D12_RASTERIZER_DESC) { buf[4] = boolByte(rs.MultisampleEnable) buf[5] = boolByte(rs.AntialiasedLineEnable) binary.LittleEndian.PutUint32(buf[8:12], uint32(rs.DepthBias)) - binary.LittleEndian.PutUint32(buf[12:16], uint32(rs.DepthBiasClamp)) - binary.LittleEndian.PutUint32(buf[16:20], uint32(rs.SlopeScaledDepthBias)) + binary.LittleEndian.PutUint32(buf[12:16], math.Float32bits(rs.DepthBiasClamp)) + binary.LittleEndian.PutUint32(buf[16:20], math.Float32bits(rs.SlopeScaledDepthBias)) binary.LittleEndian.PutUint32(buf[20:24], rs.ForcedSampleCount) buf[24] = byte(rs.ConservativeRaster) _, _ = h.Write(buf[:25]) } func writeDepthStencil(h hash.Hash, ds *d3d12.D3D12_DEPTH_STENCIL_DESC) { - var buf [24]byte + var buf [6]byte buf[0] = boolByte(ds.DepthEnable) buf[1] = byte(ds.DepthWriteMask) buf[2] = byte(ds.DepthFunc) buf[3] = boolByte(ds.StencilEnable) - _, _ = h.Write(buf[:4]) + buf[4] = ds.StencilReadMask + buf[5] = ds.StencilWriteMask + _, _ = h.Write(buf[:]) + writeStencilOp(h, &ds.FrontFace) + writeStencilOp(h, &ds.BackFace) +} + +func writeStencilOp(h hash.Hash, op *d3d12.D3D12_DEPTH_STENCILOP_DESC) { + var buf [4]byte + buf[0] = byte(op.StencilFailOp) + buf[1] = byte(op.StencilDepthFailOp) + buf[2] = byte(op.StencilPassOp) + buf[3] = byte(op.StencilFunc) + _, _ = h.Write(buf[:]) } func writeBlend(h hash.Hash, blend *d3d12.D3D12_BLEND_DESC) { diff --git a/hal/vulkan/adapter.go b/hal/vulkan/adapter.go index f914d4f..60bce06 100644 --- a/hal/vulkan/adapter.go +++ b/hal/vulkan/adapter.go @@ -181,12 +181,7 @@ func (a *Adapter) open(requestedQueueFamily *uint32) (hal.OpenDevice, error) { return hal.OpenDevice{}, fmt.Errorf("vulkan: failed to initialize allocator: %w", err) } - if err := dev.initPipelineCache(&a.properties); err != nil { - dev.allocator.Destroy() - dev.timelineFence.destroy(dev.cmds, dev.handle) - vkDestroyDevice(device, nil) - return hal.OpenDevice{}, fmt.Errorf("vulkan: failed to initialize pipeline cache: %w", err) - } + dev.initPipelineCache(&a.properties) // VK-SYNC-001: Create relay semaphores for GPU-side submission ordering. // This ensures consecutive vkQueueSubmit calls execute in order on the GPU, diff --git a/hal/vulkan/pipeline_cache.go b/hal/vulkan/pipeline_cache.go index 6dece22..a8c41f0 100644 --- a/hal/vulkan/pipeline_cache.go +++ b/hal/vulkan/pipeline_cache.go @@ -6,7 +6,7 @@ package vulkan import ( - "fmt" + "runtime" "unsafe" "github.com/gogpu/wgpu/hal" @@ -17,8 +17,10 @@ import ( const vulkanPipelineCacheFile = "pipeline.cache" // initPipelineCache creates or restores the device-wide VkPipelineCache from disk. +// Pipeline cache is a performance optimization — failures are logged and the +// device continues with pipelineCache = 0 (VK_NULL_HANDLE). // Reference: wgpu-hal/src/vulkan/device.rs (pipeline cache create/restore). -func (d *Device) initPipelineCache(props *vk.PhysicalDeviceProperties) error { +func (d *Device) initPipelineCache(props *vk.PhysicalDeviceProperties) { adapterKey := pipelinecache.VulkanAdapterKey( props.VendorID, props.DeviceID, @@ -27,7 +29,10 @@ func (d *Device) initPipelineCache(props *vk.PhysicalDeviceProperties) error { ) cachePath, err := pipelinecache.UserCachePath("vulkan", adapterKey, vulkanPipelineCacheFile) if err != nil { - return err + hal.Logger().Warn("vulkan: pipeline cache disabled, cache directory unavailable", + "error", err, + ) + return } d.pipelineCachePath = cachePath @@ -50,6 +55,7 @@ func (d *Device) initPipelineCache(props *vk.PhysicalDeviceProperties) error { var cache vk.PipelineCache result := d.cmds.CreatePipelineCache(d.handle, &createInfo, nil, &cache) + runtime.KeepAlive(initialData) if result == vk.ErrorInitializationFailed && len(initialData) > 0 { hal.Logger().Info("vulkan: stale pipeline cache rejected by driver, recreating empty", "path", cachePath, @@ -60,7 +66,10 @@ func (d *Device) initPipelineCache(props *vk.PhysicalDeviceProperties) error { result = d.cmds.CreatePipelineCache(d.handle, &createInfo, nil, &cache) } if result != vk.Success { - return fmt.Errorf("vulkan: vkCreatePipelineCache failed: %d", result) + hal.Logger().Warn("vulkan: vkCreatePipelineCache failed, pipeline cache disabled", + "result", result, + ) + return } d.pipelineCache = cache @@ -70,7 +79,6 @@ func (d *Device) initPipelineCache(props *vk.PhysicalDeviceProperties) error { "bytes", len(initialData), ) } - return nil } // savePipelineCache persists the VkPipelineCache blob to disk. @@ -86,8 +94,9 @@ func (d *Device) savePipelineCache() { } data := make([]byte, size) - dataPtr := uintptr(unsafe.Pointer(&data[0])) - result = d.cmds.GetPipelineCacheData(d.handle, d.pipelineCache, &size, &dataPtr) + bufPtr := (*uintptr)(unsafe.Pointer(&data[0])) + result = d.cmds.GetPipelineCacheData(d.handle, d.pipelineCache, &size, bufPtr) + runtime.KeepAlive(data) if result != vk.Success { hal.Logger().Warn("vulkan: failed to read pipeline cache data", "result", result,