Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
8659b57
empty
fzyzcjy Apr 6, 2026
e23d43d
abs import
fzyzcjy Apr 6, 2026
d3fc26e
mechanically move
fzyzcjy Apr 6, 2026
981710b
import
fzyzcjy Apr 6, 2026
3943b37
fmt
fzyzcjy Apr 6, 2026
ade062d
mv and rename
fzyzcjy Apr 6, 2026
6302d20
extract
fzyzcjy Apr 6, 2026
fb84c33
more
fzyzcjy Apr 6, 2026
6a06221
fmt
fzyzcjy Apr 6, 2026
f0bdf5a
more
fzyzcjy Apr 6, 2026
5e03bbe
extract
fzyzcjy Apr 6, 2026
e14676e
more
fzyzcjy Apr 6, 2026
8ee59cd
fmt
fzyzcjy Apr 6, 2026
6884a2e
add mechanical-refactor-verify skill
fzyzcjy Apr 6, 2026
6352440
keep worktree after verification for inspection
fzyzcjy Apr 6, 2026
6313761
extract scaffold into utils.py, transform script only defines transfo…
fzyzcjy Apr 6, 2026
a07fe93
rename utils.py to mechanical_refactor_verify_utils.py
fzyzcjy Apr 6, 2026
3ea792a
fix py3.10 compat: type alias and Optional syntax
fzyzcjy Apr 6, 2026
55d8d03
simplify docstring: point to SKILL.md for usage
fzyzcjy Apr 6, 2026
3666739
rename _run to exec_command
fzyzcjy Apr 6, 2026
167d5f3
replace MechanicalVerifier class with verify_mechanical_refactor func…
fzyzcjy Apr 6, 2026
704d0fe
remove RunFn, transform calls exec_command directly
fzyzcjy Apr 6, 2026
b0406a0
add git_add_and_commit helper
fzyzcjy Apr 6, 2026
f581104
move ruff format step into verify_mechanical_refactor
fzyzcjy Apr 6, 2026
85ba9a6
rename root to dir_root, remove run param from transform
fzyzcjy Apr 6, 2026
40d6eef
add gist update command, one gist per PR
fzyzcjy Apr 6, 2026
597749e
clarify PR scope: mechanical only, semantic changes go to separate PR
fzyzcjy Apr 6, 2026
2010083
remove relationship to existing refactor workflows section
fzyzcjy Apr 6, 2026
f5bf690
cp
fzyzcjy Apr 6, 2026
118423b
Merge branch 'rollout_ft/0' into rollout_ft/1
fzyzcjy Apr 6, 2026
6df2223
add mechanical refactor transform script for rollout.py split
fzyzcjy Apr 6, 2026
079635f
Revert "add mechanical refactor transform script for rollout.py split"
fzyzcjy Apr 6, 2026
40355dc
clarify: write transform to /tmp, delete after gist upload, never wri…
fzyzcjy Apr 6, 2026
73f4e6b
make verify_mechanical_refactor usage mandatory, forbid hand-rolled s…
fzyzcjy Apr 6, 2026
f9c13ac
add dedent() util, fix shlex.quote in git_add_and_commit
fzyzcjy Apr 6, 2026
ec850e5
remove __pycache__ from tracking
fzyzcjy Apr 6, 2026
f8a9af5
fix py3.10 compat: Optional[str] and add missing import
fzyzcjy Apr 6, 2026
df42bc4
more
fzyzcjy Apr 6, 2026
8d545e1
cp
fzyzcjy Apr 6, 2026
a3fe2f6
Merge branch 'rollout_ft/0' into rollout_ft/1
fzyzcjy Apr 6, 2026
21e7532
use pre-commit instead of ruff format in verify scaffold
fzyzcjy Apr 6, 2026
a352b0c
remove __pycache__
fzyzcjy Apr 6, 2026
3f32106
fmt
fzyzcjy Apr 6, 2026
ee35f3a
cp
fzyzcjy Apr 6, 2026
3d594ee
Merge branch 'rollout_ft/0' into rollout_ft/1
fzyzcjy Apr 6, 2026
b42aa4e
skip pre-commit commit when no files changed
fzyzcjy Apr 6, 2026
1fb7e93
skip pre-commit commit when no files changed
fzyzcjy Apr 6, 2026
badc784
Merge branch 'rollout_ft/0' into rollout_ft/1
fzyzcjy Apr 6, 2026
9a5e5b1
Merge main into rollout_ft/1 (cascade start)
fzyzcjy May 5, 2026
8128e97
Merge main into rollout_ft/1
fzyzcjy May 6, 2026
1ea7392
Merge origin/main into rollout_ft/1
fzyzcjy May 7, 2026
2eb8521
Merge main into rollout_ft/1
fzyzcjy May 29, 2026
17b38a8
Apply pre-commit fixes (ruff, black) to scripts/run_qwen3_4b_npu.py
fzyzcjy May 29, 2026
05444e8
Merge main into rollout_ft/1
fzyzcjy May 30, 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
1,329 changes: 0 additions & 1,329 deletions miles/ray/rollout.py

