Claude Skill

alterlab-pufferlib

Scales reinforcement learning with PufferLib — high-throughput parallel training (PuffeRL), vectorized environments, and native multi-agent systems achieving 2-10x speedups over standard implementations. Use when scaling RL to millions of steps per second, running vectorized or m

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download alterlab-ieu-alterlab-academic-skills-skills_data-science_alterlab-pufferlib-e4836c0.zip · 39 KB
Part of alterlab-ieu/alterlab-academic-skills — 94 skills

Install

skills CLI npx skills add https://github.com/AlterLab-IEU/AlterLab-Academic-Skills/tree/main/skills/data-science/alterlab-pufferlib
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install alterlab-ieu-alterlab-academic-skills@llmmart
Git git clone https://github.com/AlterLab-IEU/AlterLab-Academic-Skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole alterlab-ieu/alterlab-academic-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

PufferLib - High-Performance Reinforcement Learning

Overview

PufferLib is a high-performance reinforcement learning library designed for fast parallel environment simulation and training. It achieves training at millions of steps per second through optimized vectorization, native multi-agent support, and efficient PPO implementation (PuffeRL). The library provides the Ocean suite of 20+ environments and seamless integration with Gymnasium, PettingZoo, and specialized RL frameworks.

When to Use This Skill

Use this skill when:

  • Training RL agents with PPO on any environment (single or multi-agent)
  • Creating custom environments using the PufferEnv API
  • Optimizing performance for parallel environment simulation (vectorization)
  • Integrating existing environments from Gymnasium, PettingZoo, Atari, Procgen, etc.
  • Developing policies with CNN, LSTM, or custom architectures
  • Scaling RL to millions of steps per second for faster experimentation
  • Multi-agent RL with native multi-agent environment support

Does NOT Trigger

Scenario Use Instead
Quick single-agent prototype with a standard, well-documented PPO/SAC/DQN implementation alterlab-stable-baselines3
Supervised deep-learning training loops (classification, regression) with checkpointing and multi-GPU alterlab-pytorch-lightning
Agent-based simulation of social systems with rule-based agents and no learning alterlab-abm-mesa

Core Capabilities

1. High-Performance Training (PuffeRL)

PuffeRL is PufferLib's optimized PPO trainer (CleanRL-derived, with optional LSTM via models.LSTMWrapper) built for high-throughput training.

Recommended path — CLI / high-level helper. Drive training from a config (an .ini in pufferlib/config/) rather than hand-wiring the trainer:

# CLI: env name resolves to a config in pufferlib/config/ + an Ocean env
puffer train puffer_breakout --train.device cuda --train.learning-rate 0.015
# Resume from a checkpoint (top-level flag, not a [train] key)
puffer train puffer_breakout --load-model-path latest
import pufferlib.pufferl as pufferl
# train(env_name, args=None, vecenv=None, policy=None, logger=None)
pufferl.train('puffer_breakout')

Manual loop. PuffeRL(config, vecenv, policy, logger=None) — note the first arg is a config dict (not flat kwargs), the env arg is vecenv, and the loop is driven by global_step. The three loop methods are real: evaluate(), train(), mean_and_log().

import pufferlib.vector
from pufferlib.pufferl import PuffeRL, load_config

# Native PufferEnv -> default backend=PufferEnv. For wrapped (Gymnasium/
# PettingZoo) envs you MUST pass backend=pufferlib.vector.Multiprocessing.
vecenv = pufferlib.vector.make(MyPufferEnv, num_envs=256)

# load_config returns a nested args dict (sections: 'train', 'vec', 'env', ...)
# with defaults from pufferlib/config/*.ini. PuffeRL takes the 'train' section.
args = load_config('puffer_breakout')
config = {**args['train'], 'env': 'puffer_breakout'}
config['device'] = 'cuda'

trainer = PuffeRL(config, vecenv, my_policy)
while trainer.global_step < config['total_timesteps']:
    trainer.evaluate()      # Collect rollouts
    trainer.train()         # Train on batch
    trainer.mean_and_log()  # Aggregate + log

For comprehensive training guidance, read references/training.md for:

  • Complete training workflow and CLI options
  • Hyperparameter tuning with Protein
  • Distributed multi-GPU/multi-node training
  • Logger integration (Weights & Biases, Neptune)
  • Checkpointing and resume training
  • Performance optimization tips
  • Curriculum learning patterns

2. Environment Development (PufferEnv)

Create custom high-performance environments with the PufferEnv API.

Basic environment structure:

import numpy as np
import gymnasium
from pufferlib import PufferEnv

class MyEnvironment(PufferEnv):
    def __init__(self, buf=None):
        # Define spaces BEFORE calling super().__init__(buf)
        self.single_observation_space = gymnasium.spaces.Box(
            low=-np.inf, high=np.inf, shape=(4,), dtype=np.float32)
        self.single_action_space = gymnasium.spaces.Discrete(4)
        self.num_agents = 1

        super().__init__(buf)

    def reset(self, seed=None):
        # Reset state and return (observation, info-list)
        obs = self._get_observation()
        return obs, []

    def step(self, action):
        # Execute action, compute reward, check termination/truncation
        obs = self._get_observation()
        rewards = self._compute_reward()
        terminals = self._is_done()
        truncations = self._is_truncated()
        info = []

        return obs, rewards, terminals, truncations, info

Use the template script: scripts/env_template.py provides complete single-agent and multi-agent environment templates with examples of:

  • Different observation space types (vector, image, dict)
  • Action space variations (discrete, continuous, multi-discrete)
  • Multi-agent environment structure
  • Testing utilities

For complete environment development, read references/environments.md for:

  • PufferEnv API details and in-place operation patterns
  • Observation and action space definitions
  • Multi-agent environment creation
  • Ocean suite (20+ pre-built environments)
  • Performance optimization (Python to C workflow)
  • Environment wrappers and best practices
  • Debugging and validation techniques

3. Vectorization and Performance

Achieve maximum throughput with optimized parallel simulation.

Vectorization setup:

import pufferlib.vector

# Pass an env-constructor callable. Default backend=PufferEnv is native-only;
# for wrapped (Gymnasium/PettingZoo) envs add backend=pufferlib.vector.Multiprocessing.
env = pufferlib.vector.make(env_creator, num_envs=256, num_workers=8)

# Performance benchmarks (PufferLib's published figures; vary by env/hardware):
# - Pure Python envs: 100k-500k SPS
# - C-based envs: 100M+ SPS
# - With training: 400k-4M total SPS

Key optimizations:

  • Shared memory buffers for zero-copy observation passing
  • Busy-wait flags instead of pipes/queues
  • Surplus environments for async returns
  • Multiple environments per worker

For vectorization optimization, read references/vectorization.md for:

  • Architecture and performance characteristics
  • Worker and batch size configuration
  • Serial vs multiprocessing vs async modes
  • Shared memory and zero-copy patterns
  • Hierarchical vectorization for large scale
  • Multi-agent vectorization strategies
  • Performance profiling and troubleshooting

4. Policy Development

Build policies as standard PyTorch modules with optional utilities.

Basic policy structure:

import torch.nn as nn
from pufferlib.pytorch import layer_init

class Policy(nn.Module):
    def __init__(self, observation_space, action_space):
        super().__init__()

        # Encoder
        self.encoder = nn.Sequential(
            layer_init(nn.Linear(obs_dim, 256)),
            nn.ReLU(),
            layer_init(nn.Linear(256, 256)),
            nn.ReLU()
        )

        # Actor and critic heads
        self.actor = layer_init(nn.Linear(256, num_actions), std=0.01)
        self.critic = layer_init(nn.Linear(256, 1), std=1.0)

    def forward(self, observations):
        features = self.encoder(observations)
        return self.actor(features), self.critic(features)

For complete policy development, read references/policies.md for:

  • CNN policies for image observations
  • Recurrent policies with optimized LSTM (3x faster inference)
  • Multi-input policies for complex observations
  • Continuous action policies
  • Multi-agent policies (shared vs independent parameters)
  • Advanced architectures (attention, residual)
  • Observation normalization and gradient clipping
  • Policy debugging and testing

5. Environment Integration

Seamlessly integrate environments from popular RL frameworks.

Gymnasium integration:

import gymnasium as gym
import pufferlib.emulation
import pufferlib.vector

# Wrap a Gymnasium env in a GymnasiumPufferEnv, then vectorize.
# Wrapped (non-native) envs require an explicit backend (Serial or Multiprocessing);
# the default backend=PufferEnv is only for native PufferEnvs.
def env_creator():
    return pufferlib.emulation.GymnasiumPufferEnv(
        env_creator=lambda: gym.make('CartPole-v1'))

env = pufferlib.vector.make(
    env_creator, num_envs=256, backend=pufferlib.vector.Multiprocessing)

PettingZoo multi-agent:

import pufferlib.emulation
import pufferlib.vector
from pettingzoo.butterfly import knights_archers_zombies_v10

# Wrap a PettingZoo env in a PettingZooPufferEnv, then vectorize.
def env_creator():
    return pufferlib.emulation.PettingZooPufferEnv(
        env_creator=lambda: knights_archers_zombies_v10.parallel_env())

env = pufferlib.vector.make(
    env_creator, num_envs=128, backend=pufferlib.vector.Multiprocessing)

Supported frameworks:

  • Gymnasium / OpenAI Gym
  • PettingZoo (parallel and AEC)
  • Atari (ALE)
  • Procgen
  • NetHack / MiniHack
  • Minigrid
  • Neural MMO
  • Crafter
  • GPUDrive
  • MicroRTS
  • Griddly
  • And more...

For integration details, read references/integration.md for:

  • Complete integration examples for each framework
  • Custom wrappers (observation, reward, frame stacking, action repeat)
  • Space flattening and unflattening
  • Environment registration
  • Compatibility patterns
  • Performance considerations
  • Integration debugging

Quick Start Workflow

For Training Existing Environments

  1. Choose environment from Ocean suite or compatible framework
  2. Use scripts/train_template.py as starting point
  3. Configure hyperparameters for your task
  4. Run training with CLI or Python script
  5. Monitor with Weights & Biases or Neptune
  6. Refer to references/training.md for optimization

For Creating Custom Environments

  1. Start with scripts/env_template.py
  2. Define observation and action spaces
  3. Implement reset() and step() methods
  4. Test environment locally
  5. Wrap with pufferlib.emulation.GymnasiumPufferEnv and vectorize with pufferlib.vector.make()
  6. Refer to references/environments.md for advanced patterns
  7. Optimize with references/vectorization.md if needed

For Policy Development

  1. Choose architecture based on observations:
    • Vector observations → MLP policy
    • Image observations → CNN policy
    • Sequential tasks → LSTM policy
    • Complex observations → Multi-input policy
  2. Use layer_init for proper weight initialization
  3. Follow patterns in references/policies.md
  4. Test with environment before full training

For Performance Optimization

  1. Profile current throughput (steps per second)
  2. Check vectorization configuration (num_envs, num_workers)
  3. Optimize environment code (in-place ops, numpy vectorization)
  4. Consider C implementation for critical paths
  5. Use references/vectorization.md for systematic optimization

Resources

scripts/

train_template.py - Complete training script template with:

  • Environment creation and configuration
  • Policy initialization
  • Logger integration (WandB, Neptune)
  • Training loop with checkpointing
  • Command-line argument parsing
  • Multi-GPU distributed training setup

env_template.py - Environment implementation templates:

  • Single-agent PufferEnv example (grid world)
  • Multi-agent PufferEnv example (cooperative navigation)
  • Multiple observation/action space patterns
  • Testing utilities

references/

training.md - Comprehensive training guide:

  • Training workflow and CLI options
  • Hyperparameter configuration
  • Distributed training (multi-GPU, multi-node)
  • Monitoring and logging
  • Checkpointing
  • Protein hyperparameter tuning
  • Performance optimization
  • Common training patterns
  • Troubleshooting

environments.md - Environment development guide:

  • PufferEnv API and characteristics
  • Observation and action spaces
  • Multi-agent environments
  • Ocean suite environments
  • Custom environment development workflow
  • Python to C optimization path
  • Third-party environment integration
  • Wrappers and best practices
  • Debugging

vectorization.md - Vectorization optimization:

  • Architecture and key optimizations
  • Vectorization modes (serial, multiprocessing, async)
  • Worker and batch configuration
  • Shared memory and zero-copy patterns
  • Advanced vectorization (hierarchical, custom)
  • Multi-agent vectorization
  • Performance monitoring and profiling
  • Troubleshooting and best practices

policies.md - Policy architecture guide:

  • Basic policy structure
  • CNN policies for images
  • LSTM policies with optimization
  • Multi-input policies
  • Continuous action policies
  • Multi-agent policies
  • Advanced architectures (attention, residual)
  • Observation processing and unflattening
  • Initialization and normalization
  • Debugging and testing

integration.md - Framework integration guide:

  • Gymnasium integration
  • PettingZoo integration (parallel and AEC)
  • Third-party environments (Procgen, NetHack, Minigrid, etc.)
  • Custom wrappers (observation, reward, frame stacking, etc.)
  • Space conversion and unflattening
  • Environment registration
  • Compatibility patterns
  • Performance considerations
  • Debugging integration

Tips for Success

  1. Start simple: Begin with Ocean environments or Gymnasium integration before creating custom environments

  2. Profile early: Measure steps per second from the start to identify bottlenecks

  3. Use templates: scripts/train_template.py and scripts/env_template.py provide solid starting points

  4. Read references as needed: Each reference file is self-contained and focused on a specific capability

  5. Optimize progressively: Start with Python, profile, then optimize critical paths with C if needed

  6. Leverage vectorization: PufferLib's vectorization is key to achieving high throughput

  7. Monitor training: Use WandB or Neptune to track experiments and identify issues early

  8. Test environments: Validate environment logic before scaling up training

  9. Check existing environments: Ocean suite provides 20+ pre-built environments

  10. Use proper initialization: Always use layer_init from pufferlib.pytorch for policies

Common Use Cases

Training on Standard Benchmarks

import pufferlib.vector

# Atari (pass an env-constructor callable)
env = pufferlib.vector.make(make_pong_env, num_envs=256)

# Procgen
env = pufferlib.vector.make(make_coinrun_env, num_envs=256)

# Minigrid
env = pufferlib.vector.make(make_minigrid_env, num_envs=256)

Multi-Agent Learning

import pufferlib.vector

# PettingZoo, wrapped via PettingZooPufferEnv (needs an explicit backend)
env = pufferlib.vector.make(
    make_pistonball_env, num_envs=128, backend=pufferlib.vector.Multiprocessing)

# One shared policy serves all agents (single_observation_space / single_action_space
# are per-agent). Pass config (dict), vecenv, policy positionally to PuffeRL.
policy = create_policy(env.single_observation_space, env.single_action_space)
trainer = PuffeRL(config, env, policy)

Custom Task Development

import pufferlib.vector

# Create custom environment (a native PufferEnv subclass)
class MyTask(PufferEnv):
    # ... implement environment ...

# Native PufferEnv -> default backend=PufferEnv is fine here.
env = pufferlib.vector.make(MyTask, num_envs=256)
trainer = PuffeRL(config, env, my_policy)  # config is a dict (see Training above)

High-Performance Optimization

import pufferlib.vector

# Maximize throughput (pass an env-constructor callable)
env = pufferlib.vector.make(
    my_env_creator,     # env constructor callable
    num_envs=1024,      # Large batch
    num_workers=16,     # Many workers
    backend=pufferlib.vector.Multiprocessing,
)

Installation

# Pin the 3.0 line — the config-dict trainer API and import paths in this skill
# target it. PyPI ships only an sdist, so a C compiler is needed to build it.
uv pip install "pufferlib==3.0.*"

PufferLib 5.0 is a different product. The upstream default branch (5.0) is a C/CUDA trainer built from source via PufferTank (./build.sh ENV, ./puffer train). It has no pip package, has dropped the third-party (Gymnasium/PettingZoo-style) integrations, and offers CPU evaluation but no CPU training, so do not assume the Python APIs in this skill (PuffeRL, pufferlib.vector.make, pufferlib.emulation) exist there. If a user is on 5.0, say so and point them to its docs rather than adapting 3.0 code.

Documentation

