Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Single-Model Dual-Pass RLHF (PPO): Eliminating the Reference Model Footprint

This repository provides a production-ready, mathematically verified memory optimization for Reinforcement Learning from Human Feedback (RLHF) via Proximal Policy Optimization (PPO). By dynamically enabling and disabling PEFT (e.g., LoRA) adapters on a single instance of model weights, this framework eliminates the separate, frozen Reference model footprint entirely, saving up to 50% of policy-associated VRAM.

Because the technique removes one full copy of the model weights, it raises the model size an 8GB card (like an RTX 4060 Ti) can fit before hitting CUDA Out Of Memory (OOM). The included benchmark demonstrates this directly on a 3B model (see Empirical Benchmark Results); the same mechanism scales to larger models, though the 8B case is not benchmarked here.


The VRAM Bottleneck in RLHF (PPO)

Standard PPO training is notoriously memory-heavy. In a typical setup, you must load four independent models into GPU memory simultaneously:

  1. Actor Network ($\pi_{\theta}$): The active policy model being fine-tuned (requires weights, gradients, optimizer states, and activations).
  2. Reference Network ($\pi_{\text{ref}}$): The frozen, original pre-trained model (requires weights and activations for KL penalty calculation).
  3. Critic Network ($V_{\phi}$): The active value network estimating state rewards.
  4. Reward Network ($R$): The frozen reward model scoring generated sequences.

For an 8 Billion parameter model loaded in 16-bit precision, the base model weights alone require 16 GB of VRAM. Because the Actor and Reference models are initialized from the same base architecture, loading both concurrently consumes 32 GB of parameters before factoring in the Critic, Reward model, gradients, or optimizer states:

$$M_{\text{ppo_standard}} = M_{\text{Actor}} + M_{\text{Reference}} + M_{\text{Critic}} + M_{\text{Reward}} + M_{\text{Activations}} + M_{\text{Gradients}} + M_{\text{Optimizer}}$$


The Solution: Actor-Reference Dual-Pass Swapping

Since the Reference model ($\pi_{\text{ref}}$) is simply the base model without adapters, and the Actor ($\pi_{\theta}$) is the same model with active LoRA adapters, we do not need to load two independent copies of the model parameters.

Instead, we load one model shell and execute a dual-pass step:

                  ┌──────────────────────────────┐
                  │      Single Model Shell      │
                  └──────────────┬───────────────┘
                                 │
                  ┌──────────────┴──────────────┐
                  │                             │
       [1. Reference Pass]               [2. Actor Pass]
    with model.disable_adapter()       with active adapters
      with torch.no_grad()             with gradient tracking
                  │                             │
                  ▼                             ▼
       Reference Logits (Detached)         Actor Logits & Values
                  │                             │
                  └──────────────┬──────────────┘
                                 ▼
                     PPO loss + KL Divergence

The Algorithm:

  1. Reference Pass:
    • Wrap the forward pass in the model.disable_adapter() context manager.
    • Run the pass with torch.no_grad() to obtain reference logits ($Logits_{\text{ref}}$).
    • Detach the outputs and extract action log probabilities ($\log \pi_{\text{ref}}(a|s)$).
  2. Actor Pass:
    • Run the forward pass with adapters active (tracking gradients) to obtain actor logits ($Logits_{\text{act}}$) and critic values.
    • Extract action log probabilities ($\log \pi_{\theta}(a|s)$).
  3. Loss Computation:
    • Compute the PPO ratio $r_t(\theta) = \exp(\log \pi_{\theta}(a|s) - \log \pi_{\text{old}}(a|s))$.
    • Compute the clipped surrogate policy loss and value loss.
    • Compute the token-wise KL divergence: $$\text{KL}(\pi_{\theta} \parallel \pi_{\text{ref}}) = \log \pi_{\theta}(a|s) - \log \pi_{\text{ref}}(a|s)$$
    • Run backpropagation on the combined loss and step the optimizer.

Empirical Benchmark Results

We measured the peak GPU memory footprint of a PPO training step using Qwen/Qwen2.5-3B-Instruct (in bfloat16 precision) on a local NVIDIA GeForce RTX 4060 Ti 8GB:

  • Standard PPO (Actor + Reference): 12,272.98 MiB (12.27 GB) — Result: CUDA OOM (Crash on 8GB GPU)
  • Single-Model PPO (Dual-Pass): 6,270.98 MiB (6.27 GB) — Result: SUCCESS (Fits comfortably on 8GB GPU)
  • Peak VRAM Reduction: 6,002.00 MiB (48.9% VRAM savings)

This confirms that our dynamic dual-pass strategy successfully cuts the parameter memory requirement in half, freeing up 6 GB of VRAM for sequence activations and KV caches.


Getting Started

1. Installation

Ensure you have PyTorch, Hugging Face transformers, and PEFT installed:

pip install torch transformers peft accelerate

2. Implementation Example (Custom PyTorch Loop)

Use the custom SingleModelPPOLoss to calculate combined PPO objectives on a single model instance:

import torch
from peft import LoraConfig, get_peft_model
from transformers import AutoModelForCausalLM
from rlhf_dual_pass import step_ppo_dual_pass

# 1. Load model and wrap with LoRA
model = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-3B-Instruct", 
    torch_dtype=torch.bfloat16
).cuda()

lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=["q_proj", "v_proj"],
    task_type="CAUSAL_LM"
)
model = get_peft_model(model, lora_config)

# 2. Define critic value head
hidden_size = model.config.hidden_size
value_head = torch.nn.Linear(hidden_size, 1).cuda()

# 3. Setup optimizer
optimizer = torch.optim.AdamW(list(model.parameters()) + list(value_head.parameters()), lr=1e-5)

# 4. Perform a PPO Dual-Pass step
# inputs, action_masks, advantages, returns, old_log_probs, old_values are collected from rollout
loss, p_loss, kl_penalty, val_loss = step_ppo_dual_pass(
    model=model,
    inputs=inputs,
    action_masks=action_masks,
    old_log_probs=old_log_probs,
    advantages=advantages,
    returns=returns,
    optimizer=optimizer,
    old_values=old_values,
    value_head=value_head,
    kl_beta=0.01,
    clip_range=0.2
)

File Structure

  • rlhf_dual_pass.py: Core implementation containing the SingleModelPPOLoss class and training loop wrappers.
  • benchmark_rlhf.py: Profiling script used to measure and compare peak memory.
  • tests/verify_rlhf.py: E2E verification test that runs both benchmark arms in separate processes and asserts at least a 35% peak-VRAM saving.

Reproducing the benchmark

The benchmark is not CPU- or offline-runnable. benchmark_rlhf.py calls .cuda() and loads Qwen/Qwen2.5-3B-Instruct via from_pretrained, so it needs:

  • an NVIDIA GPU with CUDA (the reported run used an 8GB RTX 4060 Ti),
  • roughly 6GB of free disk and a network connection the first time, to fetch the model weights from Hugging Face.
python tests/verify_rlhf.py

The standard-PPO arm is expected to OOM on an 8GB card — that is the result being demonstrated, not a failure of the script.

  • notebooks/RLHF_Dual_Pass_Colab.ipynb: Complete, interactive Google Colab notebook.

About

Single-model dual-pass PPO/RLHF: toggle LoRA adapters on one model shell to drop the frozen reference-model VRAM copy. Benchmarked ~49% peak PPO-step memory reduction on a 3B model on an 8GB RTX 4060 Ti.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages