Skip to content

Latest commit

 

History

History
760 lines (621 loc) · 21.9 KB

File metadata and controls

760 lines (621 loc) · 21.9 KB

pybind11 Migration Plan: Removing gym3, Full Gymnasium Support

Overview

This plan migrates procgen from gym3.libenv.CEnv (unmaintained since 2020) to pybind11 bindings with full Gymnasium API compliance.

Current vs Target Architecture

Current (gym3-based)

gym_registration.py: ProcgenGymnasiumEnv (wrapper)
    ↓
env.py: BaseProcgenEnv(CEnv from gym3)
    ↓ (CFFI)
libenv.h: C interface layer
    ↓
vecgame.cpp: VecGame C++ class
    ↓
game.cpp: Individual games (coinrun, maze, etc.)

Target (pybind11-based)

procgen_gymnasium_env.py: ProcgenEnv(gymnasium.Env)
    ↓ (direct)
procgen_bindings.cpp: pybind11 wrapper
    ↓ (direct C++ binding)
vecgame.cpp: VecGame C++ class
    ↓
game.cpp: Individual games

Eliminated layers:

  • gym3.libenv.CEnv (CFFI)
  • libenv.h C interface
  • BaseProcgenEnv intermediate class

Gymnasium API Compliance

Per https://gymnasium.farama.org/introduction/migration_guide/

Key changes from gym to gymnasium:

  1. reset(): Returns (observation, info) instead of just observation
  2. step(): Returns (obs, reward, terminated, truncated, info) instead of (obs, reward, done, info)
    • terminated: Natural end (goal reached, death)
    • truncated: Time limit or external cutoff
  3. render_mode: Set at creation, not during render()
  4. seeding: Via reset(seed=...) not env.seed()

Implementation Plan

Step 1: Create pybind11 Bindings

File: procgen/src/procgen_bindings.cpp

#include <pybind11/pybind11.h>
#include <pybind11/numpy.h>
#include <pybind11/stl.h>
#include "vecgame.h"
#include "vecoptions.h"

namespace py = pybind11;

class ProcgenVecEnv {
private:
    VecGame* vec_game;
    int num_envs;

    // Numpy arrays for buffers
    py::array_t<uint8_t> obs_array;
    py::array_t<int32_t> action_array;
    py::array_t<float> reward_array;
    py::array_t<uint8_t> first_array;
    std::vector<py::array_t<uint8_t>> info_arrays_uint8;
    std::vector<py::array_t<int32_t>> info_arrays_int32;

public:
    ProcgenVecEnv(int num_envs, const std::map<std::string, py::object>& options) {
        this->num_envs = num_envs;

        // Convert Python options to C++ libenv_options
        std::vector<libenv_option> option_items;

        for (const auto& [key, value] : options) {
            libenv_option opt;
            opt.name = key.c_str();

            if (py::isinstance<py::bool_>(value)) {
                opt.dtype = LIBENV_DTYPE_UINT8;
                opt.count = 1;
                uint8_t* data = new uint8_t;
                *data = value.cast<bool>() ? 1 : 0;
                opt.data = data;
            } else if (py::isinstance<py::int_>(value)) {
                opt.dtype = LIBENV_DTYPE_INT32;
                opt.count = 1;
                int32_t* data = new int32_t;
                *data = value.cast<int32_t>();
                opt.data = data;
            } else if (py::isinstance<py::str>(value)) {
                std::string str_val = value.cast<std::string>();
                opt.dtype = LIBENV_DTYPE_UINT8;
                opt.count = str_val.size();
                char* data = new char[str_val.size()];
                std::memcpy(data, str_val.c_str(), str_val.size());
                opt.data = data;
            }

            option_items.push_back(opt);
        }

        libenv_options c_options;
        c_options.count = option_items.size();
        c_options.items = option_items.data();

        // Create VecGame
        vec_game = new VecGame(num_envs, VecOptions(c_options));

        // Cleanup temporary option data
        for (auto& opt : option_items) {
            delete[] static_cast<char*>(opt.data);
        }

        // Allocate numpy arrays
        allocate_buffers();
    }

    ~ProcgenVecEnv() {
        delete vec_game;
    }

