Merge pull request 'Stage 2 PR-a: predicted samples plumbing — caller-supplied per-MB pixels' (#11) from noether/stage2-predicted-samples into main

Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
2026-05-25 21:07:28 +00:00
4 changed files with 170 additions and 24 deletions
+20
View File
@@ -89,6 +89,26 @@ struct daedalus_decoder_mb_input {
* column-major within each 4x4 or 8x8 block (matches FFmpeg * column-major within each 4x4 or 8x8 block (matches FFmpeg
* convention). Caller-owned; copied during append. */ * convention). Caller-owned; copied during append. */
const int16_t *coeffs; /* points at exactly 384 int16_t */ 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 */
}; };
/* ------------------------------------------------------------------- /* -------------------------------------------------------------------
+73 -9
View File
@@ -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->mb_descs = calloc((size_t) dec->n_mbs, sizeof(*dec->mb_descs));
dec->coeffs = calloc((size_t) dec->n_mbs * 384, sizeof(int16_t)); 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); daedalus_decoder_destroy(dec);
return NULL; return NULL;
} }
@@ -66,6 +77,8 @@ void daedalus_decoder_destroy(daedalus_decoder *dec)
{ {
if (!dec) if (!dec)
return; return;
free(dec->predicted_uv);
free(dec->predicted_y);
free(dec->coeffs); free(dec->coeffs);
free(dec->mb_descs); free(dec->mb_descs);
if (dec->dctx) if (dec->dctx)
@@ -153,6 +166,34 @@ int daedalus_decoder_append_mb(daedalus_decoder *dec,
mb->coeffs, mb->coeffs,
384 * sizeof(int16_t)); 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++; dec->mbs_appended++;
return 0; 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 * int16 (64 Cb + 64 Cr); dispatch into a planar Cb||Cr scratch
* buffer (W*H/4 each, concatenated W*H/2 total); CPU-interleave * buffer (W*H/4 each, concatenated W*H/2 total); CPU-interleave
* into the caller's NV12 UV plane post-dispatch. * into the caller's NV12 UV plane post-dispatch.
* - Both dispatches use predicted=0 (the scratch buffers are * - Both dispatches pre-fill the scratch from the per-frame
* calloc'd); the shader does clip255(predicted + idct(coeffs)). * 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): * What's NOT done yet (follow-on Phase 1 sub-PRs):
* - Intra prediction (Stage 2a wavefront): predicted is forced to 0, * - Intra prediction: caller-driven (Q2 decision 2026-05-25, CPU
* so output pixels are residual-only and not a valid frame decode. * intra-pred via FFmpeg NEON kernels). Caller writes the
* Sufficient for Vulkan round-trip validation, not for bit-exact * intra-predicted samples into mb_input.predicted; this dispatch
* against FFmpeg. * 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. * - Motion compensation (Stage 2b): inter MBs not handled.
* - High-profile IDCT 8x8 (Stage 1 extension). * - High-profile IDCT 8x8 (Stage 1 extension).
* - Chroma DC / luma Intra16x16 DC Hadamard pre-pass (currently we * - 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 * transform_8x8_size_flag per MB), so we allocate worst-case for
* each and track actual counts. * 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_stride_int = (size_t) dec->width;
const size_t y_size = y_stride_int * (size_t) dec->height; 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_4x4 = (size_t) dec->n_mbs * 16;
const size_t worst_8x8 = (size_t) dec->n_mbs * 4; 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 cb_plane_size = chroma_w * chroma_h;
const size_t uv_scratch_size = 2 * cb_plane_size; 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_coeffs = malloc(n_chroma_blocks * 16 * sizeof(int16_t));
chroma_meta = malloc(n_chroma_blocks * chroma_meta = malloc(n_chroma_blocks *
sizeof(daedalus_h264_block_meta)); sizeof(daedalus_h264_block_meta));
@@ -427,6 +482,15 @@ cleanup:
free(coeffs8); free(coeffs8);
free(coeffs4); free(coeffs4);
free(scratch_y); 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; dec->mbs_appended = 0;
return rc; return rc;
} }
+14
View File
@@ -62,6 +62,20 @@ struct daedalus_decoder {
int16_t *coeffs; /* n_mbs * 384 */ int16_t *coeffs; /* n_mbs * 384 */
int mbs_appended; /* per-frame count */ 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. */ /* Output format. */
daedalus_decoder_output_format output_fmt; daedalus_decoder_output_format output_fmt;
+63 -15
View File
@@ -1,12 +1,16 @@
/* SPDX-License-Identifier: BSD-2-Clause */ /* SPDX-License-Identifier: BSD-2-Clause */
/* /*
* test_idct_bitexact — phase1 stage1 bit-exact gate for the frame- * 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 * Generates a frame of random coefficients AND random predicted
* (with predicted=0 by the scaffold's flush_frame contract), and * samples per MB, runs daedalus_decoder (which writes the predicted
* compares every output byte against an inline C reference that * samples into its frame-scoped predicted_y/_uv buffers via
* mirrors the H.264 §8.5.12.1 1D butterfly. * 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 * Why "bit-exact": the GPU shader and the C reference apply the same
* integer arithmetic. Any rounding / sign / overflow disagreement is * integer arithmetic. Any rounding / sign / overflow disagreement is
@@ -30,7 +34,7 @@
* Not in scope (covered by other tests / future PRs): * Not in scope (covered by other tests / future PRs):
* - Chroma DC / Intra16x16 DC Hadamard pre-pass * - Chroma DC / Intra16x16 DC Hadamard pre-pass
* - bit-exactness against real H.264 streams (test-vector PR) * - 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" #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 /* 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 * random coeffs in [-512, 511] — same range as the daedalus-fourier
* cycle-6 M1 gate uses. */ * cycle-6 M1 gate uses. Plus random predicted samples (uint8 each)
int16_t (*per_mb_coeffs)[384] = malloc((size_t) n_mbs * sizeof(*per_mb_coeffs)); * to exercise the Stage 2 predicted-samples plumbing — when this
if (!per_mb_coeffs) { fprintf(stderr, "alloc fail\n"); return 1; } * 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 mb = 0; mb < n_mbs; mb++) {
for (int i = 0; i < 384; i++) { for (int i = 0; i < 384; i++) {
/* Random coeffs in [-512, 511] for all of luma + Cb + Cr. /* Random coeffs in [-512, 511] for all of luma + Cb + Cr. */
* Same range as the daedalus-fourier cycle-6 M1 gate. */
per_mb_coeffs[mb][i] = (int16_t)((int)(xs64() % 1024) - 512); 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_x = (uint16_t) mx;
mb.mb_y = (uint16_t) my; mb.mb_y = (uint16_t) my;
mb.coeffs = per_mb_coeffs[idx]; mb.coeffs = per_mb_coeffs[idx];
mb.predicted = per_mb_predicted[idx];
mb.transform_8x8 = mb_8x8[idx]; mb.transform_8x8 = mb_8x8[idx];
if (mb_8x8[idx]) n_8x8_mbs++; else n_4x4_mbs++; if (mb_8x8[idx]) n_8x8_mbs++; else n_4x4_mbs++;
if (daedalus_decoder_append_mb(dec, &mb) != 0) { 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 /* Compute the reference output: same per-MB → flat raster block
* layout as flush_frame uses. Branch per MB on transform_8x8. */ * layout as flush_frame uses. Branch per MB on transform_8x8.
uint8_t *ref_y = calloc(1, y_size); *
* 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; 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 */ int16_t block_scratch[64]; /* large enough for 8x8 */
for (int my = 0; my < mb_h; my++) { for (int my = 0; my < mb_h; my++) {
for (int mx = 0; mx < mb_w; mx++) { 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_w = (size_t) width / 2;
size_t chroma_h = (size_t) height / 2; size_t chroma_h = (size_t) height / 2;
size_t chroma_plane_size = chroma_w * chroma_h; size_t chroma_plane_size = chroma_w * chroma_h;
uint8_t *ref_cb = calloc(1, chroma_plane_size); uint8_t *ref_cb = malloc(chroma_plane_size);
uint8_t *ref_cr = calloc(1, chroma_plane_size); uint8_t *ref_cr = malloc(chroma_plane_size);
if (!ref_cb || !ref_cr) return 1; 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 my = 0; my < mb_h; my++) {
for (int mx = 0; mx < mb_w; mx++) { for (int mx = 0; mx < mb_w; mx++) {
int mb_idx = my * 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_uv);
free(gpu_y); free(gpu_y);
free(mb_8x8); free(mb_8x8);
free(per_mb_predicted);
free(per_mb_coeffs); free(per_mb_coeffs);
daedalus_decoder_destroy(dec); daedalus_decoder_destroy(dec);