Files
Jesse Gross 2e036e7cdf mlx, mlxrunner: move the MLX engine out of x/
The MLX runner is the only Go inference runner left and is no longer
experimental, so its packages leave x/. The bindings become a top-level
mlx package beside the carried patches in mlx/compat, mirroring how
llama/ holds the llama.cpp integration, and the runner becomes mlxrunner
with the architectures nested under the package they implement.
Subpackages move with their parent unless listed.

  x/mlxrunner/mlx            mlx
  x/internal/mlxthread       mlx/mlxthread
  x/internal/mlxthreadtest   mlx/mlxthread/mlxthreadtest
  x/internal/mlxtest         mlx/mlxtest
  x/quant                    mlx/quant
  mlx/compat/*.patch         mlx/compat/mlx-c   (MLX patches go in mlx/compat/mlx)
  x/mlxrunner                mlxrunner
  x/models/nn                mlxrunner/nn
  x/models/<arch>            mlxrunner/model/<arch>
  x/mlxrunner/imports.go     mlxrunner/model/architectures   (new package)
  x/create                   create
  x/safetensors              fs/safetensors
  x/tokenizer                mlxrunner/tokenizer

Every package keeps its name, so the Go changes are the import path
rewrites the moves force, and the CMake, Dockerfile, CI cache keys, drift
check and Darwin payload script follow the new paths. Four edits are not
paths: the runner's blank architecture imports become the package
mlxrunner/model/architectures, so the list to extend for a new model sits
beside the architecture directories; a depguard rule keeps the two test
harnesses out of non-test code, as the x/internal placement used to; the
CI change filter's two entries for the long-deleted x/imagegen/mlx now
name the bindings' CMake project and the carried patches, so a change to
either builds the payload; and the tokenizer parity test reads its
fixtures from its own testdata instead of walking out of x/.

x/server and x/imagegen/manifest stay for the next two commits.
2026-09-16 14:06:08 -07:00

199 lines
3.8 KiB
Go

package mlxthread
import (
"context"
"errors"
"fmt"
"runtime"
"runtime/debug"
"sync/atomic"
)
var ErrStopped = errors.New("mlx thread stopped")
type Thread struct {
name string
jobs chan job
done chan struct{}
stopping atomic.Bool
}
type job struct {
fn func() error
result chan result
stop bool
}
type result struct {
err error
panic *panicError
}
// panicError carries a value recovered from the worker goroutine together with
// the stack captured at recovery, before the original stack unwinds. Because it
// implements error, re-panicking with it makes the runtime print the original
// worker location in the fatal trace instead of this package's re-panic site.
type panicError struct {
value any
stack []byte
}
func (p *panicError) Error() string {
return fmt.Sprintf("%v\n\nmlx worker stack:\n%s", p.value, p.stack)
}
// Start creates a long-lived worker goroutine locked to one OS thread.
func Start(name string, init func() error) (*Thread, error) {
t := &Thread{
name: name,
jobs: make(chan job),
done: make(chan struct{}),
}
initResult := make(chan result, 1)
go t.loop(init, initResult)
res := <-initResult
if res.panic != nil {
panic(res.panic)
}
if res.err != nil {
return nil, res.err
}
return t, nil
}
// Do runs fn on the locked OS thread.
//
// Context cancellation only applies while the work is queued. Once the worker
// accepts a job, the job runs until fn returns or reaches its own cancellation
// checks.
func (t *Thread) Do(ctx context.Context, fn func() error) error {
res, err := t.enqueue(ctx, fn, false, false)
if err != nil {
return err
}
if res.panic != nil {
panic(res.panic)
}
return res.err
}
func Call[T any](ctx context.Context, t *Thread, fn func() (T, error)) (T, error) {
var value T
err := t.Do(ctx, func() error {
var err error
value, err = fn()
return err
})
return value, err
}
// Stop runs cleanup on the locked OS thread and then shuts the worker down.
func (t *Thread) Stop(ctx context.Context, cleanup func()) error {
ctx = contextOrBackground(ctx)
if !t.stopping.CompareAndSwap(false, true) {
select {
case <-t.done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
res, err := t.enqueue(ctx, func() error {
if cleanup != nil {
cleanup()
}
return nil
}, true, true)
if err != nil {
if !errors.Is(err, ErrStopped) {
t.stopping.Store(false)
}
return err
}
if res.panic != nil {
panic(res.panic)
}
if res.err != nil {
return res.err
}
select {
case <-t.done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (t *Thread) loop(init func() error, initResult chan<- result) {
runtime.LockOSThread()
// Deliberately do not unlock. MLX thread-local state belongs to this worker
// until shutdown so it cannot leak back to arbitrary Go goroutines.
res := run(init)
initResult <- res
if res.err != nil || res.panic != nil {
close(t.done)
return
}
for {
j := <-t.jobs
res := run(j.fn)
j.result <- res
if j.stop {
close(t.done)
return
}
}
}
func (t *Thread) enqueue(ctx context.Context, fn func() error, stop, allowStopping bool) (result, error) {
ctx = contextOrBackground(ctx)
if err := ctx.Err(); err != nil {
return result{}, err
}
if !allowStopping && t.stopping.Load() {
return result{}, ErrStopped
}
resultCh := make(chan result, 1)
j := job{fn: fn, result: resultCh, stop: stop}
select {
case <-ctx.Done():
return result{}, ctx.Err()
case <-t.done:
return result{}, ErrStopped
case t.jobs <- j:
}
return <-resultCh, nil
}
func run(fn func() error) (res result) {
defer func() {
if v := recover(); v != nil {
res.panic = &panicError{value: v, stack: debug.Stack()}
}
}()
if fn != nil {
res.err = fn()
}
return res
}
func contextOrBackground(ctx context.Context) context.Context {
if ctx != nil {
return ctx
}
return context.Background()
}