From 8ffab35afed159132fc7ca93c35f8cf13af388d1 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Sun, 2 Apr 2023 23:38:42 -0400 Subject: [PATCH 01/11] proper garbage collection of WorldObject, implemented and works for ImageGraphic --- fastplotlib/graphics/_base.py | 25 ++++++++++++++------- fastplotlib/graphics/image.py | 4 +++- fastplotlib/layouts/_base.py | 41 ++++++++++++++++++++++++++++++++--- 3 files changed, 58 insertions(+), 12 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 255a2fec7..ea293dfdf 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -1,4 +1,5 @@ from typing import * +import weakref from warnings import warn import numpy as np @@ -14,6 +15,11 @@ from dataclasses import dataclass +# dict that holds all world objects for a given python kernel/session +# Graphic objects only use proxies to WorldObjects +WORLD_OBJECTS: Dict[str, WorldObject] = dict() #: {hex id str: WorldObject} + + PYGFX_EVENTS = [ "key_down", "key_up", @@ -58,10 +64,15 @@ def __init__( self.registered_callbacks = dict() self.present = PresentFeature(parent=self) + self._world_objects = WORLD_OBJECTS + @property def world_object(self) -> WorldObject: - """Associated pygfx WorldObject.""" - return self._world_object + """Associated pygfx WorldObject. Always returns a proxy, real object cannot be accessed directly.""" + return weakref.proxy(self._world_objects[hex(id(self))]) + + def _set_world_object(self, wo: WorldObject): + self._world_objects[hex(id(self))] = wo @property def position(self) -> Vector3: @@ -75,7 +86,7 @@ def visible(self) -> bool: return self.world_object.visible @visible.setter - def visible(self, v) -> bool: + def visible(self, v: bool): """Access or change the visibility.""" self.world_object.visible = v @@ -100,6 +111,9 @@ def __repr__(self): else: return rval + def __del__(self): + del self._world_objects[hex(id(self))] + class Interaction(ABC): """Mixin class that makes graphics interactive""" @@ -271,11 +285,6 @@ def __init__(self, name: str = None): super(GraphicCollection, self).__init__(name) self._graphics: List[Graphic] = list() - @property - def world_object(self) -> Group: - """Returns the underling pygfx WorldObject.""" - return self._world_object - @property def graphics(self) -> Tuple[Graphic]: """returns the Graphics within this collection""" diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 83cae3de8..91da9687d 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -100,11 +100,13 @@ def __init__( self.cmap = ImageCmapFeature(self, cmap) material = pygfx.ImageBasicMaterial(clim=(vmin, vmax), map=self.cmap()) - self._world_object: pygfx.Image = pygfx.Image( + world_object = pygfx.Image( geometry, material ) + self._set_world_object(world_object) + self.data = ImageDataFeature(self, data) # TODO: we need to organize and do this better if isolated_buffer: diff --git a/fastplotlib/layouts/_base.py b/fastplotlib/layouts/_base.py index ce35135c7..c064a90eb 100644 --- a/fastplotlib/layouts/_base.py +++ b/fastplotlib/layouts/_base.py @@ -3,7 +3,7 @@ Viewport, WgpuRenderer from wgpu.gui.auto import WgpuCanvas from warnings import warn -from ..graphics._base import Graphic +from ..graphics._base import Graphic, WORLD_OBJECTS from ..graphics.line_slider import LineSlider from typing import * @@ -287,8 +287,9 @@ def auto_scale(self, maintain_aspect: bool = False, zoom: float = 0.8): def remove_graphic(self, graphic: Graphic): """ - Remove a graphic from the scene. Note: This does not garbage collect the graphic, - you can add it back to the scene after removing it. + Remove a ``Graphic`` from the scene. Note: This does not garbage collect the graphic, + you can add it back to the scene after removing it. Use ``delete_graphic()`` to + delete and garbage collect a ``Graphic``. Parameters ---------- @@ -296,8 +297,42 @@ def remove_graphic(self, graphic: Graphic): The graphic to remove from the scene """ + self.scene.remove(graphic.world_object) + def delete_graphic(self, graphic: Graphic): + """ + Delete the graphic, garbage collects and frees GPU VRAM. + + Parameters + ---------- + graphic: Graphic or GraphicCollection + The graphic to delete + + """ + + if graphic not in self._graphics: + raise KeyError + + if graphic.world_object in self.scene.children: + self.scene.remove(graphic.world_object) + + self._graphics.remove(graphic) + + # delete associated world object to free GPU VRAM + loc = hex(id(graphic)) + del WORLD_OBJECTS[loc] + + del graphic + + def clear(self): + """ + Clear the Plot or Subplot. Also performs garbage collection, i.e. runs ``delete_graphic`` on all graphics. + """ + + for g in self._graphics: + self.delete_graphic(g) + def __getitem__(self, name: str): for graphic in self._graphics: if graphic.name == name: From 7e087db3ed6cd71eecc3b9943d4ee8c5474844c2 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 3 Apr 2023 01:21:10 -0400 Subject: [PATCH 02/11] gc works for Line, LineCollection, Scatter not tested, regular RAM is not gc, WIP --- fastplotlib/graphics/line.py | 4 +++- fastplotlib/graphics/line_collection.py | 2 +- fastplotlib/graphics/scatter.py | 4 +++- fastplotlib/layouts/_base.py | 14 ++++++++++++-- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index 926f5729c..0b1e579bc 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -85,12 +85,14 @@ def __init__( self.thickness = ThicknessFeature(self, thickness) - self._world_object: pygfx.Line = pygfx.Line( + world_object: pygfx.Line = pygfx.Line( # self.data.feature_data because data is a Buffer geometry=pygfx.Geometry(positions=self.data(), colors=self.colors()), material=material(thickness=self.thickness(), vertex_colors=True) ) + self._set_world_object(world_object) + if z_position is not None: self.world_object.position.z = z_position diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index 07fc9cad7..ac27f5b4d 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -157,7 +157,7 @@ def __init__( "or must be a str of tuple/list with the same length as the data" ) - self._world_object = pygfx.Group() + self._set_world_object(pygfx.Group()) for i, d in enumerate(data): if isinstance(z_position, list): diff --git a/fastplotlib/graphics/scatter.py b/fastplotlib/graphics/scatter.py index 016d1cac9..b53985de0 100644 --- a/fastplotlib/graphics/scatter.py +++ b/fastplotlib/graphics/scatter.py @@ -72,9 +72,11 @@ def __init__( super(ScatterGraphic, self).__init__(*args, **kwargs) - self._world_object: pygfx.Points = pygfx.Points( + world_object = pygfx.Points( pygfx.Geometry(positions=self.data(), sizes=sizes, colors=self.colors()), material=pygfx.PointsMaterial(vertex_colors=True, vertex_sizes=True) ) + self._set_world_object(world_object) + self.world_object.position.z = z_position diff --git a/fastplotlib/layouts/_base.py b/fastplotlib/layouts/_base.py index c064a90eb..5ed9b19f3 100644 --- a/fastplotlib/layouts/_base.py +++ b/fastplotlib/layouts/_base.py @@ -3,7 +3,7 @@ Viewport, WgpuRenderer from wgpu.gui.auto import WgpuCanvas from warnings import warn -from ..graphics._base import Graphic, WORLD_OBJECTS +from ..graphics._base import Graphic, GraphicCollection, WORLD_OBJECTS from ..graphics.line_slider import LineSlider from typing import * @@ -319,8 +319,18 @@ def delete_graphic(self, graphic: Graphic): self._graphics.remove(graphic) - # delete associated world object to free GPU VRAM + # for GraphicCollection objects + if isinstance(graphic, GraphicCollection): + # clear Group + graphic.world_object.clear() + # delete all child world objects in the collection + for g in graphic.graphics: + subloc = hex(id(g)) + del WORLD_OBJECTS[subloc] + + # get mem location of graphic loc = hex(id(graphic)) + # delete world object del WORLD_OBJECTS[loc] del graphic From 9713a4c2c64b82084b2949b50bd3ff8abda3de57 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 3 Apr 2023 01:49:29 -0400 Subject: [PATCH 03/11] nb to explore garbage collection --- examples/garbage_collection.ipynb | 345 ++++++++++++++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 examples/garbage_collection.ipynb diff --git a/examples/garbage_collection.ipynb b/examples/garbage_collection.ipynb new file mode 100644 index 000000000..2b746fda0 --- /dev/null +++ b/examples/garbage_collection.ipynb @@ -0,0 +1,345 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "1ef0578e-09e1-45ff-bd34-84472db3885e", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "from fastplotlib import Plot\n", + "import numpy as np\n", + "import sys\n", + "\n", + "import weakref\n", + "import gc\n", + "import os, psutil\n", + "process = psutil.Process(os.getpid())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1bb6bc6f-7786-4d23-9eb1-e30bbc66c798", + "metadata": {}, + "outputs": [], + "source": [ + "print(process.memory_info().rss / 1024 / 1024)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b23ba640-88ec-40d9-b53c-c8cbb3e39b0b", + "metadata": {}, + "outputs": [], + "source": [ + "plot = Plot()\n", + "\n", + "# a = np.random.rand(5_000_000)\n", + "# plot.add_line(a)\n", + "\n", + "\n", + "plot.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17f46f21-b29d-4dd3-9496-989bbb240f50", + "metadata": {}, + "outputs": [], + "source": [ + "print(process.memory_info().rss / 1024 / 1024)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e26d392f-6afd-4e89-a685-d618065d3caf", + "metadata": {}, + "outputs": [], + "source": [ + "a = np.random.rand(1_000, 5_000)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "31e3027a-56cf-4f7b-ba78-aed4f78eef47", + "metadata": {}, + "outputs": [], + "source": [ + "print(process.memory_info().rss / 1024 / 1024)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9cf2ceaa-5b01-4e12-be39-f267cd355833", + "metadata": {}, + "outputs": [], + "source": [ + "plot.add_line_stack(a)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "53170858-ae72-4451-8647-7d5b1f9da75e", + "metadata": {}, + "outputs": [], + "source": [ + "print(process.memory_info().rss / 1024 / 1024)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ee2481c9-82e3-4043-85fd-21a0cdf21187", + "metadata": {}, + "outputs": [], + "source": [ + "plot.auto_scale()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e4e0f73b-c58a-40e7-acf5-07a1f70d2821", + "metadata": {}, + "outputs": [], + "source": [ + "plot.delete_graphic(plot.graphics[0])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "56c64498-229e-48b7-9fb1-f7c327fff2ae", + "metadata": {}, + "outputs": [], + "source": [ + "print(process.memory_info().rss / 1024 / 1024)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dd6a26c1-ea81-469d-ae7a-95839b1f9d5a", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from wgpu.gui.auto import WgpuCanvas, run\n", + "import pygfx as gfx\n", + "import subprocess\n", + "\n", + "canvas = WgpuCanvas()\n", + "renderer = gfx.WgpuRenderer(canvas)\n", + "scene = gfx.Scene()\n", + "camera = gfx.OrthographicCamera(5000, 5000)\n", + "camera.position.x = 2048\n", + "camera.position.y = 2048\n", + "\n", + "\n", + "def make_image():\n", + " data = np.random.rand(4096, 4096).astype(np.float32)\n", + "\n", + " return gfx.Image(\n", + " gfx.Geometry(grid=gfx.Texture(data, dim=2)),\n", + " gfx.ImageBasicMaterial(clim=(0, 1)),\n", + " )\n", + "\n", + "\n", + "class Graphic:\n", + " def __init__(self):\n", + " data = np.random.rand(4096, 4096).astype(np.float32)\n", + " self.wo = gfx.Image(\n", + " gfx.Geometry(grid=gfx.Texture(data, dim=2)),\n", + " gfx.ImageBasicMaterial(clim=(0, 1)),\n", + " )\n", + "\n", + "\n", + "def draw():\n", + " renderer.render(scene, camera)\n", + " canvas.request_draw()\n", + "\n", + "\n", + "def print_nvidia(msg):\n", + " print(msg)\n", + " print(\n", + " subprocess.check_output([\"nvidia-smi\", \"--format=csv\", \"--query-gpu=memory.used\"]).decode().split(\"\\n\")[1]\n", + " )\n", + " print()\n", + "\n", + "\n", + "def add_img(*args):\n", + " print_nvidia(\"Before creating image\")\n", + " img = make_image()\n", + " print_nvidia(\"After creating image\")\n", + " scene.add(img)\n", + " img.add_event_handler(remove_img, \"click\")\n", + " draw()\n", + " print_nvidia(\"After add image to scene\")\n", + "\n", + "\n", + "def remove_img(*args):\n", + " img = scene.children[0]\n", + " scene.remove(img)\n", + " draw()\n", + " print_nvidia(\"After remove image from scene\")\n", + " del img\n", + " draw()\n", + " print_nvidia(\"After del image\")\n", + "\n", + "\n", + "renderer.add_event_handler(add_img, \"double_click\")\n", + "canvas" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2599f430-8b00-4490-9e11-774897be6e77", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "from wgpu.gui.auto import WgpuCanvas, run\n", + "import pygfx as gfx\n", + "import subprocess\n", + "\n", + "canvas = WgpuCanvas()\n", + "renderer = gfx.WgpuRenderer(canvas)\n", + "scene = gfx.Scene()\n", + "camera = gfx.OrthographicCamera(5000, 5000)\n", + "camera.position.x = 2048\n", + "camera.position.y = 2048\n", + "\n", + "\n", + "def make_image():\n", + " data = np.random.rand(4096, 4096).astype(np.float32)\n", + "\n", + " return gfx.Image(\n", + " gfx.Geometry(grid=gfx.Texture(data, dim=2)),\n", + " gfx.ImageBasicMaterial(clim=(0, 1)),\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3ec10f26-6544-4ad3-80c1-aa34617dc826", + "metadata": {}, + "outputs": [], + "source": [ + "import weakref" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "acc819a3-cd50-4fdd-a0b5-c442d80847e2", + "metadata": {}, + "outputs": [], + "source": [ + "img = make_image()\n", + "img_ref = weakref.ref(img)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f89da335-3372-486b-b773-9f103d6a9bbd", + "metadata": {}, + "outputs": [], + "source": [ + "img_ref()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c22904ad-d674-43e6-83bb-7a2f7b277c06", + "metadata": {}, + "outputs": [], + "source": [ + "del img" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "573566d7-eb91-4690-958c-d00dd495b3e4", + "metadata": {}, + "outputs": [], + "source": [ + "import gc" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "aaef3e89-2bfd-43af-9b8f-824a3f89b85f", + "metadata": {}, + "outputs": [], + "source": [ + "img_ref()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3380f35e-fcc9-43f6-80d2-7e9348cd13b4", + "metadata": {}, + "outputs": [], + "source": [ + "draw()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a27bf7c7-f3ef-4ae8-8ecf-31507f8c0449", + "metadata": {}, + "outputs": [], + "source": [ + "print(\n", + " subprocess.check_output([\"nvidia-smi\", \"--format=csv\", \"--query-gpu=memory.used\"]).decode().split(\"\\n\")[1]\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e4bf2711-8a83-4d9c-a4f7-f50de7ae1715", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 247b44b6d44bfd72c04776c177c5e9a5bdafe105 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 3 Apr 2023 13:55:29 -0400 Subject: [PATCH 04/11] heatmap deletion frees up GPU VRAM --- fastplotlib/graphics/image.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 91da9687d..cb4cf1587 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -274,7 +274,8 @@ def __init__( start_ixs = [list(map(lambda c: c * chunk_size, chunk)) for chunk in chunks] stop_ixs = [list(map(lambda c: c + chunk_size, chunk)) for chunk in start_ixs] - self._world_object = pygfx.Group() + world_object = pygfx.Group() + self._set_world_object(world_object) if (vmin is None) or (vmax is None): vmin, vmax = quick_min_max(data) From 5e9f5446aa4465b4cb615a59390be2edd12d5c9d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 3 Apr 2023 23:31:30 -0400 Subject: [PATCH 05/11] GPU VRAM and system RAM freed for all graphics --- fastplotlib/graphics/_base.py | 42 +++++++++---- fastplotlib/graphics/features/_base.py | 3 +- fastplotlib/graphics/text.py | 4 +- fastplotlib/layouts/_base.py | 83 ++++++++++++++++++-------- fastplotlib/layouts/_subplot.py | 6 +- 5 files changed, 97 insertions(+), 41 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index ea293dfdf..de126804f 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -50,7 +50,8 @@ def __init_subclass__(cls, **kwargs): class Graphic(BaseGraphic): def __init__( - self, name: str = None): + self, name: str = None + ): """ Parameters @@ -64,15 +65,16 @@ def __init__( self.registered_callbacks = dict() self.present = PresentFeature(parent=self) - self._world_objects = WORLD_OBJECTS + # store hex id str of Graphic instance mem location + self.loc: str = hex(id(self)) @property def world_object(self) -> WorldObject: """Associated pygfx WorldObject. Always returns a proxy, real object cannot be accessed directly.""" - return weakref.proxy(self._world_objects[hex(id(self))]) + return weakref.proxy(WORLD_OBJECTS[hex(id(self))]) def _set_world_object(self, wo: WorldObject): - self._world_objects[hex(id(self))] = wo + WORLD_OBJECTS[hex(id(self))] = wo @property def position(self) -> Vector3: @@ -112,7 +114,7 @@ def __repr__(self): return rval def __del__(self): - del self._world_objects[hex(id(self))] + del WORLD_OBJECTS[self.loc] class Interaction(ABC): @@ -278,17 +280,21 @@ class PreviouslyModifiedData: indices: Any +COLLECTION_GRAPHICS: dict[str, Graphic] = dict() + + class GraphicCollection(Graphic): """Graphic Collection base class""" def __init__(self, name: str = None): super(GraphicCollection, self).__init__(name) - self._graphics: List[Graphic] = list() + self._graphics: List[str] = list() @property def graphics(self) -> Tuple[Graphic]: - """returns the Graphics within this collection""" - return tuple(self._graphics) + """The Graphics within this collection. Always returns a proxy to the Graphics.""" + proxies = [weakref.proxy(COLLECTION_GRAPHICS[loc]) for loc in self._graphics] + return tuple(proxies) def add_graphic(self, graphic: Graphic, reset_index: True): """Add a graphic to the collection""" @@ -298,7 +304,11 @@ def add_graphic(self, graphic: Graphic, reset_index: True): f"You can only add {self.child_type} to a {self.__class__.__name__}, " f"you are trying to add a {graphic.__class__.__name__}." ) - self._graphics.append(graphic) + + loc = hex(id(graphic)) + COLLECTION_GRAPHICS[loc] = graphic + + self._graphics.append(loc) if reset_index: self._reset_index() self.world_object.add(graphic.world_object) @@ -306,9 +316,19 @@ def add_graphic(self, graphic: Graphic, reset_index: True): def remove_graphic(self, graphic: Graphic, reset_index: True): """Remove a graphic from the collection""" self._graphics.remove(graphic) + if reset_index: self._reset_index() - self.world_object.remove(graphic) + + self.world_object.remove(graphic.world_object) + + def __del__(self): + self.world_object.clear() + + for loc in self._graphics: + del COLLECTION_GRAPHICS[loc] + + super().__del__() def _reset_index(self): for new_index, graphic in enumerate(self._graphics): @@ -374,7 +394,7 @@ def __init__( selection_indices: Union[list, range] the corresponding indices from the parent GraphicCollection that were selected """ - self._parent = parent + self._parent = weakref.proxy(parent) self._selection = selection self._selection_indices = selection_indices diff --git a/fastplotlib/graphics/features/_base.py b/fastplotlib/graphics/features/_base.py index 80029180e..da6a177a0 100644 --- a/fastplotlib/graphics/features/_base.py +++ b/fastplotlib/graphics/features/_base.py @@ -2,6 +2,7 @@ from inspect import getfullargspec from warnings import warn from typing import * +import weakref import numpy as np from pygfx import Buffer, Texture @@ -71,7 +72,7 @@ def __init__(self, parent, data: Any, collection_index: int = None): if part of a collection, index of this graphic within the collection """ - self._parent = parent + self._parent = weakref.proxy(parent) self._data = to_gpu_supported_dtype(data) diff --git a/fastplotlib/graphics/text.py b/fastplotlib/graphics/text.py index 665c53606..8225bb300 100644 --- a/fastplotlib/graphics/text.py +++ b/fastplotlib/graphics/text.py @@ -38,11 +38,13 @@ def __init__( """ super(TextGraphic, self).__init__(name=name) - self._world_object = pygfx.Text( + world_object = pygfx.Text( pygfx.TextGeometry(text=text, font_size=size, screen_space=False), pygfx.TextMaterial(color=face_color, outline_color=outline_color, outline_thickness=outline_thickness) ) + self._set_world_object(world_object) + self.world_object.position.set(*position) self.name = None diff --git a/fastplotlib/layouts/_base.py b/fastplotlib/layouts/_base.py index 5ed9b19f3..de352e4f7 100644 --- a/fastplotlib/layouts/_base.py +++ b/fastplotlib/layouts/_base.py @@ -1,11 +1,21 @@ +from warnings import warn +from typing import * +import weakref + import numpy as np + from pygfx import Scene, OrthographicCamera, PerspectiveCamera, PanZoomController, OrbitController, \ Viewport, WgpuRenderer from wgpu.gui.auto import WgpuCanvas -from warnings import warn -from ..graphics._base import Graphic, GraphicCollection, WORLD_OBJECTS + +from ..graphics._base import Graphic, GraphicCollection from ..graphics.line_slider import LineSlider -from typing import * + + +# dict to store Graphic instances +# this is the only place where the real references to Graphics are stored in a Python session +# {hex id str: Graphic} +GRAPHICS: Dict[str, Graphic] = dict() class PlotArea: @@ -74,7 +84,9 @@ def __init__( self.renderer.add_event_handler(self.set_viewport_rect, "resize") - self._graphics: List[Graphic] = list() + # list of hex id strings for all graphics managed by this PlotArea + # the real Graphic instances are stored in the ``GRAPHICS`` dict + self._graphics: List[str] = list() # hacky workaround for now to exclude from bbox calculations self._sliders: List[LineSlider] = list() @@ -129,8 +141,13 @@ def controller(self) -> Union[PanZoomController, OrbitController]: @property def graphics(self) -> Tuple[Graphic]: - """returns the Graphics in the plot area""" - return tuple(self._graphics) + """Graphics in the plot area. Always returns a proxy to the Graphic instances.""" + proxies = list() + for loc in self._graphics: + p = weakref.proxy(GRAPHICS[loc]) + proxies.append(p) + + return tuple(proxies) def get_rect(self) -> Tuple[float, float, float, float]: """allows setting the region occupied by the viewport w.r.t. the parent""" @@ -154,7 +171,8 @@ def add_graphic(self, graphic: Graphic, center: bool = True): Parameters ---------- graphic: Graphic or GraphicCollection - Add a Graphic or a GraphicCollection to the plot area + Add a Graphic or a GraphicCollection to the plot area. + Note: this must be a real Graphic instance and not a proxy center: bool, default True Center the camera on the newly added Graphic @@ -168,12 +186,17 @@ def add_graphic(self, graphic: Graphic, center: bool = True): if graphic.name is not None: # skip for those that have no name self._check_graphic_name_exists(graphic.name) + # store in GRAPHICS dict + loc = graphic.loc + GRAPHICS[loc] = graphic + # TODO: need to refactor LineSlider entirely if isinstance(graphic, LineSlider): - self._sliders.append(graphic) + self._sliders.append(graphic) # don't manage garbage collection of LineSliders for now else: - self._graphics.append(graphic) + self._graphics.append(loc) # add hex id string for referencing this graphic instance + # add world object to scene self.scene.add(graphic.world_object) if center: @@ -185,7 +208,7 @@ def add_graphic(self, graphic: Graphic, center: bool = True): def _check_graphic_name_exists(self, name): graphic_names = list() - for g in self._graphics: + for g in self.graphics: graphic_names.append(g.name) if name in graphic_names: @@ -311,45 +334,53 @@ def delete_graphic(self, graphic: Graphic): """ - if graphic not in self._graphics: - raise KeyError + # graphic_loc = hex(id(graphic.__repr__.__self__)) + + # get location + graphic_loc = graphic.loc + + if graphic_loc not in self._graphics: + raise KeyError(f"Graphic with following address not found in plot area: {graphic_loc}") + # remove from scene if necessary if graphic.world_object in self.scene.children: self.scene.remove(graphic.world_object) - self._graphics.remove(graphic) + # remove from list of addresses + self._graphics.remove(graphic_loc) # for GraphicCollection objects - if isinstance(graphic, GraphicCollection): - # clear Group - graphic.world_object.clear() + # if isinstance(graphic, GraphicCollection): + # # clear Group + # graphic.world_object.clear() + # graphic.clear() # delete all child world objects in the collection - for g in graphic.graphics: - subloc = hex(id(g)) - del WORLD_OBJECTS[subloc] + # for g in graphic.graphics: + # subloc = hex(id(g)) + # del WORLD_OBJECTS[subloc] # get mem location of graphic - loc = hex(id(graphic)) + # loc = hex(id(graphic)) # delete world object - del WORLD_OBJECTS[loc] + #del WORLD_OBJECTS[graphic_loc] - del graphic + del GRAPHICS[graphic_loc] def clear(self): """ Clear the Plot or Subplot. Also performs garbage collection, i.e. runs ``delete_graphic`` on all graphics. """ - for g in self._graphics: + for g in self.graphics: self.delete_graphic(g) def __getitem__(self, name: str): - for graphic in self._graphics: + for graphic in self.graphics: if graphic.name == name: return graphic graphic_names = list() - for g in self._graphics: + for g in self.graphics: graphic_names.append(g.name) raise IndexError(f"no graphic of given name, the current graphics are:\n {graphic_names}") @@ -367,5 +398,5 @@ def __repr__(self): return f"{self}\n" \ f" parent: {self.parent}\n" \ f" Graphics:\n" \ - f"\t{newline.join(graphic.__repr__() for graphic in self._graphics)}" \ + f"\t{newline.join(graphic.__repr__() for graphic in self.graphics)}" \ f"\n" diff --git a/fastplotlib/layouts/_subplot.py b/fastplotlib/layouts/_subplot.py index 41d065648..7bb1f0540 100644 --- a/fastplotlib/layouts/_subplot.py +++ b/fastplotlib/layouts/_subplot.py @@ -2,6 +2,7 @@ import numpy as np from math import copysign from functools import partial +import weakref from inspect import signature, getfullargspec from warnings import warn @@ -112,7 +113,7 @@ def __init__( if self.name is not None: self.set_title(self.name) - def _create_graphic(self, graphic_class, *args, **kwargs): + def _create_graphic(self, graphic_class, *args, **kwargs) -> weakref.proxy: if "center" in kwargs.keys(): center = kwargs.pop("center") else: @@ -124,7 +125,8 @@ def _create_graphic(self, graphic_class, *args, **kwargs): graphic = graphic_class(*args, **kwargs) self.add_graphic(graphic, center=center) - return graphic + # only return a proxy to the real graphic + return weakref.proxy(graphic) def set_title(self, text: Any): """Sets the name of a subplot to 'top' viewport if defined.""" From a05229c0db23a1acdb33b66d22a260ef0c0acbdf Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Mon, 3 Apr 2023 23:35:29 -0400 Subject: [PATCH 06/11] update gc nb --- examples/garbage_collection.ipynb | 100 +++++++++++++++++++++++------- 1 file changed, 78 insertions(+), 22 deletions(-) diff --git a/examples/garbage_collection.ipynb b/examples/garbage_collection.ipynb index 2b746fda0..e3f0250f0 100644 --- a/examples/garbage_collection.ipynb +++ b/examples/garbage_collection.ipynb @@ -23,25 +23,37 @@ "cell_type": "code", "execution_count": null, "id": "1bb6bc6f-7786-4d23-9eb1-e30bbc66c798", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ - "print(process.memory_info().rss / 1024 / 1024)" + "def print_process_ram_mb():\n", + " print(process.memory_info().rss / 1024 / 1024)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b376676e-a7fe-4424-9ba6-fde5be03b649", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "print_process_ram_mb()" ] }, { "cell_type": "code", "execution_count": null, "id": "b23ba640-88ec-40d9-b53c-c8cbb3e39b0b", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ "plot = Plot()\n", - "\n", - "# a = np.random.rand(5_000_000)\n", - "# plot.add_line(a)\n", - "\n", - "\n", "plot.show()" ] }, @@ -49,67 +61,109 @@ "cell_type": "code", "execution_count": null, "id": "17f46f21-b29d-4dd3-9496-989bbb240f50", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ - "print(process.memory_info().rss / 1024 / 1024)" + "print_process_ram_mb()" ] }, { "cell_type": "code", "execution_count": null, "id": "e26d392f-6afd-4e89-a685-d618065d3caf", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ - "a = np.random.rand(1_000, 5_000)" + "# for line\n", + "# a = np.random.rand(10_000_000)\n", + "\n", + "# for heatmap\n", + "# a = np.random.rand(20_000, 20_000)\n", + "\n", + "# for line collection\n", + "# a = np.random.rand(500, 50_000)\n", + "\n", + "# for image\n", + "# a = np.random.rand(7_000, 7_000)\n", + "\n", + "# for scatter\n", + "a = np.random.rand(10_000_000, 3)" ] }, { "cell_type": "code", "execution_count": null, "id": "31e3027a-56cf-4f7b-ba78-aed4f78eef47", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ - "print(process.memory_info().rss / 1024 / 1024)" + "print_process_ram_mb()" ] }, { "cell_type": "code", "execution_count": null, "id": "9cf2ceaa-5b01-4e12-be39-f267cd355833", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ - "plot.add_line_stack(a)" + "# g = plot.add_line_collection(a)\n", + "# g = plot.add_heatmap(a)\n", + "# g = plot.add_line(a)\n", + "g = plot.add_scatter(a)" ] }, { "cell_type": "code", "execution_count": null, - "id": "53170858-ae72-4451-8647-7d5b1f9da75e", - "metadata": {}, + "id": "6518795c-98cf-405d-94ab-786ac3b2e1d6", + "metadata": { + "tags": [] + }, "outputs": [], "source": [ - "print(process.memory_info().rss / 1024 / 1024)" + "g" ] }, { "cell_type": "code", "execution_count": null, "id": "ee2481c9-82e3-4043-85fd-21a0cdf21187", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ "plot.auto_scale()" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "53170858-ae72-4451-8647-7d5b1f9da75e", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "print(process.memory_info().rss / 1024 / 1024)" + ] + }, { "cell_type": "code", "execution_count": null, "id": "e4e0f73b-c58a-40e7-acf5-07a1f70d2821", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ "plot.delete_graphic(plot.graphics[0])" @@ -119,7 +173,9 @@ "cell_type": "code", "execution_count": null, "id": "56c64498-229e-48b7-9fb1-f7c327fff2ae", - "metadata": {}, + "metadata": { + "tags": [] + }, "outputs": [], "source": [ "print(process.memory_info().rss / 1024 / 1024)" From 1f54d31b9f4d53b9b11f27dcda61c4a2a2d7cd54 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 4 Apr 2023 00:14:15 -0400 Subject: [PATCH 07/11] line slider works, not gc but not necessary for now --- fastplotlib/graphics/line_slider.py | 12 +++++++----- fastplotlib/layouts/_base.py | 7 +++---- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/fastplotlib/graphics/line_slider.py b/fastplotlib/graphics/line_slider.py index 8755af51a..f19db9cda 100644 --- a/fastplotlib/graphics/line_slider.py +++ b/fastplotlib/graphics/line_slider.py @@ -74,7 +74,7 @@ def __init__( else: material = pygfx.LineMaterial - colors_inner = np.repeat([Color("w")], 2, axis=0).astype(np.float32) + colors_inner = np.repeat([Color(color)], 2, axis=0).astype(np.float32) colors_outer = np.repeat([Color([1., 1., 1., 0.25])], 2, axis=0).astype(np.float32) line_inner = pygfx.Line( @@ -88,17 +88,19 @@ def __init__( material=material(thickness=thickness + 4, vertex_colors=True) ) - self._world_object = pygfx.Group() + world_object = pygfx.Group() - self._world_object.add(line_outer) - self._world_object.add(line_inner) + world_object.add(line_outer) + world_object.add(line_inner) + + self._set_world_object(world_object) self.position.x = x_pos self.slider = slider self.slider.observe(self.set_position, "value") - self.name = name + super().__init__(name=name) def set_position(self, change): self.position.x = change["new"] diff --git a/fastplotlib/layouts/_base.py b/fastplotlib/layouts/_base.py index de352e4f7..c98c010ea 100644 --- a/fastplotlib/layouts/_base.py +++ b/fastplotlib/layouts/_base.py @@ -186,14 +186,13 @@ def add_graphic(self, graphic: Graphic, center: bool = True): if graphic.name is not None: # skip for those that have no name self._check_graphic_name_exists(graphic.name) - # store in GRAPHICS dict - loc = graphic.loc - GRAPHICS[loc] = graphic - # TODO: need to refactor LineSlider entirely if isinstance(graphic, LineSlider): self._sliders.append(graphic) # don't manage garbage collection of LineSliders for now else: + # store in GRAPHICS dict + loc = graphic.loc + GRAPHICS[loc] = graphic self._graphics.append(loc) # add hex id string for referencing this graphic instance # add world object to scene From add412f3020fff95080a1b2b798d73541fb6c6bd Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 4 Apr 2023 00:14:28 -0400 Subject: [PATCH 08/11] update gc nb --- examples/garbage_collection.ipynb | 62 ++++++++++++++++++++----------- 1 file changed, 40 insertions(+), 22 deletions(-) diff --git a/examples/garbage_collection.ipynb b/examples/garbage_collection.ipynb index e3f0250f0..85744e6e0 100644 --- a/examples/garbage_collection.ipynb +++ b/examples/garbage_collection.ipynb @@ -72,53 +72,63 @@ { "cell_type": "code", "execution_count": null, - "id": "e26d392f-6afd-4e89-a685-d618065d3caf", + "id": "27627cd4-c363-4eab-a121-f6c8abbbe5ae", "metadata": { "tags": [] }, "outputs": [], "source": [ - "# for line\n", - "# a = np.random.rand(10_000_000)\n", - "\n", - "# for heatmap\n", - "# a = np.random.rand(20_000, 20_000)\n", - "\n", - "# for line collection\n", - "# a = np.random.rand(500, 50_000)\n", - "\n", - "# for image\n", - "# a = np.random.rand(7_000, 7_000)\n", - "\n", - "# for scatter\n", - "a = np.random.rand(10_000_000, 3)" + "graphic = \"scatter\"" + ] + }, + { + "cell_type": "markdown", + "id": "d9c10edc-169a-4dd2-bd5b-8a1b67baf3a9", + "metadata": {}, + "source": [ + "### Run the following cells repeatedly to add and remove the graphic" ] }, { "cell_type": "code", "execution_count": null, - "id": "31e3027a-56cf-4f7b-ba78-aed4f78eef47", + "id": "e26d392f-6afd-4e89-a685-d618065d3caf", "metadata": { "tags": [] }, "outputs": [], "source": [ - "print_process_ram_mb()" + "if graphic == \"line\":\n", + " a = np.random.rand(10_000_000)\n", + " g = plot.add_line(a)\n", + " \n", + "elif graphic == \"heatmap\":\n", + " a = np.random.rand(20_000, 20_000)\n", + " g = plot.add_heatmap(a)\n", + "\n", + "elif graphic == \"line_collection\":\n", + " a = np.random.rand(500, 50_000)\n", + " g = plot.add_line_collection(a)\n", + " \n", + "elif graphic == \"image\":\n", + " a = np.random.rand(7_000, 7_000)\n", + " g = plot.add_image(a)\n", + "\n", + "elif graphic == \"scatter\":\n", + " a = np.random.rand(10_000_000, 3)\n", + " g = plot.add_scatter(a)" ] }, { "cell_type": "code", "execution_count": null, - "id": "9cf2ceaa-5b01-4e12-be39-f267cd355833", + "id": "31e3027a-56cf-4f7b-ba78-aed4f78eef47", "metadata": { "tags": [] }, "outputs": [], "source": [ - "# g = plot.add_line_collection(a)\n", - "# g = plot.add_heatmap(a)\n", - "# g = plot.add_line(a)\n", - "g = plot.add_scatter(a)" + "print_process_ram_mb()" ] }, { @@ -169,6 +179,14 @@ "plot.delete_graphic(plot.graphics[0])" ] }, + { + "cell_type": "markdown", + "id": "47baa487-c66b-4c40-aa11-d819902870e3", + "metadata": {}, + "source": [ + "If there is no serious system memory leak, this value shouldn't really increase after repeated cycles" + ] + }, { "cell_type": "code", "execution_count": null, From d97a99d9c35376a0b8763edc6dd8f8ef23b46ab5 Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Wed, 5 Apr 2023 14:56:13 -0400 Subject: [PATCH 09/11] bugfix --- fastplotlib/graphics/line_collection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index ac27f5b4d..3bff6f7c5 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -343,6 +343,6 @@ def __init__( ) axis_zero = 0 - for i, line in enumerate(self._graphics): + for i, line in enumerate(self.graphics): getattr(line.position, f"set_{separation_axis}")(axis_zero) axis_zero = axis_zero + line.data()[:, axes[separation_axis]].max() + separation From 5a084c0ed3d8d5ebbe69a2b2ecbf4a8d95919bf0 Mon Sep 17 00:00:00 2001 From: Caitlin Lewis Date: Wed, 5 Apr 2023 15:14:20 -0400 Subject: [PATCH 10/11] fix bug in graphic collection --- fastplotlib/graphics/_base.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index de126804f..65f72167d 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -315,7 +315,7 @@ def add_graphic(self, graphic: Graphic, reset_index: True): def remove_graphic(self, graphic: Graphic, reset_index: True): """Remove a graphic from the collection""" - self._graphics.remove(graphic) + self._graphics.remove(graphic.loc) if reset_index: self._reset_index() @@ -341,7 +341,7 @@ def __getitem__(self, key): if isinstance(key, slice): key = cleanup_slice(key, upper_bound=len(self)) selection_indices = range(key.start, key.stop, key.step) - selection = self._graphics[key] + selection = self.graphics[key] # fancy-ish indexing elif isinstance(key, (tuple, list, np.ndarray)): @@ -353,7 +353,7 @@ def __getitem__(self, key): selection = list() for ix in key: - selection.append(self._graphics[ix]) + selection.append(self.graphics[ix]) selection_indices = key else: From 0cbe36802431c2928394fd33004804d50018a87d Mon Sep 17 00:00:00 2001 From: kushalkolar Date: Tue, 11 Apr 2023 02:21:58 -0400 Subject: [PATCH 11/11] bugfix pygfx events triggered by worldobject --- fastplotlib/graphics/_base.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 65f72167d..309b68d9f 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -232,8 +232,13 @@ def _event_handler(self, event): # for now we only have line collections so this works else: - for i, item in enumerate(self._graphics): - if item.world_object is event.pick_info["world_object"]: + # get index of world object that made this event + for i, item in enumerate(self.graphics): + wo = WORLD_OBJECTS[item.loc] + # we only store hex id of worldobject, but worldobject `pick_info` is always the real object + # so if pygfx worldobject triggers an event by itself, such as `click`, etc., this will be + # the real world object in the pick_info and not the proxy + if wo is event.pick_info["world_object"]: indices = i target_info.target._set_feature(feature=target_info.feature, new_data=target_info.new_data, indices=indices) else: