Skip to content

Commit 4426374

Browse files
simon-mdsclaude
andcommitted
fix: stop RAM growth during full pipeline runs
Processing all pages (detect/OCR/translate/inpaint/render) grew RAM monotonically and never released it. Three independent leaks: - Frontend: useBlobImage created object URLs that were never revoked, so every sprite/inpaint/render blob pinned a full decoded image in the webview for the whole run. Revoke URLs on query-cache eviction and drop the blob gcTime from 10min to 1min so inactive pages release promptly. - Metal: candle's Metal ops and the MPSGraph FFT allocate autoreleased Objective-C objects (command buffers, MPS intermediates), and the pipeline runs on tokio worker threads with no autorelease pool draining, so they accumulate for the entire run. Wrap the LaMa per-crop forward and the FFT calls in objc2 autorelease pools (no-op off Metal). - Unbounded caches: the LaMa FFT-plan caches (keyed by crop shape) and the flux2 Qwen prompt cache lived on whole-run engine instances and were never evicted. Bound them with LruCache. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2107843 commit 4426374

8 files changed

Lines changed: 128 additions & 19 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/koharu-ml/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ candle-transformers = { workspace = true }
2222
candle-nn = { workspace = true }
2323
candle-flash-attn = { workspace = true, optional = true }
2424
tokenizers = { workspace = true }
25+
lru = { workspace = true }
2526
serde = { workspace = true }
2627
serde_json = { workspace = true }
2728
tracing = { workspace = true }

crates/koharu-ml/src/flux2_klein/qwen.rs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
use lru::LruCache;
12
use std::{
23
collections::HashMap,
34
fs::File,
45
io::{Read, Seek},
6+
num::NonZeroUsize,
57
path::Path,
68
sync::{Arc, Mutex},
79
};
@@ -399,11 +401,18 @@ fn causal_padding_mask(
399401
Tensor::from_vec(values, (batch, 1, seq_len, seq_len), device)?.to_dtype(dtype)
400402
}
401403

404+
/// Upper bound on cached prompt embeddings. Each entry retains a
405+
/// `(1, max_sequence_len, hidden)` tensor; keys are full prompt strings, which
406+
/// are effectively unique per translated block, so an unbounded map grew one
407+
/// large tensor per page for the whole run. Reuse across pages is rare, so a
408+
/// small LRU is plenty (it still absorbs the repeated empty/negative prompt).
409+
const PROMPT_CACHE_CAP: usize = 8;
410+
402411
#[derive(Debug)]
403412
pub struct PromptEmbedder {
404413
tokenizer: Tokenizer,
405414
text_encoder: QwenTextEncoder,
406-
cache: Mutex<HashMap<String, Arc<QwenPromptEmbeddings>>>,
415+
cache: Mutex<LruCache<String, Arc<QwenPromptEmbeddings>>>,
407416
pad_token_id: u32,
408417
max_sequence_len: usize,
409418
hidden_layer_indices: Vec<usize>,
@@ -425,7 +434,9 @@ impl PromptEmbedder {
425434
Ok(Self {
426435
tokenizer,
427436
text_encoder,
428-
cache: Mutex::new(HashMap::new()),
437+
cache: Mutex::new(LruCache::new(
438+
NonZeroUsize::new(PROMPT_CACHE_CAP).expect("cache capacity is non-zero"),
439+
)),
429440
pad_token_id,
430441
max_sequence_len: DEFAULT_MAX_PROMPT_SEQUENCE_LEN,
431442
hidden_layer_indices: DEFAULT_HIDDEN_LAYERS.to_vec(),
@@ -471,7 +482,7 @@ impl PromptEmbedder {
471482
self.cache
472483
.lock()
473484
.expect("prompt cache poisoned")
474-
.insert(key, embeddings.clone());
485+
.put(key, embeddings.clone());
475486
Ok(embeddings)
476487
}
477488
}

crates/koharu-ml/src/lama/fft/cuda.rs

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,19 @@ use cudarc::{
88
cufft::{result as cufft, sys},
99
driver::{DevicePtr, DevicePtrMut},
1010
};
11+
use lru::LruCache;
1112
use std::{
12-
collections::HashMap,
13+
num::NonZeroUsize,
1314
sync::{Arc, Mutex, OnceLock},
1415
};
1516

17+
/// Upper bound on distinct cached cuFFT plans. Keyed by FFT shape, which
18+
/// varies per LaMa crop resolution, so an unbounded map retains one
19+
/// `cufftHandle` per distinct crop size for the whole run. Cap it so stale
20+
/// handles are evicted (and destroyed via `CachedPlan::drop`) as new shapes
21+
/// appear. Mirrors the Metal path's `FFT_PLAN_CACHE_CAP`.
22+
const FFT_PLAN_CACHE_CAP: usize = 64;
23+
1624
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1725
enum PlanKind {
1826
R2C,
@@ -41,9 +49,13 @@ impl Drop for CachedPlan {
4149
}
4250
}
4351

44-
fn plan_cache() -> &'static Mutex<HashMap<PlanKey, Arc<CachedPlan>>> {
45-
static CACHE: OnceLock<Mutex<HashMap<PlanKey, Arc<CachedPlan>>>> = OnceLock::new();
46-
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
52+
fn plan_cache() -> &'static Mutex<LruCache<PlanKey, Arc<CachedPlan>>> {
53+
static CACHE: OnceLock<Mutex<LruCache<PlanKey, Arc<CachedPlan>>>> = OnceLock::new();
54+
CACHE.get_or_init(|| {
55+
Mutex::new(LruCache::new(
56+
NonZeroUsize::new(FFT_PLAN_CACHE_CAP).expect("cache capacity is non-zero"),
57+
))
58+
})
4759
}
4860

4961
fn get_or_create_plan(
@@ -104,7 +116,7 @@ fn get_or_create_plan(
104116
handle,
105117
lock: Mutex::new(()),
106118
});
107-
cache.insert(key, plan.clone());
119+
cache.put(key, plan.clone());
108120
Ok(plan)
109121
}
110122

crates/koharu-ml/src/lama/fft/metal.rs

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,28 @@ use candle_core::{
44
bail,
55
metal_backend::{DeviceId, MetalError, MetalStorage},
66
};
7-
use objc2::{AnyThread, rc::Retained, runtime::ProtocolObject};
7+
use objc2::{
8+
AnyThread,
9+
rc::{Retained, autoreleasepool},
10+
runtime::ProtocolObject,
11+
};
812
use objc2_foundation::{NSArray, NSCopying, NSDictionary, NSNumber};
913
use objc2_metal_performance_shaders::MPSDataType;
1014
use objc2_metal_performance_shaders_graph::{
1115
MPSGraph, MPSGraphFFTDescriptor, MPSGraphFFTScalingMode, MPSGraphTensor, MPSGraphTensorData,
1216
MPSGraphTensorDataDictionary,
1317
};
14-
use std::{cell::RefCell, collections::HashMap, ptr::NonNull};
18+
use lru::LruCache;
19+
use std::{cell::RefCell, num::NonZeroUsize, ptr::NonNull};
20+
21+
/// Upper bound on distinct cached FFT plans (MPSGraphs). Each LaMa crop runs
22+
/// at its native resolution, and crop sizes vary per bubble/page, so this map
23+
/// is keyed by a continuously-varying shape. Without a cap it grows one
24+
/// retained `MPSGraph` per distinct crop size for the whole run — the source
25+
/// of the steady RAM climb when processing many pages. The working set of
26+
/// shapes touched by a single inpaint pass is small, so an LRU comfortably
27+
/// keeps hot plans while evicting (and freeing) stale ones.
28+
const FFT_PLAN_CACHE_CAP: usize = 64;
1529

1630
fn nsarray_from_usize(values: &[usize]) -> Result<Retained<objc2_foundation::NSArray<NSNumber>>> {
1731
let nums: Vec<Retained<NSNumber>> = values
@@ -78,8 +92,10 @@ struct FftPlan {
7892
}
7993

8094
thread_local! {
81-
static FFT_PLANS: RefCell<HashMap<FftKey, FftPlan>> = RefCell::new(HashMap::new());
82-
static COMMAND_QUEUES: RefCell<HashMap<DeviceId, Retained<ProtocolObject<dyn objc2_metal::MTLCommandQueue>>>> = RefCell::new(HashMap::new());
95+
static FFT_PLANS: RefCell<LruCache<FftKey, FftPlan>> = RefCell::new(LruCache::new(
96+
NonZeroUsize::new(FFT_PLAN_CACHE_CAP).expect("cache capacity is non-zero"),
97+
));
98+
static COMMAND_QUEUES: RefCell<std::collections::HashMap<DeviceId, Retained<ProtocolObject<dyn objc2_metal::MTLCommandQueue>>>> = RefCell::new(std::collections::HashMap::new());
8399
}
84100

85101
fn shared_command_queue(
@@ -101,7 +117,7 @@ fn shared_command_queue(
101117

102118
fn fft_plan(key: FftKey) -> Result<FftPlan> {
103119
FFT_PLANS.with(|plans| {
104-
if let Some(plan) = plans.borrow().get(&key) {
120+
if let Some(plan) = plans.borrow_mut().get(&key) {
105121
return Ok(plan.clone());
106122
}
107123

@@ -164,12 +180,25 @@ fn fft_plan(key: FftKey) -> Result<FftPlan> {
164180
output_shape,
165181
};
166182

167-
plans.borrow_mut().insert(key, plan.clone());
183+
plans.borrow_mut().put(key, plan.clone());
168184
Ok(plan)
169185
})
170186
}
171187

188+
/// Each MPSGraph run and the Cocoa factory calls below (`NSArray`,
189+
/// `NSDictionary`, `MPSGraphTensorData`, and the command buffers allocated
190+
/// inside `runWithMTLCommandQueue`) return autoreleased objects. The pipeline
191+
/// runs on tokio worker threads that have no autorelease pool draining, so
192+
/// without an explicit pool these temporaries — including sizeable Metal
193+
/// command buffers and MPS intermediates — accumulate for the entire run and
194+
/// are the dominant per-page RAM climb on the LaMa+Metal path. Wrapping each
195+
/// call drains them immediately; the returned `output_buffer` is candle-owned
196+
/// (held by an `Arc`), so it safely outlives the pool.
172197
pub fn rfft2(storage: &MetalStorage, layout: &Layout) -> Result<(MetalStorage, Shape)> {
198+
autoreleasepool(|_| rfft2_impl(storage, layout))
199+
}
200+
201+
fn rfft2_impl(storage: &MetalStorage, layout: &Layout) -> Result<(MetalStorage, Shape)> {
173202
let dims = layout.dims();
174203
if dims.len() != 4 {
175204
bail!("rfft2 expects rank-4 input, got {:?}", dims)
@@ -250,6 +279,15 @@ pub fn irfft2(
250279
storage: &MetalStorage,
251280
layout: &Layout,
252281
width: usize,
282+
) -> Result<(MetalStorage, Shape)> {
283+
// See `rfft2` — drain autoreleased Metal/MPS temporaries per call.
284+
autoreleasepool(|_| irfft2_impl(storage, layout, width))
285+
}
286+
287+
fn irfft2_impl(
288+
storage: &MetalStorage,
289+
layout: &Layout,
290+
width: usize,
253291
) -> Result<(MetalStorage, Shape)> {
254292
let dims = layout.dims();
255293
if dims.len() != 5 || dims[4] != 2 {

crates/koharu-ml/src/lama/mod.rs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@ const HF_REPO: &str = "mayocream/lama-manga";
2121
const BLOCK_WINDOW_RATIO: f64 = 1.7;
2222
const BLOCK_WINDOW_ASPECT_RATIO: f64 = 1.0;
2323

24+
/// Run `f` inside an Objective-C autorelease pool on Metal builds so the Metal
25+
/// temporaries it creates are freed immediately; a plain call elsewhere.
26+
#[cfg(feature = "metal")]
27+
fn drain_autorelease_pool<T>(f: impl FnOnce() -> T) -> T {
28+
objc2::rc::autoreleasepool(|_| f())
29+
}
30+
31+
#[cfg(not(feature = "metal"))]
32+
fn drain_autorelease_pool<T>(f: impl FnOnce() -> T) -> T {
33+
f()
34+
}
35+
2436
type Xyxy = [u32; 4];
2537

2638
koharu_runtime::declare_hf_model_package!(
@@ -164,9 +176,18 @@ impl Lama {
164176

165177
#[instrument(level = "debug", skip_all)]
166178
fn inference_model(&self, image: &RgbImage, mask: &GrayImage) -> Result<RgbImage> {
167-
let (image_tensor, mask_tensor) = self.preprocess(image, mask)?;
168-
let output = self.forward(&image_tensor, &mask_tensor)?;
169-
self.postprocess(&output)
179+
// Drain autoreleased Metal temporaries per crop. candle's Metal ops
180+
// (and the FFC layers' MPSGraph FFTs) allocate command buffers and MPS
181+
// intermediates that are autoreleased; on the pool-less pipeline worker
182+
// threads a leaked command buffer also pins every GPU buffer it
183+
// referenced, so without a pool GPU/unified memory climbs crop after
184+
// crop for the whole run. The returned `RgbImage` is heap-owned and
185+
// safely outlives the pool.
186+
drain_autorelease_pool(|| {
187+
let (image_tensor, mask_tensor) = self.preprocess(image, mask)?;
188+
let output = self.forward(&image_tensor, &mask_tensor)?;
189+
self.postprocess(&output)
190+
})
170191
}
171192

172193
#[instrument(level = "debug", skip_all)]

ui/hooks/useBlobData.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query'
55
import { getBlob } from '@/lib/api/default/default'
66
import { convertToBlob } from '@/lib/io/blobConvert'
77

8+
// Each cached entry holds a full-resolution decoded image (raw bytes, or a
9+
// Blob pinned by an object URL). Batch runs touch a new set of hashes per page,
10+
// so a long gcTime keeps a large sliding window of full images resident at
11+
// once. Keep just enough to make navigating a few pages back instant; inactive
12+
// entries are evicted quickly (and their object URLs revoked — see
13+
// `queryClient.ts`). Actively-observed queries are never evicted regardless.
14+
const BLOB_GC_TIME = 60 * 1000
15+
816
const blobQueryOptions = (hash: string) => ({
917
queryKey: ['blob', hash] as const,
1018
queryFn: async () => {
@@ -13,7 +21,7 @@ const blobQueryOptions = (hash: string) => ({
1321
return new Uint8Array(buf)
1422
},
1523
staleTime: Infinity,
16-
gcTime: 10 * 60 * 1000,
24+
gcTime: BLOB_GC_TIME,
1725
structuralSharing: false as const,
1826
})
1927

@@ -44,7 +52,7 @@ const blobImageQueryOptions = (hash: string) => ({
4452
return url
4553
},
4654
staleTime: Infinity,
47-
gcTime: 10 * 60 * 1000,
55+
gcTime: BLOB_GC_TIME,
4856
structuralSharing: false as const,
4957
})
5058

ui/lib/queryClient.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,20 @@ import { QueryClient } from '@tanstack/react-query'
1111
* calls and React Query hooks share the exact same cache.
1212
*/
1313
export const queryClient = new QueryClient()
14+
15+
// `useBlobImage` caches ready-to-paint object URLs (`blob:` URLs) as query
16+
// data. Object URLs pin their backing `Blob` (a full decoded image) in memory
17+
// until explicitly revoked, so a cached URL that is merely garbage-collected
18+
// still leaks the image. During a "process all pages" run every page mints new
19+
// sprite/inpaint/render blobs, so without this the webview's memory climbs one
20+
// full image per hash for the whole run. Revoke the URL when its query leaves
21+
// the cache, tying the Blob's lifetime to the cache entry's.
22+
queryClient.getQueryCache().subscribe((event) => {
23+
if (event.type !== 'removed') return
24+
const [kind] = event.query.queryKey as [unknown]
25+
if (kind !== 'blobImage') return
26+
const url = event.query.state.data
27+
if (typeof url === 'string' && url.startsWith('blob:')) {
28+
URL.revokeObjectURL(url)
29+
}
30+
})

0 commit comments

Comments
 (0)