luv

Workshop wiki

gpu-architecture.org

The GPU API and its backend proofs

Contract, mechanism, and open proof stay distinct #V9H2RK

This is the current map of luv's GPU layer. It follows the portable command vocabulary through the Vulkan and Metal implementations, with particular attention to asynchronous work, ownership, synchronization, and destruction. Older implementation snapshots and the sequence of experiments that produced this arrangement remain available in Git; they are not separate entry points in the living wiki.

Three kinds of statement appear below:

This distinction matters because a WebGPU-shaped name is not itself a WebGPU promise, and a working Vulkan or Metal path is not by itself a portable contract. The source links and limitations on this page are part of the map, not footnotes to it.

The portable GPU API is a tested seam, not a complete contract #A7N4XP

The luv system's modules now make the intended boundaries literal without turning every implementation layer into a public ASDF system:

hal/gpu.lisp                    descriptors, commands, generic operations
       |\
       | +--> hal/vulkan/ -----> owned Vulkan ABI
       |
       `----> hal/metal/ ------> LUV.METAL / Objective-C / Metal 4

hal/canvas.lisp --> hal/sdl/ --> backend presentation context

hal/gpu.lisp contains no Vulkan or Metal calls. It names providers, devices, queues, resources, descriptors, command structs, encoders, passes, finish, submit, submitted-work-done, host reads and writes, and explicit destroy. Presentation remains in the separate canvas protocol.

Both backends now render the block world through the same command lifecycle: create a general encoder, record commands and passes, finish it into a one-shot backend command buffer, and asynchronously submit that product to a queue. Metal's canvas follows this lifecycle too; its only private extension is the drawable signal and presentation handshake after commit. The backends still do not implement equal feature surfaces, but luvcraft no longer needs to know which command-submission or lifetime mechanism its selected provider uses.

This is a useful small-system boundary. It permits direct backend composition and does not require a capability framework, adapter hierarchy, or lowest common denominator before a concrete need appears.

WebGPU is a landmark, not luv's specification #W6P3JH

Luv began with a deliberately WebGPU-shaped vocabulary because WebGPU makes several hard GPU obligations unusually visible: commands are validated future actions, usage declarations carry advance information, submission is asynchronous, and logical object state is distinct from physical native lifetime. It is a source of questions and worked semantics, not an authority that luv implements by resemblance.

Current luv deliberately shares some of that shape:

Luv does not currently promise WebGPU's adapter and limit model, validation scopes, device-loss model, mapping state machine, deterministic zero initialization, browser security boundary, or complete usage-scope rules. Those omissions are decisions still to make or work still to prove, not implicit inheritance from the borrowed nouns.

The useful reading discipline is therefore: ask what observable promise a name makes in luv, then identify the component that owns its proof. When there is no such component, the resemblance is only vocabulary.

GPU work crosses several timelines #T4V8QM

The caller can finish describing a command before the queue accepts it, and the queue can accept it before the GPU finishes executing it. These are orders of events, not necessarily distinct OS threads:

flowchart LR
  A["application creates and encodes"] --> B["finished command owns recorded work"]
  B --> C["queue submission owns in-flight work"]
  C --> D["completion frontier passes submission"]
  D --> E["backend may retire retained native state"]

The generic submit documentation states the consequence directly: returning does not mean GPU completion; the implementation must retain everything the work depends on until it completes. submitted-work-done blocks at the queue's current completion point when a caller really needs that proof.

This separation is why several tempting equations are wrong. A Lisp object becoming unreachable is not GPU completion. A wrapper being marked destroyed is not necessarily native release. The end of a presented frame is not a general reclamation boundary. A completion proof also says nothing by itself about whether the right memory barriers were encoded.

Inspectable commands are protocol inputs, not a retained command IR #D8F3QM

The portable command structs and double dispatch through encode and enqueue are a real architectural seam. Command kinds are separated by scope: queue, command encoder, render pass, and compute pass. Convenience functions such as draw and prepare-texture merely construct those command objects. Unsupported command/encoder pairs fail through the generic fallback, so a backend grows by specializing concrete relationships rather than extending one opcode switch.

The objects are nevertheless transient inputs. Each backend lowers a command immediately into its native command buffer. finish transfers native command memory and retained wrappers into either a one-shot vulkan-gpu-command-buffer or metal-gpu-command-buffer; it does not preserve the high-level command sequence for replay or another lowering.

That choice is coherent as long as the purpose is legible dispatch, validation, tracing, and dependency capture. A durable backend-independent command IR would be a different feature with different ownership and transformation requirements; the current structs should not be described as one merely because they are inspectable while dispatched.

Recorded and submitted work must own their dependencies #L8R2WF

One GPU resource participates in several overlapping lifetimes:

  1. application code can still reach its Lisp wrapper;
  2. the wrapper is logically valid for new operations;
  3. an encoder or finished command may retain it as a recorded dependency;
  4. a queue submission may retain it for work the GPU can still execute; and
  5. its native object and allocation can finally be released.

Vulkan makes those ownership transfers explicit. Encode methods retain every wrapper referenced by the current command vocabulary: pipelines, bind groups, views, samplers, buffers, and textures. finish moves the set into the command buffer. Submission stamps the wrappers with the new submission index and keeps the command buffers and their captured resources in a queue-owned record until completion.

Metal now makes the same ownership transfers. Encode methods capture every wrapper referenced by its implemented render, clear, copy, preparation, and binding vocabulary. finish moves that set, the native command buffer, and its allocator into one-shot work. Public submission stamps all dependencies with a shared-event value and retains them in the queue record. The residency set keeps their allocations resident until deferred destruction removes them.

Garbage collection concerns only the first lifetime. It cannot replace the recorded and submitted ownership edges, and native API retention cannot by itself give a Lisp wrapper a useful logical destroyed state.

Each backend has a completion frontier of a different extent #T9K4RC

Both live backends now represent completed work as a monotonically valued queue frontier, but the records attached to that frontier are different.

VulkanMetal 4
Native completion valuetimeline semaphoreshared event
Submitted valuequeue submission indexqueue submission value
Retained until completioncommand buffers, captured wrappers, native temporariescommand buffers, allocators, captured wrappers
General public submityesyes
Maintenance pointssubmission, explicit wait, and eventual retirement servicesubmission, explicit wait, and eventual retirement service

submit-vulkan-command-buffers signals the queue timeline, installs a vulkan-gpu-submission record, and returns its index. Queue maintenance reads the semaphore counter and retires the completed prefix. Vulkan 1.4, vkQueueSubmit2, timeline semaphores, and synchronization2 are deliberate minimums; there is no fence or legacy-submit fallback.

submit-metal-command-buffers commits a batch of finished Metal 4 work, registers command memory and captured dependencies before presentation can fail, and records the shared-event signal itself as a retryable post-commit obligation. Reclamation drops the retained record when the event value passes it. The current Metal page #NABRMK follows that backend's larger compiler and rendering story.

A nonempty submission or retirement ledger process-roots its queue. A lazy service thread polls only those custodians, under their backend queue locks, and exits when the registry becomes empty. Explicit submission and wait calls remain useful low-latency maintenance points; the service exists so abandoning the last application reference cannot turn safe delayed reclamation into a permanent root cycle.

defun submit-metal-command-buffers gpu.lisp:604
defunsubmit-metal-command-buffers
queuecommand-buffers&keyafter-commit

Commit finished Metal work and retain its dependencies to queue's frontier.

after-commit performs the drawable signal and presentation handshake before the completion event is enqueued. Presentation is the only caller of that backend-local extension; ordinary callers use submit. #T9K4RC

unless
vectorpcommand-buffers
error'gpu-request-error:operation:submit:descriptorcommand-buffers:reason:invalid-command-buffers
when
zerop
lengthcommand-buffers
sb-thread:with-recursive-lock
metal-queue-lockqueue

Device teardown closes admission under this lock. Recheck after any wait to make this the authoritative gate before maintenance and FFI.

loopforcommand-bufferacrosscommand-buffersdo
let*
resources
native-command-buffers
map'vector#'metal-native-objectcommand-buffers
luv.metal:commit-command-buffers
metal-native-objectqueue
native-command-buffers
let
value
incf
metal-queue-submitted-valuequeue
loopforcommand-bufferacrosscommand-buffersdo
setf
metal-command-buffer-statecommand-buffer
:submitted
metal-object-last-submissioncommand-buffer
value
dolist
resourceresources
setf
metal-object-last-submissionresource
value

Register queue ownership before presentation can fail.

setf
metal-queue-pending-submissionsqueue
nconc
metal-queue-pending-submissionsqueue
list
make-metal-submission:valuevalue:command-bufferscommand-buffers:resourcesresources
retain-gpu-retirement-ledger-custodian
metal-queue-retirement-ledgerqueue
queue
unwind-protect
whenafter-commit
funcallafter-commit

Presentation must be ordered before completion, but failure to enqueue that completion is now a rooted retryable queue obligation.

setf
metal-queue-completion-signal-ready-valuequeue
maxvalue
metal-queue-completion-signal-ready-valuequeue
value

Destroy is logically immediate and physically conditional #D3H7VK

Both resource implementations now give destroy this shape. While a backend queue is live, the operation first transfers a complete native teardown into a FIFO retirement ledger, tagged with the resource's last submitted use, and only then marks the wrapper destroyed. The ledger runs the eligible prefix as the queue frontier advances. Without a live queue, teardown must succeed synchronously before logical invalidation.

Native teardowns are sequences of individually resumable calls. A failure therefore retains that entry and its dependency-ordered successors without repeating calls which already succeeded. The process custodian keeps the queue reachable until submissions, post-commit publication, and retirement are all quiescent; device destruction closes admission and refuses to pass a nonempty ledger.

This division is essential rather than stylistic. Native destruction requires every submitted use to have completed, while a pleasant API should not make every explicit destroy drain the queue. Complete command dependency capture is what makes the deferred half safe. Lightweight semantic wrappers with no independent native ownership still only change logical state.

Explicit destruction is the discipline; collection is only a safety net #G6C2TX

Every backend wrapper has an explicit destroyed state. That gives a live Lisp object an invalid state which later operations can reject; continued CLOS reachability does not make a destroyed GPU object usable.

Vulkan wrappers also install finalizers. Explicit destruction cancels the finalizer. If the collector instead finds a live wrapper, the finalizer first transfers its progress-tracked native teardown into a process-global ledger, then warns with gpu-resource-leaked and records the condition in *leaked-gpu-resources*. Thus even a warning handler which exits cannot lose native custody. Submission records hold in-flight wrappers strongly, so an ordinary collected child is already past the frontier; its fallback teardown still shares queue/device exclusion and the Vulkan driver environment.

Metal currently has explicit destruction but not the equivalent finalizer audit. Dropping the last Lisp reference without destroy can therefore leak native ownership without a warning. Both backends durably service ownership which was explicitly transferred, but neither maintains a general child registry that turns device teardown into automatic orderly destruction of every still-live wrapper.

The portable discipline is consequently simple and honest: callers explicitly destroy what they create. Vulkan's collector path is failure recovery and a REPL diagnostic, not the normal resource manager.

Vulkan frame slots are pacing clients of the frontier #M6P8XW

The Vulkan canvas keeps a bounded ring of frame slots. Each slot owns an image-acquisition binary semaphore and, while occupied, the frame's command buffer plus its queue submission index. Reusing a slot waits for the queue frontier to reach that index, then destroys the retained command buffer. Render-finished semaphores are indexed separately by acquired swapchain image.

The distinction is load-bearing:

Because Vulkan resource destruction now defers through the general queue records, the slot wait is a pacing choice rather than a second lifetime system. A resource can also be used by compute or copy work with no frame at all, so “end of frame” is not the general reclamation unit.

Metal presentation has no parallel Lisp frame-slot abstraction. Its drawable pool bounds presentation resources, while the same general queue submission records used by offscreen work retain command memory and dependencies. This keeps drawable pacing separate from the lifetime proof completed by #JAM5XR.

Usage names hazards; each backend still owns the synchronization proof #X2D7MF

Descriptors and recorded commands give the backend advance information: buffer and texture usage, attachment roles, bindings, copy directions, and the semantic destination of prepare-texture. Application code does not emit Vulkan stage masks or Metal barriers directly. That simplicity relocates the proof into each backend.

Vulkan currently tracks one scheduled layout for each whole texture. An encoder records the initial layout it assumed and its final layouts; submit validates those assumptions against earlier queued work, installs the new scheduled state, and emits a synchronization2 image barrier when the layout changes. This fits today's single-mip, single-layer textures. It is not a complete access-hazard model: two uses can remain in general while a write followed by a read still needs a memory dependency.

Metal emits concrete barriers for the command relationships exercised by the renderer, including producer/consumer transitions installed by prepare-texture. It likewise does not maintain a general subresource access history derived from all usage scopes.

The completion frontier proves that old work has finished. It does not prove that work was ordered correctly while in flight. Lifetime capture, scheduled state, and access hazards are related bookkeeping, but none can stand in for the other two.

Host access is still a deliberately narrow path #B9Q4LC

Both backends create CPU-visible buffers and keep them mapped: Vulkan uses host-visible coherent memory; Metal uses shared storage. write-buffer copies a one-dimensional single-float array into that mapping. It does not wait, acquire a mapped state, track a byte range, or exclude simultaneous GPU reads. Current rendering code supplies the missing rule by rotating per-frame or per-drawable buffers and writing only the one safe for the frame.

read-buffer is conservative in the other direction: it calls submitted-work-done for the whole device queue, then copies bytes from the mapping. There is no per-buffer last-use wait or asynchronous mapping callback.

This is useful, executable functionality, not yet a general memory model. In particular luv does not currently promise a WebGPU-like mapped/unmapped state machine, staging and readback policy, cache policy beyond the chosen storage, or deterministic zero initialization. Those contracts should be added only with an owner and a testable exclusion rule.

The remaining questions are local and testable #V3N7QJ

The architecture no longer needs a speculative rewrite to become clearer. Its open proofs are adjacent to working mechanisms:

Each item can be proved through one concrete caller and one backend path. The current seams are small enough that this is preferable to importing a generic resource manager or treating WebGPU conformance as the design goal.