A scalable asynchronous reinforcement learning implementation with in-flight weight updates.
See the code
A scalable asynchronous reinforcement learning implementation with in-flight weight updates. Designed to maximize GPU utilization while staying as on-policy as possible.
PipelineRL tackles the classic trade-off between inference throughput (large batches on many GPUs) and on-policy data freshness by performing inflight weight updates. After each optimizer step, updated weights are broadcast to the inference servers without halting sampling. This keeps batch sizes optimal and data near on-policy, yielding fast, stable RL for large language models.
PipelineRL is agent framework agnostic, meaning you can use it to train any agent by implementing a load_problems and generate_rollout functions for your task. For example, we can easily design and train a multi-turn LLM agent that must guess a number between 1 and 1024. After each guess, the agent receives feedback whether the guess was higher or lower than the target number.
First, we must implement load_problems to generate a list of train and test problems. Each problem is a dictionary with an answer key and a dataset key indicating whether it belongs to the training or testing dataset.
def load_problems(dataset_names: list[str]) -> list[dict]:
n = 1024
c = 191
problems = []
for name in dataset_names:
if name == "train":
problems.extend([
{"answer": (2 * i * c) % n + 1, "dataset": "train"} for i in range(512)
])
elif name == "test":
problems.extend([
{"answer": ((2 * i + 1) * c) % n + 1, "dataset": "test"} for i in range(512)
])
return problems
Then, we must implement a generate_rollout function which takes a problem from the load_problems function and generate a RolloutResult. RolloutResult contains the a list of TrainingText (token ids, log probs, reward, etc.), BaseMetrics (reward, success, etc.), latency of the rollout in seconds, and the dataset_name which will be used for grouping the metrics. Here, the function should use an LLM to generate guesses and provide feedback based on the problem's answer.
async def generate_rollout(
cfg: DictConfig,
llm: TrainableLLM,
problem: dict,
session: aiohttp.ClientSession,
) -> RolloutResult:
initial_messages = [
{
"role": "system",
"content": "You are a helpful assistant",
},
{
"role": "user",
"content": f"You must guess a number between 1 and 1024. Output the answer as <answer>number</answer>."
" After each guess I will tell you if your answer is higher or lower than the target number."
}
]
time_start = time.time()
llm_calls = []
guess_history = []
reward = 0
success = 0
error = 0
for i in range(13):
messages = initial_messages.copy()
if i > 0:
last_message = f"Your {i} previous guesses:"
for guess in guess_history:
relation = "lower" if guess < problem["answer"] else "higher"
last_message += f"\n{guess}, which is {relation} than the target number."
else:
last_message += "\n<wrong output>"
messages.append({
"role": "user",
"content": last_message
})
llm_call = await llm_async_generate(llm, Prompt(messages=messages), session)
llm_calls.append(llm_call)
output_text = llm_call.output.content or ""
answer = re.search("<answer>(\d+)</answer>", output_text)
if answer:
answer = int(answer.group(1))
if answer == problem["answer"]:
reward = 2 - i / 10
success = 1
break
else:
guess_history.append(answer)
else:
# bonus for using the correct output format in the first turns
reward = -2 + i / 10
error = 1
break
latency = time.time() - time_start
# TrainingText contains the prompt and output tokens, reward, and the log probs of the output tokens necessary for RL training.
training_texts = [make_training_text(llm, llm_call) for llm_call in llm_calls]
for text in training_texts:
text.reward = reward
metrics = BaseMetrics(
reward=reward,
success=success,
no_error=not error,
no_answer=error,
)
return RolloutResult(
training_texts=training_texts,
metrics=metrics,
latency=latency,
dataset_name=problem["dataset"],
)
Finally you need to create a Hydra config file that points to the rollout function and the dataset loader. Additional hyper-parameters such as model path, learning rate, etc. can also be modified. For example, guessing.yaml:
defaults:
- base
- _self_
actor:
rollout_policy: pipelinerl.domains.guessing.generate_guessing_rollout
environment: null
dataset_loader: pipelinerl.domains.guessing.load_problems
train_dataset_names:
- train
test_dataset_names:
- test
You can now launch the training with the following command:
python -m pipelinerl.launch --config-name=guessing output_dir=results/guessing
Once the LLMs are served, the actor will be evaluated on the test dataset before collecting training rollouts.
When enough data has been collected, the trainer will perform a RL step and update the actor's weights.
The streaming logs can be overwhelming, and it is therefore easier to debug using the each process log files in the results/guessing:
After roughly 20 minutes, the actor will have learned a strategy to guess the number correctly. The training can be monitored in real-time using WANDB, which will show the training and test metrics:
Clone the repository and change the directory to pipelinerl
git clone git@github.com:ServiceNow/PipelineRL.git
cd PipelineRL
Create the environments with dependencies.
conda create -n pipeline-rl -y python=3.12
conda run --no-capture-output -n pipeline-rl pip install -e .
conda run --no-capture-output -n pipeline-rl pip install flash-attn==2.8.3 --no-build-isolation
Alternatively for flash-attn, you can install it via prebuilt packages (on Linux):
# Check your PyTorch's C++ ABI setting first:
# python -c "import torch; print(torch._C._GLIBCXX_USE_CXX11_ABI)"
# Use cxx11abiTRUE or cxx11abiFALSE in the URL accordingly
conda run --no-capture-output -n pipeline-rl pip install https://github.com/lesj0610/flash-attention/releases/download/v2.8.3-cu12-torch2.10-cp312/flash_attn-2.8.3%2Bcu12torch2.10cxx11abiTRUE-cp312-cp312-linux_x86_64.whl
By default Pipeline-RL will use the file system as the medium for streaming the generated data to the trainer processes. This works on one node, but the files can get quite large. To use Redis instead you will need to install the Redis server in the same conda environment:
conda install redis-server==7.4.0 -c conda-forge
PipelineRL supports using SandboxFusion to execute and verify coding-task outputs in a remote sandbox.
To run SandboxFusion locally, follow the deployment guide and startup logs here: https://bytedance.github.io/SandboxFusion/docs/docs/get-started#local-deployment
Then point PipelineRL to your sandbox endpoint by setting sandbox_endpoint in your config (for example in conf/coding.yaml) or by exporting SANDBOX_ENDPOINT:
export SANDBOX_ENDPOINT=http://127.0.0.1:8080
PipelineRL supports using SandboxFusion to execute and verify coding-task outputs in a remote sandbox.
To run SandboxFusion locally, follow the deployment guide and startup logs here: https://bytedance.github.io/SandboxFusion/docs/docs/get-started#local-deployment
Then point PipelineRL to your sandbox endpoint by setting sandbox_endpoint in your config (for example in conf/coding.yaml) or by exporting SANDBOX_ENDPOINT:
export SANDBOX_ENDPOINT=http://127.0.0.1:8080
First, activate the conda environment:
conda activate pipeline-rl
Single node with 8 H100 GPUs:
python -m pipelinerl.launch output_dir=results/base1
If you only have 4 H100 GPUs:
python -m pipelinerl.launch --config-name base_4gpu output_dir=results/base1
To use Redis instead of the filesystem for data streaming:
python -m pipelinerl.launch streams=redis output_dir=results/base1
PipelineRL is organized as a modular, Hydra-driven pipeline with 6 core components driving 3 main stages of the RL training: actor, verifier and trainer. Below is a code-grounded mapping of each component:
pipelinerl/launch.py@hydra.main(...) def main(cfg)pipelinerl/world.py) for rank-aware job & GPU placement:
WORLD_SIZE, RANK, and MASTER_ADDR to determine cluster topology.gpus_per_llm from tensor/pipeline parallel settings and allocates each node’s GPUs into actor, preprocessor, and trainer pools based on cfg.world.*_fraction.actor_llm, preprocessor_llm, actor, preprocessor, verifier, and finetune.launch_jobs(...), which invokes:
run_ref_llm → Reference LLM servers for KL penalties.run_actor_llm → Actor LLM servers for policy sampling.run_actor → Actor processes generating raw rollouts.run_preprocess → Preprocessor workers computing advantages & reference log-probs.run_finetune → Trainer workers updating weights via Accelerate, DeepSpeed, or FSDP.run_verifier → Optional verifier servers for final reward checks.run_ref_llm (in launch.py), running vllm.entrypoints.openai.api_server to serve reference log-probs.run_actor_llm → pipelinerl/entrypoints/llm.py → pipelinerl/run_llm.py:
Worker to add:
init_actor_update_group(...) for NCCL process-group setup.receive_weight_update(request) to pause inference, broadcast new weights via NCCL, and reload model parameters.POST /v1/chat/completion for sampling.POST /receive_weight_update for weight updates.pipelinerl/entrypoints/actor.pyload_datasets.wait_for_inference_servers) and optional verifier (wait_for_verifier).TrainerState(exp_path), start listening for weight updates, and block until the first model version arrives.ActorLoop & rollout_maker_entrypoint):
ActorLoop creates problem_queue and result_queue, then spawns multiple worker processes (via mp.Process) to run rollout_maker_entrypoint.TrainerState to get model version.schedule_rollouts(cfg, attempts, problem_queue, result_queue, trainer_state, llms, name), which:
problem_queue (random sampling for training, sequential for testing).cfg.attempts concurrent HTTP calls to Actor LLM servers (generate_math_rollout).RolloutResult objects (texts, log-probs, rewards, latencies) and pushes the full batch into result_queue once all attempts complete.ActorLoop.run):
propagated_weight_version arrived.problem_queue up to the lag-controlled limit (cfg.finetune.max_lag / cfg.attempts).RolloutResult from result_queue.actor stream.SlidingWindowAggregator) and write stats to the stats stream and WANDB.run_actor_loop can pause training scheduling to run a one-shot test loop (is_training=False), based on cfg.eval_every_n_versions.cfg.finetune.max_lag and cfg.finetune.weight_update_interval, ensuring on-policy data freshness.pipelinerl/entrypoints/preprocess.pyrun_dataset_loader (thread) reads raw actor traces in chunks from the input stream.ProcessPoolExecutor workers run process_chunk(...), which:
StreamRangeSpec(topic=cfg.preprocess.output).pipelinerl/entrypoints/finetune.pyrun_sample_loader reads JSON micro-batches from the input stream into a local queue.run_fixed_batch_data_loader or run_dynamic_batch_size_data_loader collates samples into PyTorch tensors.rl_step(...) (in pipelinerl/finetune/rl/utils.py) to compute policy-gradient (+ KL penalty if configured) → optimizer.step() → lr_scheduler.step().WeightUpdateManager.send_weight_update(version) to gather model parameters, send WeightUpdateRequest to Actor LLMs (HTTP), broadcast tensors via NCCL, and write a WeightUpdateSuccess message to the update stream.pipelinerl/entrypoints/verifier.pyPOST / : checks model outputs (math or countdown puzzles) via math_verify or countdown_utils.GET /health: readiness probe.pipelinerl/streams.py.SingleStreamSpec and StreamRangeSpec for file-system or Redis-based queues.write_to_streams(...) and read_stream(...) provide a JSON-line protocol for inter-process messaging.problem_queue (multiprocessing.Queue): produced by ActorLoop.run to hold raw problems; consumed by rollout worker processes in rollout_maker_entrypoint via schedule_rollouts.result_queue (multiprocessing.Queue): produced by rollout workers (lists of RolloutResult); consumed by ActorLoop.run to publish completed rollouts.actor stream (SingleStreamSpec(topic="actor")): file- or Redis-backed stream. Produced by ActorLoop.run writing each sample dict; consumed by the Preprocessor stage (configured via cfg.preprocess.input).training_data stream (StreamRangeSpec(topic="training_data")): File- or Redis-backed stream used to transfer processed training micro-batches from the Preprocessor to the Trainer. Configured via cfg.preprocess.output and cfg.finetune.input (defaulting to "training_data") in conf/base.yaml. Written in pipelinerl/run_preprocess.py and consumed in pipelinerl/run_finetune.py.actor_test and stats_test streams: analogous streams used for evaluation loops (test samples and test metrics).stats stream (SingleStreamSpec(topic="stats")): produced by ActorLoop.publish_stats with sliding-window metrics; consumed by external monitoring (e.g. WANDB, logging viewers).Python
100.0%
A scalable asynchronous reinforcement learning implementation with in-flight weight updates.
See the code
A scalable asynchronous reinforcement learning implementation with in-flight weight updates. Designed to maximize GPU utilization while staying as on-policy as possible.
PipelineRL tackles the classic trade-off between inference throughput (large batches on many GPUs) and on-policy data freshness by performing inflight weight updates. After each optimizer step, updated weights are broadcast to the inference servers without halting sampling. This keeps batch sizes optimal and data near on-policy, yielding fast, stable RL for large language models.
PipelineRL is agent framework agnostic, meaning you can use it to train any agent by implementing a load_problems and generate_rollout functions for your task. For example, we can easily design and train a multi-turn LLM agent that must guess a number between 1 and 1024. After each guess, the agent receives feedback whether the guess was higher or lower than the target number.
First, we must implement load_problems to generate a list of train and test problems. Each problem is a dictionary with an answer key and a dataset key indicating whether it belongs to the training or testing dataset.
def load_problems(dataset_names: list[str]) -> list[dict]:
n = 1024
c = 191
problems = []
for name in dataset_names:
if name == "train":
problems.extend([
{"answer": (2 * i * c) % n + 1, "dataset": "train"} for i in range(512)
])
elif name == "test":
problems.extend([
{"answer": ((2 * i + 1) * c) % n + 1, "dataset": "test"} for i in range(512)
])
return problems
Then, we must implement a generate_rollout function which takes a problem from the load_problems function and generate a RolloutResult. RolloutResult contains the a list of TrainingText (token ids, log probs, reward, etc.), BaseMetrics (reward, success, etc.), latency of the rollout in seconds, and the dataset_name which will be used for grouping the metrics. Here, the function should use an LLM to generate guesses and provide feedback based on the problem's answer.
async def generate_rollout(
cfg: DictConfig,
llm: TrainableLLM,
problem: dict,
session: aiohttp.ClientSession,
) -> RolloutResult:
initial_messages = [
{
"role": "system",
"content": "You are a helpful assistant",
},
{
"role": "user",
"content": f"You must guess a number between 1 and 1024. Output the answer as <answer>number</answer>."
" After each guess I will tell you if your answer is higher or lower than the target number."
}
]
time_start = time.time()
llm_calls = []
guess_history = []
reward = 0
success = 0
error = 0
for i in range(13):
messages = initial_messages.copy()
if i > 0:
last_message = f"Your {i} previous guesses:"
for guess in guess_history:
relation = "lower" if guess < problem["answer"] else "higher"
last_message += f"\n{guess}, which is {relation} than the target number."
else:
last_message += "\n<wrong output>"
messages.append({
"role": "user",
"content": last_message
})
llm_call = await llm_async_generate(llm, Prompt(messages=messages), session)
llm_calls.append(llm_call)
output_text = llm_call.output.content or ""
answer = re.search("<answer>(\d+)</answer>", output_text)
if answer:
answer = int(answer.group(1))
if answer == problem["answer"]:
reward = 2 - i / 10
success = 1
break
else:
guess_history.append(answer)
else:
# bonus for using the correct output format in the first turns
reward = -2 + i / 10
error = 1
break
latency = time.time() - time_start
# TrainingText contains the prompt and output tokens, reward, and the log probs of the output tokens necessary for RL training.
training_texts = [make_training_text(llm, llm_call) for llm_call in llm_calls]
for text in training_texts:
text.reward = reward
metrics = BaseMetrics(
reward=reward,
success=success,
no_error=not error,
no_answer=error,
)
return RolloutResult(
training_texts=training_texts,
metrics=metrics,
latency=latency,
dataset_name=problem["dataset"],
)
Finally you need to create a Hydra config file that points to the rollout function and the dataset loader. Additional hyper-parameters such as model path, learning rate, etc. can also be modified. For example, guessing.yaml:
defaults:
- base
- _self_
actor:
rollout_policy: pipelinerl.domains.guessing.generate_guessing_rollout
environment: null
dataset_loader: pipelinerl.domains.guessing.load_problems
train_dataset_names:
- train
test_dataset_names:
- test
You can now launch the training with the following command:
python -m pipelinerl.launch --config-name=guessing output_dir=results/guessing
Once the LLMs are served, the actor will be evaluated on the test dataset before collecting training rollouts.
When enough data has been collected, the trainer will perform a RL step and update the actor's weights.
The streaming logs can be overwhelming, and it is therefore easier to debug using the each process log files in the results/guessing:
After roughly 20 minutes, the actor will have learned a strategy to guess the number correctly. The training can be monitored in real-time using WANDB, which will show the training and test metrics:
Clone the repository and change the directory to pipelinerl
git clone git@github.com:ServiceNow/PipelineRL.git
cd PipelineRL
Create the environments with dependencies.
conda create -n pipeline-rl -y python=3.12
conda run --no-capture-output -n pipeline-rl pip install -e .
conda run --no-capture-output -n pipeline-rl pip install flash-attn==2.8.3 --no-build-isolation
Alternatively for flash-attn, you can install it via prebuilt packages (on Linux):
# Check your PyTorch's C++ ABI setting first:
# python -c "import torch; print(torch._C._GLIBCXX_USE_CXX11_ABI)"
# Use cxx11abiTRUE or cxx11abiFALSE in the URL accordingly
conda run --no-capture-output -n pipeline-rl pip install https://github.com/lesj0610/flash-attention/releases/download/v2.8.3-cu12-torch2.10-cp312/flash_attn-2.8.3%2Bcu12torch2.10cxx11abiTRUE-cp312-cp312-linux_x86_64.whl
By default Pipeline-RL will use the file system as the medium for streaming the generated data to the trainer processes. This works on one node, but the files can get quite large. To use Redis instead you will need to install the Redis server in the same conda environment:
conda install redis-server==7.4.0 -c conda-forge
PipelineRL supports using SandboxFusion to execute and verify coding-task outputs in a remote sandbox.
To run SandboxFusion locally, follow the deployment guide and startup logs here: https://bytedance.github.io/SandboxFusion/docs/docs/get-started#local-deployment
Then point PipelineRL to your sandbox endpoint by setting sandbox_endpoint in your config (for example in conf/coding.yaml) or by exporting SANDBOX_ENDPOINT:
export SANDBOX_ENDPOINT=http://127.0.0.1:8080
PipelineRL supports using SandboxFusion to execute and verify coding-task outputs in a remote sandbox.
To run SandboxFusion locally, follow the deployment guide and startup logs here: https://bytedance.github.io/SandboxFusion/docs/docs/get-started#local-deployment
Then point PipelineRL to your sandbox endpoint by setting sandbox_endpoint in your config (for example in conf/coding.yaml) or by exporting SANDBOX_ENDPOINT:
export SANDBOX_ENDPOINT=http://127.0.0.1:8080
First, activate the conda environment:
conda activate pipeline-rl
Single node with 8 H100 GPUs:
python -m pipelinerl.launch output_dir=results/base1
If you only have 4 H100 GPUs:
python -m pipelinerl.launch --config-name base_4gpu output_dir=results/base1
To use Redis instead of the filesystem for data streaming:
python -m pipelinerl.launch streams=redis output_dir=results/base1
PipelineRL is organized as a modular, Hydra-driven pipeline with 6 core components driving 3 main stages of the RL training: actor, verifier and trainer. Below is a code-grounded mapping of each component:
pipelinerl/launch.py@hydra.main(...) def main(cfg)pipelinerl/world.py) for rank-aware job & GPU placement:
WORLD_SIZE, RANK, and MASTER_ADDR to determine cluster topology.gpus_per_llm from tensor/pipeline parallel settings and allocates each node’s GPUs into actor, preprocessor, and trainer pools based on cfg.world.*_fraction.actor_llm, preprocessor_llm, actor, preprocessor, verifier, and finetune.launch_jobs(...), which invokes:
run_ref_llm → Reference LLM servers for KL penalties.run_actor_llm → Actor LLM servers for policy sampling.run_actor → Actor processes generating raw rollouts.run_preprocess → Preprocessor workers computing advantages & reference log-probs.run_finetune → Trainer workers updating weights via Accelerate, DeepSpeed, or FSDP.run_verifier → Optional verifier servers for final reward checks.run_ref_llm (in launch.py), running vllm.entrypoints.openai.api_server to serve reference log-probs.run_actor_llm → pipelinerl/entrypoints/llm.py → pipelinerl/run_llm.py:
Worker to add:
init_actor_update_group(...) for NCCL process-group setup.receive_weight_update(request) to pause inference, broadcast new weights via NCCL, and reload model parameters.POST /v1/chat/completion for sampling.POST /receive_weight_update for weight updates.pipelinerl/entrypoints/actor.pyload_datasets.wait_for_inference_servers) and optional verifier (wait_for_verifier).TrainerState(exp_path), start listening for weight updates, and block until the first model version arrives.ActorLoop & rollout_maker_entrypoint):
ActorLoop creates problem_queue and result_queue, then spawns multiple worker processes (via mp.Process) to run rollout_maker_entrypoint.TrainerState to get model version.schedule_rollouts(cfg, attempts, problem_queue, result_queue, trainer_state, llms, name), which:
problem_queue (random sampling for training, sequential for testing).cfg.attempts concurrent HTTP calls to Actor LLM servers (generate_math_rollout).RolloutResult objects (texts, log-probs, rewards, latencies) and pushes the full batch into result_queue once all attempts complete.ActorLoop.run):
propagated_weight_version arrived.problem_queue up to the lag-controlled limit (cfg.finetune.max_lag / cfg.attempts).RolloutResult from result_queue.actor stream.SlidingWindowAggregator) and write stats to the stats stream and WANDB.run_actor_loop can pause training scheduling to run a one-shot test loop (is_training=False), based on cfg.eval_every_n_versions.cfg.finetune.max_lag and cfg.finetune.weight_update_interval, ensuring on-policy data freshness.pipelinerl/entrypoints/preprocess.pyrun_dataset_loader (thread) reads raw actor traces in chunks from the input stream.ProcessPoolExecutor workers run process_chunk(...), which:
StreamRangeSpec(topic=cfg.preprocess.output).pipelinerl/entrypoints/finetune.pyrun_sample_loader reads JSON micro-batches from the input stream into a local queue.run_fixed_batch_data_loader or run_dynamic_batch_size_data_loader collates samples into PyTorch tensors.rl_step(...) (in pipelinerl/finetune/rl/utils.py) to compute policy-gradient (+ KL penalty if configured) → optimizer.step() → lr_scheduler.step().WeightUpdateManager.send_weight_update(version) to gather model parameters, send WeightUpdateRequest to Actor LLMs (HTTP), broadcast tensors via NCCL, and write a WeightUpdateSuccess message to the update stream.pipelinerl/entrypoints/verifier.pyPOST / : checks model outputs (math or countdown puzzles) via math_verify or countdown_utils.GET /health: readiness probe.pipelinerl/streams.py.SingleStreamSpec and StreamRangeSpec for file-system or Redis-based queues.write_to_streams(...) and read_stream(...) provide a JSON-line protocol for inter-process messaging.problem_queue (multiprocessing.Queue): produced by ActorLoop.run to hold raw problems; consumed by rollout worker processes in rollout_maker_entrypoint via schedule_rollouts.result_queue (multiprocessing.Queue): produced by rollout workers (lists of RolloutResult); consumed by ActorLoop.run to publish completed rollouts.actor stream (SingleStreamSpec(topic="actor")): file- or Redis-backed stream. Produced by ActorLoop.run writing each sample dict; consumed by the Preprocessor stage (configured via cfg.preprocess.input).training_data stream (StreamRangeSpec(topic="training_data")): File- or Redis-backed stream used to transfer processed training micro-batches from the Preprocessor to the Trainer. Configured via cfg.preprocess.output and cfg.finetune.input (defaulting to "training_data") in conf/base.yaml. Written in pipelinerl/run_preprocess.py and consumed in pipelinerl/run_finetune.py.actor_test and stats_test streams: analogous streams used for evaluation loops (test samples and test metrics).stats stream (SingleStreamSpec(topic="stats")): produced by ActorLoop.publish_stats with sliding-window metrics; consumed by external monitoring (e.g. WANDB, logging viewers).Python
100.0%