Skip to content
Draft
5 changes: 5 additions & 0 deletions skyrl/train/generators/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ class GeneratorOutput(TypedDict):
# trajectory in the input batch (i.e. per ``agent_loop`` call). Used by the fully
# async trainer to compute per-group / intra-group completion-time metrics.
trajectory_generation_times: Optional[List[float]]
# Split of ``trajectory_generation_times`` into engine-wait vs ``env.step()`` time (seconds),
# plus env construction/init time. None if any trajectory did not record it.
trajectory_llm_times: Optional[List[float]]
trajectory_env_times: Optional[List[float]]
trajectory_env_setup_times: Optional[List[float]]
rollout_expert_indices: Optional[List[List[List[List[int]]]]] # [batch_size, seq_len, layer_num, topk]
# Applicable only for step-wise training
is_last_step: Optional[List[bool]]
Expand Down
61 changes: 55 additions & 6 deletions skyrl/train/generators/skyrl_gym_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ class TrajectoryOutput:
# End-to-end wall-clock time (seconds) to generate this trajectory. Optional: agent loops may
# leave this as None if they do not track timing.
e2e_time: Optional[float] = None
# Wall-clock time (seconds) awaiting the inference engine, summed over turns. None if untracked.
llm_time: Optional[float] = None
# Wall-clock time (seconds) in ``env.step()``, summed over turns. None if untracked.
env_time: Optional[float] = None
# Wall-clock time (seconds) constructing and initializing the env. None if untracked.
env_setup_time: Optional[float] = None


@dataclass
Expand All @@ -66,6 +72,10 @@ class StepWiseOutput:
# End-to-end wall-clock time (seconds) to generate this trajectory. Optional: agent loops may
# leave this as None if they do not track timing.
e2e_time: Optional[float] = None
# Same split as TrajectoryOutput.llm_time / env_time / env_setup_time.
llm_time: Optional[float] = None
env_time: Optional[float] = None
env_setup_time: Optional[float] = None


@dataclass
Expand Down Expand Up @@ -140,6 +150,12 @@ def get_turn_rollout_logprobs(self) -> Optional[List[float]]:
return self.output_logprobs + [0.0] * len(self.obs_ids)


def _optional_times(outputs, attr: str) -> Optional[List[float]]:
"""One value per trajectory, or None if any trajectory did not record ``attr``."""
values = [getattr(o, attr, None) for o in outputs]
return None if any(v is None for v in values) else values


