Files
ollama/x/create/mlxthread.go
T
Jesse Gross 13037ecb14 mlx: scope array lifetimes instead of pinning and sweeping
The bindings freed arrays by sweeping everything not pinned, so freeing
anything required knowing what every other caller still held, and code
that never swept accumulated until memory ran out. The prefix cache's
eviction of a long stored path did exactly that: each merge copied the
KV snapshots and nothing freed the consumed copies until the request
ended, which drove a second long request past physical memory.

Every array now belongs to a scope. A function scope, entered with Scoped
or one of the ScopedEval forms, frees what was created in it when the
function returns; results leave only by being returned. A held scope is
closed by its holder and frees what was attached to it. A graph is
built in a function scope and evaluated after it, so the eval frees each
intermediate as it consumes it. Pin, Unpin, Sweep, and the array list's
mutex are gone.

On an M5 Max with qwen3.8:27b-mlx, the second 84k-token request after a
stored one peaks at 35 GB instead of 57 GB; the cold path is unchanged.
The copies themselves are untouched, so restoring an owned path can still
exceed memory.
2026-09-10 17:25:34 -07:00

72 lines
1.6 KiB
Go

package create
import (
"fmt"
"runtime"
"sync"
"sync/atomic"
"github.com/ollama/ollama/x/mlxrunner/mlx"
)
var (
mlxThreadOnce sync.Once
mlxThreadStarted atomic.Bool
mlxWork chan func()
mlxInitErr error
)
// runOnMLXThread runs f on the MLX thread and returns its error. The thread is
// started (and MLX initialized) on first use. A panic in f is recovered and
// returned as an error so a kernel failure cannot kill the pinned thread.
//
// TODO(pdevine): This method should be revisited when the `ollama create` is
// instead run on the ollama server process instead of the client.
func runOnMLXThread(f func() error) error {
mlxThreadOnce.Do(func() {
mlxWork = make(chan func())
ready := make(chan error)
go func() {
runtime.LockOSThread() // pinned for the process lifetime; never unlocked
err := mlx.CheckInit()
if err == nil && mlx.GPUIsAvailable() {
mlx.SetDefaultDeviceGPU()
}
ready <- err
if err != nil {
return
}
for work := range mlxWork {
work()
}
}()
mlxInitErr = <-ready
mlxThreadStarted.Store(mlxInitErr == nil)
})
if mlxInitErr != nil {
return fmt.Errorf("MLX init failed: %w", mlxInitErr)
}
done := make(chan error, 1)
mlxWork <- func() {
defer func() {
if r := recover(); r != nil {
done <- fmt.Errorf("mlx: %v", r)
}
}()
done <- f()
}
return <-done
}
// releaseMLXCache releases the MLX buffer cache. It is a no-op if no MLX work has run.
func releaseMLXCache() {
if !mlxThreadStarted.Load() {
return
}
_ = runOnMLXThread(func() error {
mlx.ClearCache()
return nil
})
}