Skip to content

Commit fb7e7dd

Browse files
Hkhan161jonoirwinrsacursoragentclaude
authored
Add GPU fallback support via unified compute field (#65)
## Summary `compute` now accepts either a single value or an array in `cerebrium.toml`: ```toml compute = "HOPPER_H100" # single compute = ["HOPPER_H100", "HOPPER_H200", "AMPERE_A100_80GB"] # multiple ``` It's just a list of acceptable compute types. The CLI keeps a single polymorphic field internally and forwards the values on deploy. ## Changes - `config.go`: `Compute` is a single `ComputeField` (`[]string`) with `Primary()` / `IsSet()` / `String()` helpers. The deploy payload always sends `compute` as an array. - `loader.go`: parses either a string or an array into `ComputeField`, rejecting empty strings/arrays and non-string elements. - Deploy summary shows a single `Compute:` row listing all values, and shows GPU count whenever it's set. - `run.go`: the run path still sends a single compute value, as that endpoint takes one string. ## Backward compatible - `compute = "HOPPER_H100"` (string) behaves exactly as before. - Omitting `compute` is unchanged — a CPU deployment with the usual defaults. ## Test plan - [x] All tests pass (`go test ./...`) - [x] Deployed to dev with array syntax - [x] Deployed to dev with string syntax (backward compat) - [x] Deployed to dev with no compute set (default behavior) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Jonathan Irwin <jonoirwinrsa@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4823af6 commit fb7e7dd

8 files changed

Lines changed: 196 additions & 35 deletions

File tree

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ require (
99
github.com/charmbracelet/bubbles v0.21.0
1010
github.com/charmbracelet/bubbletea v1.3.10
1111
github.com/charmbracelet/lipgloss v1.1.0
12+
github.com/go-viper/mapstructure/v2 v2.4.0
1213
github.com/golang-jwt/jwt/v5 v5.3.0
14+
github.com/gorilla/websocket v1.5.3
1315
github.com/hashicorp/go-version v1.7.0
1416
github.com/mattn/go-isatty v0.0.20
1517
github.com/muesli/termenv v0.16.0
@@ -34,9 +36,7 @@ require (
3436
github.com/davecgh/go-spew v1.1.1 // indirect
3537
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
3638
github.com/fsnotify/fsnotify v1.9.0 // indirect
37-
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
3839
github.com/google/uuid v1.6.0 // indirect
39-
github.com/gorilla/websocket v1.5.3 // indirect
4040
github.com/inconshreveable/mousetrap v1.1.0 // indirect
4141
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect
4242
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect

internal/ui/commands/deploy.go

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -121,14 +121,14 @@ func NewDeployView(ctx context.Context, conf DeployConfig) *DeployView {
121121
ctx, cancel := context.WithCancel(ctx)
122122

123123
return &DeployView{
124-
ctx: ctx,
125-
ctxCancel: cancel,
126-
state: initialState,
127-
isPartnerDeploy: isPartnerDeploy,
128-
spinner: ui.NewSpinner(),
129-
progressBar: prog,
124+
ctx: ctx,
125+
ctxCancel: cancel,
126+
state: initialState,
127+
isPartnerDeploy: isPartnerDeploy,
128+
spinner: ui.NewSpinner(),
129+
progressBar: prog,
130130
atomicBytesUploaded: &atomic.Int64{},
131-
conf: conf,
131+
conf: conf,
132132
}
133133
}
134134

@@ -1401,16 +1401,16 @@ func (m *DeployView) renderDeploymentSummary() string {
14011401

14021402
// HARDWARE PARAMETERS
14031403
var hardwareItems []string
1404-
if m.conf.Config.Hardware.Compute != nil {
1405-
hardwareItems = append(hardwareItems, fmt.Sprintf("Compute: %s", *m.conf.Config.Hardware.Compute))
1404+
if m.conf.Config.Hardware.Compute.IsSet() {
1405+
hardwareItems = append(hardwareItems, fmt.Sprintf("Compute: %s", m.conf.Config.Hardware.Compute))
14061406
}
14071407
if m.conf.Config.Hardware.CPU != nil {
14081408
hardwareItems = append(hardwareItems, fmt.Sprintf("CPU: %.1f", *m.conf.Config.Hardware.CPU))
14091409
}
14101410
if m.conf.Config.Hardware.Memory != nil {
14111411
hardwareItems = append(hardwareItems, fmt.Sprintf("Memory: %.0f GB", *m.conf.Config.Hardware.Memory))
14121412
}
1413-
if m.conf.Config.Hardware.GPUCount != nil && m.conf.Config.Hardware.Compute != nil && *m.conf.Config.Hardware.Compute != "CPU" {
1413+
if m.conf.Config.Hardware.GPUCount != nil {
14141414
hardwareItems = append(hardwareItems, fmt.Sprintf("GPU Count: %d", *m.conf.Config.Hardware.GPUCount))
14151415
}
14161416
if m.conf.Config.Hardware.Region != nil {
@@ -1469,7 +1469,7 @@ func (m *DeployView) renderDeploymentSummary() string {
14691469
concurrency := fmt.Sprintf("Replica Concurrency: %d", *m.conf.Config.Scaling.ReplicaConcurrency)
14701470

14711471
// Add GPU warning if applicable
1472-
if m.conf.Config.Hardware.Compute != nil && *m.conf.Config.Hardware.Compute != "CPU" && *m.conf.Config.Scaling.ReplicaConcurrency > 1 {
1472+
if m.conf.Config.Hardware.Compute.IsSet() && m.conf.Config.Hardware.Compute.Primary() != "CPU" && *m.conf.Config.Scaling.ReplicaConcurrency > 1 {
14731473
concurrency += " ⚠️ (Multiple concurrent requests on GPU)"
14741474
}
14751475
scalingItems = append(scalingItems, concurrency)
@@ -1549,16 +1549,16 @@ func (m *DeployView) renderDeploymentSummary() string {
15491549

15501550
// HARDWARE PARAMETERS
15511551
var hardwareRows []ui.TableRow
1552-
if m.conf.Config.Hardware.Compute != nil {
1553-
hardwareRows = append(hardwareRows, ui.TableRow{Label: "Compute:", Value: *m.conf.Config.Hardware.Compute})
1552+
if m.conf.Config.Hardware.Compute.IsSet() {
1553+
hardwareRows = append(hardwareRows, ui.TableRow{Label: "Compute:", Value: m.conf.Config.Hardware.Compute.String()})
15541554
}
15551555
if m.conf.Config.Hardware.CPU != nil {
15561556
hardwareRows = append(hardwareRows, ui.TableRow{Label: "CPU:", Value: fmt.Sprintf("%.1f", *m.conf.Config.Hardware.CPU)})
15571557
}
15581558
if m.conf.Config.Hardware.Memory != nil {
15591559
hardwareRows = append(hardwareRows, ui.TableRow{Label: "Memory:", Value: fmt.Sprintf("%.0f GB", *m.conf.Config.Hardware.Memory)})
15601560
}
1561-
if m.conf.Config.Hardware.GPUCount != nil && m.conf.Config.Hardware.Compute != nil && *m.conf.Config.Hardware.Compute != "CPU" {
1561+
if m.conf.Config.Hardware.GPUCount != nil {
15621562
hardwareRows = append(hardwareRows, ui.TableRow{Label: "GPU Count:", Value: fmt.Sprintf("%d", *m.conf.Config.Hardware.GPUCount)})
15631563
}
15641564
if m.conf.Config.Hardware.Region != nil {
@@ -1636,7 +1636,7 @@ func (m *DeployView) renderDeploymentSummary() string {
16361636
concurrency := fmt.Sprintf("%d", *m.conf.Config.Scaling.ReplicaConcurrency)
16371637

16381638
// Add GPU warning if applicable
1639-
if m.conf.Config.Hardware.Compute != nil && *m.conf.Config.Hardware.Compute != "CPU" && *m.conf.Config.Scaling.ReplicaConcurrency > 1 {
1639+
if m.conf.Config.Hardware.Compute.IsSet() && m.conf.Config.Hardware.Compute.Primary() != "CPU" && *m.conf.Config.Scaling.ReplicaConcurrency > 1 {
16401640
concurrency += " ⚠️ (Multiple concurrent requests on GPU)"
16411641
}
16421642
scalingRows = append(scalingRows, ui.TableRow{Label: "Replica Concurrency:", Value: concurrency})

internal/ui/commands/deploy_test.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,6 @@ func TestDeployView(t *testing.T) {
128128
mockClient := apimock.NewMockClient(t)
129129

130130
// Create a comprehensive config to test the display
131-
compute := "NVIDIA_A10"
132131
cpu := 2.0
133132
memory := 8.0
134133
gpuCount := 1
@@ -147,7 +146,7 @@ func TestDeployView(t *testing.T) {
147146
Exclude: []string{"__pycache__", "*.pyc"},
148147
},
149148
Hardware: projectconfig.HardwareConfig{
150-
Compute: &compute,
149+
Compute: projectconfig.ComputeField{"NVIDIA_A10"},
151150
CPU: &cpu,
152151
Memory: &memory,
153152
GPUCount: &gpuCount,

internal/ui/commands/run.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -617,8 +617,8 @@ func (m *RunView) prepareRun() tea.Msg {
617617
hardwareInfo := ""
618618
if m.conf.Config != nil {
619619
var info []string
620-
if m.conf.Config.Hardware.Compute != nil && *m.conf.Config.Hardware.Compute != "" {
621-
info = append(info, fmt.Sprintf("Compute: %s", *m.conf.Config.Hardware.Compute))
620+
if m.conf.Config.Hardware.Compute.IsSet() {
621+
info = append(info, fmt.Sprintf("Compute: %s", m.conf.Config.Hardware.Compute.Primary()))
622622
}
623623
if m.conf.Config.Hardware.GPUCount != nil && *m.conf.Config.Hardware.GPUCount > 0 {
624624
info = append(info, fmt.Sprintf("GPU: %d", *m.conf.Config.Hardware.GPUCount))
@@ -848,8 +848,8 @@ func (m *RunView) uploadRun() tea.Msg {
848848
// Build hardware config
849849
hardwareConfig := make(map[string]any)
850850
if m.conf.Config != nil {
851-
if m.conf.Config.Hardware.Compute != nil && *m.conf.Config.Hardware.Compute != "" {
852-
hardwareConfig["computeType"] = *m.conf.Config.Hardware.Compute
851+
if m.conf.Config.Hardware.Compute.IsSet() {
852+
hardwareConfig["computeType"] = m.conf.Config.Hardware.Compute.Primary()
853853
}
854854
if m.conf.Config.Hardware.GPUCount != nil && *m.conf.Config.Hardware.GPUCount > 0 {
855855
hardwareConfig["gpuCount"] = *m.conf.Config.Hardware.GPUCount

pkg/projectconfig/config.go

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package projectconfig
22

3+
import "strings"
4+
35
// ProjectConfig represents the complete cerebrium.toml configuration
46
type ProjectConfig struct {
57
Deployment DeploymentConfig `mapstructure:"deployment" toml:"deployment"`
@@ -30,12 +32,36 @@ type DeploymentConfig struct {
3032

3133
// HardwareConfig represents the [cerebrium.hardware] section
3234
type HardwareConfig struct {
33-
CPU *float64 `mapstructure:"cpu" toml:"cpu,omitempty"`
34-
Memory *float64 `mapstructure:"memory" toml:"memory,omitempty"`
35-
Compute *string `mapstructure:"compute" toml:"compute,omitempty"`
36-
GPUCount *int `mapstructure:"gpu_count" toml:"gpu_count,omitempty"`
37-
Provider *string `mapstructure:"provider" toml:"provider,omitempty"`
38-
Region *string `mapstructure:"region" toml:"region,omitempty"`
35+
CPU *float64 `mapstructure:"cpu" toml:"cpu,omitempty"`
36+
Memory *float64 `mapstructure:"memory" toml:"memory,omitempty"`
37+
Compute ComputeField `mapstructure:"compute" toml:"compute,omitempty"`
38+
GPUCount *int `mapstructure:"gpu_count" toml:"gpu_count,omitempty"`
39+
Provider *string `mapstructure:"provider" toml:"provider,omitempty"`
40+
Region *string `mapstructure:"region" toml:"region,omitempty"`
41+
}
42+
43+
// ComputeField holds the list of acceptable compute types. It accepts either a
44+
// single value (`compute = "HOPPER_H100"`) or an array
45+
// (`compute = ["HOPPER_H100", "HOPPER_H200"]`) in cerebrium.toml.
46+
type ComputeField []string
47+
48+
// Primary returns the requested compute type, or "" when unset.
49+
func (c ComputeField) Primary() string {
50+
if len(c) == 0 {
51+
return ""
52+
}
53+
return c[0]
54+
}
55+
56+
// IsSet reports whether compute was configured in any form.
57+
func (c ComputeField) IsSet() bool {
58+
return len(c) > 0
59+
}
60+
61+
// String renders the configured compute types as a comma-separated list for
62+
// display, e.g. "HOPPER_H100, HOPPER_H200".
63+
func (c ComputeField) String() string {
64+
return strings.Join(c, ", ")
3965
}
4066

4167
// ScalingConfig represents the [cerebrium.scaling] section
@@ -121,10 +147,11 @@ func (pc *ProjectConfig) ToPayload() map[string]any {
121147
if pc.Hardware.Memory != nil {
122148
payload["memory"] = *pc.Hardware.Memory
123149
}
124-
if pc.Hardware.Compute != nil {
125-
payload["compute"] = *pc.Hardware.Compute
150+
if pc.Hardware.Compute.IsSet() {
151+
// Always send the full list of compute types.
152+
payload["compute"] = []string(pc.Hardware.Compute)
126153
}
127-
if pc.Hardware.GPUCount != nil && pc.Hardware.Compute != nil && *pc.Hardware.Compute != "CPU" {
154+
if pc.Hardware.GPUCount != nil && pc.Hardware.Compute.IsSet() && pc.Hardware.Compute.Primary() != "CPU" {
128155
payload["gpuCount"] = *pc.Hardware.GPUCount
129156
}
130157
if pc.Hardware.Provider != nil {

pkg/projectconfig/loader.go

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ package projectconfig
33
import (
44
"fmt"
55
"os"
6+
"reflect"
67

8+
"github.com/go-viper/mapstructure/v2"
79
"github.com/spf13/viper"
810
)
911

@@ -46,9 +48,15 @@ func Load(configPath string) (*ProjectConfig, error) {
4648
return nil, fmt.Errorf("failed to parse deployment config: %w", err)
4749
}
4850

49-
// Parse hardware section
51+
// Parse hardware section.
52+
// The compute decode hook lets `compute` accept either a scalar or an array
53+
// without leaking that polymorphism into the rest of the codebase. Hardware
54+
// has no duration or comma-slice fields, so viper's default hooks aren't
55+
// needed here.
5056
if v.IsSet("cerebrium.hardware") {
51-
if err := v.UnmarshalKey("cerebrium.hardware", &config.Hardware); err != nil {
57+
if err := v.UnmarshalKey("cerebrium.hardware", &config.Hardware,
58+
viper.DecodeHook(computeFieldDecodeHook()),
59+
); err != nil {
5260
return nil, fmt.Errorf("failed to parse hardware config: %w", err)
5361
}
5462
}
@@ -130,6 +138,41 @@ func Load(configPath string) (*ProjectConfig, error) {
130138
return &config, nil
131139
}
132140

141+
// computeFieldDecodeHook teaches mapstructure how to read the polymorphic
142+
// `compute` field. Without it viper would refuse to decode a TOML scalar into
143+
// the ComputeField slice type. Rejects empty strings so downstream code can
144+
// rely on `IsSet() => Primary() != ""`.
145+
func computeFieldDecodeHook() mapstructure.DecodeHookFunc {
146+
computeFieldType := reflect.TypeOf(ComputeField{})
147+
return func(_ reflect.Type, to reflect.Type, data any) (any, error) {
148+
if to != computeFieldType {
149+
return data, nil
150+
}
151+
switch v := data.(type) {
152+
case string:
153+
if v == "" {
154+
return nil, fmt.Errorf("compute must not be empty")
155+
}
156+
return ComputeField{v}, nil
157+
case []any:
158+
if len(v) == 0 {
159+
return nil, fmt.Errorf("compute array must not be empty")
160+
}
161+
out := make(ComputeField, len(v))
162+
for i, item := range v {
163+
s, ok := item.(string)
164+
if !ok || s == "" {
165+
return nil, fmt.Errorf("compute values must be non-empty strings")
166+
}
167+
out[i] = s
168+
}
169+
return out, nil
170+
default:
171+
return nil, fmt.Errorf("compute must be a string or array of strings")
172+
}
173+
}
174+
}
175+
133176
// applyDefaults sets default values for CLI-only fields that weren't specified in the config.
134177
// Payload fields (pythonVersion, baseImage, provider, region, scaling, auth, entrypoint,
135178
// port, healthcheck, etc.) are deliberately left unset so the backend can apply its own defaults.

pkg/projectconfig/loader_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,95 @@ name = "test-app"
208208
assert.Contains(t, err.Error(), "'cerebrium' key not found")
209209
})
210210
}
211+
212+
func TestLoad_Compute(t *testing.T) {
213+
const header = `[cerebrium.deployment]
214+
name = "test-app"
215+
216+
[cerebrium.hardware]
217+
`
218+
219+
write := func(t *testing.T, body string) string {
220+
t.Helper()
221+
path := filepath.Join(t.TempDir(), "cerebrium.toml")
222+
require.NoError(t, os.WriteFile(path, []byte(header+body), 0644))
223+
return path
224+
}
225+
226+
t.Run("scalar form populates primary only", func(t *testing.T) {
227+
cfg, err := Load(write(t, `compute = "HOPPER_H100"`))
228+
require.NoError(t, err)
229+
assert.Equal(t, ComputeField{"HOPPER_H100"}, cfg.Hardware.Compute)
230+
assert.Equal(t, "HOPPER_H100", cfg.Hardware.Compute.Primary())
231+
assert.True(t, cfg.Hardware.Compute.IsSet())
232+
})
233+
234+
t.Run("array form preserves order", func(t *testing.T) {
235+
cfg, err := Load(write(t, `compute = ["HOPPER_H100", "HOPPER_H200", "AMPERE_A100_80GB"]`))
236+
require.NoError(t, err)
237+
assert.Equal(t, ComputeField{"HOPPER_H100", "HOPPER_H200", "AMPERE_A100_80GB"}, cfg.Hardware.Compute)
238+
assert.Equal(t, "HOPPER_H100", cfg.Hardware.Compute.Primary())
239+
})
240+
241+
t.Run("single-element array behaves like scalar", func(t *testing.T) {
242+
cfg, err := Load(write(t, `compute = ["HOPPER_H100"]`))
243+
require.NoError(t, err)
244+
assert.Equal(t, ComputeField{"HOPPER_H100"}, cfg.Hardware.Compute)
245+
assert.Equal(t, "HOPPER_H100", cfg.Hardware.Compute.Primary())
246+
})
247+
248+
t.Run("missing compute leaves field empty", func(t *testing.T) {
249+
cfg, err := Load(write(t, ``))
250+
require.NoError(t, err)
251+
assert.False(t, cfg.Hardware.Compute.IsSet())
252+
assert.Equal(t, "", cfg.Hardware.Compute.Primary())
253+
})
254+
255+
t.Run("empty array is rejected", func(t *testing.T) {
256+
_, err := Load(write(t, `compute = []`))
257+
require.Error(t, err)
258+
assert.Contains(t, err.Error(), "compute array must not be empty")
259+
})
260+
261+
t.Run("empty string is rejected", func(t *testing.T) {
262+
_, err := Load(write(t, `compute = ""`))
263+
require.Error(t, err)
264+
assert.Contains(t, err.Error(), "compute must not be empty")
265+
})
266+
267+
t.Run("non-string element is rejected", func(t *testing.T) {
268+
_, err := Load(write(t, `compute = ["HOPPER_H100", 42]`))
269+
require.Error(t, err)
270+
assert.Contains(t, err.Error(), "compute values must be non-empty strings")
271+
})
272+
273+
t.Run("wrong type is rejected", func(t *testing.T) {
274+
_, err := Load(write(t, `compute = 42`))
275+
require.Error(t, err)
276+
assert.Contains(t, err.Error(), "compute must be a string or array of strings")
277+
})
278+
}
279+
280+
func TestToPayload_Compute(t *testing.T) {
281+
t.Run("scalar form sends single-element array", func(t *testing.T) {
282+
pc := &ProjectConfig{
283+
Deployment: DeploymentConfig{Name: "x"},
284+
Hardware: HardwareConfig{Compute: ComputeField{"HOPPER_H100"}},
285+
}
286+
assert.Equal(t, []string{"HOPPER_H100"}, pc.ToPayload()["compute"])
287+
})
288+
289+
t.Run("multi-element form sends array", func(t *testing.T) {
290+
pc := &ProjectConfig{
291+
Deployment: DeploymentConfig{Name: "x"},
292+
Hardware: HardwareConfig{Compute: ComputeField{"HOPPER_H100", "HOPPER_H200"}},
293+
}
294+
assert.Equal(t, []string{"HOPPER_H100", "HOPPER_H200"}, pc.ToPayload()["compute"])
295+
})
296+
297+
t.Run("unset compute is omitted", func(t *testing.T) {
298+
pc := &ProjectConfig{Deployment: DeploymentConfig{Name: "x"}}
299+
_, present := pc.ToPayload()["compute"]
300+
assert.False(t, present)
301+
})
302+
}

pkg/projectconfig/validator.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ func Validate(config *ProjectConfig) error {
4040
}
4141

4242
// Default gpu_count to 1 if compute is set but gpu_count is not
43-
if config.Hardware.Compute != nil && *config.Hardware.Compute != "CPU" {
43+
if config.Hardware.Compute.IsSet() && config.Hardware.Compute.Primary() != "CPU" {
4444
if config.Hardware.GPUCount == nil {
4545
defaultGPUCount := 1
4646
config.Hardware.GPUCount = &defaultGPUCount

0 commit comments

Comments
 (0)