Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
25 changes: 25 additions & 0 deletions areal/api/cli_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -2341,6 +2341,25 @@ class SessionTracerConfig:
)


@dataclass
class MemoryProfilerConfig:
"""CUDA memory snapshot profiling configuration.

Attributes:
profile_steps: Steps at which to record memory snapshots.
max_entries: Max entries for torch.cuda.memory._record_memory_history.
"""

profile_steps: list[int] = field(
default_factory=lambda: [0, 1],
metadata={"help": "List of global steps to capture memory snapshots."},
)
max_entries: int = field(
default=100000,
metadata={"help": "Max entries for memory history ring buffer."},
)


@dataclass
class PerfTracerConfig:
"""Configuration for perf tracer emission."""
Expand Down Expand Up @@ -2607,6 +2626,12 @@ class BaseExperimentConfig:
default=None,
metadata={"help": "Performance tracer configuration. None means disabled."},
)
memory_profiler: MemoryProfilerConfig | None = field(
default=None,
metadata={
"help": "Memory snapshot profiler configuration. None means disabled."
},
)
recover: RecoverConfig = field(default_factory=RecoverConfig)

sglang: SGLangConfig = field(default_factory=SGLangConfig)
Expand Down
6 changes: 6 additions & 0 deletions areal/api/engine_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,12 @@ def offload(self) -> None:
def get_device_stats(self) -> DeviceRuntimeInfo:
raise NotImplementedError()

def start_memory_profile(self, max_entries: int = 100000) -> None:
pass

def stop_memory_profile(self, snapshot_dir: str) -> None:
pass

def save_perf_tracer(self, step: int | None = None, force: bool = False) -> None:
"""Save performance tracer data.

Expand Down
6 changes: 4 additions & 2 deletions areal/dataset/hhrlhf.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ def process(sample):
if max_length is not None:
# Filter out sequences longer than max_length
dataset = dataset.filter(
lambda x: (len(x["chosen_ids"]) <= max_length)
and (len(x["rejected_ids"]) <= max_length)
lambda x: (
(len(x["chosen_ids"]) <= max_length)
and (len(x["rejected_ids"]) <= max_length)
)
)

return dataset
69 changes: 59 additions & 10 deletions areal/engine/megatron_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
from areal.engine.megatron_utils.megatron_lora import get_vllm_lora_target_modules
from areal.engine.megatron_utils.packed_context_parallel import (
packed_context_parallel_forward,
split_packed_seqs_for_context_parallel,
)
from areal.engine.megatron_utils.pipeline_parallel import (
configure_pipeline_layer_splits,
Expand Down Expand Up @@ -713,7 +714,14 @@ def forward_step(batch_iter, model):
mb_input.padded_mb.update(tree_kwargs)
tree_attn_keys = list(tree_kwargs.keys())

output = packed_context_parallel_forward(model, mb_input.padded_mb)
cp_size = mpu.get_context_parallel_world_size()
cp_local = cp_size > 1

output = packed_context_parallel_forward(
model,
mb_input.padded_mb,
gather_cp_output=not cp_local,
)

# Release tree attention metadata after forward pass
for key in tree_attn_keys:
Expand All @@ -729,12 +737,30 @@ def _process_output(input_, output_):
if mpu.is_pipeline_last_stage(
ignore_virtual=False, vp_stage=model_vp_stage
):
output = unpad_logits(
output,
padding_length=mb_input.padding_length,
cu_seqlens=cu_seqlens,
old_cu_seqlens=mb_input.old_cu_seqlens,
)
if cp_local and cu_seqlens is not None:
padded_cu_seqlens = mb_input.padded_mb["cu_seqlens"]
rolled_ids = torch.roll(
mb_input.padded_mb["input_ids"], shifts=-1, dims=-1
)
cp_labels = split_packed_seqs_for_context_parallel(
rolled_ids, padded_cu_seqlens
)
cp_loss_mask = split_packed_seqs_for_context_parallel(
mb_input.padded_mb["loss_mask"], padded_cu_seqlens
)
cp_cu_seqlens = padded_cu_seqlens // cp_size
cp_inputs = dict(mb_input.orig_mb)
cp_inputs["_cp_local_labels"] = cp_labels
cp_inputs["loss_mask"] = cp_loss_mask
cp_inputs["cu_seqlens"] = cp_cu_seqlens
Comment on lines +752 to +755

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the CP-local branch, cp_inputs = dict(mb_input.orig_mb) keeps the unsplit original microbatch tensors (and possibly unpadded lengths) but only replaces loss_mask/cu_seqlens and injects _cp_local_labels. Any other packed, token-aligned fields in orig_mb (e.g., PPO/RW training targets) will have mismatched shapes relative to the CP-sharded output, leading to incorrect loss computation or runtime shape errors. Consider building cp_inputs from padded_mb and splitting every token-aligned tensor with split_packed_seqs_for_context_parallel, or restrict this path to the SFT loss that only consumes labels/loss_mask.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is by design. cp_inputs = dict(mb_input.orig_mb) creates a shallow copy of the original microbatch dict, then we overwrite the fields that need CP-local values (_cp_local_labels, loss_mask, cu_seqlens). The SFT loss function (compute_packed_sft_loss) only consumes these replaced fields — it does not use any of the original unsplit tensors. The remaining fields in orig_mb (like metadata) are harmlessly carried through.

return output, functools.partial(_process_output, cp_inputs)
else:
output = unpad_logits(
output,
padding_length=mb_input.padding_length,
cu_seqlens=cu_seqlens,
old_cu_seqlens=mb_input.old_cu_seqlens,
)
return output, functools.partial(_process_output, mb_input.orig_mb)

forward_backward_func = get_forward_backward_func()
Expand Down Expand Up @@ -789,7 +815,11 @@ def process_output(
loss_multiplier=loss_multiplier,
)

self.forward_backward_batch(mb_list, process_output, forward_only=False)
self.forward_backward_batch(
mb_list,
process_output,
forward_only=False,
)
Comment on lines +818 to +822

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cp_local_loss is force-enabled whenever context_parallel_world_size() > 1. However, the CP-local path only rolls/splits input_ids (labels) and loss_mask/cu_seqlens, and it also mutates semantics by rolling loss_mask. This will break non-SFT training paths (e.g., PPO/RW) whose loss functions expect additional per-token tensors in inputs (advantages/old_logprobs/returns/etc.) and an un-rolled loss_mask. Make CP-local loss opt-in (plumb a config/flag down to train_batch/eval_batch) or generalize the CP-local input rewriting to split all token-aligned tensors without changing their semantics for other algorithms.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not a concern — train_batch and eval_batch (which call forward_backward_batch) are only used by the SFT path. The RL training path uses different methods (forward_backward_func directly via Megatron pipeline). So the CP-local logic is scoped correctly to SFT only.

In the latest commit, cp_local_loss has been removed as a parameter entirely — the engine now infers CP-local mode internally from mpu.get_context_parallel_world_size() > 1.


# Step 4: Optimizer step
return self.optimizer_step()
Expand Down Expand Up @@ -829,7 +859,9 @@ def process_output(

# Step 4: Aggregate losses
if mpu.is_pipeline_last_stage():
return aggregate_eval_losses(losses, mpu.get_data_parallel_group())
return aggregate_eval_losses(
losses, mpu.get_data_parallel_group(with_context_parallel=True)
)
return None

@torch.no_grad()
Expand Down Expand Up @@ -1029,6 +1061,19 @@ def _validate_fp8_consistency(self):
def get_device_stats(self) -> DeviceRuntimeInfo:
return DeviceRuntimeInfo.get_current()

def start_memory_profile(self, max_entries: int = 100000) -> None:
torch.cuda.memory._record_memory_history(max_entries=max_entries)

def stop_memory_profile(self, snapshot_dir: str) -> None:
pp = mpu.get_pipeline_model_parallel_rank()
dp = mpu.get_data_parallel_rank()
cp = mpu.get_context_parallel_rank()
tp = mpu.get_tensor_model_parallel_rank()
filename = f"snapshot_rank{self.rank:02d}_p{pp}d{dp}c{cp}t{tp}.pickle"
path = os.path.join(snapshot_dir, filename)
torch.cuda.memory._dump_snapshot(path)
torch.cuda.memory._record_memory_history(enabled=None)

def save_perf_tracer(self, step: int | None = None, force: bool = False) -> None:
perf_tracer.save(step=step, force=force)

Expand Down Expand Up @@ -1741,7 +1786,11 @@ def _compute_logprobs_and_loss(
else None,
)
else:
labels = torch.roll(inputs["input_ids"], shifts=-1, dims=-1)
cp_local_labels = inputs.get("_cp_local_labels")
if cp_local_labels is not None:
labels = cp_local_labels
else:
labels = torch.roll(inputs["input_ids"], shifts=-1, dims=-1)
logprobs, entropy = gather_logprobs_entropy(
output,
labels,
Expand Down
44 changes: 43 additions & 1 deletion areal/engine/megatron_utils/packed_context_parallel.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,47 @@ def preprocess_packed_seqs_context_parallel(
return splitted.unsqueeze(0), packed_seq_params


def split_packed_seqs_for_context_parallel(
tensor: torch.Tensor,
cu_seqlens: torch.Tensor,
) -> torch.Tensor:
"""Split a 1D packed tensor using the same interleaved pattern as
preprocess_packed_seqs_context_parallel."""
cp_size = mpu.get_context_parallel_world_size()
cp_rank = mpu.get_context_parallel_rank()
if cp_size <= 1:
return tensor

input_lens = cu_seqlens[1:] - cu_seqlens[:-1]
batch_size = input_lens.shape[0]
output_len = input_lens.sum().item() // cp_size

splitted = torch.zeros(output_len, dtype=tensor.dtype, device=tensor.device)
for i in range(batch_size):
seqlen = input_lens[i] // cp_size
half_seqlen = seqlen // 2
start_idx = cu_seqlens[i] // cp_size

d = tensor[cu_seqlens[i] : cu_seqlens[i + 1]]
splitted[start_idx : start_idx + half_seqlen] = d[
half_seqlen * cp_rank : half_seqlen * (cp_rank + 1)
]

remain_start = input_lens[i] - half_seqlen * (cp_rank + 1)
remain_end = input_lens[i] - half_seqlen * cp_rank
remain_end = min(remain_end, d.shape[0])
remain_len = remain_end - remain_start
splitted[start_idx + half_seqlen : start_idx + half_seqlen + remain_len] = d[
remain_start:remain_end
]
return splitted


def postprocess_packed_seqs_context_parallel(
output: torch.Tensor,
cu_seqlens: torch.Tensor | None,
post_process: bool,
gather_output: bool = True,
) -> torch.Tensor:
"""
Postprocess packed sequences
Expand All @@ -81,6 +118,10 @@ def postprocess_packed_seqs_context_parallel(
return output
if cp_size <= 1 or cu_seqlens is None:
return output.squeeze(0)

if not gather_output:
return output.squeeze(0)

# shape = [batch_size, seq_len] + list(output.shape[2:])
# [1, packed, dim] -> [batch_size, seq_len, dim]
batch_size = cu_seqlens.shape[0] - 1
Expand Down Expand Up @@ -125,6 +166,7 @@ def postprocess_packed_seqs_context_parallel(
def packed_context_parallel_forward(
model: torch.nn.Module,
input_: dict[str, Any],
gather_cp_output: bool = True,
):
input_ids = input_["input_ids"]
position_ids = input_["position_ids"]
Expand Down Expand Up @@ -170,6 +212,6 @@ def packed_context_parallel_forward(
ignore_virtual=False, vp_stage=model_vp_stage
)
output = postprocess_packed_seqs_context_parallel(
output, cu_seqlens, is_pipeline_last_stage
output, cu_seqlens, is_pipeline_last_stage, gather_output=gather_cp_output
)
return output
6 changes: 6 additions & 0 deletions areal/infra/controller/train_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,12 @@ def onload(self) -> None:
def get_device_stats(self):
return self._custom_function_call("get_device_stats")

def start_memory_profile(self, max_entries: int = 100000):
return self._custom_function_call("start_memory_profile", max_entries)

def stop_memory_profile(self, snapshot_dir: str):
return self._custom_function_call("stop_memory_profile", snapshot_dir)

def config_perf_tracer(self, config: PerfTracerConfig, role: str) -> None:
async def _call():
tasks = [
Expand Down
19 changes: 18 additions & 1 deletion areal/trainer/rl_trainer.py
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,12 @@ def train(
# Wait for async checkpoint staging to complete before modifying parameters
self.saver.maybe_wait_for_staging()

if (
config.memory_profiler is not None
and global_step in config.memory_profiler.profile_steps
):
self.actor.start_memory_profile(config.memory_profiler.max_entries)

with (
stats_tracker.record_timing("train_step"),
perf_tracer.trace_scope(
Expand All @@ -665,7 +671,18 @@ def train(
self.actor.ppo_update(adv_batch)
self.actor.step_lr_scheduler()
self.actor.get_device_stats().log("ppo update")
# Actor stays onloaded through paused window below

if (
config.memory_profiler is not None
and global_step in config.memory_profiler.profile_steps
):
log_dir = StatsLogger.get_log_path(config.stats_logger)
snapshot_dir = os.path.join(
log_dir, "memory_snapshots", f"step_{global_step}"
)
os.makedirs(snapshot_dir, exist_ok=True)
self.actor.stop_memory_profile(snapshot_dir)
logger.info(f"Memory snapshots saved to {snapshot_dir}")

if self.critic is not None:
with (
Expand Down
18 changes: 18 additions & 0 deletions areal/trainer/rw_trainer.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,12 @@ def train(self):
# Wait for async checkpoint staging to complete before modifying parameters
self.saver.maybe_wait_for_staging()

if (
config.memory_profiler is not None
and global_step in config.memory_profiler.profile_steps
):
self.actor.start_memory_profile(config.memory_profiler.max_entries)

with (
stats_tracker.record_timing("train_step"),
perf_tracer.trace_scope(
Expand All @@ -226,6 +232,18 @@ def train(self):
self.actor.step_lr_scheduler()
self.actor.get_device_stats().log("after train step")

if (
config.memory_profiler is not None
and global_step in config.memory_profiler.profile_steps
):
log_dir = StatsLogger.get_log_path(config.stats_logger)
snapshot_dir = os.path.join(
log_dir, "memory_snapshots", f"step_{global_step}"
)
os.makedirs(snapshot_dir, exist_ok=True)
self.actor.stop_memory_profile(snapshot_dir)
logger.info(f"Memory snapshots saved to {snapshot_dir}")

self.actor.set_version(global_step + 1)

with (
Expand Down
5 changes: 3 additions & 2 deletions areal/trainer/sft/lm_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ def train_lm(self, data: list[dict[str, Any]]) -> None:

def _train_lm(self, data: dict[str, Any]) -> None:
self.engine.train()
data["loss_mask"] = torch.roll(data["loss_mask"].bool(), shifts=-1, dims=-1)
stats = self.engine.train_batch(
input_=data,
loss_fn=compute_packed_sft_loss,
Expand All @@ -40,6 +41,7 @@ def evaluate_lm(self, data: list[dict[str, Any]]) -> None:

def _evaluate_lm(self, data: dict[str, Any]) -> None:
self.engine.eval()
data["loss_mask"] = torch.roll(data["loss_mask"].bool(), shifts=-1, dims=-1)
self.engine.eval_batch(
input_=data,
loss_fn=compute_packed_sft_loss,
Expand Down Expand Up @@ -88,11 +90,10 @@ def compute_packed_sft_loss(
cu_seqlens: torch.Tensor = input_["cu_seqlens"]
loss_mask = input_["loss_mask"].bool()

loss_mask = torch.roll(loss_mask, shifts=-1, dims=-1)
logprobs = torch.where(loss_mask, logprobs, 0)

device = logprobs.device
loss = -logprobs.sum() / loss_mask.count_nonzero()
Comment thread
yulangz marked this conversation as resolved.
loss = -logprobs.sum() / (1e-5 + loss_mask.count_nonzero())
with torch.no_grad():
batch_size = cu_seqlens.shape[0] - 1
seqlogp = torch.zeros(batch_size, dtype=torch.float64, device=device)
Expand Down
18 changes: 18 additions & 0 deletions areal/trainer/sft_trainer.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,12 @@ def train(self):

self.saver.maybe_wait_for_staging()

if (
config.memory_profiler is not None
and global_step in config.memory_profiler.profile_steps
):
self.actor.start_memory_profile(config.memory_profiler.max_entries)

Comment on lines +183 to +188

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SFTTrainer unconditionally calls self.actor.start_memory_profile(...) / stop_memory_profile(...) when the config is set, but the v1 controller (LMController) currently does not define these methods (only LMControllerV2 does). This will raise AttributeError for non-v2 runs. Either add these methods to LMController as well (dispatch via _custom_function_call with rpc_meta={"broadcast": True}), or guard these calls based on controller version/capability.

Copilot uses AI. Check for mistakes.
with (
stats_tracker.record_timing("train_step"),
perf_tracer.trace_scope(
Expand All @@ -192,6 +198,18 @@ def train(self):
self.actor.step_lr_scheduler()
self.actor.get_device_stats().log("after train step")

if (
config.memory_profiler is not None
and global_step in config.memory_profiler.profile_steps
):
log_dir = StatsLogger.get_log_path(config.stats_logger)
snapshot_dir = os.path.join(
log_dir, "memory_snapshots", f"step_{global_step}"
)
os.makedirs(snapshot_dir, exist_ok=True)
self.actor.stop_memory_profile(snapshot_dir)
logger.info(f"Memory snapshots saved to {snapshot_dir}")

self.actor.set_version(global_step + 1)

with (
Expand Down
Loading
Loading