Skip to content

Add experimental PerVertexKHR capture and replay (test entry point only) - #2858

Draft
carbonimax wants to merge 15 commits into
KhronosGroup:mainfrom
carbonimax:pervertex-experimental
Draft

carbonimax wants to merge 15 commits into
KhronosGroup:mainfrom
carbonimax:pervertex-experimental

Conversation

@carbonimax

@carbonimax carbonimax commented Oct 3, 2026 •

Copy link
Copy Markdown

Draft, experimental; capture and replay are selected only by a test entry point. This change implements portable PerVertexKHR fragment inputs and BaryCoordKHR / BaryCoordNoPerspKHR using captured vertex outputs. It does not claim extension conformance. The shipping barycentric extension path and advertisement remain native.

The branch has been rebased onto MoltenVK main 67b2682699d7903606b97a0392061d85d27d49e6. The additional changes are limited to reusable replay tables, replay scratch checks, safe copying of tessellation-level bits, and tests for the rebase. No new runtime configuration option is introduced by those changes. Three later commits add opt-in performance changes; they are off by default and described in Opt-in performance changes below.

New replay-table optimization

The command encoding pool caches immutable occurrence, primitive-index and corner tables by topology and provoking mode. For single-instance draws, a larger table contains each smaller draw's table as a prefix. Complete instanced point/line/triangle lists can use the same representation. Primitive restart, instanced strips/fans and incomplete instanced lists keep their per-draw tables.

Tables grow geometrically. Superseded buffers remain resident until pool destruction because pending command buffers may still reference them. Metal buffer-size limits are checked, and allocation failure falls back to per-draw population. This removes repeated CPU population for admitted draws; no application frame-rate or timing claim is made here. The tradeoff is retained host-visible table memory until the pool is destroyed.

Replay scratch lengths are now checked before CPU population. The test tessellation path copies a float's bits with memcpy instead of type punning. These are safety changes, not claimed performance gains.

Opt-in performance changes

The first two of the last three commits reduce encoder and allocation work around captured draws; the third one removes a CPU readback on the experimental mesh path. These changes are disabled when the variables are unset; use =1 to opt in. None of them changes the captured ABI, the replay shaders or the SPIRV-Cross pin.

  • Encode the PerVertexKHR captures of a subpass before its render pass (MVK_CONFIG_PERVERTEX_PREPASS=1). Each capture otherwise ends the render encoder, runs its compute capture and restarts the render pass. With the variable, when an inline, single-view subpass opens, its indexed captured draws are encoded ahead of time in one compute encoder. The state commands they depend on are replayed there, and the encoder state, push descriptors included, is restored before the render pass starts. The window stops at barriers, subpass changes, copies, queries, secondary command buffers, non-indexed PerVertex captures, PerVertex TES captures and conventional indirect draws. Native mesh draws have no capture and can be crossed.
  • Pool PerVertexKHR scratch buffers and batch their residency (MVK_CONFIG_PERVERTEX_SCRATCH_POOL=1, MVK_CONFIG_PERVERTEX_BATCH_RESIDENCY=1, independent). The pool serves the scratch buffers of direct draws from free lists keyed by storage mode and power-of-two size; pooled buffers stay resident and return to the pool on their last reference. The tradeoff is that the pool keeps its high-water mark until device destruction. Batched residency adds the scratch allocations of a Metal command buffer to the residency set together, with one commit at submission and one at completion, instead of one commit per buffer.
  • Draw single indirect mesh grids without CPU readback (MVK_CONFIG_MESH_INDIRECT_GPU=1). vkCmdDrawMeshTasksIndirectEXT with drawCount == 1 records a command that passes the indirect buffer to drawMeshThreadgroupsWithIndirectBuffer, so the client does not have to read the grid back on the CPU. drawCount == 0 is a no-op, not a command. drawCount > 1 stays refused, as does every call without the variable. This only concerns native mesh pipelines of the experimental mesh path. Known gap: the direct draw refuses a grid at or above the Metal limit at record time, but an indirect grid cannot be checked then, and Metal draws nothing for it without reporting an error.