Files (alterlab-academic-skills)
  • evals
    • evals.json 5.4 KB
      {
        "skill": "alterlab-pufferlib",
        "evals": [
          {
            "id": "vectorized-ppo-training-throughput",
            "prompt": "I'm training a PPO agent on Procgen coinrun and standard training is way too slow. I want to push hundreds of parallel environments and hit millions of steps per second on my GPU. How do I set this up?",
            "expected_output": "Invokes alterlab-pufferlib: builds a vectorized environment with pufferlib.vector.make(env_creator, num_envs=256, num_workers=...) passing an env-constructor callable, trains with the PuffeRL (PPO+LSTM) trainer on device='cuda' using the evaluate()/train()/mean_and_log() loop, and tunes num_envs/num_workers/batch_size to maximize steps-per-second throughput.",
            "assertions": [
              { "type": "should_trigger", "value": true },
              { "type": "output_contains", "value": "PuffeRL" },
              { "type": "behavior", "value": "Uses pufferlib.vector.make with many parallel envs and the PuffeRL trainer to maximize steps-per-second, tuning num_envs/num_workers." }
            ]
          },
          {
            "id": "custom-pufferenv-implementation",
            "prompt": "I need to build my own high-performance grid-world environment for RL. How do I implement it so it works with PufferLib's fast training and vectorization?",
            "expected_output": "Invokes alterlab-pufferlib: implements a custom environment by subclassing PufferEnv, defining single_observation_space and single_action_space plus num_agents BEFORE calling super().__init__(buf), and implementing reset(seed) returning (obs, info-list) and step(action) returning (obs, rewards, terminals, truncations, info) with in-place buffer operations; suggests starting from scripts/env_template.py.",
            "assertions": [
              { "type": "should_trigger", "value": true },
              { "type": "output_contains", "value": "PufferEnv" },
              { "type": "behavior", "value": "Subclasses PufferEnv, defines observation/action spaces before super().__init__(buf), and implements reset/step with the PufferEnv signature." }
            ]
          },
          {
            "id": "multi-agent-pettingzoo-integration",
            "prompt": "I have a cooperative multi-agent PettingZoo environment (knights_archers_zombies) and I want to train all agents with a shared policy at scale. How do I wrap and vectorize it in PufferLib?",
            "expected_output": "Invokes alterlab-pufferlib: wraps the PettingZoo parallel_env in pufferlib.emulation.PettingZooPufferEnv via an env_creator callable, vectorizes with pufferlib.vector.make(env_creator, num_envs=...), and trains a shared policy built from env.single_observation_space / env.single_action_space using the PuffeRL trainer for native multi-agent RL.",
            "assertions": [
              { "type": "should_trigger", "value": true },
              { "type": "output_contains", "value": "PettingZooPufferEnv" },
              { "type": "behavior", "value": "Wraps the PettingZoo env with PettingZooPufferEnv, vectorizes it, and trains a shared multi-agent policy." }
            ]
          },
          {
            "id": "lstm-cnn-policy-development",
            "prompt": "My environment gives image observations and the task is partially observable, so I think I need a recurrent CNN policy. How do I write the policy module for PufferLib training?",
            "expected_output": "Invokes alterlab-pufferlib: builds the policy as a standard torch.nn.Module with a CNN encoder for image observations feeding an optimized LSTM for the partially-observable/recurrent component, with separate actor and critic heads, using layer_init from pufferlib.pytorch for proper weight initialization (actor std=0.01, critic std=1.0); points to references/policies.md.",
            "assertions": [
              { "type": "should_trigger", "value": true },
              { "type": "output_contains", "value": "layer_init" },
              { "type": "behavior", "value": "Builds a CNN+LSTM torch.nn.Module policy with actor/critic heads using layer_init from pufferlib.pytorch." }
            ]
          },
          {
            "id": "near-miss-alterlab-stable-baselines3",
            "prompt": "I just want to quickly prototype a standard PPO agent on CartPole-v1 using a well-documented, off-the-shelf implementation with sensible defaults. I don't care about squeezing out maximum throughput, I just want something that works in a few lines.",
            "expected_output": "Does NOT invoke this skill; defers to alterlab-stable-baselines3. The user explicitly wants quick prototyping with a standard, well-documented off-the-shelf algorithm rather than high-throughput vectorized scaling, which is stable-baselines3's territory rather than PufferLib's.",
            "assertions": [
              { "type": "should_not_trigger", "value": true },
              { "type": "output_contains", "value": "alterlab-stable-baselines3" }
            ]
          },
          {
            "id": "near-miss-alterlab-pytorch-lightning",
            "prompt": "I'm training a supervised image classifier with a CNN and I want clean multi-GPU training, checkpointing, and logging boilerplate handled for me. How should I structure the training loop?",
            "expected_output": "Does NOT invoke this skill; defers to alterlab-pytorch-lightning. The user is doing supervised deep learning (image classification), not reinforcement learning with environments and policies, so the general training-loop organization belongs to pytorch-lightning rather than PufferLib's RL-specific tooling.",
            "assertions": [
              { "type": "should_not_trigger", "value": true },
              { "type": "output_contains", "value": "alterlab-pytorch-lightning" }
            ]
          }
        ]
      }
      
  • references
    • environments.md 15.4 KB
      # PufferLib Environments Guide
      
      ## Overview
      
      PufferLib provides the PufferEnv API for creating high-performance custom environments, and the Ocean suite containing 20+ pre-built environments. Environments support both single-agent and multi-agent scenarios with native vectorization.
      
      ## PufferEnv API
      
      ### Core Characteristics
      
      PufferEnv is designed for performance through in-place operations:
      - Observations, actions, and rewards are initialized from a shared buffer object
      - All operations happen in-place to avoid creating and copying arrays
      - Native support for both single-agent and multi-agent environments
      - **Native spaces must be flat**: `single_observation_space` must be a `Box`, and `single_action_space` a `Discrete`, `MultiDiscrete`, or `Box`. A `Dict` (or other structured) observation space is **not** allowed for a native PufferEnv — the base class raises `APIUsageError`. If you need Dict/structured observations, build a Gymnasium env and use `pufferlib.emulation.GymnasiumPufferEnv`, which flattens them for you.
      
      ### Creating a PufferEnv
      
      ```python
      import numpy as np
      import gymnasium
      import pufferlib
      from pufferlib import PufferEnv
      
      class MyEnvironment(PufferEnv):
          def __init__(self, buf=None):
              # Define spaces and num_agents BEFORE super().__init__(buf).
              # Native obs space MUST be a Box (no Dict). Here: an 84x84x3 image.
              self.single_observation_space = gymnasium.spaces.Box(
                  low=0, high=255, shape=(84, 84, 3), dtype=np.uint8)
              self.single_action_space = gymnasium.spaces.Discrete(4)  # 4 discrete actions
              self.num_agents = 1
      
              super().__init__(buf)
      
          def reset(self, seed=None):
              """Reset environment to initial state. Returns (obs, info-list)."""
              # Reset internal state
              self.agent_pos = np.array([0, 0])
              self.step_count = 0
      
              # Return initial observation and an (empty) info LIST (asserted by PufferEnv)
              obs = np.zeros((84, 84, 3), dtype=np.uint8)
              return obs, []
      
          def step(self, action):
              """Execute one environment step. Returns 5 values."""
              # Update state based on action
              self.step_count += 1
      
              # Calculate reward
              rewards = self._compute_reward()
      
              # Check termination vs. truncation
              terminals = self._is_done()
              truncations = self.step_count >= 1000
      
              # Generate observation
              obs = self._get_observation()
      
              # Additional info (list of dicts; PufferEnv asserts info is a list)
              info = [{'episode': {'r': rewards, 'l': self.step_count}}] if truncations else []
      
              return obs, rewards, terminals, truncations, info
      
          def _compute_reward(self):
              """Compute reward for current state."""
              return 1.0
      
          def _get_observation(self):
              """Generate observation from current state (a Box-shaped array)."""
              return np.random.randint(0, 256, (84, 84, 3), dtype=np.uint8)
      ```
      
      ### Observation Spaces
      
      Define spaces directly with `gymnasium.spaces` and assign them to
      `self.single_observation_space` / `self.single_action_space` before calling
      `super().__init__(buf)`.
      
      #### Native observation spaces (Box only)
      
      A native PufferEnv observation space must be a `Box`. Encode discrete or
      heterogeneous state into a single Box vector/array:
      
      ```python
      import gymnasium
      
      # Flat continuous/feature vector
      self.single_observation_space = gymnasium.spaces.Box(
          low=-np.inf, high=np.inf, shape=(10,), dtype=np.float32)
      
      # Image observation
      self.single_observation_space = gymnasium.spaces.Box(
          low=0, high=255, shape=(84, 84, 3), dtype=np.uint8)
      ```
      
      #### Dict / structured observations (emulation path only)
      
      `Dict` observation spaces are **not** valid for a native PufferEnv. To use them,
      build a Gymnasium env and wrap it with `pufferlib.emulation.GymnasiumPufferEnv`,
      which flattens the Dict to a Box for you (recover the structure in the policy via
      `pufferlib.pytorch.nativize_tensor`):
      
      ```python
      import gymnasium
      # Inside a standard gymnasium.Env (NOT a PufferEnv):
      observation_space = gymnasium.spaces.Dict({
          'image': gymnasium.spaces.Box(low=0, high=255, shape=(84, 84, 3), dtype=np.uint8),
          'vector': gymnasium.spaces.Box(low=-np.inf, high=np.inf, shape=(10,), dtype=np.float32),
      })
      ```
      
      ### Action Spaces
      
      ```python
      import gymnasium
      
      # Discrete actions
      self.single_action_space = gymnasium.spaces.Discrete(4)  # 4 actions: 0, 1, 2, 3
      
      # Continuous actions
      self.single_action_space = gymnasium.spaces.Box(
          low=-1.0, high=1.0, shape=(3,), dtype=np.float32)  # 3D continuous action
      
      # Multi-discrete actions
      self.single_action_space = gymnasium.spaces.MultiDiscrete([3, 3])  # Two 3-way discrete choices
      ```
      
      ## Multi-Agent Environments
      
      PufferLib has native multi-agent support, treating single-agent and multi-agent environments uniformly: set `num_agents > 1` and use the **same** array-based API as a single-agent env. There is no `{agent_id: ...}` dict / `'__all__'` convention in native PufferEnv — that belongs to PettingZoo (use `pufferlib.emulation.PettingZooPufferEnv` for those). The native obs/reward/terminal/truncation buffers are simply sized along a leading agent axis, and `info` is a list of dicts.
      
      ### Multi-Agent PufferEnv
      
      ```python
      import gymnasium
      import numpy as np
      
      class MultiAgentEnv(PufferEnv):
          def __init__(self, num_agents=4, buf=None):
              # Define spaces and num_agents BEFORE super().__init__(buf).
              # single_*_space is PER AGENT and must be a Box (obs) / Discrete-or-Box (action).
              self.num_agents = num_agents
              self.single_observation_space = gymnasium.spaces.Box(
                  low=-np.inf, high=np.inf, shape=(14,), dtype=np.float32)  # e.g. pos+vel+global
              self.single_action_space = gymnasium.spaces.Discrete(5)
      
              super().__init__(buf)
      
          def reset(self, seed=None):
              """Reset all agents. Returns (obs, info-list)."""
              # obs is shaped (num_agents, *single_observation_space.shape)
              obs = np.zeros((self.num_agents, 14), dtype=np.float32)
              return obs, []
      
          def step(self, actions):
              """Step all agents. `actions` is an array of shape (num_agents, ...).
              Returns (obs, rewards, terminals, truncations, info-list) where the first
              four are arrays of length num_agents and info is a list of dicts."""
              obs = self._get_observations()                 # (num_agents, 14)
              rewards = self._compute_rewards()               # (num_agents,)
              terminals = self._terminated()                  # (num_agents,) bool
              truncations = self._truncated()                 # (num_agents,) bool
              info = []
              return obs, rewards, terminals, truncations, info
      ```
      
      ## Environments: Ocean vs. third-party bindings
      
      PufferLib ships two distinct collections — keep them straight:
      
      - **Ocean** (`pufferlib.ocean`) is PufferLib's own suite of fast, mostly native-C
        environments. It includes `breakout`, `pong`, `snake`, `enduro`, `freeway`,
        `connect4`, `go`, `g2048`, `tetris`, `pacman`, `nmmo3`, `moba`, `drive`,
        `rware`, `trash_pickup`, `cartpole`, `grid`, and many more (40+). These are the
        high-throughput envs the SPS benchmarks refer to.
      - **Third-party bindings** (`pufferlib.environments.*`) wrap external suites:
        `atari`, `procgen`, `nethack`, `minihack`, `minigrid`, `crafter`, `craftax`,
        `butterfly` (PettingZoo), `magent`, `nmmo`, `gpudrive`, `microrts`, `griddly`,
        `pokemon_red`, `mujoco`, `dm_control`, `vizdoom`, and others. So Atari, Procgen,
        and NetHack are **bindings, not Ocean envs**.
      
      ### Using these environments
      
      ```python
      import functools
      import pufferlib.vector
      import pufferlib.ocean
      
      # Ocean (native): resolve a constructor with env_creator. The name is prefixed
      # 'puffer_' (matching the CLI `puffer train puffer_breakout`).
      breakout = pufferlib.ocean.environment.env_creator('puffer_breakout')
      env = pufferlib.vector.make(breakout, num_envs=256)
      
      # Bind constructor kwargs with functools.partial (PufferLib has no string registry).
      snake = pufferlib.ocean.environment.env_creator('puffer_snake')
      env = pufferlib.vector.make(functools.partial(snake, num_agents=4), num_envs=128)
      
      # Third-party bindings live under pufferlib.environments.<name> and expose an
      # `env_creator`. They are not native, so pass an explicit backend.
      import pufferlib.environments.atari as atari
      env = pufferlib.vector.make(
          atari.env_creator('BreakoutNoFrameskip-v4'),
          num_envs=128, backend=pufferlib.vector.Multiprocessing)
      ```
      
      ## Custom Environment Development
      
      ### Development Workflow
      
      1. **Prototype in Python**: Start with pure Python PufferEnv
      2. **Optimize Critical Paths**: Identify bottlenecks
      3. **Implement in C**: Rewrite performance-critical code in C
      4. **Create Bindings**: Use Python C API
      5. **Compile**: Build as extension module
      6. **Register**: Add to Ocean suite
      
      ### Performance Benchmarks
      
      - **Pure Python**: 100k-500k steps/second
      - **C Implementation**: 100M+ steps/second
      - **Training with Python env**: ~400k total SPS
      - **Training with C env**: ~4M total SPS
      
      ### Python Optimization Tips
      
      ```python
      # Use NumPy operations instead of Python loops
      # Bad
      for i in range(len(array)):
          array[i] = array[i] * 2
      
      # Good
      array *= 2
      
      # Pre-allocate arrays instead of appending
      # Bad
      observations = []
      for i in range(n):
          observations.append(generate_obs())
      
      # Good
      observations = np.empty((n, obs_shape), dtype=np.float32)
      for i in range(n):
          observations[i] = generate_obs()
      
      # Use in-place operations
      # Bad
      new_state = state + delta
      
      # Good
      state += delta
      ```
      
      ### C Extension Example
      
      ```c
      // my_env.c
      #include <Python.h>
      #include <numpy/arrayobject.h>
      
      // Fast environment step implementation
      static PyObject* fast_step(PyObject* self, PyObject* args) {
          PyArrayObject* state;
          int action;
      
          if (!PyArg_ParseTuple(args, "O!i", &PyArray_Type, &state, &action)) {
              return NULL;
          }
      
          // High-performance C implementation
          // ...
      
          return Py_BuildValue("Ofi", obs, reward, done);
      }
      
      static PyMethodDef methods[] = {
          {"fast_step", fast_step, METH_VARARGS, "Fast environment step"},
          {NULL, NULL, 0, NULL}
      };
      
      static struct PyModuleDef module = {
          PyModuleDef_HEAD_INIT,
          "my_env_c",
          NULL,
          -1,
          methods
      };
      
      PyMODINIT_FUNC PyInit_my_env_c(void) {
          import_array();
          return PyModule_Create(&module);
      }
      ```
      
      ## Third-Party Environment Integration
      
      ### Gymnasium Environments
      
      ```python
      import gymnasium as gym
      import pufferlib.emulation
      import pufferlib.vector
      
      # Wrap a Gymnasium env in a GymnasiumPufferEnv, then vectorize. Wrapped
      # (non-native) envs require an explicit backend (default backend=PufferEnv is
      # native-only).
      def env_creator():
          return pufferlib.emulation.GymnasiumPufferEnv(
              env_creator=lambda: gym.make('CartPole-v1'))
      
      env = pufferlib.vector.make(
          env_creator, num_envs=256, backend=pufferlib.vector.Multiprocessing)
      ```
      
      ### PettingZoo Environments
      
      ```python
      from pettingzoo.butterfly import pistonball_v6
      import pufferlib.emulation
      import pufferlib.vector
      
      # Wrap a PettingZoo env in a PettingZooPufferEnv, then vectorize.
      def env_creator():
          return pufferlib.emulation.PettingZooPufferEnv(
              env_creator=lambda: pistonball_v6.parallel_env())
      
      env = pufferlib.vector.make(
          env_creator, num_envs=128, backend=pufferlib.vector.Multiprocessing)
      ```
      
      ### Custom Wrappers
      
      A PufferEnv defines `single_observation_space` / `single_action_space` — assigning
      `observation_space` / `action_space` is rejected by the base class. Mirror the
      wrapped env's *single* spaces, set `num_agents` before `super().__init__`, and keep
      the native 5-tuple step signature.
      
      ```python
      class CustomWrapper(pufferlib.PufferEnv):
          """Wrapper to modify a native PufferEnv's behavior."""
      
          def __init__(self, base_env, buf=None):
              self.base_env = base_env
              # Copy the per-agent spaces (NOT observation_space/action_space).
              self.single_observation_space = base_env.single_observation_space
              self.single_action_space = base_env.single_action_space
              self.num_agents = base_env.num_agents
              super().__init__(buf)
      
          def reset(self, seed=None):
              obs, info = self.base_env.reset(seed)
              return self._process_obs(obs), info
      
          def step(self, action):
              action = self._process_action(action)
              obs, rewards, terminals, truncations, info = self.base_env.step(action)
              obs = self._process_obs(obs)
              rewards = self._process_reward(rewards)
              return obs, rewards, terminals, truncations, info
      ```
      
      ## Environment Best Practices
      
      ### State Management
      
      ```python
      # Store minimal state, compute on demand
      class EfficientEnv(PufferEnv):
          def __init__(self, buf=None):
              super().__init__(buf)
              self.agent_pos = np.zeros(2)  # Minimal state
      
          def _get_observation(self):
              # Compute full observation on demand
              observation = np.zeros((84, 84, 3), dtype=np.uint8)
              self._render_scene(observation, self.agent_pos)
              return observation
      ```
      
      ### Reward Scaling
      
      ```python
      # Normalize rewards to reasonable range
      def step(self, action):
          # ... environment logic ...
      
          # Scale large rewards
          raw_reward = compute_raw_reward()
          reward = np.clip(raw_reward / 100.0, -10, 10)
      
          return obs, reward, terminal, truncation, info
      ```
      
      ### Episode Termination
      
      PufferLib keeps termination and truncation separate (the 4th return value).
      A time limit is a *truncation*; reaching a goal/failure is a *termination*.
      
      ```python
      def step(self, action):
          # ... environment logic ...
      
          terminal = self._check_success() or self._check_failure()  # task-ending
          truncation = self.step_count >= self.max_steps             # time limit
          info = [{'success': self._check_success()}] if (terminal or truncation) else []
      
          return obs, reward, terminal, truncation, info
      ```
      
      ### Memory Efficiency
      
      ```python
      # Reuse buffers instead of allocating new ones
      class MemoryEfficientEnv(PufferEnv):
          def __init__(self, buf=None):
              super().__init__(buf)
      
              # Pre-allocate observation buffer
              self._obs_buffer = np.zeros((84, 84, 3), dtype=np.uint8)
      
          def _get_observation(self):
              # Reuse buffer, modify in place
              self._render_scene(self._obs_buffer)
              return self._obs_buffer  # Return view, not copy
      ```
      
      ## Debugging Environments
      
      ### Validation Checks
      
      ```python
      # Add assertions to catch bugs. Note: PufferEnv exposes single_*_space (per agent);
      # observation_space/action_space are the joint (batched) spaces set by the base class.
      def step(self, action):
          obs, reward, terminal, truncation, info = self._step_impl(action)
      
          assert self.single_observation_space.contains(obs), "Invalid observation"
          assert np.all(np.isfinite(reward)), "Non-finite reward"
      
          return obs, reward, terminal, truncation, info
      ```
      
      ### Rendering
      
      ```python
      class DebuggableEnv(PufferEnv):
          def __init__(self, buf=None, render_mode=None):
              super().__init__(buf)
              self.render_mode = render_mode
      
          def render(self):
              """Render environment for debugging."""
              if self.render_mode == 'human':
                  # Display to screen
                  self._display_scene()
              elif self.render_mode == 'rgb_array':
                  # Return image
                  return self._render_to_array()
      ```
      
      ### Logging
      
      ```python
      import logging
      
      logger = logging.getLogger(__name__)
      
      def step(self, action):
          logger.debug(f"Step {self.step_count}: action={action}")
      
          obs, reward, terminal, truncation, info = self._step_impl(action)
      
          if np.any(terminal) or np.any(truncation):
              logger.info(f"Episode finished: reward={self.total_reward}")
      
          return obs, reward, terminal, truncation, info
      ```
      
    • integration.md 19.7 KB
      # PufferLib Integration Guide
      
      > Targets PufferLib **3.0.x**.
      >
      > **Critical rule for this whole guide:** any env wrapped via the emulation layer
      > (`GymnasiumPufferEnv` / `PettingZooPufferEnv`) is *not* a native PufferEnv, so
      > `pufferlib.vector.make` requires an explicit `backend=pufferlib.vector.Multiprocessing`
      > (or `Serial`). The default `backend=PufferEnv` is native-only and raises
      > `APIUsageError` on a wrapped env. The examples below omit it for brevity only where
      > noted — add it in real code.
      
      ## Overview
      
      PufferLib provides an emulation layer that enables seamless integration with popular RL frameworks including Gymnasium, OpenAI Gym, PettingZoo, and many specialized environment libraries. The emulation layer flattens observation and action spaces for efficient vectorization while maintaining compatibility. Many third-party suites also ship ready-made bindings under `pufferlib.environments.*` (e.g. `atari`, `procgen`, `nethack`, `minigrid`, `crafter`), each exposing an `env_creator`.
      
      ## Gymnasium Integration
      
      ### Basic Gymnasium Environments
      
      ```python
      import gymnasium as gym
      import pufferlib.emulation
      import pufferlib.vector
      
      # PufferLib has no string registry -- pass an environment constructor
      # (callable) to `pufferlib.vector.make`. There is no top-level
      # `pufferlib.emulate`/`pufferlib.make`; wrap Gymnasium envs with
      # `pufferlib.emulation.GymnasiumPufferEnv`, then vectorize.
      
      # Method 1: Wrap a single Gymnasium env, then vectorize.
      # Wrapped envs require an explicit backend (default backend=PufferEnv is native-only).
      def cartpole_creator():
          return pufferlib.emulation.GymnasiumPufferEnv(
              env_creator=lambda: gym.make('CartPole-v1'))
      
      env = pufferlib.vector.make(
          cartpole_creator, num_envs=256, backend=pufferlib.vector.Multiprocessing)
      
      # Method 2: Same pattern with an inline creator
      env = pufferlib.vector.make(
          lambda: pufferlib.emulation.GymnasiumPufferEnv(
              env_creator=lambda: gym.make('CartPole-v1')),
          num_envs=256, backend=pufferlib.vector.Multiprocessing,
      )
      
      # Method 3: Custom Gymnasium environment
      class MyGymEnv(gym.Env):
          def __init__(self):
              self.observation_space = gym.spaces.Box(low=-1, high=1, shape=(4,))
              self.action_space = gym.spaces.Discrete(2)
      
          def reset(self, seed=None, options=None):
              super().reset(seed=seed)
              return self.observation_space.sample(), {}
      
          def step(self, action):
              obs = self.observation_space.sample()
              reward = 1.0
              terminated = False
              truncated = False
              info = {}
              return obs, reward, terminated, truncated, info
      
      # Wrap custom environment (MyGymEnv is a gym.Env, so emulate it)
      env = pufferlib.vector.make(
          lambda: pufferlib.emulation.GymnasiumPufferEnv(env_creator=MyGymEnv),
          num_envs=128, backend=pufferlib.vector.Multiprocessing,
      )
      ```
      
      ### Atari Environments
      
      The simplest path is PufferLib's bundled Atari binding, which handles the standard
      preprocessing for you:
      
      ```python
      import pufferlib.environments.atari as atari
      import pufferlib.vector
      
      # The binding takes an ALE ROM name (e.g. 'breakout', 'pong') and returns an
      # already-emulated env, so still pass a non-native backend.
      env = pufferlib.vector.make(
          atari.env_creator('breakout'),
          num_envs=256, backend=pufferlib.vector.Multiprocessing)
      ```
      
      You can also build the Gymnasium env yourself and emulate it:
      
      ```python
      import functools
      import gymnasium as gym
      from gymnasium.wrappers import AtariPreprocessing, FrameStack
      import pufferlib.emulation
      import pufferlib.vector
      
      def make_atari_env(env_name='ALE/Pong-v5'):
          env = gym.make(env_name)
          env = AtariPreprocessing(env, frame_skip=4)
          env = FrameStack(env, num_stack=4)
          return env
      
      def atari_creator():
          return pufferlib.emulation.GymnasiumPufferEnv(env_creator=make_atari_env)
      
      env = pufferlib.vector.make(
          atari_creator, num_envs=256, backend=pufferlib.vector.Multiprocessing)
      ```
      
      ### Complex Observation Spaces
      
      ```python
      import numpy as np
      import gymnasium as gym
      from gymnasium.spaces import Dict, Box, Discrete
      import pufferlib.emulation
      import pufferlib.vector
      
      class ComplexObsEnv(gym.Env):
          def __init__(self):
              # Dict observation space
              self.observation_space = Dict({
                  'image': Box(low=0, high=255, shape=(84, 84, 3), dtype=np.uint8),
                  'vector': Box(low=-np.inf, high=np.inf, shape=(10,), dtype=np.float32),
                  'discrete': Discrete(5)
              })
              self.action_space = Discrete(4)
      
          def reset(self, seed=None, options=None):
              return {
                  'image': np.zeros((84, 84, 3), dtype=np.uint8),
                  'vector': np.zeros(10, dtype=np.float32),
                  'discrete': 0
              }, {}
      
          def step(self, action):
              obs = {
                  'image': np.random.randint(0, 256, (84, 84, 3), dtype=np.uint8),
                  'vector': np.random.randn(10).astype(np.float32),
                  'discrete': np.random.randint(0, 5)
              }
              return obs, 1.0, False, False, {}
      
      # GymnasiumPufferEnv flattens Dict spaces to a Box for the trainer; recover the
      # structure in the policy via pufferlib.pytorch.nativize_tensor (see policies.md).
      env = pufferlib.vector.make(
          lambda: pufferlib.emulation.GymnasiumPufferEnv(env_creator=ComplexObsEnv),
          num_envs=128, backend=pufferlib.vector.Multiprocessing,
      )
      ```
      
      ## PettingZoo Integration
      
      ### Parallel Environments
      
      ```python
      from pettingzoo.butterfly import pistonball_v6
      import pufferlib.emulation
      import pufferlib.vector
      
      # Multi-agent envs are wrapped with PettingZooPufferEnv, then vectorized.
      # PufferLib has no string registry -- pass the constructor (callable).
      def pistonball_creator():
          return pufferlib.emulation.PettingZooPufferEnv(
              env_creator=lambda: pistonball_v6.parallel_env())
      
      env = pufferlib.vector.make(
          pistonball_creator, num_envs=128, backend=pufferlib.vector.Multiprocessing)
      ```
      
      ### AEC (Agent Environment Cycle) Environments
      
      ```python
      from pettingzoo.classic import chess_v5
      from pettingzoo.utils import aec_to_parallel
      import pufferlib.emulation
      import pufferlib.vector
      
      # Convert AEC to a parallel env, wrap with PettingZooPufferEnv, then vectorize.
      # PufferLib has no string registry -- pass the constructor (callable).
      def chess_creator():
          return pufferlib.emulation.PettingZooPufferEnv(
              env_creator=lambda: aec_to_parallel(chess_v5.env()))
      
      env = pufferlib.vector.make(
          chess_creator, num_envs=64, backend=pufferlib.vector.Multiprocessing)
      ```
      
      ### Multi-Agent Training
      
      ```python
      import pufferlib.emulation
      import pufferlib.vector
      from pufferlib.pufferl import PuffeRL, load_config
      
      # Create multi-agent environment. `kaz_creator` is a placeholder that wraps a
      # PettingZoo env (e.g. knights_archers_zombies) with PettingZooPufferEnv.
      env = pufferlib.vector.make(
          kaz_creator, num_envs=128, backend=pufferlib.vector.Multiprocessing)
      
      # One shared policy serves all agents. PufferLib emulates PettingZoo as a single
      # flat agent axis, so build the policy from the PER-AGENT spaces.
      policy = create_policy(env.single_observation_space, env.single_action_space)
      
      # Train: PuffeRL(config_dict, vecenv, policy). Loop on global_step.
      args = load_config('puffer_breakout')          # or your own env config
      config = {**args['train'], 'env': 'multiagent'}
      trainer = PuffeRL(config, env, policy)
      
      while trainer.global_step < config['total_timesteps']:
          trainer.evaluate()   # rollouts are batched arrays, not {agent_id: ...} dicts
          trainer.train()
          trainer.mean_and_log()
      ```
      
      ## Third-Party Environments
      
      PufferLib ships bindings for these suites under `pufferlib.environments.<name>`,
      each exposing an `env_creator(name, ...)` (install the underlying package first).
      They are emulated, so pass `backend=pufferlib.vector.Multiprocessing`. The
      `make_*` names in the GPUDrive/MicroRTS/Griddly snippets below are placeholders
      for whichever creator you obtain (binding `env_creator` or your own callable).
      
      ### Procgen
      
      ```python
      import pufferlib.environments.procgen as procgen
      import pufferlib.vector
      
      env = pufferlib.vector.make(
          procgen.env_creator('coinrun'),
          num_envs=256, backend=pufferlib.vector.Multiprocessing)
      ```
      
      ### NetHack / MiniHack
      
      ```python
      import pufferlib.environments.nethack as nethack
      import pufferlib.vector
      
      env = pufferlib.vector.make(
          nethack.env_creator('nethack'),
          num_envs=128, backend=pufferlib.vector.Multiprocessing)
      ```
      
      ### Minigrid
      
      ```python
      import pufferlib.environments.minigrid as minigrid
      import pufferlib.vector
      
      env = pufferlib.vector.make(
          minigrid.env_creator('MiniGrid-Empty-8x8-v0'),
          num_envs=256, backend=pufferlib.vector.Multiprocessing)
      ```
      
      ### Neural MMO
      
      ```python
      import functools
      import pufferlib.vector
      
      # Large-scale multi-agent environment.
      # Pass a constructor callable -- there is no string registry. `make_neuralmmo`
      # is a placeholder for the env creator you provide; bind kwargs with partial.
      env = pufferlib.vector.make(
          functools.partial(
              make_neuralmmo,
              num_agents=128,  # Agents per environment
              map_size=128,
          ),
          num_envs=64,
      )
      ```
      
      ### Crafter
      
      ```python
      import pufferlib.vector
      
      # Open-ended crafting environment.
      # Pass a constructor callable -- there is no string registry. `make_crafter`
      # is a placeholder for the env creator you provide.
      env = pufferlib.vector.make(make_crafter, num_envs=128)
      ```
      
      ### GPUDrive
      
      ```python
      import functools
      import pufferlib.vector
      
      # GPU-accelerated driving simulator.
      # Pass a constructor callable -- there is no string registry. `make_gpudrive`
      # is a placeholder for the env creator you provide; bind kwargs with partial.
      env = pufferlib.vector.make(
          functools.partial(make_gpudrive, num_vehicles=8),
          num_envs=1024,  # Can handle many environments on GPU
      )
      ```
      
      ### MicroRTS
      
      ```python
      import functools
      import pufferlib.vector
      
      # Real-time strategy game.
      # Pass a constructor callable -- there is no string registry. `make_microrts`
      # is a placeholder for the env creator you provide; bind kwargs with partial.
      env = pufferlib.vector.make(
          functools.partial(make_microrts, map_size=16, max_steps=2000),
          num_envs=128,
      )
      ```
      
      ### Griddly
      
      ```python
      import pufferlib.vector
      
      # Grid-based games.
      # Pass a constructor callable -- there is no string registry. make_griddly_*
      # are placeholders for the env creators you provide.
      env = pufferlib.vector.make(make_griddly_clusters, num_envs=256)
      env = pufferlib.vector.make(make_griddly_sokoban, num_envs=256)
      ```
      
      ## Custom Wrappers
      
      There is **no `pufferlib.Wrapper`** base class. For emulated envs, wrap at the
      **Gymnasium** level (subclass `gymnasium.Wrapper`) *before* passing the env to
      `GymnasiumPufferEnv` — that way you keep the standard Gymnasium 5-tuple step
      `(obs, reward, terminated, truncated, info)` and PufferLib handles the rest.
      PufferLib also ships ready wrappers (`pufferlib.ResizeObservation`,
      `pufferlib.ClipAction`, `pufferlib.EpisodeStats`). For frame stacking / action
      repeat, prefer the standard `gymnasium.wrappers` (e.g. `FrameStack`).
      
      ```python
      import numpy as np
      import gymnasium as gym
      import pufferlib.emulation
      import pufferlib.vector
      
      class RewardShaping(gym.Wrapper):
          """Add a shaped reward at the Gymnasium level."""
      
          def __init__(self, env, shaping_fn):
              super().__init__(env)
              self.shaping_fn = shaping_fn
      
          def step(self, action):
              obs, reward, terminated, truncated, info = self.env.step(action)
              reward = reward + self.shaping_fn(obs, action)
              return obs, reward, terminated, truncated, info
      
      def proximity_shaping(obs, action):
          goal_pos = np.array([10, 10])
          distance = np.linalg.norm(goal_pos - obs[:2])
          return -0.1 * distance
      
      # Apply wrappers inside the creator, then emulate + vectorize.
      def shaped_creator():
          base = gym.make('CartPole-v1')
          base = RewardShaping(base, proximity_shaping)
          return pufferlib.emulation.GymnasiumPufferEnv(env_creator=lambda: base)
      
      env = pufferlib.vector.make(
          shaped_creator, num_envs=128, backend=pufferlib.vector.Multiprocessing)
      ```
      
      ## Space Conversion
      
      ### Flattening Spaces
      
      PufferLib automatically flattens complex observation/action spaces:
      
      ```python
      from gymnasium.spaces import Dict, Box, Discrete
      import pufferlib
      
      # Complex space
      original_space = Dict({
          'image': Box(0, 255, (84, 84, 3), dtype=np.uint8),
          'vector': Box(-np.inf, np.inf, (10,), dtype=np.float32),
          'discrete': Discrete(5)
      })
      
      # Automatically flattened by PufferLib
      # Observations are presented as flat arrays for efficient processing
      # But can be unflattened when needed for policy processing
      ```
      
      ### Recovering structure in the policy
      
      There is no `unflatten_observations` helper. Use
      `pufferlib.pytorch.nativize_dtype` (once, from `env.emulated`) plus
      `pufferlib.pytorch.nativize_tensor` per batch to recover the structured dict:
      
      ```python
      import pufferlib.pytorch
      
      class PolicyWithDictObs(nn.Module):
          def __init__(self, env):
              super().__init__()
              self.dtype = pufferlib.pytorch.nativize_dtype(env.emulated)
              # ... encoders / heads ...
      
          def encode_observations(self, flat_observations, state=None):
              obs = pufferlib.pytorch.nativize_tensor(flat_observations, self.dtype)
              image_features = self.image_encoder(obs['image'].float() / 255.0)
              vector_features = self.vector_encoder(obs['vector'])
              # ...
      ```
      
      ## Environment Lookup
      
      ### Mapping Names to Constructors
      
      PufferLib has no string registry and no `pufferlib.register`/`pufferlib.make`.
      If you want name-based lookup, keep your own mapping of names to environment
      constructors (callables) and pass the resolved constructor to
      `pufferlib.vector.make`:
      
      ```python
      import functools
      import pufferlib.vector
      from my_package.envs import MyEnvironment
      
      # Your own registry: name -> constructor (callable)
      ENV_REGISTRY = {
          'my-custom-env': functools.partial(MyEnvironment, param1='value1'),
      }
      
      # Resolve the name to a constructor, then vectorize
      env_creator = ENV_REGISTRY['my-custom-env']
      env = pufferlib.vector.make(env_creator, num_envs=256)
      ```
      
      ### Sharing Constructors
      
      To make an environment easy for others to use, simply expose its constructor
      (a `PufferEnv` subclass, or a function/`functools.partial` that returns one)
      from your package and document the kwargs:
      
      ```python
      # In my_package/envs.py
      def make_my_env(default_param='default_value'):
          return MyEnvironment(param1=default_param)
      ```
      
      ## Compatibility Patterns
      
      ### Gymnasium to PufferLib
      
      ```python
      import gymnasium as gym
      import pufferlib.emulation
      import pufferlib.vector
      
      # Standard Gymnasium environment
      class GymEnv(gym.Env):
          def reset(self, seed=None, options=None):
              return observation, info
      
          def step(self, action):
              return observation, reward, terminated, truncated, info
      
      # Wrap with GymnasiumPufferEnv, then vectorize (pass a constructor callable)
      env = pufferlib.vector.make(
          lambda: pufferlib.emulation.GymnasiumPufferEnv(env_creator=GymEnv),
          num_envs=128,
      )
      ```
      
      ### PettingZoo to PufferLib
      
      ```python
      from pettingzoo import ParallelEnv
      import pufferlib.emulation
      import pufferlib.vector
      
      # PettingZoo parallel environment
      class PZEnv(ParallelEnv):
          def reset(self, seed=None, options=None):
              return {agent: obs for agent, obs in ...}, {agent: info for agent in ...}
      
          def step(self, actions):
              return observations, rewards, terminations, truncations, infos
      
      # Wrap with PettingZooPufferEnv, then vectorize (pass a constructor callable)
      env = pufferlib.vector.make(
          lambda: pufferlib.emulation.PettingZooPufferEnv(env_creator=PZEnv),
          num_envs=128,
      )
      ```
      
      ### Legacy Gym (v0.21) to PufferLib
      
      ```python
      import gym  # Old gym
      import pufferlib.emulation
      import pufferlib.vector
      
      # Legacy gym environment (returns done instead of terminated/truncated)
      class LegacyEnv(gym.Env):
          def reset(self):
              return observation
      
          def step(self, action):
              return observation, reward, done, info
      
      # Wrap with GymnasiumPufferEnv, then vectorize (pass a constructor callable)
      env = pufferlib.vector.make(
          lambda: pufferlib.emulation.GymnasiumPufferEnv(env_creator=LegacyEnv),
          num_envs=128,
      )
      ```
      
      ## Performance Considerations
      
      ### Efficient Integration
      
      ```python
      import gymnasium as gym
      import pufferlib.emulation
      import pufferlib.vector
      
      # Fast: a native PufferEnv constructor (no emulation layer).
      # `make_coinrun` is a placeholder for your PufferEnv creator.
      env = pufferlib.vector.make(make_coinrun, num_envs=256)
      
      # Slower: Generic Gymnasium wrapper (emulated -> needs a non-native backend)
      env = pufferlib.vector.make(
          lambda: pufferlib.emulation.GymnasiumPufferEnv(
              env_creator=lambda: gym.make('CartPole-v1')),
          num_envs=256, backend=pufferlib.vector.Multiprocessing,
      )
      
      # Slowest: Nested wrappers add overhead
      def nested_creator():
          gym_env = gym.make('CartPole-v1')
          gym_env = SomeWrapper(gym_env)
          gym_env = AnotherWrapper(gym_env)
          return pufferlib.emulation.GymnasiumPufferEnv(env_creator=lambda: gym_env)
      
      env = pufferlib.vector.make(
          nested_creator, num_envs=256, backend=pufferlib.vector.Multiprocessing)
      ```
      
      ### Minimize Wrapper Overhead
      
      ```python
      import pufferlib.emulation
      import pufferlib.vector
      
      # BAD: Too many wrappers
      def bad_creator():
          env = gym.make('CartPole-v1')
          env = Wrapper1(env)
          env = Wrapper2(env)
          env = Wrapper3(env)
          return pufferlib.emulation.GymnasiumPufferEnv(env_creator=lambda: env)
      
      env = pufferlib.vector.make(
          bad_creator, num_envs=256, backend=pufferlib.vector.Multiprocessing)
      
      # GOOD: Combine wrapper logic
      class CombinedWrapper(gym.Wrapper):
          def step(self, action):
              obs, reward, terminated, truncated, info = self.env.step(action)
              # Apply all transformations at once
              obs = self._transform_obs(obs)
              reward = self._transform_reward(reward)
              return obs, reward, terminated, truncated, info
      
      def good_creator():
          env = CombinedWrapper(gym.make('CartPole-v1'))
          return pufferlib.emulation.GymnasiumPufferEnv(env_creator=lambda: env)
      
      env = pufferlib.vector.make(
          good_creator, num_envs=256, backend=pufferlib.vector.Multiprocessing)
      ```
      
      ## Debugging Integration
      
      ### Verify Environment Compatibility
      
      ```python
      import numpy as np
      
      def test_environment(env, num_steps=100):
          """Smoke-test a single PufferEnv before vectorizing (5-tuple native API)."""
          obs, info = env.reset()
          assert isinstance(info, list), "PufferEnv reset must return an info LIST"
      
          for _ in range(num_steps):
              # One action per agent.
              action = np.array([env.single_action_space.sample()
                                 for _ in range(env.num_agents)])
              obs, reward, terminal, truncation, info = env.step(action)
      
              assert np.all(np.isfinite(np.asarray(reward, dtype=float))), "Non-finite reward"
              assert isinstance(info, list), "info must be a list of dicts"
      
              if np.any(terminal) or np.any(truncation):
                  obs, info = env.reset()
      
          print("Environment passed compatibility test")
      
      # Test before vectorizing
      test_environment(MyEnvironment())
      ```
      
      ### Compare Outputs
      
      ```python
      # Verify PufferLib emulation matches original
      import gymnasium as gym
      import pufferlib.emulation
      import pufferlib.vector
      import numpy as np
      
      gym_env = gym.make('CartPole-v1')
      puffer_env = pufferlib.vector.make(
          lambda: pufferlib.emulation.GymnasiumPufferEnv(
              env_creator=lambda: gym.make('CartPole-v1')),
          num_envs=1, backend=pufferlib.vector.Multiprocessing,
      )
      
      # Both reset() calls return (obs, info). vecenv.step returns a 5-tuple:
      # (obs, rewards, terminals, truncations, infos).
      gym_env.reset(seed=42)
      puffer_obs, _ = puffer_env.reset(seed=42)
      
      for _ in range(100):
          action = gym_env.action_space.sample()
      
          gym_obs, gym_reward, gym_term, gym_trunc, gym_info = gym_env.step(action)
          puffer_obs, puffer_reward, puffer_term, puffer_trunc, *_ = \
              puffer_env.step(np.array([action]))
      
          # Compare outputs (accounting for the leading batch/agent dimension)
          assert np.allclose(gym_obs, puffer_obs[0])
          assert gym_reward == puffer_reward[0]
      ```
      
    • policies.md 19.8 KB
      # PufferLib Policies Guide
      
      ## Overview
      
      PufferLib policies are standard PyTorch modules — the library does not enforce a base class. There are two requirements for use with the PuffeRL trainer (3.0.x):
      
      1. PuffeRL calls `policy.forward_eval(observations, state)` during rollouts and expects it to return `(logits, value)`. Provide `forward_eval` (and usually an identical `forward`).
      2. Structure the network as `encode_observations(obs, state)` + `decode_actions(hidden)` so it can be wrapped by `pufferlib.models.LSTMWrapper` without rewrites. `pufferlib.models.Default` is the canonical reference implementation.
      
      The example policies below show the network bodies; in practice wrap each one in this `encode_observations` / `decode_actions` / `forward_eval` interface (as `pufferlib.models.Default` does).
      
      ## Policy Architecture
      
      ### Basic Policy Structure
      
      ```python
      import torch
      import torch.nn as nn
      from pufferlib.pytorch import layer_init
      
      class BasicPolicy(nn.Module):
          def __init__(self, observation_space, action_space):
              super().__init__()
      
              self.observation_space = observation_space
              self.action_space = action_space
      
              # Encoder network
              self.encoder = nn.Sequential(
                  layer_init(nn.Linear(observation_space.shape[0], 256)),
                  nn.ReLU(),
                  layer_init(nn.Linear(256, 256)),
                  nn.ReLU()
              )
      
              # Policy head (actor)
              self.actor = layer_init(nn.Linear(256, action_space.n), std=0.01)
      
              # Value head (critic)
              self.critic = layer_init(nn.Linear(256, 1), std=1.0)
      
          def forward(self, observations):
              """Forward pass through policy."""
              # Encode observations
              features = self.encoder(observations)
      
              # Get action logits and value
              logits = self.actor(features)
              value = self.critic(features)
      
              return logits, value
      
          def get_action(self, observations, deterministic=False):
              """Sample action from policy."""
              logits, value = self.forward(observations)
      
              if deterministic:
                  action = logits.argmax(dim=-1)
              else:
                  dist = torch.distributions.Categorical(logits=logits)
                  action = dist.sample()
      
              return action, value
      ```
      
      ### Layer Initialization
      
      PufferLib provides `layer_init` for proper weight initialization:
      
      ```python
      from pufferlib.pytorch import layer_init
      
      # Default orthogonal initialization
      layer = layer_init(nn.Linear(256, 256))
      
      # Custom standard deviation
      actor_head = layer_init(nn.Linear(256, num_actions), std=0.01)
      critic_head = layer_init(nn.Linear(256, 1), std=1.0)
      
      # Works with any layer type
      conv = layer_init(nn.Conv2d(3, 32, kernel_size=8, stride=4))
      ```
      
      ## CNN Policies
      
      For image-based observations:
      
      ```python
      class CNNPolicy(nn.Module):
          def __init__(self, observation_space, action_space):
              super().__init__()
      
              # CNN encoder for images
              self.encoder = nn.Sequential(
                  layer_init(nn.Conv2d(3, 32, kernel_size=8, stride=4)),
                  nn.ReLU(),
                  layer_init(nn.Conv2d(32, 64, kernel_size=4, stride=2)),
                  nn.ReLU(),
                  layer_init(nn.Conv2d(64, 64, kernel_size=3, stride=1)),
                  nn.ReLU(),
                  nn.Flatten(),
                  layer_init(nn.Linear(64 * 7 * 7, 512)),
                  nn.ReLU()
              )
      
              self.actor = layer_init(nn.Linear(512, action_space.n), std=0.01)
              self.critic = layer_init(nn.Linear(512, 1), std=1.0)
      
          def forward(self, observations):
              # Normalize pixel values
              x = observations.float() / 255.0
      
              features = self.encoder(x)
              logits = self.actor(features)
              value = self.critic(features)
      
              return logits, value
      ```
      
      ### Efficient CNN Architecture
      
      ```python
      class EfficientCNN(nn.Module):
          """Optimized CNN for Atari-style games."""
      
          def __init__(self, observation_space, action_space):
              super().__init__()
      
              in_channels = observation_space.shape[0]  # Typically 4 for framestack
      
              self.network = nn.Sequential(
                  layer_init(nn.Conv2d(in_channels, 32, 8, stride=4)),
                  nn.ReLU(),
                  layer_init(nn.Conv2d(32, 64, 4, stride=2)),
                  nn.ReLU(),
                  layer_init(nn.Conv2d(64, 64, 3, stride=1)),
                  nn.ReLU(),
                  nn.Flatten()
              )
      
              # Calculate feature size
              with torch.no_grad():
                  sample = torch.zeros(1, *observation_space.shape)
                  n_features = self.network(sample).shape[1]
      
              self.fc = layer_init(nn.Linear(n_features, 512))
              self.actor = layer_init(nn.Linear(512, action_space.n), std=0.01)
              self.critic = layer_init(nn.Linear(512, 1), std=1.0)
      
          def forward(self, x):
              x = x.float() / 255.0
              x = self.network(x)
              x = torch.relu(self.fc(x))
      
              return self.actor(x), self.critic(x)
      ```
      
      ## Recurrent Policies (LSTM)
      
      PufferLib provides optimized LSTM integration via `pufferlib.models.LSTMWrapper` (note: it lives in `pufferlib.models`, not `pufferlib.pytorch`). Wrap a policy that exposes `encode_observations` / `decode_actions` and a `hidden_size` attribute, and set `use_rnn=True` in the trainer config:
      
      ```python
      from pufferlib.models import LSTMWrapper
      
      class RecurrentPolicy(nn.Module):
          def __init__(self, observation_space, action_space, hidden_size=256):
              super().__init__()
      
              # Observation encoder
              self.encoder = nn.Sequential(
                  layer_init(nn.Linear(observation_space.shape[0], 128)),
                  nn.ReLU()
              )
      
              # LSTM layer
              self.lstm = nn.LSTM(128, hidden_size, num_layers=1)
      
              # Policy and value heads
              self.actor = layer_init(nn.Linear(hidden_size, action_space.n), std=0.01)
              self.critic = layer_init(nn.Linear(hidden_size, 1), std=1.0)
      
              # Hidden state
              self.hidden_size = hidden_size
      
          def forward(self, observations, state=None):
              """
              Args:
                  observations: (batch, obs_dim)
                  state: Optional (h, c) tuple for LSTM
      
              Returns:
                  logits, value, new_state
              """
              batch_size = observations.shape[0]
      
              # Encode observations
              features = self.encoder(observations)
      
              # Initialize hidden state if needed
              if state is None:
                  h = torch.zeros(1, batch_size, self.hidden_size, device=features.device)
                  c = torch.zeros(1, batch_size, self.hidden_size, device=features.device)
                  state = (h, c)
      
              # LSTM forward
              features = features.unsqueeze(0)  # Add sequence dimension
              lstm_out, new_state = self.lstm(features, state)
              lstm_out = lstm_out.squeeze(0)
      
              # Get outputs
              logits = self.actor(lstm_out)
              value = self.critic(lstm_out)
      
              return logits, value, new_state
      ```
      
      ### LSTM Optimization
      
      PufferLib's LSTM optimization uses LSTMCell during rollouts and LSTM during training for up to 3x faster inference:
      
      ```python
      class OptimizedLSTMPolicy(nn.Module):
          def __init__(self, observation_space, action_space, hidden_size=256):
              super().__init__()
      
              self.encoder = nn.Sequential(
                  layer_init(nn.Linear(observation_space.shape[0], 128)),
                  nn.ReLU()
              )
      
              # Use LSTMCell for step-by-step inference
              self.lstm_cell = nn.LSTMCell(128, hidden_size)
      
              # Use LSTM for batch training
              self.lstm = nn.LSTM(128, hidden_size, num_layers=1)
      
              self.actor = layer_init(nn.Linear(hidden_size, action_space.n), std=0.01)
              self.critic = layer_init(nn.Linear(hidden_size, 1), std=1.0)
      
              self.hidden_size = hidden_size
      
          def encode_observations(self, observations, state):
              """Fast inference using LSTMCell."""
              features = self.encoder(observations)
      
              if state is None:
                  h = torch.zeros(observations.shape[0], self.hidden_size, device=features.device)
                  c = torch.zeros(observations.shape[0], self.hidden_size, device=features.device)
              else:
                  h, c = state
      
              # Step-by-step with LSTMCell (faster for inference)
              h, c = self.lstm_cell(features, (h, c))
      
              logits = self.actor(h)
              value = self.critic(h)
      
              return logits, value, (h, c)
      
          def decode_actions(self, observations, actions, state):
              """Batch training using LSTM."""
              seq_len, batch_size = observations.shape[:2]
      
              # Reshape for LSTM
              obs_flat = observations.reshape(seq_len * batch_size, -1)
              features = self.encoder(obs_flat)
              features = features.reshape(seq_len, batch_size, -1)
      
              if state is None:
                  h = torch.zeros(1, batch_size, self.hidden_size, device=features.device)
                  c = torch.zeros(1, batch_size, self.hidden_size, device=features.device)
                  state = (h, c)
      
              # Batch processing with LSTM (faster for training)
              lstm_out, new_state = self.lstm(features, state)
      
              # Flatten back
              lstm_out = lstm_out.reshape(seq_len * batch_size, -1)
      
              logits = self.actor(lstm_out)
              value = self.critic(lstm_out)
      
              return logits, value, new_state
      ```
      
      ## Multi-Input Policies
      
      For environments with multiple observation types:
      
      ```python
      class MultiInputPolicy(nn.Module):
          def __init__(self, observation_space, action_space):
              super().__init__()
      
              # Separate encoders for different observation types
              self.image_encoder = nn.Sequential(
                  layer_init(nn.Conv2d(3, 32, 8, stride=4)),
                  nn.ReLU(),
                  layer_init(nn.Conv2d(32, 64, 4, stride=2)),
                  nn.ReLU(),
                  nn.Flatten()
              )
      
              self.vector_encoder = nn.Sequential(
                  layer_init(nn.Linear(observation_space['vector'].shape[0], 128)),
                  nn.ReLU()
              )
      
              # Combine features
              combined_size = 64 * 9 * 9 + 128  # Image features + vector features
              self.combiner = nn.Sequential(
                  layer_init(nn.Linear(combined_size, 512)),
                  nn.ReLU()
              )
      
              self.actor = layer_init(nn.Linear(512, action_space.n), std=0.01)
              self.critic = layer_init(nn.Linear(512, 1), std=1.0)
      
          def forward(self, observations):
              # Process each observation type
              image_features = self.image_encoder(observations['image'].float() / 255.0)
              vector_features = self.vector_encoder(observations['vector'])
      
              # Combine
              combined = torch.cat([image_features, vector_features], dim=-1)
              features = self.combiner(combined)
      
              return self.actor(features), self.critic(features)
      ```
      
      ## Continuous Action Policies
      
      For continuous control tasks:
      
      ```python
      class ContinuousPolicy(nn.Module):
          def __init__(self, observation_space, action_space):
              super().__init__()
      
              self.encoder = nn.Sequential(
                  layer_init(nn.Linear(observation_space.shape[0], 256)),
                  nn.ReLU(),
                  layer_init(nn.Linear(256, 256)),
                  nn.ReLU()
              )
      
              # Mean of action distribution
              self.actor_mean = layer_init(nn.Linear(256, action_space.shape[0]), std=0.01)
      
              # Log std of action distribution
              self.actor_logstd = nn.Parameter(torch.zeros(1, action_space.shape[0]))
      
              # Value head
              self.critic = layer_init(nn.Linear(256, 1), std=1.0)
      
          def forward(self, observations):
              features = self.encoder(observations)
      
              action_mean = self.actor_mean(features)
              action_std = torch.exp(self.actor_logstd)
      
              value = self.critic(features)
      
              return action_mean, action_std, value
      
          def get_action(self, observations, deterministic=False):
              action_mean, action_std, value = self.forward(observations)
      
              if deterministic:
                  return action_mean, value
              else:
                  dist = torch.distributions.Normal(action_mean, action_std)
                  action = dist.sample()
                  return torch.tanh(action), value  # Bound actions to [-1, 1]
      ```
      
      ## Observation Processing (dict / structured observations)
      
      When an emulated env exposes a Dict observation space, PufferLib flattens it to a
      byte tensor. Recover the structured tensors with `pufferlib.pytorch.nativize_dtype`
      (once, from `env.emulated`) plus `pufferlib.pytorch.nativize_tensor` per batch —
      there is no `unflatten_observations` helper.
      
      ```python
      import pufferlib.pytorch
      
      class PolicyWithDictObs(nn.Module):
          def __init__(self, env):
              super().__init__()
      
              # Build the native dtype description once from the emulated env.
              self.dtype = pufferlib.pytorch.nativize_dtype(env.emulated)
      
              self.encoders = nn.ModuleDict({
                  'image': self._make_image_encoder(),
                  'vector': self._make_vector_encoder(),
              })
              # ... actor / critic heads ...
      
          def encode_observations(self, flat_observations, state=None):
              # Recover the structured observation dict from the flat byte tensor.
              obs = pufferlib.pytorch.nativize_tensor(flat_observations, self.dtype)
      
              image_features = self.encoders['image'](obs['image'].float() / 255.0)
              vector_features = self.encoders['vector'](obs['vector'])
              # Combine and continue...
      ```
      
      ## Multi-Agent Policies
      
      ### Shared Parameters
      
      All agents use the same policy:
      
      ```python
      class SharedMultiAgentPolicy(nn.Module):
          def __init__(self, observation_space, action_space, num_agents):
              super().__init__()
      
              self.num_agents = num_agents
      
              # Single policy shared across all agents
              self.encoder = nn.Sequential(
                  layer_init(nn.Linear(observation_space.shape[0], 256)),
                  nn.ReLU()
              )
      
              self.actor = layer_init(nn.Linear(256, action_space.n), std=0.01)
              self.critic = layer_init(nn.Linear(256, 1), std=1.0)
      
          def forward(self, observations):
              """
              Args:
                  observations: (batch * num_agents, obs_dim)
              Returns:
                  logits: (batch * num_agents, num_actions)
                  values: (batch * num_agents, 1)
              """
              features = self.encoder(observations)
              return self.actor(features), self.critic(features)
      ```
      
      ### Independent Parameters
      
      Each agent has its own policy:
      
      ```python
      class IndependentMultiAgentPolicy(nn.Module):
          def __init__(self, observation_space, action_space, num_agents):
              super().__init__()
      
              self.num_agents = num_agents
      
              # Separate policy for each agent
              self.policies = nn.ModuleList([
                  self._make_policy(observation_space, action_space)
                  for _ in range(num_agents)
              ])
      
          def _make_policy(self, observation_space, action_space):
              return nn.Sequential(
                  layer_init(nn.Linear(observation_space.shape[0], 256)),
                  nn.ReLU(),
                  layer_init(nn.Linear(256, 256)),
                  nn.ReLU()
              )
      
          def forward(self, observations, agent_ids):
              """
              Args:
                  observations: (batch, obs_dim)
                  agent_ids: (batch,) which agent each obs belongs to
              """
              outputs = []
              for agent_id in range(self.num_agents):
                  mask = agent_ids == agent_id
                  if mask.any():
                      agent_obs = observations[mask]
                      agent_out = self.policies[agent_id](agent_obs)
                      outputs.append(agent_out)
      
              return torch.cat(outputs, dim=0)
      ```
      
      ## Advanced Architectures
      
      ### Attention-Based Policy
      
      ```python
      class AttentionPolicy(nn.Module):
          def __init__(self, observation_space, action_space, d_model=256, nhead=8):
              super().__init__()
      
              self.encoder = layer_init(nn.Linear(observation_space.shape[0], d_model))
      
              self.attention = nn.MultiheadAttention(d_model, nhead, batch_first=True)
      
              self.actor = layer_init(nn.Linear(d_model, action_space.n), std=0.01)
              self.critic = layer_init(nn.Linear(d_model, 1), std=1.0)
      
          def forward(self, observations):
              # Encode
              features = self.encoder(observations)
      
              # Self-attention
              features = features.unsqueeze(1)  # Add sequence dimension
              attn_out, _ = self.attention(features, features, features)
              attn_out = attn_out.squeeze(1)
      
              return self.actor(attn_out), self.critic(attn_out)
      ```
      
      ### Residual Policy
      
      ```python
      class ResidualBlock(nn.Module):
          def __init__(self, dim):
              super().__init__()
              self.block = nn.Sequential(
                  layer_init(nn.Linear(dim, dim)),
                  nn.ReLU(),
                  layer_init(nn.Linear(dim, dim))
              )
      
          def forward(self, x):
              return x + self.block(x)
      
      class ResidualPolicy(nn.Module):
          def __init__(self, observation_space, action_space, num_blocks=4):
              super().__init__()
      
              dim = 256
      
              self.encoder = layer_init(nn.Linear(observation_space.shape[0], dim))
      
              self.blocks = nn.Sequential(
                  *[ResidualBlock(dim) for _ in range(num_blocks)]
              )
      
              self.actor = layer_init(nn.Linear(dim, action_space.n), std=0.01)
              self.critic = layer_init(nn.Linear(dim, 1), std=1.0)
      
          def forward(self, observations):
              x = torch.relu(self.encoder(observations))
              x = self.blocks(x)
              return self.actor(x), self.critic(x)
      ```
      
      ## Policy Best Practices
      
      ### Initialization
      
      ```python
      # Always use layer_init for proper initialization
      good_layer = layer_init(nn.Linear(256, 256))
      
      # Use small std for actor head (more stable early training)
      actor = layer_init(nn.Linear(256, num_actions), std=0.01)
      
      # Use std=1.0 for critic head
      critic = layer_init(nn.Linear(256, 1), std=1.0)
      ```
      
      ### Observation Normalization
      
      ```python
      class NormalizedPolicy(nn.Module):
          def __init__(self, observation_space, action_space):
              super().__init__()
      
              # Running statistics for normalization
              self.obs_mean = nn.Parameter(torch.zeros(observation_space.shape[0]), requires_grad=False)
              self.obs_std = nn.Parameter(torch.ones(observation_space.shape[0]), requires_grad=False)
      
              # ... rest of policy ...
      
          def forward(self, observations):
              # Normalize observations
              normalized_obs = (observations - self.obs_mean) / (self.obs_std + 1e-8)
      
              # Continue with normalized observations
              return self.policy(normalized_obs)
      
          def update_normalization(self, observations):
              """Update running statistics."""
              self.obs_mean.data = observations.mean(dim=0)
              self.obs_std.data = observations.std(dim=0)
      ```
      
      ### Gradient Clipping
      
      ```python
      # The trainer clips gradients for you; set the norm via the config dict
      # (not a PuffeRL kwarg). PuffeRL(config, vecenv, policy) reads config['max_grad_norm'].
      config['max_grad_norm'] = 1.5  # default in pufferlib/config/default.ini
      trainer = PuffeRL(config, vecenv, policy)
      ```
      
      ### Model Compilation
      
      ```python
      # torch.compile is also config-driven: set config['compile'] = True (and
      # optionally config['compile_mode']). The trainer compiles the policy internally,
      # so you do not call torch.compile yourself.
      config['compile'] = True
      trainer = PuffeRL(config, vecenv, policy)
      ```
      
      ## Debugging Policies
      
      ### Check Output Shapes
      
      ```python
      def test_policy_shapes(policy, observation_space, batch_size=32):
          """Verify policy output shapes."""
          # Create dummy observations
          obs = torch.randn(batch_size, *observation_space.shape)
      
          # Forward pass
          logits, value = policy(obs)
      
          # Check shapes
          assert logits.shape == (batch_size, policy.action_space.n)
          assert value.shape == (batch_size, 1)
      
          print("✓ Policy shapes correct")
      ```
      
      ### Verify Gradients
      
      ```python
      def check_gradients(policy, observation_space):
          """Check that gradients flow properly."""
          obs = torch.randn(1, *observation_space.shape, requires_grad=True)
      
          logits, value = policy(obs)
      
          # Backward pass
          loss = logits.sum() + value.sum()
          loss.backward()
      
          # Check gradients exist
          for name, param in policy.named_parameters():
              if param.grad is None:
                  print(f"⚠ No gradient for {name}")
              elif torch.isnan(param.grad).any():
                  print(f"⚠ NaN gradient for {name}")
              else:
                  print(f"✓ Gradient OK for {name}")
      ```
      
    • training.md 11.5 KB
      # PufferLib Training Guide
      
      > Targets PufferLib **3.0.x**. The trainer is config-driven: `PuffeRL.__init__(self, config, vecenv, policy, logger=None)` where `config` is a **dict** (typically `args['train']` from `load_config`), not flat keyword args. The dev `4.0` branch differs.
      
      ## Overview
      
      PuffeRL is PufferLib's high-performance training algorithm based on CleanRL's PPO with optional LSTMs, enhanced with research improvements. It achieves high training throughput through optimized vectorization and an efficient implementation.
      
      ## Training Workflow
      
      ### Basic Training Loop
      
      The PuffeRL trainer provides three core methods:
      
      ```python
      # Collect environment interactions
      rollout_data = trainer.evaluate()
      
      # Train on collected batch
      train_metrics = trainer.train()
      
      # Aggregate and log results
      trainer.mean_and_log()
      ```
      
      ### CLI Training
      
      Quick start via the `puffer` CLI. `env_name` resolves to a config section in `pufferlib/config/*.ini` plus its registered Ocean env. Config keys are overridden with `--<section>.<key>`:
      
      ```bash
      # Basic training (Ocean Breakout)
      puffer train puffer_breakout --train.device cuda
      
      # Override config keys (note real key names: learning-rate, total-timesteps)
      puffer train puffer_breakout \
          --train.device cuda \
          --train.learning-rate 0.015 \
          --train.total-timesteps 10_000_000 \
          --vec.num-envs 256
      ```
      
      ### Python Training Script
      
      The supported high-level entry point is `pufferl.train(env_name, ...)`, which builds the vecenv, policy, logger, and config for you:
      
      ```python
      import pufferlib.pufferl as pufferl
      
      pufferl.train('puffer_breakout')                  # all defaults from config/*.ini
      pufferl.train('puffer_breakout', vecenv=my_vec, policy=my_policy)  # override pieces
      ```
      
      To drive the trainer manually, build the config dict yourself:
      
      ```python
      import pufferlib.vector
      from pufferlib.pufferl import PuffeRL, load_config
      
      # Native PufferEnv -> default backend=PufferEnv. Wrapped (Gymnasium/PettingZoo)
      # envs require backend=pufferlib.vector.Multiprocessing (or Serial).
      vecenv = pufferlib.vector.make(env_creator, num_envs=256)
      
      # load_config returns a nested args dict; PuffeRL takes the 'train' section (a dict).
      args = load_config('puffer_breakout')
      config = {**args['train'], 'env': 'puffer_breakout'}
      config['device'] = 'cuda'
      
      trainer = PuffeRL(config, vecenv, my_policy)  # (config, vecenv, policy, logger=None)
      
      # Training loop is driven by global_step, not a fixed iteration count.
      while trainer.global_step < config['total_timesteps']:
          trainer.evaluate()      # Collect rollouts
          trainer.train()         # Train on batch
          trainer.mean_and_log()  # Aggregate + log
      ```
      
      ## Key Training Parameters
      
      Keys and defaults below are from `pufferlib/config/default.ini` (3.0.x); per-env `.ini` files override them. Use the exact key names — they differ from generic CleanRL/PPO conventions (e.g. `update_epochs`, not `n_epochs`; `minibatch_size`/`bptt_horizon`, not a single `num_steps`).
      
      ### Core Hyperparameters (`[train]`)
      
      - **learning_rate**: Optimizer LR (default: `0.015`; note PufferLib's default `optimizer = muon`)
      - **batch_size**: Timesteps per training batch (default: `auto`, derived from `bptt_horizon` x agents)
      - **minibatch_size**: Minibatch size (default: `8192`)
      - **max_minibatch_size**: Gradient accumulation above this size (default: `32768`)
      - **bptt_horizon**: BPTT / rollout horizon per segment (default: `64`)
      - **update_epochs**: Training epochs per batch (default: `1`)
      - **total_timesteps**: Total env steps to train for (default: `10_000_000`)
      
      ### Vectorization (`[vec]`)
      
      - **num_envs**: Parallel environments (default: `2`)
      - **num_workers**: Vectorization workers (default: `auto`)
      - **backend**: `Multiprocessing` (CLI default) / `Serial` / `Ray` / `PufferEnv` (native)
      
      ### PPO Parameters (`[train]`)
      
      - **gamma**: Discount factor (default: `0.995`)
      - **gae_lambda**: GAE lambda (default: `0.90`)
      - **clip_coef**: PPO clipping coefficient (default: `0.2`)
      - **ent_coef**: Entropy coefficient (default: `0.001`)
      - **vf_coef**: Value loss coefficient (default: `2.0`)
      - **vf_clip_coef**: Value clipping coefficient (default: `0.2`)
      - **max_grad_norm**: Max gradient norm (default: `1.5`)
      
      ### Performance Parameters (`[train]`)
      
      - **device**: Computing device (`cuda` / `cpu`, default `cuda`)
      - **compile**: Use torch.compile (default: `False`; `compile_mode = max-autotune-no-cudagraphs`)
      - **cpu_offload**: Keep observations on CPU to save GPU memory (default: `False`)
      
      ## Distributed Training
      
      ### Multi-GPU Training
      
      Use torchrun for distributed training across multiple GPUs:
      
      ```bash
      torchrun --nproc_per_node=4 train.py \
          --train.device cuda \
          --train.batch-size 131072
      ```
      
      ### Multi-Node Training
      
      For distributed training across multiple nodes:
      
      ```bash
      # On main node (rank 0)
      torchrun --nproc_per_node=8 \
          --nnodes=4 \
          --node_rank=0 \
          --master_addr=MASTER_IP \
          --master_port=29500 \
          train.py
      
      # On worker nodes (rank 1, 2, 3)
      torchrun --nproc_per_node=8 \
          --nnodes=4 \
          --node_rank=NODE_RANK \
          --master_addr=MASTER_IP \
          --master_port=29500 \
          train.py
      ```
      
      ## Monitoring and Logging
      
      ### Logger Integration
      
      Loggers live in `pufferlib.pufferl` (`WandbLogger`, `NeptuneLogger`, `NoLogger`), **not** the top-level package. Each takes the nested `args` config dict and reads keys from it (it does not take `project=`/`name=` kwargs).
      
      The intended path is to let `pufferl.train` pick the logger from config flags:
      
      ```bash
      # Weights & Biases: set --wandb plus the wandb_project / wandb_group keys
      puffer train puffer_breakout --train.wandb True --train.wandb-project my_project
      
      # Neptune: set --neptune plus neptune_name / neptune_project
      puffer train puffer_breakout --train.neptune True --train.neptune-project my_project
      ```
      
      To construct one manually, pass the `args` dict (with the relevant keys populated):
      
      ```python
      from pufferlib.pufferl import WandbLogger, NeptuneLogger, NoLogger
      
      # args is the nested dict from load_config(); WandbLogger reads
      # args['wandb_project'], args['wandb_group'], args['tag'], args['no_model_upload'].
      logger = WandbLogger(args)          # or NeptuneLogger(args), or NoLogger(args)
      trainer = PuffeRL(config, vecenv, policy, logger=logger)
      ```
      
      ### Key Metrics
      
      Training logs include:
      
      - **Performance Metrics**:
        - Steps per second (SPS)
        - Training throughput
        - Wall-clock time per iteration
      
      - **Learning Metrics**:
        - Episode rewards (mean, min, max)
        - Episode lengths
        - Value function loss
        - Policy loss
        - Entropy
        - Explained variance
        - Clipfrac
      
      - **Environment Metrics**:
        - Environment-specific rewards
        - Success rates
        - Custom metrics
      
      ### Terminal Dashboard
      
      PufferLib provides a real-time terminal dashboard showing:
      - Training progress
      - Current SPS
      - Episode statistics
      - Loss values
      - GPU utilization
      
      ## Checkpointing
      
      Checkpointing is **auto-managed**: PuffeRL calls `save_checkpoint()` every `config['checkpoint_interval']` epochs and on completion. The method takes **no path argument** — it writes `model_*.pt` and `trainer_state.pt` under `config['data_dir']/<env>_<run_id>/`.
      
      ```python
      # Manual save (no path arg). Returns the model path written.
      model_path = trainer.save_checkpoint()
      ```
      
      ### Resuming / loading
      
      There is no `trainer.load_checkpoint(path)`. Loading happens at policy-build time via config keys, then training resumes from the loaded weights:
      
      ```bash
      # Resume from the most recent local checkpoint (top-level flag, not in the [train] section)
      puffer train puffer_breakout --load-model-path latest
      
      # Or load a specific file, or a logged W&B/Neptune run with --load-id <run_id> --wandb
      puffer train puffer_breakout --load-model-path experiments/puffer_breakout_000400.pt
      ```
      
      ## Hyperparameter Tuning with Protein
      
      Protein is PufferLib's Pareto-genetic / GP sweep method (`pufferlib.sweep.Protein`). You do **not** instantiate it directly with a `search_space=` kwarg — sweeps are config-driven via the `[sweep]` section and run with `pufferl.sweep()`. Sweeps **require** a wandb or neptune logger.
      
      In the config (`.ini`), `[sweep]` selects the method and metric, and `[sweep.<section>.<key>]` blocks declare the search space:
      
      ```ini
      [sweep]
      method = Protein
      metric = score
      goal = maximize
      
      [sweep.train.learning_rate]
      distribution = log_normal
      min = 1e-4
      max = 1e-2
      
      [sweep.vec.num_envs]
      distribution = uniform_pow2
      min = 1
      max = 16
      ```
      
      Run it:
      
      ```bash
      puffer sweep puffer_breakout --train.wandb True --train.wandb-project my_sweep
      ```
      
      ```python
      import pufferlib.pufferl as pufferl
      pufferl.sweep(env_name='puffer_breakout')   # args must enable wandb or neptune
      ```
      
      ## Performance Optimization Tips
      
      ### Maximizing Throughput
      
      1. **Batch Size**: Increase batch_size to fully utilize GPU
      2. **Num Envs**: Balance between CPU and GPU utilization
      3. **Compile**: Enable torch.compile for 10-20% speedup
      4. **Workers**: Adjust num_workers based on environment complexity
      5. **Device**: Always use 'cuda' for neural network training
      
      ### Environment Speed
      
      - Pure Python environments: ~100k-500k SPS
      - C-based environments: ~4M SPS
      - With training overhead: ~1M-4M total SPS
      
      ### Memory Management
      
      - Reduce batch_size if running out of GPU memory
      - Decrease num_envs if running out of CPU memory
      - Use gradient accumulation for large effective batch sizes
      
      ## Common Training Patterns
      
      ### Curriculum Learning
      
      ```python
      import functools
      import pufferlib.vector
      
      # Start with easy tasks, gradually increase difficulty.
      # Bind constructor kwargs with functools.partial -- there is no string
      # registry, so `MyEnv` here is your own PufferEnv class / creator callable.
      difficulty_levels = [0.1, 0.3, 0.5, 0.7, 1.0]
      
      for difficulty in difficulty_levels:
          env = pufferlib.vector.make(
              functools.partial(MyEnv, difficulty=difficulty), num_envs=256)
          trainer = PuffeRL(config, env, policy)  # (config dict, vecenv, policy)
      
          while trainer.global_step < steps_per_level:
              trainer.evaluate()
              trainer.train()
              trainer.mean_and_log()
      ```
      
      ### Reward Shaping
      
      ```python
      # A PufferEnv step returns a 5-tuple (obs, rewards, terminals, truncations, info).
      class RewardShapedEnv(pufferlib.PufferEnv):
          def step(self, actions):
              obs, rewards, terminals, truncations, info = super().step(actions)
      
              # Add shaped rewards
              rewards = rewards + 0.1 * proximity_bonus
      
              return obs, rewards, terminals, truncations, info
      ```
      
      ### Multi-Stage Training
      
      `config` is the dict passed at construction; PuffeRL reads most values once at
      init, so change a stage's learning rate by rebuilding the trainer with an
      updated config (or use the built-in `anneal_lr` / `min_lr_ratio` config keys).
      
      ```python
      stages = [
          {'learning_rate': 1e-3, 'steps': 1_000_000},   # Exploration
          {'learning_rate': 3e-4, 'steps': 5_000_000},   # Main training
          {'learning_rate': 1e-4, 'steps': 2_000_000},   # Fine-tuning
      ]
      
      for stage in stages:
          config = {**config, 'learning_rate': stage['learning_rate']}
          trainer = PuffeRL(config, vecenv, policy)
          target = trainer.global_step + stage['steps']
          while trainer.global_step < target:
              trainer.evaluate()
              trainer.train()
              trainer.mean_and_log()
      ```
      
      ## Troubleshooting
      
      ### Low Performance
      
      - Check environment is vectorized correctly
      - Verify GPU utilization with `nvidia-smi`
      - Increase batch_size to saturate GPU
      - Enable compile mode
      - Profile with `torch.profiler`
      
      ### Training Instability
      
      - Reduce learning_rate
      - Decrease batch_size
      - Increase num_envs for more diverse samples
      - Add entropy coefficient for more exploration
      - Check reward scaling
      
      ### Memory Issues
      
      - Reduce batch_size or num_envs
      - Use gradient accumulation
      - Disable compile mode if causing OOM
      - Check for memory leaks in custom environments
      
    • vectorization.md 14.1 KB
      # PufferLib Vectorization Guide
      
      ## Overview
      
      PufferLib's vectorization system enables high-performance parallel environment simulation, achieving millions of steps per second through optimized implementation inspired by EnvPool. The system supports both synchronous and asynchronous vectorization with minimal overhead.
      
      ## Vectorization Architecture
      
      ### Key Optimizations
      
      1. **Shared Memory Buffer**: Single unified buffer across all environments (unlike Gymnasium's per-environment buffers)
      2. **Busy-Wait Flags**: Workers busy-wait on unlocked flags rather than using pipes/queues
      3. **Zero-Copy Batching**: Contiguous worker subsets return observations without copying
      4. **Surplus Environments**: Simulates more environments than batch size for async returns
      5. **Multiple Envs per Worker**: Optimizes performance for lightweight environments
      
      ### Performance Characteristics
      
      - **Pure Python environments**: 100k-500k SPS
      - **C-based environments**: 100M+ SPS
      - **With training**: 400k-4M total SPS
      - **Vectorization overhead**: <5% with optimal configuration
      
      ## Creating Vectorized Environments
      
      ### Basic Vectorization
      
      ```python
      import pufferlib.vector
      
      # PufferLib has no string registry -- pass an environment constructor
      # (callable) to `pufferlib.vector.make`. `env_creator` is a placeholder for
      # the callable that builds your env (a PufferEnv class, or a function /
      # functools.partial that returns one).
      
      # Native PufferEnv (default backend=PufferEnv)
      env = pufferlib.vector.make(env_creator, num_envs=256)
      
      # With explicit configuration. envs-per-worker is derived as num_envs // num_workers.
      env = pufferlib.vector.make(
          env_creator,
          num_envs=256,
          num_workers=8,
          backend=pufferlib.vector.Multiprocessing,
      )
      ```
      
      ### Choosing a backend
      
      The backends live in `pufferlib.vector` (`Serial`, `Multiprocessing`, `Ray`) — there is **no `pufferlib.vectorization` module**. In normal use you do not instantiate them directly; you pass the class as the `backend=` argument to `pufferlib.vector.make`:
      
      ```python
      import pufferlib.vector
      
      # Serial — single process, easy debugging
      env = pufferlib.vector.make(
          MyEnvironment, num_envs=16, backend=pufferlib.vector.Serial)
      
      # Multiprocessing — parallel workers (required for wrapped Gymnasium/PettingZoo envs)
      env = pufferlib.vector.make(
          MyEnvironment, num_envs=256, num_workers=8,
          backend=pufferlib.vector.Multiprocessing)
      
      # Native (default) — a native PufferEnv that handles per-process batching itself
      env = pufferlib.vector.make(MyNativePufferEnv, num_envs=256)  # backend=PufferEnv
      ```
      
      ## Vectorization Modes
      
      `pufferlib.vector.make` accepts only these extra kwargs: `num_workers`, `batch_size`, `zero_copy`, `overwork`, `backend` (plus `num_envs`, `seed`, `env_args`, `env_kwargs`). Passing anything else (e.g. `envs_per_worker`, `mode`, `surplus_envs`) raises `APIUsageError`.
      
      ### Serial
      
      Best for debugging and lightweight environments — all envs run in the main process:
      
      ```python
      import pufferlib.vector
      
      env = pufferlib.vector.make(
          env_creator, num_envs=16, backend=pufferlib.vector.Serial)
      ```
      
      **When to use:** development/debugging, very fast envs, small env counts, single-threaded profiling.
      
      ### Multiprocessing
      
      Best for most production use cases and required for wrapped Gymnasium/PettingZoo envs:
      
      ```python
      import pufferlib.vector
      
      env = pufferlib.vector.make(
          env_creator, num_envs=256, num_workers=8,
          backend=pufferlib.vector.Multiprocessing)
      ```
      
      **When to use:** production training, CPU-intensive envs, large-scale parallel simulation.
      
      ### Asynchronous batching (surplus envs)
      
      There is no `mode='async'` flag. Async-style behavior comes from setting
      `batch_size` smaller than `num_envs`: PufferLib simulates the surplus
      environments and returns the first `batch_size` agents that are ready, which
      improves GPU utilization with variable step times.
      
      ```python
      env = pufferlib.vector.make(
          env_creator, num_envs=256, batch_size=128, num_workers=8,
          backend=pufferlib.vector.Multiprocessing)  # 128 surplus envs hide stragglers
      ```
      
      ## Optimizing Vectorization Performance
      
      ### Worker Configuration
      
      ```python
      import multiprocessing
      
      # Calculate optimal workers
      num_cpus = multiprocessing.cpu_count()
      
      # Conservative (leave headroom for training)
      num_workers = num_cpus - 2
      
      # Aggressive (maximize environment throughput)
      num_workers = num_cpus
      
      # With hyperthreading
      num_workers = num_cpus // 2  # Physical cores only
      ```
      
      ### Envs per worker (derived, not a kwarg)
      
      PufferLib computes envs-per-worker as `num_envs // num_workers` — there is no
      `envs_per_worker` argument. Tune the ratio by choosing `num_envs` and
      `num_workers`:
      
      ```python
      # Fast envs -> pack many per worker (high num_envs / num_workers ratio)
      env = pufferlib.vector.make(env_creator, num_envs=512, num_workers=8,    # 64 each
          backend=pufferlib.vector.Multiprocessing)
      
      # Slow envs -> fewer per worker
      env = pufferlib.vector.make(env_creator, num_envs=128, num_workers=8,    # 16 each
          backend=pufferlib.vector.Multiprocessing)
      ```
      
      ### Batch Size Tuning
      
      ```python
      # Small batch (< 8k): Good for fast iteration
      batch_size = 4096
      num_envs = 256
      steps_per_env = batch_size // num_envs  # 16 steps
      
      # Medium batch (8k-32k): Good balance
      batch_size = 16384
      num_envs = 512
      steps_per_env = 32
      
      # Large batch (> 32k): Maximum throughput
      batch_size = 65536
      num_envs = 1024
      steps_per_env = 64
      ```
      
      ## Shared Memory Optimization
      
      ### Buffer Management
      
      PufferLib uses shared memory for zero-copy observation passing:
      
      ```python
      import numpy as np
      import gymnasium
      
      class OptimizedEnv(PufferEnv):
          def __init__(self, buf=None):
              # Define spaces and num_agents BEFORE super().__init__(buf).
              # Native obs space must be a Box.
              self.single_observation_space = gymnasium.spaces.Box(
                  low=0, high=255, shape=(84, 84, 3), dtype=np.uint8)
              self.single_action_space = gymnasium.spaces.Discrete(4)
              self.num_agents = 1
      
              # super().__init__ calls set_buffers, which allocates the shared buffers
              # self.observations / self.rewards / self.terminals / self.truncations.
              super().__init__(buf)
      
          def reset(self, seed=None):
              # Write directly into the shared observation buffer (no allocation).
              self._render_to_buffer(self.observations)
              return self.observations, []
      
          def step(self, action):
              self._update_state(action)
              self._render_to_buffer(self.observations)  # in-place into shared buffer
      
              # Write reward/flags into the shared buffers, then return them.
              self.rewards[:] = self._reward()
              self.terminals[:] = self._terminated()
              self.truncations[:] = self._truncated()
              return self.observations, self.rewards, self.terminals, self.truncations, []
      ```
      
      ### Zero-Copy Patterns
      
      ```python
      # BAD: Creates copies
      def get_observation(self):
          obs = np.zeros((84, 84, 3))
          # ... fill obs ...
          return obs.copy()  # Unnecessary copy!
      
      # GOOD: Reuses buffer
      def get_observation(self):
          # Use pre-allocated buffer
          self._render_to_buffer(self._obs_buffer)
          return self._obs_buffer  # No copy
      
      # BAD: Allocates new arrays
      def step(self, action):
          new_state = self.state + action  # Allocates
          self.state = new_state
          return obs, reward, terminal, truncation, info
      
      # GOOD: In-place operations
      def step(self, action):
          self.state += action  # In-place
          return obs, reward, terminal, truncation, info
      ```
      
      ## Advanced Vectorization
      
      ### Wrapping a vecenv
      
      There is no public `VectorEnv` base class to subclass. To add custom behavior,
      wrap the object returned by `pufferlib.vector.make` and delegate `reset`/`step`:
      
      ```python
      import pufferlib.vector
      
      class CustomVecWrapper:
          """Illustrative wrapper around a PufferLib vecenv."""
      
          def __init__(self, vecenv):
              self.vecenv = vecenv
              self.num_envs = vecenv.num_envs
              self.single_observation_space = vecenv.single_observation_space
              self.single_action_space = vecenv.single_action_space
      
          def reset(self, seed=0):
              return self.vecenv.reset(seed)
      
          def step(self, actions):
              return self.vecenv.step(actions)  # delegate, then post-process as needed
      
      vecenv = pufferlib.vector.make(
          env_creator, num_envs=256, backend=pufferlib.vector.Multiprocessing)
      vecenv = CustomVecWrapper(vecenv)
      ```
      
      ### Large-scale parallelism
      
      PufferLib already packs `num_envs // num_workers` environments into each worker
      process, so a single `make` call covers the "hierarchical" case — you do not
      nest backends. Scale by raising `num_envs` and `num_workers`:
      
      ```python
      # 256 envs across 8 workers => 32 envs per worker, handled internally.
      env = pufferlib.vector.make(
          env_creator, num_envs=256, num_workers=8,
          backend=pufferlib.vector.Multiprocessing)
      ```
      
      ## Multi-Agent Vectorization
      
      ### Native Multi-Agent Support
      
      PufferLib treats multi-agent environments as first-class citizens:
      
      ```python
      import functools
      import pufferlib.vector
      
      # Multi-agent environment automatically vectorized.
      # PufferLib has no string registry -- pass an environment constructor
      # (callable). For PettingZoo envs, wrap them with
      # pufferlib.emulation.PettingZooPufferEnv inside the creator. `MultiAgentEnv`
      # here is your own multi-agent PufferEnv class / creator callable; bind
      # constructor kwargs with functools.partial.
      env = pufferlib.vector.make(
          functools.partial(MultiAgentEnv, num_agents=4),
          num_envs=128,
      )
      
      # Observations: {agent_id: [batch_obs]} for each agent
      # Actions: {agent_id: [batch_actions]} for each agent
      # Rewards: {agent_id: [batch_rewards]} for each agent
      ```
      
      Multi-agent vectorization is handled internally — you do not implement a custom
      vectorizer. Pass your multi-agent PufferEnv (or a PettingZooPufferEnv-wrapped
      env) to `pufferlib.vector.make` and PufferLib batches across agents and envs.
      
      ## Performance Monitoring
      
      ### Profiling Vectorization
      
      ```python
      import time
      
      def profile_vectorization(vec_env, num_steps=10000):
          """Profile vectorization performance."""
          start = time.time()
      
          vec_env.reset()
      
          for _ in range(num_steps):
              actions = vec_env.action_space.sample()
              vec_env.step(actions)
      
          elapsed = time.time() - start
          sps = (num_steps * vec_env.num_envs) / elapsed
      
          print(f"Steps per second: {sps:,.0f}")
          print(f"Time per step: {elapsed/num_steps*1000:.2f}ms")
      
          return sps
      ```
      
      ### Bottleneck Analysis
      
      ```python
      import cProfile
      import pstats
      
      def analyze_bottlenecks(vec_env):
          """Identify vectorization bottlenecks."""
          profiler = cProfile.Profile()
      
          profiler.enable()
      
          vec_env.reset()
          for _ in range(1000):
              actions = vec_env.action_space.sample()
              vec_env.step(actions)
      
          profiler.disable()
      
          stats = pstats.Stats(profiler)
          stats.sort_stats('cumulative')
          stats.print_stats(20)
      ```
      
      ### Real-Time Monitoring
      
      ```python
      class MonitoredVecEnv:
          """Wraps a PufferLib vecenv to log throughput (no base class to subclass)."""
      
          def __init__(self, vecenv):
              self.vecenv = vecenv
              self.num_envs = vecenv.num_envs
              self.step_times = []
              self.step_count = 0
      
          def reset(self, seed=0):
              return self.vecenv.reset(seed)
      
          def step(self, actions):
              start = time.perf_counter()
              result = self.vecenv.step(actions)
              self.step_times.append(time.perf_counter() - start)
              self.step_count += 1
      
              if self.step_count % 1000 == 0:
                  mean_time = np.mean(self.step_times[-1000:])
                  sps = self.num_envs / mean_time
                  print(f"SPS: {sps:,.0f} | Step time: {mean_time*1000:.2f}ms")
      
              return result
      ```
      
      ## Troubleshooting
      
      ### Low Throughput
      
      ```python
      # Check configuration
      print(f"Num envs: {vec_env.num_envs}")
      print(f"Num workers: {vec_env.num_workers}")
      print(f"Envs per worker: {vec_env.num_envs // vec_env.num_workers}")
      
      # Profile single environment
      single_env = MyEnvironment()
      single_sps = profile_single_env(single_env)
      print(f"Single env SPS: {single_sps:,.0f}")
      
      # Compare vectorized
      vec_sps = profile_vectorization(vec_env)
      print(f"Vectorized SPS: {vec_sps:,.0f}")
      print(f"Speedup: {vec_sps / single_sps:.1f}x")
      ```
      
      ### Memory Issues
      
      ```python
      import pufferlib.vector
      
      # Reduce number of environments (lowers per-worker env count too)
      env = pufferlib.vector.make(
          env_creator, num_envs=128, num_workers=8,   # was num_envs=256
          backend=pufferlib.vector.Multiprocessing)
      
      # Use the Serial backend for debugging (single process)
      env = pufferlib.vector.make(
          env_creator, num_envs=16, backend=pufferlib.vector.Serial)
      ```
      
      ### Synchronization Problems
      
      ```python
      # Ensure thread-safe operations
      import threading
      
      class ThreadSafeEnv(PufferEnv):
          def __init__(self, buf=None):
              super().__init__(buf)
              self.lock = threading.Lock()
      
          def step(self, action):
              with self.lock:
                  return super().step(action)
      ```
      
      ## Best Practices
      
      ### Configuration Guidelines
      
      Tune `num_envs` and `num_workers` (envs-per-worker is the derived ratio):
      
      ```python
      # Start conservative, then scale up iteratively.
      vec_kwargs = dict(num_envs=64, num_workers=4)      # 16 envs/worker
      vec_kwargs = dict(num_envs=256, num_workers=8)     # 32 envs/worker
      
      # Monitor and adjust:
      #   sps below target  -> raise num_envs and/or num_workers
      #   memory too high   -> lower num_envs (and consider cpu_offload in training)
      ```
      
      ### Environment Design
      
      ```python
      # Minimize per-step allocations
      class EfficientEnv(PufferEnv):
          def __init__(self, buf=None):
              super().__init__(buf)
      
              # Pre-allocate all buffers
              self._obs = np.zeros((84, 84, 3), dtype=np.uint8)
              self._state = np.zeros(10, dtype=np.float32)
      
          def step(self, action):
              # Use pre-allocated buffers
              self._update_state_inplace(action)
              self._render_to_obs()
      
              return self._obs, reward, terminal, truncation, info
      ```
      
      ### Testing
      
      ```python
      import numpy as np
      import pufferlib.vector
      
      # Verify the Multiprocessing backend matches Serial for the same seed.
      serial_env = pufferlib.vector.make(
          env_creator, num_envs=4, seed=42, backend=pufferlib.vector.Serial)
      vec_env = pufferlib.vector.make(
          env_creator, num_envs=4, num_workers=2, seed=42,
          backend=pufferlib.vector.Multiprocessing)
      
      serial_obs, _ = serial_env.reset(seed=42)
      vec_obs, _ = vec_env.reset(seed=42)
      
      assert np.allclose(serial_obs, vec_obs), "Vectorization mismatch!"
      ```
      
  • scripts
    • env_template.py 11.1 KB
      #!/usr/bin/env python3
      """
      PufferLib Environment Template
      
      This template provides a starting point for creating custom PufferEnv environments.
      Customize the observation space, action space, and environment logic for your task.
      """
      
      import numpy as np
      import gymnasium
      from pufferlib import PufferEnv
      
      
      class MyEnvironment(PufferEnv):
          """
          Custom PufferLib environment template.
      
          This is a simple grid world example. Customize it for your specific task.
          """
      
          def __init__(self, buf=None, grid_size=10, max_steps=1000):
              """
              Initialize environment.
      
              Args:
                  buf: Shared memory buffer (managed by PufferLib)
                  grid_size: Size of the grid world
                  max_steps: Maximum steps per episode
              """
              self.grid_size = grid_size
              self.max_steps = max_steps
      
              # Define spaces and num_agents BEFORE calling super().__init__(buf)
      
              # Define observation space.
              # IMPORTANT: a native PufferEnv obs space MUST be a Box (Dict is rejected
              # by the base class). For Dict/structured observations, build a Gymnasium
              # env and wrap it with pufferlib.emulation.GymnasiumPufferEnv instead.
              #
              # Option 1: Flat vector observation
              self.single_observation_space = gymnasium.spaces.Box(
                  low=-np.inf, high=np.inf, shape=(4,), dtype=np.float32)  # [x, y, goal_x, goal_y]
      
              # Option 2: Image observation (still a Box)
              # self.single_observation_space = gymnasium.spaces.Box(
              #     low=0, high=255, shape=(grid_size, grid_size, 3), dtype=np.uint8)
      
              # Define action space
              # Option 1: Discrete actions
              self.single_action_space = gymnasium.spaces.Discrete(4)  # 0: up, 1: right, 2: down, 3: left
      
              # Option 2: Continuous actions
              # self.single_action_space = gymnasium.spaces.Box(
              #     low=-1.0, high=1.0, shape=(2,), dtype=np.float32)  # [dx, dy]
      
              # Option 3: Multi-discrete actions
              # self.single_action_space = gymnasium.spaces.MultiDiscrete([3, 3])  # Two 3-way choices
      
              self.num_agents = 1
      
              # Initialize state
              self.agent_pos = None
              self.goal_pos = None
              self.step_count = 0
      
              super().__init__(buf)
      
          def reset(self, seed=None):
              """
              Reset environment to initial state.
      
              Returns:
                  observation: Initial observation
                  info: List of info dicts (one per agent); empty list here
              """
              # Reset state
              self.agent_pos = np.array([0, 0], dtype=np.float32)
              self.goal_pos = np.array([self.grid_size - 1, self.grid_size - 1], dtype=np.float32)
              self.step_count = 0
      
              # Return initial observation and an (empty) info list
              return self._get_observation(), []
      
          def step(self, action):
              """
              Execute one environment step.
      
              Args:
                  action: Action to take
      
              Returns:
                  observation: New observation
                  reward: Reward for this step
                  terminal: Whether episode terminated (goal/failure)
                  truncation: Whether episode was truncated (time limit)
                  info: List of info dicts (one per agent)
              """
              self.step_count += 1
      
              # Execute action
              self._apply_action(action)
      
              # Compute reward
              reward = self._compute_reward()
      
              # Episode terminates on goal; truncates on the step limit
              terminal = self._is_terminated()
              truncation = self.step_count >= self.max_steps
      
              # Get new observation
              observation = self._get_observation()
      
              # Additional info (list of dicts, one per agent)
              info = []
              if terminal or truncation:
                  # Include episode statistics when episode ends
                  info.append({'episode': {
                      'r': reward,
                      'l': self.step_count
                  }})
      
              return observation, reward, terminal, truncation, info
      
          def _apply_action(self, action):
              """Apply action to update environment state."""
              # Discrete actions: 0=up, 1=right, 2=down, 3=left
              if action == 0:  # Up
                  self.agent_pos[1] = min(self.agent_pos[1] + 1, self.grid_size - 1)
              elif action == 1:  # Right
                  self.agent_pos[0] = min(self.agent_pos[0] + 1, self.grid_size - 1)
              elif action == 2:  # Down
                  self.agent_pos[1] = max(self.agent_pos[1] - 1, 0)
              elif action == 3:  # Left
                  self.agent_pos[0] = max(self.agent_pos[0] - 1, 0)
      
          def _compute_reward(self):
              """Compute reward for current state."""
              # Distance to goal
              distance = np.linalg.norm(self.agent_pos - self.goal_pos)
      
              # Reward shaping: negative distance + bonus for reaching goal
              reward = -distance / self.grid_size
      
              # Goal reached
              if distance < 0.5:
                  reward += 10.0
      
              return reward
      
          def _is_terminated(self):
              """Check if episode terminated (goal reached). Timeout is a truncation, handled in step()."""
              distance = np.linalg.norm(self.agent_pos - self.goal_pos)
              goal_reached = distance < 0.5
      
              return goal_reached
      
          def _get_observation(self):
              """Generate observation from current state."""
              # Return flat vector observation
              observation = np.concatenate([
                  self.agent_pos,
                  self.goal_pos
              ]).astype(np.float32)
      
              return observation
      
      
      class MultiAgentEnvironment(PufferEnv):
          """
          Multi-agent environment template (NATIVE PufferEnv API).
      
          Example: cooperative navigation where each agent reaches its own goal.
      
          Native multi-agent uses the SAME array-based API as single-agent: set
          num_agents > 1, make single_*_space PER AGENT, and have reset/step return
          arrays whose leading dimension is num_agents. There is NO {agent_id: ...}
          dict / '__all__' convention here -- that belongs to PettingZoo (use
          pufferlib.emulation.PettingZooPufferEnv for those). obs space must be a Box.
          """
      
          def __init__(self, buf=None, num_agents=4, grid_size=10, max_steps=1000):
              # Define spaces and num_agents BEFORE calling super().__init__(buf)
              self.num_agents = num_agents
              self.grid_size = grid_size
              self.max_steps = max_steps
      
              # Per-agent observation: [pos(2), goal(2), other agent positions(2*(n-1))]
              obs_dim = 2 + 2 + 2 * (num_agents - 1)
              self.single_observation_space = gymnasium.spaces.Box(
                  low=-np.inf, high=np.inf, shape=(obs_dim,), dtype=np.float32)
      
              # Per-agent action space
              self.single_action_space = gymnasium.spaces.Discrete(5)  # 4 directions + stay
      
              # Initialize state
              self.agent_positions = None
              self.goal_positions = None
              self.step_count = 0
      
              super().__init__(buf)
      
          def reset(self, seed=None):
              """Reset all agents. Returns (obs, info-list)."""
              self.agent_positions = np.random.rand(self.num_agents, 2) * self.grid_size
              self.goal_positions = np.random.rand(self.num_agents, 2) * self.grid_size
              self.step_count = 0
      
              return self._get_obs(), []
      
          def step(self, actions):
              """
              Step all agents.
      
              Args:
                  actions: array of shape (num_agents,) of discrete actions
      
              Returns:
                  observations: (num_agents, obs_dim) float32 array
                  rewards: (num_agents,) float32 array
                  terminals: (num_agents,) bool array
                  truncations: (num_agents,) bool array
                  info: list of dicts (PufferEnv asserts info is a list)
              """
              self.step_count += 1
      
              actions = np.asarray(actions).reshape(self.num_agents)
              for agent_idx in range(self.num_agents):
                  self._apply_action(agent_idx, int(actions[agent_idx]))
      
              observations = self._get_obs()
              rewards = self._compute_rewards()
              terminals = self._terminated()
              truncations = np.full(
                  self.num_agents, self.step_count >= self.max_steps, dtype=bool)
      
              return observations, rewards, terminals, truncations, []
      
          def _apply_action(self, agent_idx, action):
              """Apply action for specific agent."""
              if action == 0:  # Up
                  self.agent_positions[agent_idx, 1] += 1
              elif action == 1:  # Right
                  self.agent_positions[agent_idx, 0] += 1
              elif action == 2:  # Down
                  self.agent_positions[agent_idx, 1] -= 1
              elif action == 3:  # Left
                  self.agent_positions[agent_idx, 0] -= 1
              # action == 4: Stay
      
              # Clip to grid bounds
              self.agent_positions[agent_idx] = np.clip(
                  self.agent_positions[agent_idx],
                  0,
                  self.grid_size - 1
              )
      
          def _compute_rewards(self):
              """Per-agent reward: negative normalized distance to each agent's goal."""
              distances = np.linalg.norm(
                  self.agent_positions - self.goal_positions, axis=1)
              return (-distances / self.grid_size).astype(np.float32)
      
          def _terminated(self):
              """Per-agent termination: True once close to its goal."""
              distances = np.linalg.norm(
                  self.agent_positions - self.goal_positions, axis=1)
              return distances < 0.5
      
          def _get_obs(self):
              """Build the (num_agents, obs_dim) observation array."""
              obs = np.zeros(
                  (self.num_agents, self.single_observation_space.shape[0]),
                  dtype=np.float32)
              for i in range(self.num_agents):
                  others = np.concatenate([
                      self.agent_positions[j] for j in range(self.num_agents) if j != i
                  ]) if self.num_agents > 1 else np.zeros(0, dtype=np.float32)
                  obs[i] = np.concatenate([
                      self.agent_positions[i], self.goal_positions[i], others
                  ]).astype(np.float32)
              return obs
      
      
      def test_environment():
          """Test environment to verify it works correctly."""
          print("Testing single-agent environment...")
          env = MyEnvironment()
      
          obs, info = env.reset()
          print(f"Initial observation shape: {obs.shape}")
      
          for step in range(10):
              action = env.single_action_space.sample()
              obs, reward, terminal, truncation, info = env.step(action)
      
              print(f"Step {step}: reward={reward:.3f}, terminal={terminal}, truncation={truncation}")
      
              if terminal or truncation:
                  obs, info = env.reset()
                  print("Episode finished, resetting...")
      
          print("\nTesting multi-agent environment...")
          multi_env = MultiAgentEnvironment(num_agents=4)
      
          obs, info = multi_env.reset()
          print(f"Obs shape (num_agents, obs_dim): {obs.shape}")
      
          for step in range(10):
              # One action per agent: array of shape (num_agents,)
              actions = np.array([
                  multi_env.single_action_space.sample()
                  for _ in range(multi_env.num_agents)
              ])
              obs, rewards, terminals, truncations, info = multi_env.step(actions)
      
              print(f"Step {step}: mean_reward={rewards.mean():.3f}")
      
              if terminals.all() or truncations.any():
                  obs, info = multi_env.reset()
                  print("Episode finished, resetting...")
      
          print("\nEnvironment tests passed.")
      
      
      if __name__ == '__main__':
          test_environment()
      
    • train_template.py 11.2 KB
      #!/usr/bin/env python3
      """
      PufferLib Training Template (targets PufferLib 3.0.x)
      
      This template provides a complete training script for reinforcement learning
      with PufferLib. Customize the environment, policy, and training configuration
      as needed for your use case.
      
      Key 3.0 API facts this template respects:
      - pufferlib.vector.make(env_creator, num_envs=..., backend=...). The default
        backend is PufferEnv (native vectorization); wrapped Gymnasium/PettingZoo
        envs require backend=pufferlib.vector.Multiprocessing (or Serial).
      - PuffeRL(config, vecenv, policy, logger=None) -- the first arg is a config
        DICT (not flat kwargs), the env arg is the vecenv, and the loop is driven by
        trainer.global_step < config['total_timesteps'].
      - Loggers (WandbLogger/NeptuneLogger/NoLogger) live in pufferlib.pufferl and
        take the nested args config dict, not project=/name= kwargs.
      """
      
      import argparse
      import numpy as np
      import torch
      import torch.nn as nn
      import pufferlib
      import pufferlib.vector
      from pufferlib.pufferl import PuffeRL, NoLogger, WandbLogger, NeptuneLogger
      from pufferlib.pytorch import layer_init
      
      
      class Policy(nn.Module):
          """Example policy network for a flat-vector, discrete-action env.
      
          PufferLib does not enforce a base class, but PuffeRL calls
          `policy.forward_eval(obs, state)` and expects it to return (logits, values).
          Structuring the network as encode_observations + decode_actions (as below)
          lets you wrap it with pufferlib.models.LSTMWrapper later without rewrites.
          The constructor takes the (driver) env so it can read single_observation_space
          / single_action_space, mirroring pufferlib.models.Default.
          """
      
          def __init__(self, env, hidden_size=256):
              super().__init__()
              self.hidden_size = hidden_size
      
              obs_dim = int(np.prod(env.single_observation_space.shape))
              num_actions = env.single_action_space.n
      
              self.encoder = nn.Sequential(
                  layer_init(nn.Linear(obs_dim, hidden_size)),
                  nn.ReLU(),
                  layer_init(nn.Linear(hidden_size, hidden_size)),
                  nn.ReLU(),
              )
              self.actor = layer_init(nn.Linear(hidden_size, num_actions), std=0.01)
              self.critic = layer_init(nn.Linear(hidden_size, 1), std=1.0)
      
          def encode_observations(self, observations, state=None):
              return self.encoder(observations.float())
      
          def decode_actions(self, hidden):
              return self.actor(hidden), self.critic(hidden)
      
          def forward_eval(self, observations, state=None):
              hidden = self.encode_observations(observations, state)
              return self.decode_actions(hidden)
      
          def forward(self, observations, state=None):
              return self.forward_eval(observations, state)
      
      
      def resolve_env_creator(env_name):
          """Map a CLI env name to an environment constructor (callable).
      
          Ocean environments resolve by name in PufferLib 3.0:
      
              from pufferlib.ocean import env_creator
              return env_creator(f'puffer_{env_name}')   # e.g. 'puffer_breakout'
      
          For your own environments, maintain a mapping, e.g.:
      
              registry = {'my_task': MyTask, 'my_task_4p': functools.partial(MyTask, num_agents=4)}
              return registry[env_name]
          """
          raise NotImplementedError(
              f"Define a constructor for {env_name!r} (see resolve_env_creator).")
      
      
      def make_env():
          """Create vectorized environment. Customize this for your task.
      
          PufferLib has no string registry -- pass an environment constructor
          (callable) to `pufferlib.vector.make`.
          """
          # Option 1: A PufferEnv constructor (e.g. an Ocean environment).
          #   `env_creator` is a placeholder: replace it with the callable that
          #   constructs your environment, e.g. pufferlib.ocean.env_creator('puffer_breakout').
          env_creator = None  # TODO: replace with your environment constructor (callable)
          return pufferlib.vector.make(env_creator, num_envs=256)
      
          # Option 2: A Gymnasium environment wrapped for PufferLib. Wrapped (non-native)
          # envs require an explicit backend -- the default backend=PufferEnv is native-only.
          # import gymnasium as gym
          # import pufferlib.emulation
          # gym_creator = lambda: pufferlib.emulation.GymnasiumPufferEnv(
          #     env_creator=lambda: gym.make('CartPole-v1'))
          # return pufferlib.vector.make(
          #     gym_creator, num_envs=256, backend=pufferlib.vector.Multiprocessing)
      
          # Option 3: A custom PufferEnv.
          # from my_envs import MyEnvironment
          # return pufferlib.vector.make(MyEnvironment, num_envs=256)
      
      
      def create_policy(env):
          """Create policy network. PufferLib policies read single_*_space off the env."""
          return Policy(env, hidden_size=256)
      
      
      def train(args):
          """Main training function."""
          # Set random seeds
          torch.manual_seed(args.seed)
      
          # Create environment.
          # `pufferlib.vector.make` takes an environment constructor (callable).
          # Resolve the CLI name to a constructor here; `resolve_env_creator` is a
          # placeholder you implement (Ocean: pufferlib.ocean.env_creator('puffer_<name>');
          # your own envs: a dict of PufferEnv classes / functools.partial).
          print(f"Creating environment with {args.num_envs} parallel environments...")
          env_creator = resolve_env_creator(args.env_name)
          env = pufferlib.vector.make(
              env_creator,
              num_envs=args.num_envs,
              num_workers=args.num_workers
          )
      
          # Create policy
          print("Initializing policy...")
          policy = create_policy(env)
      
          if args.device == 'cuda' and torch.cuda.is_available():
              policy = policy.cuda()
              print(f"Using GPU: {torch.cuda.get_device_name(0)}")
          else:
              args.device = 'cpu'
              print("Using CPU")
      
          # Build the config DICT PuffeRL expects. In real use, prefer
          # `args = pufferlib.pufferl.load_config(env_name)` and pass `args['train']`
          # so every required key is filled from pufferlib/config/*.ini. Here we set
          # the keys this template exposes explicitly.
          config = {
              'env': args.env_name,
              'seed': args.seed,
              'torch_deterministic': True,
              'cpu_offload': False,
              'device': args.device,
              'total_timesteps': args.total_timesteps,
              'learning_rate': args.learning_rate,
              'anneal_lr': True,
              'min_lr_ratio': 0.0,
              'gamma': args.gamma,
              'gae_lambda': args.gae_lambda,
              'update_epochs': args.update_epochs,
              'clip_coef': args.clip_coef,
              'ent_coef': args.ent_coef,
              'vf_coef': args.vf_coef,
              'vf_clip_coef': 0.2,
              'max_grad_norm': args.max_grad_norm,
              'batch_size': args.batch_size,
              'minibatch_size': args.minibatch_size,
              'max_minibatch_size': args.minibatch_size,
              'bptt_horizon': args.bptt_horizon,
              'compile': args.compile,
              'use_rnn': False,
              'data_dir': args.checkpoint_dir,
              'checkpoint_interval': args.checkpoint_interval,
          }
      
          # Logger. The 3.0 loggers read keys from a config dict; the simplest path is
          # NoLogger. Populate wandb_*/neptune_* keys in `config` if you wire those up.
          if args.logger == 'wandb':
              logger = WandbLogger(config)
          elif args.logger == 'neptune':
              logger = NeptuneLogger(config)
          else:
              logger = NoLogger(config)
      
          # Create trainer: positional (config, vecenv, policy, logger).
          print("Creating trainer...")
          trainer = PuffeRL(config, env, policy, logger)
      
          # Training loop is driven by global_step, not a fixed iteration count.
          print(f"Training to {config['total_timesteps']:,} timesteps...")
          while trainer.global_step < config['total_timesteps']:
              trainer.evaluate()      # Collect rollouts
              trainer.train()         # Train on batch
              trainer.mean_and_log()  # Aggregate + log (PuffeRL also auto-checkpoints
                                      # every config['checkpoint_interval'] epochs)
      
          print("Training complete!")
      
          # Final checkpoint (save_checkpoint takes no path; it writes under data_dir).
          final_path = trainer.save_checkpoint()
          print(f"Saved final model to {final_path}")
      
      
      def main():
          parser = argparse.ArgumentParser(description='PufferLib Training')
      
          # Environment
          parser.add_argument('--env-name', type=str, default='my-env',
                              help='Env key resolved to a constructor by '
                                   'resolve_env_creator (PufferLib has no string registry)')
          parser.add_argument('--num-envs', type=int, default=256,
                              help='Number of parallel environments')
          parser.add_argument('--num-workers', type=int, default=8,
                              help='Number of vectorization workers')
      
          # Training (defaults mirror pufferlib/config/default.ini, 3.0.x)
          parser.add_argument('--total-timesteps', type=int, default=10_000_000,
                              help='Total environment steps to train for')
          parser.add_argument('--learning-rate', type=float, default=0.015,
                              help='Learning rate (PufferLib default optimizer is muon)')
          parser.add_argument('--batch-size', type=int, default=32768,
                              help='Timesteps per training batch')
          parser.add_argument('--minibatch-size', type=int, default=8192,
                              help='Minibatch size (also used as max for grad accumulation)')
          parser.add_argument('--bptt-horizon', type=int, default=64,
                              help='BPTT / rollout horizon per segment')
          parser.add_argument('--update-epochs', type=int, default=1,
                              help='Training epochs per batch')
          parser.add_argument('--device', type=str, default='cuda',
                              choices=['cuda', 'cpu'], help='Device to use')
      
          # PPO Parameters
          parser.add_argument('--gamma', type=float, default=0.995,
                              help='Discount factor')
          parser.add_argument('--gae-lambda', type=float, default=0.90,
                              help='GAE lambda')
          parser.add_argument('--clip-coef', type=float, default=0.2,
                              help='PPO clipping coefficient')
          parser.add_argument('--ent-coef', type=float, default=0.001,
                              help='Entropy coefficient')
          parser.add_argument('--vf-coef', type=float, default=2.0,
                              help='Value function coefficient')
          parser.add_argument('--max-grad-norm', type=float, default=1.5,
                              help='Maximum gradient norm')
      
          # Logging
          parser.add_argument('--logger', type=str, default='none',
                              choices=['wandb', 'neptune', 'none'],
                              help='Logger to use (set wandb_*/neptune_* config keys to wire up)')
      
          # Checkpointing (PuffeRL auto-saves under data_dir every checkpoint_interval epochs)
          parser.add_argument('--checkpoint-dir', type=str, default='experiments',
                              help='data_dir where checkpoints are written')
          parser.add_argument('--checkpoint-interval', type=int, default=200,
                              help='Checkpoint save frequency (epochs)')
      
          # Misc
          parser.add_argument('--seed', type=int, default=42,
                              help='Random seed')
          parser.add_argument('--compile', action='store_true',
                              help='Use torch.compile for faster training')
      
          args = parser.parse_args()
      
          # Create checkpoint directory
          import os
          os.makedirs(args.checkpoint_dir, exist_ok=True)
      
          # Run training
          train(args)
      
      
      if __name__ == '__main__':
          main()
      
  • SKILL.md 17.7 KB
    ---
    name: alterlab-pufferlib
    description: Scales reinforcement learning with PufferLib — high-throughput parallel training (PuffeRL), vectorized environments, and native multi-agent systems achieving 2-10x speedups over standard implementations. Use when scaling RL to millions of steps per second, running vectorized or multi-agent setups, building custom PufferEnv tasks, or integrating game environments (Atari, Procgen, NetHack, PettingZoo). For standard single-agent algorithm implementations (PPO/SAC/DQN) or quick prototyping prefer alterlab-stable-baselines3. Part of the AlterLab Academic Skills suite.
    license: MIT
    allowed-tools: Read Write Edit Bash(python:*) Bash(uv:*)
    compatibility: No API key required. Runs locally via `uv run python`; requires the pufferlib Python package (GPU optional for faster training). Targets PufferLib 3.0.x, still the latest PyPI release as of 2026-09 (3.0.0, June 2025); upstream's default branch is now 5.0, a from-source C/CUDA rewrite with a different API.
    metadata:
        skill-author: AlterLab
        version: "1.0.1"
        last_updated: "2026-09-23"
    ---
    
    # PufferLib - High-Performance Reinforcement Learning
    
    ## Overview
    
    PufferLib is a high-performance reinforcement learning library designed for fast parallel environment simulation and training. It achieves training at millions of steps per second through optimized vectorization, native multi-agent support, and efficient PPO implementation (PuffeRL). The library provides the Ocean suite of 20+ environments and seamless integration with Gymnasium, PettingZoo, and specialized RL frameworks.
    
    ## When to Use This Skill
    
    Use this skill when:
    - **Training RL agents** with PPO on any environment (single or multi-agent)
    - **Creating custom environments** using the PufferEnv API
    - **Optimizing performance** for parallel environment simulation (vectorization)
    - **Integrating existing environments** from Gymnasium, PettingZoo, Atari, Procgen, etc.
    - **Developing policies** with CNN, LSTM, or custom architectures
    - **Scaling RL** to millions of steps per second for faster experimentation
    - **Multi-agent RL** with native multi-agent environment support
    
    ### Does NOT Trigger
    
    | Scenario | Use Instead |
    |----------|-------------|
    | Quick single-agent prototype with a standard, well-documented PPO/SAC/DQN implementation | `alterlab-stable-baselines3` |
    | Supervised deep-learning training loops (classification, regression) with checkpointing and multi-GPU | `alterlab-pytorch-lightning` |
    | Agent-based simulation of social systems with rule-based agents and no learning | `alterlab-abm-mesa` |
    
    ## Core Capabilities
    
    ### 1. High-Performance Training (PuffeRL)
    
    PuffeRL is PufferLib's optimized PPO trainer (CleanRL-derived, with optional LSTM via `models.LSTMWrapper`) built for high-throughput training.
    
    **Recommended path — CLI / high-level helper.** Drive training from a config (an `.ini` in `pufferlib/config/`) rather than hand-wiring the trainer:
    ```bash
    # CLI: env name resolves to a config in pufferlib/config/ + an Ocean env
    puffer train puffer_breakout --train.device cuda --train.learning-rate 0.015
    # Resume from a checkpoint (top-level flag, not a [train] key)
    puffer train puffer_breakout --load-model-path latest
    ```
    ```python
    import pufferlib.pufferl as pufferl
    # train(env_name, args=None, vecenv=None, policy=None, logger=None)
    pufferl.train('puffer_breakout')
    ```
    
    **Manual loop.** `PuffeRL(config, vecenv, policy, logger=None)` — note the first arg is a **config dict** (not flat kwargs), the env arg is `vecenv`, and the loop is driven by `global_step`. The three loop methods are real: `evaluate()`, `train()`, `mean_and_log()`.
    ```python
    import pufferlib.vector
    from pufferlib.pufferl import PuffeRL, load_config
    
    # Native PufferEnv -> default backend=PufferEnv. For wrapped (Gymnasium/
    # PettingZoo) envs you MUST pass backend=pufferlib.vector.Multiprocessing.
    vecenv = pufferlib.vector.make(MyPufferEnv, num_envs=256)
    
    # load_config returns a nested args dict (sections: 'train', 'vec', 'env', ...)
    # with defaults from pufferlib/config/*.ini. PuffeRL takes the 'train' section.
    args = load_config('puffer_breakout')
    config = {**args['train'], 'env': 'puffer_breakout'}
    config['device'] = 'cuda'
    
    trainer = PuffeRL(config, vecenv, my_policy)
    while trainer.global_step < config['total_timesteps']:
        trainer.evaluate()      # Collect rollouts
        trainer.train()         # Train on batch
        trainer.mean_and_log()  # Aggregate + log
    ```
    
    **For comprehensive training guidance**, read `references/training.md` for:
    - Complete training workflow and CLI options
    - Hyperparameter tuning with Protein
    - Distributed multi-GPU/multi-node training
    - Logger integration (Weights & Biases, Neptune)
    - Checkpointing and resume training
    - Performance optimization tips
    - Curriculum learning patterns
    
    ### 2. Environment Development (PufferEnv)
    
    Create custom high-performance environments with the PufferEnv API.
    
    **Basic environment structure:**
    ```python
    import numpy as np
    import gymnasium
    from pufferlib import PufferEnv
    
    class MyEnvironment(PufferEnv):
        def __init__(self, buf=None):
            # Define spaces BEFORE calling super().__init__(buf)
            self.single_observation_space = gymnasium.spaces.Box(
                low=-np.inf, high=np.inf, shape=(4,), dtype=np.float32)
            self.single_action_space = gymnasium.spaces.Discrete(4)
            self.num_agents = 1
    
            super().__init__(buf)
    
        def reset(self, seed=None):
            # Reset state and return (observation, info-list)
            obs = self._get_observation()
            return obs, []
    
        def step(self, action):
            # Execute action, compute reward, check termination/truncation
            obs = self._get_observation()
            rewards = self._compute_reward()
            terminals = self._is_done()
            truncations = self._is_truncated()
            info = []
    
            return obs, rewards, terminals, truncations, info
    ```
    
    **Use the template script:** `scripts/env_template.py` provides complete single-agent and multi-agent environment templates with examples of:
    - Different observation space types (vector, image, dict)
    - Action space variations (discrete, continuous, multi-discrete)
    - Multi-agent environment structure
    - Testing utilities
    
    **For complete environment development**, read `references/environments.md` for:
    - PufferEnv API details and in-place operation patterns
    - Observation and action space definitions
    - Multi-agent environment creation
    - Ocean suite (20+ pre-built environments)
    - Performance optimization (Python to C workflow)
    - Environment wrappers and best practices
    - Debugging and validation techniques
    
    ### 3. Vectorization and Performance
    
    Achieve maximum throughput with optimized parallel simulation.
    
    **Vectorization setup:**
    ```python
    import pufferlib.vector
    
    # Pass an env-constructor callable. Default backend=PufferEnv is native-only;
    # for wrapped (Gymnasium/PettingZoo) envs add backend=pufferlib.vector.Multiprocessing.
    env = pufferlib.vector.make(env_creator, num_envs=256, num_workers=8)
    
    # Performance benchmarks (PufferLib's published figures; vary by env/hardware):
    # - Pure Python envs: 100k-500k SPS
    # - C-based envs: 100M+ SPS
    # - With training: 400k-4M total SPS
    ```
    
    **Key optimizations:**
    - Shared memory buffers for zero-copy observation passing
    - Busy-wait flags instead of pipes/queues
    - Surplus environments for async returns
    - Multiple environments per worker
    
    **For vectorization optimization**, read `references/vectorization.md` for:
    - Architecture and performance characteristics
    - Worker and batch size configuration
    - Serial vs multiprocessing vs async modes
    - Shared memory and zero-copy patterns
    - Hierarchical vectorization for large scale
    - Multi-agent vectorization strategies
    - Performance profiling and troubleshooting
    
    ### 4. Policy Development
    
    Build policies as standard PyTorch modules with optional utilities.
    
    **Basic policy structure:**
    ```python
    import torch.nn as nn
    from pufferlib.pytorch import layer_init
    
    class Policy(nn.Module):
        def __init__(self, observation_space, action_space):
            super().__init__()
    
            # Encoder
            self.encoder = nn.Sequential(
                layer_init(nn.Linear(obs_dim, 256)),
                nn.ReLU(),
                layer_init(nn.Linear(256, 256)),
                nn.ReLU()
            )
    
            # Actor and critic heads
            self.actor = layer_init(nn.Linear(256, num_actions), std=0.01)
            self.critic = layer_init(nn.Linear(256, 1), std=1.0)
    
        def forward(self, observations):
            features = self.encoder(observations)
            return self.actor(features), self.critic(features)
    ```
    
    **For complete policy development**, read `references/policies.md` for:
    - CNN policies for image observations
    - Recurrent policies with optimized LSTM (3x faster inference)
    - Multi-input policies for complex observations
    - Continuous action policies
    - Multi-agent policies (shared vs independent parameters)
    - Advanced architectures (attention, residual)
    - Observation normalization and gradient clipping
    - Policy debugging and testing
    
    ### 5. Environment Integration
    
    Seamlessly integrate environments from popular RL frameworks.
    
    **Gymnasium integration:**
    ```python
    import gymnasium as gym
    import pufferlib.emulation
    import pufferlib.vector
    
    # Wrap a Gymnasium env in a GymnasiumPufferEnv, then vectorize.
    # Wrapped (non-native) envs require an explicit backend (Serial or Multiprocessing);
    # the default backend=PufferEnv is only for native PufferEnvs.
    def env_creator():
        return pufferlib.emulation.GymnasiumPufferEnv(
            env_creator=lambda: gym.make('CartPole-v1'))
    
    env = pufferlib.vector.make(
        env_creator, num_envs=256, backend=pufferlib.vector.Multiprocessing)
    ```
    
    **PettingZoo multi-agent:**
    ```python
    import pufferlib.emulation
    import pufferlib.vector
    from pettingzoo.butterfly import knights_archers_zombies_v10
    
    # Wrap a PettingZoo env in a PettingZooPufferEnv, then vectorize.
    def env_creator():
        return pufferlib.emulation.PettingZooPufferEnv(
            env_creator=lambda: knights_archers_zombies_v10.parallel_env())
    
    env = pufferlib.vector.make(
        env_creator, num_envs=128, backend=pufferlib.vector.Multiprocessing)
    ```
    
    **Supported frameworks:**
    - Gymnasium / OpenAI Gym
    - PettingZoo (parallel and AEC)
    - Atari (ALE)
    - Procgen
    - NetHack / MiniHack
    - Minigrid
    - Neural MMO
    - Crafter
    - GPUDrive
    - MicroRTS
    - Griddly
    - And more...
    
    **For integration details**, read `references/integration.md` for:
    - Complete integration examples for each framework
    - Custom wrappers (observation, reward, frame stacking, action repeat)
    - Space flattening and unflattening
    - Environment registration
    - Compatibility patterns
    - Performance considerations
    - Integration debugging
    
    ## Quick Start Workflow
    
    ### For Training Existing Environments
    
    1. Choose environment from Ocean suite or compatible framework
    2. Use `scripts/train_template.py` as starting point
    3. Configure hyperparameters for your task
    4. Run training with CLI or Python script
    5. Monitor with Weights & Biases or Neptune
    6. Refer to `references/training.md` for optimization
    
    ### For Creating Custom Environments
    
    1. Start with `scripts/env_template.py`
    2. Define observation and action spaces
    3. Implement `reset()` and `step()` methods
    4. Test environment locally
    5. Wrap with `pufferlib.emulation.GymnasiumPufferEnv` and vectorize with `pufferlib.vector.make()`
    6. Refer to `references/environments.md` for advanced patterns
    7. Optimize with `references/vectorization.md` if needed
    
    ### For Policy Development
    
    1. Choose architecture based on observations:
       - Vector observations → MLP policy
       - Image observations → CNN policy
       - Sequential tasks → LSTM policy
       - Complex observations → Multi-input policy
    2. Use `layer_init` for proper weight initialization
    3. Follow patterns in `references/policies.md`
    4. Test with environment before full training
    
    ### For Performance Optimization
    
    1. Profile current throughput (steps per second)
    2. Check vectorization configuration (num_envs, num_workers)
    3. Optimize environment code (in-place ops, numpy vectorization)
    4. Consider C implementation for critical paths
    5. Use `references/vectorization.md` for systematic optimization
    
    ## Resources
    
    ### scripts/
    
    **train_template.py** - Complete training script template with:
    - Environment creation and configuration
    - Policy initialization
    - Logger integration (WandB, Neptune)
    - Training loop with checkpointing
    - Command-line argument parsing
    - Multi-GPU distributed training setup
    
    **env_template.py** - Environment implementation templates:
    - Single-agent PufferEnv example (grid world)
    - Multi-agent PufferEnv example (cooperative navigation)
    - Multiple observation/action space patterns
    - Testing utilities
    
    ### references/
    
    **training.md** - Comprehensive training guide:
    - Training workflow and CLI options
    - Hyperparameter configuration
    - Distributed training (multi-GPU, multi-node)
    - Monitoring and logging
    - Checkpointing
    - Protein hyperparameter tuning
    - Performance optimization
    - Common training patterns
    - Troubleshooting
    
    **environments.md** - Environment development guide:
    - PufferEnv API and characteristics
    - Observation and action spaces
    - Multi-agent environments
    - Ocean suite environments
    - Custom environment development workflow
    - Python to C optimization path
    - Third-party environment integration
    - Wrappers and best practices
    - Debugging
    
    **vectorization.md** - Vectorization optimization:
    - Architecture and key optimizations
    - Vectorization modes (serial, multiprocessing, async)
    - Worker and batch configuration
    - Shared memory and zero-copy patterns
    - Advanced vectorization (hierarchical, custom)
    - Multi-agent vectorization
    - Performance monitoring and profiling
    - Troubleshooting and best practices
    
    **policies.md** - Policy architecture guide:
    - Basic policy structure
    - CNN policies for images
    - LSTM policies with optimization
    - Multi-input policies
    - Continuous action policies
    - Multi-agent policies
    - Advanced architectures (attention, residual)
    - Observation processing and unflattening
    - Initialization and normalization
    - Debugging and testing
    
    **integration.md** - Framework integration guide:
    - Gymnasium integration
    - PettingZoo integration (parallel and AEC)
    - Third-party environments (Procgen, NetHack, Minigrid, etc.)
    - Custom wrappers (observation, reward, frame stacking, etc.)
    - Space conversion and unflattening
    - Environment registration
    - Compatibility patterns
    - Performance considerations
    - Debugging integration
    
    ## Tips for Success
    
    1. **Start simple**: Begin with Ocean environments or Gymnasium integration before creating custom environments
    
    2. **Profile early**: Measure steps per second from the start to identify bottlenecks
    
    3. **Use templates**: `scripts/train_template.py` and `scripts/env_template.py` provide solid starting points
    
    4. **Read references as needed**: Each reference file is self-contained and focused on a specific capability
    
    5. **Optimize progressively**: Start with Python, profile, then optimize critical paths with C if needed
    
    6. **Leverage vectorization**: PufferLib's vectorization is key to achieving high throughput
    
    7. **Monitor training**: Use WandB or Neptune to track experiments and identify issues early
    
    8. **Test environments**: Validate environment logic before scaling up training
    
    9. **Check existing environments**: Ocean suite provides 20+ pre-built environments
    
    10. **Use proper initialization**: Always use `layer_init` from `pufferlib.pytorch` for policies
    
    ## Common Use Cases
    
    ### Training on Standard Benchmarks
    ```python
    import pufferlib.vector
    
    # Atari (pass an env-constructor callable)
    env = pufferlib.vector.make(make_pong_env, num_envs=256)
    
    # Procgen
    env = pufferlib.vector.make(make_coinrun_env, num_envs=256)
    
    # Minigrid
    env = pufferlib.vector.make(make_minigrid_env, num_envs=256)
    ```
    
    ### Multi-Agent Learning
    ```python
    import pufferlib.vector
    
    # PettingZoo, wrapped via PettingZooPufferEnv (needs an explicit backend)
    env = pufferlib.vector.make(
        make_pistonball_env, num_envs=128, backend=pufferlib.vector.Multiprocessing)
    
    # One shared policy serves all agents (single_observation_space / single_action_space
    # are per-agent). Pass config (dict), vecenv, policy positionally to PuffeRL.
    policy = create_policy(env.single_observation_space, env.single_action_space)
    trainer = PuffeRL(config, env, policy)
    ```
    
    ### Custom Task Development
    ```python
    import pufferlib.vector
    
    # Create custom environment (a native PufferEnv subclass)
    class MyTask(PufferEnv):
        # ... implement environment ...
    
    # Native PufferEnv -> default backend=PufferEnv is fine here.
    env = pufferlib.vector.make(MyTask, num_envs=256)
    trainer = PuffeRL(config, env, my_policy)  # config is a dict (see Training above)
    ```
    
    ### High-Performance Optimization
    ```python
    import pufferlib.vector
    
    # Maximize throughput (pass an env-constructor callable)
    env = pufferlib.vector.make(
        my_env_creator,     # env constructor callable
        num_envs=1024,      # Large batch
        num_workers=16,     # Many workers
        backend=pufferlib.vector.Multiprocessing,
    )
    ```
    
    ## Installation
    
    ```bash
    # Pin the 3.0 line — the config-dict trainer API and import paths in this skill
    # target it. PyPI ships only an sdist, so a C compiler is needed to build it.
    uv pip install "pufferlib==3.0.*"
    ```
    
    **PufferLib 5.0 is a different product.** The upstream default branch (5.0) is a C/CUDA
    trainer built from source via PufferTank (`./build.sh ENV`, `./puffer train`). It has no pip
    package, has dropped the third-party (Gymnasium/PettingZoo-style) integrations, and offers
    CPU evaluation but no CPU training, so do not assume the Python APIs in this skill (`PuffeRL`,
    `pufferlib.vector.make`, `pufferlib.emulation`) exist there. If a user is on 5.0, say so and
    point them to its docs rather than adapting 3.0 code.
    
    ## Documentation
    
    - 3.0 source (matches this skill): https://github.com/PufferAI/PufferLib/tree/3.0
    - Current upstream docs (describe 5.0, not 3.0): https://puffer.ai/docs.html
    - GitHub: https://github.com/PufferAI/PufferLib
    - Discord: Community support available
    
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related