Use modern GTK4 snapshot API when available by QuLogic · Pull Request #32251 · matplotlib/matplotlib · GitHub
Skip to content

Use modern GTK4 snapshot API when available - #32251

Open
QuLogic wants to merge 3 commits into
matplotlib:mainfrom
QuLogic:gtk4-snapshot
Open

Use modern GTK4 snapshot API when available#32251
QuLogic wants to merge 3 commits into
matplotlib:mainfrom
QuLogic:gtk4-snapshot

Conversation

@QuLogic

@QuLogic QuLogic commented Aug 28, 2026

Copy link
Copy Markdown
Member

PR summary

On modern GTK 4, the Snapshot API provides enough of a Path API that we can use it for drawing the zoom overlay. This means that we can drop the use of Cairo entirely. The Snapshot API can then draw the overlay on the GPU, and we keep the texture for the figure around so it also remains on the GPU.

Additionally, on even newer GTK 4, we can specify the scaling of the texture, and so we can round out the location and disable the linear scaling filter so that the canvas is no longer blurry on fractional HiDPI screens.

Also, fix the size of the icons in the toolbar.

AI Disclosure

None

PR quality check

  • Use an expressive title, e.g. "Fix title font property precedence"
  • [?] New and changed code is tested
  • [n/a] Plotting related features are demonstrated in an example
  • [n/a] New features and API changes have release notes
  • [n/a] Documentation complies with general and docstring guidelines

@iccir

iccir commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Comment thread lib/matplotlib/backends/backend_gtk4.py
@iccir

iccir commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Regarding my slowdown comment from yesterday, here's a line profile:

Function: FigureCanvasGTK4Agg.on_snapshot_event at line 16

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    16                                               @profile
    17                                               def on_snapshot_event(self, snapshot):
    18        20         29.0      1.4      0.0          if self._idle_draw_id:
    19        20        341.0     17.1      0.0              GLib.source_remove(self._idle_draw_id)
    20        20         14.0      0.7      0.0              self._idle_draw_id = 0
    21        20     420158.0  21007.9     15.3              self.draw()
    22                                           
    23        20        481.0     24.1      0.0              view = self.get_renderer().buffer_rgba()
    24        20    2312717.0 115635.9     84.4              buf = GLib.Bytes.new(view.cast('B'))
    25        40       1721.0     43.0      0.1              self._texture = Gdk.MemoryTexture.new(view.shape[1], view.shape[0],
    26        20       1974.0     98.7      0.1                                                    Gdk.MemoryFormat.R8G8B8A8,
    27        20          6.0      0.3      0.0                                                    buf, view.strides[0])
    28                                           
    29        20         79.0      4.0      0.0          width = self.get_width()
    30        20         21.0      1.1      0.0          height = self.get_height()
    31                                                   # Yes, Graphene.Rect really does have this strange initialization API.
    32        20         77.0      3.9      0.0          area = Graphene.Rect()
    33        40         81.0      2.0      0.0          Graphene.Rect.init(
    34        20          3.0      0.1      0.0              area,
    35                                                       # Snap the texture to a physical pixel so it is not blurred.
    36        20        111.0      5.5      0.0              1 - ceil(self.device_pixel_ratio) / self.device_pixel_ratio,
    37        20         24.0      1.2      0.0              1 - ceil(self.device_pixel_ratio) / self.device_pixel_ratio,
    38        20         19.0      0.9      0.0              ceil(width * self.device_pixel_ratio),
    39        20         17.0      0.8      0.0              ceil(height * self.device_pixel_ratio))
    40                                           
    41        20         58.0      2.9      0.0          snapshot.save()
    42        20         67.0      3.4      0.0          snapshot.scale(1 / self.device_pixel_ratio, 1 / self.device_pixel_ratio)
    43                                           
    44        20          4.0      0.2      0.0          if (_SCALED_TEXTURE_AVAILABLE and
    45        20        493.0     24.6      0.0                  self._texture.get_height() == floor(area.size.height)):
    46        40        422.0     10.6      0.0              snapshot.append_scaled_texture(self._texture, Gsk.ScalingFilter.NEAREST,
    47        20          2.0      0.1      0.0                                             area)
    48                                                   else:
    49                                                       snapshot.append_texture(self._texture, area)
    50                                           
    51        20         48.0      2.4      0.0          snapshot.restore()

And the native calls via Xcode Instruments:

2.72 G   82.5%	163.56 M _pygi_marshal_from_py_array	
855.07 M 26.0%	35.00  M pygi_marshal_from_py_basic_type_cache_adapter	
551.04 M 16.7%	83.01  M pygi_marshal_from_py_basic_type	
400.03 M 12.1%	110.01 M pygi_guint8_from_py_converted	
70.01 M   2.1%	70.01  M PyLong_AsLong	

On my machine, we are spending 15% in the Agg draw and 85% of the time in GLib.Bytes.new(view.cast('B')). That seems excessive?

If I change it to:

    buf = GLib.Bytes.new(bytes(view.cast('B')))

the resize seems much more responsive, although I'm not sure if there's a better way.

This may be related to Python bytearray to GBytes conversion is very slow.

GTK 4.14 adds enough path API that we can fully avoid Cairo, so it makes
more sense to move to the new API now. This means we can drop Cairo, and
also not do any processing ourselves to change formats. The texture is
likely cached on the GPU, so we cache the object on the canvas until
re-drawing occurs.

On older versions, we fall back to a version using Cairo, but this is
only required for the zoom rectangle.

Also fix some deprecations in the GTK4 examples.
On fractional HiDPI screens, just placing the texture directly will use
default scaling, which will result in blurry output. By rounding the
position and using nearest scaling, we get direct pixel output and no
blurriness.
For some reason, the Gtk.Image loads the icon at 16 pixels, even though
the toolbar and the button are sized to 24. With Gtk.Picture, it
displays at the "natural" size, which matches the 24 pixels we expect,
and also resizes correctly if a theme uses a different size toolbar.
@QuLogic

QuLogic commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

Thanks for investigating so deeply. For me, it is only 82.5% draw vs 16.5% conversion to GLib.Bytes. But just adding the bytes drops it to 98.2% draw vs 0.6% conversion. I think there might be some additional optimization that could be made upstream, but I will push that here for now.

@QuLogic

QuLogic commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

This may be related to Python bytearray to GBytes conversion is very slow.

It looks like that PR was released in 3.57.1/3.58.0, but for me, it actually regresses the ratio to 72.1% draw vs 27% conversion. So I guess we should stay with bytes for the time being, which remains just as fast as on the old version.

@iccir iccir left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested the latest on macOS 27 with gtk4 and everything seems good.

@QuLogic

QuLogic commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants