diff --git a/crates/h264enc/src/dsp.rs b/crates/h264enc/src/dsp.rs
--- a/crates/h264enc/src/dsp.rs
+++ b/crates/h264enc/src/dsp.rs
@@ -14,13 +14,24 @@ pub fn sad(a: &[u8], sa: usize, b: &[u8], sb: usize, w: usize, h: usize) -> u32
 /// SAD of a 16-wide block (fast path).
 #[inline]
 pub fn sad16(a: &[u8], sa: usize, b: &[u8], sb: usize, h: usize) -> u32 {
+    sad_n::<16>(a, sa, b, sb, h)
+}
+
+/// SAD of an 8-wide block (fast path).
+#[inline]
+pub fn sad8(a: &[u8], sa: usize, b: &[u8], sb: usize, h: usize) -> u32 {
+    sad_n::<8>(a, sa, b, sb, h)
+}
+
+#[inline(always)]
+fn sad_n<const N: usize>(a: &[u8], sa: usize, b: &[u8], sb: usize, h: usize) -> u32 {
     let mut s = 0u32;
     for r in 0..h {
-        let (Some(ra), Some(rb)) = (a.get(r * sa..).and_then(|x| x.first_chunk::<16>()), b.get(r * sb..).and_then(|x| x.first_chunk::<16>())) else {
+        let (Some(ra), Some(rb)) = (a.get(r * sa..).and_then(|x| x.first_chunk::<N>()), b.get(r * sb..).and_then(|x| x.first_chunk::<N>())) else {
             break;
         };
         let mut acc = 0u16;
-        for i in 0..16 {
+        for i in 0..N {
             acc += ra[i].abs_diff(rb[i]) as u16;
         }
         s += acc as u32;
diff --git a/crates/h264enc/src/picture.rs b/crates/h264enc/src/picture.rs
--- a/crates/h264enc/src/picture.rs
+++ b/crates/h264enc/src/picture.rs
@@ -93,10 +93,10 @@ pub fn build_hpel(f: &Plane, reuse: Option<[Plane; 3]>) -> [Plane; 3] {
     });
     let rows = f.data.len() / f.stride;
     let band = 16usize;
-    let work = |b: usize, hrows: &mut [u8], vrows: &mut [u8], crows: &mut [u8]| {
+    let work = |b: usize, hrows: &mut [u8], vrows: &mut [u8], crows: &mut [u8], scratch: &mut HpelScratch| {
         let r0 = b * band;
         let r1 = (r0 + band).min(rows);
-        hpel_rows(f, r0, r1, hrows, vrows, crows);
+        hpel_rows(f, r0, r1, hrows, vrows, crows, scratch);
     };
     let chunk = band * f.stride;
     #[cfg(feature = "threads")]
@@ -107,25 +107,34 @@ pub fn build_hpel(f: &Plane, reuse: Option<[Plane; 3]>) -> [Plane; 3] {
             .zip(vp.data.par_chunks_mut(chunk))
             .zip(cp.data.par_chunks_mut(chunk))
             .enumerate()
-            .for_each(|(b, ((h, v), c))| work(b, h, v, c));
+            .for_each_init(HpelScratch::default, |s, (b, ((h, v), c))| work(b, h, v, c, s));
     }
     #[cfg(not(feature = "threads"))]
     {
+        let mut s = HpelScratch::default();
         for (b, ((h, v), c)) in hp.data.chunks_mut(chunk).zip(vp.data.chunks_mut(chunk)).zip(cp.data.chunks_mut(chunk)).enumerate() {
-            work(b, h, v, c);
+            work(b, h, v, c, &mut s);
         }
     }
     [hp, vp, cp]
 }
 
-fn hpel_rows(f: &Plane, r0: usize, r1: usize, hout: &mut [u8], vout: &mut [u8], cout: &mut [u8]) {
+/// Per-worker buffers for `hpel_rows`, reused across bands.
+#[derive(Default)]
+struct HpelScratch {
+    hs: Vec<i16>,
+    ext: Vec<u8>,
+}
+
+fn hpel_rows(f: &Plane, r0: usize, r1: usize, hout: &mut [u8], vout: &mut [u8], cout: &mut [u8], scratch: &mut HpelScratch) {
     let stride = f.stride;
     let rows = f.data.len() / stride;
     let clampr = |r: isize| r.clamp(0, rows as isize - 1) as usize;
     // Horizontal intermediate (unclipped, unshifted) for rows r0-2 .. r1+3.
     let nrows = r1 - r0 + 5;
-    let mut hs = vec![0i16; nrows * stride];
-    let mut ext = vec![0u8; stride + 5];
+    scratch.hs.resize(nrows * stride, 0);
+    scratch.ext.resize(stride + 5, 0);
+    let (hs, ext) = (&mut scratch.hs[..], &mut scratch.ext[..]);
     for k in 0..nrows {
         let r = clampr(r0 as isize - 2 + k as isize);
         let row = &f.data[r * stride..r * stride + stride];
@@ -198,8 +207,32 @@ impl RefPic {
         }
     }
 
+    /// Luma prediction of a w x h block at (x, y) displaced by `mv`, as (samples, stride). Full- and half-sample
+    /// positions are read straight from their plane; other positions are interpolated into `buf` (stride 16).
+    #[inline]
+    pub fn luma_ref<'s>(&'s self, x: usize, y: usize, mv: Mv, w: usize, h: usize, buf: &'s mut [u8]) -> (&'s [u8], usize) {
+        let [(pa, dxa, dya), (pb, dxb, dyb)] = QPEL_SRC[((mv.y & 3) * 4 + (mv.x & 3)) as usize];
+        if pa == pb && dxa == dxb && dya == dyb {
+            let a = self.plane(pa);
+            let i = a.idx(x as isize + (mv.x as isize >> 2) + dxa as isize, y as isize + (mv.y as isize >> 2) + dya as isize);
+            (&a.data[i..], a.stride)
+        } else {
+            self.mc_luma(x, y, mv, w, h, buf, 16);
+            (buf, 16)
+        }
+    }
+
     /// Luma prediction of a w x h block at picture position (x, y) displaced by `mv` (quarter samples).
     pub fn mc_luma(&self, x: usize, y: usize, mv: Mv, w: usize, h: usize, out: &mut [u8], ostride: usize) {
+        match w {
+            16 => self.mc_luma_w(x, y, mv, 16, h, out, ostride),
+            8 => self.mc_luma_w(x, y, mv, 8, h, out, ostride),
+            _ => self.mc_luma_w(x, y, mv, w, h, out, ostride),
+        }
+    }
+
+    #[inline(always)]
+    fn mc_luma_w(&self, x: usize, y: usize, mv: Mv, w: usize, h: usize, out: &mut [u8], ostride: usize) {
         let xi = x as isize + (mv.x as isize >> 2);
         let yi = y as isize + (mv.y as isize >> 2);
         let q = ((mv.y & 3) * 4 + (mv.x & 3)) as usize;
@@ -227,6 +260,15 @@ impl RefPic {
 
     /// Chroma prediction (both planes) of a w x h chroma block at chroma position (x, y); mv in luma quarter units.
     pub fn mc_chroma(&self, x: usize, y: usize, mv: Mv, w: usize, h: usize, out_u: &mut [u8], out_v: &mut [u8], ostride: usize) {
+        match w {
+            8 => self.mc_chroma_w(x, y, mv, 8, h, out_u, out_v, ostride),
+            4 => self.mc_chroma_w(x, y, mv, 4, h, out_u, out_v, ostride),
+            _ => self.mc_chroma_w(x, y, mv, w, h, out_u, out_v, ostride),
+        }
+    }
+
+    #[inline(always)]
+    fn mc_chroma_w(&self, x: usize, y: usize, mv: Mv, w: usize, h: usize, out_u: &mut [u8], out_v: &mut [u8], ostride: usize) {
         let xi = x as isize + (mv.x as isize >> 3);
         let yi = y as isize + (mv.y as isize >> 3);
         let fx = (mv.x & 7) as u32;
diff --git a/crates/h264enc/src/slice.rs b/crates/h264enc/src/slice.rs
--- a/crates/h264enc/src/slice.rs
+++ b/crates/h264enc/src/slice.rs
@@ -3,7 +3,7 @@
 use filmcraft_bitstream::BitWriter;
 
 use crate::cabac::{BinSink, CabacEncoder, CabacEstimator, Contexts, CostTable};
-use crate::dsp::{sad, sad16, satd, ssd};
+use crate::dsp::{sad, sad8, sad16, satd, ssd};
 use crate::intra::{Edge4, Edge8, filter8, mode_available, pred_chroma, pred4x4, pred8x8, pred16x16};
 use crate::mbinfo::{MbInfo, MbKind, Mv, Part};
 use crate::nal::{SliceHeader, SliceType, nal};
diff --git a/crates/h264enc/src/slice/motion.rs b/crates/h264enc/src/slice/motion.rs
--- a/crates/h264enc/src/slice/motion.rs
+++ b/crates/h264enc/src/slice/motion.rs
@@ -44,7 +44,11 @@ impl<'a, 'b> SliceEnc<'a, 'b> {
         let p = self.f.p;
         let fcost = |mv: Mv| -> u32 {
             let i = pl.idx(bx as isize + (mv.x as isize >> 2), by as isize + (mv.y as isize >> 2));
-            let d = if w == 16 { sad16(src, sstride, &pl.data[i..], pl.stride, h) } else { sad(src, sstride, &pl.data[i..], pl.stride, w, h) };
+            let d = match w {
+                16 => sad16(src, sstride, &pl.data[i..], pl.stride, h),
+                8 => sad8(src, sstride, &pl.data[i..], pl.stride, h),
+                _ => sad(src, sstride, &pl.data[i..], pl.stride, w, h),
+            };
             d + ((lam * mv_bits(mv, mvp)) >> 8)
         };
         let mut best = Mv::new((mvp.x as i32 + 2) & !3, (mvp.y as i32 + 2) & !3);
@@ -113,8 +117,8 @@ impl<'a, 'b> SliceEnc<'a, 'b> {
         // sub-pel refinement with SATD
         let mut buf = [0u8; 256];
         let scost = |mv: Mv, buf: &mut [u8; 256]| -> u32 {
-            r.mc_luma(bx, by, mv, w, h, buf, 16);
-            satd(src, sstride, buf, 16, w, h) + ((lam * mv_bits(mv, mvp)) >> 8)
+            let (pr, ps) = r.luma_ref(bx, by, mv, w, h, buf);
+            satd(src, sstride, pr, ps, w, h) + ((lam * mv_bits(mv, mvp)) >> 8)
         };
         let mut bs = scost(best, &mut buf);
         let mut seen = Visited::<2>::new(best, 4, 1);
