Skip to content

[rayapp] accept memory quantity strings in required_resources - #505

Merged
elliot-barn merged 1 commit into
mainfrom
elliot-barn/rayapp-memory-quantity
Sep 24, 2026
Merged

elliot-barn merged 1 commit into
mainfrom
elliot-barn/rayapp-memory-quantity

Conversation

@elliot-barn

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

Copy link
Copy Markdown
Collaborator

Problem

required_resources.memory reaches the API as an integer number of bytes, but every user-facing surface advertises Kubernetes quantity strings — the declarative compute docs, and the SDK's own docstring ("Memory in bytes or Kubernetes format string (e.g. '4Gi')").

The SDK reconciles the two on the way out, in PhysicalResources.to_dict(for_api=True):

if self.memory is not None:
    if for_api:
        result["memory"] = _parse_memory_string(self.memory)  # k8s parse_quantity -> int bytes

The publish path never touches the SDK. convertNodeCommonFields copies required_resources through verbatim, rayapp build base64s it into ray-app.json, and the console clone path (workspaces_service._make_template_compute_config) parses that bundle straight into the backend's PhysicalResources, whose memory is an Optional[int] with no quantity parsing. So "8Gi" passes anyscale compute-config create -f — the path templates are tested on — and then 422s at launch:

body.config.head_node_type.required_resources.memory: value is not a valid integer
body.config.worker_node_types.0.required_resources.memory: value is not a valid integer

Confirmed against the live Azure staging API by replaying a built bundle through the legacy create path.

CPU/GPU survive the same trip only because they carry alias="cpu"/"gpu" plus allow_population_by_field_name. memory has no such escape hatch.

Fix

Convert in the Go mirror too, so both conversion paths agree and a template can be authored the way the docs describe.

Parsing is done by k8s.io/apimachinery's resource.ParseQuantity — the same parser Kubernetes uses, and the one the SDK reaches through kubernetes.utils.quantity — rather than a hand-rolled grammar, so the accepted syntax can't drift from what the docs describe. Pinned to v0.34.0, which needs only go 1.24; v0.37 would drag the go directive to 1.26 and bump x/net.

Three rules on top of the parser, each because the value has to land in an integer bytes field:

rule why
whole bytes only Kubernetes tolerates a fraction — 100m is 100 millibytes — but its own docs say "this isn't useful to specify since you must always assign whole numbers of bytes", and a fraction can't survive PhysicalResources.memory regardless
no negatives nothing downstream rejects them — the backend only range-checks int64 — so a negative reaches launch as a nonsense pod request. YAML parses memory: -1 as an int, so the integer path needs this too
must contain a digit ParseQuantity reads a bare suffix (Gi, k, .) as zero, so a typo would silently become no memory at all. The SDK rejects those

The whole-bytes rule also removes the one place the two parsers disagree: ParseQuantity().Value() rounds up (3.7Mi → 3879732) where the SDK's int(Decimal) truncates (3879731). That difference only exists for values this now rejects, so agreement is structural rather than sampled.

Testing

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

  • TestParseMemoryBytesMatchesSDK runs every golden through the SDK's _parse_memory_string and compares, so this cannot drift from the Python side. It skips where python3 or the anyscale package is unavailable, following the existing exec.LookPath("bash") precedent in this package. It ran (not skipped) locally and passed.

  • Golden table covers every accepted suffix so a wrong multiplier can't hide; the error table covers the rejected forms above plus 8gi, 8GB, 1K (uppercase K isn't a k8s suffix), 1 Gi, and a value that saturates int64.

  • End to end against anyscale/templates: built every template on the #940 branch (56 at the time) with this binary and with the currently pinned release, and diffed every bundle. 115 compute configs compared; exactly 3 differ — the three K8S configs — and the only changed lines are the memory values:

    getting-started  8Gi -> 8589934592,  32Gi -> 34359738368
    pytorch-fsdp     8Gi -> 8589934592,  16Gi -> 17179869184
    workspace-intro  8Gi -> 8589934592,  32Gi -> 34359738368
    

    All 112 AWS/GCP configs are byte-identical. This fires only on required_resources.memory, which instance-type configs never set.

  • The switch from the hand-rolled parser to ParseQuantity was checked for equivalence, not just for passing tests: the same pytorch-fsdp bundle built with each binary is byte-identical across all three of AWS, GCP and K8S.

Notes

🤖 Generated with Claude Code

@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 adds support for parsing and normalizing Kubernetes-style memory quantity strings (such as '8Gi') into integer byte values within the required_resources of compute configurations, aligning the Go implementation with the Python SDK. Feedback focuses on improving robustness: first, by using exact float64 bounds when checking if a float64 fits within int64 limits to avoid precision issues; and second, by adding a safety check when looking up memory multipliers to prevent silent failures if the regular expression and multiplier map ever drift.

Comment on lines +351 to +353
if t > math.MaxInt64 || t < math.MinInt64 {
return 0, fmt.Errorf("value %v is out of range", t)
}

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.

medium

