Skip to content

[rayapp] package the K8S compute config declaratively - #506

Closed
elliot-barn wants to merge 1 commit into
mainfrom
elliot-barn/rayapp-derive-k8s-config
Closed

elliot-barn wants to merge 1 commit into
mainfrom
elliot-barn/rayapp-derive-k8s-config

Conversation

@elliot-barn

@elliot-barn elliot-barn commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

A K8s-stack cloud (AKS/EKS/GKE) cannot honor a named instance type — those are per-cluster registrations, while required_resources resolves server-side into a free pod. A template that wants to run there needs a declarative config under the K8S key, and writing one by hand for every template is the only option today.

What this does

Whatever a template puts under the K8S key is packaged declaratively. AWS and GCP are packaged exactly as they always have been.

compute_config in BUILD.yaml result
K8S: configs/<t>/k8s.yaml the authored config, put through the same translation — a no-op when it's already declarative, and it converts any instance_type the file still names
K8S: auto derived from the template's AWS config
no K8S key no K8S config, exactly as before

Deriving is opt-in per template, and a template that asks for one and can't have it fails the build rather than quietly shipping without.

How it decides a node is declarative

Nothing in the schema flags it. ComputeNodeType carries instance_type and required_resources both as optional — "Optional when using free pod shapes with required_resources" — and which one is set is the difference. So the decision is per node, on what that node carries, never on a filename or provider key, and an already-declarative node passes through untouched. The docs make it a rule:

Each node group must use either predefined instances or declarative syntax, not both.
— docs.anyscale.com/configuration/compute/declarative

so a node setting both is reported rather than silently half-honored.

Translation

  • instance_type → required_resources {CPU, memory, GPU} from a hand-maintained shape table (11 entries covers every template today). An unlisted type is reported, not guessed — a wrong shape is a pod that never schedules.
  • GPU nodes also get required_labels: ray.io/accelerator-type; the launch-time GPU validation reads the label, never required_resources.accelerator. Values stay inside the documented set (T4, A10G, A100, L4, H100, TPU-*), enforced by a test.
  • An unschedulable head (resources: {CPU: 0}) only coordinates, so it gets coordinator sizing rather than the whole machine.
  • auto_select_worker_config has no declarative form, and it's the most common template shape — 21 templates share one auto-select config. One group shaped like the head's machine stands in, the same substitution the hand-written single-node pilot makes. This is the only invented shape here.
  • Memory is emitted as integer bytes: the published bundle is parsed straight into the backend's PhysicalResources, whose memory is an int.

Testing

The rules were reverse-engineered from the three hand-written pilots in anyscale/templates#940, so the sharpest test is whether they reproduce them. Substituting K8S: auto for each authored path:

template derived vs authored
getting-started identical
workspace-intro (basic-single-node, the auto-select case) identical
pytorch-fsdp identical except the worker group name — 1xT4:4CPU-16GB carried over from the VM config vs the human's gpu_worker

Those three are golden tests here, alongside coverage of schedulable heads, head-only clusters, declarative heads and declarative workers passing through, explicit accelerator labels winning over the table, a node setting both forms, and the skip cases.

End to end against anyscale/templates: an authored k8s.yaml round-trips unchanged with AWS and GCP byte-identical; K8S: auto derives; a template with no K8S key is untouched.

go test ./rayapp/ passes, coverage 87.0% (gate 80%); gofmt and go vet clean.

Notes

  • Needs a templates-side change to be usable: validate-build-yaml.py requires every compute_config value to be an existing path, so K8S: auto currently fails with not found: auto. Separate PR.
  • Nothing consumes the K8S key yet. _get_template_compute_config forces provider = CloudProviders.AWS for Kubernetes clouds and rebuilds a head-only config from the cloud's smallest CPU instance type, so these configs sit unused until that changes. K8S also isn't a CloudProviders value, so selecting it needs an explicit branch, not just dropping the override.
  • Independent of [rayapp] accept memory quantity strings in required_resources #505 (memory quantity strings), in either merge order. Without it, an authored memory: 8Gi still passes through as a string; with it, it becomes bytes. Derived configs emit bytes either way.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

⚠️ PR Size Warning

This PR exceeds the size limits defined in .buildkite/raycilint.yaml.

  • additions: 646 (max allowed: 500)

Break this into smaller PRs, or apply the skip-prcheck label to bypass.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces functionality to derive declarative Kubernetes compute configurations from instance-typed configurations, enabling better compatibility for K8s-stack clouds. The reviewer identified a logic issue where head nodes without explicit resource definitions were incorrectly treated as schedulable by default, potentially leading to over-provisioning of coordinator nodes. The reviewer provided actionable suggestions to refine the schedulability logic and updated the test cases to ensure correct behavior.

Comment on lines +120 to +136
// A head pinned to CPU: 0 only coordinates, so give it coordinator sizing
// rather than the whole machine.
headShape := instanceShape{cpu: coordinatorCPU, memoryBytes: coordinatorMemoryBytes}
schedulable, err := headIsSchedulable(head)
if err != nil {
return nil, err
}
if schedulable {
headShape = headInstanceShape
}
newHead, err := declarativeNode(head, headShape)
if err != nil {
return nil, err
}
cc["head_node"] = newHead

workers, err := sliceField(cc, "worker_nodes")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In convertNewComputeConfigToLegacy, if worker_nodes are present or auto_select_worker_config is true, the head node is pinned to CPU: 0 (unschedulable) by default if resources is omitted. However, headIsSchedulable currently returns true when resources is omitted, causing the derived K8S config to request the full headInstanceShape (e.g., 16 CPUs, 64GB memory) for a head pod that only coordinates. To align this, we should retrieve workers earlier and pass schedulableByDefault to headIsSchedulable.

	workers, err := sliceField(cc, "worker_nodes")
	if err != nil {
		return nil, err
	}

	// A head pinned to CPU: 0 only coordinates, so give it coordinator sizing
	// rather than the whole machine.
	headShape := instanceShape{cpu: coordinatorCPU, memoryBytes: coordinatorMemoryBytes}
	schedulableByDefault := len(workers) == 0 && !autoSelect
	schedulable, err := headIsSchedulable(head, schedulableByDefault)
	if err != nil {
		return nil, err
	}
	if schedulable {
		headShape = headInstanceShape
	}
	newHead, err := declarativeNode(head, headShape)
	if err != nil {
		return nil, err
	}
	cc["head_node"] = newHead

