Skip to content

Commit 9da2936

Browse files
authored
Merge pull request #38 from gomlx/add-backend-options
Improve XLA backend options support
2 parents ab1f50d + 4811968 commit 9da2936

3 files changed

Lines changed: 349 additions & 34 deletions

File tree

compute/xla/parse_options_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright 2026 The GoMLX Authors. SPDX-License-Identifier: Apache-2.0
2+
3+
package xla
4+
5+
import (
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestParseOptions(t *testing.T) {
12+
// Test bool option
13+
opts := map[string]string{
14+
"foo": "true",
15+
"bar": "false",
16+
"baz": "",
17+
"nofizz": "",
18+
}
19+
20+
val, found, err := parseOptions[bool]("foo", opts)
21+
assert.NoError(t, err)
22+
assert.True(t, found)
23+
assert.True(t, val)
24+
assert.NotContains(t, opts, "foo")
25+
26+
val, found, err = parseOptions[bool]("bar", opts)
27+
assert.NoError(t, err)
28+
assert.True(t, found)
29+
assert.False(t, val)
30+
assert.NotContains(t, opts, "bar")
31+
32+
val, found, err = parseOptions[bool]("baz", opts)
33+
assert.NoError(t, err)
34+
assert.True(t, found)
35+
assert.True(t, val)
36+
assert.NotContains(t, opts, "baz")
37+
38+
val, found, err = parseOptions[bool]("fizz", opts)
39+
assert.NoError(t, err)
40+
assert.True(t, found)
41+
assert.False(t, val)
42+
assert.NotContains(t, opts, "nofizz")
43+
44+
// Test []int64 option
45+
opts = map[string]string{
46+
"devices1": "0;1;2",
47+
"devices2": "3:4:5",
48+
"devices3": "6 7 8",
49+
"devices4": "9",
50+
"devices5": "",
51+
}
52+
53+
valList, found, err := parseOptions[[]int64]("devices1", opts)
54+
assert.NoError(t, err)
55+
assert.True(t, found)
56+
assert.Equal(t, []int64{0, 1, 2}, valList)
57+
58+
valList, found, err = parseOptions[[]int64]("devices2", opts)
59+
assert.NoError(t, err)
60+
assert.True(t, found)
61+
assert.Equal(t, []int64{3, 4, 5}, valList)
62+
63+
valList, found, err = parseOptions[[]int64]("devices3", opts)
64+
assert.NoError(t, err)
65+
assert.True(t, found)
66+
assert.Equal(t, []int64{6, 7, 8}, valList)
67+
68+
valList, found, err = parseOptions[[]int64]("devices4", opts)
69+
assert.NoError(t, err)
70+
assert.True(t, found)
71+
assert.Equal(t, []int64{9}, valList)
72+
73+
valList, found, err = parseOptions[[]int64]("devices5", opts)
74+
assert.NoError(t, err)
75+
assert.True(t, found)
76+
assert.Nil(t, valList)
77+
78+
// Test error cases
79+
opts = map[string]string{
80+
"bad_bool": "invalid",
81+
"bad_int": "1;foo",
82+
}
83+
84+
_, _, err = parseOptions[bool]("bad_bool", opts)
85+
assert.Error(t, err)
86+
87+
_, _, err = parseOptions[[]int64]("bad_int", opts)
88+
assert.Error(t, err)
89+
}

compute/xla/xla.go

Lines changed: 183 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,19 @@
4444
//
4545
// # Options
4646
//
47-
// Those can be passed after the plugin name, e.g.: GOMLX_BACKEND="xla:my_pjrt,notf32,shared_buffers".
47+
// Those can be passed after the plugin name, e.g.: GOMLX_BACKEND="xla:cuda,tf32=false,preallocate=false".
4848
//
49-
// - "tf32", "notf32": controls whether to use TF32 for DotGeneral operations that are using float32
49+
// - "tf32" (boolean, default=true): controls whether to use TF32 for DotGeneral operations that are using float32
5050
// (it can be faster in modern GPUs). It's enabled by default.
51-
// - "shared_buffers", "noshared_buffers": controls whether to use shared buffers for the device buffer
51+
// - "shared_buffer" (boolean, default=true): controls whether to use shared buffers for the device buffer
5252
// (where device=CPU). It's enabled by default if the plugin is called "cpu".
53+
// - "preallocate" (boolean, default=true): whether the CUDA PJRT preallocates a large portion of the memory.
54+
// - "memory_fraction" (float, default=0.75): how much memory to preallocate.
55+
// - "allocator" (string, default="default"): which allocator to use. For CUDA the available ones are "default"
56+
// (== "bfc"), "bfc" ("best-fit for coalescing", avoids framementation), "cuda_async" (dynamic, no preallocation),
57+
// "platform" (slow, good for debugging), "vmm"
58+
// - "visible_devices" (list of integers, e.g., "0;1;2"): list IDs of the devices made visible to the backend.
59+
// - "use_tfrt_gpu_client" (boolean, default=false): uses the "TFRT" dispatcher for GPU.
5360
//
5461
// # (NO) Dynamic Shapes
5562
//
@@ -64,6 +71,7 @@ import (
6471
"os"
6572
"path/filepath"
6673
"slices"
74+
"strconv"
6775
"strings"
6876

6977
"github.com/gomlx/compute"
@@ -107,20 +115,59 @@ func New(config string) (compute.Backend, error) {
107115
return NewWithOptions(config, nil)
108116
}
109117

118+
// optionsDocumentation contains the help text for the options.
119+
const optionsDocumentation = `"xla" backend extra options:
120+
- "tf32" (boolean, default=true): controls whether to use TF32 for DotGeneral operations that are using float32
121+
(it can be faster in modern GPUs). It's enabled by default.
122+
- "shared_buffer" (boolean, default=true): controls whether to use shared buffers for the device buffer
123+
(where device=CPU). It's enabled by default if the plugin is called "cpu".
124+
- "preallocate" (boolean, default=true): whether the CUDA PJRT preallocates a large portion of the memory.
125+
- "memory_fraction" (float, default=0.75): how much memory to preallocate.
126+
- "allocator" (string, default="default"): which allocator to use. For CUDA the available ones are "default"
127+
(== "bfc"), "bfc" ("best-fit for coalescing", avoids framementation), "cuda_async" (dynamic, no preallocation),
128+
"platform" (slow, good for debugging), "vmm"
129+
- "visible_devices" (list of integers, e.g., "0;1;2"): list IDs of the devices made visible to the backend.
130+
- "use_tfrt_gpu_client" (boolean, default=false): uses the "TFRT" dispatcher for GPU.`
131+
110132
// NewWithOptions creates a StableHLO backend with the given client options.
111133
// It allows more control, not available with the default New constructor.
112134
//
113135
// This function triggers AutoInstall if it is enabled (the default). See EnableAutoInstall to disable it.
114136
func NewWithOptions(config string, options pjrt.NamedValuesMap) (*Backend, error) {
115137
pluginName := config
116-
var pluginOptions []string
138+
139+
// Make shallow copy options, since we may change it:
140+
pluginOptions := make(pjrt.NamedValuesMap)
141+
for key, value := range options {
142+
pluginOptions[key] = value
143+
}
144+
145+
// Parse backendOptions from config string.
146+
backendOptions := make(map[string]string)
117147
parts := strings.Split(config, ",")
118148
if len(parts) > 1 {
119-
// Plugin options (exclude empty).
120-
pluginOptions = slices.DeleteFunc(parts[1:], func(s string) bool { return s == "" })
121149
pluginName = parts[0]
150+
for _, part := range parts[1:] {
151+
if part == "" {
152+
continue
153+
}
154+
key, val, found := strings.Cut(part, "=")
155+
if !found {
156+
backendOptions[part] = ""
157+
} else {
158+
backendOptions[key] = val
159+
}
160+
}
122161
}
123162

163+
_, helpOptionSet := backendOptions["help"]
164+
if pluginName == "help" || helpOptionSet {
165+
klog.Infof("Available plugins: %q", GetAvailablePlugins())
166+
klog.Info(optionsDocumentation)
167+
return nil, errors.New("Help requested")
168+
}
169+
170+
// FInd plugin.
124171
if !filepath.IsAbs(pluginName) {
125172
if autoInstall {
126173
err := AutoInstall()
@@ -148,54 +195,156 @@ func NewWithOptions(config string, options pjrt.NamedValuesMap) (*Backend, error
148195
}
149196
}
150197

151-
plugin, err := pjrt.GetPlugin(pluginName)
152-
if err != nil {
153-
return nil, errors.WithMessagef(err, "backend %q:", BackendName)
154-
}
155-
var client *pjrt.Client
156-
client, err = plugin.NewClient(options)
157-
if err != nil {
158-
return nil, errors.WithMessagef(err, "while creating plugin %s for backend %q", pluginName, BackendName)
159-
}
160-
klog.V(1).Infof("created new plugin %q for backend %q", pluginName, BackendName)
198+
// Create backend option (not associated with a plugin yet)
161199
backend := &Backend{
162-
plugin: plugin,
163-
client: client,
164200
pluginName: pluginName,
165201
config: config,
166202
capabilities: Capabilities.Clone(),
167-
numDevices: len(client.AddressableDevices()),
168203

169204
// Enable TF32 by default for CUDA.
170205
DotGeneralUseTF32: isPluginType(pluginName, "cuda"),
206+
207+
// SharedBuffers is true for CPU by default
208+
hasSharedBuffers: isPluginType(pluginName, "cpu"),
171209
}
172210

173211
// Support "shared buffers":
174-
backend.hasSharedBuffers = isPluginType(pluginName, "cpu")
175-
if idx := slices.Index(pluginOptions, "shared_buffers"); idx != -1 {
176-
backend.hasSharedBuffers = true
177-
pluginOptions = slices.Delete(pluginOptions, idx, idx+1)
178-
} else if idx := slices.Index(pluginOptions, "noshared_buffers"); idx != -1 {
179-
backend.hasSharedBuffers = false
180-
pluginOptions = slices.Delete(pluginOptions, idx, idx+1)
212+
if b, found, err := parseOptions[bool]("shared_buffers", backendOptions); err != nil {
213+
return nil, err
214+
} else if found {
215+
backend.hasSharedBuffers = b
181216
}
182217

183218
// Support for tf32 DotGeneral.
184-
if idx := slices.Index(pluginOptions, "tf32"); idx != -1 {
185-
backend.DotGeneralUseTF32 = true
186-
pluginOptions = slices.Delete(pluginOptions, idx, idx+1)
187-
} else if idx := slices.Index(pluginOptions, "notf32"); idx != -1 {
188-
backend.DotGeneralUseTF32 = false
189-
pluginOptions = slices.Delete(pluginOptions, idx, idx+1)
219+
if b, found, err := parseOptions[bool]("tf32", backendOptions); err != nil {
220+
return nil, err
221+
} else if found {
222+
backend.DotGeneralUseTF32 = b
223+
}
224+
225+
// Support "preallocate":
226+
if b, found, err := parseOptions[bool]("preallocate", backendOptions); err != nil {
227+
return nil, err
228+
} else if found {
229+
pluginOptions["preallocate"] = b
230+
}
231+
232+
// Control memory fraction of preallocated memory:
233+
if f, found, err := parseOptions[float32]("memory_fraction", backendOptions); err != nil {
234+
return nil, err
235+
} else if found {
236+
pluginOptions["memory_fraction"] = f
237+
}
238+
239+
// Allocator to use for CUDA:
240+
if s, found, err := parseOptions[string]("allocator", backendOptions); err != nil {
241+
return nil, err
242+
} else if found {
243+
pluginOptions["allocator"] = s
244+
}
245+
246+
// Visible devices for the client:
247+
if visibleDevices, found, err := parseOptions[[]int64]("visible_devices", backendOptions); err != nil {
248+
return nil, err
249+
} else if found {
250+
pluginOptions["visible_devices"] = visibleDevices
251+
}
252+
253+
// Use TFRT GPU client:
254+
if useTFRT, found, err := parseOptions[bool]("use_tfrt_gpu_client", backendOptions); err != nil {
255+
return nil, err
256+
} else if found {
257+
pluginOptions["use_tfrt_gpu_client"] = useTFRT
190258
}
191259

192260
// Any leftover plugin options are unknown.
193-
if len(pluginOptions) != 0 {
194-
klog.Errorf("backend %q: unknown plugin options %q", BackendName, pluginOptions)
261+
if len(backendOptions) != 0 {
262+
klog.Errorf("backend %q: unknown plugin options %v", BackendName, xslices.SortedKeys(backendOptions))
195263
}
264+
265+
// Create plugin.
266+
plugin, err := pjrt.GetPlugin(pluginName)
267+
if err != nil {
268+
return nil, errors.WithMessagef(err, "backend %q:", BackendName)
269+
}
270+
var client *pjrt.Client
271+
client, err = plugin.NewClient(pluginOptions)
272+
if err != nil {
273+
return nil, errors.WithMessagef(err, "while creating plugin %s for backend %q", pluginName, BackendName)
274+
}
275+
klog.V(1).Infof("created new plugin %q for backend %q", pluginName, BackendName)
276+
277+
// Set backend.
278+
backend.plugin = plugin
279+
backend.client = client
280+
backend.numDevices = len(client.AddressableDevices())
281+
196282
return backend, nil
197283
}
198284

285+
// parseOptions parses the optionName from backendOptions (string).
286+
// If optionName is found, it's removed from backendOptions.
287+
// For bool options, it also searches for "no"+optionName, and if found, removes it and returns false.
288+
// It returns the parsed value, whether it was found, and any parsing error.
289+
func parseOptions[T interface {
290+
string | bool | float32 | []int64
291+
}](
292+
optionName string, backendOptions map[string]string) (T, bool, error) {
293+
var val T
294+
295+
if _, ok := any(val).(bool); ok {
296+
noKey := "no" + optionName
297+
if _, foundNo := backendOptions[noKey]; foundNo {
298+
delete(backendOptions, noKey)
299+
return any(false).(T), true, nil
300+
}
301+
}
302+
303+
valStr, found := backendOptions[optionName]
304+
if !found {
305+
return val, false, nil
306+
}
307+
delete(backendOptions, optionName)
308+
309+
switch any(val).(type) {
310+
case string:
311+
return any(valStr).(T), true, nil
312+
case bool:
313+
if valStr == "" {
314+
return any(true).(T), true, nil
315+
}
316+
b, err := strconv.ParseBool(valStr)
317+
if err != nil {
318+
return val, true, errors.Wrapf(err, "Failed to parse option %q=%q", optionName, valStr)
319+
}
320+
return any(b).(T), true, nil
321+
case float32:
322+
f, err := strconv.ParseFloat(valStr, 32)
323+
if err != nil {
324+
return val, true, errors.Wrapf(err, "Failed to parse option %q=%q", optionName, valStr)
325+
}
326+
return any(float32(f)).(T), true, nil
327+
case []int64:
328+
if valStr == "" {
329+
return val, true, nil
330+
}
331+
parts := strings.FieldsFunc(valStr, func(r rune) bool {
332+
return r == ';' || r == ':' || r == ' '
333+
})
334+
res := make([]int64, 0, len(parts))
335+
for _, part := range parts {
336+
valInt, err := strconv.ParseInt(part, 10, 64)
337+
if err != nil {
338+
return val, true, errors.Wrapf(err, "Failed to parse option %q=%q", optionName, valStr)
339+
}
340+
res = append(res, valInt)
341+
}
342+
return any(res).(T), true, nil
343+
default:
344+
panic("unreachable")
345+
}
346+
}
347+
199348
// Registers New() as the default constructor for "xla" backend.
200349
func init() {
201350
compute.Register(BackendName, New)

0 commit comments

Comments
 (0)