Files
daedalus-decoder/src/daedalus_decoder.c
T
claude-noether 44ca4e550f phase1: substrate selector API + cross-substrate bit-exact ctest
Surfaces daedalus-fourier's substrate-override capability at the
decoder boundary.  Lets tests run on CPU-only hosts (CI runners,
x86 dev boxes) AND cross-checks V3D shader output against NEON
reference on hosts that have both.

API additions (pre-0.1 ABI, additive):

  - daedalus_decoder_substrate enum { AUTO, CPU, QPU }
    (mirrors daedalus_substrate; isolated for ABI reasons).
  - daedalus_decoder_set_substrate(dec, sub) setter, same
    mid-frame-change restrictions as set_output_format.
  - Default remains AUTO — the only sensible choice for production.

Internal:

  - flush_frame now calls daedalus_dispatch_h264_idct{4,8} with an
    explicit substrate instead of daedalus_recipe_dispatch_*.  Mapped
    via a small map_substrate() helper.  No perf delta on AUTO (recipe
    layer was just doing the same dispatch under the hood).

Test changes:

  - test_smoke: new EXPECTs for set_substrate (valid + bogus).
  - test_idct_bitexact: new argv[4] takes "auto" (default), "cpu", or
    "qpu" to force the substrate.
  - CMakeLists.txt: new ctest entry `idct_bitexact_cpu` re-runs the
    QVGA case forcing the CPU path.  Catches silent drift between
    the V3D shader and the NEON reference; both must produce
    identical output for the same coefficient input (and they do —
    see ctest log below).

Verified on hertz (Pi 5 / V3D 7.1 / daedalus-fourier 0.1.0):

  $ ctest --test-dir build --output-on-failure
      Start 1: smoke
  1/4 Test #1: smoke ............................   Passed    0.10 sec
      Start 2: idct_bitexact
  2/4 Test #2: idct_bitexact ....................   Passed    0.03 sec
      Start 3: idct_bitexact_cpu
  3/4 Test #3: idct_bitexact_cpu ................   Passed    0.03 sec
      Start 4: idct_bitexact_1080p
  4/4 Test #4: idct_bitexact_1080p ..............   Passed    0.06 sec

  100% tests passed, 0 tests failed out of 4

CPU substrate produces byte-identical Y + Cb + Cr planes against the
same C reference that the AUTO/QPU path matches — confirming the V3D
shaders and the daedalus-fourier NEON path agree at the spec level.

Why we plumbed the lower-level dispatch instead of leaving recipe in
place: recipe is just a thin wrapper that calls dispatch with
AUTO.  Once we needed substrate control, the wrapper became a
liability (would have required adding a parallel recipe API for each
substrate); going direct is simpler and the AUTO path is unchanged.

Coverage note: idct_bitexact_cpu runs at QVGA (300 MBs); not also at
1080p because the CPU path's wall time scales linearly with block
count and a 1080p CPU run is ~0.5s on hertz — fine standalone but
slows ctest enough that it would tempt opt-in gating.  The bit-exact
content is the same regardless of frame size; the 1080p variant only
exists to gate index-arithmetic bugs that surface above small int
boundaries.
2026-05-24 23:07:45 +02:00

