perf(webgpu): byteCount-bounded readback for over-provisioned buffers (#133) #151
3 changed files with 64 additions and 17 deletions
|
|
@ -613,7 +613,7 @@ env.wgpuWriteBufferRange = (handle, dstByteOffset, srcPtr, byteSize) => {
|
||||||
const READBACK_IDLE = 0;
|
const READBACK_IDLE = 0;
|
||||||
const READBACK_PENDING = 1;
|
const READBACK_PENDING = 1;
|
||||||
const READBACK_READY = 2;
|
const READBACK_READY = 2;
|
||||||
const readbacks = new Map(); // device-buffer handle → { staging, size, state, pendingData }
|
const readbacks = new Map(); // device-buffer handle → { staging, capacity, readBytes, state, pendingData }
|
||||||
// Readbacks scheduled this frame that still need their mapAsync kicked
|
// Readbacks scheduled this frame that still need their mapAsync kicked
|
||||||
// off — done after the frame's queue.submit so the map waits for the
|
// off — done after the frame's queue.submit so the map waits for the
|
||||||
// compute writes that wrote to `buf` to finish, not just the standalone
|
// compute writes that wrote to `buf` to finish, not just the standalone
|
||||||
|
|
@ -623,21 +623,30 @@ const pendingReadbackMaps = [];
|
||||||
env.wgpuReadbackEnqueue = (handle, byteSize, resetBytes) => {
|
env.wgpuReadbackEnqueue = (handle, byteSize, resetBytes) => {
|
||||||
const buf = buffers.get(handle);
|
const buf = buffers.get(handle);
|
||||||
if (!buf) return;
|
if (!buf) return;
|
||||||
const aligned = (byteSize + 3) & ~3;
|
|
||||||
const resetAligned = resetBytes > 0 ? ((resetBytes + 3) & ~3) : 0;
|
const resetAligned = resetBytes > 0 ? ((resetBytes + 3) & ~3) : 0;
|
||||||
let rb = readbacks.get(handle);
|
let rb = readbacks.get(handle);
|
||||||
if (!rb) {
|
if (!rb) {
|
||||||
|
// Size the staging buffer to the FULL device-buffer capacity, not
|
||||||
|
// this first call's byteSize: a prefix readback (byteCount < size)
|
||||||
|
// is allowed to vary call-to-call, so the staging must cover any
|
||||||
|
// later full-size drain without re-allocating.
|
||||||
|
const capacity = Math.max(16, (buf.size + 3) & ~3);
|
||||||
rb = {
|
rb = {
|
||||||
staging: device.createBuffer({
|
staging: device.createBuffer({
|
||||||
size: Math.max(16, aligned),
|
size: capacity,
|
||||||
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
|
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
|
||||||
}),
|
}),
|
||||||
size: aligned,
|
capacity,
|
||||||
|
readBytes: 0, // bytes copied/mapped by the in-flight enqueue
|
||||||
state: READBACK_IDLE,
|
state: READBACK_IDLE,
|
||||||
pendingData: null,
|
pendingData: null,
|
||||||
};
|
};
|
||||||
readbacks.set(handle, rb);
|
readbacks.set(handle, rb);
|
||||||
}
|
}
|
||||||
|
// Copy/map only the requested prefix — that's the whole point of the
|
||||||
|
// byteCount path: skip the over-provisioned tail GPU→staging→wasm.
|
||||||
|
const aligned = Math.min((byteSize + 3) & ~3, rb.capacity);
|
||||||
|
rb.readBytes = aligned;
|
||||||
if (rb.state !== READBACK_IDLE) {
|
if (rb.state !== READBACK_IDLE) {
|
||||||
// Previous map still in flight (or has data nobody polled yet);
|
// Previous map still in flight (or has data nobody polled yet);
|
||||||
// skip this enqueue AND the paired reset. Events written by the
|
// skip this enqueue AND the paired reset. Events written by the
|
||||||
|
|
@ -681,8 +690,9 @@ env.wgpuReadbackEnqueue = (handle, byteSize, resetBytes) => {
|
||||||
if (resetAligned > 0) enc.clearBuffer(buf, 0, resetAligned);
|
if (resetAligned > 0) enc.clearBuffer(buf, 0, resetAligned);
|
||||||
queue.submit([enc.finish()]);
|
queue.submit([enc.finish()]);
|
||||||
rb.state = READBACK_PENDING;
|
rb.state = READBACK_PENDING;
|
||||||
rb.staging.mapAsync(GPUMapMode.READ).then(() => {
|
const mapBytes = rb.readBytes;
|
||||||
rb.pendingData = new Uint8Array(rb.staging.getMappedRange()).slice();
|
rb.staging.mapAsync(GPUMapMode.READ, 0, mapBytes).then(() => {
|
||||||
|
rb.pendingData = new Uint8Array(rb.staging.getMappedRange(0, mapBytes)).slice();
|
||||||
rb.staging.unmap();
|
rb.staging.unmap();
|
||||||
rb.state = READBACK_READY;
|
rb.state = READBACK_READY;
|
||||||
}).catch(e => {
|
}).catch(e => {
|
||||||
|
|
@ -1010,8 +1020,9 @@ env.wgpuFrameEnd = () => {
|
||||||
// writes (not pre-substep state).
|
// writes (not pre-substep state).
|
||||||
while (pendingReadbackMaps.length > 0) {
|
while (pendingReadbackMaps.length > 0) {
|
||||||
const rb = pendingReadbackMaps.pop();
|
const rb = pendingReadbackMaps.pop();
|
||||||
rb.staging.mapAsync(GPUMapMode.READ).then(() => {
|
const mapBytes = rb.readBytes;
|
||||||
rb.pendingData = new Uint8Array(rb.staging.getMappedRange()).slice();
|
rb.staging.mapAsync(GPUMapMode.READ, 0, mapBytes).then(() => {
|
||||||
|
rb.pendingData = new Uint8Array(rb.staging.getMappedRange(0, mapBytes)).slice();
|
||||||
rb.staging.unmap();
|
rb.staging.unmap();
|
||||||
rb.state = READBACK_READY;
|
rb.state = READBACK_READY;
|
||||||
}).catch(e => {
|
}).catch(e => {
|
||||||
|
|
|
||||||
|
|
@ -189,8 +189,17 @@ int main() {
|
||||||
push.origin[2] = origin0 + float(kHitZ) * kSpacing;
|
push.origin[2] = origin0 + float(kHitZ) * kSpacing;
|
||||||
push.dir[0] = -1.0f; push.dir[1] = 0.0f; push.dir[2] = 0.0f;
|
push.dir[0] = -1.0f; push.dir[1] = 0.0f; push.dir[2] = 0.0f;
|
||||||
|
|
||||||
|
// Drive byteCount-bounded prefix readback (issue #133) once the full read
|
||||||
|
// has verified the hit: re-read ONLY the first 8 bytes (hit +
|
||||||
|
// instanceCustomIndex) with byteCount=8 after poisoning the host mirror,
|
||||||
|
// and confirm the two prefix fields land while primitiveIndex (bytes 8..11)
|
||||||
|
// stays poisoned — i.e. the copy was actually bounded, not full-capacity.
|
||||||
|
constexpr std::uint32_t kPoison = 0xEEEEEEEEu;
|
||||||
|
constexpr std::uint32_t kPrefixLen = 2 * sizeof(std::uint32_t); // hit + customIndex
|
||||||
|
|
||||||
static int frame = 0;
|
static int frame = 0;
|
||||||
static bool dispatched = false;
|
static bool dispatched = false;
|
||||||
|
static bool prefixSent = false;
|
||||||
static bool reported = false;
|
static bool reported = false;
|
||||||
EventListener<void> tick(&window.onBeforeUpdate, [&]() {
|
EventListener<void> tick(&window.onBeforeUpdate, [&]() {
|
||||||
if (reported) return;
|
if (reported) return;
|
||||||
|
|
@ -199,7 +208,7 @@ int main() {
|
||||||
pickShader.Dispatch(&push, sizeof(push), pickHandles, 1, 1, 1);
|
pickShader.Dispatch(&push, sizeof(push), pickHandles, 1, 1, 1);
|
||||||
pickBuf.EnqueueReadback();
|
pickBuf.EnqueueReadback();
|
||||||
dispatched = true;
|
dispatched = true;
|
||||||
} else if (dispatched && pickBuf.PollReadback()) {
|
} else if (dispatched && !prefixSent && pickBuf.PollReadback()) {
|
||||||
const PickResult& r = pickBuf.value[0];
|
const PickResult& r = pickBuf.value[0];
|
||||||
const bool ok = (r.hit == 1u) && (r.instanceCustomIndex == kExpectedCustomIndex);
|
const bool ok = (r.hit == 1u) && (r.instanceCustomIndex == kExpectedCustomIndex);
|
||||||
std::println("[RayQueryPick] result: hit={} customIndex={} prim={} t={}",
|
std::println("[RayQueryPick] result: hit={} customIndex={} prim={} t={}",
|
||||||
|
|
@ -210,6 +219,20 @@ int main() {
|
||||||
std::println("[RayQueryPick] FAIL — expected hit=1 customIndex={}, got hit={} customIndex={}",
|
std::println("[RayQueryPick] FAIL — expected hit=1 customIndex={}, got hit={} customIndex={}",
|
||||||
kExpectedCustomIndex, r.hit, r.instanceCustomIndex);
|
kExpectedCustomIndex, r.hit, r.instanceCustomIndex);
|
||||||
}
|
}
|
||||||
|
// Now kick off the bounded prefix re-read. Poison the whole mirror
|
||||||
|
// first; the prefix poll must overwrite only bytes [0,8).
|
||||||
|
pickBuf.value[0] = { kPoison, kPoison, kPoison, 0.0f };
|
||||||
|
pickBuf.EnqueueReadback(/*resetBytes*/ 0, /*byteCount*/ kPrefixLen);
|
||||||
|
prefixSent = true;
|
||||||
|
} else if (prefixSent && pickBuf.PollReadback(/*byteCount*/ kPrefixLen)) {
|
||||||
|
const PickResult& r = pickBuf.value[0];
|
||||||
|
const bool prefixOk = (r.hit == 1u)
|
||||||
|
&& (r.instanceCustomIndex == kExpectedCustomIndex)
|
||||||
|
&& (r.primitiveIndex == kPoison); // tail untouched
|
||||||
|
std::println("[RayQueryPick] prefix readback (byteCount={}): hit={} customIndex={} prim=0x{:08X}",
|
||||||
|
kPrefixLen, r.hit, r.instanceCustomIndex, r.primitiveIndex);
|
||||||
|
std::println("[RayQueryPick] {} — byteCount-bounded readback copied only the live prefix",
|
||||||
|
prefixOk ? "PASS" : "FAIL");
|
||||||
// The render loop runs after main's _Exit, where stdio is never
|
// The render loop runs after main's _Exit, where stdio is never
|
||||||
// flushed implicitly — push the verdict out explicitly.
|
// flushed implicitly — push the verdict out explicitly.
|
||||||
std::fflush(stdout);
|
std::fflush(stdout);
|
||||||
|
|
|
||||||
|
|
@ -102,10 +102,18 @@ export namespace Crafter {
|
||||||
FlushDeviceRange(off, off, kStride);
|
FlushDeviceRange(off, off, kStride);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedule a GPU→CPU readback of this buffer's entire contents.
|
// Schedule a GPU→CPU readback of this buffer. Asynchronous; data
|
||||||
// Asynchronous; data isn't ready until a later PollReadback
|
// isn't ready until a later PollReadback returns true. Successive
|
||||||
// returns true. Successive Enqueues without a Poll are dropped
|
// Enqueues without a Poll are dropped — they're a no-op while the
|
||||||
// — they're a no-op while the previous map is in flight.
|
// previous map is in flight.
|
||||||
|
//
|
||||||
|
// `byteCount` (0 = the whole buffer) bounds the readback to the
|
||||||
|
// live prefix: an over-provisioned event-queue buffer need only
|
||||||
|
// copy its header / used span GPU→staging→wasm, not the full
|
||||||
|
// capacity, every drain. Clamped to `size`. The staging buffer is
|
||||||
|
// sized to the full capacity, so the prefix length may vary from
|
||||||
|
// one Enqueue to the next; pass the SAME byteCount to the matching
|
||||||
|
// PollReadback so the right number of bytes lands in `.value`.
|
||||||
//
|
//
|
||||||
// `resetBytes` ≥ 0 — if non-zero, the first `resetBytes` bytes
|
// `resetBytes` ≥ 0 — if non-zero, the first `resetBytes` bytes
|
||||||
// of THIS buffer are clearBuffer-cleared on the GPU command
|
// of THIS buffer are clearBuffer-cleared on the GPU command
|
||||||
|
|
@ -114,17 +122,22 @@ export namespace Crafter {
|
||||||
// The reset is tied to a successful enqueue (skipped enqueue =
|
// The reset is tied to a successful enqueue (skipped enqueue =
|
||||||
// skipped reset), preserving accumulated state across missed
|
// skipped reset), preserving accumulated state across missed
|
||||||
// drains.
|
// drains.
|
||||||
void EnqueueReadback(std::uint32_t resetBytes = 0) {
|
void EnqueueReadback(std::uint32_t resetBytes = 0, std::uint32_t byteCount = 0) {
|
||||||
|
const std::uint32_t n = (byteCount == 0 || byteCount > size) ? size : byteCount;
|
||||||
WebGPU::wgpuReadbackEnqueue(handle,
|
WebGPU::wgpuReadbackEnqueue(handle,
|
||||||
static_cast<std::int32_t>(size),
|
static_cast<std::int32_t>(n),
|
||||||
static_cast<std::int32_t>(resetBytes));
|
static_cast<std::int32_t>(resetBytes));
|
||||||
}
|
}
|
||||||
// Try to copy the readback bytes into this->value. Returns true
|
// Try to copy the readback bytes into this->value. Returns true
|
||||||
// if the previous EnqueueReadback resolved and the data is now
|
// if the previous EnqueueReadback resolved and the data is now
|
||||||
// mirrored into .value; false if the map is still pending.
|
// mirrored into .value; false if the map is still pending.
|
||||||
bool PollReadback() requires(Mapped) {
|
// `byteCount` must match the value passed to the paired
|
||||||
|
// EnqueueReadback (0 = whole buffer); it bounds the staging→wasm
|
||||||
|
// copy to the live prefix that was captured.
|
||||||
|
bool PollReadback(std::uint32_t byteCount = 0) requires(Mapped) {
|
||||||
|
const std::uint32_t n = (byteCount == 0 || byteCount > size) ? size : byteCount;
|
||||||
return WebGPU::wgpuReadbackPoll(handle, this->value,
|
return WebGPU::wgpuReadbackPoll(handle, this->value,
|
||||||
static_cast<std::int32_t>(size)) != 0;
|
static_cast<std::int32_t>(n)) != 0;
|
||||||
}
|
}
|
||||||
// Non-consuming readiness probe. Returns true if a subsequent
|
// Non-consuming readiness probe. Returns true if a subsequent
|
||||||
// PollReadback would succeed without changing state otherwise.
|
// PollReadback would succeed without changing state otherwise.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue