Repository navigation
ci(jax): serve the JAX wheel build from the EngFlow Bazel cache #7912
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
gulsumgudukbay
wants to merge
11
commits into
main
Choose a base branch
from
users/gulsumgudukbay/ci-pr-stage-reuse-and-jax-bazel-cache
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 8 commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
4155359
ci(jax): serve the JAX wheel build from the EngFlow Bazel cache
gulsumgudukbay e0de8d0
ci(jax): read EngFlow certs from the runner, not GitHub secrets
gulsumgudukbay 525a491
ci(jax): serve the release JAX wheel build from the cache too
gulsumgudukbay 4d47079
Merge branch 'main' into users/gulsumgudukbay/ci-pr-stage-reuse-and-j…
gulsumgudukbay 96f3336
Merge remote-tracking branch 'origin/main' into users/gulsumgudukbay/…
gulsumgudukbay 91a5d4f
ci(jax): keep the Bazel cache to the CI wheel build for now
gulsumgudukbay a9ddda0
ci(jax): fail fast on an unusable Bazel cache, upload only from main
gulsumgudukbay ceb65d9
Merge remote-tracking branch 'origin/main' into users/gulsumgudukbay/…
gulsumgudukbay daa2ea5
Merge remote-tracking branch 'origin/main' into users/gulsumgudukbay/…
gulsumgudukbay 53ee88f
ci(jax): read the Bazel cache on actinolite with an EngFlow JWT
gulsumgudukbay d0374db
ci(jax): fetch the Bazel cache writer token on pushes to main
gulsumgudukbay File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
209 changes: 209 additions & 0 deletions
209
build_tools/github_actions/configure_jax_bazel_cache.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,209 @@ | ||
| #!/usr/bin/env python3 | ||
| # Copyright Advanced Micro Devices, Inc. | ||
| # SPDX-License-Identifier: MIT | ||
|
|
||
| """Prints the `build/build.py` options that point the JAX wheel build at the | ||
| shared Bazel remote cache. | ||
|
|
||
| The JAX wheel job compiles jaxlib, the ROCm plugin and the XLA ROCm backend with | ||
| Bazel against an already-installed ROCm. Those compiles repeat across runs | ||
| whenever the JAX ref and the ROCm build are unchanged, which is the common case | ||
| for a pull request that only touches CI or test-selection files. | ||
|
|
||
| The cache is the EngFlow cluster ROCm's JAX CI already uses, reached with the | ||
| mTLS client credentials `rocm.bazelrc` expects at `/data`. Only the cache is | ||
| used: the build runs `--config=rocm_release_wheel`, never `rocm_rbe`, so it | ||
| never picks up that config's `--remote_executor` and every action still executes | ||
| locally. | ||
|
|
||
| This runs *inside* the manylinux build container rather than on the runner. The | ||
| container reaches the network through the Docker daemon and receives the | ||
| credentials through a mount of the runner's `/data/ci-cert.*` files, so it is | ||
| the only place that can tell whether Bazel will actually reach the cache. When | ||
| those files are absent or the release type may not use the cache, this prints | ||
| nothing and the build runs exactly as it would without a cache. Credentials | ||
| that are present but rejected, or a cache that does not answer, raise instead. | ||
|
|
||
| Options are printed to stdout so the caller can expand them into the build | ||
| command; all logging goes to stderr. | ||
| """ | ||
|
|
||
| import argparse | ||
| import os | ||
| import socket | ||
| import ssl | ||
| import sys | ||
| from pathlib import Path | ||
| from urllib.parse import urlsplit | ||
|
|
||
| REMOTE_CACHE_URL = "grpcs://wardite.cluster.engflow.com" | ||
|
|
||
| # Paths `build/rocm/rocm.bazelrc` already expects, populated by mounting the | ||
| # runner's `/data/ci-cert.*` files into the build container. | ||
| CLIENT_CERTIFICATE = Path("/data/ci-cert.crt") | ||
| CLIENT_KEY = Path("/data/ci-cert.key") | ||
|
|
||
| # Release types that may use the cache. Only CI for now: release builds need a | ||
| # cache of their own, as nightly has for ccache in setup_ccache.py. | ||
| SHARED_CACHE_RELEASE_TYPES = frozenset({"ci"}) | ||
|
|
||
| DEFAULT_PROBE_TIMEOUT_SECONDS = 10 | ||
| DEFAULT_REMOTE_TIMEOUT_SECONDS = 60 | ||
| DEFAULT_TLS_PORT = 443 | ||
|
|
||
| # The HTTP/2 connection preface and an empty SETTINGS frame, which every gRPC | ||
| # client sends first and a gRPC server answers with its own SETTINGS frame. | ||
| HTTP2_CLIENT_PREFACE = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n" + bytes.fromhex( | ||
| "000000040000000000" | ||
| ) | ||
|
|
||
|
|
||
| class CacheUnusableError(RuntimeError): | ||
| """The cache is configured and credentialed but cannot be used.""" | ||
|
|
||
|
|
||
| def _log(msg: str): | ||
| print(f"[jax-bazel-cache] {msg}", file=sys.stderr) | ||
|
|
||
|
|
||
| def resolve_cache_url(cache_url: str, release_type: str) -> str: | ||
| """Returns the cache URL to use, or "" when caching is disabled. | ||
|
|
||
| The release type decides whether to cache at all; an explicit `cache_url` | ||
| only repoints where the cache is, so infrastructure can move it without a | ||
| code change. | ||
| """ | ||
| if release_type not in SHARED_CACHE_RELEASE_TYPES: | ||
| return "" | ||
| return cache_url or REMOTE_CACHE_URL | ||
|
|
||
|
|
||
| def upload_allowed(event_name: str, ref: str) -> bool: | ||
| """Only pushes to main write; pull requests and dispatches only read.""" | ||
| return event_name == "push" and ref == "refs/heads/main" | ||
|
|
||
|
|
||
| def endpoint_address(url: str) -> tuple[str, int]: | ||
| """Returns the (host, port) a `grpcs://host[:port]` cache URL points at.""" | ||
| split = urlsplit(url) | ||
| return split.hostname or "", split.port or DEFAULT_TLS_PORT | ||
|
|
||
|
|
||
| def probe_cache( | ||
| url: str, | ||
| certificate: Path, | ||
| key: Path, | ||
| timeout: float = DEFAULT_PROBE_TIMEOUT_SECONDS, | ||
| ): | ||
| """Raises CacheUnusableError unless the cache accepts the client credentials. | ||
|
|
||
| Under TLS 1.3 the server checks the client certificate after the handshake | ||
| has completed on the client side, so a rejection only arrives on the first | ||
| read. Sending the preface gRPC opens with and reading the reply surfaces it | ||
| here rather than as a wall of Bazel cache warnings during the build. | ||
| """ | ||
| host, port = endpoint_address(url) | ||
| if not host: | ||
| raise CacheUnusableError(f"Cannot parse a host out of {url!r}") | ||
| context = ssl.create_default_context() | ||
| context.minimum_version = ssl.TLSVersion.TLSv1_2 | ||
| context.set_alpn_protocols(["h2"]) | ||
| try: | ||
| context.load_cert_chain(certfile=certificate, keyfile=key) | ||
| with socket.create_connection((host, port), timeout=timeout) as sock: | ||
| with context.wrap_socket(sock, server_hostname=host) as tls: | ||
| tls.sendall(HTTP2_CLIENT_PREFACE) | ||
| if not tls.recv(1): | ||
| raise CacheUnusableError(f"{url} closed the connection") | ||
| except OSError as e: | ||
| raise CacheUnusableError( | ||
| f"Cannot use the cache at {url} with {certificate}: {e}" | ||
| ) from e | ||
|
|
||
|
|
||
| def bazel_cache_options( | ||
| url: str, certificate: Path, key: Path, allow_upload: bool | ||
| ) -> list[str]: | ||
| """Returns the `--bazel_options=...` arguments for a usable cache.""" | ||
| return [ | ||
| f"--bazel_options=--remote_cache={url}", | ||
| f"--bazel_options=--tls_client_certificate={certificate}", | ||
| f"--bazel_options=--tls_client_key={key}", | ||
| f"--bazel_options=--remote_timeout={DEFAULT_REMOTE_TIMEOUT_SECONDS}", | ||
| f"--bazel_options=--remote_upload_local_results={str(allow_upload).lower()}", | ||
| ] | ||
|
|
||
|
|
||
| def main(argv: list[str] | None = None): | ||
| parser = argparse.ArgumentParser( | ||
| description="Print build/build.py options for the JAX Bazel remote cache." | ||
| ) | ||
| parser.add_argument( | ||
| "--cache-url", | ||
| default=os.environ.get("JAX_BAZEL_REMOTE_CACHE_URL", ""), | ||
| help="Remote cache URL, for release types that may use the cache.", | ||
| ) | ||
| parser.add_argument( | ||
| "--release-type", | ||
| default=os.environ.get("RELEASE_TYPE", "ci"), | ||
| help="Release types that must not read shared entries resolve to no cache.", | ||
| ) | ||
| parser.add_argument( | ||
| "--event-name", | ||
| default=os.environ.get("GITHUB_EVENT_NAME", ""), | ||
| help="GitHub event that started the run; only a push to main uploads.", | ||
| ) | ||
| parser.add_argument( | ||
| "--ref", | ||
| default=os.environ.get("GITHUB_REF", ""), | ||
| help="Git ref the run is for, e.g. refs/heads/main.", | ||
| ) | ||
| parser.add_argument( | ||
| "--client-certificate", | ||
| type=Path, | ||
| default=CLIENT_CERTIFICATE, | ||
| help="mTLS client certificate for the cache.", | ||
| ) | ||
| parser.add_argument( | ||
| "--client-key", | ||
| type=Path, | ||
| default=CLIENT_KEY, | ||
| help="mTLS client key for the cache.", | ||
| ) | ||
| parser.add_argument( | ||
| "--probe-timeout", | ||
| type=float, | ||
| default=DEFAULT_PROBE_TIMEOUT_SECONDS, | ||
| help="Seconds to wait for the cache to complete the handshake.", | ||
| ) | ||
| args = parser.parse_args(argv) | ||
|
|
||
| url = resolve_cache_url(args.cache_url, args.release_type) | ||
| if not url: | ||
| _log(f"No remote cache for release_type={args.release_type!r}") | ||
| return | ||
|
|
||
| missing = [p for p in (args.client_certificate, args.client_key) if not p.exists()] | ||
| if missing: | ||
| # Runners without `/data/ci-cert.*` skip the cache. That is a normal | ||
| # outcome, not an error: the build simply compiles locally. | ||
| _log(f"No cache credentials at {', '.join(str(p) for p in missing)}") | ||
| return | ||
|
|
||
| allow_upload = upload_allowed(args.event_name, args.ref) | ||
| _log( | ||
| f"Cache mode: url={url} release_type={args.release_type} upload={allow_upload}" | ||
| ) | ||
| probe_cache(url, args.client_certificate, args.client_key, args.probe_timeout) | ||
|
|
||
| print( | ||
| " ".join( | ||
| bazel_cache_options( | ||
| url, args.client_certificate, args.client_key, allow_upload | ||
| ) | ||
| ) | ||
| ) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This matches the client side of #7026, but as I noted there, a client-side read-only gate is only hygiene; the boundary has to be server-side. Here that's the scope of this cert on EngFlow, and per the README any PR, including forks, can read it. What is
RBE_CI_CERTallowed to do there?On S3, runner base credentials allow uploads to
therock-ci-artifacts-external, and other writes go through OIDC roles that fork PRs can't use (docs); PyTorch sccache has a bucket and role per release type (docs). If an S3-backed cache would work here, we can create the buckets and roles. This job already authenticates to AWS for the wheel upload, so it wouldn't need different runners.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @subodh-dubey-amd, your other comments are addressed in a9ddda0. On credentials: the certificate I tested with can write to the cache, so I agree it can't sit on the runners as is. Unless you or @amd-shiraz prefer S3, I'd like to stay on EngFlow, with a read-only certificate on the runners and a write certificate that only push-to-main jobs can use.
I am discussing with @charleshofer regarding read-only client certificate.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed: only a read-only cert on the runners. For the write cert, anything on the runner is readable by PR jobs, so it needs a source gated on the ref. On the AWS side we could keep it in Secrets Manager behind an OIDC role that only trusts
refs/heads/main, and the job already hasid-token: write.