    void allocate_buffers() {
        // Observation buffer: (num_envs, 64, 64, 3)
        obs_array = py::array_t<uint8_t>({num_envs, 64, 64, 3});

        // Action buffer: (num_envs,)
        action_array = py::array_t<int32_t>(num_envs);

        // Reward buffer: (num_envs,)
        reward_array = py::array_t<float>(num_envs);

        // First/done buffer: (num_envs,)
        first_array = py::array_t<uint8_t>(num_envs);

        // Info buffers based on info_types
        size_t info_count = vec_game->info_types.size();
        for (size_t i = 0; i < info_count; i++) {
            auto& t = vec_game->info_types[i];
            if (t.dtype == LIBENV_DTYPE_UINT8) {
                if (t.ndim == 3) {
                    // RGB render buffer
                    info_arrays_uint8.push_back(
                        py::array_t<uint8_t>({num_envs, t.shape[0], t.shape[1], t.shape[2]})
                    );
                } else {
                    info_arrays_uint8.push_back(py::array_t<uint8_t>(num_envs));
                }
            } else if (t.dtype == LIBENV_DTYPE_INT32) {
                info_arrays_int32.push_back(py::array_t<int32_t>(num_envs));
            }
        }

        // Setup C++ buffers
        setup_cpp_buffers();
    }

    void setup_cpp_buffers() {
        std::vector<std::vector<void*>> ac(num_envs);
        std::vector<std::vector<void*>> ob(num_envs);
        std::vector<std::vector<void*>> info(num_envs);

        auto obs_ptr = obs_array.mutable_data();
        auto action_ptr = action_array.mutable_data();

        for (int i = 0; i < num_envs; i++) {
            // Action pointers
            ac[i].push_back(&action_ptr[i]);

            // Observation pointers
            ob[i].push_back(&obs_ptr[i * 64 * 64 * 3]);

            // Info pointers
            info[i].resize(vec_game->info_types.size());
            size_t uint8_idx = 0, int32_idx = 0;
            for (size_t j = 0; j < vec_game->info_types.size(); j++) {
                auto& t = vec_game->info_types[j];
                if (t.dtype == LIBENV_DTYPE_UINT8) {
                    if (t.ndim == 3) {
                        // RGB buffer
                        auto ptr = info_arrays_uint8[uint8_idx].mutable_data();
                        info[i][j] = &ptr[i * t.shape[0] * t.shape[1] * t.shape[2]];
                    } else {
                        auto ptr = info_arrays_uint8[uint8_idx].mutable_data();
                        info[i][j] = &ptr[i];
                    }
                    uint8_idx++;
                } else if (t.dtype == LIBENV_DTYPE_INT32) {
                    auto ptr = info_arrays_int32[int32_idx].mutable_data();
                    info[i][j] = &ptr[i];
                    int32_idx++;
                }
            }
        }

        vec_game->set_buffers(ac, ob, info,
                             reward_array.mutable_data(),
                             first_array.mutable_data());
    }

    void observe() {
        vec_game->observe();
    }

    void act() {
        vec_game->act();
    }

    py::array_t<uint8_t> get_obs() { return obs_array; }
    py::array_t<float> get_rewards() { return reward_array; }
    py::array_t<uint8_t> get_firsts() { return first_array; }

    py::dict get_info() {
        py::dict info_dict;

        size_t uint8_idx = 0, int32_idx = 0;
        for (size_t i = 0; i < vec_game->info_types.size(); i++) {
            auto& t = vec_game->info_types[i];
            std::string name(t.name);

            if (t.dtype == LIBENV_DTYPE_UINT8) {
                info_dict[name.c_str()] = info_arrays_uint8[uint8_idx];
                uint8_idx++;
            } else if (t.dtype == LIBENV_DTYPE_INT32) {
                info_dict[name.c_str()] = info_arrays_int32[int32_idx];
                int32_idx++;
            }
        }

        return info_dict;
    }

    void set_action(py::array_t<int32_t> actions) {
        auto actions_ptr = actions.data();
        auto action_buf = action_array.mutable_data();
        std::memcpy(action_buf, actions_ptr, num_envs * sizeof(int32_t));
    }

    py::bytes get_state(int env_idx) {
        const int MAX_STATE_SIZE = 1 << 20;
        std::vector<char> buffer(MAX_STATE_SIZE);
        int n = get_state(reinterpret_cast<libenv_env*>(vec_game), env_idx,
                         buffer.data(), MAX_STATE_SIZE);
        return py::bytes(buffer.data(), n);
    }

    void set_state(int env_idx, py::bytes state) {
        std::string state_str = state;
        set_state(reinterpret_cast<libenv_env*>(vec_game), env_idx,
                 const_cast<char*>(state_str.data()), state_str.size());
    }
};

PYBIND11_MODULE(procgen_bindings, m) {
    m.doc() = "Procgen pybind11 bindings";

    py::class_<ProcgenVecEnv>(m, "ProcgenVecEnv")
        .def(py::init<int, const std::map<std::string, py::object>&>())
        .def("observe", &ProcgenVecEnv::observe)
        .def("act", &ProcgenVecEnv::act)
        .def("get_obs", &ProcgenVecEnv::get_obs)
        .def("get_rewards", &ProcgenVecEnv::get_rewards)
        .def("get_firsts", &ProcgenVecEnv::get_firsts)
        .def("get_info", &ProcgenVecEnv::get_info)
        .def("set_action", &ProcgenVecEnv::set_action)
        .def("get_state", &ProcgenVecEnv::get_state)
        .def("set_state", &ProcgenVecEnv::set_state);
}

Step 2: Create Gymnasium-Compliant Python Wrapper

File: procgen/procgen_gymnasium_env.py

import gymnasium as gym
from gymnasium import spaces
import numpy as np
from typing import Optional, Dict, Any, Tuple
from .procgen_bindings import ProcgenVecEnv


class ProcgenEnv(gym.Env):
    """
    Gymnasium-compliant Procgen environment

    Implements full Gymnasium API:
    - reset() returns (observation, info)
    - step() returns (observation, reward, terminated, truncated, info)
    - render_mode specified at creation
    - seeding via reset(seed=...)
    """

    metadata = {"render_modes": ["rgb_array", "human"], "render_fps": 15}

    def __init__(
        self,
        env_name: str,
        render_mode: Optional[str] = None,
        # Game options
        num_levels: int = 0,
        start_level: int = 0,
        distribution_mode: str = "hard",
        # Visual options
        center_agent: bool = True,
        use_backgrounds: bool = True,
        use_monochrome_assets: bool = False,
        restrict_themes: bool = False,
        use_generated_assets: bool = False,
        paint_vel_info: bool = False,
        # Difficulty modifiers
        random_percent: int = 0,
        key_penalty: int = 0,
        step_penalty: int = 0,
        rand_region: int = 0,
        # Other
        continue_after_coin: bool = False,
        num_threads: int = 0,
        **kwargs
    ):
        super().__init__()

        self.env_name = env_name
        self.render_mode = render_mode
        self._render_human = render_mode == "rgb_array"

        # Distribution mode mapping
        distribution_mode_dict = {
            "easy": 0,
            "hard": 1,
            "extreme": 2,
            "memory": 10,
        }

        # Build options dict
        options = {
            "env_name": env_name,
            "num_levels": num_levels,
            "start_level": start_level,
            "num_actions": 15,  # Procgen has 15 discrete actions
            "use_sequential_levels": False,
            "debug_mode": 0,
            "rand_seed": 0,  # Will be set in reset()
            "num_threads": num_threads,
            "render_human": self._render_human,
            "resource_root": self._get_resource_root(),
            # Game options
            "center_agent": center_agent,
            "use_generated_assets": use_generated_assets,
            "use_monochrome_assets": use_monochrome_assets,
            "restrict_themes": restrict_themes,
            "use_backgrounds": use_backgrounds,
            "paint_vel_info": paint_vel_info,
            "distribution_mode": distribution_mode_dict[distribution_mode],
            "random_percent": random_percent,
            "key_penalty": key_penalty,
            "step_penalty": step_penalty,
            "rand_region": rand_region,
            "continue_after_coin": continue_after_coin,
        }

        # Create vectorized environment with 1 env
        self.vec_env = ProcgenVecEnv(1, options)

        # Define spaces
        self.observation_space = spaces.Box(
            low=0, high=255, shape=(64, 64, 3), dtype=np.uint8
        )
        self.action_space = spaces.Discrete(15)

        # Internal state
        self._last_obs = None
        self._last_info = None

    def _get_resource_root(self) -> str:
        import os
        script_dir = os.path.dirname(os.path.abspath(__file__))
        resource_root = os.path.join(script_dir, "data", "assets") + os.sep
        assert os.path.exists(resource_root)
        return resource_root

    def reset(
        self,
        seed: Optional[int] = None,
        options: Optional[Dict[str, Any]] = None
    ) -> Tuple[np.ndarray, Dict[str, Any]]:
        """
        Reset the environment

        Returns:
            observation: (64, 64, 3) RGB array
            info: Dictionary with episode information
        """
        super().reset(seed=seed)

        if seed is not None:
            # Recreate environment with new seed
            # For now, just note it - proper implementation would update vec_env
            pass

        # Reset by taking a dummy action
        self.vec_env.set_action(np.array([0], dtype=np.int32))
        self.vec_env.act()
        self.vec_env.observe()

        # Get observation
        obs = self.vec_env.get_obs()[0]  # Extract single env
        info_dict = self.vec_env.get_info()

        # Build info dict
        info = {}
        for key, value in info_dict.items():
            if key != "rgb":  # Don't include render buffer in info
                info[key] = value[0] if hasattr(value, '__getitem__') else value

        self._last_obs = obs
        self._last_info = info

        return obs, info

    def step(
        self, action: int
    ) -> Tuple[np.ndarray, float, bool, bool, Dict[str, Any]]:
        """
        Execute one step

        Returns:
            observation: (64, 64, 3) RGB array
            reward: Float reward
            terminated: Whether episode ended naturally
            truncated: Whether episode was cut off (not used in procgen)
            info: Dictionary with episode information
        """
        # Set action and step
        self.vec_env.set_action(np.array([action], dtype=np.int32))
        self.vec_env.act()
        self.vec_env.observe()

        # Get results
        obs = self.vec_env.get_obs()[0]
        reward = float(self.vec_env.get_rewards()[0])
        first = bool(self.vec_env.get_firsts()[0])
        info_dict = self.vec_env.get_info()

        # Build info dict
        info = {}
        for key, value in info_dict.items():
            if key != "rgb":
                info[key] = value[0] if hasattr(value, '__getitem__') else value

        # In procgen, 'first' means the episode just ended
        # terminated = natural end, truncated = time limit (procgen doesn't use)
        terminated = first
        truncated = False

        self._last_obs = obs
        self._last_info = info

        return obs, reward, terminated, truncated, info

    def render(self) -> Optional[np.ndarray]:
        """
        Render the environment

        Returns:
            RGB array if render_mode="rgb_array", else None
        """
        if self.render_mode == "rgb_array":
            return self._last_obs
        elif self.render_mode == "human":
            # For human mode, gymnasium typically uses a renderer
            # For now, return the observation
            return self._last_obs
        return None

    def close(self):
        """Close the environment"""
        # pybind11 handles cleanup via destructor
        pass

    def get_state(self) -> bytes:
        """Get serialized state (procgen-specific)"""
        return self.vec_env.get_state(0)

    def set_state(self, state: bytes):
        """Set serialized state (procgen-specific)"""
        self.vec_env.set_state(0, state)

