perf(pyrowave): pool encoder scratch buffers + fix client parser O(n²) — lift the 2.5 Gbps wall
ci / web (push) Successful in 47s
ci / docs-site (push) Successful in 1m7s
apple / swift (push) Successful in 1m17s
decky / build-publish (push) Successful in 25s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
ci / bench (push) Successful in 5m51s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4m51s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
release / apple (push) Successful in 9m11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m6s
deb / build-publish (push) Successful in 12m55s
android / android (push) Successful in 13m15s
docker / deploy-docs (push) Successful in 25s
deb / build-publish-host (push) Successful in 13m7s
arch / build-publish (push) Successful in 16m4s
windows-host / package (push) Successful in 16m19s
apple / screenshots (push) Successful in 6m34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m47s
ci / rust (push) Successful in 24m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m31s

Real-world PyroWave streaming maxed ~2.5 Gbps with sagging fps while raw
transport does 4.8. Root-caused to serial per-frame paths at BOTH ends
(the transport was never the limit); this fixes the two dominant ones.

Host (vendored shim, patch 0004): pyrowave_encoder_encode_gpu_synchronous
allocated four Vulkan buffers (meta + bitstream, Device + CachedHost) on
EVERY frame. At 240 fps with MB-scale bitstreams that per-frame allocator
churn stalled the encode itself. Pool them on the encoder and reuse across
frames (recreate only on a size grow); the sizes are session-fixed, so it
is pure reuse after frame 1. On an RTX 4090 the 5120x1440 submit+fence-wait
drops ~15 ms -> ~1 ms, i.e. the host serial ceiling goes 64 -> 1025 fps
(444+HDR 44 -> 614). Safe under the synchronous encode model; re-validated
by pyrowave_win_smoke (Windows) and pyrowave_smoke/_444 (Linux). Applies to
both host encoder paths (they share the shim).

Client (Apple Metal decoder): WaveletBitstream.parse reserved the payload
buffer per packet (reserveCapacity(count + words), an exact realloc each of
~3000 packets/frame => O(n²)) and copied word-by-word. Reserve once up
front and memcpy each packet's coefficients in one shot (all Apple
platforms are little-endian, so the wire's LE u32s land verbatim; memcpy is
alignment-free). 5.44 ms -> 0.055 ms per 1.44 MB frame (25x); byte-identical
(parser unit tests + golden-frame PSNR unchanged).

Also:
- native.rs: PUNKTFUNK_PYROWAVE_MAX_MBPS caps PyroWave's open-loop Automatic
  bitrate pin for hosts on a constrained link (unset => no cap; an explicit
  client rate bypasses it). The pin is all-intra + ABR-off, so at a high
  pixel rate it can outrun the fabric (4:4:4+HDR 5120x1440@240 pins ~5.3
  Gbps, over a 5 GbE link) and the overshoot just becomes loss.
- pf-encode caps(): report the real opened chroma instead of a hardcoded
  4:2:0 default, so a genuine 4:4:4 session no longer trips the spurious
  "encoder chroma disagrees with the negotiated Welcome" warn. Also fix a
  latent Windows reset() that rebuilt at 4:2:0 for a 4:4:4 session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-18 19:09:23 +02:00
parent b89dbfa979
commit 9fe9c451dc
7 changed files with 264 additions and 38 deletions
@@ -0,0 +1,108 @@
Encoder scratch-buffer pool — PUNKTFUNK LOCAL PATCH.
Not upstream. pyrowave_encoder_encode_gpu_synchronous() allocated four Vulkan
buffers on EVERY frame (meta + bitstream, each Device-local and CachedHost).
At streaming rates (240 fps, 1-3 MB bitstreams) that per-frame allocator churn
did not just cost CPU — it stalled the encode: measured on an RTX 4090 the
per-frame submit+fence-wait at 5120x1440 was ~15 ms (≈64 fps ceiling) and
collapsed to ~1 ms (≈1025 fps) once the buffers are pooled. The four buffers are
sized from the fixed session resolution + pinned bitrate budget, so after the
first frame they are pure reuse; each is only re-created if a larger size is
requested. Safe because the encode is synchronous (compute_num_packets/packetize
wait the fence before the next encode reuses a buffer), and holding the handles
on the encoder keeps next_frame_context() from recycling them.
Correctness re-validated by the pyrowave_win_smoke round-trip (100/180/60 across
SDR/HDR x 420/444) after the change.
diff --git a/crates/pyrowave-sys/vendor/pyrowave/pyrowave_c.cpp b/crates/pyrowave-sys/vendor/pyrowave/pyrowave_c.cpp
index 114b0bfc..eb917e64 100644
--- a/crates/pyrowave-sys/vendor/pyrowave/pyrowave_c.cpp
+++ b/crates/pyrowave-sys/vendor/pyrowave/pyrowave_c.cpp
@@ -799,6 +799,10 @@ struct pyrowave_encoder_opaque
Fence queued_fence;
BufferHandle queued_meta;
BufferHandle queued_bitstream;
+ // PUNKTFUNK: the GPU-side twins of queued_meta/queued_bitstream, pooled on the encoder so
+ // the encode path reuses them across frames instead of allocating four buffers per frame.
+ BufferHandle queued_meta_gpu;
+ BufferHandle queued_bitstream_gpu;
ChromaSubsampling chroma = {};
int width = 0;
int height = 0;
@@ -936,41 +940,49 @@ pyrowave_encoder_encode_gpu_synchronous(pyrowave_encoder encoder,
device->next_frame_context();
- BufferCreateInfo bufinfo = {};
- bufinfo.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT |
- VK_BUFFER_USAGE_TRANSFER_DST_BIT |
- VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
-
- bufinfo.size = encoder->encoder.get_meta_required_size();
- bufinfo.domain = BufferDomain::CachedHost;
- encoder->queued_meta = device->create_buffer(bufinfo);
-
- if (!encoder->queued_meta)
- return PYROWAVE_ERROR_OUT_OF_HOST_MEMORY;
-
- bufinfo.domain = BufferDomain::Device;
- auto queued_meta_gpu = device->create_buffer(bufinfo);
-
- if (!queued_meta_gpu)
- return PYROWAVE_ERROR_OUT_OF_DEVICE_MEMORY;
-
auto target_bitstream_size = rate_control->maximum_bitstream_size & ~VkDeviceSize(3u);
// Check for bogus sizes.
if (target_bitstream_size > UINT32_MAX || target_bitstream_size == 0)
return PYROWAVE_ERROR_INVALID_ARGUMENT;
- bufinfo.size = target_bitstream_size + encoder->encoder.get_meta_required_size();
- bufinfo.domain = BufferDomain::CachedHost;
- encoder->queued_bitstream = device->create_buffer(bufinfo);
+ const VkDeviceSize meta_size = encoder->encoder.get_meta_required_size();
+ const VkDeviceSize bitstream_size = target_bitstream_size + meta_size;
+
+ // PUNKTFUNK: pool the four scratch buffers on the encoder and only (re)create one when a
+ // larger size is needed. Upstream allocated all four (meta + bitstream, each Device +
+ // CachedHost) on every call; at streaming rates (240 fps, MB-scale bitstreams) that
+ // allocator churn dominated the per-frame CPU cost. The sizes are effectively constant per
+ // session (fixed resolution + a pinned bitrate budget), so after the first frame these are
+ // pure reuse. The synchronous encode model (packetize()/compute_num_packets() wait the
+ // fence before the next encode reuses a buffer) guarantees no in-flight GPU access to a
+ // buffer we hand back, and holding the handles keeps next_frame_context() from recycling
+ // them.
+ BufferCreateInfo bufinfo = {};
+ bufinfo.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT |
+ VK_BUFFER_USAGE_TRANSFER_DST_BIT |
+ VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
- if (!encoder->queued_bitstream)
- return PYROWAVE_ERROR_OUT_OF_HOST_MEMORY;
+ auto ensure_buffer = [&](BufferHandle &handle, VkDeviceSize size, BufferDomain domain) -> bool {
+ if (!handle || handle->get_create_info().size < size)
+ {
+ bufinfo.size = size;
+ bufinfo.domain = domain;
+ handle = device->create_buffer(bufinfo);
+ }
+ return bool(handle);
+ };
- bufinfo.domain = BufferDomain::Device;
- auto queued_bitstream_gpu = device->create_buffer(bufinfo);
+ auto &queued_meta_gpu = encoder->queued_meta_gpu;
+ auto &queued_bitstream_gpu = encoder->queued_bitstream_gpu;
- if (!queued_bitstream_gpu)
+ if (!ensure_buffer(encoder->queued_meta, meta_size, BufferDomain::CachedHost))
+ return PYROWAVE_ERROR_OUT_OF_HOST_MEMORY;
+ if (!ensure_buffer(queued_meta_gpu, meta_size, BufferDomain::Device))
+ return PYROWAVE_ERROR_OUT_OF_DEVICE_MEMORY;
+ if (!ensure_buffer(encoder->queued_bitstream, bitstream_size, BufferDomain::CachedHost))
+ return PYROWAVE_ERROR_OUT_OF_HOST_MEMORY;
+ if (!ensure_buffer(queued_bitstream_gpu, bitstream_size, BufferDomain::Device))
return PYROWAVE_ERROR_OUT_OF_DEVICE_MEMORY;
Encoder::BitstreamBuffers bitstream_buffers = {};