Kautenja / nes-py

A Python3 NES emulator and OpenAI Gym interface
MIT License
235 stars 63 forks source link

Pyglet fails to render from multiple threads #50

Closed EliasHasle closed 4 years ago

EliasHasle commented 5 years ago

Describe the bug

Calling render from a new thread after first calling it from another results in an error (see console output below). This may well be a limitation of Pyglet, but I don't know. Note that running the environments without rendering to screen works as expected, but for some purposes (debugging of graphical glitches etc.) it can be very useful to monitor multiple environments at the same time, even if they are in different threads.

If Pyglet is the problem, maybe one of the alternatives works better?

To Reproduce

import threading
import gym_super_mario_bros
from gym_super_mario_bros.actions import COMPLEX_MOVEMENT
from nes_py.wrappers import JoypadSpace

RENDER = True
THREADS = 2

def testEnv(thread_index):
    env = gym_super_mario_bros.make('SuperMarioBros-v0')
    env = JoypadSpace(env, COMPLEX_MOVEMENT)
    done = True
    for i in range(5000):
        if done:
            env.reset()
        obs,rew,done,info = env.step(env.action_space.sample())
        if RENDER:
            env.render()
    return True

threads = [None]*THREADS
for i in range(THREADS):
    t = threading.Thread(target=testEnv,args=(i,))
    threads[i] = t
    t.start()
for t in threads:
    t.join()

Expected behavior

Rendering in independent windows.

Environment

Additional context

Exception in thread Thread-1: Traceback (most recent call last): File "C:\Users\elias\AppData\Local\Programs\Python\Python36\lib\threading.py", line 916, in _bootstrap_inner self.run() File "C:\Users\elias\AppData\Local\Programs\Python\Python36\lib\threading.py", line 864, in run self._target(*self._args, self._kwargs) File "c:\users\elias\dropbox (personal)\phd\projects\smb\smb_sample_frames.py", line 120, in env_worker env.render() File "C:\Users\elias\AppData\Local\Programs\Python\Python36\lib\site-packages\gym\core.py", line 275, in render return self.env.render(mode, kwargs) File "C:\Users\elias\AppData\Local\Programs\Python\Python36\lib\site-packages\gym\core.py", line 275, in render return self.env.render(mode, kwargs) File "C:\Users\elias\AppData\Local\Programs\Python\Python36\lib\site-packages\nes_py\nes_env.py", line 335, in render self.viewer.show(self.screen) File "C:\Users\elias\AppData\Local\Programs\Python\Python36\lib\site-packages\nes_py_image_viewer.py", line 65, in show self.open() File "C:\Users\elias\AppData\Local\Programs\Python\Python36\lib\site-packages\nes_py_image_viewer.py", line 47, in open resizable=True, File "C:\Users\elias\AppData\Local\Programs\Python\Python36\lib\site-packages\pyglet\window\win32__init.py", line 134, in init super(Win32Window, self).init(*args, **kwargs) File "C:\Users\elias\AppData\Local\Programs\Python\Python36\lib\site-packages\pyglet\window\init.py", line 512, in init config = screen.get_best_config(template_config) File "C:\Users\elias\AppData\Local\Programs\Python\Python36\lib\site-packages\pyglet\canvas\base.py", line 159, in get_best_config configs = self.get_matching_configs(template) File "C:\Users\elias\AppData\Local\Programs\Python\Python36\lib\site-packages\pyglet\canvas\win32.py", line 34, in get_matching_configs configs = template.match(canvas) File "C:\Users\elias\AppData\Local\Programs\Python\Python36\lib\site-packages\pyglet\gl\win32.py", line 27, in match return self._get_arb_pixel_format_matching_configs(canvas) File "C:\Users\elias\AppData\Local\Programs\Python\Python36\lib\site-packages\pyglet\gl\win32.py", line 100, in _get_arb_pixel_format_matching_configs nformats, pformats, nformats) File "C:\Users\elias\AppData\Local\Programs\Python\Python36\lib\site-packages\pyglet\gl\lib_wgl.py", line 106, in call__ return self.func(*args, kwargs) File "C:\Users\elias\AppData\Local\Programs\Python\Python36\lib\site-packages\pyglet\gl\lib.py", line 63, in MissingFunction raise MissingFunctionException(name, requires, suggestions) pyglet.gl.lib.MissingFunctionException: wglChoosePixelFormatARB is not exported by the available OpenGL driver. ARB_pixel_format is required for this functionality.

Kautenja commented 5 years ago

hmm I get a slightly different error on MacOS, but irrespective it looks like pyglet is not thread safe. In the interest of supporting render for all use cases, it's probably best to replace pyglet here with something else.

Kautenja commented 5 years ago

OpenCV is the obvious choice as it's already in the dependency tree for the resizing wrapper. I found some basic code here for working with video streams that can probably be adapted pretty easily: https://solarianprogrammer.com/2018/04/21/python-opencv-show-video-tkinter-window/

EliasHasle commented 5 years ago

I haven't consulted the source, but are you sure the problem is not with sharing some pyglet stuff between environments that would better be kept individually for each? I suspect, after some googling, that pyglet, and OpenGL in general, needs a separate rendering context for each thread, and one may have to explicitly switch context before rendering (which may be an expensive operation). Hm. Maybe it would hurt single-threaded multi-window performance if more "individual property" were introduced. On the other hand, maybe some GL window libraries sort this out as needed, depending on threads etc.

I shall also try an experiment with multiple windows in a single thread, to be able to draw conclusions on whether threading is the relevant issue here. At least we know that multiprocessing works as intended.

Kautenja commented 5 years ago

looks like the opencv solution isn't viable. It looks bad in single threaded mode and hangs indefinitely running the script you provided. I can't seem to find a better render solution at the moment. fortunately this only seems to affect python threads.

cosw0t commented 4 years ago

Hi @EliasHasle , have you managed to resolve this?

Kautenja commented 4 years ago

@michele-arrival to the best of my knowledge, the issue still exists. I've updated the bug script to show the issue with multiprocessing as well:

import threading
import multiprocessing
import gym_super_mario_bros
from gym_super_mario_bros.actions import COMPLEX_MOVEMENT
from nes_py.wrappers import JoypadSpace

RENDER = True
MULTIPROCESS = False
THREADS = 2

def testEnv():
    env = gym_super_mario_bros.make('SuperMarioBros-v0')
    env = JoypadSpace(env, COMPLEX_MOVEMENT)
    done = True
    for _ in range(5000):
        if done:
            env.reset()
        _, _, done, _ = env.step(env.action_space.sample())
        if RENDER:
            env.render()
    return True

threads = [None] * THREADS
for i in range(THREADS):
    if MULTIPROCESS:
        threads[i] = multiprocessing.Process(target=testEnv)
    else:
        threads[i] = threading.Thread(target=testEnv)
    threads[i].start()
for t in threads:
    t.join()
Kautenja commented 4 years ago

A related unresolved issue on Stack Overflow.

Kautenja commented 4 years ago

Multiprocessing will work, but nes-py has to be imported within the process that executes the OpenGL context:

import threading
import multiprocessing

RENDER = True
MULTIPROCESS = True
THREADS = 2

def testEnv():
    import gym_super_mario_bros
    from gym_super_mario_bros.actions import COMPLEX_MOVEMENT
    from nes_py.wrappers import JoypadSpace
    env = gym_super_mario_bros.make('SuperMarioBros-v0')
    env = JoypadSpace(env, COMPLEX_MOVEMENT)
    done = True
    for _ in range(5000):
        if done:
            env.reset()
        _, _, done, _ = env.step(env.action_space.sample())
        if RENDER:
            env.render()
    return True

threads = [None] * THREADS
for i in range(THREADS):
    if MULTIPROCESS:
        threads[i] = multiprocessing.Process(target=testEnv)
    else:
        threads[i] = threading.Thread(target=testEnv)
    threads[i].start()
for t in threads:
    t.join()

Because of how OpenGL works, python threads will not work without some form of special support that would needlessly complicate the render logic in nes-py. In most cases, multiprocessing is a better option for concurrency because it provides true process level concurrency, opposed to python-level threads. I've added some logic to detect rendering from python threads and fail gracefully with a RuntimeError.

cosw0t commented 4 years ago

Thanks for the update!

venetsia commented 3 years ago

So in other words what would be the fix for this?