View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0010293 | ardour | bugs | public | 2026-04-22 03:47 | 2026-07-28 15:41 |
| Reporter | bsj | Assigned To | |||
| Priority | high | Severity | major | Reproducibility | always |
| Status | new | Resolution | open | ||
| Platform | Apple Macintosh | OS | MacOS | OS Version | 10.12 or later |
| Product Version | 9.2 | ||||
| Summary | 0010293: Enhancement Request: Improved UI Thread Efficiency and Meter Redrawing on macOS (Apple Silicon) | ||||
| Description | On modern Apple Silicon hardware (specifically the M4), Ardour 9.x experiences significant GUI sluggishness as the track count increases, even when CPU and GPU usage are low. While a session with 1-4 tracks feels "musical" and snappy, the meter response becomes visually "heavy" and disconnected once the track count reaches 10-16 tracks and beyond. This occurs in a completely empty session with no third-party plugins. Currently, the only workaround to regain smooth metering is to hide or disable tracks etc. I wish Ardour was Metal or Vulkan accelerated!!!!! | ||||
| Steps To Reproduce | very simple, keep adding tracks and the meters will evenly get worse and worse less snappy with no plugins just an empty session no matter buffer size. | ||||
| Additional Information | Technical Observations: Hardware: Apple M4 (Base model), macOS [Insert your version, e.g., Sonoma]. Behavior: The issue appears to be GUI Thread Contention. Hiding tracks immediately restores fluidity, suggesting that the redraw requests for multiple active meters are flooding the main UI thread. Ineffectiveness of Current Settings: Toggling "Use intermediate image-surface," "Retina scaling," and various "macOS Drawing Models" does not fully resolve the scaling issue. Redraw Overhead: It appears macOS may be forcing expensive full-window or large-area redraws for every meter update, causing the UI to fall behind the audio engine's "musical" timing. | ||||
| Tags | GUI, lag, slowdowns, sluggish | ||||
|
|
There is no GUI thread contention, because the GUI is 100% single threaded. The issues with macOS redrawing are deep and subtle. macOS itself indeed forces full-window redraws for every meter update (a change Apple made 6-8 years ago, without announcing it), which is why we (and many other apps) had to add our own system of keeping track of what areas need updating. However, recently I've observed that even when our code does the right thing, macOS' lower layers do not appear to always flush just-drawn areas to the screen if they are small. I am still (slowly) investigating what could cause this and how to fix it. |
|
|
I reported this as 10450 before finding this issue. Looks like the same problem. Looking at a trace in Instruments, it looks like it spends most of its time in CG::DisplayList::executeEntries, and of that a bunch in things like CGClipCreateWithPath. A lot of the traces wind up in things like malloc and memmove. This sounds like Ardour/cairo is possibly creating a huge amount of tiny draws for CoreGraphics to process? It doesn't look like it spends most of the time actually *drawing* anything in terms of pixels. On a hunch I tried this: diff --git a/libs/widgets/fastmeter.cc b/libs/widgets/fastmeter.cc index bea228b84a..09d1e96db0 100644 --- a/libs/widgets/fastmeter.cc +++ b/libs/widgets/fastmeter.cc @@ -757,9 +757,10 @@ FastMeter::queue_vertical_redraw (const Glib::RefPtr<Gdk::Window>& win, float ol rect.x = 1; rect.width = pixwidth; - rect.height = new_top; - rect.y = 1 + pixheight - new_top; + rect.height = pixheight; + rect.y = 1; +#if 0 if (current_level > old_level) { /* colored/pixbuf got larger, just draw the new section */ /* rect.y stays where it is because of X coordinates */ @@ -776,6 +777,7 @@ FastMeter::queue_vertical_redraw (const Glib::RefPtr<Gdk::Window>& win, float ol */ rect.height = pixrect.height - rect.height; } +#endif GdkRegion* region = 0; bool queue = false; And that significantly *improved* the issue. I think what is happening is that Ardour's dirty-rectangle tracking thing to minimize draw areas is actually completely thrashing CoreGraphics. I think CG is trying to draw the entire surface every frame, and it does so in a deferred processing call. When Ardour calls into cairo which forwards to CG, no drawing happens, but instead CG records *the operations needed to draw that area of the screen*. When Ardour does its own dirty-rect tracking, as it does for meters, this means that behind the scenes CG actually ends up splitting the meter into a zillion little rects in its drawing list, each recording the operations Ardour performed when it last drew those pixels. Then on every frame, it walks the entire screen to draw everything from scratch. This ends up drawing the same number of pixels in the end, but the CPU is completely thrashed by overhead tracking tiny little areas to draw individually. This is why when the meters get "reset", it briefly speeds up. At that point the entire meter is drawn as one unit, and this cleans up the CoreGraphics display list for that area of the screen. Then as the meters move, it gets thrashed. This also explains why tracks that are silent don't contribute much (those meters are not moving so aren't thashing). It's not just the meters; this also explains why clicking on a track slows things down again. Essentially, any time there is a partial redraw of anything by Ardour, things get worse. By trying to draw less per frame, Ardour is actually creating a pathological situation for CoreGraphics. I have no idea whose fault this is, or whether this "deferred redraw" behavior can be turned off anywhere, but I can think of two ways forward: - Turn off dirty rect logic across the board and just redraw everything each frame - Draw everything to an offscreen surface (that is known to be flushed to pixels, no display list nonsense behind the scenes), then just blit to the screen each frame. |
|
|
@lina Wow, you are very astute with all this. O___o -Great job! : ) I hope @paul reads your ideas/observations here. -J |
|
|
I suspect this is related: https://gitlab.gnome.org/GNOME/gtk/-/work_items/3714 |
|
|
OK, I found something: https://developer.apple.com/documentation/appkit/nsview/wantslayer?language=objc wantsLayer is false. However, layer is true, so the NSView is CALayer-backed. "In a layer-backed view, any drawing done by the view is cached to the underlying layer object." I looked around and it seems quite some time ago, macOS started using CoreAnimation layers unconditionally, even if you don't ask for them. So then anything you draw is actually buffered in a draw list, and if draws are stacking on top of a dirty view that just goes horribly wrong. The CALayer system is really designed for drawing the entire layer at once, not dirty rect tracking stuff. It doesn't work with gtk/cairo's immediate-drawing approach. I messed around a bit and indeed using an offscreen bitmap for all drawing mostly fixes the issue (or at least makes it a lot better). https://github.com/hoshinolina/ardour/tree/offscreen This is just an ugly PoC, I couldn't figure out how to get resizing to work and it also needs to test for scale factor changes (e.g. if display mode changes). I don't really have the time to look more into this, but hopefully this is a start towards fixing this problem! |
|
|
@lina Excellent investigative work! Good job! : ) I'm (personally) very curious about all this, too, but with much less understanding than you or the devs. Still, I like following these threads to see where they lead and what I can learn. : ) |
|
|
It's hard to argue with a code change if it actually works, but just to correct/remark on a couple of things. 1. Apple used to only call drawRect for the areas that the app actually invalidated with needsDrawRect. A decade ago, plus or minus, they changed this behavior (several other applications noticed). drawRect is now only ever called with the entire NSView area. Somewhere online I found a discussion between an app developer and some reasonably technical at apple. The dev noted how ridiculous this was, and that they'd have to do invalid rect tracking in their own code - the apple person said, basically, "yep, we're not doing that for you anymore, we don't think it is necessary". So, if you invalidate 1 pixel of an NSView, you will get a drawRect callback that specifies the entire NSView as the area to be redrawn. That might be a slight exaggeration, but definitely if you invalidate two non-adjacent pixels, CG will tell us to redraw the entire NSView. 2. Hence ... we introduced our own invaldiation tracking, so that *we* do not both to redraw the whole NSView every time, since that is sometimes/often very expensive (e.g. a 1 pixel movement of the playhead, which is happening multiple times per second, requires the entire editor to be redrawn. 3. We already have an offscreen-drawing hack for macOS, where we use a Cairo image surface to draw into, and then we "blit" that as single call into the CG provided surface. It's optional (via preferences). It does appear to make a difference (it is enabled by default for Mixbus, which does much more cairo drawing for the mixer in a somewhat inefficient way). It can help Ardour on some macOS systems, but doesn't appear to fix a number of other issues with macOS drawing. 4. I've tried disabling layers in the past and found that it made no detected difference. But maybe I didn't do enough. |
|
|
One thing that occurs to me is that we could try not passing all the invalidated areas to CG, but instead just always mark the whole NSView as invalidated (as per what Apple does anyway), and then just use our own list during drawRect ........ |
|
|
If you think about the way the layer thing works, forcing a redraw of the entire NSView every time makes sense. Because you aren't actually *drawing* anything, you're just building up a display list, and you don't want to segment the view into more and more subregions if the invalidated portion changes. Then, the actual rendering probably *does* have the ability to (at least in some cases) only actually render/composite the invalidated areas. Of course, that only works if you are only doing a reasonable number of CoreGraphics calls and there isn't much going on otherwise in the drawing code. In the profile traces I saw, the actual time Ardour spent "drawing" (building up display lists) was minuscule. So it does seem to be fairly efficient (as long as you're not drawing pixels directly, and all the operations flow to CG), but you're supposed to actually "draw" the entire thing or else this pathological problem in the backend happens. For example, the meters code always does its own dirty tracking and never does a full draw as far as I can tell, so it doesn't matter what you do with the dirty rects at the other layers, you still have the problem. use_intermediate_surface unfortunately doesn't seem to actually do anything, I did try it. As far as I can tell, that is used only for the editor pane, not the mixer pane. I didn't try "disabling" layers, I'm not even sure how you'd do that (I failed to find any official documented way to do it). Just set layer to NULL? But that seems like a recipe for trouble when the OS decided to set up a CALayer for you. I couldn't even find any formal documentation mentioning that layers are unconditional now, I only saw that mentioned in some WWDC presentation transcript (Apple docs, terrible as always). I don't think telling CG that the whole view was invalidated is going to help. As long as you're still drawing piecemeal, CG is not going to be able to flatten out the draw list in any meaningful way. The entire CALayer mechanism is just fundamentally incompatible with what cairo/Ardour are doing, I think... |
|
|
This is the WWDC link btw: https://developer.apple.com/videos/play/wwdc2018/209/ |
|
|
My understanding is that wantsLayer controls how CG calls back into the app when there is drawing to be done. Without wantsLayer, you get drawRect calls; with it set to true, you get calls to updateLayer. Note that the default value for wantsLayer is still (in 2026) false. You're correct that we don't use the intermediate image surface universally, only for the editor canvas (iin Mixbus it is more widely used). My idea was that the draw list that CG knows about would only ever consider of a single entry. Another detail I've noted while debugging related matters is that our drawRect call can complete without any change in the pixels visible on-screen. This does seem as if it could be caused by layers, but again, wantsLayer is false by default. |
|
|
Even with wantsLayer=false, you get a layer. This seems to be the change made back in 2018. I checked and it's false and yet there is a layer set. |
|
|
The WWDC talk encourages apps to just continue using drawRect() and not work at the layer level unless really necessary., |
|
|
right the talk says "we'll just give you a layer whether you ask for one or not, but then you can just keep doing everything the same way unless you *really* need to access the layer API to get stuff done" |
|
|
BTW, when I say "draw list", that's not the dirty rects. I mean *every single CGContext draw call*. They all get put into a DisplayList, see first attached screenshot. This part of the code doesn't have much CPU cost per frame. Where all the time is spent is later in the CoreAnimation->CoreGraphics call stack (called from the run loop, Ardour/GTK is nowhere to be seen in the call stack until you get up to ydk's poll_func and the top level event iterator). I don't know exactly how the overdraw/redraw tracking works, but I think what's happening is that it is somehow tracking the entire draw command stream/display list for each segment of the screen (unclear how it splits them in 2D as dirty rects change...), not just from the past frame but previous frames since each frame only redraws a portion of the view. So as "random" parts of the screen that don't correspond to coarse/high-level widgets get dirted and redrawn, the complexity of the layer's draw list explodes, especially since Ardour is doing lots of little draws via cairo which all wind up on CoreGraphics. > One thing that occurs to me is that we could try not passing all the invalidated areas to CG, but instead just always mark the whole NSView as invalidated (as per what Apple does anyway), and then just use our own list during drawRect ........ If what you mean is only drawing *parts* of the rect you got from NSView, I don't think that's going to work. My understanding is that the background is cleared for you (I saw some other people run into this). You have to draw everything. And this makes sense, because logically you are clobbering the draw list for that rect, so it better really draw everything, you can't rely on old pixels. Essentially, there's no back buffer, no partial draws/dirty-only rects any more. When you use layers like this, CA expects to have, at all times, a complete scene/draw graph for the entire window (because it's potentially going to traverse it all to draw the entire window during compositing). This does make sense in a "modern" graphics stack too, because it allows for things like double/triple-buffering which don't really work with naive dirty rect tracking, since you're drawing onto a buffer that will have contents from N frames in the past, not just the previous frame. So, if you want to do the "dirty rects" thing, you're going to have to keep your own backing store I think, and then just figure out how to pull that in the most efficient way into the NSView (probably GPU, whether the high level APIs will do that for you in some cases or you'll have to drop to OpenGL, I don't know). The fine-grained dirty rect redraws drawing model is essentially obsolete in modern macOS... And this also makes perfect sense, because a zillion little draws is a waste of time on GPUs, you spend all your time encoding draw commands. GPUs would rather blast through a ton of pixels all at once than deal with individual draw commands each of which only draw a handful of pixels. The whole "draw as few pixels as possible" model made sense back with CPU rasterization, but if a GPU is involved, the threshold where you'd want to split vs not split draws for efficiently gets much higher. It still helps to not draw the entire view when you don't have to (because doing that at least costs a lot of memory bandwidth), but only to a point. |
|
|
Looks like the files didn't upload? The tracker is very slow from my location, maybe networking trouble? Uploaded here: https://cloud.hoshinolina.net/s/83rJPEi4N3fnsot |
|
|
i am still unclear on whether or not the GPU is involved with cairo drawing on macOS. since they ultimately call various CG functions, and the CG functions presumably are supposed to be usable by anything on anything, it seems likely that they ultimately end up on the GPU. my sense is that on X Window, this is not true and that using cairo ends up using the CPU, but i am not sure about that either. |
|
|
I don't see anything that looks like CPU *nor* GPU drawing in the flame graph. I suspect what it does is ultimately push a flattened draw queue/list to the compositor, which then draws it (presumably on the GPU?). Either that or it's CPU, but it's insignificant compared to all the overhead in just tracking all the operations... |
| Date Modified | Username | Field | Change |
|---|---|---|---|
| 2026-04-22 03:47 | bsj | New Issue | |
| 2026-04-22 03:47 | bsj | Tag Attached: GUI | |
| 2026-04-22 03:47 | bsj | Tag Attached: lag | |
| 2026-04-22 03:47 | bsj | Tag Attached: slowdowns | |
| 2026-04-22 03:47 | bsj | Tag Attached: sluggish | |
| 2026-05-02 16:35 | paul | Note Added: 0030314 | |
| 2026-07-28 07:14 | lina | Note Added: 0030611 | |
| 2026-07-28 07:33 | GhostsonAcid | Note Added: 0030612 | |
| 2026-07-28 07:56 | lina | Note Added: 0030613 | |
| 2026-07-28 10:14 | lina | Note Added: 0030614 | |
| 2026-07-28 11:07 | GhostsonAcid | Note Added: 0030615 | |
| 2026-07-28 13:06 | paul | Note Added: 0030616 | |
| 2026-07-28 13:12 | paul | Note Added: 0030617 | |
| 2026-07-28 14:06 | lina | Note Added: 0030618 | |
| 2026-07-28 14:08 | lina | Note Added: 0030619 | |
| 2026-07-28 14:13 | paul | Note Added: 0030620 | |
| 2026-07-28 14:24 | lina | Note Added: 0030621 | |
| 2026-07-28 14:37 | paul | Note Added: 0030622 | |
| 2026-07-28 14:46 | paul | Note Added: 0030623 | |
| 2026-07-28 15:17 | lina | Note Added: 0030624 | |
| 2026-07-28 15:26 | lina | Note Added: 0030625 | |
| 2026-07-28 15:28 | paul | Note Added: 0030626 | |
| 2026-07-28 15:41 | lina | Note Added: 0030627 |