Repository navigation
[rayapp] package the K8S compute config declaratively - #506
elliot-barn wants to merge 1 commit into
Conversation
|
There was a problem hiding this comment.
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.
| // 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") |
There was a problem hiding this comment.
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| 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 | ||
| } |
There was a problem hiding this comment.
Update headIsSchedulable to accept schedulableByDefault and return it when the CPU resource is not explicitly specified.
| 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 | |
| } |
| 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 | ||
| `, | ||
| }, |
There was a problem hiding this comment.
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>
e7d656c to
f489d35
Compare
|
Closing this as parked, not rejected — recording why so it isn't rediscovered from scratch. Nothing consumes the And shipping it early could be actively wrong. Derived GPU shapes carry the AWS accelerator choice — It also needed a templates-side change to be usable at all: What's worth keeping, on branch Reasonable to revive once the backend actually selects the Unrelated to the memory fix in #505, which stands on its own. |
Problem
A K8s-stack cloud (AKS/EKS/GKE) cannot honor a named instance type — those are per-cluster registrations, while
required_resourcesresolves server-side into a free pod. A template that wants to run there needs a declarative config under theK8Skey, and writing one by hand for every template is the only option today.What this does
Whatever a template puts under the
K8Skey is packaged declaratively. AWS and GCP are packaged exactly as they always have been.compute_configin BUILD.yamlK8S: configs/<t>/k8s.yamlinstance_typethe file still namesK8S: autoK8SkeyDeriving 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.
ComputeNodeTypecarriesinstance_typeandrequired_resourcesboth 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: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.required_labels: ray.io/accelerator-type; the launch-time GPU validation reads the label, neverrequired_resources.accelerator. Values stay inside the documented set (T4, A10G, A100, L4, H100, TPU-*), enforced by a test.resources: {CPU: 0}) only coordinates, so it gets coordinator sizing rather than the whole machine.auto_select_worker_confighas 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.PhysicalResources, whosememoryis anint.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: autofor each authored path:getting-startedworkspace-intro(basic-single-node, the auto-select case)pytorch-fsdp1xT4:4CPU-16GBcarried over from the VM config vs the human'sgpu_workerThose 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.yamlround-trips unchanged with AWS and GCP byte-identical;K8S: autoderives; a template with noK8Skey is untouched.go test ./rayapp/passes, coverage 87.0% (gate 80%);gofmtandgo vetclean.Notes
validate-build-yaml.pyrequires everycompute_configvalue to be an existing path, soK8S: autocurrently fails withnot found: auto. Separate PR.K8Skey yet._get_template_compute_configforcesprovider = CloudProviders.AWSfor Kubernetes clouds and rebuilds a head-only config from the cloud's smallest CPU instance type, so these configs sit unused until that changes.K8Salso isn't aCloudProvidersvalue, so selecting it needs an explicit branch, not just dropping the override.memory: 8Gistill passes through as a string; with it, it becomes bytes. Derived configs emit bytes either way.🤖 Generated with Claude Code