ABS (Agile But Safe)

repository·main·Indexed 20 days ago

https://github.com/lecar-lab/abs

An implementation of collision-free, high-speed legged locomotion using reinforcement learning. ABS integrates agile and recovery policies with a Reach-Avoid (RA) network for safety. It includes support for the Unitree Go1 robot, utilizing the unitree_legged_sdk for communication and NVIDIA Isaac Gym for simulation and training.

Tokens
58.3K
Snippets
177
Records
228
Agent score
69%

What's inside ABS

  1. Overview of pybind11

    main
    pybind11 is a lightweight, header-only C++11 library designed to expose C++ types in Python and vice versa. It is primarily used to create Python bindings for existing C++ code with minimal boilerplate by using compile-time introspection to infer type information. Unlike Boost.Python, it is self-contained and does not require linking against large external libraries, as it relies on C++11 features like tuples, lambda functions, and variadic templates.
  2. How to use opaque types to enable pass-by-reference for STL containers

    main

    Because automatic STL conversion uses copying, passing a std::vector<int>& to a function from Python will not allow the Python side to see modifications made by C++. To fix this, you can make the type opaque.

    An opaque type disables the template-based conversion machinery, meaning the contents are never inspected or extracted, allowing them to be passed by reference.

    Steps to implement opaque types:

    1. Use the PYBIND11_MAKE_OPAQUE(T) macro at the top level (outside any namespace) before any binding code. This must be present in every compilation unit using the type.
    2. Provide a corresponding py::class_<T> declaration to define the name and available operations (like append, pop_back, etc.) in Python.
    // 1. Declare as opaque at the top level
    PYBIND11_MAKE_OPAQUE(std::vector<int>);
    
    // 2. Bind the class to define Python interface
    py::class_<std::vector<int>>(m, "IntVector")
        .def(py::init<>())
        .def("pop_back", &std::vector<int>::pop_back)
        .def("__len__", [](const std::vector<int> &v) { return v.size(); });
  3. Understand C++11 clock types and their Python mappings

    main

    When using chrono conversions, the resulting Python type depends on which C++ clock is used. There are three standard clock types:

    1. std::chrono::system_clock: Measures current date and time. It is subject to OS updates (e.g., NTP synchronization).
      • Python Mapping: Converts to datetime.datetime (naive, local timezone).
    2. std::chrono::steady_clock: Ticks at a constant rate and is never adjusted. Ideal for measuring intervals/timing, but does not represent wall-clock time.
      • Python Mapping: Converts to datetime.timedelta (representing time since the clock's epoch).
    3. std::chrono::high_resolution_clock: The clock with the highest resolution available on the system.
      • Python Mapping: Behavior varies by system. If it is a typedef of system_clock, it returns datetime.datetime. If it is a different clock, it returns datetime.timedelta.
  4. Pass Eigen objects by reference using Eigen::Ref

    main

    To avoid expensive copies and allow functions to modify NumPy arrays in-place, use Eigen::Ref<MatrixType> (for mutable access) or Eigen::Ref<const MatrixType> (for read-only access) in your C++ function signatures.

    Requirements for zero-copy mapping:

    1. Type Compatibility: The NumPy dtype must match the Eigen Scalar type (e.g., float64 for double).
    2. Layout Compatibility: The storage must be compatible (see Storage Orders).
    3. Writeability: For non-const Eigen::Ref<MatrixType>, the NumPy array must have a.flags.writeable == True.

    If these requirements are not met, pybind11 will fall back to making a temporary copy.

    Note: Passing by reference is not supported for sparse types; they are always copied.

    // This function modifies the input NumPy array directly
    void scale_by_2(Eigen::Ref<Eigen::VectorXd> v) {
        v *= 2;
    }
  5. Avoid double-free errors with shared pointers

    main

    When using std::shared_ptr as a holder type, avoid returning raw pointers from functions that return objects managed by smart pointers. Returning a raw pointer causes pybind11 to create a new, independent std::shared_ptr that claims ownership, leading to a double-free (segmentation fault) when both pointers attempt to deallocate the object.

    To resolve this, use one of two methods:

    1. Consistent Wrapping: Always return the smart pointer type itself instead of a raw pointer.
    2. Enable Shared From This: Inherit the managed class from std::enable_shared_from_this<T>. This allows pybind11 to recognize and communicate with the existing std::shared_ptr.
    // Method 1: Return the shared_ptr directly
    std::shared_ptr<Child> get_child() { return child; }
    
    // Method 2: Use enable_shared_from_this
    class Child : public std::enable_shared_from_this<Child> { };
  6. Performance considerations for C++ and Python function roundtrips

    main

    Passing functions between C++ and Python involves instantiating wrapper code to translate invocations between the two languages. This introduces computational overhead.

    Performance Risks:

    • Frequent roundtrips (e.g., C++ $\rightarrow$ Python $\rightarrow$ C++ $\rightarrow$ Python) in a tight loop can significantly decrease performance due to accumulated wrapper overhead.

    Optimization:

    • pybind11 can optimize stateless functions (function pointers or lambdas without captured variables). If a stateless function is passed from Python to a C++ function exposed in Python, pybind11 extracts the underlying C++ function pointer to avoid the C++ $\rightarrow$ Python $\rightarrow$ C++ roundtrip.
  7. Handle immutable type limitations with reference arguments

    main

    In C++, functions often use mutable references (int &i) or pointers (int *i) to modify arguments. While Python passes arguments by reference, basic Python types like str, int, bool, and float are immutable.

    Binding a C++ function that modifies an immutable type will result in a Python function that appears to do nothing because the modification doesn't affect the original Python object.

    Workarounds:

    1. Encapsulation: Wrap the immutable type in a custom C++ class that allows modifications.
    2. Lambda Wrapper: Bind a small lambda function that calls the C++ function and returns the modified values as a tuple.
    // C++ function using a reference
    int foo(int &i) { i++; return 123; }
    
    // Binding using a lambda to return a tuple of (return_value, modified_arg)
    m.def("foo", [](int i) { 
        int rv = foo(i); 
        return std::make_tuple(rv, i); 
    });
  8. Manage Python interpreter lifetime

    main

    The Python interpreter's lifecycle is primarily managed by py::scoped_interpreter.

    • RAII approach: Use py::scoped_interpreter guard{};. The interpreter starts when the guard is created and shuts down when the guard is destroyed. Creating a second concurrent scoped_interpreter guard is a fatal error.
    • Manual approach: Use py::initialize_interpreter() and py::finalize_interpreter() to set the state directly.

    Warning: Do not use raw CPython API functions Py_Initialize and Py_Finalize, as they do not correctly handle pybind11's internal data. Also, be aware that while pybind11 modules can be re-initialized after a restart, third-party extension modules might not fully unload, potentially leading to memory leaks.

  9. Manage the Global Interpreter Lock (GIL) in C++

    main

    When calling C++ functions from Python, the GIL is held by default. To allow long-running C++ code to run in parallel using multiple Python threads, you must explicitly release the GIL. Conversely, when a C++ function (like a trampoline for a virtual function) needs to call back into Python code, it must acquire the GIL.

    Use py::gil_scoped_release to release the lock and py::gil_scoped_acquire to acquire it. Alternatively, for simple function bindings, you can use the py::call_guard<py::gil_scoped_release>() policy.

    // Manual management in a wrapper
    m.def("call_go", [](Animal *animal) -> std::string {
        py::gil_scoped_release release;
        return call_go(animal);
    });
    
    // Using call_guard policy (preferred for simple bindings)
    m.def("call_go", &call_go, py::call_guard<py::gil_scoped_release>());
    
    // Acquiring GIL in a trampoline function
    std::string go(int n_times) {
        py::gil_scoped_acquire acquire;
        PYBIND11_OVERRIDE_PURE(std::string, Animal, go, n_times);
    }
  10. Pass Eigen dense objects by value

    main

    When a bound function accepts ordinary Eigen dense objects (e.g., Eigen::MatrixXd), pybind11 accepts any numpy.ndarray with compatible dimensions.

    Behavior:

    • pybind11 copies the values from the NumPy array into a temporary Eigen variable.
    • For sparse matrices, it copies to/from scipy.sparse.csr_matrix or scipy.sparse.csc_matrix.
    • Limitation: This involves an implicit copy, which is expensive for large matrices and prevents the function from modifying the original NumPy array.