GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — Garbage collection when a resource is no longer in use · Issue #1264 · pygfx/pygfx

Garbage collection when a resource is no longer in use #1264

New issue
Copy link
New issue
Copy link
Closed
Closed
Garbage collection when a resource is no longer in use#1264
Copy link

Description

@kushalkolar
kushalkolar
opened on Jan 13, 2026
Issue body actions

When a resource is no longer in use, the references to it are cleared (i.e. the resource is longer referenced to in engine.Binding) but it still lingers in GPU VRAM until GPUBuffer.destroy() is called. But I'm guessing this is not recommended?

Example, run this in ipython for example, with %gui qt. It just adds a Line with 50 million points which takes up ~600MB of VRAM, and then replaces the buffer with data that has only 200 points so we should see GPU VRAM drop significantly.

import pygfx
import numpy as np
from rendercanvas.auto import RenderCanvas, loop
from rendercanvas.utils.asyncs import sleep
import wgpu
from pygfx.renderers.wgpu import select_adapter
import gc

# just choosing my nvidia GPU so I can use nvidia-smi to see RAM usage
select_adapter(wgpu.gpu.enumerate_adapters_sync()[1])


class Scope:
    def __init__(self):
        data = np.random.rand(50_000_000, 3).astype(np.float32)

        self.canvas = RenderCanvas()
        self.renderer = pygfx.WgpuRenderer(target=self.canvas)

        self.original_buffer = pygfx.Buffer(data)

        self.line = pygfx.Line(
            pygfx.Geometry(positions=self.original_buffer),
            pygfx.LineMaterial()
        )

        self.scene = pygfx.Scene()
        self.scene.add(self.line)

        self.camera = pygfx.PerspectiveCamera(fov=0)
        self.camera.show_object(self.scene)

        self.canvas.request_draw(self.render)

    def render(self):
        self.renderer.render(self.scene, self.camera)
        self.canvas.request_draw()

    def change(self):
        data2 = np.random.rand(200, 3).astype(np.float32)

        self.line.geometry.positions = pygfx.Buffer(data2)

    def check_referrers(self):
        print(gc.get_referrers(self.original_buffer))

    def delete_old_buffer(self):
        self.original_buffer._wgpu_object.destroy()

        # this alone is not sufficient to release GPU VRAM
        del self.original_buffer


s = Scope()

loop.run()

Now go through the following:

s.check_referrers()

We get 3 referrers which makes sense, the Scope instance, Geometry and Binding.

