From a7a0d56ecdff63b060cf879684a029ff4a4d6681 Mon Sep 17 00:00:00 2001 From: claude-noether Date: Mon, 25 May 2026 22:58:00 +0200 Subject: [PATCH] =?UTF-8?q?Stage=202=20PR-a:=20predicted=20samples=20plumb?= =?UTF-8?q?ing=20=E2=80=94=20caller-supplied=20per-MB=20pixels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First concrete deliverable on the daedalus-decoder Stage 2 path post the 2026-05-25 architecture re-pin (memory: dejavu / frame-major UMA). Q2 decision: CPU intra prediction. libavcodec's existing NEON intra prediction kernels generate predicted samples per MB; daedalus-decoder accepts those samples through the API and uses them as the IDCT-add starting state. FFmpeg's `idct_add` semantics — dst += idct(coeffs); clip255 — fold DESIGN.md's Stage 3 reconstruction into the existing Stage 1 IDCT dispatch for free. No new GPU work. API change ---------- `daedalus_decoder_mb_input` gains a `const uint8_t *predicted` field: predicted [ 0 .. 256) — 16×16 luma, row-major raster predicted [256 .. 320) — 8×8 Cb, row-major raster predicted [320 .. 384) — 8×8 Cr, row-major raster NULL is legal and equivalent to all-zero predicted samples — preserves the existing IDCT-isolation test contract. Internal changes ---------------- - `daedalus_decoder` gains predicted_y (W×H) and predicted_uv (planar Cb||Cr, W×H/2) buffers allocated at create, zeroed at end of every flush_frame so NULL `mb->predicted` is indistinguishable from explicit zeros from one frame to the next. - `append_mb` splats mb->predicted into predicted_y/_uv at raster (mb_y*16, mb_x*16) for luma and (mb_y*8, mb_x*8) for each chroma component. - `flush_frame` replaces `calloc(scratch_y)` and `calloc(scratch_uv)` with `malloc + memcpy from predicted_y/_uv` — the IDCT dispatch then writes residual on top, clip-adding to the predicted samples in place. Test ---- `test_idct_bitexact` extended: - Generates random predicted samples (uint8_t) per MB alongside the existing random coeffs. - Pre-fills the reference ref_y / ref_cb / ref_cr planes with those same predicted samples at the corresponding raster positions BEFORE applying ref_idct4_add / ref_idct8_add per block. - Compares GPU output to reference byte-for-byte. Result on hertz (Pi 5 V3D 7.1), all three substrates: test_idct_bitexact 320 240 0xfeedface5a5a5a5a {cpu, qpu, auto} Y bytes diff: 0/76800 (0.0000%) Cb bytes diff: 0/19200 (0.0000%) Cr bytes diff: 0/19200 (0.0000%) BIT-EXACT PASS on all three substrates Catches any silent drift between substrates and any predicted-samples plumbing mistake on either the API or the dispatch side. Followups --------- - Stage 2 PR-b: deblock dispatch in flush_frame. - Stage 2 daemon refactor (parallel, daedalus-v4l2 daemon): replace avcodec_send_packet/receive_frame with a libavcodec-parser-only path that drives daedalus_decoder_append_mb in raster order + flush_frame at slice boundary. --- include/daedalus_decoder.h | 20 ++++++++++ src/daedalus_decoder.c | 82 +++++++++++++++++++++++++++++++++----- src/internal.h | 14 +++++++ tests/test_idct_bitexact.c | 78 +++++++++++++++++++++++++++++------- 4 files changed, 170 insertions(+), 24 deletions(-) diff --git a/include/daedalus_decoder.h b/include/daedalus_decoder.h index 639aefe..917fde2 100644 --- a/include/daedalus_decoder.h +++ b/include/daedalus_decoder.h @@ -89,6 +89,26 @@ struct daedalus_decoder_mb_input { * column-major within each 4x4 or 8x8 block (matches FFmpeg * convention). Caller-owned; copied during append. */ const int16_t *coeffs; /* points at exactly 384 int16_t */ + + /* Reconstructed predicted samples for this MB, planar order: + * [ 0 .. 256) — 16×16 luma, ROW-MAJOR raster (row 0 cols 0..15, + * row 1 cols 0..15, ..., row 15 cols 0..15) + * [256 .. 320) — 8×8 Cb, ROW-MAJOR raster + * [320 .. 384) — 8×8 Cr, ROW-MAJOR raster + * + * The caller (libavcodec's CPU intra-prediction kernels for Phase 1 + * I-frames; MC fallback for Phase 2 P-frames before GPU MC lands) + * populates this from neighbour samples per H.264 §8.3 / §8.4. + * `flush_frame()`'s reconstruction step is `clip255(predicted + + * idct(coeffs))` — the IDCT shader reads dst, adds the inverse + * transform, writes clipped — so a non-zero `predicted` here makes + * the output pixel a valid H.264 reconstruction; zero means + * residual-only (used by IDCT-isolation tests). + * + * NULL is legal and means "all-zero predicted samples" for this MB + * (the per-frame predicted buffer is zeroed at flush time so a NULL + * is indistinguishable from explicit zeros). */ + const uint8_t *predicted; /* NULL or exactly 384 uint8_t */ }; /* ------------------------------------------------------------------- diff --git a/src/daedalus_decoder.c b/src/daedalus_decoder.c index a48c0b2..80e3d25 100644 --- a/src/daedalus_decoder.c +++ b/src/daedalus_decoder.c @@ -54,7 +54,18 @@ daedalus_decoder *daedalus_decoder_create(int width, int height) dec->mb_descs = calloc((size_t) dec->n_mbs, sizeof(*dec->mb_descs)); dec->coeffs = calloc((size_t) dec->n_mbs * 384, sizeof(int16_t)); - if (!dec->mb_descs || !dec->coeffs) { + + /* Predicted-samples buffers — zero-initialised so a frame where + * every append_mb gets NULL `predicted` decodes residual-only + * (the Stage 1 scaffold contract). flush_frame zeroes these at + * end-of-frame to maintain that invariant for the next frame. */ + const size_t pred_y_size = (size_t) width * (size_t) height; + const size_t pred_uv_size = pred_y_size / 2; + dec->predicted_y = calloc(1, pred_y_size); + dec->predicted_uv = calloc(1, pred_uv_size); + + if (!dec->mb_descs || !dec->coeffs || + !dec->predicted_y || !dec->predicted_uv) { daedalus_decoder_destroy(dec); return NULL; } @@ -66,6 +77,8 @@ void daedalus_decoder_destroy(daedalus_decoder *dec) { if (!dec) return; + free(dec->predicted_uv); + free(dec->predicted_y); free(dec->coeffs); free(dec->mb_descs); if (dec->dctx) @@ -153,6 +166,34 @@ int daedalus_decoder_append_mb(daedalus_decoder *dec, mb->coeffs, 384 * sizeof(int16_t)); + /* Splat predicted samples into frame-scoped planes at raster + * (mb_y*16, mb_x*16) for luma, (mb_y*8, mb_x*8) for each chroma + * component. NULL → leave buffers as-is (zeroed at create + at + * end of each flush_frame); that's the zero-predictor contract. */ + if (mb->predicted) { + const size_t y_stride = (size_t) dec->width; + const size_t uv_stride = (size_t) dec->width / 2; + const size_t uv_plane = uv_stride * ((size_t) dec->height / 2); + + const uint8_t *p_y = mb->predicted; + const uint8_t *p_cb = mb->predicted + 256; + const uint8_t *p_cr = mb->predicted + 256 + 64; + + uint8_t *dst_y = &dec->predicted_y[ + (size_t) mb->mb_y * 16 * y_stride + (size_t) mb->mb_x * 16]; + uint8_t *dst_cb = &dec->predicted_uv[ + (size_t) mb->mb_y * 8 * uv_stride + (size_t) mb->mb_x * 8]; + uint8_t *dst_cr = &dec->predicted_uv[uv_plane + + (size_t) mb->mb_y * 8 * uv_stride + (size_t) mb->mb_x * 8]; + + for (int r = 0; r < 16; r++) + memcpy(&dst_y[(size_t) r * y_stride], &p_y[r * 16], 16); + for (int r = 0; r < 8; r++) { + memcpy(&dst_cb[(size_t) r * uv_stride], &p_cb[r * 8], 8); + memcpy(&dst_cr[(size_t) r * uv_stride], &p_cr[r * 8], 8); + } + } + dec->mbs_appended++; return 0; } @@ -174,14 +215,18 @@ int daedalus_decoder_append_mb(daedalus_decoder *dec, * int16 (64 Cb + 64 Cr); dispatch into a planar Cb||Cr scratch * buffer (W*H/4 each, concatenated W*H/2 total); CPU-interleave * into the caller's NV12 UV plane post-dispatch. - * - Both dispatches use predicted=0 (the scratch buffers are - * calloc'd); the shader does clip255(predicted + idct(coeffs)). + * - Both dispatches pre-fill the scratch from the per-frame + * predicted_y / predicted_uv buffers (accumulated by append_mb's + * per-MB predicted-samples splat). The IDCT shader's + * `dst += idct(coeffs)` + clip255 then folds reconstruction into + * the IDCT pass — no separate Stage 3 dispatch needed. * * What's NOT done yet (follow-on Phase 1 sub-PRs): - * - Intra prediction (Stage 2a wavefront): predicted is forced to 0, - * so output pixels are residual-only and not a valid frame decode. - * Sufficient for Vulkan round-trip validation, not for bit-exact - * against FFmpeg. + * - Intra prediction: caller-driven (Q2 decision 2026-05-25, CPU + * intra-pred via FFmpeg NEON kernels). Caller writes the + * intra-predicted samples into mb_input.predicted; this dispatch + * consumes them as the IDCT-add starting state. GPU wavefront + * intra-pred (DESIGN.md Stage 2a) is no longer planned. * - Motion compensation (Stage 2b): inter MBs not handled. * - High-profile IDCT 8x8 (Stage 1 extension). * - Chroma DC / luma Intra16x16 DC Hadamard pre-pass (currently we @@ -222,9 +267,17 @@ int daedalus_decoder_flush_frame(daedalus_decoder *dec, * transform_8x8_size_flag per MB), so we allocate worst-case for * each and track actual counts. */ + /* Pre-fill the dispatch scratch with the per-MB predicted samples + * accumulated by append_mb. daedalus-fourier's IDCT 4x4/8x8 + * shaders implement FFmpeg `idct_add` semantics — dst += idct(coeffs) + * with clip255 — so a non-zero predicted dst becomes the + * reconstruction step (residual + predicted → clip) "for free", + * collapsing DESIGN.md's Stage 3 into Stage 1's existing dispatch. */ const size_t y_stride_int = (size_t) dec->width; const size_t y_size = y_stride_int * (size_t) dec->height; - uint8_t *scratch_y = calloc(1, y_size); + uint8_t *scratch_y = malloc(y_size); + if (scratch_y) + memcpy(scratch_y, dec->predicted_y, y_size); const size_t worst_4x4 = (size_t) dec->n_mbs * 16; const size_t worst_8x8 = (size_t) dec->n_mbs * 4; @@ -349,7 +402,9 @@ int daedalus_decoder_flush_frame(daedalus_decoder *dec, const size_t cb_plane_size = chroma_w * chroma_h; const size_t uv_scratch_size = 2 * cb_plane_size; - scratch_uv = calloc(1, uv_scratch_size); + scratch_uv = malloc(uv_scratch_size); + if (scratch_uv) + memcpy(scratch_uv, dec->predicted_uv, uv_scratch_size); chroma_coeffs = malloc(n_chroma_blocks * 16 * sizeof(int16_t)); chroma_meta = malloc(n_chroma_blocks * sizeof(daedalus_h264_block_meta)); @@ -427,6 +482,15 @@ cleanup: free(coeffs8); free(coeffs4); free(scratch_y); + + /* Zero the predicted-samples buffers so the next frame starts from + * the all-zero-predictor baseline; MBs whose append_mb gets NULL + * for `predicted` then decode residual-only. */ + if (dec->predicted_y) + memset(dec->predicted_y, 0, (size_t) dec->width * (size_t) dec->height); + if (dec->predicted_uv) + memset(dec->predicted_uv, 0, (size_t) dec->width * (size_t) dec->height / 2); + dec->mbs_appended = 0; return rc; } diff --git a/src/internal.h b/src/internal.h index 87845c7..4f96c42 100644 --- a/src/internal.h +++ b/src/internal.h @@ -62,6 +62,20 @@ struct daedalus_decoder { int16_t *coeffs; /* n_mbs * 384 */ int mbs_appended; /* per-frame count */ + /* Per-frame predicted samples, accumulated by append_mb(), consumed + * by flush_frame() as the initial dst content for the IDCT-add + * dispatch (predicted + idct → clip → final pixel). Zeroed at end + * of each flush_frame so NULL `mb->predicted` is indistinguishable + * from explicit zeros. + * + * predicted_y: width × height, row-major (stride = width) + * predicted_uv: PLANAR Cb||Cr, each (width/2) × (height/2), so + * size = width × height / 2, with Cb plane at + * offset 0 and Cr at offset (width/2)*(height/2). + * Matches scratch_uv layout in flush_frame. */ + uint8_t *predicted_y; + uint8_t *predicted_uv; + /* Output format. */ daedalus_decoder_output_format output_fmt; diff --git a/tests/test_idct_bitexact.c b/tests/test_idct_bitexact.c index 6545ac5..f8ffcf8 100644 --- a/tests/test_idct_bitexact.c +++ b/tests/test_idct_bitexact.c @@ -1,12 +1,16 @@ /* SPDX-License-Identifier: BSD-2-Clause */ /* * test_idct_bitexact — phase1 stage1 bit-exact gate for the frame- - * scaled luma IDCT 4×4 dispatch. + * scaled luma + chroma IDCT 4×4 / 8×8 dispatch + Stage 2 predicted- + * samples plumbing. * - * Generates a frame of random coefficients, runs daedalus_decoder - * (with predicted=0 by the scaffold's flush_frame contract), and - * compares every output byte against an inline C reference that - * mirrors the H.264 §8.5.12.1 1D butterfly. + * Generates a frame of random coefficients AND random predicted + * samples per MB, runs daedalus_decoder (which writes the predicted + * samples into its frame-scoped predicted_y/_uv buffers via + * append_mb, then pre-fills the IDCT dispatch scratch from them in + * flush_frame), and compares every output byte against an inline C + * reference that mirrors the H.264 §8.5.12.1 1D butterfly applied + * to the same predicted+coeffs inputs. * * Why "bit-exact": the GPU shader and the C reference apply the same * integer arithmetic. Any rounding / sign / overflow disagreement is @@ -30,7 +34,7 @@ * Not in scope (covered by other tests / future PRs): * - Chroma DC / Intra16x16 DC Hadamard pre-pass * - bit-exactness against real H.264 streams (test-vector PR) - * - non-zero predicted pixels (intra prediction lands in Stage 2a) + * - deblock (lands in Stage 2 PR-b after this one) */ #include "daedalus_decoder.h" @@ -202,15 +206,25 @@ int main(int argc, char **argv) /* Build the per-MB inputs. Each MB gets 16 luma 4×4 blocks of * random coeffs in [-512, 511] — same range as the daedalus-fourier - * cycle-6 M1 gate uses. */ - int16_t (*per_mb_coeffs)[384] = malloc((size_t) n_mbs * sizeof(*per_mb_coeffs)); - if (!per_mb_coeffs) { fprintf(stderr, "alloc fail\n"); return 1; } + * cycle-6 M1 gate uses. Plus random predicted samples (uint8 each) + * to exercise the Stage 2 predicted-samples plumbing — when this + * is non-zero, flush_frame must pre-fill the IDCT-dispatch scratch + * from dec->predicted_y / dec->predicted_uv (Stage 2 PR-a) rather + * than from calloc-zero (the Stage 1 scaffold contract). The + * reference path mirrors this by pre-filling ref_y / ref_cb / ref_cr + * from the same predicted bytes BEFORE the per-block ref_idct*_add + * calls — so the test catches any mismatch between caller-supplied + * predicted and what reaches the GPU's IDCT-add starting state. */ + int16_t (*per_mb_coeffs)[384] = malloc((size_t) n_mbs * sizeof(*per_mb_coeffs)); + uint8_t (*per_mb_predicted)[384] = malloc((size_t) n_mbs * sizeof(*per_mb_predicted)); + if (!per_mb_coeffs || !per_mb_predicted) { fprintf(stderr, "alloc fail\n"); return 1; } for (int mb = 0; mb < n_mbs; mb++) { for (int i = 0; i < 384; i++) { - /* Random coeffs in [-512, 511] for all of luma + Cb + Cr. - * Same range as the daedalus-fourier cycle-6 M1 gate. */ + /* Random coeffs in [-512, 511] for all of luma + Cb + Cr. */ per_mb_coeffs[mb][i] = (int16_t)((int)(xs64() % 1024) - 512); + /* Random predicted samples in [0, 255]. */ + per_mb_predicted[mb][i] = (uint8_t)(xs64() & 0xff); } } @@ -230,6 +244,7 @@ int main(int argc, char **argv) mb.mb_x = (uint16_t) mx; mb.mb_y = (uint16_t) my; mb.coeffs = per_mb_coeffs[idx]; + mb.predicted = per_mb_predicted[idx]; mb.transform_8x8 = mb_8x8[idx]; if (mb_8x8[idx]) n_8x8_mbs++; else n_4x4_mbs++; if (daedalus_decoder_append_mb(dec, &mb) != 0) { @@ -256,9 +271,26 @@ int main(int argc, char **argv) } /* Compute the reference output: same per-MB → flat raster block - * layout as flush_frame uses. Branch per MB on transform_8x8. */ - uint8_t *ref_y = calloc(1, y_size); + * layout as flush_frame uses. Branch per MB on transform_8x8. + * + * ref_y is pre-filled with each MB's 16×16 luma predicted samples + * at raster (my*16, mx*16), then ref_idct4_add/8_add overlay the + * residual via FFmpeg `idct_add` semantics (dst += idct(coeffs); + * clip255). This mirrors what flush_frame does on the GPU side: + * scratch_y starts from dec->predicted_y, IDCT-add writes back. */ + uint8_t *ref_y = malloc(y_size); if (!ref_y) return 1; + for (int my = 0; my < mb_h; my++) { + for (int mx = 0; mx < mb_w; mx++) { + int mb_idx = my * mb_w + mx; + const uint8_t *p_y = per_mb_predicted[mb_idx]; /* [0..256) */ + for (int r = 0; r < 16; r++) { + memcpy(&ref_y[((size_t) my * 16 + r) * (size_t) width + + (size_t) mx * 16], + &p_y[r * 16], 16); + } + } + } int16_t block_scratch[64]; /* large enough for 8x8 */ for (int my = 0; my < mb_h; my++) { for (int mx = 0; mx < mb_w; mx++) { @@ -302,9 +334,24 @@ int main(int argc, char **argv) size_t chroma_w = (size_t) width / 2; size_t chroma_h = (size_t) height / 2; size_t chroma_plane_size = chroma_w * chroma_h; - uint8_t *ref_cb = calloc(1, chroma_plane_size); - uint8_t *ref_cr = calloc(1, chroma_plane_size); + uint8_t *ref_cb = malloc(chroma_plane_size); + uint8_t *ref_cr = malloc(chroma_plane_size); if (!ref_cb || !ref_cr) return 1; + /* Pre-fill ref_cb / ref_cr with per-MB 8x8 chroma predicted samples + * (mirrors the predicted-samples plumbing on the chroma path). */ + for (int my = 0; my < mb_h; my++) { + for (int mx = 0; mx < mb_w; mx++) { + int mb_idx = my * mb_w + mx; + const uint8_t *p_cb = per_mb_predicted[mb_idx] + 256; + const uint8_t *p_cr = per_mb_predicted[mb_idx] + 256 + 64; + for (int r = 0; r < 8; r++) { + memcpy(&ref_cb[((size_t) my * 8 + r) * chroma_w + (size_t) mx * 8], + &p_cb[r * 8], 8); + memcpy(&ref_cr[((size_t) my * 8 + r) * chroma_w + (size_t) mx * 8], + &p_cr[r * 8], 8); + } + } + } for (int my = 0; my < mb_h; my++) { for (int mx = 0; mx < mb_w; mx++) { int mb_idx = my * mb_w + mx; @@ -386,6 +433,7 @@ int main(int argc, char **argv) free(gpu_uv); free(gpu_y); free(mb_8x8); + free(per_mb_predicted); free(per_mb_coeffs); daedalus_decoder_destroy(dec); -- 2.47.3