Skip to content

Commit 7abfc46

Browse files
vinchenzo-dbIsaac
andauthored
[air] Add compute.priority_class for AI Runtime reservations (#6533)
Write https://github.com/databricks-eng/universe/pull/2557225?timeline_per_page=5 to Go. Tests: Unit tests. ## Changes <!-- Brief summary of your changes that is easy to understand --> ## Why <!-- Why are these changes needed? Provide the context that the reviewer might be missing. For example, were there any decisions behind the change that are not reflected in the code itself? --> ## Tests <!-- How have you tested the changes? --> <!-- If your PR needs to be included in the release notes for next release, add a changelog fragment: create .nextchanges/<section>/<name>.md with a one-line description (e.g. .nextchanges/cli/quickstart.md). See .nextchanges/README.md. --> Co-authored-by: Isaac <no-reply@databricks.com>
1 parent fb4da64 commit 7abfc46

9 files changed

Lines changed: 191 additions & 27 deletions

File tree

‎acceptance/experimental/air/config-help/output.txt‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ config.compute
6666
num_accelerators Total number of GPUs to allocate.
6767
accelerator_type Which accelerator to run on, e.g. GPU_1xA10.
6868
provisioned_capacity_id Pre-provisioned AIR capacity reservation id.
69+
priority_class Scheduling priority within the reservation: BEST_EFFORT (lowest, preemptable), NORMAL, or CRITICAL (highest).
6970

7071
Use "-h config.compute.<field>" for details on a field.
7172

@@ -79,7 +80,14 @@ config.mlflow_artifact_location
7980

8081
>>> [CLI] experimental air run -h config.compute.provisioned_capacity_id
8182
config.compute.provisioned_capacity_id
82-
Pre-provisioned AIR capacity reservation id. Must be 1-255 characters.
83+
Pre-provisioned AIR capacity reservation id. Must be 1-255 characters. Contact your Databricks account team to provision capacity.
84+
85+
Type: string
86+
Required: no
87+
88+
>>> [CLI] experimental air run -h config.compute.priority_class
89+
config.compute.priority_class
90+
Scheduling priority within the reservation: BEST_EFFORT (lowest, preemptable), NORMAL, or CRITICAL (highest). Requires provisioned_capacity_id.
8391

8492
Type: string
8593
Required: no
@@ -127,7 +135,7 @@ config.compute.num_accelerators
127135
>>> [CLI] experimental air run -h config.compute.acclerator_type
128136
Error: unknown config field "config.compute.acclerator_type"; did you mean "accelerator_type"?
129137

130-
fields under "config.compute" are: accelerator_type, num_accelerators, provisioned_capacity_id
138+
fields under "config.compute" are: accelerator_type, num_accelerators, priority_class, provisioned_capacity_id
131139

132140
=== free-form map keys are not schema fields
133141
>>> [CLI] experimental air run -h config.parameters.learning_rate

‎acceptance/experimental/air/config-help/script‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ trace $CLI experimental air run -h config.compute
1515
title "new submission fields are documented"
1616
trace $CLI experimental air run -h config.mlflow_artifact_location
1717
trace $CLI experimental air run -h config.compute.provisioned_capacity_id
18+
trace $CLI experimental air run -h config.compute.priority_class
1819
trace $CLI experimental air run -h config.environment.dependencies
1920

2021
title "leaf field"

‎experimental/air/cmd/compute.go‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,40 @@ func parseGPUType(value string) (gpuType, error) {
3737
return "", fmt.Errorf("invalid GPU type %q: %s", value, validGPUTypesHint())
3838
}
3939

40+
// priorityClass is the AI Runtime scheduling priority a run is given within its
41+
// reservation. The values match the AiRuntimeTask.PriorityClass proto enum and
42+
// AICM's PriorityClassValidator (the server-side contract).
43+
type priorityClass string
44+
45+
const (
46+
priorityClassBestEffort priorityClass = "BEST_EFFORT"
47+
priorityClassNormal priorityClass = "NORMAL"
48+
priorityClassCritical priorityClass = "CRITICAL"
49+
)
50+
51+
// priorityClasses lists every valid value, low to high. Used for validation
52+
// error messages.
53+
var priorityClasses = []priorityClass{priorityClassBestEffort, priorityClassNormal, priorityClassCritical}
54+
55+
func validPriorityClassesHint() string {
56+
names := make([]string, len(priorityClasses))
57+
for i, p := range priorityClasses {
58+
names[i] = string(p)
59+
}
60+
return "valid values are: " + strings.Join(names, ", ")
61+
}
62+
63+
// parsePriorityClass normalizes a YAML priority_class to the enum's contract
64+
// value. Unlike accelerator_type, the match is case-insensitive: the input is
65+
// trimmed and upper-cased, so `critical` and `CRITICAL` both resolve.
66+
func parsePriorityClass(value string) (priorityClass, error) {
67+
switch p := priorityClass(strings.ToUpper(strings.TrimSpace(value))); p {
68+
case priorityClassBestEffort, priorityClassNormal, priorityClassCritical:
69+
return p, nil
70+
}
71+
return "", fmt.Errorf("invalid priority_class %q: %s", value, validPriorityClassesHint())
72+
}
73+
4074
// gpusPerNode returns the per-node GPU count, which is the partition count from
4175
// the name (GPU_1xH100 -> 1, GPU_8xH100 -> 8). num_accelerators must be a
4276
// round multiple of this since accelerators are allocated in whole nodes.
@@ -57,7 +91,8 @@ func gpusPerNode(g gpuType) (int, error) {
5791
type computeConfig struct {
5892
NumAccelerators int `yaml:"num_accelerators" help:"Total number of GPUs to allocate. Must be a positive multiple of the accelerator type's per-node GPU count. See https://docs.databricks.com/aws/en/machine-learning/ai-runtime/cli/yaml-config#reference for supported GPU types."`
5993
AcceleratorType string `yaml:"accelerator_type" help:"Which accelerator to run on, e.g. GPU_1xA10. See https://docs.databricks.com/aws/en/machine-learning/ai-runtime/cli/yaml-config#reference for the current list of supported GPU types. Matched case-sensitively."`
60-
ProvisionedCapacityID *string `yaml:"provisioned_capacity_id" help:"Pre-provisioned AIR capacity reservation id. Must be 1-255 characters."`
94+
ProvisionedCapacityID *string `yaml:"provisioned_capacity_id" help:"Pre-provisioned AIR capacity reservation id. Must be 1-255 characters. Contact your Databricks account team to provision capacity."`
95+
PriorityClass *string `yaml:"priority_class" help:"Scheduling priority within the reservation: BEST_EFFORT (lowest, preemptable), NORMAL, or CRITICAL (highest). Requires provisioned_capacity_id."`
6196
}
6297

6398
// validate checks the compute block against the backend's constraints.
@@ -90,5 +125,18 @@ func (c *computeConfig) validate() error {
90125
*c.ProvisionedCapacityID = v
91126
}
92127

128+
if c.PriorityClass != nil {
129+
p, err := parsePriorityClass(*c.PriorityClass)
130+
if err != nil {
131+
return fmt.Errorf("compute.priority_class: %w", err)
132+
}
133+
// A priority class only ranks pending work within a reservation, so it
134+
// requires one.
135+
if c.ProvisionedCapacityID == nil {
136+
return errors.New("compute.priority_class requires compute.provisioned_capacity_id — priority applies only to a pre-provisioned capacity reservation")
137+
}
138+
*c.PriorityClass = string(p)
139+
}
140+
93141
return nil
94142
}

‎experimental/air/cmd/compute_test.go‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,30 @@ func TestGPUsPerNode(t *testing.T) {
5959
require.Error(t, err)
6060
}
6161

62+
func TestParsePriorityClass(t *testing.T) {
63+
// Case-insensitive: mixed/lower case and surrounding whitespace normalize to
64+
// the enum's contract value.
65+
for _, in := range []string{"CRITICAL", "critical", "Critical", " critical "} {
66+
got, err := parsePriorityClass(in)
67+
require.NoError(t, err)
68+
assert.Equal(t, priorityClassCritical, got)
69+
}
70+
71+
for _, in := range []string{"urgent", "", "high"} {
72+
_, err := parsePriorityClass(in)
73+
require.Error(t, err)
74+
assert.Contains(t, err.Error(), "valid values are")
75+
}
76+
}
77+
78+
func TestComputeConfigValidateNormalizesPriorityClass(t *testing.T) {
79+
// A lower-case value is upper-cased in place so the submitted payload carries
80+
// the enum's contract value.
81+
cfg := computeConfig{NumAccelerators: 1, AcceleratorType: "GPU_1xH100", ProvisionedCapacityID: new("cap"), PriorityClass: new("critical")}
82+
require.NoError(t, cfg.validate())
83+
assert.Equal(t, "CRITICAL", *cfg.PriorityClass)
84+
}
85+
6286
func TestComputeConfigValidate(t *testing.T) {
6387
tests := []struct {
6488
name string
@@ -75,6 +99,9 @@ func TestComputeConfigValidate(t *testing.T) {
7599
{"legacy type rejected", computeConfig{NumAccelerators: 8, AcceleratorType: "h100_80gb"}, "accelerator_type"},
76100
{"non-positive count", computeConfig{NumAccelerators: 0, AcceleratorType: "GPU_1xH100"}, "must be positive"},
77101
{"count not a multiple", computeConfig{NumAccelerators: 4, AcceleratorType: "GPU_8xH100"}, "multiple of 8"},
102+
{"priority class", computeConfig{NumAccelerators: 1, AcceleratorType: "GPU_1xH100", ProvisionedCapacityID: new("cap"), PriorityClass: new("critical")}, ""},
103+
{"priority class requires reservation", computeConfig{NumAccelerators: 1, AcceleratorType: "GPU_1xH100", PriorityClass: new("NORMAL")}, "requires compute.provisioned_capacity_id"},
104+
{"invalid priority class", computeConfig{NumAccelerators: 1, AcceleratorType: "GPU_1xH100", ProvisionedCapacityID: new("cap"), PriorityClass: new("urgent")}, "invalid priority_class"},
78105
}
79106
for _, tt := range tests {
80107
t.Run(tt.name, func(t *testing.T) {

‎experimental/air/cmd/runconfig_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@ func TestResolveConfigField_Containers(t *testing.T) {
489489
compute, err := resolveConfigField("config.compute")
490490
require.NoError(t, err)
491491
assert.Equal(t, "object", compute.typeName)
492-
require.Len(t, compute.children, 3)
492+
require.Len(t, compute.children, 4)
493493
}
494494

495495
func TestResolveConfigField_Errors(t *testing.T) {

‎experimental/air/cmd/runsubmit.go‎

Lines changed: 49 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,12 @@ func buildSubmitPayload(cfg *runConfig, commandPath, dlImage, usagePolicyID stri
114114
}
115115
}
116116

117-
func submitRun(ctx context.Context, w *databricks.WorkspaceClient, payload jobs.SubmitRun, provisionedCapacityID string) (int64, error) {
118-
if provisionedCapacityID == "" {
117+
func submitRun(ctx context.Context, w *databricks.WorkspaceClient, payload jobs.SubmitRun, provisionedCapacityID, priorityClass string) (int64, error) {
118+
// Neither reservation field is modeled by the SDK's AiRuntimeTask, so a run
119+
// that sets either has to go through the raw /api/2.2 body. priority_class only
120+
// ever appears alongside a reservation (validation enforces it), but route on
121+
// both so it can never be silently dropped.
122+
if provisionedCapacityID == "" && priorityClass == "" {
119123
wait, err := w.Jobs.Submit(ctx, payload)
120124
if err != nil {
121125
return 0, err
@@ -133,7 +137,7 @@ func submitRun(ctx context.Context, w *databricks.WorkspaceClient, payload jobs.
133137
if err := decoder.Decode(&body); err != nil {
134138
return 0, fmt.Errorf("failed to decode AIR submit payload: %w", err)
135139
}
136-
if err := injectProvisionedCapacityID(body, provisionedCapacityID); err != nil {
140+
if err := injectReservationFields(body, provisionedCapacityID, priorityClass); err != nil {
137141
return 0, err
138142
}
139143

@@ -149,33 +153,52 @@ func submitRun(ctx context.Context, w *databricks.WorkspaceClient, payload jobs.
149153
return response.RunId, nil
150154
}
151155

152-
func injectProvisionedCapacityID(body map[string]any, provisionedCapacityID string) error {
156+
// injectReservationFields sets the reservation-only fields the SDK does not
157+
// model onto the decoded submit body: priority_class rides directly on the
158+
// ai_runtime_task, while provisioned_capacity_id rides on the deployment's
159+
// compute spec. Each is set only when non-empty.
160+
func injectReservationFields(body map[string]any, provisionedCapacityID, priorityClass string) error {
161+
aiRuntimeTask, err := aiRuntimeTaskFromSubmitBody(body)
162+
if err != nil {
163+
return err
164+
}
165+
if priorityClass != "" {
166+
aiRuntimeTask["priority_class"] = priorityClass
167+
}
168+
if provisionedCapacityID != "" {
169+
deployments, ok := aiRuntimeTask["deployments"].([]any)
170+
if !ok || len(deployments) != 1 {
171+
return errors.New("AIR submit payload must contain exactly one deployment")
172+
}
173+
deployment, ok := deployments[0].(map[string]any)
174+
if !ok {
175+
return errors.New("AIR submit payload deployment has an invalid shape")
176+
}
177+
computeSpec, ok := deployment["compute"].(map[string]any)
178+
if !ok {
179+
return errors.New("AIR submit payload is missing deployment compute")
180+
}
181+
computeSpec["provisioned_capacity_id"] = provisionedCapacityID
182+
}
183+
return nil
184+
}
185+
186+
// aiRuntimeTaskFromSubmitBody navigates a decoded runs/submit body to its single
187+
// ai_runtime_task map, erroring if the payload isn't the expected single-task shape.
188+
func aiRuntimeTaskFromSubmitBody(body map[string]any) (map[string]any, error) {
153189
tasks, ok := body["tasks"].([]any)
154190
if !ok || len(tasks) != 1 {
155-
return errors.New("AIR submit payload must contain exactly one task")
191+
return nil, errors.New("AIR submit payload must contain exactly one task")
156192
}
157193
task, ok := tasks[0].(map[string]any)
158194
if !ok {
159-
return errors.New("AIR submit payload task has an invalid shape")
195+
return nil, errors.New("AIR submit payload task has an invalid shape")
160196
}
161197
aiRuntimeTask, ok := task["ai_runtime_task"].(map[string]any)
162198
if !ok {
163-
return errors.New("AIR submit payload is missing ai_runtime_task")
164-
}
165-
deployments, ok := aiRuntimeTask["deployments"].([]any)
166-
if !ok || len(deployments) != 1 {
167-
return errors.New("AIR submit payload must contain exactly one deployment")
168-
}
169-
deployment, ok := deployments[0].(map[string]any)
170-
if !ok {
171-
return errors.New("AIR submit payload deployment has an invalid shape")
199+
return nil, errors.New("AIR submit payload is missing ai_runtime_task")
172200
}
173-
computeSpec, ok := deployment["compute"].(map[string]any)
174-
if !ok {
175-
return errors.New("AIR submit payload is missing deployment compute")
176-
}
177-
computeSpec["provisioned_capacity_id"] = provisionedCapacityID
178-
return nil
201+
return aiRuntimeTask, nil
179202
}
180203

181204
// submitToken resolves the idempotency token: the --idempotency-key flag wins,
@@ -313,8 +336,12 @@ func submitWorkload(ctx context.Context, w *databricks.WorkspaceClient, cfg *run
313336
if cfg.Compute.ProvisionedCapacityID != nil {
314337
provisionedCapacityID = *cfg.Compute.ProvisionedCapacityID
315338
}
339+
priorityClass := ""
340+
if cfg.Compute.PriorityClass != nil {
341+
priorityClass = *cfg.Compute.PriorityClass
342+
}
316343
// Submit returns as soon as the run is created; we don't wait for it to finish.
317-
runID, err := submitRun(ctx, w, payload, provisionedCapacityID)
344+
runID, err := submitRun(ctx, w, payload, provisionedCapacityID, priorityClass)
318345
if err != nil {
319346
return 0, "", err
320347
}

‎experimental/air/cmd/runsubmit_test.go‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,42 @@ func TestSubmitRunInjectsProvisionedCapacityID(t *testing.T) {
109109
Compute: &computeConfig{AcceleratorType: "GPU_1xH100", NumAccelerators: 1},
110110
}, "/command.sh", "4", "", snapshotResult{}, nil)
111111

112-
runID, err := submitRun(t.Context(), w, payload, "capacity-1")
112+
runID, err := submitRun(t.Context(), w, payload, "capacity-1", "")
113113
require.NoError(t, err)
114114
assert.Equal(t, int64(42), runID)
115115
}
116116

117+
func TestSubmitRunInjectsPriorityClass(t *testing.T) {
118+
server := testserver.New(t)
119+
t.Cleanup(server.Close)
120+
server.Handle("POST", "/api/2.2/jobs/runs/submit", func(req testserver.Request) any {
121+
var body map[string]any
122+
require.NoError(t, json.Unmarshal(req.Body, &body))
123+
tasks := body["tasks"].([]any)
124+
task := tasks[0].(map[string]any)
125+
airTask := task["ai_runtime_task"].(map[string]any)
126+
// priority_class rides directly on the ai_runtime_task, next to
127+
// provisioned_capacity_id on the deployment compute.
128+
assert.Equal(t, "CRITICAL", airTask["priority_class"])
129+
deployment := airTask["deployments"].([]any)[0].(map[string]any)
130+
compute := deployment["compute"].(map[string]any)
131+
assert.Equal(t, "capacity-1", compute["provisioned_capacity_id"])
132+
return jobs.SubmitRunResponse{RunId: 7}
133+
})
134+
135+
w, err := databricks.NewWorkspaceClient(&databricks.Config{Host: server.URL, Token: "token", WorkspaceID: "123"})
136+
require.NoError(t, err)
137+
payload := buildSubmitPayload(&runConfig{
138+
ExperimentName: "exp",
139+
Command: new("x"),
140+
Compute: &computeConfig{AcceleratorType: "GPU_1xH100", NumAccelerators: 1},
141+
}, "/command.sh", "4", "", snapshotResult{}, nil)
142+
143+
runID, err := submitRun(t.Context(), w, payload, "capacity-1", "CRITICAL")
144+
require.NoError(t, err)
145+
assert.Equal(t, int64(7), runID)
146+
}
147+
117148
func TestBuildSubmitPayloadDefaultRetries(t *testing.T) {
118149
// max_retries unset defaults to 3 (matching the Python native path), so both
119150
// retry fields are sent.

‎experimental/air/cmd/validateconfig.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ func validateConfigRequest(cfg *runConfig, commandPath string) map[string]any {
7272
"experiment": cfg.ExperimentName,
7373
"deployments": []any{map[string]any{"command_path": commandPath, "compute": compute}},
7474
}
75+
if cfg.Compute != nil {
76+
// priority_class rides on the ai_runtime_task (task-level), not the deployment
77+
// compute where provisioned_capacity_id lives.
78+
putOpt(task, "priority_class", cfg.Compute.PriorityClass)
79+
}
7580
putOpt(task, "mlflow_run", cfg.MLflowRunName)
7681
putOpt(task, "mlflow_experiment_directory", cfg.MLflowExperimentDirectory)
7782
putOpt(task, "mlflow_artifact_location", cfg.MLflowArtifactLocation)

‎experimental/air/cmd/validateconfig_test.go‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,23 @@ func TestValidateConfigRequestShape(t *testing.T) {
110110
assert.Equal(t, map[string]any{"HF_HOME": "/tmp/hf"}, runOptions["env_variables"])
111111
}
112112

113+
func TestValidateConfigRequestCarriesPriorityClass(t *testing.T) {
114+
var gotReq map[string]any
115+
srv := validateServer(t, http.StatusOK, `{}`, &gotReq)
116+
117+
cfg := baseRunConfig()
118+
cfg.Compute.ProvisionedCapacityID = new("cap-8xh100-res")
119+
cfg.Compute.PriorityClass = new("CRITICAL")
120+
err := preflightValidate(t.Context(), newTestWorkspaceClient(t, srv.URL), cfg, "/Workspace/Users/me/cmd.sh")
121+
require.NoError(t, err)
122+
123+
task := gotReq["task"].(map[string]any)
124+
// priority_class is task-level; provisioned_capacity_id stays on the compute spec.
125+
assert.Equal(t, "CRITICAL", task["priority_class"])
126+
compute := task["deployments"].([]any)[0].(map[string]any)["compute"].(map[string]any)
127+
assert.Equal(t, "cap-8xh100-res", compute["provisioned_capacity_id"])
128+
}
129+
113130
func TestValidateConfigRequestOmitsUnsetOptions(t *testing.T) {
114131
// A minimal config carries no run_options and only the fields it set, so the
115132
// server never validates values the user didn't provide.

0 commit comments

Comments
 (0)