447 lines
18 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* SPDX-License-Identifier: BSD-2-Clause */
/*
* daedalus-decoder — public C API implementation.
*
* Scaffold only. Most functions return success with no GPU work
* performed; the bodies will fill in across Phases 1-4 per DESIGN.md
* §8. This file exists so the API surface compiles, links, and can
* be smoke-tested end-to-end (ctx create / append / flush / destroy)
* before any shader work begins.
*/
#include "internal.h"
#include <stdlib.h>
#include <string.h>
/* Built via -D from CMakeLists. */
#ifndef DAEDALUS_DECODER_VERSION
#define DAEDALUS_DECODER_VERSION "0.0.1+scaffold"
#endif
const char *daedalus_decoder_version(void)
{
return DAEDALUS_DECODER_VERSION;
}
daedalus_decoder *daedalus_decoder_create(int width, int height)
{
if (width <= 0 || height <= 0)
return NULL;
if ((width & 15) || (height & 15))
return NULL; /* must be multiple of 16 */
daedalus_decoder *dec = calloc(1, sizeof(*dec));
if (!dec)
return NULL;
dec->width = width;
dec->height = height;
dec->mb_width = width >> 4;
dec->mb_height = height >> 4;
dec->n_mbs = dec->mb_width * dec->mb_height;
dec->output_fmt = DAEDALUS_DECODER_OUTPUT_NV12;
dec->substrate = DAEDALUS_DECODER_SUBSTRATE_AUTO;
/* daedalus-fourier ctx — required. Phase 1 needs the QPU; if
* Vulkan init fails the decoder is unusable. Caller can check
* via daedalus_decoder_has_qpu(). */
dec->dctx = daedalus_ctx_create();
if (!dec->dctx) {
free(dec);
return NULL;
}
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) {
daedalus_decoder_destroy(dec);
return NULL;
}
return dec;
}
void daedalus_decoder_destroy(daedalus_decoder *dec)
{
if (!dec)
return;
free(dec->coeffs);
free(dec->mb_descs);
if (dec->dctx)
daedalus_ctx_destroy(dec->dctx);
free(dec);
}
int daedalus_decoder_set_output_format(daedalus_decoder *dec,
daedalus_decoder_output_format fmt)
{
if (!dec)
return -1;
if (dec->mbs_appended != 0)
return -1; /* mid-frame change forbidden */
if (fmt != DAEDALUS_DECODER_OUTPUT_NV12 &&
fmt != DAEDALUS_DECODER_OUTPUT_RGBA)
return -1;
dec->output_fmt = fmt;
return 0;
}
int daedalus_decoder_set_substrate(daedalus_decoder *dec,
daedalus_decoder_substrate sub)
{
if (!dec)
return -1;
if (dec->mbs_appended != 0)
return -1;
if (sub != DAEDALUS_DECODER_SUBSTRATE_AUTO &&
sub != DAEDALUS_DECODER_SUBSTRATE_CPU &&
sub != DAEDALUS_DECODER_SUBSTRATE_QPU)
return -1;
dec->substrate = sub;
return 0;
}
/* Map our public substrate enum onto daedalus-fourier's. Same
* ordering by intent — we duplicate the enum for ABI isolation. */
static daedalus_substrate map_substrate(daedalus_decoder_substrate s)
{
switch (s) {
case DAEDALUS_DECODER_SUBSTRATE_CPU: return DAEDALUS_SUBSTRATE_CPU;
case DAEDALUS_DECODER_SUBSTRATE_QPU: return DAEDALUS_SUBSTRATE_QPU;
case DAEDALUS_DECODER_SUBSTRATE_AUTO:
default: return DAEDALUS_SUBSTRATE_AUTO;
}
}
int daedalus_decoder_append_mb(daedalus_decoder *dec,
const struct daedalus_decoder_mb_input *mb)
{
if (!dec || !mb || !mb->coeffs)
return -1;
if (mb->mb_x >= dec->mb_width || mb->mb_y >= dec->mb_height)
return -1;
/* Raster-order check — Phase 1's intra wavefront requires it.
* Caller is libavcodec's slice loop which produces raster order
* naturally, so this should never fire in practice. */
int expected = mb->mb_y * dec->mb_width + mb->mb_x;
if (expected != dec->mbs_appended)
return -1;
struct daedalus_decoder_mb_desc *d = &dec->mb_descs[expected];
d->mb_x = mb->mb_x;
d->mb_y = mb->mb_y;
d->mb_type = mb->mb_type;
d->mb_qp_y = mb->mb_qp_y;
d->mb_qp_uv = mb->mb_qp_uv;
d->cbp = mb->cbp;
memcpy(d->intra_4x4_modes, mb->intra_4x4_modes, 16);
d->intra_16x16_mode = mb->intra_16x16_mode;
d->intra_chroma_mode = mb->intra_chroma_mode;
d->partition_mode = mb->partition_mode;
memcpy(d->ref_idx_l0, mb->ref_idx_l0, 4);
memcpy(d->ref_idx_l1, mb->ref_idx_l1, 4);
memcpy(d->mv_l0, mb->mv_l0, sizeof(d->mv_l0));
memcpy(d->mv_l1, mb->mv_l1, sizeof(d->mv_l1));
d->deblock_disable = mb->deblock_disable;
d->deblock_alpha_c0 = mb->deblock_alpha_c0;
d->deblock_beta = mb->deblock_beta;
d->transform_8x8 = mb->transform_8x8;
memcpy(&dec->coeffs[(size_t) expected * 384],
mb->coeffs,
384 * sizeof(int16_t));
dec->mbs_appended++;
return 0;
}
/* Phase 1 stage 1 — frame-scaled IDCT 4x4 dispatch (luma + chroma).
*
* Brings up the GPU substrate by calling daedalus-fourier's existing
* `daedalus_recipe_dispatch_h264_idct4` at frame batch granularity in
* contrast to the substitution-arc shim that called it with
* n_blocks = 1 per call. Two Vulkan submits + waits per frame (one
* luma, one chroma) instead of millions of per-block dispatches.
*
* What's done in this stage:
* - Luma: build a per-frame meta[] in raster order (n_blocks =
* N_MBs × 16); flat-pack coeffs from each MB's first 256 int16;
* dispatch into a frame-sized zero-initialised Y scratch plane.
* - Chroma: build an interleaved Cb+Cr meta[] (n_blocks = N_MBs × 8,
* 4 Cb + 4 Cr per MB); flat-pack coeffs from each MB's next 128
* 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)).
*
* 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.
* - 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
* treat all chroma blocks as plain 4×4 AC IDCT; real decode needs
* the chroma DC 2×2 Hadamard contribution folded in).
* - Deblock (Stage 4).
* - dmabuf export — still memcpy-out to caller-provided planes.
* - Stage 5 RGBA opt-in.
* - GPU-side NV12 interleave — currently a CPU memcpy loop after
* the chroma dispatch. Trivial cost (~1 MB / frame at 1080p)
* vs the IDCT itself, but worth folding into a Stage-5 pass
* later for full-GPU residency.
*/
int daedalus_decoder_flush_frame(daedalus_decoder *dec,
uint8_t *out_y, size_t y_stride,
uint8_t *out_uv, size_t uv_stride)
{
if (!dec)
return -1;
if (dec->mbs_appended != dec->n_mbs)
return -1; /* incomplete frame */
if (!out_y)
return -1;
int rc = 0;
/* ---- Build frame-scaled luma dispatches (4x4 + 8x8) ---- */
/* Two partitions of the per-MB luma section based on each MB's
* transform_8x8 flag:
*
* transform_8x8 == 0 → 16 4x4 blocks contribute to the 4x4
* dispatch (16 coeffs each).
* transform_8x8 == 1 → 4 8x8 blocks contribute to the 8x8
* dispatch (64 coeffs each).
*
* Both partitions can be non-empty in the same frame (FFmpeg sets
* transform_8x8_size_flag per MB), so we allocate worst-case for
* each and track actual counts.
*/
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);
const size_t worst_4x4 = (size_t) dec->n_mbs * 16;
const size_t worst_8x8 = (size_t) dec->n_mbs * 4;
int16_t *coeffs4 = malloc(worst_4x4 * 16 * sizeof(int16_t));
int16_t *coeffs8 = malloc(worst_8x8 * 64 * sizeof(int16_t));
daedalus_h264_block_meta *meta4 = malloc(worst_4x4 * sizeof(*meta4));
daedalus_h264_block_meta *meta8 = malloc(worst_8x8 * sizeof(*meta8));
if (!scratch_y || !coeffs4 || !coeffs8 || !meta4 || !meta8) {
rc = -1;
goto cleanup;
}
/* Walk MBs in raster order, append each MB's luma blocks to the
* partition selected by its transform_8x8 flag.
*
* NB: per-MB 4x4 / 8x8 coefficient ORDER inside the H.264 bitstream
* follows the z-scan from spec §6.4.3 / fig 6-10. We're using
* flat raster on the input side too (sb_y outer, sb_x inner) for
* Phase 1 self-consistency; the z-scan permutation is the
* libavcodec-intercept patch's responsibility.
*/
size_t bi4 = 0, bi8 = 0;
for (int mb_y = 0; mb_y < dec->mb_height; mb_y++) {
for (int mb_x = 0; mb_x < dec->mb_width; mb_x++) {
int mb_idx = mb_y * dec->mb_width + mb_x;
const struct daedalus_decoder_mb_desc *d = &dec->mb_descs[mb_idx];
const int16_t *mb_coeffs = &dec->coeffs[(size_t) mb_idx * 384];
if (d->transform_8x8) {
/* 4 luma 8x8 blocks, raster sb_y*2+sb_x. */
for (int sb_y = 0; sb_y < 2; sb_y++) {
for (int sb_x = 0; sb_x < 2; sb_x++) {
size_t px_y = (size_t) mb_y * 16 + (size_t) sb_y * 8;
size_t px_x = (size_t) mb_x * 16 + (size_t) sb_x * 8;
meta8[bi8].dst_off = (uint32_t)
(px_y * y_stride_int + px_x);
int block_in_mb = sb_y * 2 + sb_x;
memcpy(&coeffs8[bi8 * 64],
&mb_coeffs[block_in_mb * 64],
64 * sizeof(int16_t));
bi8++;
}
}
} else {
/* 16 luma 4x4 blocks, raster sb_y*4+sb_x. */
for (int sb_y = 0; sb_y < 4; sb_y++) {
for (int sb_x = 0; sb_x < 4; sb_x++) {
size_t px_y = (size_t) mb_y * 16 + (size_t) sb_y * 4;
size_t px_x = (size_t) mb_x * 16 + (size_t) sb_x * 4;
meta4[bi4].dst_off = (uint32_t)
(px_y * y_stride_int + px_x);
int block_in_mb = sb_y * 4 + sb_x;
memcpy(&coeffs4[bi4 * 16],
&mb_coeffs[block_in_mb * 16],
16 * sizeof(int16_t));
bi4++;
}
}
}
}
}
/* assert bi4 + bi8*4 == n_mbs*16; loop math guarantees it */
/* ---- One Vulkan submit + wait per non-empty luma partition.
* AUTO substrate picks QPU per the post-decree recipe table; falls
* back to CPU NEON if the daedalus-fourier ctx wasn't QPU-capable.
* Skipping the dispatch when the partition is empty avoids the
* shader-pool warm-up cost on the common case (a typical Baseline
* stream is all-4x4 → 8x8 dispatch is no-op). */
const daedalus_substrate sub = map_substrate(dec->substrate);
if (bi4 > 0) {
int dr = daedalus_dispatch_h264_idct4(dec->dctx, sub,
scratch_y, y_stride_int,
coeffs4, bi4, meta4);
if (dr != 0) { rc = -3; goto cleanup; }
}
if (bi8 > 0) {
int dr = daedalus_dispatch_h264_idct8(dec->dctx, sub,
scratch_y, y_stride_int,
coeffs8, bi8, meta8);
if (dr != 0) { rc = -3; goto cleanup; }
}
/* ---- Copy Y out to caller's plane at the requested stride. ---- */
for (int r = 0; r < dec->height; r++)
memcpy(out_y + (size_t) r * y_stride,
&scratch_y[(size_t) r * y_stride_int],
(size_t) dec->width);
/* ---- Build frame-scaled chroma 4×4 dispatch ---- */
/*
* 4:2:0 layout — chroma planes are (W/2) by (H/2), one Cb + one
* Cr per pixel pair. H.264 per-MB chroma is two 8×8 components,
* each split into 4 4×4 blocks, so 8 chroma 4×4 blocks per MB.
*
* We dispatch BOTH components in a single shader call against a
* planar scratch buffer:
* scratch_uv[0 .. cb_plane_size) — Cb plane (W/2 × H/2)
* scratch_uv[cb_plane_size .. 2*size) — Cr plane (W/2 × H/2)
*
* meta[i].dst_off is a flat offset into the scratch buffer (the
* shader treats dst+dst_off as a contiguous 4×4 with row pitch =
* stride), so Cr blocks just add cb_plane_size to their offset.
* Stride is W/2 (the chroma row width); this works because Cb and
* Cr planes share the same row pitch.
*
* Post-dispatch we interleave the two planes into NV12 UV layout
* on the CPU. Doing this on the GPU is a Stage-5 follow-up
* (would need a small "copy + interleave" shader); CPU memcpy
* loop is ~1 MB/frame at 1080p so it's not on the critical path.
*/
int16_t *chroma_coeffs = NULL;
daedalus_h264_block_meta *chroma_meta = NULL;
uint8_t *scratch_uv = NULL;
if (out_uv) {
const size_t n_chroma_blocks_per_mb = 8; /* 4 Cb + 4 Cr */
const size_t n_chroma_blocks =
(size_t) dec->n_mbs * n_chroma_blocks_per_mb;
const size_t chroma_w = (size_t) dec->width / 2;
const size_t chroma_h = (size_t) dec->height / 2;
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);
chroma_coeffs = malloc(n_chroma_blocks * 16 * sizeof(int16_t));
chroma_meta = malloc(n_chroma_blocks *
sizeof(daedalus_h264_block_meta));
if (!scratch_uv || !chroma_coeffs || !chroma_meta) {
rc = -1;
goto chroma_cleanup;
}
size_t cbi = 0;
for (int mb_y = 0; mb_y < dec->mb_height; mb_y++) {
for (int mb_x = 0; mb_x < dec->mb_width; mb_x++) {
int mb_idx = mb_y * dec->mb_width + mb_x;
const int16_t *mb_coeffs = &dec->coeffs[(size_t) mb_idx * 384];
/* Per-MB coeff layout (set by append_mb):
* [ 0 .. 256) — 16 luma 4×4 blocks
* [256 .. 320) — 4 Cb 4×4 blocks (raster sb_y*2+sb_x)
* [320 .. 384) — 4 Cr 4×4 blocks (raster sb_y*2+sb_x)
*/
for (int comp = 0; comp < 2; comp++) { /* 0=Cb 1=Cr */
size_t plane_base = (size_t) comp * cb_plane_size;
size_t coeff_base = 256u + (size_t) comp * 64u;
for (int sb_y = 0; sb_y < 2; sb_y++) {
for (int sb_x = 0; sb_x < 2; sb_x++) {
size_t px_y = (size_t) mb_y * 8 + (size_t) sb_y * 4;
size_t px_x = (size_t) mb_x * 8 + (size_t) sb_x * 4;
chroma_meta[cbi].dst_off = (uint32_t)
(plane_base + px_y * chroma_w + px_x);
int block_in_comp = sb_y * 2 + sb_x;
memcpy(&chroma_coeffs[cbi * 16],
&mb_coeffs[coeff_base + (size_t) block_in_comp * 16],
16 * sizeof(int16_t));
cbi++;
}
}
}
}
}
/* assert cbi == n_chroma_blocks; loop math guarantees it */
int cr_rc = daedalus_dispatch_h264_idct4(dec->dctx, sub,
scratch_uv, chroma_w,
chroma_coeffs,
n_chroma_blocks,
chroma_meta);
if (cr_rc != 0) {
rc = -3;
goto chroma_cleanup;
}
/* CPU NV12 interleave: out_uv[r][2c+0] = Cb[r][c], [2c+1] = Cr. */
const uint8_t *cb_plane = scratch_uv;
const uint8_t *cr_plane = scratch_uv + cb_plane_size;
for (size_t r = 0; r < chroma_h; r++) {
uint8_t *dst_row = out_uv + r * uv_stride;
const uint8_t *cb_row = cb_plane + r * chroma_w;
const uint8_t *cr_row = cr_plane + r * chroma_w;
for (size_t c = 0; c < chroma_w; c++) {
dst_row[c * 2 + 0] = cb_row[c];
dst_row[c * 2 + 1] = cr_row[c];
}
}
chroma_cleanup:
free(chroma_meta);
free(chroma_coeffs);
free(scratch_uv);
if (rc != 0)
goto cleanup;
}
cleanup:
free(meta8);
free(meta4);
free(coeffs8);
free(coeffs4);
free(scratch_y);
dec->mbs_appended = 0;
return rc;
}
int daedalus_decoder_export_dmabuf(daedalus_decoder *dec, int plane)
{
(void) dec; (void) plane;
/* TODO Phase 1: vkGetMemoryFdKHR on the DPB slot's VkImage memory. */
return -1;
}
int daedalus_decoder_has_qpu(const daedalus_decoder *dec)
{
if (!dec || !dec->dctx)
return 0;
return daedalus_ctx_has_qpu(dec->dctx);
}