How Prompt Deduplication Works
Overview
Prompt deduplication removes redundant computation in RL training by identifying sequences with identical prompts and processing them together, eliminating up to 82% of wasted token processing while maintaining mathematical correctness for gradients.
How Prompt Deduplication Works
The Problem: Prompt Redundancy in RL Training
During reinforcement learning training (e.g., PPO), the typical workflow involves:
- Sampling: Generate multiple responses for the same prompt to explore different strategies
- Evaluation: Score each response using a reward model
- Training: Compute policy gradients on all prompt-response pairs
This creates significant computational waste because the same prompt is processed multiple times:
Prompt A + Response 1 ──┐
Prompt A + Response 2 ──┤
Prompt A + Response 3 ──┼──→ Same prompt processed N times!
... │
Prompt A + Response N ──┘
Impact on Computation
In typical RL training:
- 80–95% of tokens are redundant prompt tokens
- Transformer attention has O(n²) complexity, so processing the same prompt N times costs N² of computation
- For a 10K-token prompt with 10 responses of 1K tokens each:
- Total: 110K tokens
- Unique: 20K tokens
- Redundancy: 82% of computation is wasted on duplicate prompts
For long-context RL (prompts with 4K–32K tokens), this redundancy dominates the training cost.
The Solution: Automatic Prompt Deduplication
ZoRRo Train removes prompt redundancy through three optimization phases:
Phase 1: Prompt Detection & Deduplication
The algorithm identifies which sequences share identical prompts and groups them:
Input Batch (8 sequences):
┌─────────────────────────────────┐
│ [Prompt A][Response 1] │
│ [Prompt A][Response 2] │ ──→ Deduplicated Batch:
│ [Prompt B][Response 3] │ [Prompt A][Response 1][Response 2]
│ [Prompt B][Response 4] │ [Prompt B][Response 3][Response 4]
│ ... │
└─────────────────────────────────┘
Key operations:
- Group samples by identical prompts using
find_prompt_groups() - Create a single concatenated sequence where each unique prompt appears once followed by its responses
- Track reconstruction metadata for reversing the transformation
Phase 2: Optimized Attention
Two attention strategies are available:
Standard QKV Optimization (use_split_attention=False)
# Compute Q, K, V on deduplicated batch
dedup_input_ids, _, reconstruction_info = ZoRRoTrain.create_deduplicated_batch(
input_ids, position_ids, response_length, prompt_groups, unique_prompts
)
# Forward through model
output = model(dedup_input_ids)
# Reconstruct to original batch shape
reconstructed = ZoRRoTrain.reconstruct_sequences(output, reconstruction_info)
Process:
- Compute Q, K, V projections on the deduplicated batch
- Reconstruct to full batch shape
- Run standard causal attention
Split Attention Optimization (use_split_attention=True, default)
This approach saves additional computation by avoiding redundant prompt-to-prompt attention:
- Prompt-to-Prompt: Deduplicated prompts attend only to themselves (computed once for all copies)
- Response-to-Full: Each response attends to its unique prompt + itself
The split attention approach is more efficient because prompt-to-prompt computations are only done once per unique prompt, not once per response.
Phase 3: Transparent Reconstruction
- Output logits are automatically reconstructed to match the original batch shape
- Gradients flow correctly through the deduplicated computation graph
- The rest of the model (embeddings, MLP layers, final projection) sees the expected batch shape
Core Algorithm: ZoRRoTrain Class
The ZoRRoTrain class provides model-free tensor deduplication utilities:
Finding Prompt Groups
prompt_groups, unique_prompts = ZoRRoTrain.find_prompt_groups(
input_ids, # [batch_size, seq_len]
response_length # length of response portion
)
Returns:
prompt_groups: List of lists, each containing indices of samples sharing a promptunique_prompts: [num_unique, prompt_len] tensor of unique prompt tokens
Example:
# Input: 4 samples where prompts at [0,1] and [2,3] match
prompt_groups = [[0, 1], [2, 3]] # Two groups
unique_prompts = [[prompt_A_tokens], [prompt_B_tokens]]
Creating Deduplicated Batch
dedup_input_ids, dedup_position_ids, reconstruction_info = ZoRRoTrain.create_deduplicated_batch(
input_ids, # [batch_size, seq_len]
position_ids, # [batch_size, seq_len]
response_length,
prompt_groups,
unique_prompts,
attention_mask=None, # Optional
use_unpad=False # If True, remove padding
)
Output structure:
dedup_input_ids: [1, total_dedup_tokens] – single packed sequencededup_position_ids: [1, total_dedup_tokens] or [1, total_valid_tokens] if unpackedreconstruction_info: Dict containing metadata for reconstruction
Concatenation pattern:
[Prompt_1][Response_1_1][Response_1_2]...[Prompt_2][Response_2_1][Response_2_2]...
Reconstructing Original Batch
reconstructed_hidden = ZoRRoTrain.reconstruct_sequences(
dedup_hidden, # [1, total_tokens, hidden_dim]
reconstruction_info
)
Returns: Original batch shape [batch_size, seq_len, hidden_dim]
The reconstruction uses a Triton-optimized kernel for efficiency:
@triton.jit
def _triton_reconstruct_seq(...):
# For each output sample, copy its unique prompt once
# then copy its response tokens
Architecture: Patching Strategy
The optimization is delivered via monkey-patching a Hugging Face Qwen model. Two patchers are available:
Production Patcher: Qwen3ModelOncePatcher
Used in production and GPU training loops. Installed once and patches the model for the entire training run.
from arctic_platform.rl.zorro_train import Qwen3ModelOncePatcher
patcher = Qwen3ModelOncePatcher(
model,
use_split_attention=True,
attn_implementation="eager" # or "flash_attention_2"
)
patcher.patch(response_len=512, rollout_n=10, temperature=1.0)
# Now all forward passes use deduplication internally
output = model(input_ids)
Reference Patcher: Qwen3ModelPatcher
A context-manager harness for demos and correctness testing:
from arctic_platform.rl.zorro_train.qwen_model_patcher import Qwen3ModelPatcher
with Qwen3ModelPatcher(model, use_split_attention=True):
output = model(input_ids) # Runs deduplicated
# Patching automatically removed
Lower-Level: QwenAttentionPatcher
Operates at the attention layer for fine-grained control:
from arctic_platform.rl.zorro_train.qwen_attention_patcher import QwenAttentionPatcher
patcher = QwenAttentionPatcher(
attention_layer,
use_split_attention=True
)
patcher.patch()
Working with Packed/Unpacked Sequences
ZoRRo Train handles both formats:
Padded Format (with attention mask)
# Input: [batch_size, seq_len] with padding
# attention_mask: 1 for valid tokens, 0 for padding
dedup_ids, dedup_pos, info = ZoRRoTrain.create_deduplicated_batch(
input_ids,
position_ids,
response_length,
prompt_groups,
unique_prompts,
attention_mask=attention_mask, # Include mask
use_unpad=False
)
Unpacked Format (variable lengths, no padding)
# Removes padding during deduplication
dedup_ids, dedup_pos, info = ZoRRoTrain.create_deduplicated_batch(
input_ids,
position_ids,
response_length,
prompt_groups,
unique_prompts,
attention_mask=attention_mask,
use_unpad=True # Enable unpacking
)
When unpacked, the returned reconstruction_info contains padding boundaries so gradients and outputs can be correctly mapped back.
Flash Attention Integration
For non-square attention masks (e.g., with KV caching), convert to Flash Attention parameters:
flash_params = ZoRRoTrain.attention_mask_to_flash_attn_params(
attention_mask # [total_q, total_kv] sparse block-diagonal
)
# Now use in Flash Attention kernels
output = flash_attention_forward(
q, k, v,
cu_seqlens_q=flash_params["cu_seqlens_q"],
cu_seqlens_k=flash_params["cu_seqlens_k"],
max_seqlen_q=flash_params["max_seqlen_q"],
max_seqlen_k=flash_params["max_seqlen_k"]
)
Correctness & Gradients
ZoRRo Train maintains mathematical correctness:
- Gradients computed through deduplicated batch are numerically equivalent to baseline (within floating-point precision)
- The deduplication is transparent to the model—no API changes required
- Activation checkpointing and mixed precision (bfloat16) are fully supported
Gradient flow:
Input → Dedup → Forward → Attention (deduplicated) → Reconstruct → Output
↓ ↑
└──────────── Backward (gradients flow through both) ────┘
Performance Expectations
Speedup depends on the deduplication ratio:
| Scenario | Batch Size | Unique Prompts | Prompt Len | Response Len | Expected Speedup |
|---|---|---|---|---|---|
| High dedup | 16 | 1 | 8K | 1K | ~2–4x |
| Medium dedup | 16 | 4 | 8K | 1K | ~1.5–2x |
| Low dedup | 16 | 16 | 8K | 1K | ~1x (no benefit) |
Factors affecting speedup:
- Deduplication ratio: More identical prompts → more savings
- Attention implementation: Flash Attention gives best results
- Sequence lengths: Longer prompts = more benefit (due to O(n²) attention)
- Hardware: GPU memory vs. compute bound
Supported Models
Currently ZoRRo Train supports:
qwen3qwen3-moeqwen3-next-moeqwen3.6qwen3.6-moe
These span dense, MoE, and hybrid architectures. More models will be added in the future.
Example: Using DeduplicatedActor
For a complete reference implementation, use the DeduplicatedActor harness:
from arctic_platform.rl.zorro_train.actor import DeduplicatedActor
# Initialize
actor = DeduplicatedActor(
model_name_or_path="Qwen/Qwen3-0.6B",
device="cuda",
logits_optimization="none", # "none" | "compute" | "memory"
use_split_attention=True,
attn_implementation="eager", # or "flash_attention_2"
)
# Forward pass (auto-deduplicated)
output = actor.forward(
batch,
temperature=1.0,
calculate_entropy=True
)
log_probs = output.logprobs # [num_valid_response_tokens]
entropy = output.entropy # [num_valid_response_tokens]
# Training step
actor.model.train()
metrics = actor.compute_policy_loss_and_backward(
batch,
temperature=1.0,
gradient_accumulation=1
)
print(f"Policy loss: {metrics['actor/policy_loss']:.4f}")
Output logprobs and entropy are packed 1D tensors in the original sample order, containing only valid (non-padded) response tokens.
Debugging & Analysis
Batch Statistics
Analyze a normal (non-deduplicated) batch:
from arctic_platform.rl.zorro_train.zorro_train import analyze_normal_batch_via_attention_mask
analyze_normal_batch_via_attention_mask(
input_ids,
attention_mask,
padded_response_len=4096
)
Output example:
batch stats: prompt lens: 15.9Ki resp count=16/min=0.4Ki/mean=0.6Ki/max=1.0Ki
non-pad-tokens=26.6K/82.5%
This shows:
- Prompt length
- Response counts (min/mean/max)
- Total non-padded tokens and percentage
Key Limitations & Future Work
Current Limitations:
- Best speedups require identical prompts (partial overlap not yet exploited)
- Implemented for Qwen models only (other architectures coming soon)
Future Directions:
- Support for other model families (Llama, Mistral, etc.)
- Prefix caching for partially overlapping prompts
- Dynamic deduplication detection without manual group specification