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.
Standard PPO training is notoriously memory-heavy. In a typical setup, you must load four independent models into GPU memory simultaneously:
-
Actor Network (
$\pi_{\theta}$ ): The active policy model being fine-tuned (requires weights, gradients, optimizer states, and activations). -
Reference Network (
$\pi_{\text{ref}}$ ): The frozen, original pre-trained model (requires weights and activations for KL penalty calculation). -
Critic Network (
$V_{\phi}$ ): The active value network estimating state rewards. -
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:
Since the Reference model (
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
-
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)$).
- Wrap the forward pass in the
-
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)$).
- Run the forward pass with adapters active (tracking gradients) to obtain actor logits (
-
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.
- Compute the PPO ratio
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.
Ensure you have PyTorch, Hugging Face transformers, and PEFT installed:
pip install torch transformers peft accelerateUse 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
)rlhf_dual_pass.py: Core implementation containing theSingleModelPPOLossclass 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.
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.pyThe 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.