diff --git a/CMakeLists.txt b/CMakeLists.txt index c014580..a35fb99 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -120,6 +120,21 @@ add_executable(bench_neon_h264idct8 ) target_compile_options(bench_neon_h264idct8 PRIVATE -O3 -march=armv8-a+simd) +# Cycle 8 — H.264 luma vertical deblock NEON M3 baseline bench. +set(FFASM_H264DSP_SOURCES + ${FFSNAP}/libavcodec/aarch64/h264dsp_neon.S +) +set_source_files_properties(${FFASM_H264DSP_SOURCES} PROPERTIES + COMPILE_OPTIONS "${FFASM_FLAGS}" + LANGUAGE ASM) + +add_executable(bench_neon_h264deblock + tests/bench_neon_h264deblock.c + tests/h264_deblock_ref.c + ${FFASM_H264DSP_SOURCES} +) +target_compile_options(bench_neon_h264deblock PRIVATE -O3 -march=armv8-a+simd) + add_executable(bench_neon_idct tests/bench_neon_idct.c tests/vp9_idct8_ref.c diff --git a/docs/k8_h264deblock_phase3.md b/docs/k8_h264deblock_phase3.md new file mode 100644 index 0000000..440469f --- /dev/null +++ b/docs/k8_h264deblock_phase3.md @@ -0,0 +1,116 @@ +--- +cycle: 8 +phase: 3 +status: closed 2026-05-18 — M1 PASS, M3₈ = 91.95 Medge/s +date_opened: 2026-05-18 +date_closed: 2026-05-18 +parent: k8_h264deblock_phase1.md +host: hertz +--- + +# Cycle 8, Phase 3 — H.264 luma deblock NEON baseline + +## M1 + M3 + +``` +=== M1₈ bit-exact (10000 random edges) === +M1₈ correctness: 10000 / 10000 edges bit-exact (100.0000%) + filter triggered on 2507/10000 edges (25.07%) + +=== M3₈ NEON throughput === + total edges: 20 443 136 + elapsed (kernel)=0.222 s + throughput = 91.947 Medge/s + per-edge = 10.9 ns + H.264 1080p30 worst-case floor: 11.49x margin + H.264 1080p30 realistic floor: 30.65x margin +``` + +Filter triggers 25 % of the time — realistic gating: random +alpha/beta/tc0 cover both filter-applies and skip cases. + +## Key Phase 9 lesson — H.264 v_loop_filter is VERTICAL filtering of HORIZONTAL edges + +The FFmpeg naming convention "v_loop_filter_luma" / "h_loop_filter_luma" +refers to the **filter direction**, not the edge orientation: + +- `v_loop_filter_luma` — filter applied VERTICALLY across a + HORIZONTAL edge (16-col wide edge between row -1 and row 0). + pix points to row 0, column 0 of the bottom block. +- `h_loop_filter_luma` — filter applied HORIZONTALLY across a + VERTICAL edge (16-row tall edge between col -1 and col 0). + +This is the H.264 spec convention but it tripped up the cycle 8 +first C-ref draft (which assumed v_loop_filter operated on a +vertical edge with row-wise filtering). Trace showed only ±1 pixel +differences which initially looked like a rounding issue but was +actually a layout misinterpretation: +- The 16 "columns" in the NEON's vector lanes correspond to image + COLUMNS spanning the edge horizontally. +- The 8 "rows" (p3..p0 / q0..q3 context) span the edge vertically. + +Cycle 6 had a similar lesson with column-major-block; cycle 8 has +this related-but-distinct edge-orientation lesson. Encoded for +future cycles. + +## R₈ prediction (revised from Phase 1) + +Phase 1 predicted R₈ = 0.3-0.8 ORANGE/YELLOW based on VP9 LPF +analog. With M3₈ = 92 Medge/s captured (vs cycle 2's 48 +Medge/s), the picture refines: + +- H.264 deblock per-edge 10.9 ns vs cycle 2's 20 ns — **H.264 is + ~2× faster on NEON per edge** +- Cycle 2 QPU was 19.6 Medge/s = R = 0.41 GREEN +- H.264 deblock is MORE complex per edge (alpha/beta gating, tc0 + array, ap/aq side conditions, conditional p1/q1 writes) → QPU + work per edge likely 1.5-2× heavier than cycle 2's QPU +- Expected QPU M2 = 8-13 Medge/s +- **Predicted R₈ = 0.09-0.14 → ORANGE (lower than predicted)** + +Still likely worth building the QPU shader because: +- ORANGE is in the "M4 may still rescue" band (per cycle 1 + calibration where R=0.92 turned into +7.2% M4) +- For real deployment, mixed-kernel (Issue 003) helper value + matters more than isolation R +- Even at modest QPU contribution, the 25 %-of-edges-trigger + reality means QPU only needs to handle the 25 % that actually + filter; that's a 4× effective contribution multiplier + +## Cycle comparison + +| | Cycle 2 LPF wd=4 | Cycle 8 H.264 deblock | +|---|---|---| +| Codec | VP9 | H.264 | +| Edge size | 8 rows, 4-tap | 8 rows, 4-tap (similar) | +| NEON M3 | 48.285 Medge/s | **91.947 Medge/s** (1.9× faster) | +| Per-edge ns | 20.7 | **10.9** | +| Filter triggering rate | ~30 % (cycle 2 bench) | 25 % | +| Cycle 2 verdict | GREEN (M4 +6.9 %) | TBD (predicted ORANGE) | + +H.264 deblock's per-edge work is comparable to VP9 LPF but +2× faster on NEON due to: +- 16 columns processed in parallel (vs VP9 LPF 4-tap's 8 columns) +- More efficient byte-vector ops in FFmpeg's NEON implementation +- H.264 deblock doesn't have VP9's wd=4/8/16 variant overhead + +## Acceptance for Phase 7 + +- ✓ M1 bit-exact (100.00 % on 10 000 random edges) +- ✓ M3 captured (91.947 Medge/s) +- ✓ 30fps@1080p floor exceeded by 11× worst-case +- → Phase 4 plan QPU shader (next) + +## Cycle 8 next phase + +Phase 4: plan v3d_h264deblock.comp. Likely follows cycle 2 LPF +shader template (no barrier, edge per lane decomposition, +uint8 dst SSBO). Differences: +- 16 columns per edge (not 8) +- alpha/beta gating with multiple short-circuit conditions +- tc0 per 4-col segment +- ap/aq side conditions affecting p1/q1 writes +- More compute per pixel than cycle 2 + +Then Phase 5 Sonnet review (non-skippable), Phase 6 implement, +Phase 7 measure. diff --git a/tests/bench_neon_h264deblock.c b/tests/bench_neon_h264deblock.c new file mode 100644 index 0000000..0d842d9 --- /dev/null +++ b/tests/bench_neon_h264deblock.c @@ -0,0 +1,254 @@ +/* + * Cycle 8 Phase 3 — NEON M3 baseline for H.264 luma vertical + * deblock (non-intra, bS<4). + * + * M1 against the standalone C reference, M3 throughput. + * + * License: BSD-2-Clause; links FFmpeg LGPL-2.1+ snapshot. + */ +#define _POSIX_C_SOURCE 200809L +#include +#include +#include +#include +#include +#include +#include + +extern void daedalus_h264_v_loop_filter_luma_ref( + uint8_t *pix, ptrdiff_t stride, + int alpha, int beta, int8_t tc0[4]); + +extern void ff_h264_v_loop_filter_luma_neon( + uint8_t *pix, ptrdiff_t stride, + int alpha, int beta, int8_t *tc0); + +/* Edge layout: 8 rows × 16 cols (rows -4..+3 around edge). The + * edge is between rows -1 and 0 (= a HORIZONTAL edge filtered + * VERTICALLY per H.264 v_loop_filter convention). + * + * Tile: 16 rows × 16 cols. Edge at row 4 (rows 0..3 above + edge + * + rows 5..7 below; rows 8..15 are halo). pix points to tile + + * EDGE_ROW*stride. */ +#define TILE_STRIDE 16 +#define TILE_ROWS 16 +#define TILE_BYTES (TILE_ROWS * TILE_STRIDE) +#define EDGE_ROW 4 + +static uint64_t xs_state; +static inline uint64_t xs(void) { + uint64_t x = xs_state; + x ^= x << 13; x ^= x >> 7; x ^= x << 17; + return xs_state = x; +} + +/* Generate a tile with a horizontal edge at row EDGE_ROW (between + * rows 3 and 4). Top side (rows 0..3) clusters around side_a_base, + * bottom (rows 4..7) around side_b_base. Other rows are halo. */ +static void gen_tile(uint8_t *tile) +{ + int side_a_base = (int)(xs() % 200) + 20; + int side_b_base = (int)(xs() % 200) + 20; + int noise = (int)(xs() % 30) + 1; + for (int r = 0; r < TILE_ROWS; r++) { + for (int c = 0; c < TILE_STRIDE; c++) { + int v; + if (r >= EDGE_ROW - 4 && r < EDGE_ROW + 4) { + /* edge region rows EDGE_ROW-4..EDGE_ROW+3 */ + int local = r - (EDGE_ROW - 4); + int base = local < 4 ? side_a_base : side_b_base; + int n = ((int)(xs() % (2 * noise + 1))) - noise; + v = base + n; + } else { + v = (int)(xs() & 0xff); /* halo */ + } + tile[r * TILE_STRIDE + c] = (uint8_t)(v < 0 ? 0 : v > 255 ? 255 : v); + } + } +} + +static void gen_thresholds(int *alpha, int *beta, int8_t tc0[4]) +{ + /* Realistic H.264 alpha/beta ranges: typical 0..30 in spec + * tables for QP 30..40. Allow up to 64 to stress alpha/beta + * gating. */ + *alpha = (int)(xs() % 64) + 1; + *beta = (int)(xs() % 16) + 1; + /* tc0 from spec table: -1 means "no filter for this segment", + * 0..6 typical non-zero values. */ + for (int s = 0; s < 4; s++) { + int r = (int)(xs() % 8); + tc0[s] = (int8_t)(r == 0 ? -1 : (r - 1)); + } +} + +static double now_seconds(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC_RAW, &ts); + return ts.tv_sec + ts.tv_nsec * 1e-9; +} + +static int correctness_check(uint64_t seed, int n) +{ + xs_state = seed ? seed : 0xdeb1ec500dULL; + int mismatches = 0, prints = 0; + int filtered_count = 0; + + uint8_t tile_a[TILE_BYTES], tile_b[TILE_BYTES], tile_saved[TILE_BYTES]; + + for (int i = 0; i < n; i++) { + gen_tile(tile_a); + memcpy(tile_b, tile_a, TILE_BYTES); + memcpy(tile_saved, tile_a, TILE_BYTES); + + int alpha, beta; + int8_t tc0[4]; + gen_thresholds(&alpha, &beta, tc0); + + uint8_t *pix_a = tile_a + EDGE_ROW * TILE_STRIDE; + uint8_t *pix_b = tile_b + EDGE_ROW * TILE_STRIDE; + + daedalus_h264_v_loop_filter_luma_ref(pix_a, TILE_STRIDE, alpha, beta, tc0); + ff_h264_v_loop_filter_luma_neon(pix_b, TILE_STRIDE, alpha, beta, tc0); + + /* Check the edge region rows ±2 (the only rows deblock can modify). */ + int diff = 0; + for (int r = EDGE_ROW - 2; r < EDGE_ROW + 2; r++) { + for (int c = 0; c < TILE_STRIDE; c++) { + if (tile_a[r*TILE_STRIDE + c] != tile_b[r*TILE_STRIDE + c]) diff++; + } + } + /* Count whether filter actually triggered for any row. */ + int triggered = (memcmp(tile_a, tile_saved, TILE_BYTES) != 0); + if (triggered) filtered_count++; + + if (diff) { + if (prints < 3) { + fprintf(stderr, "MISMATCH edge %d (%d/64 modifiable pixels differ), alpha=%d beta=%d, tc0=[%d,%d,%d,%d]:\n", + i, diff, alpha, beta, tc0[0], tc0[1], tc0[2], tc0[3]); + fprintf(stderr, " input tile (cols 0..15):"); + for (int r = 0; r < TILE_ROWS; r++) { + fprintf(stderr, "\n r%2d ", r); + for (int c = 0; c < TILE_STRIDE; c++) + fprintf(stderr, "%3u ", tile_saved[r*TILE_STRIDE + c]); + } + fprintf(stderr, "\n ref out (edge rows 2..5, all cols):"); + for (int r = EDGE_ROW - 2; r < EDGE_ROW + 2; r++) { + fprintf(stderr, "\n r%2d ", r); + for (int c = 0; c < TILE_STRIDE; c++) + fprintf(stderr, "%3u ", tile_a[r*TILE_STRIDE + c]); + } + fprintf(stderr, "\n neon out (edge rows 2..5, all cols):"); + for (int r = EDGE_ROW - 2; r < EDGE_ROW + 2; r++) { + fprintf(stderr, "\n r%2d ", r); + for (int c = 0; c < TILE_STRIDE; c++) + fprintf(stderr, "%3u ", tile_b[r*TILE_STRIDE + c]); + } + fprintf(stderr, "\n"); + prints++; + } + mismatches++; + } + } + + printf("M1₈ correctness: %d / %d edges bit-exact (%.4f%%)\n", + n - mismatches, n, 100.0 * (n - mismatches) / n); + printf(" filter triggered on %d/%d edges (%.2f%%)\n", + filtered_count, n, 100.0 * filtered_count / n); + return mismatches; +} + +static void throughput_neon(uint64_t seed, int n_edges, double duration_s) +{ + xs_state = seed ? seed : 0xdeb1ec500dULL; + uint8_t *master = malloc((size_t) n_edges * TILE_BYTES); + uint8_t *work = malloc((size_t) n_edges * TILE_BYTES); + int *alphas = malloc(n_edges * sizeof(int)); + int *betas = malloc(n_edges * sizeof(int)); + int8_t (*tc0s)[4] = malloc(n_edges * 4); + if (!master || !work || !alphas || !betas || !tc0s) { + fprintf(stderr, "alloc fail\n"); exit(1); + } + for (int i = 0; i < n_edges; i++) { + gen_tile(master + i * TILE_BYTES); + gen_thresholds(&alphas[i], &betas[i], tc0s[i]); + } + + memcpy(work, master, (size_t) n_edges * TILE_BYTES); + for (int i = 0; i < n_edges; i++) + ff_h264_v_loop_filter_luma_neon(work + i * TILE_BYTES + EDGE_ROW * TILE_STRIDE, + TILE_STRIDE, alphas[i], betas[i], tc0s[i]); + + double t0 = now_seconds(); + double t_end = t0 + duration_s; + uint64_t done = 0; + while (now_seconds() < t_end) { + memcpy(work, master, (size_t) n_edges * TILE_BYTES); + for (int i = 0; i < n_edges; i++) + ff_h264_v_loop_filter_luma_neon(work + i * TILE_BYTES + EDGE_ROW * TILE_STRIDE, + TILE_STRIDE, alphas[i], betas[i], tc0s[i]); + done += n_edges; + } + double elapsed = now_seconds() - t0; + + int iters = (int)(done / n_edges); + double s0 = now_seconds(); + for (int i = 0; i < iters; i++) + memcpy(work, master, (size_t) n_edges * TILE_BYTES); + double s1 = now_seconds(); + + double kernel_seconds = elapsed - (s1 - s0); + double medges = done / kernel_seconds / 1e6; + + printf("M3₈ NEON throughput:\n"); + printf(" edges/batch: %d\n", n_edges); + printf(" batches done: %d\n", iters); + printf(" total edges: %llu\n", (unsigned long long) done); + printf(" elapsed (kernel)=%.6f s\n", kernel_seconds); + printf(" throughput = %.3f Medge/s\n", medges); + printf(" per-edge = %.1f ns\n", kernel_seconds / done * 1e9); + /* 1080p H.264 worst-case: ~8 Medge/s (luma v+h). Realistic: 2-4. */ + printf(" H.264 1080p30 worst-case floor: %.2fx margin (8.0 Medge/s req'd)\n", medges / 8.0); + printf(" H.264 1080p30 realistic floor: %.2fx margin (3.0 Medge/s req'd)\n", medges / 3.0); + + free(master); free(work); free(alphas); free(betas); free(tc0s); +} + +int main(int argc, char **argv) +{ + int n_edges = 65536; + double duration = 5.0; + uint64_t seed = 0; + int do_correctness = 1; + + static struct option opts[] = { + {"edges", required_argument, 0, 'e'}, + {"duration", required_argument, 0, 'd'}, + {"seed", required_argument, 0, 's'}, + {"no-correctness", no_argument, 0, 'C'}, + {0,0,0,0} + }; + for (int c; (c = getopt_long(argc, argv, "e:d:s:C", opts, 0)) != -1;) { + switch (c) { + case 'e': n_edges = atoi(optarg); break; + case 'd': duration = atof(optarg); break; + case 's': seed = strtoull(optarg, 0, 0); break; + case 'C': do_correctness = 0; break; + default: return 2; + } + } + + if (do_correctness) { + printf("=== M1₈ bit-exact (10000 random edges) ===\n"); + int mis = correctness_check(seed, 10000); + if (mis != 0) { + fprintf(stderr, "M1 gate FAILED — refusing to measure throughput.\n"); + return 1; + } + printf("\n"); + } + + printf("=== M3₈ NEON throughput ===\n"); + throughput_neon(seed, n_edges, duration); + return 0; +} diff --git a/tests/h264_deblock_ref.c b/tests/h264_deblock_ref.c new file mode 100644 index 0000000..9911185 --- /dev/null +++ b/tests/h264_deblock_ref.c @@ -0,0 +1,108 @@ +/* + * Standalone bit-exact C reference for H.264 luma "vertical" + * loop filter (v_loop_filter_luma): applies filter VERTICALLY + * across a HORIZONTAL edge. The edge spans the 16-column + * macroblock width, between rows -1 and 0. + * + * Mirrors FFmpeg `ff_h264_v_loop_filter_luma_neon` in + * external/ffmpeg-snapshot/libavcodec/aarch64/h264dsp_neon.S + * line 111. Operates on a 8-row × 16-col region: + * pix[r*stride + c] for r in -4..+3, c in 0..15 + * With pix pointing to row 0, col 0 of the bottom block. + * + * 16 columns divided into 4 segments of 4 cols; each segment + * has its own tc0 strength (tc0[0..3]). + * + * Note: FFmpeg's "v_loop_filter" naming uses the FILTER + * DIRECTION (vertical = across the edge from above), not the + * edge orientation (horizontal). H.264 spec calls this the + * "horizontal edge" filter. + * + * Signature: + * void(uint8_t *pix, ptrdiff_t stride, + * int alpha, int beta, int8_t tc0[4]); + * + * License: LGPL-2.1-or-later (matches FFmpeg upstream). + */ +#include +#include + +static inline int clip_u8(int v) { return v < 0 ? 0 : v > 255 ? 255 : v; } +static inline int clip3(int v, int lo, int hi) { + return v < lo ? lo : v > hi ? hi : v; +} +static inline int abs_i(int x) { return x < 0 ? -x : x; } + +/* Apply luma deblock to one COLUMN at the horizontal edge. + * p0..p3 are pixels above the edge (pix[-stride..-4*stride]), + * q0..q3 below (pix[0..+3*stride]). + * tc0_s is the segment's tc0 value (already known >= 0). + * + * Writes back to pix[-2*stride], pix[-1*stride], pix[0], pix[+stride] + * (= p1, p0, q0, q1). + */ +static void h264_deblock_luma_col(uint8_t *pix, ptrdiff_t stride, + int alpha, int beta, int tc0_s) +{ + int p3 = pix[-4*stride], p2 = pix[-3*stride], p1 = pix[-2*stride], p0 = pix[-1*stride]; + int q0 = pix[ 0*stride], q1 = pix[ 1*stride], q2 = pix[ 2*stride], q3 = pix[ 3*stride]; + (void) p3; (void) q3; /* not used in bS<4 path */ + + /* Edge pre-conditions. */ + if (abs_i(p0 - q0) >= alpha) return; + if (abs_i(p1 - p0) >= beta) return; + if (abs_i(q1 - q0) >= beta) return; + + /* Side conditions. */ + int ap = abs_i(p2 - p0); + int aq = abs_i(q2 - q0); + int ap_lt_beta = (ap < beta); + int aq_lt_beta = (aq < beta); + + /* Combined filter strength. */ + int tc = tc0_s + ap_lt_beta + aq_lt_beta; + + /* p0 / q0 update. */ + int delta = clip3(((q0 - p0) * 4 + (p1 - q1) + 4) >> 3, -tc, tc); + int p0p = clip_u8(p0 + delta); + int q0p = clip_u8(q0 - delta); + + /* p1 update (only if ap> 1) - 2*p1) >> 1, -tc0_s, tc0_s); + p1p = p1 + delta_p1; + } + /* q1 update (only if aq> 1) - 2*q1) >> 1, -tc0_s, tc0_s); + q1p = q1 + delta_q1; + } + + pix[-2*stride] = (uint8_t) p1p; + pix[-1*stride] = (uint8_t) p0p; + pix[ 0*stride] = (uint8_t) q0p; + pix[ 1*stride] = (uint8_t) q1p; +} + +void daedalus_h264_v_loop_filter_luma_ref( + uint8_t *pix, ptrdiff_t stride, + int alpha, int beta, int8_t tc0[4]) +{ + /* H.264 deblock "outer" precondition: alpha == 0 OR beta == 0 + * skips filtering. Also if ALL tc0[*] == -1, skip + * (h264_loop_filter_start macro check). */ + if (alpha == 0 || beta == 0) return; + if (tc0[0] < 0 && tc0[1] < 0 && tc0[2] < 0 && tc0[3] < 0) return; + + /* 16 columns divided into 4 segments of 4 columns each. */ + for (int s = 0; s < 4; s++) { + int tc0_s = tc0[s]; + if (tc0_s < 0) continue; /* bS = 0 segment → skip */ + for (int c = 0; c < 4; c++) { + int col = s * 4 + c; + h264_deblock_luma_col(pix + col, stride, alpha, beta, tc0_s); + } + } +}