Comment on lines +218 to +232
func headIsSchedulable(head map[string]any) (bool, error) {
res, err := mapField(head, "resources")
if err != nil {
return false, err
}
cpu, ok := res["CPU"]
if !ok {
return true, nil
}
n, err := intOrDefault(cpu, 0)
if err != nil {
return false, fmt.Errorf("head_node resources CPU: %w", err)
}
return n != 0, nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Update headIsSchedulable to accept schedulableByDefault and return it when the CPU resource is not explicitly specified.

Suggested change
func headIsSchedulable(head map[string]any) (bool, error) {
res, err := mapField(head, "resources")
if err != nil {
return false, err
}
cpu, ok := res["CPU"]
if !ok {
return true, nil
}
n, err := intOrDefault(cpu, 0)
if err != nil {
return false, fmt.Errorf("head_node resources CPU: %w", err)
}
return n != 0, nil
}
func headIsSchedulable(head map[string]any, schedulableByDefault bool) (bool, error) {
res, err := mapField(head, "resources")
if err != nil {
return false, err
}
cpu, ok := res["CPU"]
if !ok {
return schedulableByDefault, nil
}
n, err := intOrDefault(cpu, 0)
if err != nil {
return false, fmt.Errorf("head_node resources CPU: %w", err)
}
return n != 0, nil
}

Comment on lines +68 to +86
name: "schedulable head takes the full instance shape",
input: `
head_node:
instance_type: m5.4xlarge
worker_nodes:
- instance_type: g6.2xlarge
max_nodes: 4
market_type: SPOT
`,
want: `
head_node:
required_resources: {CPU: 16, memory: 68719476736}
worker_nodes:
- required_resources: {CPU: 8, memory: 34359738368, GPU: 1}
required_labels: {ray.io/accelerator-type: L4}
max_nodes: 4
market_type: SPOT
`,
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Since a head node with workers and no explicit resources is unschedulable by default (and thus gets coordinator sizing), we should explicitly set resources.CPU: 1 in this test case to keep the head schedulable and verify that it takes the full instance shape.

		{
			name: "schedulable head takes the full instance shape",
			input: "\nhead_node:\n  instance_type: m5.4xlarge\n  resources:\n    CPU: 1\nworker_nodes:\n- instance_type: g6.2xlarge\n  max_nodes: 4\n  market_type: SPOT\n",
			want: "\nhead_node:\n  required_resources: {CPU: 16, memory: 68719476736}\n  resources: {CPU: 1}\nworker_nodes:\n- required_resources: {CPU: 8, memory: 34359738368, GPU: 1}\n  required_labels: {ray.io/accelerator-type: L4}\n  max_nodes: 4\n  market_type: SPOT\n",
		},

A K8s-stack cloud (AKS/EKS/GKE) cannot honor a named instance type:
those are per-cluster registrations, while `required_resources`
resolves server-side into a free pod. So whatever a template puts under
the K8S key is packaged declaratively:

  K8S: configs/<t>/k8s.yaml   the authored config, put through the same
                              translation -- a no-op when it is already
                              declarative, and it converts any
                              instance_type the file still names
  K8S: auto                   derived from the template's AWS config
  (no K8S key)                no K8S config, exactly as before

AWS and GCP are packaged as they always have been. Deriving is opt-in
per template, and a template that asks for one and cannot have it fails
the build rather than quietly shipping without.

What "declarative" means here
-----------------------------
Nothing in the schema flags it. ComputeNodeType carries instance_type
and required_resources both as optional -- "Optional when using free
pod shapes with required_resources" -- and which one is set is the
whole difference. So the decision is per node, on what that node
carries, never on a filename or a provider key, and a node that is
already declarative passes through untouched. The docs make this a
rule: "Each node group must use either predefined instances or
declarative syntax, not both"
(docs.anyscale.com/configuration/compute/declarative), so a node
setting both is reported rather than silently half-honored.

Translation
-----------
- instance_type -> required_resources {CPU, memory, GPU} from a
  hand-maintained shape table; an unlisted type is reported, not
  guessed, since a wrong shape is a pod that never schedules.
- GPU instances also get required_labels ray.io/accelerator-type; the
  launch-time GPU validation reads the label, never
  required_resources.accelerator. Values stay inside the documented
  set, which a test enforces.
- An unschedulable head (CPU: 0) only coordinates, so it gets
  coordinator sizing rather than the whole machine.
- auto_select_worker_config has no declarative form, and it is the
  most common template shape (21 templates share one auto-select
  config). One group shaped like the head's machine stands in, the
  same substitution the hand-written single-node config makes. This is
  the only invented shape here.

Memory is emitted as integer bytes: the published bundle is parsed
straight into the backend's PhysicalResources, whose memory is an int.

Test
----
The rules were derived from the three hand-written pilots in
anyscale/templates#940 and reproduce them. Building that branch with
`K8S: auto` in place of each authored path gives a config
byte-identical to the authored getting-started/k8s.yaml and
basic-single-node/k8s.yaml, and identical to pytorch-fsdp/k8s.yaml
except the worker group name, which is carried over from the VM config
rather than renamed. Those three are golden tests here.

Also verified end to end on anyscale/templates: an authored k8s.yaml
round-trips unchanged with AWS and GCP byte-identical, `K8S: auto`
derives, and a template with no K8S key is untouched.

go test ./rayapp/ passes, coverage 87.0% (gate 80%); gofmt and go vet
clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Ray CI Test <rayci@ray.io>
@elliot-barn
elliot-barn force-pushed the elliot-barn/rayapp-derive-k8s-config branch from e7d656c to f489d35 Compare September 17, 2026 19:30
@elliot-barn elliot-barn changed the title [rayapp] derive a K8S compute config when a template ships none [rayapp] package the K8S compute config declaratively Sep 17, 2026
@elliot-barn

Copy link
Copy Markdown
Collaborator Author

Closing this as parked, not rejected — recording why so it isn't rediscovered from scratch.

Nothing consumes the K8S key today. _get_template_compute_config forces provider = CloudProviders.AWS for Kubernetes clouds and then rebuilds a head-only config from the cloud's smallest CPU instance type. K8S also isn't a CloudProviders value, so selecting it needs an explicit branch, not just dropping the override. Until that changes, this generates configs no code path reads.

And shipping it early could be actively wrong. Derived GPU shapes carry the AWS accelerator choice — g6.* → L4, g5.* → A10G. anyscale/templates#940 picked T4 for pytorch-fsdp specifically because Azure GPU pools have no L4 or A10G. Auto-deriving would hand out configs naming accelerators that don't exist on the target cloud: pods that never schedule, silently, across dozens of templates. That choice can't be inferred from the VM config, which is exactly why the hand-written pilots exist. The synthesized auto-select worker group (max_nodes: 1) is a guess in the same category.

It also needed a templates-side change to be usable at all: validate-build-yaml.py requires every compute_config value to be an existing path, so K8S: auto fails with not found: auto.

What's worth keeping, on branch elliot-barn/rayapp-derive-k8s-config: the derivation rules were reverse-engineered from the three pilots and reproduce them — getting-started and basic-single-node byte-identical, pytorch-fsdp identical but for the worker group name. So the translation itself is sound; it's the per-cloud accelerator mapping that needs solving properly before this is safe to turn on.

Reasonable to revive once the backend actually selects the K8S key and there's a need beyond the three pilots.

Unrelated to the memory fix in #505, which stands on its own.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant