perf(analysis): run image decode/resize/encode on the blocking pool (0.87.5)

`VisionClient::analyze` ran `image::load_from_memory`, `DynamicImage::resize_exact(Triangle)`,
and per-band JPEG encodes directly on the tokio task. Each call is
hundreds of ms of pure CPU per page. With multiple workers, the runtime
threads got starved — axum handlers, SSE streams, the crawler daemon's
async timers, and other tasks sharing the runtime all stalled while
analysis was active.

Extract a `prepare_analysis` helper that does ALL the CPU work
(decode + plan + width-reduce + slice + JPEG encode) and returns a
`PreparedAnalysis { Single | Sliced | Undecodable }` of already-encoded
byte payloads. `analyze` calls it inside `tokio::task::spawn_blocking`,
then the async HTTP loop only iterates over the prepared bytes — no
image-crate operations happen on the runtime any more.

Single page → one spawn_blocking → one HTTP call.
Tall page → one spawn_blocking → N OCR calls + 1 grounding call.

Memory peak rises slightly for the Sliced path (all bands held
encoded at the same time instead of one-at-a-time) — for a typical
manga page split into 4 slices at ~200 KB each, that's ~600 KB of
extra peak. Negligible vs. the runtime-starvation cost.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-22 21:23:34 +02:00
parent 660184a048
commit 80f4819fad
4 changed files with 138 additions and 41 deletions

2
backend/Cargo.lock generated
View File

@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.87.4"
version = "0.87.5"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.87.4"
version = "0.87.5"
edition = "2021"
default-run = "mangalord"

View File

@@ -57,6 +57,59 @@ struct SliceParams {
max_slices: usize,
}
/// Result of the (blocking-pool) prep pass: a self-contained set of byte
/// payloads the async HTTP loop can dispatch without further CPU work.
/// The enum mirrors [`Plan`] but carries actual JPEGs instead of geometry.
enum PreparedAnalysis {
/// `image::load_from_memory` failed — fall back to sending the raw bytes.
Undecodable,
/// One vision call against `jpeg` (the encoded whole page).
Single { jpeg: Vec<u8> },
/// Per-band OCR calls plus a final whole-image grounding call. Each
/// slice tuple is `(y0, y1, encoded_jpeg)`.
Sliced {
slices: Vec<(u32, u32, Vec<u8>)>,
whole: Vec<u8>,
},
}
/// CPU-only image work: decode → plan → optional width-reduce → slice →
/// JPEG encode. Returns `Ok(Undecodable)` (never `Err`) when the bytes
/// don't parse, so the caller can fall through to the raw-pass-through
/// behaviour.
fn prepare_analysis(image: &[u8], params: SliceParams) -> PreparedAnalysis {
let Some(img) = image::load_from_memory(image).ok() else {
return PreparedAnalysis::Undecodable;
};
match plan_slices(img.width(), img.height(), &params) {
Plan::Single { .. } => match render_whole(&img, params.max_pixels) {
Some(jpeg) => PreparedAnalysis::Single { jpeg },
None => PreparedAnalysis::Undecodable,
},
Plan::Sliced { width, height, bands } => {
let work = if width == img.width() && height == img.height() {
img.clone()
} else {
img.resize_exact(width, height, FilterType::Triangle)
};
let mut slices = Vec::with_capacity(bands.len());
for (y0, y1) in &bands {
let Some(jpeg) = render_slice(&work, *y0, *y1, params.max_pixels) else {
// A failed slice falls back to the undecodable path —
// the server then gets one combined call instead of N
// broken slice calls.
return PreparedAnalysis::Undecodable;
};
slices.push((*y0, *y1, jpeg));
}
let Some(whole) = render_whole(&img, params.max_pixels) else {
return PreparedAnalysis::Undecodable;
};
PreparedAnalysis::Sliced { slices, whole }
}
}
}
impl VisionClient {
pub fn new(http: reqwest::Client, cfg: &AnalysisConfig) -> Self {
Self {
@@ -84,9 +137,26 @@ impl VisionClient {
/// Analyze one page image. `mime` is the stored content type (used only
/// for the fallback when the image can't be decoded locally).
pub async fn analyze(&self, image: &[u8], mime: &str) -> anyhow::Result<VisionAnalysis> {
// Undecodable locally → send the raw bytes in a single combined call
// and let the server cope (preserves the prior behavior).
let Some(img) = image::load_from_memory(image).ok() else {
// All the CPU-heavy work — JPEG/PNG decode, optional width reduce,
// per-band slice, JPEG re-encode — runs on the blocking pool so it
// doesn't starve the tokio runtime (axum handlers, SSE streams,
// other daemons share the same threads). A single hop pays the
// overhead once per page; the per-call work stays on the blocking
// worker until the HTTP loop below picks back up.
let prepared = {
let bytes = image.to_vec();
let params = self.slice;
tokio::task::spawn_blocking(move || prepare_analysis(&bytes, params))
.await
.map_err(|e| anyhow!("vision prep join: {e}"))?
};
match prepared {
PreparedAnalysis::Undecodable => {
// Send raw bytes in a single combined call and let the server
// cope (preserves prior behavior). Base64 encoding still runs
// on the runtime; for an undecodable page this is the
// happy path's exit and stays brief.
let url = format!("data:{mime};base64,{}", b64(image));
let body = build_request_body(
&self.model,
@@ -97,13 +167,9 @@ impl VisionClient {
self.temperature,
&self.system_prompt,
);
return parse_chat_completion(&self.post_chat(body).await?);
};
match plan_slices(img.width(), img.height(), &self.slice) {
Plan::Single { .. } => {
let jpeg = render_whole(&img, self.slice.max_pixels)
.ok_or_else(|| anyhow!("failed to encode page image"))?;
parse_chat_completion(&self.post_chat(body).await?)
}
PreparedAnalysis::Single { jpeg } => {
let body = build_request_body(
&self.model,
self.max_tokens,
@@ -115,25 +181,14 @@ impl VisionClient {
);
parse_chat_completion(&self.post_chat(body).await?)
}
Plan::Sliced { width, height, bands } => {
PreparedAnalysis::Sliced { slices, whole } => {
tracing::debug!(
bands = bands.len(),
width,
height,
bands = slices.len(),
"analysis: slicing tall page"
);
// Work image at the (possibly width-reduced) slice space.
let work = if width == img.width() && height == img.height() {
img.clone()
} else {
img.resize_exact(width, height, FilterType::Triangle)
};
// Pass A: OCR each band (keep its y-range for seam dedup).
let mut slices: Vec<SliceOcr> = Vec::with_capacity(bands.len());
for (y0, y1) in &bands {
let jpeg = render_slice(&work, *y0, *y1, self.slice.max_pixels)
.ok_or_else(|| anyhow!("failed to encode page slice"))?;
let mut ocrs: Vec<SliceOcr> = Vec::with_capacity(slices.len());
for (y0, y1, jpeg) in &slices {
let body = build_ocr_body(
&self.model,
self.max_tokens,
@@ -141,20 +196,18 @@ impl VisionClient {
self.frequency_penalty,
self.temperature,
&self.ocr_prompt,
&data_url(&jpeg),
&data_url(jpeg),
);
let parsed = parse_chat_completion(&self.post_chat(body).await?)?;
slices.push(SliceOcr {
ocrs.push(SliceOcr {
y0: *y0 as f64,
y1: *y1 as f64,
pieces: parsed.ocr_results,
});
}
let merged = merge_ocr(slices);
let merged = merge_ocr(ocrs);
// Pass B: ground tags/scene/safety on the whole image + OCR.
let whole = render_whole(&img, self.slice.max_pixels)
.ok_or_else(|| anyhow!("failed to encode page image"))?;
let ocr_text = merged
.iter()
.map(|o| format!("[{}] {}", o.kind, o.text))
@@ -1043,4 +1096,48 @@ mod tests {
let dec = image::load_from_memory(&out).unwrap();
assert_eq!((dec.width(), dec.height()), (100, 100));
}
fn small_params() -> SliceParams {
SliceParams {
max_pixels: 1_000_000,
min_slice_height: 100,
overlap: 0.05,
tall_threshold: 1.8,
max_slices: 6,
}
}
#[test]
fn prepare_analysis_single_pages_emit_one_jpeg() {
let jpeg = jpeg_of(200, 200);
match prepare_analysis(&jpeg, small_params()) {
PreparedAnalysis::Single { jpeg } => assert!(!jpeg.is_empty()),
other => panic!("expected Single, got {:?}", std::mem::discriminant(&other)),
}
}
#[test]
fn prepare_analysis_tall_pages_emit_slice_and_whole_jpegs() {
// Long enough that height > slice_h_budget * tall_threshold —
// with the test params (max_pixels=1M, width=200, threshold=1.8)
// the threshold is 200×5000×1.8 = 9000 px. A 200×10_000 page
// pushes us into Sliced.
let jpeg = jpeg_of(200, 10_000);
match prepare_analysis(&jpeg, small_params()) {
PreparedAnalysis::Sliced { slices, whole } => {
assert!(slices.len() >= 2, "expected at least 2 bands, got {}", slices.len());
assert!(slices.iter().all(|(_, _, j)| !j.is_empty()));
assert!(!whole.is_empty());
}
other => panic!("expected Sliced, got {:?}", std::mem::discriminant(&other)),
}
}
#[test]
fn prepare_analysis_garbage_bytes_yield_undecodable() {
match prepare_analysis(&[0u8, 1, 2, 3], small_params()) {
PreparedAnalysis::Undecodable => {}
other => panic!("expected Undecodable, got {:?}", std::mem::discriminant(&other)),
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.87.4",
"version": "0.87.5",
"private": true,
"type": "module",
"scripts": {