Skip to content

Commit 44e5a11

Browse files
ajroetkerRJKeevil
authored andcommitted
Memory optimization: limit JIT cache, coarse bucketing, immediate device release
- Limit XLA JIT cache to 16 entries (was unlimited -1) - Replace power-of-2 padding with coarse shape buckets Reduces unique shapes from 42 to 9 (3 batch × 3 sequence buckets) Batch: [1, 8, 32], Sequence: [32, 128, 512] - Transfer output tensors to local memory immediately after inference Calls MaterializeLocal() + InvalidateOnDevice() to free TPU/GPU memory before Go's GC would naturally reclaim tensor wrappers Address PR feedback: FinalizeAll after read, configurable cache/buckets - Call FinalizeAll() immediately after ConstFlatData() to free device and local memory sooner (per reviewer suggestion) - Make JIT cache size configurable via WithGoMLXMaxCache() - Make batch/sequence buckets configurable via WithGoMLXBatchBuckets() and WithGoMLXSequenceBuckets() - Fix comment: cache is NOT LRU, returns errors when full
1 parent 527bee2 commit 44e5a11

2 files changed

Lines changed: 145 additions & 13 deletions

File tree

backends/model_gomlx.go

Lines changed: 88 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ type GoMLXModel struct {
2424
Exec *context.Exec // exec is used to execute the model with a context.
2525
Call func(ctx *context.Context, inputs []*graph.Node) []*graph.Node
2626
Destroy func()
27+
// BatchBuckets defines bucket sizes for batch dimension padding.
28+
// Coarse bucketing reduces JIT cache pressure by limiting unique shapes.
29+
BatchBuckets []int
30+
// SequenceBuckets defines bucket sizes for sequence length padding.
31+
// Coarse bucketing reduces JIT cache pressure by limiting unique shapes.
32+
SequenceBuckets []int
2733
}
2834

2935
func loadExternalData(path string, model *onnx.Model) error {
@@ -131,14 +137,44 @@ func createGoMLXModelBackend(model *Model, options *options.Options) error {
131137
return contextErr
132138
}
133139

134-
exec.SetMaxCache(-1)
140+
// Limit JIT compilation cache to bound memory growth.
141+
//
142+
// Each unique input shape triggers XLA JIT recompilation, and each compiled
143+
// program consumes 50-200MB depending on model complexity. With unlimited
144+
// cache (-1), memory can grow unbounded as different request shapes accumulate.
145+
//
146+
// IMPORTANT: The cache is NOT LRU - if full, new shapes return errors.
147+
// The cache size must be >= the number of unique shapes from bucketing:
148+
// - Default batch buckets: 1, 8, 32 (3 values)
149+
// - Default sequence buckets: 32, 128, 512 (3 values)
150+
// - Maximum unique shapes: 3 × 3 = 9 per model
151+
//
152+
// Default cache of 16 provides headroom above the 9 possible shapes.
153+
// Configure via WithGoMLXMaxCache() if using custom bucket configurations.
154+
maxCache := 16
155+
if options.GoMLXOptions.MaxCache > 0 {
156+
maxCache = options.GoMLXOptions.MaxCache
157+
}
158+
exec.SetMaxCache(maxCache)
159+
160+
// Use configured buckets or fall back to defaults.
161+
batchBucketsCfg := batchBuckets
162+
if len(options.GoMLXOptions.BatchBuckets) > 0 {
163+
batchBucketsCfg = options.GoMLXOptions.BatchBuckets
164+
}
165+
sequenceBucketsCfg := sequenceBuckets
166+
if len(options.GoMLXOptions.SequenceBuckets) > 0 {
167+
sequenceBucketsCfg = options.GoMLXOptions.SequenceBuckets
168+
}
135169

136170
model.GoMLXModel = &GoMLXModel{
137-
Backend: backend,
138-
OnnxModel: modelParsed,
139-
Ctx: ctx,
140-
Exec: exec,
141-
Call: callFunc,
171+
Backend: backend,
172+
OnnxModel: modelParsed,
173+
Ctx: ctx,
174+
Exec: exec,
175+
Call: callFunc,
176+
BatchBuckets: batchBucketsCfg,
177+
SequenceBuckets: sequenceBucketsCfg,
142178
Destroy: func() {
143179
exec.Finalize()
144180
ctx.Finalize()
@@ -188,11 +224,15 @@ func createInputTensorsGoMLX(batch *PipelineBatch, model *Model, padBatchDimensi
188224

189225
batchSize := batch.Size
190226
if padBatchDimension {
191-
batchSize = nextPowerOf2(batchSize)
227+
// Use coarse bucketing instead of power-of-2 to reduce JIT cache pressure.
228+
// See shapeBucket() for memory savings analysis.
229+
batchSize = shapeBucket(batchSize, model.GoMLXModel.BatchBuckets)
192230
}
193231
maxSeqLength := batch.MaxSequenceLength
194232
if padSequenceDimension && !leftPad {
195-
maxSeqLength = nextPowerOf2(maxSeqLength)
233+
// Use coarse bucketing instead of power-of-2 to reduce JIT cache pressure.
234+
// See shapeBucket() for memory savings analysis.
235+
maxSeqLength = shapeBucket(maxSeqLength, model.GoMLXModel.SequenceBuckets)
196236
}
197237
total := batchSize * maxSeqLength
198238

@@ -324,21 +364,21 @@ func runGoMLXSessionOnBatch(batch *PipelineBatch, p *BasePipeline) error {
324364
if err != nil {
325365
return err
326366
}
327-
defer func() {
328-
for _, t := range outputTensors {
329-
err = errors.Join(t.FinalizeAll())
330-
}
331-
}()
332367

333368
convertedOutput := make([]any, len(outputTensors))
334369
for i, t := range outputTensors {
335370
var rawOutput []float32
371+
// ConstFlatData automatically triggers MaterializeLocal under the hood,
372+
// copying data from device (TPU/GPU) to local (CPU) memory.
336373
err = tensors.ConstFlatData(t, func(flat []float32) {
337374
rawOutput = flat
338375
})
339376
if err != nil {
340377
return err
341378
}
379+
// FinalizeAll immediately frees both the device copy and the local copy.
380+
// This is critical for TPU/GPU where Go's GC doesn't see device-side allocations.
381+
_ = t.FinalizeAll()
342382
convertedOutput[i] = ReshapeOutput(rawOutput, p.Model.OutputsMeta[i], batch.Size, batch.PaddingMask, batch.MaxSequenceLength)
343383
}
344384

@@ -366,6 +406,41 @@ func nextPowerOf2(n int) int {
366406
return pow
367407
}
368408

409+
// shapeBucket quantizes input dimensions to coarse buckets to reduce JIT cache pressure.
410+
//
411+
// XLA compiles a separate program for each unique input shape. Using power-of-2 padding
412+
// creates up to 42 unique shapes per model (6 batch sizes × 7 sequence lengths), which
413+
// can consume 2-8GB of memory for cached compiled programs.
414+
//
415+
// Coarse bucketing reduces this to just 9 shapes (3 batch × 3 sequence), significantly
416+
// reducing memory usage while maintaining reasonable padding overhead:
417+
//
418+
// Batch buckets: 1, 8, 32 (covers 1-32 batch sizes)
419+
// Sequence buckets: 32, 128, 512 (covers 1-512 token sequences)
420+
//
421+
// Trade-off: Slightly more padding waste for small inputs (e.g., batch=2 pads to 8),
422+
// but dramatically fewer compiled programs in memory.
423+
//
424+
// Example memory savings with 2 models (embedder + reranker):
425+
// - Power-of-2: 42 shapes × 2 models × 100MB avg = 8.4GB
426+
// - Coarse buckets: 9 shapes × 2 models × 100MB avg = 1.8GB
427+
func shapeBucket(n int, buckets []int) int {
428+
for _, bucket := range buckets {
429+
if n <= bucket {
430+
return bucket
431+
}
432+
}
433+
// Return the largest bucket if n exceeds all
434+
return buckets[len(buckets)-1]
435+
}
436+
437+
// Default shape buckets for batch size and sequence length.
438+
// These provide a good balance between padding overhead and JIT cache size.
439+
var (
440+
batchBuckets = []int{1, 8, 32}
441+
sequenceBuckets = []int{32, 128, 512}
442+
)
443+
369444
func (goMLXModel *GoMLXModel) Save(w io.Writer) error {
370445
if err := goMLXModel.OnnxModel.ContextToONNX(goMLXModel.Ctx); err != nil {
371446
return err

options/options.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,19 @@ type GoMLXOptions struct {
6161
Cuda bool
6262
XLA bool
6363
TPU bool
64+
// MaxCache limits the number of JIT-compiled programs cached by XLA.
65+
// IMPORTANT: This is NOT an LRU cache - if full, new shapes return errors.
66+
// Must be >= len(BatchBuckets) * len(SequenceBuckets).
67+
// Default: 16 (sufficient for default 3×3=9 bucket combinations).
68+
MaxCache int
69+
// BatchBuckets defines the bucket sizes for batch dimension padding.
70+
// Coarse bucketing reduces JIT cache pressure by limiting unique shapes.
71+
// Default: []int{1, 8, 32}
72+
BatchBuckets []int
73+
// SequenceBuckets defines the bucket sizes for sequence length padding.
74+
// Coarse bucketing reduces JIT cache pressure by limiting unique shapes.
75+
// Default: []int{32, 128, 512}
76+
SequenceBuckets []int
6477
}
6578

6679
// WithOption is the interface for all option functions.
@@ -221,6 +234,50 @@ func WithTPU() WithOption {
221234
}
222235
}
223236

237+
// WithGoMLXMaxCache (XLA only) sets the maximum number of JIT-compiled programs to cache.
238+
// IMPORTANT: This is NOT an LRU cache - if full, new shapes return errors.
239+
// The cache size must be >= len(BatchBuckets) * len(SequenceBuckets) to avoid errors.
240+
// Default is 16, which provides headroom for the default 3×3=9 bucket combinations.
241+
func WithGoMLXMaxCache(maxCache int) WithOption {
242+
return func(o *Options) error {
243+
if o.Backend == "XLA" {
244+
o.GoMLXOptions.MaxCache = maxCache
245+
return nil
246+
}
247+
return fmt.Errorf("WithGoMLXMaxCache is only supported for XLA backend")
248+
}
249+
}
250+
251+
// WithGoMLXBatchBuckets (XLA only) sets the bucket sizes for batch dimension padding.
252+
// Inputs are padded to the smallest bucket >= their batch size.
253+
// Fewer/coarser buckets reduce JIT cache pressure but increase padding overhead.
254+
// Default is []int{1, 8, 32}.
255+
// IMPORTANT: Ensure MaxCache >= len(BatchBuckets) * len(SequenceBuckets).
256+
func WithGoMLXBatchBuckets(buckets []int) WithOption {
257+
return func(o *Options) error {
258+
if o.Backend == "XLA" {
259+
o.GoMLXOptions.BatchBuckets = buckets
260+
return nil
261+
}
262+
return fmt.Errorf("WithGoMLXBatchBuckets is only supported for XLA backend")
263+
}
264+
}
265+
266+
// WithGoMLXSequenceBuckets (XLA only) sets the bucket sizes for sequence length padding.
267+
// Inputs are padded to the smallest bucket >= their sequence length.
268+
// Fewer/coarser buckets reduce JIT cache pressure but increase padding overhead.
269+
// Default is []int{32, 128, 512}.
270+
// IMPORTANT: Ensure MaxCache >= len(BatchBuckets) * len(SequenceBuckets).
271+
func WithGoMLXSequenceBuckets(buckets []int) WithOption {
272+
return func(o *Options) error {
273+
if o.Backend == "XLA" {
274+
o.GoMLXOptions.SequenceBuckets = buckets
275+
return nil
276+
}
277+
return fmt.Errorf("WithGoMLXSequenceBuckets is only supported for XLA backend")
278+
}
279+
}
280+
224281
// WithCoreML (ORT only) Use this function to set the CoreML options flags for the ONNX backend configuration.
225282
// The `flags` parameter represents the CoreML options flags.
226283
// The `o.CoreMLOptions` field in `OrtOptions` struct will be set to the provided flags parameter.

0 commit comments

Comments
 (0)