Skip to content

Commit 7397c4a

Browse files
committed
Make cache limit non-default on Go backend, review fixes
1 parent 44e5a11 commit 7397c4a

4 files changed

Lines changed: 101 additions & 89 deletions

File tree

backends/model_gomlx.go

Lines changed: 86 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
2027
type 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

3539
func 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+
191209
func 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-
444452
func (goMLXModel *GoMLXModel) Save(w io.Writer) error {
445453
if err := goMLXModel.OnnxModel.ContextToONNX(goMLXModel.Ctx); err != nil {
446454
return err

backends/pipeline.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,10 @@ func CreateInputTensors(batch *PipelineBatch, model *Model, runtime string) erro
218218
case "ORT":
219219
return createInputTensorsORT(batch, model)
220220
case "GO":
221+
if model.GoMLXModel.MaxCache > 0 {
222+
// only pad the batch dimension if we have a limited cache
223+
return createInputTensorsGoMLX(batch, model, true, true)
224+
}
221225
return createInputTensorsGoMLX(batch, model, false, false)
222226
case "XLA":
223227
return createInputTensorsGoMLX(batch, model, true, true)

hugot_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ func featureExtractionPipelineValidation(t *testing.T, session *Session) {
176176
err = pipeline.Validate()
177177
assert.Error(t, err)
178178

179-
pipeline.Model.InputsMeta[0].Dimensions = backends.NewShape(1, 1, 1, 1)
179+
pipeline.Model.InputsMeta[0].Dimensions = backends.NewShape(1, 1, 1, 1, 1)
180180
err = pipeline.Validate()
181181
assert.Error(t, err)
182182
}

options/options.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -234,47 +234,47 @@ func WithTPU() WithOption {
234234
}
235235
}
236236

237-
// WithGoMLXMaxCache (XLA only) sets the maximum number of JIT-compiled programs to cache.
237+
// WithGoMLXMaxCache (XLA and GO only) sets the maximum number of JIT-compiled programs to cache.
238238
// IMPORTANT: This is NOT an LRU cache - if full, new shapes return errors.
239239
// 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.
240+
// Default is unlimited for GO, and 16 for XLA, which provides headroom for the default 3×3=9 bucket combinations.
241241
func WithGoMLXMaxCache(maxCache int) WithOption {
242242
return func(o *Options) error {
243-
if o.Backend == "XLA" {
243+
if o.Backend == "XLA" || o.Backend == "GO" {
244244
o.GoMLXOptions.MaxCache = maxCache
245245
return nil
246246
}
247-
return fmt.Errorf("WithGoMLXMaxCache is only supported for XLA backend")
247+
return fmt.Errorf("WithGoMLXMaxCache is only supported for XLA and Go backends")
248248
}
249249
}
250250

251-
// WithGoMLXBatchBuckets (XLA only) sets the bucket sizes for batch dimension padding.
251+
// WithGoMLXBatchBuckets (XLA and GO only) sets the bucket sizes for batch dimension padding.
252252
// Inputs are padded to the smallest bucket >= their batch size.
253253
// Fewer/coarser buckets reduce JIT cache pressure but increase padding overhead.
254254
// Default is []int{1, 8, 32}.
255255
// IMPORTANT: Ensure MaxCache >= len(BatchBuckets) * len(SequenceBuckets).
256256
func WithGoMLXBatchBuckets(buckets []int) WithOption {
257257
return func(o *Options) error {
258-
if o.Backend == "XLA" {
258+
if o.Backend == "XLA" || o.Backend == "GO" {
259259
o.GoMLXOptions.BatchBuckets = buckets
260260
return nil
261261
}
262-
return fmt.Errorf("WithGoMLXBatchBuckets is only supported for XLA backend")
262+
return fmt.Errorf("WithGoMLXBatchBuckets is only supported for XLA and Go backends")
263263
}
264264
}
265265

266-
// WithGoMLXSequenceBuckets (XLA only) sets the bucket sizes for sequence length padding.
266+
// WithGoMLXSequenceBuckets (XLA and GO only) sets the bucket sizes for sequence length padding.
267267
// Inputs are padded to the smallest bucket >= their sequence length.
268268
// Fewer/coarser buckets reduce JIT cache pressure but increase padding overhead.
269269
// Default is []int{32, 128, 512}.
270270
// IMPORTANT: Ensure MaxCache >= len(BatchBuckets) * len(SequenceBuckets).
271271
func WithGoMLXSequenceBuckets(buckets []int) WithOption {
272272
return func(o *Options) error {
273-
if o.Backend == "XLA" {
273+
if o.Backend == "XLA" || o.Backend == "GO" {
274274
o.GoMLXOptions.SequenceBuckets = buckets
275275
return nil
276276
}
277-
return fmt.Errorf("WithGoMLXSequenceBuckets is only supported for XLA backend")
277+
return fmt.Errorf("WithGoMLXSequenceBuckets is only supported for XLA and Go backends")
278278
}
279279
}
280280

0 commit comments

Comments
 (0)