diff --git a/examples/timestamp_query/main.go b/examples/timestamp_query/main.go index ef89a8b..b705be9 100644 --- a/examples/timestamp_query/main.go +++ b/examples/timestamp_query/main.go @@ -222,20 +222,23 @@ fn main(@builtin(global_invocation_id) global_id: vec3) { log.Printf("unmap staging buffer: %v", err) } - // Calculate elapsed ticks. - // Note: To convert to nanoseconds, you need the timestamp period - // from the adapter (typically 1 ns/tick, but varies by GPU). + // Calculate elapsed ticks and convert them with the period reported by + // the queue. The period varies by GPU and backend. elapsedTicks := endTimestamp - startTimestamp - - // Assume 1 ns/tick (common on most GPUs). - const assumedPeriodNs = 1.0 - elapsedNs := float64(elapsedTicks) * assumedPeriodNs + period := queue.GetTimestampPeriod() + if period <= 0 { + const fallbackPeriod = float32(1) + log.Printf("timestamp period unavailable; using %.1f ns/tick fallback", fallbackPeriod) + period = fallbackPeriod + } + elapsedNs := float64(elapsedTicks) * float64(period) fmt.Printf("Timestamp Query Results:\n") fmt.Printf(" Start timestamp: %d ticks\n", startTimestamp) fmt.Printf(" End timestamp: %d ticks\n", endTimestamp) fmt.Printf(" Elapsed ticks: %d\n", elapsedTicks) - fmt.Printf(" GPU execution time: ~%.3f ms (assuming 1 ns/tick)\n", elapsedNs/1_000_000) + fmt.Printf(" Timestamp period: %.6f ns/tick\n", period) + fmt.Printf(" GPU execution time: ~%.3f ms\n", elapsedNs/1_000_000) fmt.Println() fmt.Println("GPU timestamp queries provide accurate profiling!") diff --git a/go.mod b/go.mod index 343a367..d78de1e 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/go-webgpu/webgpu go 1.25.0 -require github.com/go-webgpu/goffi v0.6.0 +require github.com/go-webgpu/goffi v0.6.2 require golang.org/x/sys v0.47.0 diff --git a/go.sum b/go.sum index 050135c..80075bf 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/go-webgpu/goffi v0.6.0 h1:dTBwfzj8CZUW0w0fgeMaYGBrIktK7nzfjMsnSpkSt4Y= -github.com/go-webgpu/goffi v0.6.0/go.mod h1:wfoxNsJkU+5RFbV1kNN1kunhc1lFHuJKK3zpgx08/uM= +github.com/go-webgpu/goffi v0.6.2 h1:xuMaUbqsNQ/xiyy5UwAKZb5vQZUDg9QRCrJIpHJaXSE= +github.com/go-webgpu/goffi v0.6.2/go.mod h1:wfoxNsJkU+5RFbV1kNN1kunhc1lFHuJKK3zpgx08/uM= github.com/gogpu/gputypes v0.5.1 h1:X38OPcP6umQqqubzzJYL6Nm1tXHSNQj6TRSAoxdAJmg= github.com/gogpu/gputypes v0.5.1/go.mod h1:cnXrDMwTpWTvJLW1Vreop3PcT6a2YP/i3s91rPaOavw= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= diff --git a/wgpu/command.go b/wgpu/command.go index 993d90e..1849f8e 100644 --- a/wgpu/command.go +++ b/wgpu/command.go @@ -478,6 +478,28 @@ func (q *Queue) Submit(commands ...*CommandBuffer) (uint64, error) { return uint64(submissionIndex), nil } +// GetTimestampPeriod returns the duration of one GPU timestamp tick in +// nanoseconds, as reported by wgpu-native. It returns zero for a nil or +// released queue, or when the native call is unavailable. +func (q *Queue) GetTimestampPeriod() float32 { + if q == nil || q.handle == 0 { + return 0 + } + if procQueueGetTimestampPeriod == nil { + return 0 + } + + proc, ok := procQueueGetTimestampPeriod.(float32Proc) + if !ok { + return 0 + } + period, err := proc.CallFloat32(q.handle) + if err != nil { + return 0 + } + return period +} + // Release releases the command buffer. func (cb *CommandBuffer) Release() { if cb.handle != 0 { diff --git a/wgpu/loader.go b/wgpu/loader.go index f2d2890..fe66a20 100644 --- a/wgpu/loader.go +++ b/wgpu/loader.go @@ -18,3 +18,11 @@ type Proc interface { // Arguments are passed as uintptr to match C ABI. Call(args ...uintptr) (uintptr, uintptr, error) } + +// float32Proc is implemented by platform loaders for procedures whose native +// return type is float32. Proc.Call intentionally keeps the existing integer +// return contract for the rest of the WebGPU API; this narrow interface lets +// those procedures use the platform's floating-point return ABI instead. +type float32Proc interface { + CallFloat32(args ...uintptr) (float32, error) +} diff --git a/wgpu/loader_float.go b/wgpu/loader_float.go new file mode 100644 index 0000000..3b63336 --- /dev/null +++ b/wgpu/loader_float.go @@ -0,0 +1,62 @@ +package wgpu + +import ( + "fmt" + "syscall" + "unsafe" + + "github.com/go-webgpu/goffi/ffi" + "github.com/go-webgpu/goffi/types" +) + +type float32CallOps struct { + prepare func( + *types.CallInterface, + types.CallingConvention, + *types.TypeDescriptor, + []*types.TypeDescriptor, + ) error + call func( + *types.CallInterface, + unsafe.Pointer, + unsafe.Pointer, + []unsafe.Pointer, + ) (syscall.Errno, error) +} + +var nativeFloat32CallOps = float32CallOps{ + prepare: ffi.PrepareCallInterface, + call: ffi.CallFunction, +} + +// callFloat32 invokes a native function using the platform's scalar +// floating-point return convention. +func callFloat32( + ops float32CallOps, + name string, + convention types.CallingConvention, + fn unsafe.Pointer, + args ...uintptr, +) (float32, error) { + // TODO: cache float-return CIFs once each procedure's call shape is stable. + argTypes := make([]*types.TypeDescriptor, len(args)) + for i := range argTypes { + argTypes[i] = types.PointerTypeDescriptor + } + + var cif types.CallInterface + if err := ops.prepare(&cif, convention, types.FloatTypeDescriptor, argTypes); err != nil { + return 0, fmt.Errorf("wgpu: failed to prepare CIF for %s: %w", name, err) + } + + argPtrs := make([]unsafe.Pointer, len(args)) + for i := range args { + argPtrs[i] = unsafe.Pointer(&args[i]) + } + + var result float32 + if _, err := ops.call(&cif, fn, unsafe.Pointer(&result), argPtrs); err != nil { + return 0, fmt.Errorf("wgpu: call to %s failed: %w", name, err) + } + return result, nil +} diff --git a/wgpu/loader_float_test.go b/wgpu/loader_float_test.go new file mode 100644 index 0000000..c46b4ba --- /dev/null +++ b/wgpu/loader_float_test.go @@ -0,0 +1,105 @@ +package wgpu + +import ( + "errors" + "syscall" + "testing" + "unsafe" + + "github.com/go-webgpu/goffi/types" +) + +func TestABIFloat32Call(t *testing.T) { + const argument = uintptr(0x1234) + functionToken := byte(1) + function := unsafe.Pointer(&functionToken) + + t.Run("success", func(t *testing.T) { + ops := float32CallOps{ + prepare: func( + _ *types.CallInterface, + convention types.CallingConvention, + returnType *types.TypeDescriptor, + argTypes []*types.TypeDescriptor, + ) error { + if convention != types.UnixCallingConvention { + t.Fatalf("calling convention = %v, want Unix", convention) + } + if returnType != types.FloatTypeDescriptor { + t.Fatalf("return type = %v, want float32", returnType) + } + if len(argTypes) != 1 || argTypes[0] != types.PointerTypeDescriptor { + t.Fatalf("argument types = %v, want one pointer", argTypes) + } + return nil + }, + call: func( + _ *types.CallInterface, + gotFunction unsafe.Pointer, + result unsafe.Pointer, + args []unsafe.Pointer, + ) (syscall.Errno, error) { + if gotFunction != function { + t.Fatalf("function = %p, want %p", gotFunction, function) + } + if len(args) != 1 || *(*uintptr)(args[0]) != argument { + t.Fatalf("arguments do not preserve %#x", argument) + } + *(*float32)(result) = 0.125 + return 0, nil + }, + } + + got, err := callFloat32(ops, "testFloat32", types.UnixCallingConvention, function, argument) + if err != nil { + t.Fatal(err) + } + if got != 0.125 { + t.Fatalf("result = %v, want 0.125", got) + } + }) + + t.Run("prepare error", func(t *testing.T) { + wantErr := errors.New("prepare failed") + ops := float32CallOps{ + prepare: func( + *types.CallInterface, + types.CallingConvention, + *types.TypeDescriptor, + []*types.TypeDescriptor, + ) error { + return wantErr + }, + } + + if _, err := callFloat32(ops, "testFloat32", types.UnixCallingConvention, function, argument); !errors.Is(err, wantErr) { + t.Fatalf("error = %v, want wrapped %v", err, wantErr) + } + }) + + t.Run("call error", func(t *testing.T) { + wantErr := errors.New("call failed") + ops := float32CallOps{ + prepare: func( + *types.CallInterface, + types.CallingConvention, + *types.TypeDescriptor, + []*types.TypeDescriptor, + ) error { + return nil + }, + call: func( + *types.CallInterface, + unsafe.Pointer, + unsafe.Pointer, + []unsafe.Pointer, + ) (syscall.Errno, error) { + return 0, wantErr + }, + } + + if _, err := callFloat32(ops, "testFloat32", types.UnixCallingConvention, function, argument); !errors.Is(err, wantErr) { + t.Fatalf("error = %v, want wrapped %v", err, wantErr) + } + }) +} diff --git a/wgpu/loader_unix.go b/wgpu/loader_unix.go index 3ddd56d..e5b3ce7 100644 --- a/wgpu/loader_unix.go +++ b/wgpu/loader_unix.go @@ -125,3 +125,15 @@ func (u *unixProc) Call(args ...uintptr) (uintptr, uintptr, error) { // This matches Windows syscall.LazyProc.Call signature return result, 0, nil } + +// CallFloat32 invokes a procedure whose native return type is float32. +// +// Proc.Call uses a pointer-sized return descriptor for the rest of the API. +// A float32 is returned in the platform floating-point register instead, so +// it needs a call interface prepared with FloatTypeDescriptor. +func (u *unixProc) CallFloat32(args ...uintptr) (float32, error) { + if u.fnPtr == nil { + return 0, fmt.Errorf("wgpu: failed to get symbol %s from %s", u.name, u.lib.name) + } + return callFloat32(nativeFloat32CallOps, u.name, types.UnixCallingConvention, u.fnPtr, args...) +} diff --git a/wgpu/loader_windows.go b/wgpu/loader_windows.go index d96582e..c113cd3 100644 --- a/wgpu/loader_windows.go +++ b/wgpu/loader_windows.go @@ -4,6 +4,9 @@ package wgpu import ( "syscall" + "unsafe" + + "github.com/go-webgpu/goffi/types" ) // windowsLibrary wraps syscall.LazyDLL to implement the Library interface. @@ -40,3 +43,19 @@ func (w *windowsLibrary) NewProc(name string) Proc { func (w *windowsProc) Call(args ...uintptr) (uintptr, uintptr, error) { return w.proc.Call(args...) } + +// CallFloat32 invokes a float32-returning procedure through goffi so the +// Windows x64 ABI reads XMM0. syscall.LazyProc.Call only exposes integer +// return registers and therefore cannot safely call this signature. +func (w *windowsProc) CallFloat32(args ...uintptr) (float32, error) { + if err := w.proc.Find(); err != nil { + return 0, err + } + return callFloat32( + nativeFloat32CallOps, + w.proc.Name, + types.WindowsCallingConvention, + unsafe.Pointer(w.proc.Addr()), + args..., + ) +} diff --git a/wgpu/queue_timestamp_period_test.go b/wgpu/queue_timestamp_period_test.go new file mode 100644 index 0000000..d8f6668 --- /dev/null +++ b/wgpu/queue_timestamp_period_test.go @@ -0,0 +1,144 @@ +package wgpu + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" +) + +type timestampPeriodProcStub struct { + handle uintptr + period float32 + err error +} + +func (p *timestampPeriodProcStub) Call(args ...uintptr) (uintptr, uintptr, error) { + return 0, 0, nil +} + +func (p *timestampPeriodProcStub) CallFloat32(args ...uintptr) (float32, error) { + if len(args) == 1 { + p.handle = args[0] + } + return p.period, p.err +} + +func TestABIQueueGetTimestampPeriodNullGuard(t *testing.T) { + var nilQueue *Queue + if got := nilQueue.GetTimestampPeriod(); got != 0 { + t.Fatalf("nil queue timestamp period = %v, want 0", got) + } + + releasedQueue := &Queue{} + if got := releasedQueue.GetTimestampPeriod(); got != 0 { + t.Fatalf("released queue timestamp period = %v, want 0", got) + } +} + +type integerOnlyTimestampPeriodProc struct{} + +func (*integerOnlyTimestampPeriodProc) Call(args ...uintptr) (uintptr, uintptr, error) { + return 0, 0, nil +} + +func TestABIQueueGetTimestampPeriodRequiresFloat32Proc(t *testing.T) { + original := procQueueGetTimestampPeriod + procQueueGetTimestampPeriod = &integerOnlyTimestampPeriodProc{} + defer func() { procQueueGetTimestampPeriod = original }() + + if got := (&Queue{handle: 0x1234}).GetTimestampPeriod(); got != 0 { + t.Fatalf("queue timestamp period = %v, want 0 for integer-only proc", got) + } +} + +func TestABIQueueGetTimestampPeriodUnavailable(t *testing.T) { + original := procQueueGetTimestampPeriod + procQueueGetTimestampPeriod = nil + defer func() { procQueueGetTimestampPeriod = original }() + + if got := (&Queue{handle: 0x1234}).GetTimestampPeriod(); got != 0 { + t.Fatalf("queue timestamp period = %v, want 0 for unavailable proc", got) + } +} + +func TestABIQueueGetTimestampPeriodUsesNativeFloat32(t *testing.T) { + stub := ×tampPeriodProcStub{period: 0.125} + original := procQueueGetTimestampPeriod + procQueueGetTimestampPeriod = stub + defer func() { procQueueGetTimestampPeriod = original }() + + got := (&Queue{handle: 0x1234}).GetTimestampPeriod() + if got != stub.period { + t.Fatalf("queue timestamp period = %v, want %v", got, stub.period) + } + if stub.handle != 0x1234 { + t.Fatalf("queue handle = %#x, want %#x", stub.handle, uintptr(0x1234)) + } +} + +func TestABIQueueGetTimestampPeriodCallError(t *testing.T) { + stub := ×tampPeriodProcStub{period: 0.125, err: errors.New("call failed")} + original := procQueueGetTimestampPeriod + procQueueGetTimestampPeriod = stub + defer func() { procQueueGetTimestampPeriod = original }() + + if got := (&Queue{handle: 0x1234}).GetTimestampPeriod(); got != 0 { + t.Fatalf("queue timestamp period = %v, want 0 after call error", got) + } +} + +func TestABIQueueGetTimestampPeriodDynamicLibrary(t *testing.T) { + path := os.Getenv("WGPU_TIMESTAMP_PERIOD_ABI_STUB_LIBRARY") + if path == "" { + path = buildTimestampPeriodABILibrary(t) + } + library, err := loadLibrary(path) + if err != nil { + t.Fatal(err) + } + defer closeTimestampPeriodABILibrary(t, library) + proc, ok := library.NewProc("wgpuQueueGetTimestampPeriod").(float32Proc) + if !ok { + t.Fatal("platform loader does not implement float32 return calls") + } + got, err := proc.CallFloat32(0x1234) + if err != nil { + t.Fatal(err) + } + if got != 0.125 { + t.Fatalf("dynamic library timestamp period = %v, want 0.125", got) + } +} + +func buildTimestampPeriodABILibrary(t *testing.T) string { + t.Helper() + + name := "libtimestamp_period.so" + args := []string{"-shared", "-fPIC", "-O2"} + switch runtime.GOOS { + case "darwin": + name = "libtimestamp_period.dylib" + case "windows": + name = "timestamp_period.dll" + args = []string{"-shared", "-O2"} + } + + outputPath := filepath.Join(t.TempDir(), name) + args = append(args, "-o", outputPath, filepath.Join("testdata", "timestamp_period.c")) + compiler := os.Getenv("CC") + if compiler == "" { + compiler = "gcc" + } + output, err := exec.Command(compiler, args...).CombinedOutput() + if err == nil { + return outputPath + } + if os.Getenv("CI") != "" { + t.Fatalf("build timestamp-period ABI library: %v\n%s", err, output) + } + t.Skipf("timestamp-period ABI library requires a C compiler: %v", err) + return "" +} diff --git a/wgpu/queue_timestamp_period_unix_test.go b/wgpu/queue_timestamp_period_unix_test.go new file mode 100644 index 0000000..1e341f5 --- /dev/null +++ b/wgpu/queue_timestamp_period_unix_test.go @@ -0,0 +1,30 @@ +//go:build linux || darwin + +package wgpu + +import ( + "testing" + + "github.com/go-webgpu/goffi/ffi" +) + +func closeTimestampPeriodABILibrary(t *testing.T, library Library) { + t.Helper() + unixLibrary, ok := library.(*unixLibrary) + if !ok { + t.Fatalf("timestamp-period ABI library has type %T, want *unixLibrary", library) + } + if err := ffi.FreeLibrary(unixLibrary.handle); err != nil { + t.Fatalf("close timestamp-period ABI library: %v", err) + } +} + +func TestABIFloat32ProcMissingSymbol(t *testing.T) { + proc := &unixProc{ + lib: &unixLibrary{name: "missing-library"}, + name: "missing-symbol", + } + if _, err := proc.CallFloat32(0x1234); err == nil { + t.Fatal("missing symbol returned no error") + } +} diff --git a/wgpu/queue_timestamp_period_windows_test.go b/wgpu/queue_timestamp_period_windows_test.go new file mode 100644 index 0000000..2f165c8 --- /dev/null +++ b/wgpu/queue_timestamp_period_windows_test.go @@ -0,0 +1,19 @@ +//go:build windows + +package wgpu + +import ( + "syscall" + "testing" +) + +func closeTimestampPeriodABILibrary(t *testing.T, library Library) { + t.Helper() + windowsLibrary, ok := library.(*windowsLibrary) + if !ok { + t.Fatalf("timestamp-period ABI library has type %T, want *windowsLibrary", library) + } + if err := syscall.FreeLibrary(syscall.Handle(windowsLibrary.dll.Handle())); err != nil { + t.Fatalf("close timestamp-period ABI library: %v", err) + } +} diff --git a/wgpu/testdata/timestamp_period.c b/wgpu/testdata/timestamp_period.c new file mode 100644 index 0000000..089fc34 --- /dev/null +++ b/wgpu/testdata/timestamp_period.c @@ -0,0 +1,9 @@ +#if defined(_WIN32) +#define EXPORT __declspec(dllexport) +#else +#define EXPORT __attribute__((visibility("default"))) +#endif + +EXPORT float wgpuQueueGetTimestampPeriod(const void *queue) { + return queue ? 0.125f : 0.0f; +} diff --git a/wgpu/wgpu.go b/wgpu/wgpu.go index d41a6bc..9963c2e 100644 --- a/wgpu/wgpu.go +++ b/wgpu/wgpu.go @@ -42,8 +42,9 @@ var ( procDeviceGetLimits Proc // Function pointers - Queue - procQueueRelease Proc - procQueueWriteBuffer Proc + procQueueRelease Proc + procQueueWriteBuffer Proc + procQueueGetTimestampPeriod Proc // Function pointers - Instance (global) procGetInstanceFeatures Proc // v29: global instance feature query @@ -277,6 +278,7 @@ func initSymbols() { // Queue procQueueRelease = wgpuLib.NewProc("wgpuQueueRelease") procQueueWriteBuffer = wgpuLib.NewProc("wgpuQueueWriteBuffer") + procQueueGetTimestampPeriod = wgpuLib.NewProc("wgpuQueueGetTimestampPeriod") // Instance global queries (v29) procGetInstanceFeatures = wgpuLib.NewProc("wgpuGetInstanceFeatures")