Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
c1df884
feat(trainer): add multi-objective policy distillation
yulangz Aug 11, 2026
a51aa18
fix: propagate role env to forked workers
yulangz Aug 11, 2026
3794efa
feat: offload SGLang CUDA graphs during AWEX training
yulangz Aug 11, 2026
a0be494
Merge branch 'main' into feature/mopd
yulangz Aug 11, 2026
e249e32
Merge remote-tracking branch 'github/main' into feature/mopd
yulangz Aug 12, 2026
a05754b
fix: restore MOPD CI compatibility
yulangz Aug 12, 2026
47f36e0
chore: merge main into feature/mopd
yulangz Aug 12, 2026
627a4f4
chore: merge main into feature/mopd
yulangz Aug 12, 2026
476fc62
test: stabilize MOPD CI fixtures
yulangz Aug 12, 2026
7cad29b
fix(engine): stabilize AWEX colocated weight updates
yulangz Aug 13, 2026
ce6b7a7
feat(engine): add GPU-staged optimizer support
yulangz Aug 17, 2026
f7f7f7c
feat(examples): add CPU-staged Qwen3 MoE example
yulangz Aug 17, 2026
62a7b4a
test: consolidate staged optimizer coverage
yulangz Aug 18, 2026
07fda30
feat: add DAPO-Math CPU-staged training example
Aug 20, 2026
8fd2a5e
fix(dataset): use compatible chat template helper
Aug 21, 2026
7dd2bed
fix: release colocated actor memory between phases
Aug 24, 2026
0365518
feat(engine): integrate CPU-staged optimizer residency
Aug 24, 2026
e1fc363
fix(engine): use native AWEX Qwen3 converter
Aug 24, 2026
d5bd29b
feat(engine): decouple staged offload from optimizer choice
Aug 25, 2026
e97a42d
test(engine): add staged optimizer parity coverage
Aug 27, 2026
3c90fba
refactor(engine): share staged optimizer runtime
Aug 27, 2026
eb63993
refactor(engine): reuse native staged update rules
Aug 27, 2026
e9ecd7e
perf(engine): select staged AdamW update backend
Aug 27, 2026
08826c7
test(engine): expand staged optimizer parallel coverage
Aug 28, 2026
f4c94a2
fix(engine): handle staged optimizer schedules and empty owners
Sep 7, 2026
e851361
chore: merge main into optimizer offload branch
Sep 7, 2026
551b48a
fix(engine): use physical GPU ranks for AWEX TP servers
Sep 7, 2026
27b110c
fix(models): disable bridge wgrad fusion without native extension
Sep 7, 2026
812992a
refactor: simplify staged optimizers and add Qwen3.5 example
Sep 7, 2026
335cf93
fix(engine): budget padded microbatches by rectangular input size
Sep 8, 2026
6fc019c
fix(ci): provision Muon dependency and correct test fixtures
Sep 8, 2026
5da34c0
fix(ci): install Muon dependency in a runner-writable directory
Sep 8, 2026
b8a97bd
test: reduce tree training memory and always release engines
Sep 9, 2026
8c909e3
fix(engine): avoid ephemeral ports for SGLang rendezvous
Sep 10, 2026
25d91c2
chore: merge main into optimizer offload branch
Sep 18, 2026
d745230
Merge remote-tracking branch 'origin/main' into feature/optimizer_off…
Sep 20, 2026
fbc070f
test: align unit tests with runtime backends
Sep 20, 2026
fff0213
Merge remote-tracking branch 'origin/main' into feature/optimizer_off…
Sep 21, 2026
1d6f822
refactor(engine): split Muon from staged AdamW
Sep 21, 2026
8228c45
chore: merge main and align CI fixtures
Sep 22, 2026
0186f36
test(engine): align optimizer fixture with model config
Sep 22, 2026
163773d
fix(engine): keep AWEX transfer ranks dense
Sep 22, 2026
e9e05d4
test: defer VLM model path resolution during collection
Sep 22, 2026
b63aeae
fix(ci): preserve checkout path for worker processes
Sep 22, 2026
b00bd98
chore: merge main into optimizer offload branch
Sep 29, 2026
bac678a
fix(engine): merge main and refresh offload test fixtures
Oct 8, 2026
231e92b
fix(ci): align AWEX with latest main before unit tests
Oct 9, 2026
4a2a677
fix(engine): rebuild staged optimizer state after HF reload
Oct 12, 2026
37431a6
fix(engine): avoid double-offset AWEX transfer ranks
Oct 12, 2026
be24155
refactor(engine): split activation tracking from optimizer offload
Oct 12, 2026
1d0f81a
fix(engine): release AWEX IPC exports after reader acknowledgement
Oct 12, 2026
25ba63b
refactor(engine): narrow AWEX changes to staged optimizer support
Oct 13, 2026
e40259f
fix(engine): allow chunked Qwen3.5 text in model-owned THD
Oct 13, 2026
309a5ce
fix: propagate AWEX transfer ranks for separate rollout workers
Oct 13, 2026
f4a707c
fix(engine): preserve stub optimizers on frozen pipeline ranks
Oct 13, 2026
fdb3955
ci: isolate ephemeral GPU runners by workflow run and attempt
Oct 13, 2026
1cbf988
chore: merge latest main into optimizer offload branch
Oct 13, 2026
af68c53
chore: merge latest main with named loss support
Oct 15, 2026
99e9507
test: complete Qwen recovery configuration fixture
Oct 15, 2026
59f5705
chore: sync R3 main and drop superseded Flash Next fix
Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/test-areal.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,12 @@ jobs:
variant: ${{ fromJson(needs.determine-variants.outputs.matrix) }}
env:
CONTAINER_IMAGE: ghcr.io/areal-project/areal-runtime:${{ inputs.image_tag || 'dev' }}-${{ matrix.variant }}
RUNNER_LABELS: gcp-a2-highgpu-2g,variant-${{ matrix.variant }}
RUNNER_LABELS: gcp-a2-highgpu-2g-run-${{ github.run_id }}-${{ github.run_attempt }},variant-${{ matrix.variant }}
steps:
- name: Set instance variables
id: vars
run: |
echo "instance_name=gcp-runner-${{ github.run_id }}-${{ matrix.variant }}" >> "$GITHUB_OUTPUT"
echo "instance_name=gcp-runner-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.variant }}" >> "$GITHUB_OUTPUT"

- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v3
Expand Down Expand Up @@ -297,7 +297,7 @@ jobs:
contents: read
runs-on:
- self-hosted
- gcp-a2-highgpu-2g
- "gcp-a2-highgpu-2g-run-${{ github.run_id }}-${{ github.run_attempt }}"
- "variant-${{ matrix.variant }}"
timeout-minutes: 120
env:
Expand Down Expand Up @@ -347,7 +347,7 @@ jobs:

- name: Delete runner instance
env:
INSTANCE_NAME: gcp-runner-${{ github.run_id }}-${{ matrix.variant }}
INSTANCE_NAME: gcp-runner-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.variant }}
run: |
# Look up instance zone dynamically since matrix job outputs
# cannot be consumed per-element by downstream matrix jobs.
Expand Down
44 changes: 44 additions & 0 deletions areal/api/cli_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +914,36 @@ class FP8EngineConfig:
)


@dataclass
class CPUStagedOffloadConfig:
"""Bounded GPU staging for CPU-resident Megatron optimizer state."""

enabled: bool = field(
default=False,
metadata={
"help": (
"Keep optimizer state in pinned CPU memory and stream bounded "
"update units through GPU staging buffers. The optimizer algorithm "
"is selected independently by optimizer.type. Megatron only."
)
},
)
buffer_count: int = field(
default=2,
metadata={"help": "Number of reusable GPU optimizer staging buffers."},
)
bucket_size_mb: float = field(
default=128.0,
metadata={"help": "Maximum size in MiB of one GPU optimizer staging unit."},
)

def __post_init__(self) -> None:
if self.buffer_count < 1:
raise ValueError("cpu_staged_offload.buffer_count must be at least 1")
if self.bucket_size_mb <= 0:
raise ValueError("cpu_staged_offload.bucket_size_mb must be positive")


@dataclass
class MegatronEngineConfig:
"""Configuration for Megatron-LM training framework.
Expand Down Expand Up @@ -962,6 +992,15 @@ class MegatronEngineConfig:
main_params_dtype: str = "float32"
exp_avg_dtype: str = "float32"
exp_avg_sq_dtype: str = "float32"
cpu_staged_offload: CPUStagedOffloadConfig = field(
default_factory=CPUStagedOffloadConfig,
metadata={
"help": (
"Keep the selected optimizer's state CPU-resident and use bounded "
"GPU staging buffers for optimizer steps."
)
},
)

# Checkpointing Configuration
async_save: bool = field(
Expand Down Expand Up @@ -1180,6 +1219,11 @@ class MegatronEngineConfig:
)

def __post_init__(self) -> None:
if self.cpu_staged_offload.enabled and self.async_save:
raise ValueError(
"megatron.async_save is not supported with "
"megatron.cpu_staged_offload.enabled=true"
)
if self.enable_mtp_training and not self.enable_mtp:
raise ValueError("enable_mtp_training requires enable_mtp=True")
if self.mtp_only:
Expand Down
11 changes: 11 additions & 0 deletions areal/dataset/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"virl39k",
"hh-rlhf",
"torl_data",
"dapo-math-17k",
"swe_sft",
]

Expand Down Expand Up @@ -174,6 +175,16 @@ def _get_custom_dataset(
max_length=max_length,
**kwargs,
)
elif "dapo-math-17k" in path.lower() and type == "rl":
from .dapo_math_17k import get_dapo_math_17k_rl_dataset

return get_dapo_math_17k_rl_dataset(
path=path,
split=split,
tokenizer=tokenizer,
max_length=max_length,
**kwargs,
)
elif (
_SWE_PATH_PATTERN.search(path.lower()) or _is_swe_sft_artifact(path)
) and type == "sft":
Expand Down
60 changes: 60 additions & 0 deletions areal/dataset/dapo_math_17k.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# SPDX-License-Identifier: Apache-2.0

from pathlib import Path

from datasets import Dataset, load_dataset

from areal.utils.hf_utils import apply_chat_template


def get_dapo_math_17k_rl_dataset(
path: str,
split: str,
tokenizer,
max_length: int | None = None,
) -> Dataset:
"""Load local DAPO-Math-17k parquet data for RL training."""
del split
dataset_root = Path(path)
parquet_files = sorted((dataset_root / "data").glob("*.parquet"))
json_files = sorted(dataset_root.glob("*.jsonl")) + sorted(
dataset_root.glob("*.json")
)
if parquet_files:
dataset = load_dataset(
"parquet",
data_files=[str(data_file) for data_file in parquet_files],
split="train",
)
elif json_files:
dataset = load_dataset(
"json",
data_files=[str(data_file) for data_file in json_files],
split="train",
)
else:
raise FileNotFoundError(
f"No parquet or JSON data files found under {dataset_root}"
)

def process(sample):
answer = sample.get("label")
if answer is None:
answer = sample["reward_model"]["ground_truth"]
return {"messages": sample["prompt"], "answer": str(answer)}

dataset = dataset.map(process, remove_columns=dataset.column_names)

if max_length is not None:
if tokenizer is None:
raise ValueError("tokenizer is required when max_length is set")

def filter_length(sample):
prompt_ids = apply_chat_template(
tokenizer, sample["messages"], add_generation_prompt=True, tokenize=True
)
return len(prompt_ids) <= max_length

dataset = dataset.filter(filter_length)

return dataset
62 changes: 44 additions & 18 deletions areal/engine/awex/colocate_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ def __init__(
self._infer_world_size: int | None = None
self._num_infer_engines: int | None = None
self._logical_train_rank: int | None = None
self._pending_ipc_export: (
tuple[list[torch.Tensor], list[torch.Tensor]] | None
) = None

@property
def residency(self) -> MegatronWeightResidency:
Expand Down Expand Up @@ -348,18 +351,21 @@ def execute_colocate_weight_update(self, version: int) -> None:
4. Signal all_training_offloaded_weights (reader waits for this)
5. share_memory_() + cuda_ipc_serialize → put to MetaServer
6. Wait for weights_update_finished (reader done copying)
7. Clean up shared tensors → signal write_finished
8. Wait for all infer engines to finish (finished_weights_update_engines)
7. Release exported tensors, then signal write_finished
8. Wait for all infer engines to finish the update lifecycle
"""
from awex.util.tensor_util import (
cuda_ipc_serialize,
group_tensors_by_shape_and_dtype,
release_tensors,
)

# Reclaim any IPC-exported blocks from the previous version whose
# peer mappings closed after our last collect (belt-and-braces with the
# collect in this method's finally block).
if self._pending_ipc_export is not None:
raise RuntimeError(
"Previous CUDA IPC export is still awaiting reader acknowledgement"
)

# Reclaim any legacy IPC-exported blocks from a previous version.
torch.cuda.ipc_collect()

# Free optimizer states + grad buffers BEFORE reloading the train
Expand Down Expand Up @@ -430,6 +436,8 @@ def execute_colocate_weight_update(self, version: int) -> None:
release_tensors(owned)
del tensors, owned
parameters.clear()
gc.collect()
torch.cuda.empty_cache()

self._residency.release_memory(tags=["weights"])

Expand All @@ -446,6 +454,9 @@ def execute_colocate_weight_update(self, version: int) -> None:
)

group_shared = [t.share_memory_() for t in group_tensors]
# Retain producer storage until the reader acknowledges completion.
# On failure or timeout the reader may still hold an IPC mapping.
self._pending_ipc_export = (group_tensors, group_shared)
serialized_weights = cuda_ipc_serialize((group_shared, metadata, names))
torch.cuda.synchronize()
logger.info("CUDA IPC serialization complete, putting to MetaServer")
Expand Down Expand Up @@ -493,20 +504,28 @@ def execute_colocate_weight_update(self, version: int) -> None:
self._meta_server_client.delete_if_exists(serialized_weights_key)
logger.info("Got done signal from infer side: %s", update_finished_key)
finally:
release_tensors(group_tensors)
release_tensors(group_shared)
del group_tensors, group_shared
torch.cuda.synchronize()
gc.collect()
# Storages exported via cudaIpcGetMemHandle park in PyTorch's
# CudaIPCSentDataLimbo when freed and are NOT returned to the
# allocator until ipc_collect() confirms the peer closed its
# mapping. GiB-scale group tensors are exported every version;
# without collection the train-process residual grows by GBs per
# version, eating the colocated rollout's prefill headroom until
# its logits all-gather OOMs.
torch.cuda.ipc_collect()
torch.cuda.empty_cache()

# AWEX drops imported tensor references and synchronizes before ACK.
# Release both retained and local references before write_finished,
# freeing packed-weight memory before the reader's remaining lifecycle.
allocated_before = torch.cuda.memory_allocated()
reserved_before = torch.cuda.memory_reserved()
self._pending_ipc_export = None
del group_tensors, group_shared
gc.collect()
torch.cuda.ipc_collect()
torch.cuda.empty_cache()
logger.info(
"CUDA IPC cleanup after reader acknowledgement: rank=%d device=%d "
"allocated=%.3f->%.3f GiB reserved=%.3f->%.3f GiB",
dist.get_rank(self._engine.cpu_group),
torch.cuda.current_device(),
allocated_before / 1024**3,
torch.cuda.memory_allocated() / 1024**3,
reserved_before / 1024**3,
torch.cuda.memory_reserved() / 1024**3,
)

write_finished_key = f"write_finished{key_suffix}"
self._meta_server_client.put_object(write_finished_key, True)
Expand Down Expand Up @@ -579,6 +598,13 @@ def _convert_parameters(self) -> dict[str, torch.Tensor]:

# ── Memory management (manual offload) ────────────────────────────────

def checkpoint_residency(self, *, with_model: bool, with_optimizer: bool):
"""Temporarily restore only resources required by a checkpoint."""
return self._residency.checkpoint_residency(
with_model=with_model,
with_optimizer=with_optimizer,
)

def release_memory(self, tags: list[str] | None = None) -> None:
"""Compatibility delegate for callers of the former combined adapter."""
self._residency.release_memory(tags)
Expand Down
23 changes: 17 additions & 6 deletions areal/engine/awex/sglang_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,18 +168,20 @@ def _resolve_transfer_rank(
node_id: int,
nnodes: int,
instance_world_size: int,
server_base_gpu_id: int = 0,
) -> int:
"""Resolve the inference rank in AWEX's global transfer world.

A one-GPU SGLang server can use the colocated actor's inherited global
rank when CUDA device isolation remaps its only GPU to device zero. For a
multi-GPU server, every TP/PP scheduler inherits the same environment, so
its scheduler-local physical GPU identity must be used instead.
multi-GPU server, every TP/PP scheduler inherits the same environment.
Subtract the server's logical base GPU before adding the dense transfer base.
"""
explicit_rank = os.environ.get("AWEX_TRANSFER_RANK")
if explicit_rank is not None:
transfer_rank = int(explicit_rank)
else:
explicit_base = os.environ.get("AWEX_TRANSFER_RANK_BASE")
env_rank = os.environ.get("RANK")
env_world_size = os.environ.get("WORLD_SIZE")
if (
Expand All @@ -189,6 +191,12 @@ def _resolve_transfer_rank(
and int(env_world_size) == infer_world_size
):
transfer_rank = int(env_rank)
elif explicit_base is not None:
n_gpus_per_node = max(1, infer_world_size // nnodes)
local_gpu_rank = gpu_id - server_base_gpu_id
transfer_rank = (
node_id * n_gpus_per_node + int(explicit_base) + local_gpu_rank
)
else:
n_gpus_per_node = max(1, infer_world_size // nnodes)
transfer_rank = node_id * n_gpus_per_node + gpu_id
Expand Down Expand Up @@ -973,10 +981,10 @@ def _init_receiver_from_meta_server(self, meta_server_addr: str):

receiver = self._require_receiver()

# `physical_gpu_id` is node-local. Multi-node colocate needs a globally
# unique transfer rank that stays physically paired with the training
# process. SGLang may run with logical gpu_id=0 under CUDA_VISIBLE_DEVICES
# isolation, so do not use scheduler.gpu_id for AWEX keys.
# Transfer ranks are dense process coordinates, while physical GPU IDs
# are only used for device pairing and MetaServer keys. The launcher
# supplies the server's dense node-local base when GPU visibility is
# isolated or non-contiguous.
gpu_id = self._logical_gpu_id()
physical_gpu_id = self._physical_gpu_id()
node_id = int(os.environ.get("SLURM_NODEID", "0"))
Expand Down Expand Up @@ -1010,6 +1018,9 @@ def _init_receiver_from_meta_server(self, meta_server_addr: str):
node_id=node_id,
nnodes=nnodes,
instance_world_size=self._instance_world_size(),
server_base_gpu_id=self._int_attr(
self._scheduler.server_args, "base_gpu_id", 0
),
)

logger.info(
Expand Down
Loading
Loading