Description
Issue №1677 opened by MyreMylar at 2020-04-27 11:28:04
Context:
I am experimenting with generalised improvements to the speed and future proofing of loading resources in the Pygame GUI package.
From a fair bit of reading over the slightly muddled state of parallel programming in python, and after profiling a few other options - I determined that using a few python Threads from threading import Thread
was likely a reasonable way to get some speed up when loading a long-ish list of files (five threads seems to be the optimum number on my dev machine).
My initial explorations were mainly done using pygame.Surfaces where, when loading about 20 of them, I saw roughly a 3x speed up when using 5 Threads over simple sequential loading - without any obvious (to me) negative side effects.
Problem:
While this multi-threaded loading approach works fine for pygame.Surfaces, it doesn't appear to work for pygame.font.Font objects, the other main resource used in Pygame GUI projects.
Desired outcome
pygame.font.Font objects are loadable on Threads other than the main thread - or somebody has an idea how I could achieve the same broad goal under the current system.
Example code
import pygame
from typing import Union, Any, Dict
from collections import namedtuple
from threading import Thread, Lock
from importlib.resources import open_binary
from queue import Queue
from fps_counter import FPSCounter
PackageResource = namedtuple('PackageResource', ('package', 'resource'))
class FontResource:
def __init__(self, font_id: str, size: int, style: Dict[str, bool],
location: Union[PackageResource, str]):
self.font_id = font_id
self.size = size
self.style = style
self.location = location
self.loaded_resource = None
class ImageResource:
def __init__(self, image_id: str, location: Union[PackageResource, str]):
self.image_id = image_id
self.location = location
self.loaded_resource = None
def complete_image_load(image_resource: ImageResource) -> ImageResource:
with open_binary(image_resource.location.package,
image_resource.location.resource) as open_resource:
image_resource.loaded_resource = pygame.image.load(open_resource)
return image_resource
def complete_font_load(font_resource: FontResource) -> FontResource:
with open_binary(font_resource.location.package,
font_resource.location.resource) as open_resource:
font_resource.loaded_resource = pygame.font.Font(open_resource, font_resource.size)
font_resource.loaded_resource.set_bold(font_resource.style['bold'])
font_resource.loaded_resource.set_italic(font_resource.style['italic'])
return font_resource
def complete_load(resource_loc: Union[FontResource, ImageResource]) -> Any:
if isinstance(resource_loc, FontResource):
return complete_font_load(resource_loc)
elif isinstance(resource_loc, ImageResource):
return complete_image_load(resource_loc)
class ClosableQueue(Queue):
SENTINEL = object()
def close(self):
self.put(self.SENTINEL)
def __iter__(self):
while True:
item = self.get()
try:
if item is self.SENTINEL:
return # Cause the thread to exit
yield item
finally:
self.task_done()
class StoppableOutputWorker(Thread):
def __init__(self, func, in_queue, out_list):
super().__init__()
self.func = func
self.in_queue = in_queue
self.out_list = out_list
self.lock = Lock()
def run(self):
for item in self.in_queue:
result = self.func(item)
with self.lock:
self.out_list.append(result)
def start_output_threads(count, *args):
threads = [StoppableOutputWorker(*args) for _ in range(count)]
for thread in threads:
thread.start()
return threads
def stop_threads(closable_queue, threads):
for _ in threads:
closable_queue.close()
closable_queue.join()
for thread in threads:
thread.join()
class Camera:
def __init__(self):
self.dimensions = (800, 600)
def draw_images(window_surface, loaded_resources):
x_pos = 0
y_pos = 0
for loaded_resource in loaded_resources:
if isinstance(loaded_resource, ImageResource):
window_surface.blit(loaded_resource.loaded_resource, (x_pos, y_pos))
x_pos += 160
if x_pos >= 800:
x_pos = 0
y_pos += 120
# Uncomment below to see error
# elif isinstance(loaded_resource.loaded_resource, pygame.font.Font):
# text_surf = loaded_resource.loaded_resource.render('Hello world',
# False, pygame.Color('# FFFFFFFF'))
# window_surface.blit(text_surf, (x_pos, y_pos))
# x_pos += 160
# if x_pos >= 800:
# x_pos = 0
# y_pos += 120
if __name__ == '__main__':
pygame.init()
screen = pygame.display.set_mode((800, 600))
background = pygame.Surface((800, 600))
background.fill(pygame.Color("# 000000"))
camera = Camera()
clock = pygame.time.Clock()
load_timer = pygame.time.Clock()
time_1 = load_timer.tick()
font_list = [FontResource(font_id='agency_bold_14',
size=14,
style={'bold': True, 'italic': False},
location=PackageResource('fonts', 'AGENCYR.TTF')),
FontResource(font_id='verdana_regular_12',
size=12,
style={'bold': False, 'italic': False},
location=PackageResource('fonts', 'verdana.ttf')),
FontResource(font_id='verdana_regular_16',
size=16,
style={'bold': False, 'italic': False},
location=PackageResource('fonts', 'verdana.ttf')),
ImageResource(image_id='space_1',
location=PackageResource('images', 'space_1.jpg')),
ImageResource(image_id='space_2',
location=PackageResource('images', 'space_2.jpg')),
ImageResource(image_id='space_3',
location=PackageResource('images', 'space_3.jpg')),
ImageResource(image_id='space_4',
location=PackageResource('images', 'space_4.jpg')),
ImageResource(image_id='space_5',
location=PackageResource('images', 'space_5.jpg')),
ImageResource(image_id='space_6',
location=PackageResource('images', 'space_6.jpg')),
ImageResource(image_id='space_7',
location=PackageResource('images', 'space_7.jpg')),
ImageResource(image_id='space_8',
location=PackageResource('images', 'space_8.jpg')),
ImageResource(image_id='space_9',
location=PackageResource('images', 'space_9.jpg')),
ImageResource(image_id='space_10',
location=PackageResource('images', 'space_10.jpg'))
]
to_load_queue = ClosableQueue()
done_loading_list = []
for resource in font_list:
to_load_queue.put(resource)
resources_length = to_load_queue.qsize()
loading_threads = start_output_threads(5, complete_load, to_load_queue, done_loading_list)
loading_finished = False
running = True
while running:
time_delta = clock.tick(60) / 1000.0
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if not loading_finished and len(done_loading_list) == resources_length:
stop_threads(to_load_queue, loading_threads)
time_2 = load_timer.tick()
print("total time:" + str((time_2 - time_1) / 1000.0) + ' seconds')
print(len(done_loading_list), 'items finished')
loading_finished = True
screen.blit(background, (0, 0))
draw_images(screen, done_loading_list)
pygame.display.flip()
Using example images downloaded from here:
https://spacetelescope.org/images/archive/top100/
Possibly confusing things:
- I'm using importlib.resources for this example because apparently it is the wave of the future. Making use of it requires that your resource directories have a dunder init.py file inside of them.
- importlib.resources requires python 3.7+, though there is a backport you could also try. Or you could switch the load functions to use old style file paths.
- There is no error unless you uncomment the block of code which tries to use the loaded font objects - then you get
pygame.error: Text has zero width
as if the font module had been shut down (which I'm assuming is what is happening?).
Platform: Windows
Versions tested: 1.9.6, 2.0.0.dev6
Related Docs: https://www.pygame.org/docs/ref/font.html# pygame.font.Font
To Do to close:
- Produce simple test case showing error.
- Investigate why image loading and font loading appear to behave differently.
- Fix in c code if possible.
Comments
# # MyreMylar commented at 2020-04-28 15:59:20
I've found a solution to this a day later. Apparently if you switch the font loading from:
def complete_font_load(font_resource: FontResource) -> FontResource:
with open_binary(font_resource.location.package,
font_resource.location.resource) as open_resource:
font_resource.loaded_resource = pygame.font.Font(open_resource, font_resource.size)
font_resource.loaded_resource.set_bold(font_resource.style['bold'])
font_resource.loaded_resource.set_italic(font_resource.style['italic'])
return font_resource
Over to:
def complete_font_load(font_resource: FontResource) -> FontResource:
font_resource.loaded_resource = pygame.font.Font(io.BytesIO(read_binary(font_resource.location.package,
font_resource.location.resource)),
font_resource.size)
font_resource.loaded_resource.set_bold(font_resource.style['bold'])
font_resource.loaded_resource.set_italic(font_resource.style['italic'])
return font_resource
It seems to work fine. This is using the read_binary()
function from importlib.resources
.
I'm thinking the issue is nothing to do with multi-threading and it's actually that the font loading is unable to handle a file object/stream of type:
typing.BinaryIO
versus the normal:
io.BytesIO
while the pygame.image.load()
image loader is able to handle both types.
Not sure if this is that important - the silent handling of it only to error later is a bit weird though. Will update the title.
# # MyreMylar commented at 2022-04-07 12:10:43
This still happens in 2.1.3.