When checking if a float64 value fits within the range of int64, comparing against math.MaxInt64 directly can be imprecise. math.MaxInt64 (9223372036854775807) cannot be represented exactly as a float64 and rounds up to 9223372036854775808.0. This causes the check t > math.MaxInt64 to evaluate to false when t is exactly 9223372036854775808.0, leading to silent saturation to math.MaxInt64 during the int64(t) cast.

Using exact representable float64 bounds (9223372036854775808.0 and -9223372036854775808.0) avoids this rounding issue.

Suggested change
if t > math.MaxInt64 || t < math.MinInt64 {
return 0, fmt.Errorf("value %v is out of range", t)
}
if t >= 9223372036854775808.0 || t < -9223372036854775808.0 {
return 0, fmt.Errorf("value %v is out of range", t)
}

Comment thread rayapp/compute_config_convert.go Outdated
if !ok {
return 0, fmt.Errorf("invalid memory quantity %q", s)
}
mantissa.Mul(mantissa, new(big.Rat).SetInt64(memoryQuantityMultipliers[m[2]]))

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.

medium

Directly accessing memoryQuantityMultipliers[m[2]] without checking if the key exists is risky. If the regular expression memoryQuantityRe is updated in the future to match a new suffix but the memoryQuantityMultipliers map is not updated accordingly, this lookup will silently return 0 (the zero value of int64), resulting in a silent failure where the memory is parsed as 0 bytes.

Adding a check for key existence prevents this potential drift.

	multiplier, ok := memoryQuantityMultipliers[m[2]]
	if !ok {
		return 0, fmt.Errorf("internal error: unhandled memory suffix %q", m[2])
	}
	mantissa.Mul(mantissa, new(big.Rat).SetInt64(multiplier))

The API takes `required_resources.memory` as an integer number of bytes,
but every user-facing surface advertises Kubernetes quantity strings
("8Gi"). The SDK reconciles those in PhysicalResources.to_dict(for_api=
True) -> _parse_memory_string; the published bundle never touches the
SDK, since rayapp copies required_resources verbatim into ray-app.json
and the console clone path parses it straight into the backend's
PhysicalResources, whose `memory` is an Optional[int]. So "8Gi" passes
`anyscale compute-config create -f` and then 422s at launch:

  body.config.head_node_type.required_resources.memory:
    value is not a valid integer

Convert in the Go mirror too, so the two conversion paths agree and a
template can be authored the way the docs describe.

Parse with k8s.io/apimachinery's resource.ParseQuantity -- the same
parser Kubernetes uses, and the one the SDK reaches through
kubernetes.utils.quantity -- rather than hand-rolling the grammar, so
the accepted syntax cannot drift from what the docs describe. Pinned to
apimachinery v0.34.0, which needs only go 1.24; v0.37 would drag the go
directive to 1.26 and bump x/net.

Three rules on top of the parser, each because the value has to land in
an integer bytes field:

- Whole bytes only. Kubernetes tolerates a fraction ("100m" is 100
  *milli*bytes) but its own docs say "this isn't useful to specify
  since you must always assign whole numbers of bytes", and a fraction
  could not survive PhysicalResources.memory anyway. This also removes
  the one place the two parsers disagree: ParseQuantity().Value()
  rounds up where the SDK's int(Decimal) truncates, and that only
  differs for values we now reject.
- No negatives. Nothing downstream rejects them -- the backend only
  range-checks int64 -- so a negative would reach launch as a nonsense
  pod request. YAML parses `memory: -1` as an int, so the integer path
  needs this too.
- Must contain a digit. ParseQuantity reads a bare suffix ("Gi", "k",
  ".") as zero, so a typo would silently become no memory at all. The
  SDK rejects those.

TestParseMemoryBytesMatchesSDK runs every golden through the SDK
function this mirrors and compares, so the two cannot drift unnoticed;
it skips where python3 or the anyscale package is unavailable.

No effect on VM configs: this fires only on required_resources.memory,
which instance-type configs (every aws.yaml/gce.yaml in
anyscale/templates today) never set. Building anyscale/templates#940
with this binary gives byte-identical AWS and GCP configs and converts
only the K8S memory values.

Test: go test ./rayapp/ passes, coverage 87.5% (gate 80%); gofmt and go
vet clean. golangci-lint panics on this package on a clean tree too
(toolchain mismatch: built with go1.25, sources need go1.26).

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-memory-quantity branch from bdb0884 to da8634a Compare September 18, 2026 21:45
@elliot-barn
elliot-barn merged commit ef6403a into main Sep 24, 2026
4 checks passed
@elliot-barn
elliot-barn deleted the elliot-barn/rayapp-memory-quantity branch September 24, 2026 18:46
elliot-barn added a commit to anyscale/templates that referenced this pull request Oct 2, 2026
Bumps the pinned rayapp from v0.45.0 to v0.49.0.

The only rayapp change in that range is ray-project/rayci#505:
`required_resources` now accepts memory quantity strings (e.g. `16Gi`),
as used in K8S compute configs.

Checked that
https://github.com/ray-project/rayci/releases/download/v0.49.0/rayapp-linux-amd64
returns 200 and the binary runs.

`download_rayapp.sh` is used by the premerge `rayapp build all`, the
template test pipeline (`rayapp test`), and product's
`tmpl-publish/build.sh`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.

2 participants