Cycle 8 Phase 3 closed: H.264 deblock NEON = 92 Medge/s

M1: 10000/10000 bit-exact (after orientation fix: ff_h264_v_loop_
filter is "vertical filtering of horizontal edges", not "vertical
edge"; 16 columns process the edge horizontally with 8 rows of
vertical context).

M3: 91.947 Medge/s per core. Per-edge 10.9 ns. 11x worst-case
1080p30 floor, 30x realistic floor. Filter triggers on 25 % of
edges (random alpha/beta/tc0 covers both gating paths).

Cycle 8 Phase 9 lesson: H.264/FFmpeg "v_loop_filter" naming uses
filter DIRECTION (vertical) not edge orientation. Edge is
horizontal; filter operates vertically across it. Distinct from
cycle 6's column-major-block lesson but related discovery
pattern. Encoded for future cycles.

R8 prediction revised: 0.09-0.14 ORANGE (down from Phase 1's
0.3-0.8 estimate). H.264 deblock is 2x faster on NEON than VP9
LPF wd=4 (cycle 2) but H.264 deblock has more per-edge branches
that hurt QPU more. Worth building anyway:
- ORANGE in cycle 1's "M4 may rescue" band
- Mixed-kernel deployment helper value (Issue 003) matters more
  than isolation R
- 25%-trigger rate gives 4x effective contribution multiplier
  on QPU side

- tests/h264_deblock_ref.c (column-walking C ref per row segment)
- tests/bench_neon_h264deblock.c (M1 + M3 bench)
- CMakeLists.txt: cycle 8 NEON bench wiring + h264dsp_neon.S
- docs/k8_h264deblock_phase3.md (closure)

Next: Phase 4 plan QPU shader, Phase 5 Sonnet review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 14:39:36 +00:00
parent 5a085e7180
commit 436a5c4f74
4 changed files with 493 additions and 0 deletions
+15
View File
@@ -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
+116
View File
@@ -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.
+254
View File
@@ -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 <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <stddef.h>
#include <string.h>
#include <time.h>
#include <getopt.h>
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;
}
+108
View File
@@ -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 <stdint.h>
#include <stddef.h>
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<beta). */
int p1p = p1;
if (ap_lt_beta) {
int delta_p1 = clip3((p2 + ((p0 + q0 + 1) >> 1) - 2*p1) >> 1, -tc0_s, tc0_s);
p1p = p1 + delta_p1;
}
/* q1 update (only if aq<beta). */
int q1p = q1;
if (aq_lt_beta) {
int delta_q1 = clip3((q2 + ((p0 + q0 + 1) >> 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);
}
}
}