# Truncated Ppo

> T-PPO improves training efficiency via truncated rollouts and extended GAE, enabling batch continuity without waiting for full sequence completion.

- Skill: `adu2021/truncated-ppo` (Agent Skill)
- Install (CLI): `npx skillmds@latest add adu2021/truncated-ppo`
- Raw SKILL.md: https://api.skillmd.com/api/skills/adu2021/truncated-ppo/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- License: MIT
- Author: adu2021 (https://skillmd.com/u/adu2021)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/adu2021/truncated-ppo

---


# Truncated Proximal Policy Optimization

## Core Concept

Truncated PPO (T-PPO) enhances training efficiency for reasoning LLMs through truncated rollouts and extended generalized advantage estimation (EGAE). Rather than waiting for complete generation sequences before updating, T-PPO divides long sequences into fixed-window chunks and applies EGAE to compute advantages from incomplete trajectories. This approach maintains constant batch size through replacement of finished sequences while achieving 60% wall-clock time reduction and 2.5x training efficiency improvement without sacrificing convergence on mathematical reasoning tasks.

## Architecture Overview

- **Truncated Rollout Strategy**: Divide response generation into fixed-length windows rather than waiting for completion
- **Extended GAE (EGAE)**: Generalize advantage estimation to incomplete trajectories by assuming terminal value equals penultimate state value
- **Batch Continuity**: Replace finished sequences with new samples, maintaining constant batch size and GPU utilization
- **Token Filtering**: Selectively filter training tokens to enable independent policy/value optimization
- **Successive Batching**: Continuously feed new prompts while earlier ones still generate

## Implementation

### Step 1: Implement Truncated Rollout Strategy

Divide long sequences into manageable chunks:

```python
import torch
import torch.nn as nn
from typing import List, Dict

class TruncatedRolloutBuffer:
    """
    Manages truncated rollouts: divides generation into windows.
    """
    def __init__(self, window_size=128, max_total_length=512):
        self.window_size = window_size
        self.max_total_length = max_total_length

    def create_windows(self, prompt_ids, max_new_tokens):
        """
        Divide expected generation into fixed-length windows.

        Args:
            prompt_ids: [batch, prompt_len] token IDs
            max_new_tokens: maximum tokens to generate per prompt

        Returns:
            window_config: dict with window boundaries and settings
        """
        batch_size = prompt_ids.shape[0]
        num_windows = (max_new_tokens + self.window_size - 1) // self.window_size

        window_config = {
            'window_size': self.window_size,
            'num_windows': num_windows,
            'batch_size': batch_size,
            'max_new_tokens': max_new_tokens
        }

        return window_config

    def process_truncated_generation(self, prompt_ids, generation_outputs,
                                    window_idx):
        """
        Extract tokens for a specific generation window.

        Args:
            prompt_ids: [batch, prompt_len]
            generation_outputs: full generated sequences (may not be complete)
            window_idx: which window to extract

        Returns:
            window_tokens: [batch, window_size] tokens in this window
            is_complete: [batch] boolean indicating if sequence completed
        """
        start_idx = window_idx * self.window_size
        end_idx = (window_idx + 1) * self.window_size

        # Extract window
        window_tokens = generation_outputs[:, start_idx:end_idx]

        # Pad if incomplete
        if window_tokens.shape[1] < self.window_size:
            padding = torch.full(
                (window_tokens.shape[0], self.window_size - window_tokens.shape[1]),
                fill_value=0  # or pad token ID
            )
            window_tokens = torch.cat([window_tokens, padding], dim=1)

        # Check completion
        is_complete = (window_tokens != 0).sum(dim=1) < self.window_size

        return window_tokens, is_complete
```

### Step 2: Implement Extended GAE

Generalize GAE to incomplete trajectories:

```python
class ExtendedGeneralizedAdvantageEstimation:
    """
    Extended GAE: handles incomplete (truncated) trajectories.
    Key assumption: V(s_l) = V(s_{l-1}) for ungenerated states.
    """
    def __init__(self, gamma=0.99, gae_lambda=0.95):
        self.gamma = gamma
        self.gae_lambda = gae_lambda

    def compute_advantages_truncated(self, rewards, values, dones,
                                    trajectory_length):
        """
        Compute advantages assuming incomplete trajectories.

        Args:
            rewards: [batch, seq_len] rewards at each position
            values: [batch, seq_len] state value estimates
            dones: [batch, seq_len] episode termination flags
            trajectory_length: list of actual lengths per batch

        Returns:
            advantages: [batch, seq_len] advantage estimates
            returns: [batch, seq_len] return estimates
        """
        batch_size, seq_len = rewards.shape
        advantages = torch.zeros_like(rewards)
        gae = 0

        # Iterate backwards through time
        for t in reversed(range(seq_len)):
            if t == seq_len - 1:
                # Last position: assume next value equals current for ungenerated
                next_value = values[:, t].clone()
                # Check if trajectory continues beyond window
                next_non_terminal = 1 - dones[:, t]
            else:
                next_value = values[:, t + 1]
                next_non_terminal = 1 - dones[:, t]

            delta = (
                rewards[:, t] +
                self.gamma * next_value * next_non_terminal -
                values[:, t]
            )

            # GAE computation with temporal decay
            gae = (
                delta +
                self.gamma * self.gae_lambda * next_non_terminal * gae
            )

            advantages[:, t] = gae

        # Compute returns
        returns = advantages + values

        return advantages, returns

    def compute_advantages_complete(self, rewards, values, dones):
        """
        Standard GAE for completed trajectories (reference).
        """
        batch_size, seq_len = rewards.shape
        advantages = torch.zeros_like(rewards)
        gae = 0

        # Bootstrap from final value
        next_value = values[:, -1].clone()

        for t in reversed(range(seq_len)):
            if t == seq_len - 1:
                next_non_terminal = 1 - dones[:, t]
                next_value = values[:, t]
            else:
                next_non_terminal = 1 - dones[:, t]
                next_value = values[:, t + 1]

            delta = (
                rewards[:, t] +
                self.gamma * next_value * next_non_terminal -
                values[:, t]
            )

            gae = (
                delta +
                self.gamma * self.gae_lambda * next_non_terminal * gae
            )

            advantages[:, t] = gae

        returns = advantages + values

        return advantages, returns
```

### Step 3: Implement Token Filtering

Selectively filter tokens for optimization:

```python
class TokenFilter:
    """
    Filters training tokens to improve efficiency.
    """
    @staticmethod
    def filter_high_advantage(tokens, advantages, log_probs,
                             percentile_threshold=50):
        """
        Focus on tokens with high absolute advantage.

        Args:
            tokens: [batch, seq_len] token IDs
            advantages: [batch, seq_len] advantage estimates
            log_probs: [batch, seq_len] log probabilities
            percentile_threshold: keep tokens above this percentile

        Returns:
            filtered_mask: [batch, seq_len] boolean mask
        """
        # Compute percentile threshold
        advantage_threshold = torch.kthvalue(
            advantages.reshape(-1),
            int(advantages.numel() * (100 - percentile_threshold) / 100)
        ).values

        # Create mask
        filtered_mask = torch.abs(advantages) > advantage_threshold

        return filtered_mask

    @staticmethod
    def filter_redundant_tokens(log_probs, entropy_threshold=0.1):
        """
        Filter low-entropy (confident) tokens to reduce redundancy.

        Args:
            log_probs: [batch, seq_len, vocab_size]
            entropy_threshold: keep if entropy > threshold

        Returns:
            filtered_mask: [batch, seq_len] boolean mask
        """
        # Compute entropy
        probs = torch.softmax(log_probs, dim=-1)
        entropy = -(probs * torch.log(probs + 1e-8)).sum(dim=-1)

        # Filter
        filtered_mask = entropy > entropy_threshold

        return filtered_mask

    @staticmethod
    def filter_composite(advantages, log_probs, weight_advantage=0.7):
        """
        Combine multiple filtering criteria.
        """
        mask_advantage = TokenFilter.filter_high_advantage(
            None, advantages, None, percentile_threshold=40
        )

        mask_entropy = TokenFilter.filter_redundant_tokens(
            log_probs, entropy_threshold=0.15
        )

        # Weighted combination
        combined_mask = (
            weight_advantage * mask_advantage.float() +
            (1 - weight_advantage) * mask_entropy.float()
        ) > 0.5

        return combined_mask
```

### Step 4: Implement Successive Batching

Maintain constant batch size with continuous sampling:

```python
class SuccessiveBatchSampler:
    """
    Samples batches continuously, replacing finished sequences.
    Maintains constant batch size for GPU efficiency.
    """
    def __init__(self, prompt_dataset, batch_size=32, window_size=128):
        self.prompt_dataset = prompt_dataset
        self.batch_size = batch_size
        self.window_size = window_size
        self.current_idx = 0
        self.active_sequences = []

    def get_next_batch(self):
        """
        Return batch with new samples replacing finished ones.

        Returns:
            batch: dict with prompts, generation_states
        """
        batch = {
            'new_prompts': [],
            'in_progress': self.active_sequences.copy()
        }

        # Add new sequences to replace finished ones
        num_new_needed = self.batch_size - len(self.active_sequences)

        for _ in range(num_new_needed):
            if self.current_idx >= len(self.prompt_dataset):
                self.current_idx = 0  # Cycle through dataset

            prompt = self.prompt_dataset[self.current_idx]
            batch['new_prompts'].append(prompt)
            self.active_sequences.append({
                'prompt': prompt,
                'generated_tokens': [],
                'generation_step': 0
            })
            self.current_idx += 1

        return batch

    def update_generation_state(self, window_outputs, finished_mask):
        """
        Update tracking of in-progress generations.

        Args:
            window_outputs: [batch, window_size] generated tokens
            finished_mask: [batch] boolean indicating finished sequences
        """
        # Remove finished sequences
        self.active_sequences = [
            seq for i, seq in enumerate(self.active_sequences)
            if not finished_mask[i]
        ]
```

### Step 5: Implement T-PPO Training Loop

Complete training with truncation and successive batching:

```python
class TruncatedPPOTrainer:
    """
    Full T-PPO trainer with truncated rollouts and batch continuity.
    """
    def __init__(self, model, value_model, window_size=128, batch_size=32):
        self.model = model  # policy model
        self.value_model = value_model
        self.window_size = window_size
        self.batch_size = batch_size
        self.egae = ExtendedGeneralizedAdvantageEstimation()
        self.rollout_buffer = TruncatedRolloutBuffer(window_size)
        self.batch_sampler = SuccessiveBatchSampler(
            [], batch_size=batch_size, window_size=window_size
        )

    def training_step(self, batch_prompts, batch_responses, window_idx,
                     verification_fn):
        """
        Single training step on a generation window.

        Args:
            batch_prompts: [batch, prompt_len]
            batch_responses: [batch, total_len] responses generated so far
            window_idx: which window to train on
            verification_fn: function to verify answer correctness
        """
        # Extract window
        window_tokens, is_complete = self.rollout_buffer.process_truncated_generation(
            batch_prompts, batch_responses, window_idx
        )

        # Compute rewards based on verification
        rewards = torch.zeros(self.batch_size, self.window_size)

        for i in range(self.batch_size):
            response_text = self.model.tokenizer.decode(batch_responses[i])
            is_correct = verification_fn(response_text)

            # Sparse reward: only at end of window or completion
            if is_complete[i] or (window_idx == self.rollout_buffer.window_size - 1):
                rewards[i, -1] = 1.0 if is_correct else 0.0

        # Compute values using value model
        with torch.no_grad():
            value_outputs = self.value_model(window_tokens)
            values = value_outputs.squeeze(-1)  # [batch, window_size]

        # Compute advantages
        advantages, returns = self.egae.compute_advantages_truncated(
            rewards,
            values,
            ~is_complete.unsqueeze(1),  # dones flags
            self.window_size
        )

        # Get policy log probs
        policy_outputs = self.model(window_tokens)
        log_probs = policy_outputs.log_probs  # [batch, window_size]

        # Filter tokens if desired
        token_filter = TokenFilter()
        filter_mask = token_filter.filter_composite(
            advantages, policy_outputs.logits
        )

        # PPO loss with clipping
        ratio = torch.exp(log_probs - policy_outputs.old_log_probs)
        clipped_ratio = torch.clamp(ratio, 1 - 0.2, 1 + 0.2)

        loss_actor = -torch.min(
            ratio * advantages,
            clipped_ratio * advantages
        )[filter_mask].mean()

        # Value loss
        loss_value = torch.nn.functional.mse_loss(values, returns)

        # Total loss
        loss = loss_actor + 0.5 * loss_value

        return loss, {
            'actor_loss': loss_actor.item(),
            'value_loss': loss_value.item(),
            'filtered_fraction': filter_mask.float().mean().item()
        }

    def train_epoch(self, dataloader, num_epochs=1):
        """
        Train for one epoch with successive batching.
        """
        optimizer = torch.optim.Adam(
            list(self.model.parameters()) +
            list(self.value_model.parameters()),
            lr=1e-4
        )

        for epoch in range(num_epochs):
            epoch_loss = 0
            epoch_metrics = {}

            for batch_idx, (prompts, responses) in enumerate(dataloader):
                # Determine number of windows
                max_len = responses.shape[1]
                num_windows = (max_len + self.window_size - 1) // self.window_size

                # Train on each window
                for window_idx in range(num_windows):
                    loss, metrics = self.training_step(
                        prompts, responses, window_idx,
                        verification_fn=self._verify_correctness
                    )

                    optimizer.zero_grad()
                    loss.backward()
                    torch.nn.utils.clip_grad_norm_(
                        list(self.model.parameters()) +
                        list(self.value_model.parameters()),
                        1.0
                    )
                    optimizer.step()

                    epoch_loss += loss.item()

                    for k, v in metrics.items():
                        if k not in epoch_metrics:
                            epoch_metrics[k] = []
                        epoch_metrics[k].append(v)

            # Log results
            avg_loss = epoch_loss / (len(dataloader) * num_windows)
            print(f"Epoch {epoch + 1}, Loss: {avg_loss:.4f}")

            for k, v in epoch_metrics.items():
                print(f"  {k}: {sum(v) / len(v):.4f}")

    def _verify_correctness(self, response_text):
        """Verify if response is correct (domain-specific)"""
        return True  # Placeholder
```

## Practical Guidance

- **Window Size**: Larger windows (256) reduce overhead but increase memory; start with 128
- **EGAE Assumption**: V(s_l) = V(s_{l-1}) works well empirically; validate on your tasks
- **Token Filtering**: Apply selectively; too aggressive filtering can bias updates
- **Batch Continuity**: Requires prompt dataset large enough to avoid repetition
- **Speedup Target**: Expect 2-3x speedup; measure actual wall-clock time, not just theoretical
- **Hyperparameters**: Use standard PPO settings (clip=0.2, GAE lambda=0.95); adjust window size first
- **Evaluation**: Benchmark on long-sequence reasoning tasks (AIME, MATH500, code generation)
- **Comparison**: Always compare to standard PPO on same tasks to validate efficiency gains

## Reference

Paper: arXiv:2506.15050
Key metrics: 60% wall-clock reduction, 2.5x training efficiency on math reasoning
EGAE improvement: Handles incomplete trajectories enabling continuous batching
Related work: PPO, GAE, policy optimization, training efficiency