New tests: PerVertexPrepassStateTests.mm checks the prepass state save and restore against the built library, in place and with a grown push-descriptor set. PerVertexScratchPoolTests.mm checks size classes, storage modes, return on the last reference and single residency per created buffer, from the allocation calls of a fake device. Both run on the CPU. The GPU paths of the prepass, the pool and batched residency are covered only by running the existing GPU suites with the three variables set (table below), which does not prove each path was taken. The indirect mesh command has no test. No timing or frame-rate claim is made for these commits: there is no public, reproducible benchmark for them yet.

Rebase details

The rebase retains main's depth-clip result field and serialization, pipeline-cache matching fixes, and vertex depth-clip configuration. The pinned replay compiler explicitly rejects depth-clip emulation; unsupported replay pipelines are refused rather than silently losing the transform. Capture/replay fields are retained alongside main's fields. Archive coverage was checked against the actual serializers.

The first three commits still originate from #2851 (primitive topology / triangle fans) and #2852 (multiview pass grouping). They should be reviewed there and will drop out when those changes merge. This branch retains the pipeline topology accessor needed for capture sizing.

The experimental mesh implementation remains separate from #2842. It is a narrow test-only path, not a mesh-shader deliverable of this pull request. The previous textual overlap survey with other proposals is historical; it is not a current semantic compatibility claim.

SPIRV-Cross dependency and building

This requires KhronosGroup/SPIRV-Cross#2699, pinned at 81739edf516c2bef043ac61f510b4bd78e126a22 in ExternalRevisions/SPIRV-Cross_repo_revision. That revision already contains main's pin aa217aeb6c9f0ace7a0ab233b28807edf45eb165 and its depth-clip APIs. The additional replay-table optimization does not change the captured ABI or require a further SPIRV-Cross change.

The series includes the changes also proposed in #2690, #2691, #2695, #2696, #2697 and #2698. Those are code overlaps in the pinned series, not six separately fetched dependencies. Its temporary mesh LocalSizeId change overlaps #2686; #2699 describes the intended removal before merging. Native vertex_value behavior on newer GPUs still needs separate review and testing.

Until the series is merged, fetch the pull-request ref before fetching dependencies from a clean checkout:

git clone https://github.com/KhronosGroup/SPIRV-Cross.git External/SPIRV-Cross
git -C External/SPIRV-Cross fetch origin refs/pull/2699/head
./fetchDependencies --macos
make macos

The standard dependency fetch does not fetch pull-request refs. A clean build or CI may therefore fail to resolve the pin until the dependency is merged or the ref is explicitly fetched. A successful local build does not resolve that CI limitation.

Activation and scope

MoltenVK/Tests/PerVertexTES/EnableTestDevice.mm is excluded from shipping CMake/Xcode targets. A test build links it, and the test application calls mvkEnableTESFixtureDevice(VkPhysicalDevice) before device creation. This selects the portable capture/replay path on devices with Metal barycentric support. MoltenVK/Tests/PerVertexKHR-README.md documents the runners and scope.

The portable tests cover vertex/fragment SPIR-V pipelines, typed scalar/vector/composite inputs, barycentrics, points/lines/triangles including fans, dynamic topology within a topology class, and direct/indexed draws with one sample and the first provoking vertex. Code also exists for paths outside that tested scope: indirect draws, tessellation, mesh, several views, multisampling, multiple instances and newer native per-vertex hardware. They are not all gated or qualified in the same way. Tessellation admission has a separate test compile guard; mesh has a separate test device entry point.

The implementation still refuses valid usages including unsupported depth-clip replay, some memoryless attachment splits, line polygon mode, dynamic rasterizer discard, multiple-view capture, unsupported resolve modes, and interfaces beyond the replay buffer/varying budgets. Whether every such refusal is allowed by Vulkan remains an open limitation.

Default behavior requiring review

Capture/replay remains opt-in, but the original proposal also changes general runtime behavior:

  • Metal command-buffer commits and completion handlers go through device-level tracking; device destruction waits for committed buffers. Device loss is published after submitted encodings and completion handlers finish, suppresses signals, and cancels emulated semaphore waits. Failed acquisition of a Metal command buffer reports device loss.
  • Queue submissions prepare all batches before committing any; fence/semaphore signals respect earlier queue submissions. Binary Metal-event semaphores use MTLSharedEvent.
  • Compatible tessellation pipelines retain float32 levels with per-draw conversion, patch-count overflow checks and indirect parameter strides.
  • Rasterizing pipelines perform fragment reflection. Pipeline-cache UUID/layout changes invalidate prior caches, and public converter structures grow.
  • Abandoned prefills, command-pool reset and failed secondary command propagation are handled explicitly. Drawable acquisition uses the surface queue; image storage queries report actual texture storage. New support kernels are compiled during device creation. The topology/multiview fixes above also apply generally.

The synthetic tests below exercise several of these paths, but do not qualify every default-path change or all Vulkan applications.

Current local validation

Tested on Apple M4 Pro, macOS 27.2. Release test libraries were built for both x86_64 and arm64 with -O2, assertions retained, deployment target macOS 11, and private Metal APIs off. The test adapter was linked only into these validation libraries. SPIRV-Cross was built from the unmodified pin above.

Check Result
Shared table prefix/admission oracle, ASan/UBSan 5,022 prefix checks passed
Production pipeline-cache round trip Passed; all eight omitted-field controls detected; depth-clip result flag preserved
Production archive coverage check under Rosetta Passed
Scratch/preflight, draw-size, topology, capture variants, prefill and storage-policy CPU suites Passed, including allocation failures, rollback, malformed snapshots, limits and lifetime checks
Production restart kernel on CPU 74,136 patterns plus large boundaries passed
Converter interfaces/reflection and portable barycentrics Passed; generated Metal 2.4 syntax passed; unsupported replay depth-clip option rejected
Dynamic topology / image oracle, Metal API and shader validation 14 cases passed; zero image differences against the references; deliberately wrong references detected
x86_64 indexed PerVertex draw under Rosetta, Metal validation Passed; zero static/dynamic image differences
Memoryless depth/stencil split Both refused before submission as expected
Experimental barycentric CTS case list 1,201 Pass
Ordinary topology/fan CTS case list 258 Pass, 299 NotSupported, 5 known Fail; exact match to the checked-in expected per-case list

The opt-in commits were checked on the same machine, with the same Release -O2 configuration for x86_64 and arm64 and the unmodified SPIRV-Cross pin above:

Check Result
Prepass state save/restore, CPU, against the built library Passed, in place and with a grown push-descriptor set
Scratch pool, CPU, fake device Passed; allocation classes, last-reference lifetime and one residency call per created buffer
Existing CPU suites (scratch/preflight, draw-size, topology, capture variants, prefill, storage policy, restart, shared tables) at each of the three commits Passed
Pipeline-cache round trip, converter interfaces/reflection, portable barycentrics Passed
Dynamic topology / image oracle, Metal API and shader validation 14 cases passed by default, and 14 passed with the prepass, scratch pool and batched residency variables set
Experimental barycentric CTS case list 1,201 Pass by default, and 1,201 Pass with the same three variables set; no crash
Ordinary topology/fan CTS case list, default settings Same 562 per-case results as the checked-in expected list

Batched residency has no dedicated test; pooled direct-draw allocations bypass it.

The five ordinary-list failures are retained expectations: two restart-disabled triangle-fan cases and three mesh cases affected by the experimental shim. They are not presented as passing tests. There were no CTS process crashes. These results qualify the tested synthetic scope, not conformance, arbitrary applications, native Intel hardware, iOS, newer native per-vertex hardware, or public performance improvements. Earlier M1/M2 reports belong to prior revisions and are not fresh qualification of this rebase.

To reproduce the portable checks, fetch the pinned dependencies, build a test library using MoltenVK/Tests/PerVertexTES/build-library.sh <output> OFF ON, and use the commands and CTS lists in PerVertexKHR-README.md. Release -O2 can be selected by reconfiguring its generated driver with CMAKE_BUILD_TYPE=Release and CMAKE_{C,CXX,OBJC,OBJCXX}_FLAGS_RELEASE=-O2 before rebuilding. The CTS binary is VK-GL-CTS 1.4.6.2. This testing does not require an external application.

Follow-up work

Capture scheduling and scratch-pool/residency changes are now included as opt-in commits (see above); they still need GPU tests that prove each path is taken, and reproducible public evidence before any default change is proposed. Deduplicated indexed capture and device-specific threadgroup tuning remain absent. No logging or application-specific tuning is added here.

AI assistance

The original contribution was developed with Claude Code assistance, as described in the prior revision. This local rebase, selection, additional tests and draft update were prepared with Codex assistance. The three opt-in commits were developed and checked with Claude Code assistance. Existing license headers and authors are preserved.

carbonimax and others added 12 commits October 9, 2026 12:57
The render state only kept the Metal primitive type, which cannot tell a
triangle fan from a triangle list. It now also keeps the Vulkan topology,
static from the pipeline or dynamic from vkCmdSetPrimitiveTopology, and the
encoder state reads the effective one through getPrimitiveTopology().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The draw paths decided whether to convert a triangle fan to triangles from
the topology the pipeline was created with, ignoring a primitive topology
set with vkCmdSetPrimitiveTopology. A pipeline created with another
triangle topology drew a dynamic triangle fan as a triangle list, and a
pipeline created as a triangle fan drew a dynamic triangle list or strip
through the fan conversion. The four draw paths now read the effective
topology from the encoder state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without layered rendering, each Metal pass renders one enabled view. The
first view of a pass was computed by walking view groups and decrementing
the pass index inside each group, which skipped the gaps between groups:
for view mask 0b101, pass 0 started at view 3 instead of view 0. The first
view of pass N is now the lowest bit left after clearing N enabled views.

mvkGetNextViewMaskGroup shifted a signed 1 by the running end position.
With view 31 enabled the loop reached a shift by 32, which is undefined.
The shifts are now unsigned and the loop stops once the mask is empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds a portable implementation of the PerVertexKHR fragment inputs and
the BaryCoordKHR and BaryCoordNoPerspKHR builtins of
VK_KHR_fragment_shader_barycentric for vertex and fragment pipelines on
GPUs with MSL 2.4. A draw whose fragment shader reads PerVertexKHR inputs
is captured by a vertex pass into a private scratch buffer, then replayed
by a raster pass that gives each fragment the values of the vertices of
its primitive.

The capture and replay is a separate path of the physical device, which
only an explicit test entry point, not part of the shipping targets,
selects. Without it, VK_KHR_fragment_shader_barycentric and
VK_NV_fragment_shader_barycentric keep their native path: they are
advertised on GPUs with Metal barycentric coordinates, BaryCoordKHR and
BaryCoordNoPerspKHR read Metal barycentric coordinates, and PerVertexKHR
inputs are not supported. The same flag selects the native vertex_value
path of Apple10 GPUs, the commitment of memoryless attachments and the
deferral of immediate Metal command buffer prefill.

In a test-enabled build, some implementation paths outside the claimed
scope remain reachable: indirect draws, mesh shaders, the native path for
Apple10 GPUs and multiview with more than one view. They are not
qualified or claimed as supported. Tessellation admission has a separate
test-only compile guard.

Some changes apply whether or not the extension is enabled: Metal command
buffer completion handlers go through one device-level path, device loss
is published only after submitted encodings and handlers complete,
vkQueueSubmit prepares every batch before it submits any, compatible
tessellation pipelines keep their levels in float32, and the pipeline
cache UUID and archive layout change.

This builds on the preceding commits, which track the effective primitive
topology and fix multiview pass grouping. It adds record-time topology
tracking, which sizes the capture scratch buffer, and keeps the pipeline
topology accessor that this sizing uses.

The pinned SPIRV-Cross revision is the MSL capture and replay series this
change depends on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Converter tests (MoltenVKShaderConverter/Tests) check the SPIR-V
reflection, interface matching, pipeline cache serialization and MSL
generation of the capture and replay shaders on the CPU.

Vulkan tests (MoltenVK/Tests) build an experimental library with the test
entry point that selects the capture and replay path, then run host-side
checks (prefill, helper preflight, scratch sizing, draw sizes, primitive
restart, topology, capture variants, memoryless attachments), a dynamic
primitive topology oracle, the refusal of memoryless attachments split
across capture and replay, and the CTS cases of the claimed scope through
a loader shim, with their case lists and expected statuses. Six of the
host-side checks run on the CPU only; the primitive restart and
memoryless checks submit Metal work to the GPU with their GPU argument.
The memoryless storage check also covers the native path, where an
enabled barycentric extension keeps memoryless storage.
PerVertexKHR-README.md describes the scope, the activation, the commands
and the expected results, including the origin of the expected E
failures and the limit of the F check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Query pools are retained when their queries are activated, and released
once the completion handler has finished them. Two paths of the command
buffer completion handling in this change never finish them: a failed
continuation leaves no Metal command buffer, and a prefilled command
buffer that is reset or freed before submission is abandoned and never
enqueued. An abandoned command buffer can hold ordinary commands as well
as captured draws. Release the pools on those paths too.

