huggingface / huggingface/candle

Metal buffer pool grows beyond physical RAM on small unified-memory devices

Open
#3,464 3 comments 0 reactions 0 assignees View on GitHub
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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.