perf(vulkan): capture frames without per-frame allocations
Each capture slot keeps one sync file descriptor for its lifetime: every export is moved onto it with dup3 and the source closed, so a capture makes no os.File. The Vulkan structs of the capture path (fence poll, sync export, command recording, HDR pass) live in the renderer instead of escaping to the heap. EndCapture no longer closes the descriptor; freeCaptureSlot closes it once. Adds TestCaptureCycleAllocations (0 allocs, SDR and HDR, no descriptor growth) to perf-check.
- commit
- 3213e44c329f6e58e9d8d2cb7d67611daf154e8e
- author
- brice · · 2026-10-14 20:58 UTC
- committer
- brice · · 2026-10-14 21:39 UTC
- parent
- 3d39787
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +308 -62
M Makefile +1 -1
| @@ -69,7 +69,7 @@ adapter-imports: | |||
| 69 | 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | |
| 70 | 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | |
| 71 | 71 | perf-check: | |
| 72 | - | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCastFrameAllocations|TestCastProducerAllocations)$$' -count=1 | |
| 72 | + | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations)$$' -count=1 | |
| 73 | 73 | check: vet test arch fakes-check log-check perf-check | |
| 74 | 74 | ||
| 75 | 75 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | |
| @@ -69,7 +69,7 @@ adapter-imports: | |||||
| 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | 69 | [ -z "$$bad" ] || { echo "$$bad"; echo 'adapter imports another adapter: use ports or a shared library (Makefile ADAPTER_LIBS)' >&2; exit 1; } | ||
| 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | 70 | # Fast, mandatory guards for tiled content publications and SHM copies. | ||
| 71 | perf-check: | 71 | perf-check: | ||
| 72 | - | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCastFrameAllocations|TestCastProducerAllocations)$$' -count=1 | 72 | + | CGO_ENABLED=0 go test ./internal/core ./internal/app ./internal/adapters/drm ./internal/adapters/headless ./internal/adapters/vulkan ./internal/adapters/wayland ./internal/adapters/outputkit/capture ./internal/adapters/screencast -run '^(TestAnimateAllocations|TestPublishAllocations|TestPublishSubscribersAllocations|TestPublishStashSlideAllocations|TestPublishOverviewAllocations|TestPublishLeavingAllocations|TestOutputRoutingAllocations|TestReportSeenAllocations|TestFrameLifecycleTransitionsAllocations|TestFullscreenShownAllocations|TestFrameDecisionAllocations|TestCommitFrameAllocations|TestColorPipelineAllocations|TestShownBySnapshotAllocations|TestFlipShowsAllocations|TestMRUNoteFocusDoesNotAllocateWhenSettled|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestRenderSteadyStateDMABufAllocations|TestSceneOpaqueRegionAllocations|TestSceneDeltaAllocations|TestAccountFlipAllocations|TestFlipDoneAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestCapturedCommitApplyAllocations|TestCapturedViewportApplyAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget|TestReleaseHeldAllocations|TestCommitDamageAllocations|TestEmitContentAllocations|TestPresentationPushAllocations|TestAnimatingFrameSendAllocations|TestContentBatchCycleAllocations|TestWriteMappedAllocations|TestCapturedForwardAllocations|TestCastFrameAllocations|TestCastProducerAllocations|TestCaptureCycleAllocations)$$' -count=1 |
| 73 | check: vet test arch fakes-check log-check perf-check | 73 | check: vet test arch fakes-check log-check perf-check | ||
| 74 | 74 | ||||
| 75 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | 75 | # On-demand SDR/HDR colour check with examples/testpattern on a headless | ||
M internal/adapters/vulkan/capture.go +97 -44
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/neferwl/internal/ports" | |
| 13 | 13 | vk "github.com/bnema/purego-vulkan/vulkan" | |
| 14 | + | "golang.org/x/sys/unix" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 17 | //go:generate glslc -O --target-env=vulkan1.3 shaders/capture_sdr.frag -o shaders/capture_sdr.frag.spv | |
| @@ -107,6 +108,27 @@ func encodeSRGB8(f float32) byte { | |||
| 107 | 108 | return byte(math.Round(v * 255)) | |
| 108 | 109 | } | |
| 109 | 110 | ||
| 111 | + | // captureScratch holds the structs a capture passes to Vulkan by pointer. | |
| 112 | + | // The purego call makes a pointer to a local escape, so a local would be a | |
| 113 | + | // heap allocation per capture; these live in the Renderer instead (the | |
| 114 | + | // owner goroutine uses them, and Vulkan reads each only during its call). | |
| 115 | + | // Every field is set in full before use. Sequential uses may share a field; | |
| 116 | + | // the sync export uses Renderer.call.getFD and .fd. | |
| 117 | + | type captureScratch struct { | |
| 118 | + | cmd vk.CommandBuffer | |
| 119 | + | fence vk.Fence | |
| 120 | + | begin vk.CommandBufferBeginInfo | |
| 121 | + | submit vk.SubmitInfo | |
| 122 | + | // The copy (recordCaptureCopy). | |
| 123 | + | region vk.BufferImageCopy | |
| 124 | + | hb vk.BufferMemoryBarrier | |
| 125 | + | b vk.ImageMemoryBarrier | |
| 126 | + | // The HDR conversion pass (recordCaptureHDR). | |
| 127 | + | hdrIn, hdrB vk.ImageMemoryBarrier | |
| 128 | + | attachment vk.RenderingAttachmentInfo | |
| 129 | + | info vk.RenderingInfo | |
| 130 | + | } | |
| 131 | + | ||
| 110 | 132 | // captureSync is the fence and semaphore surface of a capture slot: the | |
| 111 | 133 | // non-blocking poll, the sync file export and the semaphore replacement | |
| 112 | 134 | // (a seam for tests; the device implements it). | |
| @@ -128,18 +150,21 @@ type deviceSync struct{ r *Renderer } | |||
| 128 | 150 | func (d deviceSync) fenceStatus(f vk.Fence) vk.Result { | |
| 129 | 151 | // A zero timeout only reports the fence state (the bindings lack | |
| 130 | 152 | // vkGetFenceStatus): Timeout while the copy runs. | |
| 131 | - | return d.r.dd.WaitForFences(d.r.device, 1, &f, 1, 0) | |
| 153 | + | d.r.capCall.fence = f | |
| 154 | + | return d.r.dd.WaitForFences(d.r.device, 1, &d.r.capCall.fence, 1, 0) | |
| 132 | 155 | } | |
| 133 | 156 | ||
| 134 | 157 | func (d deviceSync) resetFence(f vk.Fence) vk.Result { | |
| 135 | - | return d.r.dd.ResetFences(d.r.device, 1, &f) | |
| 158 | + | d.r.capCall.fence = f | |
| 159 | + | return d.r.dd.ResetFences(d.r.device, 1, &d.r.capCall.fence) | |
| 136 | 160 | } | |
| 137 | 161 | ||
| 138 | 162 | func (d deviceSync) exportSyncFD(s vk.Semaphore) (int32, vk.Result) { | |
| 139 | - | var fd int32 | |
| 140 | - | get := vk.SemaphoreGetFdInfoKHR{SType: vk.StructureTypeSemaphoreGetFDInfoKHR, Semaphore: s, HandleType: vk.ExternalSemaphoreHandleTypeSyncFDBit} | |
| 141 | - | res := d.r.dd.GetSemaphoreFdKHR(d.r.device, &get, &fd) | |
| 142 | - | return fd, res | |
| 163 | + | sc := &d.r.call | |
| 164 | + | sc.fd = -1 | |
| 165 | + | sc.getFD = vk.SemaphoreGetFdInfoKHR{SType: vk.StructureTypeSemaphoreGetFDInfoKHR, Semaphore: s, HandleType: vk.ExternalSemaphoreHandleTypeSyncFDBit} | |
| 166 | + | res := d.r.dd.GetSemaphoreFdKHR(d.r.device, &sc.getFD, &sc.fd) | |
| 167 | + | return sc.fd, res | |
| 143 | 168 | } | |
| 144 | 169 | ||
| 145 | 170 | func (d deviceSync) newSemaphore() (vk.Semaphore, error) { | |
| @@ -170,14 +195,43 @@ type captureSlot struct { | |||
| 170 | 195 | // handle kept after EndCapture is invalid by contract (its next | |
| 171 | 196 | // state belongs to another lease). | |
| 172 | 197 | frame captureFrame | |
| 198 | + | // file wraps syncFD, the slot's sync file descriptor, which the slot | |
| 199 | + | // owns from the first export to freeCaptureSlot. Every later export | |
| 200 | + | // is moved onto the same descriptor number (dup3), so a capture | |
| 201 | + | // makes no os.File and the descriptor is closed exactly once. Nil | |
| 202 | + | // until the first export. | |
| 203 | + | file *os.File | |
| 204 | + | syncFD int | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // adoptSync makes fd, a sync file the caller owns, the slot's. It always | |
| 208 | + | // takes ownership of fd: fd is closed on failure and after the move. The | |
| 209 | + | // slot's previous sync file is replaced, so a lease must have ended first. | |
| 210 | + | func (s *captureSlot) adoptSync(fd int) error { | |
| 211 | + | if s.file == nil { | |
| 212 | + | f := os.NewFile(uintptr(fd), "neferwl-capture") | |
| 213 | + | if f == nil { | |
| 214 | + | return fmt.Errorf("invalid capture sync file %d", fd) | |
| 215 | + | } | |
| 216 | + | s.file, s.syncFD = f, fd | |
| 217 | + | return nil | |
| 218 | + | } | |
| 219 | + | err := unix.Dup3(fd, s.syncFD, unix.O_CLOEXEC) | |
| 220 | + | _ = unix.Close(fd) | |
| 221 | + | if err != nil { | |
| 222 | + | return fmt.Errorf("move capture sync file: %w", err) | |
| 223 | + | } | |
| 224 | + | return nil | |
| 173 | 225 | } | |
| 174 | 226 | ||
| 175 | 227 | // captureFrame is the lease handed to the worker (ports.CaptureFrame). | |
| 176 | 228 | // Its fields are written by the owner before the handoff and read by the | |
| 177 | 229 | // worker; the owner touches them again only after EndCapture. | |
| 178 | 230 | type captureFrame struct { | |
| 179 | - | r *Renderer | |
| 180 | - | slot *captureSlot | |
| 231 | + | r *Renderer | |
| 232 | + | slot *captureSlot | |
| 233 | + | // done is the slot's sync file (nil: nothing to wait for). The slot | |
| 234 | + | // owns it: valid until EndCapture, never closed by the reader. | |
| 181 | 235 | done *os.File | |
| 182 | 236 | pixels []byte // the slot's mapping, width*height*4 | |
| 183 | 237 | w, h int | |
| @@ -301,9 +355,9 @@ func (r *Renderer) captureSync() captureSync { | |||
| 301 | 355 | func (r *Renderer) freeCaptureSlot(s *captureSlot) { | |
| 302 | 356 | d := r.dd | |
| 303 | 357 | s.frame.valid = false | |
| 304 | - | if s.frame.done != nil { | |
| 305 | - | s.frame.done.Close() | |
| 306 | - | s.frame.done = nil | |
| 358 | + | s.frame.done = nil | |
| 359 | + | if s.file != nil { | |
| 360 | + | _ = s.file.Close() | |
| 307 | 361 | } | |
| 308 | 362 | if s.done != 0 { | |
| 309 | 363 | r.captureSync().destroySemaphore(s.done) | |
| @@ -425,13 +479,14 @@ func (r *Renderer) beginCapture(debug bool) (*captureFrame, error) { | |||
| 425 | 479 | if s == nil { | |
| 426 | 480 | return nil, ports.ErrCaptureBusy | |
| 427 | 481 | } | |
| 428 | - | d := r.dd | |
| 429 | - | cmd := s.cmd | |
| 482 | + | d, sc := r.dd, &r.capCall | |
| 483 | + | sc.cmd = s.cmd | |
| 484 | + | cmd := sc.cmd | |
| 430 | 485 | if err := checked("vkResetCommandBuffer(capture)", d.ResetCommandBuffer(cmd, 0)); err != nil { | |
| 431 | 486 | return nil, err | |
| 432 | 487 | } | |
| 433 | - | begin := vk.CommandBufferBeginInfo{SType: vk.StructureTypeCommandBufferBeginInfo, Flags: vk.CommandBufferUsageOneTimeSubmitBit} | |
| 434 | - | if err := checked("vkBeginCommandBuffer(capture)", d.BeginCommandBuffer(cmd, &begin)); err != nil { | |
| 488 | + | sc.begin = vk.CommandBufferBeginInfo{SType: vk.StructureTypeCommandBufferBeginInfo, Flags: vk.CommandBufferUsageOneTimeSubmitBit} | |
| 489 | + | if err := checked("vkBeginCommandBuffer(capture)", d.BeginCommandBuffer(cmd, &sc.begin)); err != nil { | |
| 435 | 490 | return nil, err | |
| 436 | 491 | } | |
| 437 | 492 | if hdr { | |
| @@ -445,14 +500,14 @@ func (r *Renderer) beginCapture(debug bool) (*captureFrame, error) { | |||
| 445 | 500 | // After the frame on the same queue: the copy reads what it drew, and | |
| 446 | 501 | // the next frame's first barrier (transfer source stage) orders its | |
| 447 | 502 | // writes after this read. | |
| 448 | - | submit := vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &cmd} | |
| 503 | + | sc.submit = vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &sc.cmd} | |
| 449 | 504 | if !debug { | |
| 450 | 505 | // The export below takes the signal; a debug capture waits on | |
| 451 | 506 | // the fence only, so the semaphore must not be signalled (a | |
| 452 | 507 | // binary semaphore is signalled once, then waited or exported). | |
| 453 | - | submit.SignalSemaphoreCount, submit.SignalSemaphores = 1, &s.done | |
| 508 | + | sc.submit.SignalSemaphoreCount, sc.submit.SignalSemaphores = 1, &s.done | |
| 454 | 509 | } | |
| 455 | - | if err := checked("vkQueueSubmit(capture)", d.QueueSubmit(r.queue, 1, &submit, s.fence)); err != nil { | |
| 510 | + | if err := checked("vkQueueSubmit(capture)", d.QueueSubmit(r.queue, 1, &sc.submit, s.fence)); err != nil { | |
| 456 | 511 | return nil, err | |
| 457 | 512 | } | |
| 458 | 513 | s.pending = true | |
| @@ -469,10 +524,10 @@ func (r *Renderer) beginCapture(debug bool) (*captureFrame, error) { | |||
| 469 | 524 | } | |
| 470 | 525 | if fd >= 0 { | |
| 471 | 526 | // -1: already signalled at export, nothing to wait for. | |
| 472 | - | f.done = os.NewFile(uintptr(fd), "neferwl-capture") | |
| 473 | - | if f.done == nil { | |
| 474 | - | return nil, fmt.Errorf("invalid capture sync file %d", fd) | |
| 527 | + | if err := s.adoptSync(int(fd)); err != nil { | |
| 528 | + | return nil, err | |
| 475 | 529 | } | |
| 530 | + | f.done = s.file | |
| 476 | 531 | } | |
| 477 | 532 | } | |
| 478 | 533 | s.leased, f.valid = true, true | |
| @@ -487,10 +542,7 @@ func (r *Renderer) EndCapture(cf ports.CaptureFrame) { | |||
| 487 | 542 | return | |
| 488 | 543 | } | |
| 489 | 544 | f.valid = false | |
| 490 | - | if f.done != nil { | |
| 491 | - | f.done.Close() | |
| 492 | - | f.done = nil | |
| 493 | - | } | |
| 545 | + | f.done = nil // the slot keeps the descriptor for its next lease | |
| 494 | 546 | f.slot.leased = false | |
| 495 | 547 | } | |
| 496 | 548 | ||
| @@ -566,20 +618,20 @@ func (r *Renderer) readHDRTarget(read func(words []uint32)) error { | |||
| 566 | 618 | // Exported targets are borrowed from the display without a layout | |
| 567 | 619 | // change: the display may be reading them at the same time. | |
| 568 | 620 | func (r *Renderer) recordCaptureCopy(cmd vk.CommandBuffer, t *target, layout vk.ImageLayout, s *captureSlot) { | |
| 569 | - | d := r.dd | |
| 621 | + | d, sc := r.dd, &r.capCall | |
| 570 | 622 | if t.exported { | |
| 571 | 623 | // Acquire from the display. The frame released the image at | |
| 572 | 624 | // its color attachment stage: this acquire waits for that. | |
| 573 | - | b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: t.image, SubresourceRange: colorRange} | |
| 574 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageAllCommandsBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b) | |
| 625 | + | sc.b = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: t.image, SubresourceRange: colorRange} | |
| 626 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageAllCommandsBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &sc.b) | |
| 575 | 627 | } | |
| 576 | - | region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}} | |
| 577 | - | d.CmdCopyImageToBuffer(cmd, t.image, layout, s.buffer, 1, ®ion) | |
| 578 | - | hb := vk.BufferMemoryBarrier{SType: vk.StructureTypeBufferMemoryBarrier, SrcAccessMask: vk.AccessTransferWriteBit, DstAccessMask: vk.AccessHostReadBit, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Buffer: s.buffer, Size: wholeSize} | |
| 579 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageHostBit, 0, 0, nil, 1, &hb, 0, nil) | |
| 628 | + | sc.region = vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}} | |
| 629 | + | d.CmdCopyImageToBuffer(cmd, t.image, layout, s.buffer, 1, &sc.region) | |
| 630 | + | sc.hb = vk.BufferMemoryBarrier{SType: vk.StructureTypeBufferMemoryBarrier, SrcAccessMask: vk.AccessTransferWriteBit, DstAccessMask: vk.AccessHostReadBit, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Buffer: s.buffer, Size: wholeSize} | |
| 631 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageHostBit, 0, 0, nil, 1, &sc.hb, 0, nil) | |
| 580 | 632 | if t.exported { | |
| 581 | - | b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: r.family, DstQueueFamilyIndex: vk.QueueFamilyForeignEXT, Image: t.image, SubresourceRange: colorRange} | |
| 582 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b) | |
| 633 | + | sc.b = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: r.family, DstQueueFamilyIndex: vk.QueueFamilyForeignEXT, Image: t.image, SubresourceRange: colorRange} | |
| 634 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &sc.b) | |
| 583 | 635 | } | |
| 584 | 636 | } | |
| 585 | 637 | ||
| @@ -587,25 +639,26 @@ func (r *Renderer) recordCaptureCopy(cmd vk.CommandBuffer, t *target, layout vk. | |||
| 587 | 639 | // the sRGB8 capture image with the capture pass, then copies that. hdrOwn | |
| 588 | 640 | // is put back in TRANSFER_SRC, as the next frame expects. | |
| 589 | 641 | func (r *Renderer) recordCaptureHDR(cmd vk.CommandBuffer, s *captureSlot) { | |
| 590 | - | d, p, out := r.dd, &r.capturePass, &r.captureSDR | |
| 591 | - | in := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, DstAccessMask: vk.AccessShaderReadBit, OldLayout: vk.ImageLayoutTransferSrcOptimal, NewLayout: vk.ImageLayoutShaderReadOnlyOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: r.hdrOwn.image, SubresourceRange: colorRange} | |
| 592 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, &in) | |
| 642 | + | d, p, out, sc := r.dd, &r.capturePass, &r.captureSDR, &r.capCall | |
| 643 | + | in, b := &sc.hdrIn, &sc.hdrB | |
| 644 | + | *in = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, DstAccessMask: vk.AccessShaderReadBit, OldLayout: vk.ImageLayoutTransferSrcOptimal, NewLayout: vk.ImageLayoutShaderReadOnlyOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: r.hdrOwn.image, SubresourceRange: colorRange} | |
| 645 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, in) | |
| 593 | 646 | // The previous capture read the image as a transfer source. | |
| 594 | - | b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, DstAccessMask: vk.AccessColorAttachmentWriteBit, OldLayout: vk.ImageLayoutUndefined, NewLayout: vk.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: out.image, SubresourceRange: colorRange} | |
| 595 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, &b) | |
| 596 | - | attachment := vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: out.view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpDontCare, StoreOp: vk.AttachmentStoreOpStore} | |
| 597 | - | info := vk.RenderingInfo{SType: vk.StructureTypeRenderingInfo, RenderArea: vk.Rect2D{Extent: vk.Extent2D{Width: uint32(r.width), Height: uint32(r.height)}}, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: &attachment} | |
| 598 | - | d.CmdBeginRendering(cmd, &info) | |
| 647 | + | *b = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, DstAccessMask: vk.AccessColorAttachmentWriteBit, OldLayout: vk.ImageLayoutUndefined, NewLayout: vk.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: out.image, SubresourceRange: colorRange} | |
| 648 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, b) | |
| 649 | + | sc.attachment = vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: out.view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpDontCare, StoreOp: vk.AttachmentStoreOpStore} | |
| 650 | + | sc.info = vk.RenderingInfo{SType: vk.StructureTypeRenderingInfo, RenderArea: vk.Rect2D{Extent: vk.Extent2D{Width: uint32(r.width), Height: uint32(r.height)}}, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: &sc.attachment} | |
| 651 | + | d.CmdBeginRendering(cmd, &sc.info) | |
| 599 | 652 | d.CmdBindPipeline(cmd, vk.PipelineBindPointGraphics, p.pipeline) | |
| 600 | 653 | d.CmdBindDescriptorSets(cmd, vk.PipelineBindPointGraphics, p.layout, 0, 1, &p.set, 0, nil) | |
| 601 | 654 | d.CmdDraw(cmd, 3, 1, 0, 0) | |
| 602 | 655 | d.CmdEndRendering(cmd) | |
| 603 | 656 | in.SrcAccessMask, in.DstAccessMask = vk.AccessShaderReadBit, vk.AccessTransferReadBit | |
| 604 | 657 | in.OldLayout, in.NewLayout = vk.ImageLayoutShaderReadOnlyOptimal, vk.ImageLayoutTransferSrcOptimal | |
| 605 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &in) | |
| 658 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, in) | |
| 606 | 659 | b.SrcAccessMask, b.DstAccessMask = vk.AccessColorAttachmentWriteBit, vk.AccessTransferReadBit | |
| 607 | 660 | b.OldLayout, b.NewLayout = vk.ImageLayoutColorAttachmentOptimal, vk.ImageLayoutTransferSrcOptimal | |
| 608 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b) | |
| 661 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, b) | |
| 609 | 662 | r.recordCaptureCopy(cmd, out, vk.ImageLayoutTransferSrcOptimal, s) | |
| 610 | 663 | } | |
| 611 | 664 | ||
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | 11 | ||||
| 12 | "github.com/bnema/neferwl/internal/ports" | 12 | "github.com/bnema/neferwl/internal/ports" | ||
| 13 | vk "github.com/bnema/purego-vulkan/vulkan" | 13 | vk "github.com/bnema/purego-vulkan/vulkan" | ||
| 14 | + | "golang.org/x/sys/unix" | |||
| 14 | ) | 15 | ) | ||
| 15 | 16 | ||||
| 16 | //go:generate glslc -O --target-env=vulkan1.3 shaders/capture_sdr.frag -o shaders/capture_sdr.frag.spv | 17 | //go:generate glslc -O --target-env=vulkan1.3 shaders/capture_sdr.frag -o shaders/capture_sdr.frag.spv | ||
| @@ -107,6 +108,27 @@ func encodeSRGB8(f float32) byte { | |||||
| 107 | return byte(math.Round(v * 255)) | 108 | return byte(math.Round(v * 255)) | ||
| 108 | } | 109 | } | ||
| 109 | 110 | ||||
| 111 | + | // captureScratch holds the structs a capture passes to Vulkan by pointer. | |||
| 112 | + | // The purego call makes a pointer to a local escape, so a local would be a | |||
| 113 | + | // heap allocation per capture; these live in the Renderer instead (the | |||
| 114 | + | // owner goroutine uses them, and Vulkan reads each only during its call). | |||
| 115 | + | // Every field is set in full before use. Sequential uses may share a field; | |||
| 116 | + | // the sync export uses Renderer.call.getFD and .fd. | |||
| 117 | + | type captureScratch struct { | |||
| 118 | + | cmd vk.CommandBuffer | |||
| 119 | + | fence vk.Fence | |||
| 120 | + | begin vk.CommandBufferBeginInfo | |||
| 121 | + | submit vk.SubmitInfo | |||
| 122 | + | // The copy (recordCaptureCopy). | |||
| 123 | + | region vk.BufferImageCopy | |||
| 124 | + | hb vk.BufferMemoryBarrier | |||
| 125 | + | b vk.ImageMemoryBarrier | |||
| 126 | + | // The HDR conversion pass (recordCaptureHDR). | |||
| 127 | + | hdrIn, hdrB vk.ImageMemoryBarrier | |||
| 128 | + | attachment vk.RenderingAttachmentInfo | |||
| 129 | + | info vk.RenderingInfo | |||
| 130 | + | } | |||
| 131 | + | ||||
| 110 | // captureSync is the fence and semaphore surface of a capture slot: the | 132 | // captureSync is the fence and semaphore surface of a capture slot: the | ||
| 111 | // non-blocking poll, the sync file export and the semaphore replacement | 133 | // non-blocking poll, the sync file export and the semaphore replacement | ||
| 112 | // (a seam for tests; the device implements it). | 134 | // (a seam for tests; the device implements it). | ||
| @@ -128,18 +150,21 @@ type deviceSync struct{ r *Renderer } | |||||
| 128 | func (d deviceSync) fenceStatus(f vk.Fence) vk.Result { | 150 | func (d deviceSync) fenceStatus(f vk.Fence) vk.Result { | ||
| 129 | // A zero timeout only reports the fence state (the bindings lack | 151 | // A zero timeout only reports the fence state (the bindings lack | ||
| 130 | // vkGetFenceStatus): Timeout while the copy runs. | 152 | // vkGetFenceStatus): Timeout while the copy runs. | ||
| 131 | - | return d.r.dd.WaitForFences(d.r.device, 1, &f, 1, 0) | 153 | + | d.r.capCall.fence = f |
| 154 | + | return d.r.dd.WaitForFences(d.r.device, 1, &d.r.capCall.fence, 1, 0) | |||
| 132 | } | 155 | } | ||
| 133 | 156 | ||||
| 134 | func (d deviceSync) resetFence(f vk.Fence) vk.Result { | 157 | func (d deviceSync) resetFence(f vk.Fence) vk.Result { | ||
| 135 | - | return d.r.dd.ResetFences(d.r.device, 1, &f) | 158 | + | d.r.capCall.fence = f |
| 159 | + | return d.r.dd.ResetFences(d.r.device, 1, &d.r.capCall.fence) | |||
| 136 | } | 160 | } | ||
| 137 | 161 | ||||
| 138 | func (d deviceSync) exportSyncFD(s vk.Semaphore) (int32, vk.Result) { | 162 | func (d deviceSync) exportSyncFD(s vk.Semaphore) (int32, vk.Result) { | ||
| 139 | - | var fd int32 | 163 | + | sc := &d.r.call |
| 140 | - | get := vk.SemaphoreGetFdInfoKHR{SType: vk.StructureTypeSemaphoreGetFDInfoKHR, Semaphore: s, HandleType: vk.ExternalSemaphoreHandleTypeSyncFDBit} | 164 | + | sc.fd = -1 |
| 141 | - | res := d.r.dd.GetSemaphoreFdKHR(d.r.device, &get, &fd) | 165 | + | sc.getFD = vk.SemaphoreGetFdInfoKHR{SType: vk.StructureTypeSemaphoreGetFDInfoKHR, Semaphore: s, HandleType: vk.ExternalSemaphoreHandleTypeSyncFDBit} |
| 142 | - | return fd, res | 166 | + | res := d.r.dd.GetSemaphoreFdKHR(d.r.device, &sc.getFD, &sc.fd) |
| 167 | + | return sc.fd, res | |||
| 143 | } | 168 | } | ||
| 144 | 169 | ||||
| 145 | func (d deviceSync) newSemaphore() (vk.Semaphore, error) { | 170 | func (d deviceSync) newSemaphore() (vk.Semaphore, error) { | ||
| @@ -170,14 +195,43 @@ type captureSlot struct { | |||||
| 170 | // handle kept after EndCapture is invalid by contract (its next | 195 | // handle kept after EndCapture is invalid by contract (its next | ||
| 171 | // state belongs to another lease). | 196 | // state belongs to another lease). | ||
| 172 | frame captureFrame | 197 | frame captureFrame | ||
| 198 | + | // file wraps syncFD, the slot's sync file descriptor, which the slot | |||
| 199 | + | // owns from the first export to freeCaptureSlot. Every later export | |||
| 200 | + | // is moved onto the same descriptor number (dup3), so a capture | |||
| 201 | + | // makes no os.File and the descriptor is closed exactly once. Nil | |||
| 202 | + | // until the first export. | |||
| 203 | + | file *os.File | |||
| 204 | + | syncFD int | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | // adoptSync makes fd, a sync file the caller owns, the slot's. It always | |||
| 208 | + | // takes ownership of fd: fd is closed on failure and after the move. The | |||
| 209 | + | // slot's previous sync file is replaced, so a lease must have ended first. | |||
| 210 | + | func (s *captureSlot) adoptSync(fd int) error { | |||
| 211 | + | if s.file == nil { | |||
| 212 | + | f := os.NewFile(uintptr(fd), "neferwl-capture") | |||
| 213 | + | if f == nil { | |||
| 214 | + | return fmt.Errorf("invalid capture sync file %d", fd) | |||
| 215 | + | } | |||
| 216 | + | s.file, s.syncFD = f, fd | |||
| 217 | + | return nil | |||
| 218 | + | } | |||
| 219 | + | err := unix.Dup3(fd, s.syncFD, unix.O_CLOEXEC) | |||
| 220 | + | _ = unix.Close(fd) | |||
| 221 | + | if err != nil { | |||
| 222 | + | return fmt.Errorf("move capture sync file: %w", err) | |||
| 223 | + | } | |||
| 224 | + | return nil | |||
| 173 | } | 225 | } | ||
| 174 | 226 | ||||
| 175 | // captureFrame is the lease handed to the worker (ports.CaptureFrame). | 227 | // captureFrame is the lease handed to the worker (ports.CaptureFrame). | ||
| 176 | // Its fields are written by the owner before the handoff and read by the | 228 | // Its fields are written by the owner before the handoff and read by the | ||
| 177 | // worker; the owner touches them again only after EndCapture. | 229 | // worker; the owner touches them again only after EndCapture. | ||
| 178 | type captureFrame struct { | 230 | type captureFrame struct { | ||
| 179 | - | r *Renderer | 231 | + | r *Renderer |
| 180 | - | slot *captureSlot | 232 | + | slot *captureSlot |
| 233 | + | // done is the slot's sync file (nil: nothing to wait for). The slot | |||
| 234 | + | // owns it: valid until EndCapture, never closed by the reader. | |||
| 181 | done *os.File | 235 | done *os.File | ||
| 182 | pixels []byte // the slot's mapping, width*height*4 | 236 | pixels []byte // the slot's mapping, width*height*4 | ||
| 183 | w, h int | 237 | w, h int | ||
| @@ -301,9 +355,9 @@ func (r *Renderer) captureSync() captureSync { | |||||
| 301 | func (r *Renderer) freeCaptureSlot(s *captureSlot) { | 355 | func (r *Renderer) freeCaptureSlot(s *captureSlot) { | ||
| 302 | d := r.dd | 356 | d := r.dd | ||
| 303 | s.frame.valid = false | 357 | s.frame.valid = false | ||
| 304 | - | if s.frame.done != nil { | 358 | + | s.frame.done = nil |
| 305 | - | s.frame.done.Close() | 359 | + | if s.file != nil { |
| 306 | - | s.frame.done = nil | 360 | + | _ = s.file.Close() |
| 307 | } | 361 | } | ||
| 308 | if s.done != 0 { | 362 | if s.done != 0 { | ||
| 309 | r.captureSync().destroySemaphore(s.done) | 363 | r.captureSync().destroySemaphore(s.done) | ||
| @@ -425,13 +479,14 @@ func (r *Renderer) beginCapture(debug bool) (*captureFrame, error) { | |||||
| 425 | if s == nil { | 479 | if s == nil { | ||
| 426 | return nil, ports.ErrCaptureBusy | 480 | return nil, ports.ErrCaptureBusy | ||
| 427 | } | 481 | } | ||
| 428 | - | d := r.dd | 482 | + | d, sc := r.dd, &r.capCall |
| 429 | - | cmd := s.cmd | 483 | + | sc.cmd = s.cmd |
| 484 | + | cmd := sc.cmd | |||
| 430 | if err := checked("vkResetCommandBuffer(capture)", d.ResetCommandBuffer(cmd, 0)); err != nil { | 485 | if err := checked("vkResetCommandBuffer(capture)", d.ResetCommandBuffer(cmd, 0)); err != nil { | ||
| 431 | return nil, err | 486 | return nil, err | ||
| 432 | } | 487 | } | ||
| 433 | - | begin := vk.CommandBufferBeginInfo{SType: vk.StructureTypeCommandBufferBeginInfo, Flags: vk.CommandBufferUsageOneTimeSubmitBit} | 488 | + | sc.begin = vk.CommandBufferBeginInfo{SType: vk.StructureTypeCommandBufferBeginInfo, Flags: vk.CommandBufferUsageOneTimeSubmitBit} |
| 434 | - | if err := checked("vkBeginCommandBuffer(capture)", d.BeginCommandBuffer(cmd, &begin)); err != nil { | 489 | + | if err := checked("vkBeginCommandBuffer(capture)", d.BeginCommandBuffer(cmd, &sc.begin)); err != nil { |
| 435 | return nil, err | 490 | return nil, err | ||
| 436 | } | 491 | } | ||
| 437 | if hdr { | 492 | if hdr { | ||
| @@ -445,14 +500,14 @@ func (r *Renderer) beginCapture(debug bool) (*captureFrame, error) { | |||||
| 445 | // After the frame on the same queue: the copy reads what it drew, and | 500 | // After the frame on the same queue: the copy reads what it drew, and | ||
| 446 | // the next frame's first barrier (transfer source stage) orders its | 501 | // the next frame's first barrier (transfer source stage) orders its | ||
| 447 | // writes after this read. | 502 | // writes after this read. | ||
| 448 | - | submit := vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &cmd} | 503 | + | sc.submit = vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &sc.cmd} |
| 449 | if !debug { | 504 | if !debug { | ||
| 450 | // The export below takes the signal; a debug capture waits on | 505 | // The export below takes the signal; a debug capture waits on | ||
| 451 | // the fence only, so the semaphore must not be signalled (a | 506 | // the fence only, so the semaphore must not be signalled (a | ||
| 452 | // binary semaphore is signalled once, then waited or exported). | 507 | // binary semaphore is signalled once, then waited or exported). | ||
| 453 | - | submit.SignalSemaphoreCount, submit.SignalSemaphores = 1, &s.done | 508 | + | sc.submit.SignalSemaphoreCount, sc.submit.SignalSemaphores = 1, &s.done |
| 454 | } | 509 | } | ||
| 455 | - | if err := checked("vkQueueSubmit(capture)", d.QueueSubmit(r.queue, 1, &submit, s.fence)); err != nil { | 510 | + | if err := checked("vkQueueSubmit(capture)", d.QueueSubmit(r.queue, 1, &sc.submit, s.fence)); err != nil { |
| 456 | return nil, err | 511 | return nil, err | ||
| 457 | } | 512 | } | ||
| 458 | s.pending = true | 513 | s.pending = true | ||
| @@ -469,10 +524,10 @@ func (r *Renderer) beginCapture(debug bool) (*captureFrame, error) { | |||||
| 469 | } | 524 | } | ||
| 470 | if fd >= 0 { | 525 | if fd >= 0 { | ||
| 471 | // -1: already signalled at export, nothing to wait for. | 526 | // -1: already signalled at export, nothing to wait for. | ||
| 472 | - | f.done = os.NewFile(uintptr(fd), "neferwl-capture") | 527 | + | if err := s.adoptSync(int(fd)); err != nil { |
| 473 | - | if f.done == nil { | 528 | + | return nil, err |
| 474 | - | return nil, fmt.Errorf("invalid capture sync file %d", fd) | |||
| 475 | } | 529 | } | ||
| 530 | + | f.done = s.file | |||
| 476 | } | 531 | } | ||
| 477 | } | 532 | } | ||
| 478 | s.leased, f.valid = true, true | 533 | s.leased, f.valid = true, true | ||
| @@ -487,10 +542,7 @@ func (r *Renderer) EndCapture(cf ports.CaptureFrame) { | |||||
| 487 | return | 542 | return | ||
| 488 | } | 543 | } | ||
| 489 | f.valid = false | 544 | f.valid = false | ||
| 490 | - | if f.done != nil { | 545 | + | f.done = nil // the slot keeps the descriptor for its next lease |
| 491 | - | f.done.Close() | |||
| 492 | - | f.done = nil | |||
| 493 | - | } | |||
| 494 | f.slot.leased = false | 546 | f.slot.leased = false | ||
| 495 | } | 547 | } | ||
| 496 | 548 | ||||
| @@ -566,20 +618,20 @@ func (r *Renderer) readHDRTarget(read func(words []uint32)) error { | |||||
| 566 | // Exported targets are borrowed from the display without a layout | 618 | // Exported targets are borrowed from the display without a layout | ||
| 567 | // change: the display may be reading them at the same time. | 619 | // change: the display may be reading them at the same time. | ||
| 568 | func (r *Renderer) recordCaptureCopy(cmd vk.CommandBuffer, t *target, layout vk.ImageLayout, s *captureSlot) { | 620 | func (r *Renderer) recordCaptureCopy(cmd vk.CommandBuffer, t *target, layout vk.ImageLayout, s *captureSlot) { | ||
| 569 | - | d := r.dd | 621 | + | d, sc := r.dd, &r.capCall |
| 570 | if t.exported { | 622 | if t.exported { | ||
| 571 | // Acquire from the display. The frame released the image at | 623 | // Acquire from the display. The frame released the image at | ||
| 572 | // its color attachment stage: this acquire waits for that. | 624 | // its color attachment stage: this acquire waits for that. | ||
| 573 | - | b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: t.image, SubresourceRange: colorRange} | 625 | + | sc.b = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, DstAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: vk.QueueFamilyForeignEXT, DstQueueFamilyIndex: r.family, Image: t.image, SubresourceRange: colorRange} |
| 574 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageAllCommandsBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b) | 626 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageAllCommandsBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &sc.b) |
| 575 | } | 627 | } | ||
| 576 | - | region := vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}} | 628 | + | sc.region = vk.BufferImageCopy{ImageSubresource: vk.ImageSubresourceLayers{AspectMask: vk.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vk.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}} |
| 577 | - | d.CmdCopyImageToBuffer(cmd, t.image, layout, s.buffer, 1, ®ion) | 629 | + | d.CmdCopyImageToBuffer(cmd, t.image, layout, s.buffer, 1, &sc.region) |
| 578 | - | hb := vk.BufferMemoryBarrier{SType: vk.StructureTypeBufferMemoryBarrier, SrcAccessMask: vk.AccessTransferWriteBit, DstAccessMask: vk.AccessHostReadBit, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Buffer: s.buffer, Size: wholeSize} | 630 | + | sc.hb = vk.BufferMemoryBarrier{SType: vk.StructureTypeBufferMemoryBarrier, SrcAccessMask: vk.AccessTransferWriteBit, DstAccessMask: vk.AccessHostReadBit, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Buffer: s.buffer, Size: wholeSize} |
| 579 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageHostBit, 0, 0, nil, 1, &hb, 0, nil) | 631 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageHostBit, 0, 0, nil, 1, &sc.hb, 0, nil) |
| 580 | if t.exported { | 632 | if t.exported { | ||
| 581 | - | b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: r.family, DstQueueFamilyIndex: vk.QueueFamilyForeignEXT, Image: t.image, SubresourceRange: colorRange} | 633 | + | sc.b = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, OldLayout: layout, NewLayout: layout, SrcQueueFamilyIndex: r.family, DstQueueFamilyIndex: vk.QueueFamilyForeignEXT, Image: t.image, SubresourceRange: colorRange} |
| 582 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &b) | 634 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageBottomOfPipeBit, 0, 0, nil, 0, nil, 1, &sc.b) |
| 583 | } | 635 | } | ||
| 584 | } | 636 | } | ||
| 585 | 637 | ||||
| @@ -587,25 +639,26 @@ func (r *Renderer) recordCaptureCopy(cmd vk.CommandBuffer, t *target, layout vk. | |||||
| 587 | // the sRGB8 capture image with the capture pass, then copies that. hdrOwn | 639 | // the sRGB8 capture image with the capture pass, then copies that. hdrOwn | ||
| 588 | // is put back in TRANSFER_SRC, as the next frame expects. | 640 | // is put back in TRANSFER_SRC, as the next frame expects. | ||
| 589 | func (r *Renderer) recordCaptureHDR(cmd vk.CommandBuffer, s *captureSlot) { | 641 | func (r *Renderer) recordCaptureHDR(cmd vk.CommandBuffer, s *captureSlot) { | ||
| 590 | - | d, p, out := r.dd, &r.capturePass, &r.captureSDR | 642 | + | d, p, out, sc := r.dd, &r.capturePass, &r.captureSDR, &r.capCall |
| 591 | - | in := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, DstAccessMask: vk.AccessShaderReadBit, OldLayout: vk.ImageLayoutTransferSrcOptimal, NewLayout: vk.ImageLayoutShaderReadOnlyOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: r.hdrOwn.image, SubresourceRange: colorRange} | 643 | + | in, b := &sc.hdrIn, &sc.hdrB |
| 592 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, &in) | 644 | + | *in = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, DstAccessMask: vk.AccessShaderReadBit, OldLayout: vk.ImageLayoutTransferSrcOptimal, NewLayout: vk.ImageLayoutShaderReadOnlyOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: r.hdrOwn.image, SubresourceRange: colorRange} |
| 645 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageFragmentShaderBit, 0, 0, nil, 0, nil, 1, in) | |||
| 593 | // The previous capture read the image as a transfer source. | 646 | // The previous capture read the image as a transfer source. | ||
| 594 | - | b := vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, DstAccessMask: vk.AccessColorAttachmentWriteBit, OldLayout: vk.ImageLayoutUndefined, NewLayout: vk.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: out.image, SubresourceRange: colorRange} | 647 | + | *b = vk.ImageMemoryBarrier{SType: vk.StructureTypeImageMemoryBarrier, SrcAccessMask: vk.AccessTransferReadBit, DstAccessMask: vk.AccessColorAttachmentWriteBit, OldLayout: vk.ImageLayoutUndefined, NewLayout: vk.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: queueFamilyIgnored, DstQueueFamilyIndex: queueFamilyIgnored, Image: out.image, SubresourceRange: colorRange} |
| 595 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, &b) | 648 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, b) |
| 596 | - | attachment := vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: out.view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpDontCare, StoreOp: vk.AttachmentStoreOpStore} | 649 | + | sc.attachment = vk.RenderingAttachmentInfo{SType: vk.StructureTypeRenderingAttachmentInfo, ImageView: out.view, ImageLayout: vk.ImageLayoutColorAttachmentOptimal, LoadOp: vk.AttachmentLoadOpDontCare, StoreOp: vk.AttachmentStoreOpStore} |
| 597 | - | info := vk.RenderingInfo{SType: vk.StructureTypeRenderingInfo, RenderArea: vk.Rect2D{Extent: vk.Extent2D{Width: uint32(r.width), Height: uint32(r.height)}}, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: &attachment} | 650 | + | sc.info = vk.RenderingInfo{SType: vk.StructureTypeRenderingInfo, RenderArea: vk.Rect2D{Extent: vk.Extent2D{Width: uint32(r.width), Height: uint32(r.height)}}, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: &sc.attachment} |
| 598 | - | d.CmdBeginRendering(cmd, &info) | 651 | + | d.CmdBeginRendering(cmd, &sc.info) |
| 599 | d.CmdBindPipeline(cmd, vk.PipelineBindPointGraphics, p.pipeline) | 652 | d.CmdBindPipeline(cmd, vk.PipelineBindPointGraphics, p.pipeline) | ||
| 600 | d.CmdBindDescriptorSets(cmd, vk.PipelineBindPointGraphics, p.layout, 0, 1, &p.set, 0, nil) | 653 | d.CmdBindDescriptorSets(cmd, vk.PipelineBindPointGraphics, p.layout, 0, 1, &p.set, 0, nil) | ||
| 601 | d.CmdDraw(cmd, 3, 1, 0, 0) | 654 | d.CmdDraw(cmd, 3, 1, 0, 0) | ||
| 602 | d.CmdEndRendering(cmd) | 655 | d.CmdEndRendering(cmd) | ||
| 603 | in.SrcAccessMask, in.DstAccessMask = vk.AccessShaderReadBit, vk.AccessTransferReadBit | 656 | in.SrcAccessMask, in.DstAccessMask = vk.AccessShaderReadBit, vk.AccessTransferReadBit | ||
| 604 | in.OldLayout, in.NewLayout = vk.ImageLayoutShaderReadOnlyOptimal, vk.ImageLayoutTransferSrcOptimal | 657 | in.OldLayout, in.NewLayout = vk.ImageLayoutShaderReadOnlyOptimal, vk.ImageLayoutTransferSrcOptimal | ||
| 605 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &in) | 658 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageFragmentShaderBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, in) |
| 606 | b.SrcAccessMask, b.DstAccessMask = vk.AccessColorAttachmentWriteBit, vk.AccessTransferReadBit | 659 | b.SrcAccessMask, b.DstAccessMask = vk.AccessColorAttachmentWriteBit, vk.AccessTransferReadBit | ||
| 607 | b.OldLayout, b.NewLayout = vk.ImageLayoutColorAttachmentOptimal, vk.ImageLayoutTransferSrcOptimal | 660 | b.OldLayout, b.NewLayout = vk.ImageLayoutColorAttachmentOptimal, vk.ImageLayoutTransferSrcOptimal | ||
| 608 | - | d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, &b) | 661 | + | d.CmdPipelineBarrier(cmd, vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageTransferBit, 0, 0, nil, 0, nil, 1, b) |
| 609 | r.recordCaptureCopy(cmd, out, vk.ImageLayoutTransferSrcOptimal, s) | 662 | r.recordCaptureCopy(cmd, out, vk.ImageLayoutTransferSrcOptimal, s) | ||
| 610 | } | 663 | } | ||
| 611 | 664 | ||||
M internal/adapters/vulkan/capture_test.go +206 -16
| @@ -291,8 +291,7 @@ func TestCaptureCurve(t *testing.T) { | |||
| 291 | 291 | } | |
| 292 | 292 | } | |
| 293 | 293 | ||
| 294 | - | // The lease object of a slot is reused: a steady state of captures | |
| 295 | - | // allocates only the sync file, and a stale handle reports invalid. | |
| 294 | + | // The lease object of a slot is reused, and a stale handle reports invalid. | |
| 296 | 295 | func TestCaptureLeaseReused(t *testing.T) { | |
| 297 | 296 | r, err := New(8, 8) | |
| 298 | 297 | if err != nil { | |
| @@ -309,24 +308,215 @@ func TestCaptureLeaseReused(t *testing.T) { | |||
| 309 | 308 | t.Fatal("lease object not reused") | |
| 310 | 309 | } | |
| 311 | 310 | r.EndCapture(second) | |
| 312 | - | cycle := func() { | |
| 313 | - | cf, err := r.BeginCapture() | |
| 314 | - | if err != nil { | |
| 315 | - | t.Fatal(err) | |
| 311 | + | dst := make([]byte, 4) | |
| 312 | + | if err := second.Read(image.Rect(0, 0, 1, 1), dst, 4); err == nil { | |
| 313 | + | t.Fatal("stale lease read accepted") | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | // pollSyncFile waits for a sync file without allocating (syncfile.Wait | |
| 318 | + | // builds its poll set per call). | |
| 319 | + | func pollSyncFile(f *os.File, fds []unix.PollFd) error { | |
| 320 | + | fds[0] = unix.PollFd{Fd: int32(f.Fd()), Events: unix.POLLIN} | |
| 321 | + | for { | |
| 322 | + | n, err := unix.Poll(fds, 1000) | |
| 323 | + | switch { | |
| 324 | + | case errors.Is(err, unix.EINTR): | |
| 325 | + | case err != nil: | |
| 326 | + | return err | |
| 327 | + | case n > 0 && fds[0].Revents&unix.POLLIN != 0: | |
| 328 | + | return nil | |
| 329 | + | case n > 0: | |
| 330 | + | return errors.New("sync file poll error") | |
| 331 | + | default: | |
| 332 | + | return errors.New("sync file poll timeout") | |
| 316 | 333 | } | |
| 317 | - | if done := cf.Done(); done != nil { | |
| 318 | - | if err := syncfile.Wait(context.Background(), done); err != nil { | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | // openFDs counts the process's open descriptors. | |
| 338 | + | func openFDs(t *testing.T) int { | |
| 339 | + | t.Helper() | |
| 340 | + | ents, err := os.ReadDir("/proc/self/fd") | |
| 341 | + | if err != nil { | |
| 342 | + | t.Skipf("no /proc/self/fd: %v", err) | |
| 343 | + | } | |
| 344 | + | return len(ents) | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | // The renderer capture cycle (lease, wait for the copy, read the rows, | |
| 348 | + | // return the lease) allocates nothing once the slots exist, on the SDR and | |
| 349 | + | // the HDR conversion path, and holds a constant number of descriptors: the | |
| 350 | + | // slot keeps its sync file for every lease. No mock stands in the measured | |
| 351 | + | // closure. | |
| 352 | + | func TestCaptureCycleAllocations(t *testing.T) { | |
| 353 | + | for _, tc := range []struct { | |
| 354 | + | name string | |
| 355 | + | new func(*testing.T) *Renderer | |
| 356 | + | }{ | |
| 357 | + | {"sdr", func(t *testing.T) *Renderer { | |
| 358 | + | r, err := New(16, 8) | |
| 359 | + | if err != nil { | |
| 360 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 361 | + | } | |
| 362 | + | t.Cleanup(r.Close) | |
| 363 | + | return r | |
| 364 | + | }}, | |
| 365 | + | {"hdr", hdrTestRenderer}, | |
| 366 | + | } { | |
| 367 | + | t.Run(tc.name, func(t *testing.T) { | |
| 368 | + | r := tc.new(t) | |
| 369 | + | requireCaptureSync(t, r) | |
| 370 | + | if err := render(r, ports.Scene{Seq: 1, Background: "#3060c0"}, nil); err != nil { | |
| 319 | 371 | t.Fatal(err) | |
| 320 | 372 | } | |
| 373 | + | dst := make([]byte, 16*8*4) | |
| 374 | + | fds := make([]unix.PollFd, 1) | |
| 375 | + | region := image.Rect(0, 0, 16, 8) | |
| 376 | + | var cycleErr error | |
| 377 | + | cycle := func() { | |
| 378 | + | cf, err := r.BeginCapture() | |
| 379 | + | if err != nil { | |
| 380 | + | cycleErr = err | |
| 381 | + | return | |
| 382 | + | } | |
| 383 | + | if done := cf.Done(); done != nil { | |
| 384 | + | if err := pollSyncFile(done, fds); err != nil { | |
| 385 | + | cycleErr = err | |
| 386 | + | } | |
| 387 | + | } | |
| 388 | + | if err := cf.Read(region, dst, 16*4); err != nil { | |
| 389 | + | cycleErr = err | |
| 390 | + | } | |
| 391 | + | r.EndCapture(cf) | |
| 392 | + | } | |
| 393 | + | cycle() // creates the slots, the capture pass and the sync files | |
| 394 | + | cycle() | |
| 395 | + | if cycleErr != nil { | |
| 396 | + | t.Fatal(cycleErr) | |
| 397 | + | } | |
| 398 | + | fdsBefore := openFDs(t) | |
| 399 | + | if allocs := testing.AllocsPerRun(50, cycle); allocs != 0 { | |
| 400 | + | t.Errorf("%.1f allocations per capture cycle, want 0", allocs) | |
| 401 | + | } | |
| 402 | + | if cycleErr != nil { | |
| 403 | + | t.Fatal(cycleErr) | |
| 404 | + | } | |
| 405 | + | if after := openFDs(t); after != fdsBefore { | |
| 406 | + | t.Errorf("%d descriptors open after the cycles, %d before: a sync file leaked", after, fdsBefore) | |
| 407 | + | } | |
| 408 | + | if dst[0] == 0 && dst[1] == 0 && dst[2] == 0 { | |
| 409 | + | t.Error("capture read back black") | |
| 410 | + | } | |
| 411 | + | }) | |
| 412 | + | } | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | // adoptSync takes ownership of the descriptor it is given: the first one is | |
| 416 | + | // wrapped, a later one is moved onto the same descriptor number and its | |
| 417 | + | // source is closed, so nothing leaks and nothing is closed twice. | |
| 418 | + | func TestCaptureAdoptSync(t *testing.T) { | |
| 419 | + | pipe := func() (r, w int) { | |
| 420 | + | var p [2]int | |
| 421 | + | if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil { | |
| 422 | + | t.Fatal(err) | |
| 321 | 423 | } | |
| 322 | - | r.EndCapture(cf) | |
| 323 | - | } | |
| 324 | - | cycle() | |
| 325 | - | // No lease wrapper: the allocations left are the Vulkan structs the | |
| 326 | - | // FFI boundary keeps on the heap (as in Render, budget 16) plus the | |
| 327 | - | // sync file (os.NewFile and its finalizer). | |
| 328 | - | if allocs := testing.AllocsPerRun(10, cycle); allocs > 16 { | |
| 329 | - | t.Errorf("%.1f allocations per capture, want <= 16", allocs) | |
| 424 | + | return p[0], p[1] | |
| 425 | + | } | |
| 426 | + | r1, w1 := pipe() | |
| 427 | + | r2, w2 := pipe() | |
| 428 | + | t.Cleanup(func() { _ = unix.Close(w1); _ = unix.Close(w2) }) | |
| 429 | + | var s captureSlot | |
| 430 | + | if err := s.adoptSync(r1); err != nil { | |
| 431 | + | t.Fatal(err) | |
| 432 | + | } | |
| 433 | + | if s.syncFD != r1 || s.file == nil { | |
| 434 | + | t.Fatalf("first adoption: fd %d file %v, want %d", s.syncFD, s.file, r1) | |
| 435 | + | } | |
| 436 | + | file := s.file | |
| 437 | + | if err := s.adoptSync(r2); err != nil { | |
| 438 | + | t.Fatal(err) | |
| 439 | + | } | |
| 440 | + | if s.file != file || s.syncFD != r1 { | |
| 441 | + | t.Fatalf("second adoption replaced the file: %p fd %d", s.file, s.syncFD) | |
| 442 | + | } | |
| 443 | + | if _, err := unix.FcntlInt(uintptr(r2), unix.F_GETFD, 0); err != unix.EBADF { | |
| 444 | + | t.Errorf("source descriptor still open after the move: %v", err) | |
| 445 | + | } | |
| 446 | + | if _, err := unix.Write(w2, []byte{7}); err != nil { | |
| 447 | + | t.Fatal(err) | |
| 448 | + | } | |
| 449 | + | var b [1]byte | |
| 450 | + | if n, err := s.file.Read(b[:]); err != nil || n != 1 || b[0] != 7 { | |
| 451 | + | t.Fatalf("slot file does not read the second pipe: %d %v %v", n, b[0], err) | |
| 452 | + | } | |
| 453 | + | // A failed move still closes the source. | |
| 454 | + | r3, w3 := pipe() | |
| 455 | + | t.Cleanup(func() { _ = unix.Close(w3) }) | |
| 456 | + | bad := captureSlot{file: file, syncFD: -1} | |
| 457 | + | if err := bad.adoptSync(r3); err == nil { | |
| 458 | + | t.Error("move onto an invalid descriptor accepted") | |
| 459 | + | } | |
| 460 | + | if _, err := unix.FcntlInt(uintptr(r3), unix.F_GETFD, 0); err != unix.EBADF { | |
| 461 | + | t.Errorf("source descriptor leaked by a failed move: %v", err) | |
| 462 | + | } | |
| 463 | + | if err := file.Close(); err != nil { | |
| 464 | + | t.Errorf("slot file close: %v", err) | |
| 465 | + | } | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | // A slot keeps one descriptor for its sync files: it is the same number and | |
| 469 | + | // the same file for every lease, closed once by Close, and a lease the | |
| 470 | + | // device finished at export (no sync file) hands out none. | |
| 471 | + | func TestCaptureSyncFileOwnership(t *testing.T) { | |
| 472 | + | r, err := New(8, 8) | |
| 473 | + | if err != nil { | |
| 474 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 475 | + | } | |
| 476 | + | requireCaptureSync(t, r) | |
| 477 | + | closed := false | |
| 478 | + | defer func() { | |
| 479 | + | if !closed { | |
| 480 | + | r.Close() | |
| 481 | + | } | |
| 482 | + | }() | |
| 483 | + | if err := render(r, ports.Scene{Background: "#00ff00"}, nil); err != nil { | |
| 484 | + | t.Fatal(err) | |
| 485 | + | } | |
| 486 | + | before := openFDs(t) | |
| 487 | + | cf := captureWait(t, r).(*captureFrame) | |
| 488 | + | s := cf.slot | |
| 489 | + | file, fd := cf.Done(), s.syncFD | |
| 490 | + | if file == nil || s.file != file { | |
| 491 | + | t.Fatalf("first lease: done %v, slot file %v", file, s.file) | |
| 492 | + | } | |
| 493 | + | r.EndCapture(cf) | |
| 494 | + | if cf.Done() != nil { | |
| 495 | + | t.Error("ended lease still exposes the sync file") | |
| 496 | + | } | |
| 497 | + | if _, err := file.Stat(); err != nil { | |
| 498 | + | t.Errorf("EndCapture closed the slot's sync file: %v", err) | |
| 499 | + | } | |
| 500 | + | for range 4 { | |
| 501 | + | next := captureWait(t, r).(*captureFrame) | |
| 502 | + | if next.slot == s { | |
| 503 | + | if next.Done() != file || s.syncFD != fd { | |
| 504 | + | t.Fatalf("slot sync file changed: %p fd %d, was %p fd %d", next.Done(), s.syncFD, file, fd) | |
| 505 | + | } | |
| 506 | + | } | |
| 507 | + | r.EndCapture(next) | |
| 508 | + | } | |
| 509 | + | // Two slots, each holding one sync file. | |
| 510 | + | if held := openFDs(t) - before; held > captureSlots { | |
| 511 | + | t.Errorf("%d descriptors held by %d slots", held, captureSlots) | |
| 512 | + | } | |
| 513 | + | r.Close() | |
| 514 | + | closed = true | |
| 515 | + | if _, err := file.Stat(); err == nil { | |
| 516 | + | t.Error("Close left the slot's sync file open") | |
| 517 | + | } | |
| 518 | + | if got := openFDs(t); got > before { | |
| 519 | + | t.Errorf("%d descriptors open after Close, %d before the captures", got, before) | |
| 330 | 520 | } | |
| 331 | 521 | } | |
| 332 | 522 | ||
| @@ -291,8 +291,7 @@ func TestCaptureCurve(t *testing.T) { | |||||
| 291 | } | 291 | } | ||
| 292 | } | 292 | } | ||
| 293 | 293 | ||||
| 294 | - | // The lease object of a slot is reused: a steady state of captures | 294 | + | // The lease object of a slot is reused, and a stale handle reports invalid. |
| 295 | - | // allocates only the sync file, and a stale handle reports invalid. | |||
| 296 | func TestCaptureLeaseReused(t *testing.T) { | 295 | func TestCaptureLeaseReused(t *testing.T) { | ||
| 297 | r, err := New(8, 8) | 296 | r, err := New(8, 8) | ||
| 298 | if err != nil { | 297 | if err != nil { | ||
| @@ -309,24 +308,215 @@ func TestCaptureLeaseReused(t *testing.T) { | |||||
| 309 | t.Fatal("lease object not reused") | 308 | t.Fatal("lease object not reused") | ||
| 310 | } | 309 | } | ||
| 311 | r.EndCapture(second) | 310 | r.EndCapture(second) | ||
| 312 | - | cycle := func() { | 311 | + | dst := make([]byte, 4) |
| 313 | - | cf, err := r.BeginCapture() | 312 | + | if err := second.Read(image.Rect(0, 0, 1, 1), dst, 4); err == nil { |
| 314 | - | if err != nil { | 313 | + | t.Fatal("stale lease read accepted") |
| 315 | - | t.Fatal(err) | 314 | + | } |
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | // pollSyncFile waits for a sync file without allocating (syncfile.Wait | |||
| 318 | + | // builds its poll set per call). | |||
| 319 | + | func pollSyncFile(f *os.File, fds []unix.PollFd) error { | |||
| 320 | + | fds[0] = unix.PollFd{Fd: int32(f.Fd()), Events: unix.POLLIN} | |||
| 321 | + | for { | |||
| 322 | + | n, err := unix.Poll(fds, 1000) | |||
| 323 | + | switch { | |||
| 324 | + | case errors.Is(err, unix.EINTR): | |||
| 325 | + | case err != nil: | |||
| 326 | + | return err | |||
| 327 | + | case n > 0 && fds[0].Revents&unix.POLLIN != 0: | |||
| 328 | + | return nil | |||
| 329 | + | case n > 0: | |||
| 330 | + | return errors.New("sync file poll error") | |||
| 331 | + | default: | |||
| 332 | + | return errors.New("sync file poll timeout") | |||
| 316 | } | 333 | } | ||
| 317 | - | if done := cf.Done(); done != nil { | 334 | + | } |
| 318 | - | if err := syncfile.Wait(context.Background(), done); err != nil { | 335 | + | } |
| 336 | + | ||||
| 337 | + | // openFDs counts the process's open descriptors. | |||
| 338 | + | func openFDs(t *testing.T) int { | |||
| 339 | + | t.Helper() | |||
| 340 | + | ents, err := os.ReadDir("/proc/self/fd") | |||
| 341 | + | if err != nil { | |||
| 342 | + | t.Skipf("no /proc/self/fd: %v", err) | |||
| 343 | + | } | |||
| 344 | + | return len(ents) | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | // The renderer capture cycle (lease, wait for the copy, read the rows, | |||
| 348 | + | // return the lease) allocates nothing once the slots exist, on the SDR and | |||
| 349 | + | // the HDR conversion path, and holds a constant number of descriptors: the | |||
| 350 | + | // slot keeps its sync file for every lease. No mock stands in the measured | |||
| 351 | + | // closure. | |||
| 352 | + | func TestCaptureCycleAllocations(t *testing.T) { | |||
| 353 | + | for _, tc := range []struct { | |||
| 354 | + | name string | |||
| 355 | + | new func(*testing.T) *Renderer | |||
| 356 | + | }{ | |||
| 357 | + | {"sdr", func(t *testing.T) *Renderer { | |||
| 358 | + | r, err := New(16, 8) | |||
| 359 | + | if err != nil { | |||
| 360 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 361 | + | } | |||
| 362 | + | t.Cleanup(r.Close) | |||
| 363 | + | return r | |||
| 364 | + | }}, | |||
| 365 | + | {"hdr", hdrTestRenderer}, | |||
| 366 | + | } { | |||
| 367 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 368 | + | r := tc.new(t) | |||
| 369 | + | requireCaptureSync(t, r) | |||
| 370 | + | if err := render(r, ports.Scene{Seq: 1, Background: "#3060c0"}, nil); err != nil { | |||
| 319 | t.Fatal(err) | 371 | t.Fatal(err) | ||
| 320 | } | 372 | } | ||
| 373 | + | dst := make([]byte, 16*8*4) | |||
| 374 | + | fds := make([]unix.PollFd, 1) | |||
| 375 | + | region := image.Rect(0, 0, 16, 8) | |||
| 376 | + | var cycleErr error | |||
| 377 | + | cycle := func() { | |||
| 378 | + | cf, err := r.BeginCapture() | |||
| 379 | + | if err != nil { | |||
| 380 | + | cycleErr = err | |||
| 381 | + | return | |||
| 382 | + | } | |||
| 383 | + | if done := cf.Done(); done != nil { | |||
| 384 | + | if err := pollSyncFile(done, fds); err != nil { | |||
| 385 | + | cycleErr = err | |||
| 386 | + | } | |||
| 387 | + | } | |||
| 388 | + | if err := cf.Read(region, dst, 16*4); err != nil { | |||
| 389 | + | cycleErr = err | |||
| 390 | + | } | |||
| 391 | + | r.EndCapture(cf) | |||
| 392 | + | } | |||
| 393 | + | cycle() // creates the slots, the capture pass and the sync files | |||
| 394 | + | cycle() | |||
| 395 | + | if cycleErr != nil { | |||
| 396 | + | t.Fatal(cycleErr) | |||
| 397 | + | } | |||
| 398 | + | fdsBefore := openFDs(t) | |||
| 399 | + | if allocs := testing.AllocsPerRun(50, cycle); allocs != 0 { | |||
| 400 | + | t.Errorf("%.1f allocations per capture cycle, want 0", allocs) | |||
| 401 | + | } | |||
| 402 | + | if cycleErr != nil { | |||
| 403 | + | t.Fatal(cycleErr) | |||
| 404 | + | } | |||
| 405 | + | if after := openFDs(t); after != fdsBefore { | |||
| 406 | + | t.Errorf("%d descriptors open after the cycles, %d before: a sync file leaked", after, fdsBefore) | |||
| 407 | + | } | |||
| 408 | + | if dst[0] == 0 && dst[1] == 0 && dst[2] == 0 { | |||
| 409 | + | t.Error("capture read back black") | |||
| 410 | + | } | |||
| 411 | + | }) | |||
| 412 | + | } | |||
| 413 | + | } | |||
| 414 | + | ||||
| 415 | + | // adoptSync takes ownership of the descriptor it is given: the first one is | |||
| 416 | + | // wrapped, a later one is moved onto the same descriptor number and its | |||
| 417 | + | // source is closed, so nothing leaks and nothing is closed twice. | |||
| 418 | + | func TestCaptureAdoptSync(t *testing.T) { | |||
| 419 | + | pipe := func() (r, w int) { | |||
| 420 | + | var p [2]int | |||
| 421 | + | if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil { | |||
| 422 | + | t.Fatal(err) | |||
| 321 | } | 423 | } | ||
| 322 | - | r.EndCapture(cf) | 424 | + | return p[0], p[1] |
| 323 | - | } | 425 | + | } |
| 324 | - | cycle() | 426 | + | r1, w1 := pipe() |
| 325 | - | // No lease wrapper: the allocations left are the Vulkan structs the | 427 | + | r2, w2 := pipe() |
| 326 | - | // FFI boundary keeps on the heap (as in Render, budget 16) plus the | 428 | + | t.Cleanup(func() { _ = unix.Close(w1); _ = unix.Close(w2) }) |
| 327 | - | // sync file (os.NewFile and its finalizer). | 429 | + | var s captureSlot |
| 328 | - | if allocs := testing.AllocsPerRun(10, cycle); allocs > 16 { | 430 | + | if err := s.adoptSync(r1); err != nil { |
| 329 | - | t.Errorf("%.1f allocations per capture, want <= 16", allocs) | 431 | + | t.Fatal(err) |
| 432 | + | } | |||
| 433 | + | if s.syncFD != r1 || s.file == nil { | |||
| 434 | + | t.Fatalf("first adoption: fd %d file %v, want %d", s.syncFD, s.file, r1) | |||
| 435 | + | } | |||
| 436 | + | file := s.file | |||
| 437 | + | if err := s.adoptSync(r2); err != nil { | |||
| 438 | + | t.Fatal(err) | |||
| 439 | + | } | |||
| 440 | + | if s.file != file || s.syncFD != r1 { | |||
| 441 | + | t.Fatalf("second adoption replaced the file: %p fd %d", s.file, s.syncFD) | |||
| 442 | + | } | |||
| 443 | + | if _, err := unix.FcntlInt(uintptr(r2), unix.F_GETFD, 0); err != unix.EBADF { | |||
| 444 | + | t.Errorf("source descriptor still open after the move: %v", err) | |||
| 445 | + | } | |||
| 446 | + | if _, err := unix.Write(w2, []byte{7}); err != nil { | |||
| 447 | + | t.Fatal(err) | |||
| 448 | + | } | |||
| 449 | + | var b [1]byte | |||
| 450 | + | if n, err := s.file.Read(b[:]); err != nil || n != 1 || b[0] != 7 { | |||
| 451 | + | t.Fatalf("slot file does not read the second pipe: %d %v %v", n, b[0], err) | |||
| 452 | + | } | |||
| 453 | + | // A failed move still closes the source. | |||
| 454 | + | r3, w3 := pipe() | |||
| 455 | + | t.Cleanup(func() { _ = unix.Close(w3) }) | |||
| 456 | + | bad := captureSlot{file: file, syncFD: -1} | |||
| 457 | + | if err := bad.adoptSync(r3); err == nil { | |||
| 458 | + | t.Error("move onto an invalid descriptor accepted") | |||
| 459 | + | } | |||
| 460 | + | if _, err := unix.FcntlInt(uintptr(r3), unix.F_GETFD, 0); err != unix.EBADF { | |||
| 461 | + | t.Errorf("source descriptor leaked by a failed move: %v", err) | |||
| 462 | + | } | |||
| 463 | + | if err := file.Close(); err != nil { | |||
| 464 | + | t.Errorf("slot file close: %v", err) | |||
| 465 | + | } | |||
| 466 | + | } | |||
| 467 | + | ||||
| 468 | + | // A slot keeps one descriptor for its sync files: it is the same number and | |||
| 469 | + | // the same file for every lease, closed once by Close, and a lease the | |||
| 470 | + | // device finished at export (no sync file) hands out none. | |||
| 471 | + | func TestCaptureSyncFileOwnership(t *testing.T) { | |||
| 472 | + | r, err := New(8, 8) | |||
| 473 | + | if err != nil { | |||
| 474 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 475 | + | } | |||
| 476 | + | requireCaptureSync(t, r) | |||
| 477 | + | closed := false | |||
| 478 | + | defer func() { | |||
| 479 | + | if !closed { | |||
| 480 | + | r.Close() | |||
| 481 | + | } | |||
| 482 | + | }() | |||
| 483 | + | if err := render(r, ports.Scene{Background: "#00ff00"}, nil); err != nil { | |||
| 484 | + | t.Fatal(err) | |||
| 485 | + | } | |||
| 486 | + | before := openFDs(t) | |||
| 487 | + | cf := captureWait(t, r).(*captureFrame) | |||
| 488 | + | s := cf.slot | |||
| 489 | + | file, fd := cf.Done(), s.syncFD | |||
| 490 | + | if file == nil || s.file != file { | |||
| 491 | + | t.Fatalf("first lease: done %v, slot file %v", file, s.file) | |||
| 492 | + | } | |||
| 493 | + | r.EndCapture(cf) | |||
| 494 | + | if cf.Done() != nil { | |||
| 495 | + | t.Error("ended lease still exposes the sync file") | |||
| 496 | + | } | |||
| 497 | + | if _, err := file.Stat(); err != nil { | |||
| 498 | + | t.Errorf("EndCapture closed the slot's sync file: %v", err) | |||
| 499 | + | } | |||
| 500 | + | for range 4 { | |||
| 501 | + | next := captureWait(t, r).(*captureFrame) | |||
| 502 | + | if next.slot == s { | |||
| 503 | + | if next.Done() != file || s.syncFD != fd { | |||
| 504 | + | t.Fatalf("slot sync file changed: %p fd %d, was %p fd %d", next.Done(), s.syncFD, file, fd) | |||
| 505 | + | } | |||
| 506 | + | } | |||
| 507 | + | r.EndCapture(next) | |||
| 508 | + | } | |||
| 509 | + | // Two slots, each holding one sync file. | |||
| 510 | + | if held := openFDs(t) - before; held > captureSlots { | |||
| 511 | + | t.Errorf("%d descriptors held by %d slots", held, captureSlots) | |||
| 512 | + | } | |||
| 513 | + | r.Close() | |||
| 514 | + | closed = true | |||
| 515 | + | if _, err := file.Stat(); err == nil { | |||
| 516 | + | t.Error("Close left the slot's sync file open") | |||
| 517 | + | } | |||
| 518 | + | if got := openFDs(t); got > before { | |||
| 519 | + | t.Errorf("%d descriptors open after Close, %d before the captures", got, before) | |||
| 330 | } | 520 | } | ||
| 331 | } | 521 | } | ||
| 332 | 522 | ||||
M internal/adapters/vulkan/renderer.go +2 -0
| @@ -112,6 +112,8 @@ type Renderer struct { | |||
| 112 | 112 | frameStages []vk.PipelineStageFlags | |
| 113 | 113 | // call holds the structs a per-frame Vulkan call is handed by pointer. | |
| 114 | 114 | call callScratch | |
| 115 | + | // capCall holds the structs a capture hands Vulkan by pointer. | |
| 116 | + | capCall captureScratch | |
| 115 | 117 | } | |
| 116 | 118 | ||
| 117 | 119 | // priorityAttempts falls back to an ordinary queue when elevated priority is unavailable. | |
| @@ -112,6 +112,8 @@ type Renderer struct { | |||||
| 112 | frameStages []vk.PipelineStageFlags | 112 | frameStages []vk.PipelineStageFlags | ||
| 113 | // call holds the structs a per-frame Vulkan call is handed by pointer. | 113 | // call holds the structs a per-frame Vulkan call is handed by pointer. | ||
| 114 | call callScratch | 114 | call callScratch | ||
| 115 | + | // capCall holds the structs a capture hands Vulkan by pointer. | |||
| 116 | + | capCall captureScratch | |||
| 115 | } | 117 | } | ||
| 116 | 118 | ||||
| 117 | // priorityAttempts falls back to an ordinary queue when elevated priority is unavailable. | 119 | // priorityAttempts falls back to an ordinary queue when elevated priority is unavailable. | ||
M internal/ports/devices.go +2 -1
| @@ -26,7 +26,8 @@ var ErrCaptureBusy = fmt.Errorf("%w: no free slot", ErrCaptureTransient) | |||
| 26 | 26 | // goroutine waits on Done, then reads. EndCapture returns the lease. | |
| 27 | 27 | type CaptureFrame interface { | |
| 28 | 28 | // Done is the sync file signalled when the copy finished; nil means it | |
| 29 | - | // had finished when BeginCapture returned. The frame owns it. | |
| 29 | + | // had finished when BeginCapture returned. The frame owns it: it stays | |
| 30 | + | // valid until EndCapture, and the reader must not close it. | |
| 30 | 31 | Done() *os.File | |
| 31 | 32 | // Read copies region (output pixels) as opaque sRGB8 BGRA rows into | |
| 32 | 33 | // dst, stride bytes per row. Valid after Done signalled and before | |
| @@ -26,7 +26,8 @@ var ErrCaptureBusy = fmt.Errorf("%w: no free slot", ErrCaptureTransient) | |||||
| 26 | // goroutine waits on Done, then reads. EndCapture returns the lease. | 26 | // goroutine waits on Done, then reads. EndCapture returns the lease. | ||
| 27 | type CaptureFrame interface { | 27 | type CaptureFrame interface { | ||
| 28 | // Done is the sync file signalled when the copy finished; nil means it | 28 | // Done is the sync file signalled when the copy finished; nil means it | ||
| 29 | - | // had finished when BeginCapture returned. The frame owns it. | 29 | + | // had finished when BeginCapture returned. The frame owns it: it stays |
| 30 | + | // valid until EndCapture, and the reader must not close it. | |||
| 30 | Done() *os.File | 31 | Done() *os.File | ||
| 31 | // Read copies region (output pixels) as opaque sRGB8 BGRA rows into | 32 | // Read copies region (output pixels) as opaque sRGB8 BGRA rows into | ||
| 32 | // dst, stride bytes per row. Valid after Done signalled and before | 33 | // dst, stride bytes per row. Valid after Done signalled and before | ||