A continuation can stop waiting during a device loss before Metal
completes its work. The release stays safe because device loss is
published only after submitted encodings and completion handlers
complete, so valid Vulkan usage keeps each pool alive while its commands
are pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Test scripts create their default output directory under TMPDIR instead of
a fixed path, and two code comments are reworded. No functional change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The converter test still expected the 156 bytes of padding of an earlier
SPIRVToMSLConversionConfiguration layout. The production archive check in
MVKPipeline.mm now uses 154, which matches the current layout. Test change
only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cache immutable replay tables per command encoding pool, topology and provoking mode. Admit single-instance draws and complete instanced lists only; preserve per-draw tables for restart and incomplete lists. Retain superseded buffers until pool destruction so pending command buffers remain valid, and fall back if allocation fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fely

Validate replay table lengths before CPU population and use memcpy for the float-to-integer bit representation. This preserves the existing error path and avoids strict-aliasing undefined behavior.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keep the upstream depth clip configuration when compiling vertex capture. The pinned SPIRV-Cross replay API explicitly rejects depth clip emulation; preserving this requirement refuses unsupported replay pipelines instead of silently dropping the transform. Ordinary vertex pipelines retain the upstream configuration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cover rejection of unsupported depth clip replay and preservation of the upstream depth clip result flag in pipeline archives. Document shared table admission, lifetime and the sanitizer test command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
carbonimax and others added 3 commits October 14, 2026 11:32
Each PerVertexKHR capture ends the render encoder, runs its compute
capture and restarts the render pass, which costs one encoder switch per
captured draw. With MVK_CONFIG_PERVERTEX_PREPASS=1, when a subpass opens,
its indexed PerVertexKHR captures are encoded ahead of time in a single
compute encoder, replaying the state commands they depend on and
restoring the encoder state afterwards, push descriptors included. The
prepass stops at barriers, subpass changes, copies, queries, secondary
command buffers, and non-indexed, tessellation and indirect draws.
Without the variable, or with any other value, encoding is unchanged.

PerVertexPrepassStateTests.mm checks the state save and restore on the
CPU; the GPU path has no test yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Scratch buffers of captured draws are allocated per draw and each one
commits the residency set on its own. Two opt-in levers reduce that
overhead; both are off by default and encoding is unchanged without them:
- MVK_CONFIG_PERVERTEX_BATCH_RESIDENCY=1: the scratch allocations of a
  Metal command buffer join the residency set together, with one commit
  at submission and one at completion;
- MVK_CONFIG_PERVERTEX_SCRATCH_POOL=1: scratch buffers of direct draws
  come from free lists keyed by storage mode and power-of-two size, stay
  resident and are recycled on their last reference. The pool keeps its
  high-water mark until the device is destroyed.

PerVertexScratchPoolTests.mm checks size classes, storage modes, return
on the last reference and single residency per created buffer on the
CPU, from the allocation calls of a fake device. The test device of
PerVertexHelperPreflightTests.mm gains the pool accessor, without a pool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vkCmdDrawMeshTasksIndirectEXT is refused, so a client has to read the
grid back on the CPU, draining the GPU queue, before it can call
vkCmdDrawMeshTasksEXT. With MVK_CONFIG_MESH_INDIRECT_GPU=1, a call with
drawCount <= 1 records an MVKCmdDrawMeshTasksIndirect command and Metal
reads the three words of VkDrawMeshTasksIndirectCommandEXT itself through
drawMeshThreadgroupsWithIndirectBuffer. drawCount above 1 stays refused,
and without the variable the call is refused as before. The command has
nothing to capture, so the PerVertexKHR prepass continues past it.

Only native mesh pipelines are handled. Known gap: the direct draw
refuses a grid at or above the Metal limit at record time; an indirect
grid cannot be checked then, and Metal draws nothing for it without
reporting an error. No test covers this path yet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant