@@ -17,19 +17,23 @@ import (
1717 "github.com/knights-analytics/hugot/util/fileutil"
1818)
1919
20+ // Default shape buckets for batch size and sequence length.
21+ // These provide a good balance between padding overhead and JIT cache size.
22+ var (
23+ defaultBatchBuckets = []int {1 , 8 , 32 }
24+ defaultSequenceBuckets = []int {32 , 128 , 512 }
25+ )
26+
2027type GoMLXModel struct {
21- Backend backends.Backend
22- OnnxModel * onnx.Model
23- Ctx * context.Context // ctx with the model's weights.
24- Exec * context.Exec // exec is used to execute the model with a context.
25- Call func (ctx * context.Context , inputs []* graph.Node ) []* graph.Node
26- 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
28+ Backend backends.Backend
29+ OnnxModel * onnx.Model
30+ Ctx * context.Context // ctx with the model's weights.
31+ Exec * context.Exec // exec is used to execute the model with a context.
32+ Call func (ctx * context.Context , inputs []* graph.Node ) []* graph.Node
33+ Destroy func ()
34+ MaxCache int // MaxCache sets the maximum number of unique input shapes to cache.
35+ BatchBuckets []int // BatchBuckets defines bucket sizes for batch dimension padding.
36+ SequenceBuckets []int // SequenceBuckets defines bucket sizes for sequence length padding.
3337}
3438
3539func loadExternalData (path string , model * onnx.Model ) error {
@@ -137,34 +141,23 @@ func createGoMLXModelBackend(model *Model, options *options.Options) error {
137141 return contextErr
138142 }
139143
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- }
144+ maxCache := getMaxCache (options , config )
158145 exec .SetMaxCache (maxCache )
159146
160147 // Use configured buckets or fall back to defaults.
161- batchBucketsCfg := batchBuckets
148+ batchBuckets := defaultBatchBuckets
162149 if len (options .GoMLXOptions .BatchBuckets ) > 0 {
163- batchBucketsCfg = options .GoMLXOptions .BatchBuckets
150+ batchBuckets = options .GoMLXOptions .BatchBuckets
164151 }
165- sequenceBucketsCfg := sequenceBuckets
152+ var sequenceBuckets [] int
166153 if len (options .GoMLXOptions .SequenceBuckets ) > 0 {
167- sequenceBucketsCfg = options .GoMLXOptions .SequenceBuckets
154+ sequenceBuckets = options .GoMLXOptions .SequenceBuckets
155+ } else {
156+ sequenceBuckets = defaultSequenceBuckets
157+ // Ensure that sequence buckets cover the max sequence length.
158+ if batchBuckets [len (batchBuckets )- 1 ] < model .MaxPositionEmbeddings {
159+ sequenceBuckets = append (sequenceBuckets , model .MaxPositionEmbeddings )
160+ }
168161 }
169162
170163 model .GoMLXModel = & GoMLXModel {
@@ -173,8 +166,9 @@ func createGoMLXModelBackend(model *Model, options *options.Options) error {
173166 Ctx : ctx ,
174167 Exec : exec ,
175168 Call : callFunc ,
176- BatchBuckets : batchBucketsCfg ,
177- SequenceBuckets : sequenceBucketsCfg ,
169+ MaxCache : maxCache ,
170+ BatchBuckets : batchBuckets ,
171+ SequenceBuckets : sequenceBuckets ,
178172 Destroy : func () {
179173 exec .Finalize ()
180174 ctx .Finalize ()
@@ -188,6 +182,30 @@ func createGoMLXModelBackend(model *Model, options *options.Options) error {
188182 return err
189183}
190184
185+ func getMaxCache (options * options.Options , backend string ) int {
186+ // Limit JIT compilation cache to bound memory growth.
187+ //
188+ // Each unique input shape triggers XLA JIT recompilation, and each compiled
189+ // program consumes 50-200MB depending on model complexity. With unlimited
190+ // cache (-1), memory can grow unbounded as different request shapes accumulate.
191+ //
192+ // IMPORTANT: The cache is NOT LRU - if full, new shapes return errors.
193+ // The cache size must be >= the number of unique shapes from bucketing:
194+ // - Default batch buckets: 1, 8, 32 (3 values)
195+ // - Default sequence buckets: 32, 128, 512 (3 values)
196+ // - Maximum unique shapes: 3 × 3 = 9 per model
197+ //
198+ // Default cache of 16 provides headroom above the 9 possible shapes.
199+ // Configure via WithGoMLXMaxCache() if using custom bucket configurations.
200+ maxCache := 16
201+ if options .GoMLXOptions .MaxCache > 0 {
202+ maxCache = options .GoMLXOptions .MaxCache
203+ } else if backend == "go" {
204+ maxCache = - 1
205+ }
206+ return maxCache
207+ }
208+
191209func loadInputOutputMetaGoMLX (model * onnx.Model ) ([]InputOutputInfo , []InputOutputInfo ) {
192210 var inputs , outputs []InputOutputInfo
193211
@@ -222,17 +240,20 @@ func createInputTensorsGoMLX(batch *PipelineBatch, model *Model, padBatchDimensi
222240 // TODO: replace this once dynamic input shapes fixed
223241 model .FixedCacheSize = 150
224242
243+ var err error
225244 batchSize := batch .Size
226245 if padBatchDimension {
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 )
246+ batchSize , err = shapeBucket (batchSize , model .GoMLXModel .BatchBuckets )
247+ if err != nil {
248+ return fmt .Errorf ("batch size: %w" , err )
249+ }
230250 }
231251 maxSeqLength := batch .MaxSequenceLength
232252 if padSequenceDimension && ! leftPad {
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 )
253+ maxSeqLength , err = shapeBucket (maxSeqLength , model .GoMLXModel .SequenceBuckets )
254+ if err != nil {
255+ return fmt .Errorf ("sequence length: %w" , err )
256+ }
236257 }
237258 total := batchSize * maxSeqLength
238259
@@ -365,47 +386,42 @@ func runGoMLXSessionOnBatch(batch *PipelineBatch, p *BasePipeline) error {
365386 return err
366387 }
367388
389+ defer func () {
390+ for _ , t := range outputTensors {
391+ err = errors .Join (t .FinalizeAll ())
392+ }
393+ }()
394+
395+ // Transfer output tensors from device (TPU/GPU) to local (CPU) memory immediately.
396+ //
397+ // Go's GC doesn't see the large device-side allocations, so it doesn't feel pressure
398+ // to reclaim tensor wrappers. By explicitly transferring to local memory and
399+ // invalidating device copies, we release TPU/GPU memory as soon as the computation
400+ // completes rather than waiting for eventual GC.
401+ for _ , t := range outputTensors {
402+ t .MaterializeLocal () // Copy data from device to local memory
403+ err = t .InvalidateOnDevice () // Free device memory immediately
404+ if err != nil {
405+ return err
406+ }
407+ }
408+
368409 convertedOutput := make ([]any , len (outputTensors ))
369410 for i , t := range outputTensors {
370411 var rawOutput []float32
371- // ConstFlatData automatically triggers MaterializeLocal under the hood,
372- // copying data from device (TPU/GPU) to local (CPU) memory.
373412 err = tensors .ConstFlatData (t , func (flat []float32 ) {
374413 rawOutput = flat
375414 })
376415 if err != nil {
377416 return err
378417 }
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 ()
382418 convertedOutput [i ] = ReshapeOutput (rawOutput , p .Model .OutputsMeta [i ], batch .Size , batch .PaddingMask , batch .MaxSequenceLength )
383419 }
384420
385421 batch .OutputValues = convertedOutput
386422 return err
387423}
388424
389- func nextPowerOf2 (n int ) int {
390- if n < 1 {
391- return 1
392- }
393-
394- // Check if n is a power of 2.
395- if (n & (n - 1 )) == 0 {
396- return n
397- }
398-
399- // Find the next power of 2.
400- // This approach initially sets the result to 1 and
401- // keeps shifting the bits until it finds a number greater or equal to n.
402- pow := 1
403- for pow < n {
404- pow <<= 1
405- }
406- return pow
407- }
408-
409425// shapeBucket quantizes input dimensions to coarse buckets to reduce JIT cache pressure.
410426//
411427// XLA compiles a separate program for each unique input shape. Using power-of-2 padding
@@ -424,23 +440,15 @@ func nextPowerOf2(n int) int {
424440// Example memory savings with 2 models (embedder + reranker):
425441// - Power-of-2: 42 shapes × 2 models × 100MB avg = 8.4GB
426442// - Coarse buckets: 9 shapes × 2 models × 100MB avg = 1.8GB
427- func shapeBucket (n int , buckets []int ) int {
443+ func shapeBucket (n int , buckets []int ) ( int , error ) {
428444 for _ , bucket := range buckets {
429445 if n <= bucket {
430- return bucket
446+ return bucket , nil
431447 }
432448 }
433- // Return the largest bucket if n exceeds all
434- return buckets [len (buckets )- 1 ]
449+ return 0 , fmt .Errorf ("input shape %d exceeds maximum bucket size %v" , n , buckets )
435450}
436451
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-
444452func (goMLXModel * GoMLXModel ) Save (w io.Writer ) error {
445453 if err := goMLXModel .OnnxModel .ContextToONNX (goMLXModel .Ctx ); err != nil {
446454 return err
0 commit comments