Skip to content

Record dispatches into one batch and submit when a result is read - #4

Open
yujiteshima wants to merge 2 commits into
mainfrom
deferred-submit
Open

yujiteshima wants to merge 2 commits into
mainfrom
deferred-submit

Conversation

@yujiteshima

Copy link
Copy Markdown
Owner

What

Every operator used to be its own round trip: build a command buffer and a descriptor set, submit, wait on a fence, free. Almost all of the time went into that, not into arithmetic — a * 2 + 1 - 3 + 4 cost four waits.

This PR records every dispatch into one long-lived command buffer (with a memory barrier after each) and submits once, at the first sync point:

  • a host read or write of a buffer the batch touches (to_a, head, sum, seq, fill, …) — map_buffer is the sync point
  • a full descriptor pool (256 dispatches; just flushes early)
  • GPU.sync
  • shutdown

Buffers carry the epoch they were last bound in: that is how map_buffer decides whether to flush, and how the GC finalizer knows to park a still-referenced buffer in a graveyard until the batch has run. rfft benefits without changes: its 1 + log2(n) passes become one submit.

API

  • GPU.pending — dispatches recorded but not yet submitted
  • GPU.sync — flush explicitly (handy when timing a batch)
  • GPU.sync_mode = :eager | :deferred (default :deferred); :eager restores a submit and a wait per dispatch, kept so the two can be measured
  • dispatch_pipeline() is exported in gpu_internal.h, so add-on gems (mruby-gpu-kernel) join the same batch. dispatch_compute() now takes GpuBuffer **.

Numbers (Apple M5 / MoltenVK, 100-run mean)

a * 2 + 1 - 3 + 4 power_spectrum
1,024 elements, eager (4 / 12 waits) 0.95 ms 2.69 ms
1,024 elements, deferred (1 wait) 0.25 ms 0.29 ms
1,048,576 elements, eager 2.33 ms 7.13 ms
1,048,576 elements, deferred 2.12 ms 2.74 ms

At 1M elements the four passes over memory dominate, and batching cannot remove those — fusing the expression into one shader (mruby-gpu-kernel) does: 0.35 ms.

Tests

59/59: the 45 existing tests plus 14 for batching — pending count, flush on read, a host write flushing a queued reader first, an untouched buffer not flushing, a batch longer than the descriptor pool, GC'd intermediates outliving the batch, sum, rfft deferred == eager, eager mode, sync_mode errors. mruby-gpu-kernel's 20 tests pass against this branch.

Notes

🤖 Generated with Claude Code

claude and others added 2 commits August 8, 2026 07:40
52 vk* call sites, 26 of which return a VkResult, and none were checked. A
failed call left an unusable handle in place and execution carried on, which
showed up in three ways:

  - the VM died. With no Vulkan driver present, vkCreateInstance failed, the
    null instance went to vkEnumeratePhysicalDevices, and the process aborted
    (exit 134) with no Ruby-level error. Same shape in create_buffer: when no
    memory type was both host-visible and host-coherent the search fell back
    to index 0, and map_buffer then returned NULL straight into a write loop.
  - the error named the wrong cause. vkCreateComputePipelines leaves the
    handle VK_NULL_HANDLE on failure, which ensure_pipeline read as "the .spv
    is missing" -- telling the user to run `make -C shader` for shaders that
    were already built and had been rejected by the driver.
  - the numbers were quietly wrong. An unchecked vkQueueSubmit meant a lost
    device never signalled its fence, vkWaitForFences returned at once, and
    the caller read the unwritten buffer back as ordinary Floats.

Adds VK_CHECK / gpu_check, raising a RuntimeError naming the call and the
VkResult. gpu_init and dispatch_compute now take mrb_state so they can raise;
map_buffer too, and it no longer returns NULL. Pipeline state is recorded per
pipeline (PIPE_MISSING_SPV vs PIPE_CREATE_FAILED) so the two cases give
opposite advice. A failed gpu_init sets init_failed rather than being retried
on every later operation, which would leak a fresh context each time.

Since mrb_raise unwinds, buffers are now wrapped before the dispatch that
fills them -- the GC can only free what it owns. That also covers the case
where GPU::SFloat.cast is handed a non-numeric element. narray_sum's partial
buffer is wrapped for the same reason, so destroy_buffer is now only the
finalizer and create_buffer's own unwind path.

Two silent-truncation guards in the same family: GPU::SFloat.new now rejects
a size past UINT32_MAX rather than wrapping it into a different length, and a
dispatch past maxComputeWorkGroupCount is rejected rather than left to the
driver -- lavapipe runs it and returns the right answer, so the limit is easy
to miss in testing, while a hardware-bounded driver may not. GPU.info gains
:max_workgroups so callers and bug reports can see the bound.

Verified on Mesa lavapipe: 46 tests pass (2 new), plus the new
test/shader_error_test.rb in both modes. It is a separate file because it has
to break the process-wide GPU context. Checked by hand that the no-device path
now raises and leaves the VM alive where it previously aborted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAgXRCCQ43jwXzeXKS6hG4
Every operator used to be its own round trip: build a command buffer and a
descriptor set, submit, wait on a fence, free. Almost all of the time went
into that, not into arithmetic -- a 4-operator chain cost 4 waits.

Now dispatch_pipeline() records into one long-lived command buffer with a
memory barrier after each dispatch, and gpu_flush() submits and waits once,
at the first sync point: a host read or write of a buffer the batch touches
(map_buffer), a full descriptor pool, GPU.sync, or shutdown. Buffers carry
the epoch they were last bound in, which is how map_buffer decides whether
to flush and how the GC finalizer knows to park a still-referenced buffer
in a graveyard until the batch has run.

GPU.pending shows what is queued; GPU.sync_mode = :eager restores a wait per
dispatch for measurement. dispatch_pipeline() is exported so add-on gems
(mruby-gpu-kernel) join the same batch.

Apple M5, 1024 elements: a * 2 + 1 - 3 + 4 goes from 0.95 ms (4 waits) to
0.25 ms (1 wait); power_spectrum from 2.69 ms (12 waits) to 0.29 ms.

Co-Authored-By: Claude Fable 5.1 <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.

2 participants