class SkyRLGymGenerator(GeneratorInterface):
def __init__(
self,
Expand Down Expand Up @@ -321,6 +337,10 @@ async def agent_loop(
rollout_logprobs: Optional[List[float]]
"""
agent_loop_start_time = time.monotonic()
# Engine wait vs. env.step() time, accumulated across turns. The gap to e2e_time is
# tokenization, chat templating, and event-loop scheduling.
llm_time_s = 0.0
env_time_s = 0.0

session_id = (
f"{trajectory_id.instance_id}_{trajectory_id.repetition_id}" if trajectory_id is not None else uuid4().hex
Expand All @@ -336,6 +356,7 @@ async def agent_loop(
env_extras = self._setup_env_extras(env_class, env_extras, sampling_params, trajectory_id)

env_config = getattr(self.skyrl_gym_cfg, env_class, dict())
env_setup_start_time = time.monotonic()
env = skyrl_gym.make(env_class, env_config=env_config, extras=env_extras)

# Instantiate chat_history and chat_end_index, which are only used if `retokenize_chat_history==True`.
Expand All @@ -344,6 +365,7 @@ async def agent_loop(

# init() returns the first prompt to be given to the model, and optional metadata dict
chat_history, _ = await self._run_in_executor_if_available(env.init, chat_history)
env_setup_time_s = time.monotonic() - env_setup_start_time
initial_chat_history_length = len(chat_history)
initial_input_ids = self.tokenizer.apply_chat_template(
chat_history,
Expand Down Expand Up @@ -409,7 +431,9 @@ async def agent_loop(
sampling_params=sampling_params,
cache_salt=cache_salt,
)
llm_call_start_time = time.monotonic()
engine_output = await self.inference_engine_client.generate(engine_input, model=self.policy_model_name)
llm_time_s += time.monotonic() - llm_call_start_time
output = engine_output["responses"][0]
output_ids = engine_output["response_ids"][0]
stop_reason = engine_output["stop_reasons"][0]
Expand Down Expand Up @@ -443,7 +467,9 @@ async def agent_loop(
added_eos = True

# 2. Environment step
env_step_start_time = time.monotonic()
env_step_output: BaseTextEnvStepOutput = await self._run_in_executor_if_available(env.step, output)
env_time_s += time.monotonic() - env_step_start_time
new_obs = env_step_output["observations"]
step_reward: float = env_step_output["reward"]
agent_loop_state.done = env_step_output["done"]
Expand Down Expand Up @@ -606,6 +632,9 @@ async def agent_loop(
trajectory_id,
)
agent_loop_output.e2e_time = time.monotonic() - agent_loop_start_time
agent_loop_output.llm_time = llm_time_s
agent_loop_output.env_time = env_time_s
agent_loop_output.env_setup_time = env_setup_time_s
return agent_loop_output

finally:
Expand Down Expand Up @@ -868,11 +897,10 @@ async def generate(self, input_batch: GeneratorInput, disable_tqdm: bool = False
disable=disable_tqdm,
)
# Per-trajectory end-to-end generation times (one entry per prompt, preserving input order).
# ``e2e_time`` is optional for agent loops; if any trajectory did not record it, we omit the
# field entirely rather than emit a partially-populated list.
trajectory_generation_times_per_prompt = [getattr(output, "e2e_time", None) for output in all_outputs]
if any(t is None for t in trajectory_generation_times_per_prompt):
trajectory_generation_times_per_prompt = None
trajectory_generation_times_per_prompt = _optional_times(all_outputs, "e2e_time")
trajectory_llm_times_per_prompt = _optional_times(all_outputs, "llm_time")
trajectory_env_times_per_prompt = _optional_times(all_outputs, "env_time")
trajectory_env_setup_times_per_prompt = _optional_times(all_outputs, "env_setup_time")

if self.generator_cfg.step_wise_trajectories:
responses = []
Expand All @@ -885,6 +913,9 @@ async def generate(self, input_batch: GeneratorInput, disable_tqdm: bool = False
out_trajectory_ids = []
out_env_classes = []
out_trajectory_generation_times = []
out_trajectory_llm_times = []
out_trajectory_env_times = []
out_trajectory_env_setup_times = []
for i, output in enumerate(all_outputs):
for j, step_output in enumerate(output.step_outputs):
responses.append(step_output.response_ids)
Expand All @@ -896,11 +927,20 @@ async def generate(self, input_batch: GeneratorInput, disable_tqdm: bool = False
is_last_step.append(j == len(output.step_outputs) - 1)
out_trajectory_ids.append(trajectory_ids[i])
out_env_classes.append(env_classes[i])
# For trajectory completion per turn we just use the trajectory level e2e time
# For trajectory completion per turn we just use the trajectory level times
out_trajectory_generation_times.append(getattr(output, "e2e_time", None))
out_trajectory_llm_times.append(getattr(output, "llm_time", None))
out_trajectory_env_times.append(getattr(output, "env_time", None))
out_trajectory_env_setup_times.append(getattr(output, "env_setup_time", None))
# Keep aligned with the per-prompt None handling:
if not trajectory_generation_times_per_prompt:
out_trajectory_generation_times = None
if not trajectory_llm_times_per_prompt:
out_trajectory_llm_times = None
if not trajectory_env_times_per_prompt:
out_trajectory_env_times = None
if not trajectory_env_setup_times_per_prompt:
out_trajectory_env_setup_times = None
env_classes = out_env_classes
else:
responses = [output.response_ids for output in all_outputs]
Expand All @@ -913,6 +953,9 @@ async def generate(self, input_batch: GeneratorInput, disable_tqdm: bool = False
out_trajectory_ids = None
# One time per trajectory, already aligned 1:1 with responses (None if not all recorded).
out_trajectory_generation_times = trajectory_generation_times_per_prompt
out_trajectory_llm_times = trajectory_llm_times_per_prompt
out_trajectory_env_times = trajectory_env_times_per_prompt
out_trajectory_env_setup_times = trajectory_env_setup_times_per_prompt

has_vision_features = any(getattr(output, "pixel_values", None) is not None for output in all_outputs)
pixel_values = (
Expand Down Expand Up @@ -953,6 +996,9 @@ async def generate(self, input_batch: GeneratorInput, disable_tqdm: bool = False
# NOTE: we only use trajectory completion times per prompt for
# metrics, to avoid duplicate entries with step-wise training
trajectory_completion_times=trajectory_generation_times_per_prompt,
trajectory_llm_times=trajectory_llm_times_per_prompt,
trajectory_env_times=trajectory_env_times_per_prompt,
trajectory_env_setup_times=trajectory_env_setup_times_per_prompt,
)

if self.generator_cfg.zero_reward_on_non_stop:
Expand All @@ -974,6 +1020,9 @@ async def generate(self, input_batch: GeneratorInput, disable_tqdm: bool = False
"trajectory_ids": out_trajectory_ids,
# NOTE: for completion metrics, we output the completion time
"trajectory_generation_times": out_trajectory_generation_times,
"trajectory_llm_times": out_trajectory_llm_times,
"trajectory_env_times": out_trajectory_env_times,
"trajectory_env_setup_times": out_trajectory_env_setup_times,
"rollout_expert_indices": rollout_expert_indices,
"is_last_step": is_last_step,
"env_metrics": env_metrics,
Expand Down
99 changes: 73 additions & 26 deletions skyrl/train/generators/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,20 @@ def _flatten_field(generator_outputs: List[GeneratorOutput], key: str) -> list:
return flat


def _concat_field(generator_outputs: List[GeneratorOutput], key: str) -> Optional[list]:
"""Flatten an optional per-trajectory field, keyed off the first output (None if absent)."""
if generator_outputs[0].get(key) is None:
return None
return _flatten_field(generator_outputs, key)


def _last_step_only(values: Optional[list], is_last_step: Optional[List[bool]]) -> Optional[list]:
"""Keep only last-step entries when step-wise, so one trajectory contributes one value."""
if values is None or not is_last_step:
return values
return [v for v, last in zip(values, is_last_step) if last]


def concatenate_generator_outputs(generator_outputs: List[GeneratorOutput], step_wise: bool = False) -> GeneratorOutput:
"""
Concatenate the generator outputs of multiple batches. Then validate the concatenated result.
Expand All @@ -264,17 +278,12 @@ def concatenate_generator_outputs(generator_outputs: List[GeneratorOutput], step
"response_ids": _flatten_field(generator_outputs, "response_ids"),
"rewards": _flatten_field(generator_outputs, "rewards"),
"loss_masks": _flatten_field(generator_outputs, "loss_masks"),
"stop_reasons": (
_flatten_field(generator_outputs, "stop_reasons") if first.get("stop_reasons") is not None else None
),
"rollout_logprobs": (
_flatten_field(generator_outputs, "rollout_logprobs") if first.get("rollout_logprobs") is not None else None
),
"trajectory_generation_times": (
_flatten_field(generator_outputs, "trajectory_generation_times")
if first.get("trajectory_generation_times") is not None
else None
),
"stop_reasons": _concat_field(generator_outputs, "stop_reasons"),
"rollout_logprobs": _concat_field(generator_outputs, "rollout_logprobs"),
"trajectory_generation_times": _concat_field(generator_outputs, "trajectory_generation_times"),
"trajectory_llm_times": _concat_field(generator_outputs, "trajectory_llm_times"),
"trajectory_env_times": _concat_field(generator_outputs, "trajectory_env_times"),
"trajectory_env_setup_times": _concat_field(generator_outputs, "trajectory_env_setup_times"),
}

# propagate additional keys with list values as-is
Expand All @@ -284,19 +293,22 @@ def concatenate_generator_outputs(generator_outputs: List[GeneratorOutput], step
for key in additional_keys:
result[key] = _flatten_field(generator_outputs, key)

# With step-wise training, only use the trajectory generation time from the last step
trajectory_generation_times = result.get("trajectory_generation_times")
if step_wise and trajectory_generation_times and result.get("is_last_step"):
trajectory_generation_times = [
t for t, is_last_step in zip(trajectory_generation_times, result.get("is_last_step")) if is_last_step
]
# With step-wise training each trajectory spans multiple rows; keep only its last-step timing.
is_last_step = result.get("is_last_step") if step_wise else None
trajectory_generation_times = _last_step_only(result.get("trajectory_generation_times"), is_last_step)
trajectory_llm_times = _last_step_only(result.get("trajectory_llm_times"), is_last_step)
trajectory_env_times = _last_step_only(result.get("trajectory_env_times"), is_last_step)
trajectory_env_setup_times = _last_step_only(result.get("trajectory_env_setup_times"), is_last_step)

# Re-aggregate rollout metrics
# Re-aggregate rollout metrics; the extra_keys fallback below cannot aggregate a p90 or a ratio.
rollout_metrics = get_rollout_metrics(
result["response_ids"],
result["rewards"],
loss_masks=result.get("loss_masks"),
trajectory_completion_times=trajectory_generation_times,
trajectory_llm_times=trajectory_llm_times,
trajectory_env_times=trajectory_env_times,
trajectory_env_setup_times=trajectory_env_setup_times,
)

# Preserve generator-specific metrics from per-group rollout_metrics. get_rollout_metrics only
Expand Down Expand Up @@ -377,13 +389,31 @@ def compute_turn_token_counts(loss_masks: List[List[int]]) -> List[int]:
return turn_token_counts


def _add_time_stats(rollout_metrics: Dict[str, Any], name: str, times: Optional[List[float]]) -> Optional[float]:
"""Add mean/p90/max for a per-trajectory time list; returns its sum, or None if absent."""
if not times:
return None
arr = np.array(times, dtype=np.float64)
rollout_metrics.update(
{
f"generate/trajectory_{name}_time_mean": np.mean(arr).item(),
f"generate/trajectory_{name}_time_p90": np.percentile(arr, 90).item(),
f"generate/trajectory_{name}_time_max": np.max(arr).item(),
}
)
return np.sum(arr).item()


def get_rollout_metrics(
responses: List[List[int]],
rewards: Union[List[float], List[List[float]]],
env_metrics: Optional[List[Dict[str, Any]]] = None,
env_classes: Optional[List[str]] = None,
loss_masks: Optional[List[List[int]]] = None,
trajectory_completion_times: Optional[List[float]] = None,
trajectory_llm_times: Optional[List[float]] = None,
trajectory_env_times: Optional[List[float]] = None,
trajectory_env_setup_times: Optional[List[float]] = None,
):
"""
Computes rollout metrics including token statistics and optional environment-specific metrics.
Expand All @@ -396,6 +426,12 @@ def get_rollout_metrics(
loss_masks: Optional list of per-token loss masks; used to compute assistant-only token counts
trajectory_completion_times: Optional per-trajectory end-to-end generation times (seconds);
used to compute aggregate trajectory completion-time stats (mean / p90 / max)
trajectory_llm_times: Optional per-trajectory time (seconds) spent awaiting the inference
engine, summed over turns
trajectory_env_times: Optional per-trajectory time (seconds) spent in ``env.step()``, summed
over turns
trajectory_env_setup_times: Optional per-trajectory time (seconds) spent constructing and
initializing the env

Returns:
Dictionary of aggregated metrics
Expand Down Expand Up @@ -450,15 +486,26 @@ def get_rollout_metrics(
}
)

if trajectory_completion_times:
completion_times_arr = np.array(trajectory_completion_times, dtype=np.float64)
rollout_metrics.update(
{
"generate/trajectory_completion_time_mean": np.mean(completion_times_arr).item(),
"generate/trajectory_completion_time_p90": np.percentile(completion_times_arr, 90).item(),
"generate/trajectory_completion_time_max": np.max(completion_times_arr).item(),
}
_add_time_stats(rollout_metrics, "completion", trajectory_completion_times)
llm_sum = _add_time_stats(rollout_metrics, "llm", trajectory_llm_times)
env_sum = _add_time_stats(rollout_metrics, "env", trajectory_env_times)
_add_time_stats(rollout_metrics, "env_setup", trajectory_env_setup_times)

if llm_sum is not None and env_sum is not None and llm_sum + env_sum > 0:
# Time-weighted (sum over sum), not a mean of per-trajectory ratios, so long trajectories
# count proportionally.
rollout_metrics["generate/frac_time_in_env"] = env_sum / (llm_sum + env_sum)

# Remainder of e2e not attributed to the engine, env steps, or env setup. Tokenization, chat
# templating, output assembly, env teardown, and event-loop scheduling.
if trajectory_completion_times and trajectory_llm_times and trajectory_env_times and trajectory_env_setup_times:
overhead = (
np.array(trajectory_completion_times, dtype=np.float64)
- np.array(trajectory_llm_times, dtype=np.float64)
- np.array(trajectory_env_times, dtype=np.float64)
- np.array(trajectory_env_setup_times, dtype=np.float64)
)
_add_time_stats(rollout_metrics, "overhead", overhead.tolist())

if env_metrics is not None and env_classes is not None:
env_to_metrics = defaultdict(list)
Expand Down
3 changes: 3 additions & 0 deletions tests/train/generators/test_generator_output_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ def test_generator_output_concatenation():
# optional but present in the signature
"trajectory_ids",
"trajectory_generation_times",
"trajectory_llm_times",
"trajectory_env_times",
"trajectory_env_setup_times",
"is_last_step",
"env_metrics",
"pixel_values",
Expand Down
Loading
Loading