폴더 추가 시 UI 멈춤 수정 — 백그라운드 워커 저우선순위 + JPEG DCT 축소 디코드
원인: 실사진(수십 MP) 스캔 시 메타 추출(rayon)과 썸네일 워커가 NORMAL 우선순위로 전 코어를 포화시켜 WebView UI 스레드를 굶김. 풀해상도 JPEG 디코드는 파일당 ~72MB를 할당해 병렬 시 메모리 대역폭까지 포화. - prio 모듈: 현재 스레드 우선순위 낮추기(Win BELOW_NORMAL / macOS nice+5), 메타 전용 저우선순위 rayon 풀(코어-1, 전역 풀과 분리) - 썸네일 워커 스레드·스캔 스레드를 저우선순위로 → CPU 포화 시 UI 우선 스케줄 - meta 이미지/영상 추출을 저우선순위 풀에서 실행 - JPEG는 jpeg-decoder DCT 축소 디코드(1/8 등) 사용: 24MP 기준 풀디코드 63ms/72MB → 축소 38ms/1MB (1.7배·메모리 ~70배 절감), RGB24/L8만 처리하고 그 외(CMYK)는 기존 경로로 폴백 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Generated
+10
@@ -170,6 +170,7 @@ dependencies = [
|
|||||||
"image",
|
"image",
|
||||||
"image_hasher",
|
"image_hasher",
|
||||||
"imagesize",
|
"imagesize",
|
||||||
|
"jpeg-decoder",
|
||||||
"jwalk",
|
"jwalk",
|
||||||
"kamadak-exif",
|
"kamadak-exif",
|
||||||
"rayon",
|
"rayon",
|
||||||
@@ -3187,6 +3188,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jpeg-decoder"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
|
||||||
|
dependencies = [
|
||||||
|
"rayon",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.103"
|
version = "0.3.103"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ crossbeam-channel = { workspace = true }
|
|||||||
xxhash-rust = { version = "0.8", features = ["xxh3"] }
|
xxhash-rust = { version = "0.8", features = ["xxh3"] }
|
||||||
blake3 = { version = "1", features = ["mmap", "rayon"] }
|
blake3 = { version = "1", features = ["mmap", "rayon"] }
|
||||||
image = "0.25"
|
image = "0.25"
|
||||||
|
jpeg-decoder = "0.3"
|
||||||
image_hasher = "3"
|
image_hasher = "3"
|
||||||
fast_image_resize = "6"
|
fast_image_resize = "6"
|
||||||
imagesize = "0.14"
|
imagesize = "0.14"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub mod meta;
|
|||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod phash;
|
pub mod phash;
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
|
pub mod prio;
|
||||||
pub mod scan;
|
pub mod scan;
|
||||||
pub mod tags;
|
pub mod tags;
|
||||||
pub mod thumbq;
|
pub mod thumbq;
|
||||||
|
|||||||
@@ -95,21 +95,24 @@ pub fn extract_image_meta(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let results: Vec<MetaResult> = batch
|
// 저우선순위 백그라운드 풀에서 실행 — CPU 포화 시 UI 스레드 우선 스케줄
|
||||||
.par_iter()
|
let results: Vec<MetaResult> = crate::prio::bg_pool().install(|| {
|
||||||
.map(|(id, path)| {
|
batch
|
||||||
let dims = imagesize::size(path).ok();
|
.par_iter()
|
||||||
let (orientation, taken_at) = exif_of(path);
|
.map(|(id, path)| {
|
||||||
MetaResult {
|
let dims = imagesize::size(path).ok();
|
||||||
id: *id,
|
let (orientation, taken_at) = exif_of(path);
|
||||||
width: dims.map(|d| d.width as u32),
|
MetaResult {
|
||||||
height: dims.map(|d| d.height as u32),
|
id: *id,
|
||||||
orientation,
|
width: dims.map(|d| d.width as u32),
|
||||||
taken_at,
|
height: dims.map(|d| d.height as u32),
|
||||||
ok: dims.is_some(),
|
orientation,
|
||||||
}
|
taken_at,
|
||||||
})
|
ok: dims.is_some(),
|
||||||
.collect();
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
total += results.len() as u64;
|
total += results.len() as u64;
|
||||||
db.with_write(move |conn| {
|
db.with_write(move |conn| {
|
||||||
@@ -166,10 +169,13 @@ pub fn extract_video_meta(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let results: Vec<(i64, Option<crate::ffmpeg::MediaInfo>)> = batch
|
let results: Vec<(i64, Option<crate::ffmpeg::MediaInfo>)> =
|
||||||
.par_iter()
|
crate::prio::bg_pool().install(|| {
|
||||||
.map(|(id, path)| (*id, crate::ffmpeg::probe(tools, path).ok()))
|
batch
|
||||||
.collect();
|
.par_iter()
|
||||||
|
.map(|(id, path)| (*id, crate::ffmpeg::probe(tools, path).ok()))
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
total += results.len() as u64;
|
total += results.len() as u64;
|
||||||
db.with_write(move |conn| {
|
db.with_write(move |conn| {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
//! 백그라운드 워커 스레드 우선순위 낮추기 — 스캔/썸네일 작업이 CPU를 점유해도
|
||||||
|
//! UI(WebView) 스레드가 굶지 않도록 한다. Windows만 실제 동작, 그 외는 no-op.
|
||||||
|
|
||||||
|
/// 현재 스레드를 낮은 우선순위로 설정한다 (Windows: BELOW_NORMAL).
|
||||||
|
pub fn set_current_thread_low() {
|
||||||
|
#[cfg(windows)]
|
||||||
|
unsafe {
|
||||||
|
// kernel32: GetCurrentThread / SetThreadPriority — C 의존성 없음(직접 FFI)
|
||||||
|
const THREAD_PRIORITY_BELOW_NORMAL: i32 = -1;
|
||||||
|
extern "system" {
|
||||||
|
fn GetCurrentThread() -> isize;
|
||||||
|
fn SetThreadPriority(h: isize, prio: i32) -> i32;
|
||||||
|
}
|
||||||
|
let h = GetCurrentThread();
|
||||||
|
SetThreadPriority(h, THREAD_PRIORITY_BELOW_NORMAL);
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
unsafe {
|
||||||
|
// setpriority(PRIO_PROCESS=0, tid=0(self), niceness) — 스레드 니스 상향
|
||||||
|
extern "C" {
|
||||||
|
fn setpriority(which: i32, who: u32, prio: i32) -> i32;
|
||||||
|
}
|
||||||
|
setpriority(0, 0, 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 스캔 메타데이터 추출용 저우선순위 rayon 풀.
|
||||||
|
/// - 워커를 (코어-1)로 제한해 UI/OS에 코어를 최소 하나 남긴다.
|
||||||
|
/// - 각 워커 스레드를 BELOW_NORMAL로 낮춰, CPU 포화 시 UI 스레드가 우선 스케줄되게 한다.
|
||||||
|
/// 전역 rayon 풀과 분리되어 blake3/dedupe 등 다른 병렬 작업에 영향을 주지 않는다.
|
||||||
|
pub fn bg_pool() -> &'static rayon::ThreadPool {
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
static POOL: OnceLock<rayon::ThreadPool> = OnceLock::new();
|
||||||
|
POOL.get_or_init(|| {
|
||||||
|
let cores = std::thread::available_parallelism()
|
||||||
|
.map(|n| n.get())
|
||||||
|
.unwrap_or(4);
|
||||||
|
let workers = cores.saturating_sub(1).max(1);
|
||||||
|
rayon::ThreadPoolBuilder::new()
|
||||||
|
.num_threads(workers)
|
||||||
|
.thread_name(|i| format!("bg-meta-{i}"))
|
||||||
|
.start_handler(|_| set_current_thread_low())
|
||||||
|
.build()
|
||||||
|
.expect("백그라운드 rayon 풀 생성 실패")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -133,6 +133,8 @@ pub fn thumb_path(thumb_root: &std::path::Path, size_class: u8, key: &str) -> Pa
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn worker_loop(ctx: Arc<Ctx>, hi_rx: Receiver<i64>, lo_rx: Receiver<i64>) {
|
fn worker_loop(ctx: Arc<Ctx>, hi_rx: Receiver<i64>, lo_rx: Receiver<i64>) {
|
||||||
|
// 썸네일 디코드/리사이즈는 CPU 집약적 — UI 스레드가 굶지 않도록 우선순위를 낮춘다.
|
||||||
|
crate::prio::set_current_thread_low();
|
||||||
loop {
|
loop {
|
||||||
// hi 우선, 없으면 lo를 짧게 대기
|
// hi 우선, 없으면 lo를 짧게 대기
|
||||||
let id = match hi_rx.try_recv() {
|
let id = match hi_rx.try_recv() {
|
||||||
|
|||||||
@@ -73,15 +73,58 @@ pub fn make_image_thumb(
|
|||||||
long_edge: u32,
|
long_edge: u32,
|
||||||
quality: u8,
|
quality: u8,
|
||||||
) -> Result<(u32, u32, u32, u32)> {
|
) -> Result<(u32, u32, u32, u32)> {
|
||||||
|
let orientation = read_orientation(src);
|
||||||
|
|
||||||
|
// JPEG 빠른 경로: DCT 축소 디코드(1/2·1/4·1/8). 6000×4000 사진을 풀디코드(≈72MB)
|
||||||
|
// 하지 않고 750×500(≈1MB)만 디코드해 CPU·메모리를 대폭 절감한다. 실패 시 아래로 폴백.
|
||||||
|
if is_jpeg_ext(src) {
|
||||||
|
if let Some(scaled) = decode_jpeg_scaled(src, long_edge) {
|
||||||
|
let oriented = apply_orientation(scaled, orientation);
|
||||||
|
let (w, h) = (oriented.width(), oriented.height());
|
||||||
|
let (dw, dh) = resize_encode(&oriented, dst, long_edge, quality)?;
|
||||||
|
return Ok((w, h, dw, dh));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let decoded = image::ImageReader::open(src)?
|
let decoded = image::ImageReader::open(src)?
|
||||||
.with_guessed_format()?
|
.with_guessed_format()?
|
||||||
.decode()?;
|
.decode()?;
|
||||||
let oriented = apply_orientation(decoded, read_orientation(src));
|
let oriented = apply_orientation(decoded, orientation);
|
||||||
let (w, h) = (oriented.width(), oriented.height());
|
let (w, h) = (oriented.width(), oriented.height());
|
||||||
let (dw, dh) = resize_encode(&oriented, dst, long_edge, quality)?;
|
let (dw, dh) = resize_encode(&oriented, dst, long_edge, quality)?;
|
||||||
Ok((w, h, dw, dh))
|
Ok((w, h, dw, dh))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_jpeg_ext(src: &Path) -> bool {
|
||||||
|
src.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.map(|e| e.eq_ignore_ascii_case("jpg") || e.eq_ignore_ascii_case("jpeg"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// jpeg-decoder의 DCT 스케일 기능으로 축소 디코드한다. 긴 변이 대략 `target` 이상이 되는
|
||||||
|
/// 가장 큰 축소(1/1·1/2·1/4·1/8)를 고른다. RGB24/L8만 처리하고 그 외(CMYK 등)는 None(폴백).
|
||||||
|
fn decode_jpeg_scaled(src: &Path, target: u32) -> Option<DynamicImage> {
|
||||||
|
use jpeg_decoder::PixelFormat;
|
||||||
|
let file = std::fs::File::open(src).ok()?;
|
||||||
|
let mut dec = jpeg_decoder::Decoder::new(std::io::BufReader::new(file));
|
||||||
|
dec.read_info().ok()?;
|
||||||
|
// 회전으로 가로/세로가 뒤바뀔 수 있으므로 두 축 모두 target으로 요청(축소만 하므로 안전).
|
||||||
|
let t = target.clamp(1, u16::MAX as u32) as u16;
|
||||||
|
dec.scale(t, t).ok()?;
|
||||||
|
let pixels = dec.decode().ok()?;
|
||||||
|
let info = dec.info()?;
|
||||||
|
let (w, h) = (info.width as u32, info.height as u32);
|
||||||
|
if w == 0 || h == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match info.pixel_format {
|
||||||
|
PixelFormat::RGB24 => image::RgbImage::from_raw(w, h, pixels).map(DynamicImage::ImageRgb8),
|
||||||
|
PixelFormat::L8 => image::GrayImage::from_raw(w, h, pixels).map(DynamicImage::ImageLuma8),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 이미 디코드된 이미지에서 썸네일 생성(원격 EXIF 내장 썸네일 등). 반환: (썸네일 w, h).
|
/// 이미 디코드된 이미지에서 썸네일 생성(원격 EXIF 내장 썸네일 등). 반환: (썸네일 w, h).
|
||||||
pub fn make_image_thumb_from(
|
pub fn make_image_thumb_from(
|
||||||
img: &DynamicImage,
|
img: &DynamicImage,
|
||||||
@@ -248,6 +291,58 @@ mod tests {
|
|||||||
assert_eq!(found[2], 0x22);
|
assert_eq!(found[2], 0x22);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jpeg_dct_scaled_fast_path() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let src = dir.path().join("big.jpg");
|
||||||
|
let dst = dir.path().join("thumbs/aa/key.jpg");
|
||||||
|
|
||||||
|
// 2400×1600 JPEG — DCT 축소 경로가 타겟(256)보다 크게 디코드 후 리사이즈.
|
||||||
|
let img = image::RgbImage::from_fn(2400, 1600, |x, y| {
|
||||||
|
image::Rgb([(x % 256) as u8, (y % 256) as u8, 100])
|
||||||
|
});
|
||||||
|
img.save(&src).unwrap(); // 확장자(.jpg)로 JPEG 인코딩
|
||||||
|
|
||||||
|
// fast path 직접 검증: 축소 디코드 결과가 원본보다 작고, 긴 변이 타겟 이상.
|
||||||
|
let scaled = decode_jpeg_scaled(&src, 256).expect("DCT 축소 디코드");
|
||||||
|
assert!(scaled.width() < 2400, "축소되어야 함: {}", scaled.width());
|
||||||
|
assert!(scaled.width().max(scaled.height()) >= 256);
|
||||||
|
|
||||||
|
// 최종 썸네일: 긴 변 256, 2400×1600(3:2) → 256×170
|
||||||
|
let (_, _, dw, dh) = make_image_thumb(&src, &dst, 256, 80).unwrap();
|
||||||
|
assert_eq!((dw, dh), (256, 170));
|
||||||
|
assert!(dst.is_file());
|
||||||
|
let reopened = image::ImageReader::open(&dst).unwrap().decode().unwrap();
|
||||||
|
assert_eq!(reopened.width(), 256);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "타이밍 측정용 — cargo test -- --ignored --nocapture"]
|
||||||
|
fn bench_full_vs_dct_decode() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let src = dir.path().join("huge.jpg");
|
||||||
|
// 6000×4000 (24MP) 실사진 규모 JPEG
|
||||||
|
let img = image::RgbImage::from_fn(6000, 4000, |x, y| {
|
||||||
|
image::Rgb([(x % 256) as u8, ((x + y) % 256) as u8, (y % 256) as u8])
|
||||||
|
});
|
||||||
|
img.save(&src).unwrap();
|
||||||
|
|
||||||
|
let t0 = std::time::Instant::now();
|
||||||
|
let full = image::ImageReader::open(&src).unwrap().with_guessed_format().unwrap().decode().unwrap();
|
||||||
|
let full_ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||||
|
assert_eq!(full.width(), 6000);
|
||||||
|
|
||||||
|
let t1 = std::time::Instant::now();
|
||||||
|
let scaled = decode_jpeg_scaled(&src, 256).unwrap();
|
||||||
|
let dct_ms = t1.elapsed().as_secs_f64() * 1000.0;
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"24MP JPEG 디코드: 풀={full_ms:.0}ms(6000×4000, ~72MB) vs DCT축소={dct_ms:.0}ms({}×{}) → {:.1}배 빠름",
|
||||||
|
scaled.width(), scaled.height(), full_ms / dct_ms.max(0.001)
|
||||||
|
);
|
||||||
|
assert!(scaled.width() <= 750, "1/8 축소: {}", scaled.width());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn make_thumb_from_generated_png() {
|
fn make_thumb_from_generated_png() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -240,6 +240,8 @@ pub fn spawn_scan_thread(
|
|||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("scan".into())
|
.name("scan".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
|
// 스캔/메타 오케스트레이션 스레드도 저우선순위로 — UI 응답성 우선
|
||||||
|
archive_indexer::prio::set_current_thread_low();
|
||||||
run_scan_pipeline(&db, &tools, &thumbs, source_id, Path::new(&root), &cancel, |p| {
|
run_scan_pipeline(&db, &tools, &thumbs, source_id, Path::new(&root), &cancel, |p| {
|
||||||
if let Some(ch) = &on_progress {
|
if let Some(ch) = &on_progress {
|
||||||
let _ = ch.send(p);
|
let _ = ch.send(p);
|
||||||
|
|||||||
Reference in New Issue
Block a user