This file was deleted.

Empty file added miles/ray/rollout/__init__.py
Empty file.
109 changes: 109 additions & 0 deletions miles/ray/rollout/addr_allocator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import logging

import ray

logger = logging.getLogger(__name__)


def allocate_rollout_engine_addr_and_ports_normal(
*,
args,
rollout_engines,
worker_type="regular",
num_gpus_per_engine=None,
rank_offset=0,
base_port=15000,
):
# get ports
# there are 4 ports we need to allocate
# 1. server port
# 2. nccl port
# 3. dist_init_addr port
# 4. other ports for dp_attention, which is of size 4 + dp_size
_gpus_per_engine = num_gpus_per_engine or args.rollout_num_gpus_per_engine
num_engines_per_node = max(1, args.num_gpus_per_node // _gpus_per_engine)
addr_and_ports: dict[int, dict] = {}

# Track per-node port cursors so that different server groups (called
# sequentially) never race for the same ports on a given node.
node_port_cursor: dict[int, int] = {}

visited_nodes = set()
for rank, engine in rollout_engines:
local_rank = rank - rank_offset
node_index = local_rank // num_engines_per_node
if node_index in visited_nodes:
continue
visited_nodes.add(node_index)
# TODO: currently when restarting engines, we will set port for all engines on this node starting with this rank.
# e.g. for 8 gpus, if we are restarting engine on gpu 3, we will set port for engine 3,4,5,6,7 on this node.
num_engines_on_this_node = num_engines_per_node - (local_rank % num_engines_per_node)

def get_addr_and_ports(engine, node_idx):
# use small ports to prevent ephemeral port between 32768 and 65536.
# also, ray uses port 10002-19999, thus we avoid near-10002 to avoid racing condition
start_port = node_port_cursor.get(node_idx, base_port)

def port(consecutive=1):
nonlocal start_port
_, port = ray.get(
engine._get_current_node_ip_and_free_port.remote(
start_port=start_port,
consecutive=consecutive,
)
)
Comment on lines +49 to +54

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

The current implementation of port allocation is inefficient because it performs multiple synchronous ray.get calls within a loop for each engine (see calls to get_port() on lines 71-74 and 82). Each ray.get call incurs a round-trip overhead. It is recommended to batch the port requests by calculating the total number of ports needed per engine and making a single call to _get_current_node_ip_and_free_port.remote(consecutive=N).

start_port = port + consecutive
node_port_cursor[node_idx] = start_port
return port

def addr():
addr, _ = ray.get(engine._get_current_node_ip_and_free_port.remote())
return addr

return addr, port

get_addr, get_port = get_addr_and_ports(engine, node_index)

for i in range(num_engines_on_this_node):
current_rank = rank + i
addr_and_ports.setdefault(current_rank, {})
addr_and_ports[current_rank]["host"] = get_addr()
addr_and_ports[current_rank]["port"] = get_port()
addr_and_ports[current_rank]["nccl_port"] = get_port()
# Always allocate a unique engine_info_bootstrap_port per engine
addr_and_ports[current_rank]["engine_info_bootstrap_port"] = get_port()

if worker_type == "prefill":
addr_and_ports[current_rank]["disaggregation_bootstrap_port"] = get_port()

if _gpus_per_engine > args.num_gpus_per_node:
num_node_per_engine = _gpus_per_engine // args.num_gpus_per_node
if local_rank % num_node_per_engine == 0:
dist_init_addr = f"{get_addr()}:{get_port(30 + args.sglang_dp_size)}"
for i in range(num_node_per_engine):
addr_and_ports.setdefault(rank + i, {})
addr_and_ports[rank + i]["dist_init_addr"] = dist_init_addr
else:
for i in range(num_engines_on_this_node):
addr_and_ports[rank + i]["dist_init_addr"] = f"{get_addr()}:{get_port(30 + args.sglang_dp_size)}"

for i, _ in rollout_engines:
for key in ["port", "nccl_port", "dist_init_addr"]:
assert key in addr_and_ports[i], f"Engine {i} {key} is not set."
logger.info(f"Ports for engine {i}: {addr_and_ports[i]}")

return addr_and_ports, node_port_cursor


def allocate_rollout_engine_addr_and_ports_external(args, rollout_engines):
addr_and_ports = {}
for rank, _ in rollout_engines:
addr = args.rollout_external_engine_addrs[rank]
[host, port] = addr.split(":")
addr_and_ports[rank] = dict(
dist_init_addr=addr,
nccl_port=None,
host=host,
port=int(port),
)
return addr_and_ports
30 changes: 30 additions & 0 deletions miles/ray/rollout/debug_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import logging
from pathlib import Path

import torch

logger = logging.getLogger(__name__)


# TODO extract `load_debug_rollout_data`


# TODO: remove `self`
def save_debug_rollout_data(self, data, rollout_id, evaluation: bool):
# TODO to be refactored (originally Buffer._set_data)
if (path_template := self.args.save_debug_rollout_data) is not None:
path = Path(path_template.format(rollout_id=("eval_" if evaluation else "") + str(rollout_id)))
logger.info(f"Save debug rollout data to {path}")
path.parent.mkdir(parents=True, exist_ok=True)

# TODO may improve the format
if evaluation:
dump_data = dict(
samples=[sample.to_dict() for dataset_name, info in data.items() for sample in info["samples"]]
)
else:
dump_data = dict(
samples=[sample.to_dict() for sample in data],
)

torch.save(dict(rollout_id=rollout_id, **dump_data), path)
Comment on lines +13 to +30

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

The function save_debug_rollout_data is defined as a standalone utility but still takes self as an argument, which creates unnecessary coupling with the RolloutManager class. Since it only uses self.args, it should be refactored to take args directly.

Suggested change
def save_debug_rollout_data(self, data, rollout_id, evaluation: bool):
# TODO to be refactored (originally Buffer._set_data)
if (path_template := self.args.save_debug_rollout_data) is not None:
path = Path(path_template.format(rollout_id=("eval_" if evaluation else "") + str(rollout_id)))
logger.info(f"Save debug rollout data to {path}")
path.parent.mkdir(parents=True, exist_ok=True)
# TODO may improve the format
if evaluation:
dump_data = dict(
samples=[sample.to_dict() for dataset_name, info in data.items() for sample in info["samples"]]
)
else:
dump_data = dict(
samples=[sample.to_dict() for sample in data],
)
torch.save(dict(rollout_id=rollout_id, **dump_data), path)
def save_debug_rollout_data(args, data, rollout_id, evaluation: bool):
# TODO to be refactored (originally Buffer._set_data)
if (path_template := args.save_debug_rollout_data) is not None:
path = Path(path_template.format(rollout_id=("eval_" if evaluation else "") + str(rollout_id)))
logger.info(f"Save debug rollout data to {path}")
path.parent.mkdir(parents=True, exist_ok=True)
# TODO may improve the format
if evaluation:
dump_data = dict(
samples=[sample.to_dict() for dataset_name, info in data.items() for sample in info["samples"]]
)
else:
dump_data = dict(
samples=[sample.to_dict() for sample in data],
)
torch.save(dict(rollout_id=rollout_id, **dump_data), path)

202 changes: 202 additions & 0 deletions miles/ray/rollout/metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import logging
from typing import Any

import numpy as np

from miles.utils import tracking_utils
from miles.utils.iter_utils import group_by
from miles.utils.metric_utils import (
compute_pass_rate,
compute_rollout_step,
compute_statistics,
dict_add_prefix,
has_repetition,
)
from miles.utils.misc import load_function
from miles.utils.types import Sample


logger = logging.getLogger(__name__)


def log_eval_rollout_data(rollout_id, args, data, extra_metrics: dict[str, Any] | None = None):
if args.custom_eval_rollout_log_function_path is not None:
custom_log_func = load_function(args.custom_eval_rollout_log_function_path)
if custom_log_func(rollout_id, args, data, extra_metrics):
return

log_dict = extra_metrics or {}
for key in data.keys():
rewards = data[key]["rewards"]
log_dict[f"eval/{key}"] = sum(rewards) / len(rewards)
if (samples := data[key].get("samples")) is not None:
log_dict |= dict_add_prefix(_compute_metrics_from_samples(args, samples), f"eval/{key}/")
if "truncated" in data[key]:
truncated = data[key]["truncated"]
log_dict[f"eval/{key}-truncated_ratio"] = sum(truncated) / len(truncated)
if args.log_passrate:
log_dict |= dict_add_prefix(
compute_pass_rate(
flat_rewards=rewards,
group_size=args.n_samples_per_eval_prompt,
),
f"eval/{key}-",
)

logger.info(f"eval {rollout_id}: {log_dict}")

step = compute_rollout_step(args, rollout_id)
log_dict["eval/step"] = step
tracking_utils.log(args, log_dict, step_key="eval/step")

return log_dict


def log_rollout_data(rollout_id, args, samples, rollout_extra_metrics, rollout_time):
if args.custom_rollout_log_function_path is not None:
custom_log_func = load_function(args.custom_rollout_log_function_path)
if custom_log_func(rollout_id, args, samples, rollout_extra_metrics, rollout_time):
return

if args.load_debug_rollout_data:
return

log_dict = {**(rollout_extra_metrics or {})}
log_dict |= dict_add_prefix(_compute_metrics_from_samples(args, samples), "rollout/")
log_dict |= dict_add_prefix(_compute_perf_metrics_from_samples(args, samples, rollout_time), "perf/")
logger.info(f"perf {rollout_id}: {log_dict}")
step = compute_rollout_step(args, rollout_id)
log_dict["rollout/step"] = step
tracking_utils.log(args, log_dict, step_key="rollout/step")


def _compute_metrics_from_samples(args, samples):
response_lengths = [sample.effective_response_length for sample in samples]

log_dict = {}
log_dict |= dict_add_prefix(compute_statistics(response_lengths), "response_len/")
log_dict |= _compute_zero_std_metrics(args, samples)
log_dict |= _compute_spec_metrics(args, samples)
log_dict |= _compute_prefix_cache_metrics(args, samples)
log_dict |= _compute_reward_cat_metrics(args, samples)
log_dict["repetition_frac"] = np.mean([int(has_repetition(s.response)) for s in samples]).item()
log_dict["truncated_ratio"] = np.mean([int(s.status == Sample.Status.TRUNCATED) for s in samples]).item()

oldest_versions = [s.oldest_weight_version for s in samples if s.oldest_weight_version is not None]
if oldest_versions:
log_dict |= dict_add_prefix(compute_statistics(oldest_versions), "weight_version/")
mixed = sum(1 for s in samples if len(set(s.weight_versions)) > 1)
log_dict["weight_version/mixed_version_ratio"] = mixed / len(samples)

tito_vals = [s.metadata.get("tito_session_mismatch") for s in samples]
tito_vals = [v for v in tito_vals if v is not None]
if tito_vals:
log_dict["tito_session_mismatch_rate"] = np.mean([len(v) > 0 for v in tito_vals]).item()
for mtype in ("special_token_count", "special_token_type", "non_assistant_text", "assistant_text"):
log_dict[f"tito_session_mismatch_rate/{mtype}"] = np.mean(
[any(m.get("type") == mtype for m in v) for v in tito_vals]
).item()
if args.ci_test:
for strict_type in ("special_token_count", "special_token_type", "non_assistant_text"):
rate = log_dict.get(f"tito_session_mismatch_rate/{strict_type}", 0)
assert rate == 0, (
f"tito_session_mismatch_rate/{strict_type}={rate:.4f} must be 0 — "
"this indicates a bug in the TITO algorithm or chat template. "
"Please check your tito model and chat template."
)
# assistant_text mismatch is non-critical: assistant tokens are inherited
# from the pretokenized prefix and may differ from canonical tokenization.

return log_dict


def _compute_perf_metrics_from_samples(args, samples, rollout_time):
non_generation_time = [sample.non_generation_time for sample in samples]

log_dict = {}
log_dict["rollout_time"] = rollout_time
if max(non_generation_time) > 0:
log_dict |= dict_add_prefix(compute_statistics(non_generation_time), "non_generation_time/")

def token_perf(response_lengths, non_generation_time, key=""):
max_response_length = max(response_lengths)
if args.rollout_num_gpus:
log_dict[f"{key}tokens_per_gpu_per_sec"] = sum(response_lengths) / rollout_time / args.rollout_num_gpus
log_dict[f"longest_{key}sample_tokens_per_sec"] = max_response_length / rollout_time

if max(non_generation_time) == 0:
return

non_generation_time = [
t for t, length in zip(non_generation_time, response_lengths, strict=True) if length == max_response_length
]
mean_non_generation_time = sum(non_generation_time) / len(non_generation_time)

log_dict[f"longest_{key}sample_non_generation_time"] = mean_non_generation_time
log_dict[f"longest_{key}sample_tokens_per_sec_without_non_generation"] = max_response_length / (
rollout_time - mean_non_generation_time
)

token_perf([sample.response_length for sample in samples], non_generation_time, key="")
token_perf([sample.effective_response_length for sample in samples], non_generation_time, key="effective_")

return log_dict


def _compute_zero_std_metrics(args, all_samples: list[Sample]):
# only compute in GRPO-like algorithms where one prompt has multiple responses
if args.advantage_estimator == "ppo":
return {}

def _is_zero_std(samples: list[Sample]):
rewards = [sample.get_reward_value(args) for sample in samples]
return len(rewards) == 0 or all(rewards[0] == r for r in rewards)

all_sample_groups = group_by(all_samples, lambda s: s.group_index)
interesting_sample_groups = [g for g in all_sample_groups.values() if _is_zero_std(g)]

interesting_rewards = [str(round(g[0].get_reward_value(args), 1)) for g in interesting_sample_groups]

counts = {reward: len(items) for reward, items in group_by(interesting_rewards).items()}
log_dict = {f"zero_std/count_{reward}": count for reward, count in counts.items()}

# Percentages over total groups, so "too hard" (all-0) and "too easy"
# (all-1) rates are comparable across runs without needing to know the
# rollout batch size.
total_groups = len(all_sample_groups)
if total_groups > 0:
log_dict["zero_std/all_zero_percentage"] = counts.get("0.0", 0) / total_groups
log_dict["zero_std/all_one_percentage"] = counts.get("1.0", 0) / total_groups

return log_dict


def _compute_spec_metrics(args, all_samples: list[Sample]):
if args.sglang_speculative_algorithm is None:
return {}
num_samples = len(all_samples)
metrics = {}
metrics["spec_accept_rate"] = sum(sample.spec_info.spec_accept_rate for sample in all_samples) / num_samples
metrics["spec_accept_length"] = sum(sample.spec_info.spec_accept_length for sample in all_samples) / num_samples
return metrics


def _compute_prefix_cache_metrics(args, all_samples: list[Sample]):
num_samples = len(all_samples)
metrics = {}
total_cached_tokens = sum(sample.prefix_cache_info.cached_tokens for sample in all_samples)
total_prompt_tokens = sum(sample.prefix_cache_info.total_prompt_tokens for sample in all_samples)

metrics["prefix_cache_hit_rate"] = total_cached_tokens / total_prompt_tokens if total_prompt_tokens > 0 else 0.0
metrics["avg_cached_tokens_per_sample"] = total_cached_tokens / num_samples
return metrics


def _compute_reward_cat_metrics(args, all_samples: list[Sample]):
reward_cat_key = args.log_reward_category
if reward_cat_key is None:
return {}

samples_of_reward_cat = group_by(all_samples, lambda s: s.reward[reward_cat_key])

return {f"error_cat/{reward_cat}": len(s) / len(all_samples) for reward_cat, s in samples_of_reward_cat.items()}
Loading
Loading