[<__main__.Scope object at 0x7f5268fac830>, Geometry(
    positions=<pygfx.resources._buffer.Buffer object at 0x7f5260622350>,
) # at 0x7f526817e260, <pygfx.renderers.wgpu.engine.binding.Binding object at 0x7f526895e470>]

Call change() to replace the buffer. Then interact with the canvas to make sure new frames are drawn etc:

s.change()

If we check the referrers we can see that the only lingering reference is to the Scope instance which makes sense, it's no longer referenced in the Binding and Geometry:

[<__main__.Scope object at 0x7f5268fac830>]

Finally if we call s.delete_old_buffer(), then GPU VRAM is cleared since it calls original_buffer._wgpu_object.destroy() and removes the original_buffer reference from the Scope instance as well. However if we only remove the reference to the buffer, then it lingers in GPU VRAM, even though it's been removed from Binding.

Is this expected behavior? If not what's the best way to destroy the GPUBuffer when a Binding resource changes?

Reacted by Clay Dugo

Activity

  1. almarklein commented on Jan 23, 2026

    @almarklein
    almarklein
    on Jan 23, 2026
    Member
    More actions

    However if we only remove the reference to the buffer, then it lingers in GPU VRAM, even though it's been removed from Binding.

    The expectation is that removing the reference to the buffer will also release the VRAM, although it could linger a little longer, because the gc will need to kick in.

    What should happen:

    • Once the original_buffer is removed, it has no more references, so it is cleaned up by gc.
    • Which will cause it's internal _wgpu_object attribute, the wgpu.GPUBuffer, to be de-referenced as well.
    • Which causes it's __del__ to be called, which calls 'destroy()`.

    I did some quick tests with your code, adding

    ref = weakref.ref(s.original_buffer._wgpu_object)

    And from what I see, that object is indeed dead after calling delete_old_buffer, also if I comment the line that explicitly calls destroy().

    Are you saying you are seeing something different when you inspect the VRAM? If you do, try calling gc.collect().

  2. kushalkolar commented on Feb 2, 2026

    @kushalkolar
    kushalkolar
    on Feb 2, 2026
    ContributorAuthor
    More actions

    Are you saying you are seeing something different when you inspect the VRAM? If you do, try calling gc.collect().

    Yes!

    Simplified the example to show what I mean:

    import pygfx
    import numpy as np
    from rendercanvas.auto import RenderCanvas, loop
    from rendercanvas.utils.asyncs import sleep
    import wgpu
    from pygfx.renderers.wgpu import select_adapter
    import gc
    import weakref
    
    # just choosing my nvidia GPU so I can use nvidia-smi to see RAM usage
    select_adapter(wgpu.gpu.enumerate_adapters_sync()[1])
    
    
    class Scope:
        def __init__(self):
            data = np.random.rand(50_000_000, 3).astype(np.float32)
    
            self.canvas = RenderCanvas()
            self.renderer = pygfx.WgpuRenderer(target=self.canvas)
    
            self.line = pygfx.Line(
                pygfx.Geometry(positions=pygfx.Buffer(data)),
                pygfx.LineMaterial()
            )
    
            self.original_buffer = weakref.proxy(self.line.geometry.positions)
    
            self.scene = pygfx.Scene()
            self.scene.add(self.line)
    
            self.camera = pygfx.PerspectiveCamera(fov=0)
            self.camera.show_object(self.scene)
    
            self.canvas.request_draw(self.render)
    
        def render(self):
            self.renderer.render(self.scene, self.camera)
            self.canvas.request_draw()
    
        def change(self):
            data2 = np.random.rand(200, 3).astype(np.float32)
            # VRAM is not freed unless this line is called
            # self.line.geometry.positions._wgpu_object.destroy()
            self.line.geometry.positions = pygfx.Buffer(data2)
    
    
    s = Scope()
    
    loop.run()

    VRAM not freed if we don't call destroy, even though the reference to the original buffer no longer exists:

    gc_buffer-2026-02-02_18.06.51.mp4

    VRAM freed only if we call destroy:

    gc_buffer_destroy-2026-02-02_18.07.59.mp4
  3. kushalkolar commented on Feb 4, 2026

    @kushalkolar
    kushalkolar
    on Feb 4, 2026
    ContributorAuthor
    More actions

    More interesting stuff, I made this example with fastplotlib in the branch of this PR fastplotlib/fastplotlib#974 .

    Not using wgpu_obj.destroy(). It just dereferences, no gc.collect() either and it seems like VRAM is freed as expected. Ben running this for a long while (10+ mins) and there is no memory leak. I don't understand why it's working here though, but not in the above example. Does the resource get cached somehow and it's only cleared when we're constantly allocating new buffers or something?

    By the way I am impressed at the performance even when we're constantly replacing the buffer as shown here. Every time n_points changes, it's allocating a new buffer 😂 . pygfx and wgpu are the bestest ❤️

    no_destroy-2026-02-03_21.29.02.mp4
    import numpy as np
    import fastplotlib as fpl
    
    xs = np.linspace(0, 10 * np.pi, 100)
    ys = np.sin(xs)
    
    data = np.column_stack([xs, ys])
    
    fig = fpl.Figure(shape=(2, 2), size=(700, 700))
    
    line = fig[0, 0].add_line(data)
    text = fig[0, 0].add_text(f"n_points: {100}", offset=(0, 3, 0), anchor="middle-left")
    
    scatter = fig[0, 1].add_scatter(
        np.random.rand(100, 3),
        colors=np.random.rand(100, 4),
        sizes=(np.random.rand(100) + 1) * 3,
        edge_colors=np.random.rand(100, 4),
        point_rotations=np.random.rand(100) * 180,
        uniform_size=False,
        uniform_edge_color=False,
        point_rotation_mode="vertex",
    )
    
    fig.show()
    
    i = 0
    
    
    def update():
        global i
    
        # update line
        freq = (np.sin(i) + 1) * 5
        n_points = int((freq * 50_000) + 10)
    
        xs = np.linspace(0, 10 * np.pi, n_points)
        ys = np.sin(xs * freq)
        new_data = np.column_stack([xs, ys])
    
        line.data = new_data
    
        # update scatter
        scatter.data = np.random.rand(n_points, 3)
        scatter.colors = np.random.rand(n_points, 4)
    
        i += 0.01
        text.text = f"n_points: {n_points}"
    
    
    fig.add_animations(update)
    
    fpl.loop.run()
  4. almarklein commented on Feb 9, 2026

    @almarklein
    almarklein
    on Feb 9, 2026
    Member
    More actions

    It's also worth mentioning that calling destroy kills the buffer at once (wgpuBufferDestroy). While when the object is gc'd naturally, it is released (wgpuBufferRelease). Destroying is not recommended in general, because if a pipeline object still uses the buffer, that pipeline is now broken, and wgpu will produce an error if you try to use it.

    This may also explain why the first example is 'slow' to release VRAM. Apart from us releasing the buffer, any pipeline object will also have to be released, before the buffer can really be cleaned up by wgpu-core. So at the very least we need a draw, preferably a couple of draws, to tick passed Pygfx' caching.

  5. kushalkolar commented on Mar 4, 2026

    @kushalkolar
    kushalkolar
    on Mar 4, 2026
    ContributorAuthor
    More actions

    I'm going to close this, the first example still doesn't seem to free gpu VRAM but the second example is much more realistic and reflects our use case as well. In real usage gc is working. Can revisit this if gc is an issue in a real use case.

    Thanks for the details! Helped to understand the gc system better.

  6. kushalkolar
    closed this as completedon Mar 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      You are visitor 01402520 — GeezerVison does not store the pages you look up.
      Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.