Skip to content

Commit da86ee3

Browse files
committed
Merge branch 'main' into jax_py3.12_nightly
2 parents 41eb5a3 + e7efcf0 commit da86ee3

121 files changed

Lines changed: 5665 additions & 2404 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Dockerfile

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# syntax=docker/dockerfile:1
22

33
ARG TARGET=base
4-
ARG BASE_IMAGE=ubuntu:24.04
4+
ARG BASE_IMAGE=ubuntu:22.04
5+
ARG BASE_IMAGE_COLOCATED=us-docker.pkg.dev/cloud-tpu-v2-images/pathways-colocated-python/sidecar:2025_10_29-python_3.10-jax_0.6.2
56

67
FROM ${BASE_IMAGE} AS base
78

@@ -18,7 +19,7 @@ RUN echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.
1819
curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg && \
1920
apt-get update -y -qq && \
2021
apt-get install -y -qq apt-transport-https ca-certificates gcc g++ \
21-
git screen ca-certificates google-perftools google-cloud-cli python3.12-venv && \
22+
git screen ca-certificates google-perftools google-cloud-cli python3.10-venv python3.10-dev && \
2223
apt clean -y -qq
2324

2425
# Setup.
@@ -102,12 +103,40 @@ COPY pyproject.toml README.md /root/
102103
RUN uv pip install -qq --prerelease=allow .[core,tpu] && uv cache clean
103104
RUN if [ -n "$EXTRAS" ]; then uv pip install -qq .[$EXTRAS] && uv cache clean; fi
104105
RUN if [ "$INSTALL_PATHWAYS_JAXLIB" = "true" ]; then \
105-
uv pip install --prerelease=allow "jaxlib==0.5.3.dev20250918" \
106+
uv pip install --prerelease=allow "jaxlib==0.6.2.dev20251021" \
106107
--find-links https://storage.googleapis.com/axlearn-wheels/wheels.html; \
107108
fi
108109
RUN uv pip install -qq --no-deps libtpu==0.0.28.dev20251104+nightly && uv cache clean
109110
COPY . .
110111

112+
################################################################################
113+
# Colocated Python container spec. #
114+
################################################################################
115+
116+
FROM ${BASE_IMAGE_COLOCATED} AS colocated-python
117+
118+
WORKDIR /app
119+
COPY . .
120+
121+
# Install the additional user-provided dependencies, strictly enforcing the rules
122+
# from the base image's constraints file.
123+
RUN \
124+
# 1. Install user-provided dependencies with modified constraints
125+
grep -v "^numpy" /opt/venv/server_constraints.txt | grep -v "^scipy" > /tmp/modified_constraints.txt && \
126+
echo "--> Installing user-provided dependencies..." && \
127+
uv pip install ".[core,gcp]" -c /tmp/modified_constraints.txt && \
128+
\
129+
# 2. Override numpy and scipy with specific versions
130+
uv pip install numpy==2.1.1 scipy==1.15.3 && \
131+
\
132+
# 3. Verify that the colocated_python_cpu_client is present.
133+
echo "--> Verifying JAX patch integrity..." && \
134+
python -c "from jax._src.lib import _jax; _jax.colocated_python_cpu_client" && \
135+
echo "--> JAX patch verification successful." && \
136+
\
137+
# 4. Clean the cache to keep the image slim.
138+
uv cache clean
139+
111140
################################################################################
112141
# GPU container spec. #
113142
################################################################################

axlearn/audio/aligner/ctc_aligner.py

Lines changed: 173 additions & 68 deletions
Large diffs are not rendered by default.

axlearn/audio/aligner/ctc_aligner_test.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -488,8 +488,11 @@ def test_base(self, loop):
488488
expected_search_lattice = np.where(
489489
np.isinf(self.expected_search_lattices), 0.0, self.expected_search_lattices
490490
)
491+
# Transpose expected to match new [T+1, B, 2*(L+1)] layout
492+
expected_search_lattice = np.transpose(expected_search_lattice, (1, 0, 2))
493+
expected_backtrace = np.transpose(self.expected_backtrace, (1, 0, 2))
491494
self.assertNestedAllClose(search_lattice, expected_search_lattice)
492-
self.assertNestedEqual(state.backtrace, self.expected_backtrace)
495+
self.assertNestedEqual(state.backtrace, expected_backtrace)
493496

494497
@parameterized.parameters(("python",), ("lax",))
495498
def test_to_alignment(self, loop):
@@ -557,6 +560,7 @@ def setUp(self):
557560
# pylint: disable=invalid-name
558561
T = 4
559562
L = 3
563+
# Create in [B, T+1, 2*(L+1)] format first
560564
search_lattices = np.full((3, (T + 1), 2 * (L + 1)), fill_value=-np.inf)
561565
frame_lengths = np.array([3, 4, 3], dtype=np.int32)
562566
label_lengths = np.array([2, 3, 2], dtype=np.int32)
@@ -571,6 +575,9 @@ def setUp(self):
571575
search_lattices[2, frame_lengths[2], 2 * label_lengths[2]] = 10.0
572576
search_lattices[2, frame_lengths[2], 2 * label_lengths[2] + 1] = -np.inf
573577

578+
# Transpose to [T+1, B, 2*(L+1)] to match the new layout
579+
search_lattices = np.transpose(search_lattices, (1, 0, 2))
580+
574581
self.search_lattices = jnp.array(search_lattices)
575582
self.frame_lengths = jnp.array(frame_lengths)
576583
self.label_lengths = jnp.array(label_lengths)

axlearn/audio/evaler_asr.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,7 @@ def default_config(cls):
150150
class Scorer(Protocol):
151151
"""A function that computes word errors."""
152152

153-
def __call__(self, hypotheses: list[str], reference: list[str]) -> WordErrors:
154-
...
153+
def __call__(self, hypotheses: list[str], reference: list[str]) -> WordErrors: ...
155154

156155
def __init__(
157156
self,

axlearn/audio/evaler_asr_test.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -133,13 +133,13 @@ def _compute_metrics(
133133
if brevity_penalty:
134134
decode_kwargs["brevity_penalty"] = brevity_penalty
135135

136-
cfg: WordErrorRateMetricCalculator.Config = (
137-
WordErrorRateMetricCalculator.default_config().set(
138-
vocab=config_for_class(seqio.SentencePieceVocabulary).set(
139-
sentencepiece_model_file=vocab_file,
140-
),
141-
model_method_kwargs=decode_kwargs,
142-
)
136+
cfg: (
137+
WordErrorRateMetricCalculator.Config
138+
) = WordErrorRateMetricCalculator.default_config().set(
139+
vocab=config_for_class(seqio.SentencePieceVocabulary).set(
140+
sentencepiece_model_file=vocab_file,
141+
),
142+
model_method_kwargs=decode_kwargs,
143143
)
144144
calculator: WordErrorRateMetricCalculator = cfg.set(name="test-metric").instantiate(
145145
parent=None, model=model, model_param_partition_specs={}

axlearn/cloud/common/bastion_test.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1690,10 +1690,9 @@ def test_sync_jobs_for_valid_pending_to_sudden_invalid_jobs(self):
16901690
mock_validator_cfg = MockStatefulJobValidator.default_config()
16911691
mock_append_to_job_history = mock.MagicMock()
16921692

1693-
with self._patch_bastion(
1694-
validator_cfg=mock_validator_cfg
1695-
) as mock_bastion, mock.patch.object(
1696-
mock_bastion, "_append_to_job_history", mock_append_to_job_history
1693+
with (
1694+
self._patch_bastion(validator_cfg=mock_validator_cfg) as mock_bastion,
1695+
mock.patch.object(mock_bastion, "_append_to_job_history", mock_append_to_job_history),
16971696
):
16981697
os.makedirs(mock_bastion._active_dir, exist_ok=True)
16991698
os.makedirs(_JOB_DIR, exist_ok=True)
@@ -1802,10 +1801,9 @@ def test_sync_jobs_for_immediate_invalid_pending_jobs(self):
18021801
mock_validator_cfg = MockAlwaysInvalidValidator.default_config()
18031802
mock_append_to_job_history = mock.MagicMock()
18041803

1805-
with self._patch_bastion(
1806-
validator_cfg=mock_validator_cfg
1807-
) as mock_bastion, mock.patch.object(
1808-
mock_bastion, "_append_to_job_history", mock_append_to_job_history
1804+
with (
1805+
self._patch_bastion(validator_cfg=mock_validator_cfg) as mock_bastion,
1806+
mock.patch.object(mock_bastion, "_append_to_job_history", mock_append_to_job_history),
18091807
):
18101808
os.makedirs(mock_bastion._active_dir, exist_ok=True)
18111809
os.makedirs(_JOB_DIR, exist_ok=True)

axlearn/cloud/common/bundler.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,8 @@ class Config(Bundler.Config):
301301
cache_from: Optional[Sequence[str]] = None
302302
# Skip the build + push step (e.g., using a pre-built image).
303303
skip_bundle: bool = False
304+
# Sidecar names to build images for.
305+
sidecars: list[str] = []
304306

305307
def __init__(self, cfg: Config):
306308
super().__init__(cfg)
@@ -337,6 +339,7 @@ def from_spec(cls, spec: list[str], *, fv: Optional[flags.FlagValues]) -> Config
337339
- platform: The image target platform.
338340
- allow_dirty: Whether to ignore dirty git status.
339341
- cache_from: A comma-separated list of cache sources.
342+
- sidecars: A comma-separated list of sidecar names.
340343
- skip_bundle: Whether to skip the build + push. This option is intended to be used when an
341344
image has already been pre-built offline, in which case we may still want to leverage
342345
the install commands implemented by the bundler.
@@ -346,13 +349,15 @@ def from_spec(cls, spec: list[str], *, fv: Optional[flags.FlagValues]) -> Config
346349
cfg: BaseDockerBundler.Config = super().from_spec(spec, fv=fv)
347350
kwargs = parse_kv_flags(spec, delimiter="=")
348351
cache_from = canonicalize_to_list(kwargs.pop("cache_from", None))
352+
sidecars = canonicalize_to_list(kwargs.pop("sidecars", None))
349353
skip_bundle = to_bool(kwargs.pop("skip_bundle", False))
350354
allow_dirty = to_bool(kwargs.pop("allow_dirty", False))
351355
# Non-config specs are treated as build args.
352356
build_args = {k: kwargs.pop(k) for k in list(kwargs.keys()) if k not in cfg}
353357
return cfg.set(
354358
build_args=build_args,
355359
cache_from=cache_from,
360+
sidecars=sidecars,
356361
skip_bundle=skip_bundle,
357362
allow_dirty=allow_dirty,
358363
**kwargs,
@@ -485,6 +490,16 @@ def _build_and_push(
485490
labels: dict[str, str],
486491
) -> str:
487492
cfg: DockerBundler.Config = self.config
493+
494+
_, tag = image.rsplit(":", maxsplit=1)
495+
for sidecar in cfg.sidecars:
496+
sidecar_bundler = cfg.set(
497+
image=sidecar,
498+
target=sidecar,
499+
sidecars=[],
500+
).instantiate()
501+
sidecar_bundler.bundle(tag=tag)
502+
488503
return docker_push(
489504
docker_build(
490505
dockerfile=dockerfile,

axlearn/cloud/common/cleaner.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Copyright © 2023 Apple Inc.
22

33
"""Utilities to clean resources."""
4-
4+
import logging
55
from collections.abc import Sequence
66
from enum import Enum
77

@@ -89,6 +89,12 @@ def sweep(self, jobs: dict[str, JobSpec]) -> Sequence[str]:
8989
schedule_result = scheduler.schedule(
9090
dict(my_job=job_spec.metadata),
9191
)
92-
if schedule_result.job_verdicts["my_job"].over_limits:
92+
job_verdict = schedule_result.job_verdicts.get("my_job")
93+
# In certain corner cases, schedule_result may not contain the job being passed in
94+
# TODO(yangwwei): revisit and fix it in a proper way
95+
if job_verdict is None:
96+
logging.warning("job %s does not exist in the verdicts, skipping", job_name)
97+
continue
98+
if job_verdict.over_limits:
9399
result.append(job_name)
94100
return result

axlearn/cloud/common/cleaner_test.py

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import datetime
66
from collections.abc import Sequence
7+
from unittest import mock
78

89
from absl.testing import parameterized
910

@@ -15,7 +16,7 @@
1516
UnschedulableCleaner,
1617
)
1718
from axlearn.cloud.common.quota import QuotaInfo
18-
from axlearn.cloud.common.scheduler import JobMetadata, JobScheduler
19+
from axlearn.cloud.common.scheduler import BaseScheduler, JobMetadata, JobScheduler, JobVerdict
1920
from axlearn.cloud.common.types import JobSpec
2021
from axlearn.common.config import REQUIRED, Required, config_class, config_for_function
2122

@@ -116,3 +117,99 @@ def mock_jobs(*, prefix: str, resource_count: int):
116117
cleaner = cfg.instantiate()
117118
result = cleaner.sweep(jobs)
118119
self.assertSequenceEqual(result, list(unschedulable_jobs.keys()))
120+
121+
def test_sweep_missing_job_in_verdicts(self):
122+
"""Test that sweep handles the case when job is not in job_verdicts."""
123+
# Create a mock schedule result without the job being scheduled
124+
mock_schedule_result = BaseScheduler.ScheduleResults(
125+
project_limits={},
126+
project_usages={},
127+
job_verdicts={}, # empty job verdicts
128+
unused_limits=None,
129+
)
130+
131+
# Create a simple job
132+
job = new_jobspec(
133+
name="test_job",
134+
command="echo hello",
135+
metadata=JobMetadata(
136+
user_id="user_1",
137+
project_id="default",
138+
creation_time=datetime.datetime(1900, 1, 1, 0, 0, 0),
139+
resources={"aws_4:p5.48xlarge": 1},
140+
),
141+
)
142+
jobs = {"test_job": job}
143+
144+
# Create cleaner
145+
quota = QuotaInfo(
146+
total_resources=[{"aws_4:p5.48xlarge": 10}],
147+
project_resources={},
148+
project_membership={},
149+
)
150+
cfg = UnschedulableCleaner.default_config().set(
151+
scheduler=JobScheduler.default_config().set(
152+
quota=config_for_function(lambda: lambda: quota)
153+
)
154+
)
155+
cleaner = cfg.instantiate()
156+
157+
# Patch the scheduler.schedule method to return our mock result
158+
# and verify the logging
159+
with self.assertLogs(level="INFO") as log_context:
160+
with mock.patch.object(JobScheduler, "schedule", return_value=mock_schedule_result):
161+
result = cleaner.sweep(jobs)
162+
163+
# Should return empty list since "my_job" is not in job_verdicts
164+
self.assertSequenceEqual(result, [])
165+
166+
# Verify the log message was emitted
167+
self.assertTrue(
168+
any(
169+
"job test_job does not exist in the verdicts, skipping" in log
170+
for log in log_context.output
171+
)
172+
)
173+
174+
def test_sweep_job_with_over_limits(self):
175+
"""Test that sweep correctly identifies jobs with over_limits."""
176+
# Create a mock schedule result with a verdict that has over_limits
177+
mock_schedule_result = BaseScheduler.ScheduleResults(
178+
project_limits={},
179+
project_usages={},
180+
job_verdicts={"my_job": JobVerdict(over_limits={"aws_4:p5.48xlarge"})},
181+
unused_limits=None,
182+
)
183+
184+
# Create a simple job
185+
job = new_jobspec(
186+
name="test_job",
187+
command="echo hello",
188+
metadata=JobMetadata(
189+
user_id="user_1",
190+
project_id="default",
191+
creation_time=datetime.datetime(1900, 1, 1, 0, 0, 0),
192+
resources={"aws_4:p5.48xlarge": 100},
193+
),
194+
)
195+
jobs = {"test_job": job}
196+
197+
# Create cleaner
198+
quota = QuotaInfo(
199+
total_resources=[{"aws_4:p5.48xlarge": 10}],
200+
project_resources={},
201+
project_membership={},
202+
)
203+
cfg = UnschedulableCleaner.default_config().set(
204+
scheduler=JobScheduler.default_config().set(
205+
quota=config_for_function(lambda: lambda: quota)
206+
)
207+
)
208+
cleaner = cfg.instantiate()
209+
210+
# Patch the scheduler.schedule method to return our mock result
211+
with mock.patch.object(JobScheduler, "schedule", return_value=mock_schedule_result):
212+
result = cleaner.sweep(jobs)
213+
214+
# Should return the job since it has over_limits
215+
self.assertSequenceEqual(result, ["test_job"])
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Copyright © 2025 Apple Inc.
2+
3+
"""Command patcher interface for job processing."""
4+
5+
from typing import Any, Dict
6+
7+
from axlearn.common.config import Configurable
8+
9+
10+
class UserCommandPatcher(Configurable):
11+
"""A command patcher interface for modifying a job's user command."""
12+
13+
def patch(self, command: str, **kwargs: Dict[str, Any]) -> str:
14+
"""Patches the command string.
15+
16+
Args:
17+
command: The original user command string.
18+
19+
Returns:
20+
The patched command string.
21+
"""
22+
raise NotImplementedError(type(self))

0 commit comments

Comments
 (0)