diff --git a/crates/algo/src/retouch.rs b/crates/algo/src/retouch.rs
--- a/crates/algo/src/retouch.rs
+++ b/crates/algo/src/retouch.rs
@@ -210,34 +210,80 @@ pub fn local_sharpen(
     protect_detail: bool,
 ) -> Vec<f32> {
     let (x0, y0, x1, y1) = win;
-    let ow = x1 - x0;
+    let (ow, oh) = (x1 - x0, y1 - y0);
+    if ow == 0 || oh == 0 || ch == 0 {
+        return Vec::new();
+    }
     let blur = local_blur(data, w, h, ch, alpha, win, 1.0);
     let mut out = vec![0.0f32; blur.len()];
-    for y in y0..y1 {
-        for x in x0..x1 {
-            let o = ((y - y0) * ow + (x - x0)) * ch;
+    let stride = ow * ch;
+
+    // Rows are independent, so process them in parallel with rayon (#5426).
+    let process_row = |(oy, dst): (usize, &mut [f32])| {
+        let y = y0 + oy;
+        let blur_row = &blur[oy * stride..(oy + 1) * stride];
+        for ox in 0..ow {
+            let x = x0 + ox;
+            let o = ox * ch;
             let i = (y * w + x) * ch;
+
+            // Precompute 3×3 neighbourhood offsets once per pixel if protecting detail.
+            let offsets: [usize; 8] = if protect_detail {
+                if x > 0 && x + 1 < w && y > 0 && y + 1 < h {
+                    let w_ch = w * ch;
+                    [i - w_ch - ch, i - w_ch, i - w_ch + ch, i - ch, i + ch, i + w_ch - ch, i + w_ch, i + w_ch + ch]
+                } else {
+                    let get_n_idx = |dx: i32, dy: i32| -> usize {
+                        let nx = (x as i32 + dx).clamp(0, w as i32 - 1) as usize;
+                        let ny = (y as i32 + dy).clamp(0, h as i32 - 1) as usize;
+                        (ny * w + nx) * ch
+                    };
+                    [
+                        get_n_idx(-1, -1),
+                        get_n_idx(0, -1),
+                        get_n_idx(1, -1),
+                        get_n_idx(-1, 0),
+                        get_n_idx(1, 0),
+                        get_n_idx(-1, 1),
+                        get_n_idx(0, 1),
+                        get_n_idx(1, 1),
+                    ]
+                }
+            } else {
+                [0; 8]
+            };
+
             for c in 0..ch {
                 let v = data[i + c];
                 if Some(c) == alpha {
-                    out[o + c] = v;
+                    dst[o + c] = v;
                     continue;
                 }
-                let mut s = v + amount * (v - blur[o + c]);
+                let mut s = v + amount * (v - blur_row[o + c]);
                 if protect_detail {
                     let (mut lo, mut hi) = (v, v);
-                    for (dx, dy) in [(-1i32, -1i32), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] {
-                        let (nx, ny) = ((x as i32 + dx).clamp(0, w as i32 - 1) as usize, (y as i32 + dy).clamp(0, h as i32 - 1) as usize);
-                        let nv = data[(ny * w + nx) * ch + c];
+                    for &n_idx in &offsets {
+                        let nv = data[n_idx + c];
                         lo = lo.min(nv);
                         hi = hi.max(nv);
                     }
                     s = s.clamp(lo, hi);
                 }
-                out[o + c] = s;
+                dst[o + c] = s;
             }
         }
+    };
+
+    #[cfg(not(target_arch = "wasm32"))]
+    {
+        use rayon::prelude::*;
+        out.par_chunks_mut(stride).enumerate().for_each(process_row);
     }
+    #[cfg(target_arch = "wasm32")]
+    {
+        out.chunks_mut(stride).enumerate().for_each(process_row);
+    }
+
     out
 }
 
diff --git a/crates/algo/tests/it/main.rs b/crates/algo/tests/it/main.rs
--- a/crates/algo/tests/it/main.rs
+++ b/crates/algo/tests/it/main.rs
@@ -3,4 +3,5 @@
 mod liquify_freeze_formats;
 mod liquify_source_footprint;
 mod retouch_blur_exact;
+mod retouch_sharpen_exact;
 mod warp_clipped;
diff --git a/crates/algo/tests/it/retouch_sharpen_exact.rs b/crates/algo/tests/it/retouch_sharpen_exact.rs
new file mode 100644
--- /dev/null
+++ b/crates/algo/tests/it/retouch_sharpen_exact.rs
@@ -0,0 +1,82 @@
+//! Frozen scalar sharpen from main, used as an exact-output oracle.
+use photocraft_algo::retouch::{local_blur, local_sharpen};
+
+#[test]
+fn matches_scalar_sharpen_bit_for_bit_across_layouts_edges_and_hdr() {
+    for (w, h) in [(1, 1), (3, 7), (37, 29)] {
+        for ch in 1..=5 {
+            for alpha in std::iter::once(None).chain((0..ch).map(Some)) {
+                let data: Vec<f32> = (0..w * h * ch)
+                    .map(|i| if Some(i % ch) == alpha { [0.0, 1e-8, 0.25, 0.75, 1.0][(i / ch) % 5] } else { ((i * 73 + 19) % 113) as f32 / 17.0 - 2.0 })
+                    .collect();
+                for win in [(0, 0, w, h), (w / 2, h / 2, w, h), (0, 0, 1, 1), (0, 0, 0, h)] {
+                    for amount in [0.0, 0.5, 1.0, 2.5] {
+                        for protect_detail in [false, true] {
+                            let expected = scalar_local_sharpen(&data, w, h, ch, alpha, win, amount, protect_detail);
+                            let actual = local_sharpen(&data, w, h, ch, alpha, win, amount, protect_detail);
+                            assert_eq!(actual.len(), expected.len());
+                            for (i, (a, b)) in actual.iter().zip(&expected).enumerate() {
+                                assert_eq!(
+                                    a.to_bits(),
+                                    b.to_bits(),
+                                    "{w}x{h} ch={ch} alpha={alpha:?} win={win:?} amount={amount} protect={protect_detail} sample={i}"
+                                );
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+#[test]
+fn empty_channel_layout_remains_empty() {
+    assert!(local_sharpen(&[], 5, 5, 0, None, (0, 0, 5, 5), 1.0, true).is_empty());
+}
+
+#[allow(clippy::too_many_arguments)]
+fn scalar_local_sharpen(
+    data: &[f32],
+    w: usize,
+    h: usize,
+    ch: usize,
+    alpha: Option<usize>,
+    win: (usize, usize, usize, usize),
+    amount: f32,
+    protect_detail: bool,
+) -> Vec<f32> {
+    let (x0, y0, x1, y1) = win;
+    let (ow, oh) = (x1 - x0, y1 - y0);
+    if ow == 0 || oh == 0 || ch == 0 {
+        return Vec::new();
+    }
+    let blur = local_blur(data, w, h, ch, alpha, win, 1.0);
+    let mut out = vec![0.0f32; blur.len()];
+    for y in y0..y1 {
+        for x in x0..x1 {
+            let o = ((y - y0) * ow + (x - x0)) * ch;
+            let i = (y * w + x) * ch;
+            for c in 0..ch {
+                let v = data[i + c];
+                if Some(c) == alpha {
+                    out[o + c] = v;
+                    continue;
+                }
+                let mut s = v + amount * (v - blur[o + c]);
+                if protect_detail {
+                    let (mut lo, mut hi) = (v, v);
+                    for (dx, dy) in [(-1i32, -1i32), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] {
+                        let (nx, ny) = ((x as i32 + dx).clamp(0, w as i32 - 1) as usize, (y as i32 + dy).clamp(0, h as i32 - 1) as usize);
+                        let nv = data[(ny * w + nx) * ch + c];
+                        lo = lo.min(nv);
+                        hi = hi.max(nv);
+                    }
+                    s = s.clamp(lo, hi);
+                }
+                out[o + c] = s;
+            }
+        }
+    }
+    out
+}
diff --git a/crates/engine/examples/bench_sharpen_tool.rs b/crates/engine/examples/bench_sharpen_tool.rs
new file mode 100644
--- /dev/null
+++ b/crates/engine/examples/bench_sharpen_tool.rs
@@ -0,0 +1,62 @@
+//! Reproducible soft Sharpen strokes on a 24 MP striped document. Run in release mode.
+//! Setup, Undo and the output fingerprint are excluded from the timing.
+use std::time::Instant;
+
+use photocraft_engine::{EngineError, Session};
+use photocraft_geom::Rect;
+use serde_json::json;
+
+fn run() -> Result<(), Box<dyn std::error::Error>> {
+    for depth in [8, 16] {
+        let mut session = Session::new();
+        session.execute("file.new", json!({"width":6000,"height":4000,"depth":depth,"mode":"rgb"}))?;
+        session.execute("layer.new.layer", json!({}))?;
+        session.edit("stripes", |doc, active| {
+            let id = active.ok_or_else(|| EngineError::Other("no layer".into()))?;
+            let surface = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or_else(|| EngineError::Other("no surface".into()))?;
+            for x in (0..6000).step_by(64) {
+                let color = if x % 128 == 0 { [0.9, 0.3, 0.1, 1.0] } else { [0.1, 0.4, 0.8, 1.0] };
+                surface.fill_rect(Rect::new(x, 0, (x + 64).min(6000), 4000), &color);
+            }
+            Ok(())
+        })?;
+        for size in [100, 500, 1000] {
+            let params = json!({"points":[[2500,2000],[3500,2000]],"size":size,"hardness":20,"strength":50,"spacing":25,"protectDetail":true});
+            let mut times = Vec::new();
+            let mut fingerprint = 0u64;
+            for sample in 0..6 {
+                let start = Instant::now();
+                session.execute("paint.sharpen", params.clone())?;
+                let ms = start.elapsed().as_secs_f64() * 1000.0;
+                if sample > 0 {
+                    times.push(ms);
+                }
+                if sample == 5 {
+                    let st = session.active().ok_or("no document")?;
+                    let surface = st.doc.layer(st.active_layer.ok_or("no layer")?).and_then(|l| l.surface()).ok_or("no surface")?;
+                    for y in (1500..2500).step_by(17) {
+                        for x in (2000..4000).step_by(13) {
+                            for channel in surface.rgba(x, y) {
+                                fingerprint = fingerprint.wrapping_mul(16777619) ^ u64::from(channel.to_bits());
+                            }
+                        }
+                    }
+                }
+                session.execute("edit.undo", json!({}))?;
+            }
+            times.sort_by(f64::total_cmp);
+            println!(
+                "depth={depth} size={size} median_ms={:.3} min_ms={:.3} max_ms={:.3} sampled_fingerprint={fingerprint:016x}",
+                times[2], times[0], times[4]
+            );
+        }
+    }
+    Ok(())
+}
+
+fn main() {
+    if let Err(error) = run() {
+        eprintln!("{error}");
+        std::process::exit(1);
+    }
+}