Step 3: Update Build System

Update procgen/CMakeLists.txt:

cmake_minimum_required(VERSION 3.10 FATAL_ERROR)
project(procgen)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
set(CMAKE_CXX_VISIBILITY_PRESET hidden)

option(PROCGEN_PACKAGE "Set if the python package is being built" OFF)

# Find pybind11
find_package(pybind11 CONFIG REQUIRED)

# Find Python
find_package(Python COMPONENTS Interpreter Development REQUIRED)

# Find Qt5
find_package(Qt5 COMPONENTS Gui REQUIRED)

# Main environment library
add_library(env SHARED
    src/assetgen.cpp
    src/basic-abstract-game.cpp
    src/cpp-utils.cpp
    src/entity.cpp
    src/game.cpp
    src/game-registry.cpp
    src/games/dodgeball.cpp
    src/games/bigfish.cpp
    # ... all other game files ...
    src/vecgame.cpp
    src/vecoptions.cpp
)

target_include_directories(env PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/data/libenv)
target_link_libraries(env Qt5::Gui)

# pybind11 module
pybind11_add_module(procgen_bindings src/procgen_bindings.cpp)
target_link_libraries(procgen_bindings PRIVATE env Qt5::Gui)
target_include_directories(procgen_bindings PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)

Update setup.py:

from setuptools import setup, find_packages
import subprocess
import os
import sys

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PACKAGE_ROOT = os.path.join(SCRIPT_DIR, "procgen")

def build_extension():
    """Build the C++ extension using CMake"""
    build_dir = os.path.join(PACKAGE_ROOT, ".build")
    os.makedirs(build_dir, exist_ok=True)

    # Configure
    subprocess.check_call([
        "cmake",
        "-DCMAKE_BUILD_TYPE=Release",
        "-DPROCGEN_PACKAGE=ON",
        "../.."
    ], cwd=build_dir)

    # Build
    subprocess.check_call([
        "cmake",
        "--build",
        ".",
        "--config", "Release"
    ], cwd=build_dir)

    return build_dir

class BuildExtension(build_ext):
    def run(self):
        if not self.inplace:
            build_dir = build_extension()
            # Copy built extension to package
            # pybind11 module will be in build_dir

setup(
    name="procgen",
    packages=find_packages(),
    version=version,
    install_requires=[
        "numpy>=1.17.0,<2.0.0",
        "gymnasium>=0.29.0",
        "pybind11>=2.11.0",
    ],
    python_requires=">=3.7",
    # ... rest of setup
)

Step 4: Update Environment Registration

Update procgen/gym_registration.py:

import gymnasium as gym
from .env import ENV_NAMES
from .procgen_gymnasium_env import ProcgenEnv


def register_environments():
    """Register all Procgen environments with Gymnasium"""
    for env_name in ENV_NAMES:
        gym.register(
            id=f'procgen-{env_name}-v0',
            entry_point='procgen.procgen_gymnasium_env:ProcgenEnv',
            kwargs={"env_name": env_name},
        )


# Auto-register on import
register_environments()

Step 5: Update Dependencies

Update environment.yml:

channels:
  - conda-forge

dependencies:
  - python>=3.7,<3.10
  - c-compiler=1.0.4
  - cmake=3.14.0
  - qt=5.12.5
  - pip
  - pip:
    - numpy>=1.17.0,<2.0.0
    - gymnasium>=0.29.0
    - pybind11>=2.11.0
    - filelock>=3.0.0,<4.0.0

Remove from setup.py install_requires:

  • gym3>=0.3.3,<1.0.0

Add:

  • pybind11>=2.11.0

Step 6: Deprecate Old Code (Keep for Compatibility)

Update procgen/env.py:

# Legacy gym3-based implementation
# DEPRECATED: Use procgen_gymnasium_env.ProcgenEnv instead

import warnings

warnings.warn(
    "The gym3-based ProcgenGym3Env is deprecated. "
    "Use ProcgenEnv from procgen_gymnasium_env instead.",
    DeprecationWarning,
    stacklevel=2
)

# Keep old code for backward compatibility if needed
# But mark as deprecated

Migration Checklist

  • Install pybind11: pip install "pybind11>=2.11.0"
  • Create procgen/src/procgen_bindings.cpp
  • Create procgen/procgen_gymnasium_env.py
  • Update procgen/CMakeLists.txt to build pybind11 module
  • Update setup.py to use pybind11
  • Update environment.yml to remove gym3, add pybind11
  • Update procgen/gym_registration.py to use new env
  • Test basic functionality:
    import gymnasium as gym
    env = gym.make('procgen-coinrun-v0')
    obs, info = env.reset(seed=42)
    obs, reward, terminated, truncated, info = env.step(0)
  • Test state save/load
  • Test all game variants
  • Update documentation
  • Remove gym3 from all imports

Key Benefits

  1. Removes unmaintained gym3 dependency (last updated 2020)
  2. Full Gymnasium API compliance
    • Proper reset() returning (obs, info)
    • Proper step() returning 5 values with terminated/truncated
    • Modern seeding via reset(seed=...)
  3. Eliminates intermediate layers
    • No CFFI overhead
    • No C interface layer (libenv.h)
    • Direct C++ to Python via pybind11
  4. Better performance (pybind11 is faster than CFFI)
  5. Modern tooling (pybind11 actively maintained, used by MuJoCo)
  6. Cleaner architecture (follows Gymnasium best practices)

Testing Strategy

  1. Unit Tests

    def test_reset_returns_tuple():
        env = gym.make('procgen-coinrun-v0')
        result = env.reset()
        assert len(result) == 2
        obs, info = result
        assert obs.shape == (64, 64, 3)
        assert isinstance(info, dict)
    
    def test_step_returns_five_values():
        env = gym.make('procgen-coinrun-v0')
        env.reset()
        result = env.step(0)
        assert len(result) == 5
        obs, reward, terminated, truncated, info = result
  2. Integration Tests

    • Test all 25+ game variants
    • Test state save/load
    • Test vectorized environments
    • Test rendering modes
  3. Performance Tests

    • Compare FPS with gym3 version
    • Memory usage
    • Load time

Estimated Effort

  • pybind11 bindings: 3-4 hours
  • Gymnasium wrapper: 2-3 hours
  • Build system updates: 2-3 hours
  • Testing & debugging: 4-6 hours
  • Documentation: 1-2 hours

Total: 12-18 hours

Next Steps

  1. Install pybind11
  2. Create bindings file
  3. Create Gymnasium wrapper
  4. Update build system
  5. Test incrementally
  6. Remove gym3 completely