huggingface / huggingface/candle
Metal buffer pool grows beyond physical RAM on small unified-memory devices
- Dominant language
- Rust
- Stars
- 21k
- Forks
- 1.8k
- Avg merge
- 16h 42m
- Merged PRs (30d)
- 25
Description
## Summary
On an 8 GB Apple Silicon machine, I observed Candle's Metal buffer pool grow well past physical RAM during a single decode request, which appears to trigger severe unified-memory swap thrash.
This was instrumented while integrating a fused 4-bit Metal path in Cake, but the behavior I am reporting here is specifically about Candle's Metal allocator / pooled buffer lifetime.
## Environment
- `candle-core = 0.9.2`
- `candle-metal-kernels = 0.9.2`
- hardware: MacBook Air M1, 8 GB unified memory
- workload: 7B 4-bit Qwen2.5 decode on Metal
## What I measured
I added local instrumentation to Candle's Metal backend to log pooled / in-use buffer bytes at command-buffer completion, plus per-command-buffer GPU completion times.
During a single request:
- pooled bytes grew from roughly `5058 MB` to roughly `11199 MB`
- in-use bytes grew from roughly `5007 MB` to roughly `11195 MB`
- the request emitted `50` completed command buffers totaling about `24.7 s` of GPU time
At the same time, isolated kernel benchmarks for the hot decode projections were fast (sub-ms to a few ms), so the pathological behavior only appeared under queued real-model execution.
## Important detail
A simple cap experiment did **not** fix this:
- forcing `wait_until_completed()` + dropping unused buffers when the pool crossed a threshold made throughput worse
- the pool still remained above the cap because in-flight buffers could not be reclaimed
That suggests the issue is not just "cache too large after completion"; it is also about how long transient outputs stay resident while command buffers are still outstanding.
## Why I'm filing this
On large-memory Apple machines this may be tolerable, but on small unified-memory devices it can turn an otherwise-correct Metal workload into swap-bound execution.
The key gap seems to be that the current pool treats all Metal buffers similarly. For this kind of workload, there is a big difference between:
- persistent model weights that should stay resident
- transient intermediate outputs that should be reclaimed or avoided as aggressively as possible
## Questions
1. Is there already a supported way to mark certain Metal allocations as transient / non-pooled?
2. Are there known plans to separate persistent and transient buffers in the Metal backend?
3. Is there a recommended way to bound pool growth on small unified-memory devices without forcing global waits?
If useful, I can follow up with a reduced reproduction or the exact instrumentation patches I used to capture pool growth and command-buffer timings.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start by locating Candle's Metal allocator and pooled-buffer lifetime handling, then reproduce the reported growth on an 8 GB Apple Silicon device with the described decode workload. Compare pooled and in-use bytes across command-buffer completion; done would require a concrete, validated way to control transient-buffer retention without forcing global waits.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- backend, machine-learning, performance
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 35/100