obs-studio icon indicating copy to clipboard operation
obs-studio copied to clipboard

Qt GUI burns most of a CPU core repeatedly decoding icons

Open nyanpasu64 opened this issue 2 months ago • 16 comments

Operating System Info

Other

Other OS

Arch Linux

OBS Studio Version

32.0.0

OBS Studio Version (Other)

32.0.1

OBS Studio Log URL

https://obsproject.com/logs/ptpJ2CITAoHKvmns

OBS Studio Crash Log URL

No response

Expected Behavior

OBS Studio does not burn a CPU core decoding icons.

Current Behavior

OBS Studio burns most of a CPU core. Samply profiler shows most of it is spent in QStyleSheetStyle::drawControl(QStyle::ControlElement, QStyleOption const*, QPainter*, QWidget const*) const ... QFusionStyle::standardIcon(QStyle::StandardPixmap, QStyleOption const*, QWidget const*) const ... QFusionStyle::iconFromTheme(QStyle::StandardPixmap) const ... QResourceFileEngine::open(QFlags<QIODeviceBase::OpenModeFlag>, std::optional<QFlags<QFileDevice::Permission> >).

Profiler screenshot: Image

Manual sampling by hitting Ctrl+C in gdb replicated these results; OBS used 60% of my CPU, and two out of four Ctrl+C backtraces were in QFusionStyle::iconFromTheme.

Example backtrace:

(gdb) bt
#0  simdSwapLoop<unsigned short> (src=0x7fffdda75626 <qt_resource_name+6> "", bytes=28, dst=0x555558665750 "q") at /usr/src/debug/qt6-base/qtbase/src/corelib/global/qendian.cpp:825
#1  bswapLoop<unsigned short> (src=0x7fffdda75626 <qt_resource_name+6> "", n=28, dst=0x555558665750 "q") at /usr/src/debug/qt6-base/qtbase/src/corelib/global/qendian.cpp:852
#2  qbswap<2> (source=0x7fffdda75626 <qt_resource_name+6>, n=<optimized out>, dest=0x555558665750) at /usr/src/debug/qt6-base/qtbase/src/corelib/global/qendian.cpp:865
#3  0x00007ffff2d3f941 in qFromBigEndian<char16_t> (source=<optimized out>, count=14, dest=<optimized out>) at /usr/src/debug/qt6-base/qtbase/src/corelib/global/qendian.h:213
#4  (anonymous namespace)::QResourceRoot::name (this=<optimized out>, node=1) at /usr/src/debug/qt6-base/qtbase/src/corelib/io/qresource.cpp:788
#5  (anonymous namespace)::QResourceRoot::findNode (this=this@entry=0x555555adadd0, _path=..., locale=...) at /usr/src/debug/qt6-base/qtbase/src/corelib/io/qresource.cpp:858
#6  0x00007ffff2d422b9 in QResourcePrivate::load (this=this@entry=0x555557dfdea0, file=...) at /usr/src/debug/qt6-base/qtbase/src/corelib/io/qresource.cpp:339
#7  0x00007ffff2d42b1b in QResourcePrivate::ensureInitialized (this=this@entry=0x555557dfdea0) at /usr/src/debug/qt6-base/qtbase/src/corelib/io/qresource.cpp:389
#8  0x00007ffff2d42f57 in QResource::fileName (this=0x555557dfd0d8) at /usr/src/debug/qt6-base/qtbase/src/corelib/io/qresource.cpp:574
#9  QResourceFileEngine::open (this=<optimized out>, flags=..., permissions=std::optional [no contained value]) at /usr/src/debug/qt6-base/qtbase/src/corelib/io/qresource.cpp:1441
#10 0x00007ffff2d2121f in QFile::open (this=0x555557b7e000, mode=...) at /usr/src/debug/qt6-base/qtbase/src/corelib/global/qflags.h:77
#11 0x00007ffff355b3c2 in QImageReaderPrivate::initHandler (this=0x555557df9570) at /usr/src/debug/qt6-base/qtbase/src/gui/image/qimagereader.cpp:513
#12 0x00007ffff355b6f9 in QImageReader::supportsOption (this=0x7fffffffae00, option=QImageIOHandler::ScaledSize) at /usr/src/debug/qt6-base/qtbase/src/gui/image/qimagereader.cpp:1410
#13 0x00007ffff35145a2 in (anonymous namespace)::ImageReader::ImageReader (this=0x7fffffffae00, fileName=..., size=...) at /usr/src/debug/qt6-base/qtbase/src/gui/image/qicon.cpp:44
#14 QPixmapIconEngine::addFile (this=0x555556112970, fileName=<optimized out>, size=..., mode=QIcon::Normal, state=QIcon::Off) at /usr/src/debug/qt6-base/qtbase/src/gui/image/qicon.cpp:460
#15 0x00007ffff35153b6 in QIcon::addFile (this=this@entry=0x7fffffffb208, fileName=..., size=..., mode=mode@entry=QIcon::Normal, state=state@entry=QIcon::Off) at /usr/src/debug/qt6-base/qtbase/src/gui/image/qicon.cpp:1195
#16 0x00007ffff40384c3 in operator() (prefix=..., icon=..., __closure=<optimized out>) at /usr/src/debug/qt6-base/qtbase/src/widgets/styles/qfusionstyle.cpp:3509
#17 0x00007ffff4038e0c in QFusionStyle::iconFromTheme (this=this@entry=0x555555cb8ec0, standardIcon=standardIcon@entry=QStyle::SP_TitleBarNormalButton) at /usr/src/debug/qt6-base/qtbase/src/widgets/styles/qfusionstyle.cpp:3514
#18 0x00007ffff4038e69 in QFusionStyle::standardIcon (this=0x555555cb8ec0, standardIcon=QStyle::SP_TitleBarNormalButton, option=0x7fffffffbcf0, widget=0x55555611c7b0) at /usr/src/debug/qt6-base/qtbase/src/widgets/styles/qfusionstyle.cpp:3538
#19 0x00007ffff3fb2711 in QProxyStyle::standardIcon (this=<optimized out>, standardIcon=QStyle::SP_TitleBarNormalButton, option=0x7fffffffbcf0, widget=0x55555611c7b0) at /usr/src/debug/qt6-base/qtbase/src/widgets/styles/qproxystyle.cpp:380
#20 0x00007ffff3f8c27d in QCommonStyle::subElementRect (this=<optimized out>, sr=<optimized out>, opt=0x7fffffffbcf0, widget=0x55555611c7b0) at /usr/src/debug/qt6-base/qtbase/src/widgets/styles/qcommonstyle.cpp:3074
#21 0x00007ffff4038145 in QFusionStyle::subElementRect (this=<optimized out>, sr=QStyle::SE_DockWidgetTitleBarText, opt=0x7fffffffbcf0, w=<optimized out>) at /usr/src/debug/qt6-base/qtbase/src/widgets/styles/qfusionstyle.cpp:3467
#22 0x00007ffff400855b in QStyleSheetStyle::subElementRect (this=0x555555ce9b60, se=QStyle::SE_DockWidgetTitleBarText, opt=0x7fffffffbcf0, w=0x55555611c7b0) at /usr/src/debug/qt6-base/qtbase/src/widgets/styles/qstylesheetstyle.cpp:6402
#23 0x00007ffff3ffb3c0 in QStyleSheetStyle::drawControl (this=0x555555ce9b60, ce=<optimized out>, opt=0x7fffffffbcf0, p=0x7fffffffbcd0, w=0x55555611c7b0) at /usr/src/debug/qt6-base/qtbase/src/widgets/styles/qstylesheetstyle.cpp:4442
#24 0x00007ffff409758e in QStylePainter::drawControl (this=0x7fffffffbcd0, ce=QStyle::CE_DockWidgetTitle, opt=...) at /usr/src/debug/qt6-base/qtbase/src/widgets/styles/qstylepainter.h:51
#25 QDockWidget::paintEvent (this=<optimized out>, event=<optimized out>) at /usr/src/debug/qt6-base/qtbase/src/widgets/widgets/qdockwidget.cpp:1633
#26 0x00007ffff3f5d6f3 in QWidget::event (this=0x55555611c7b0, event=0x7fffffffbf40) at /usr/src/debug/qt6-base/qtbase/src/widgets/kernel/qwidget.cpp:9134
#27 0x00007ffff3f020a0 in QApplicationPrivate::notify_helper (this=<optimized out>, receiver=0x55555611c7b0, e=0x7fffffffbf40) at /usr/src/debug/qt6-base/qtbase/src/widgets/kernel/qapplication.cpp:3307
#28 0x00007ffff2d6a6c8 in QCoreApplication::notifyInternal2 (receiver=0x55555611c7b0, event=0x7fffffffbf40) at /usr/src/debug/qt6-base/qtbase/src/corelib/kernel/qcoreapplication.cpp:1109
#29 0x00007ffff2d6a71d in QCoreApplication::sendSpontaneousEvent (receiver=<optimized out>, event=<optimized out>) at /usr/src/debug/qt6-base/qtbase/src/corelib/kernel/qcoreapplication.cpp:1563
#30 0x00007ffff3f4e96e in QWidgetPrivate::sendPaintEvent (this=this@entry=0x5555561c6380, toBePainted=...) at /usr/src/debug/qt6-base/qtbase/src/widgets/kernel/qwidget.cpp:5661
#31 0x00007ffff3f50c27 in QWidgetPrivate::drawWidget (this=this@entry=0x5555561c6380, pdev=pdev@entry=0x555557df9428, rgn=..., offset=..., flags=flags@entry=..., sharedPainter=sharedPainter@entry=0x0, repaintManager=<optimized out>) at /usr/src/debug/qt6-base/qtbase/src/widgets/kernel/qwidget.cpp:5611
#32 0x00007ffff3f53a64 in QWidgetPrivate::paintSiblingsRecursive (this=this@entry=0x555555c6ee10, pdev=pdev@entry=0x555557df9428, siblings=<optimized out>, index=<optimized out>, rgn=..., offset=..., flags=..., sharedPainter=0x0, repaintManager=0x555558857670) at /usr/src/debug/qt6-base/qtbase/src/widgets/kernel/qwidget.cpp:5790
#33 0x00007ffff3f51185 in QWidgetPrivate::drawWidget (this=0x555555c6ee10, pdev=0x555557df9428, rgn=<optimized out>, offset=<optimized out>, flags=..., sharedPainter=<optimized out>, repaintManager=<optimized out>) at /usr/src/debug/qt6-base/qtbase/src/widgets/kernel/qwidget.cpp:5652
#34 0x00007ffff3f75929 in QWidgetRepaintManager::paintAndFlush (this=0x555558857670) at /usr/src/debug/qt6-base/qtbase/src/widgets/kernel/qwidgetrepaintmanager.cpp:907
#35 0x00007ffff3f5d0f6 in QWidget::event (this=0x555555f04720, event=0x555555d326e0) at /usr/src/debug/qt6-base/qtbase/src/widgets/kernel/qwidget.cpp:9298
#36 0x00007ffff3f020a0 in QApplicationPrivate::notify_helper (this=<optimized out>, receiver=0x555555f04720, e=0x555555d326e0) at /usr/src/debug/qt6-base/qtbase/src/widgets/kernel/qapplication.cpp:3307
#37 0x00007ffff2d6a6c8 in QCoreApplication::notifyInternal2 (receiver=0x555555f04720, event=event@entry=0x555555d326e0) at /usr/src/debug/qt6-base/qtbase/src/corelib/kernel/qcoreapplication.cpp:1109
#38 0x00007ffff2d6aab2 in QCoreApplication::sendEvent (receiver=<optimized out>, event=0x555555d326e0) at /usr/src/debug/qt6-base/qtbase/src/corelib/kernel/qcoreapplication.cpp:1549
#39 QCoreApplicationPrivate::sendPostedEvents (receiver=0x0, event_type=0, data=0x555555aa5c20) at /usr/src/debug/qt6-base/qtbase/src/corelib/kernel/qcoreapplication.cpp:1904
#40 0x00007ffff304db18 in QCoreApplication::sendPostedEvents (receiver=0x0, event_type=0) at /usr/src/debug/qt6-base/qtbase/src/corelib/kernel/qcoreapplication.cpp:1757
#41 postEventSourceDispatch (s=0x555555aa5eb0) at /usr/src/debug/qt6-base/qtbase/src/corelib/kernel/qeventdispatcher_glib.cpp:246
#42 0x00007ffff1106f8d in g_main_dispatch (context=0x7fffd8000f60) at ../glib/glib/gmain.c:3565
#43 0x00007ffff1108657 in g_main_context_dispatch_unlocked (context=0x7fffd8000f60) at ../glib/glib/gmain.c:4425
#44 g_main_context_iterate_unlocked (context=context@entry=0x7fffd8000f60, block=block@entry=1, dispatch=dispatch@entry=1, self=<optimized out>) at ../glib/glib/gmain.c:4490
#45 0x00007ffff1108865 in g_main_context_iteration (context=0x7fffd8000f60, may_block=1) at ../glib/glib/gmain.c:4556
#46 0x00007ffff304a9d2 in QEventDispatcherGlib::processEvents (this=0x555555aa0c30, flags=...) at /usr/src/debug/qt6-base/qtbase/src/corelib/kernel/qeventdispatcher_glib.cpp:399
#47 0x00007ffff2d75a86 in QEventLoop::processEvents (this=0x7fffffffcb70, flags=...) at /usr/src/debug/qt6-base/qtbase/src/corelib/kernel/qeventloop.cpp:104
#48 QEventLoop::exec (this=0x7fffffffcb70, flags=...) at /usr/src/debug/qt6-base/qtbase/src/corelib/kernel/qeventloop.cpp:186
#49 0x00007ffff2d6f171 in QCoreApplication::exec () at /usr/src/debug/qt6-base/qtbase/src/corelib/kernel/qcoreapplication.cpp:1452
#50 0x00007ffff3efd31a in QApplication::exec () at /usr/src/debug/qt6-base/qtbase/src/widgets/kernel/qapplication.cpp:2574
#51 0x000055555583d173 in run_program (logFile=..., argc=<optimized out>, argc@entry=1, argv=argv@entry=0x7fffffffda38) at /usr/src/debug/obs-studio/obs-studio-32.0.1-sources/frontend/obs-main.cpp:690
#52 0x0000555555605649 in main (argc=1, argv=0x7fffffffda38) at /usr/src/debug/obs-studio/obs-studio-32.0.1-sources/frontend/obs-main.cpp:1035

Steps to Reproduce

  1. Launch OBS.
  2. (optional) Hide the preview.

Remarkably, even with preview shown, main thread CPU usage is dominated by repeatedly redrawing icons and trying to locate image decoders, rather than displaying video! (I enabled preview and ran a new profiling session, with results indistinguishable from my previous screenshot.)

Anything else we should know?

No response

nyanpasu64 avatar Oct 24 '25 07:10 nyanpasu64

If you disable the Audio Mixer dock via the Docks menu, does the issue go away? We do know rendering of the audio meters is currently quite inefficient, and that QWidgets aren't hardware accelerated.

WizardCM avatar Oct 24 '25 12:10 WizardCM

The CPU usage appears to no longer happen.

I did notice the CPU burn was not in the audio mixer rendering (drawing rectangles? QPainter?), but rather icon decoding. Perhaps only the meters should be redrawn and not the mixer itself? Or throttle the repaint loop to 30 times a second or so? Though that wouldn't fix CPU usage...

I tried estimating the rate of function calls in gdb:

gdb obs
r (wait for program to show GUI) ^C
b QFusionStyle::iconFromTheme
ignore 1 1000
c

where I'd let the program start up, set a breakpoint in QFusionStyle::iconFromTheme, then run a stopwatch while waiting for the program to request 1000 icons. I had worried this would noticeably slow down the program (since I'd had slowdown in KDE apps even with breakpoints disabled), but OBS Studio seemed to be using the same 60-ish percent of CPU time.

  • While actively interacting with the UI it took around 12 seconds, and 1000/12 = 83.3 Hz. If I left the UI idling in the background, it took 16.3 seconds (from stopwach), and 1000/16.3 = 61.3 Hz (within rounding error of 60 Hz). I suspect you're trying to decode an icon every video frame (or two icons at 30 Hz).
  • There is the possibility gdb slowed the program down and made each function call more expensive, meaning icons were decoded at higher than 60 Hz. I don't know how to check this other than recompiling the program or running under valgrind callgrind to count function calls in KCachegrind (which definitely slows down the program massively).

Looking at the backtrace and layout asm, it seemed QWidget::event() was invoking syncBackingStore(). In the Qt source code this corresponds to QEvent::UpdateRequest.

  • I tried and failed to grep the source code for a connection to update(), but rg --multiline 'connect\(.*(\n.*)?::[uU]pdate' turned up only false positives.
  • Instead I used GammaRay to check for timers running at around 60 Hz, which revealed VolumeMeterTimer at interval 16 ms. The source code shows you override QTimer::timerEvent instead of connecting a QTimer to update(), which explains why I couldn't find it in the code.

VolumeMeterTimer::timerEvent already tries to only update the bars instead of the widget. I can't add logging because the code won't build (CMake can't find target Qt::GuiPrivate).

  • EDIT: https://old.reddit.com/r/QtFramework/comments/1o42h63/qtguiprivate_target_not_found/ "Oh, so the official instructions are wrong." 🤦
    • It seems #12328 is trying to address this?
  • If I breakpoint the method in gdb, stepping shows that needLayoutChange is false and execution does proceed into "Tell paintEvent to paint only the bars", meter->update(meter->getBarRect());.

It seems when you ask a VolumeMeter to repaint a rectangle, at least one looks up icons anyway...

  • I have 2 meters and p volumeMeters has size 2, but QFusionStyle::iconFromTheme is only called once every 16 ms, dunno if they're coalesced.

EDIT: Now if we called update() on VolumeMeter, why is QDockWidget being repainted?

nyanpasu64 avatar Oct 25 '25 00:10 nyanpasu64

Who's being redrawn?

Attaching GammaRay to OBS started crashing again (https://github.com/KDAB/GammaRay/issues/1093). I was able to perform the following:

gammaray obs
(new tab) gdb -p (pgrep obs)

A stack trace I got is:

#19 0x00007f854fa9758e in QStylePainter::drawControl (this=0x7ffcb8fdd9b0, ce=QStyle::CE_DockWidgetTitle, opt=...) at /usr/src/debug/qt6-base/qtbase/src/widgets/styles/qstylepainter.h:51
#20 QDockWidget::paintEvent (this=<optimized out>, event=<optimized out>) at /usr/src/debug/qt6-base/qtbase/src/widgets/widgets/qdockwidget.cpp:1633
#21 0x00007f854f95d6f3 in QWidget::event (this=0x55f861df5300, event=0x7ffcb8fddc20) at /usr/src/debug/qt6-base/qtbase/src/widgets/kernel/qwidget.cpp:9134
#22 0x00007f854f9020a0 in QApplicationPrivate::notify_helper (this=<optimized out>, receiver=0x55f861df5300, e=0x7ffcb8fddc20) at /usr/src/debug/qt6-base/qtbase/src/widgets/kernel/qapplication.cpp:3307
#23 0x00007f854e76a6c8 in QCoreApplication::notifyInternal2 (receiver=0x55f861df5300, event=0x7ffcb8fddc20) at /usr/src/debug/qt6-base/qtbase/src/corelib/kernel/qcoreapplication.cpp:1109
#24 0x00007f854e76a71d in QCoreApplication::sendSpontaneousEvent (receiver=<optimized out>, event=<optimized out>) at /usr/src/debug/qt6-base/qtbase/src/corelib/kernel/qcoreapplication.cpp:1563
#25 0x00007f854f94e96e in QWidgetPrivate::sendPaintEvent (this=this@entry=0x55f861deb480, toBePainted=...) at /usr/src/debug/qt6-base/qtbase/src/widgets/kernel/qwidget.cpp:5661
#26 0x00007f854f950c27 in QWidgetPrivate::drawWidget (this=this@entry=0x55f861deb480, pdev=pdev@entry=0x55f864f7bb58, rgn=..., offset=..., flags=flags@entry=..., sharedPainter=sharedPainter@entry=0x0, repaintManager=<optimized out>) at /usr/src/debug/qt6-base/qtbase/src/widgets/kernel/qwidget.cpp:5611
#27 0x00007f854f953a64 in QWidgetPrivate::paintSiblingsRecursive (this=this@entry=0x55f861aefff0, pdev=pdev@entry=0x55f864f7bb58, siblings=<optimized out>, index=<optimized out>, rgn=..., offset=..., flags=..., sharedPainter=0x0, repaintManager=0x55f862021020) at /usr/src/debug/qt6-base/qtbase/src/widgets/kernel/qwidget.cpp:5790
#28 0x00007f854f951185 in QWidgetPrivate::drawWidget (this=0x55f861aefff0, pdev=0x55f864f7bb58, rgn=<optimized out>, offset=<optimized out>, flags=..., sharedPainter=<optimized out>, repaintManager=<optimized out>) at /usr/src/debug/qt6-base/qtbase/src/widgets/kernel/qwidget.cpp:5652
#29 0x00007f854f975929 in QWidgetRepaintManager::paintAndFlush (this=0x55f862021020) at /usr/src/debug/qt6-base/qtbase/src/widgets/kernel/qwidgetrepaintmanager.cpp:907
#30 0x00007f854f95d0f6 in QWidget::event (this=0x55f86186d320, event=0x55f8650af6e0) at /usr/src/debug/qt6-base/qtbase/src/widgets/kernel/qwidget.cpp:9298

Which widgets are being redrawn? GammaRay doesn't let you search named widgets by pointer values, so let's drag object names out of memory kicking and screaming:

(gdb) p ((QWidget*)0x55f86186d320)->objectName()
$2 = {d = {d = 0x55f8617520f0, ptr = 0x55f861752100 u"OBSBasic", size = 8}, static _empty = 0 u'\000'}

(gdb) p ((QWidgetPrivate*)0x55f861aefff0)->extraData->objectName
	... u"OBSBasic" ...

(gdb) p ((QWidgetPrivate*)0x55f861deb480)->extraData->objectName
	... u"mixerDock" ...

(gdb) p ((QWidget*)0x55f861df5300)->objectName()
$3 = {d = {d = 0x55f861e09210, ptr = 0x55f861e09220 u"mixerDock", size = 9}, static _empty = 0 u'\000'}

So VolumeMeterTimer::timerEvent()VolumeMeter::update() is (indirectly?) sending event QEvent::UpdateRequest to OBSBasic, which repaints <widget class="OBSDock" name="mixerDock">

  • It's possible the whole UI is being repainted, or that every UI element up to VolumeMeter (or every element intersecting the rectangle?) is being repainted.
  • Looking in GammaRay, mixerDock is the dock item surrounding the audio mixers being repainted. Since it's calling QStylePainter::drawControl directly (not through a child widget), I suspected the icon being loaded is the "restore" icon on the top right (since the mixers and bottom toolbar live in a child widget). But it's actually a QDockWidgetTitleButton : QAbstractButton : QWidget? IDK.

The string passed to QIcon::addFile is fusion_normalizedockup-48.png. I found a similar file at https://github.com/qt/qtbase/blob/dev/src/widgets/styles/images/fusion_normalizedockup_48.png, but the icon orientation is different, and the name has an underscore instead of hyphen.

Solutions?

  • I wonder if the problem is that one of OBSDock -> QDockWidgetTitleButton is provided a theme without the necessary icon, and Qt attempts to load the icon on every repaint. So if you supplied an icon, then repainting the OBSDock : QDockWidget wouldn't burn this much CPU?
    • I have not researched how QStyleSheetStyle::drawControl "decides" to fetch an icon of the above name. This is an avenue of future research.
  • To avoid redrawing entirely, https://www.qtcentre.org/threads/9030-Repaint-child-without-having-the-parent-automatically-repainted suggests QWidget::setAttribute(Qt::WA_OpaquePaintEvent) to paint the widget without its parents, but you have to paint the whole thing without letting the background color shine through (since you always paint on top of the previous paint).
    • The Qt docs are at https://doc.qt.io/qt-6/qwidget.html#transparency-and-double-buffering. They suggest using Qt::WA_OpaquePaintEvent or:

define a suitable background color (using setBackgroundRole() with the QPalette::Window role), set the autoFillBackground property, and only implement the necessary drawing functionality in the widget's paintEvent().

I have not tested if either code change fixes the CPU burn.

EDIT: You already call setAttribute(Qt::WA_OpaquePaintEvent, true); and it doesn't work!

nyanpasu64 avatar Oct 25 '25 01:10 nyanpasu64

The problem

I think I know what's up. OBS's custom theme (I think) uses stylesheets to customize display over the Fusion(?) theme, and QStyleSheetStyle::polish contains:

    QRenderRule rule = renderRule(w, PseudoElement_None, PseudoClass_Any);
...
    if (rule.hasDrawable() || rule.hasBox()) {
...
        if (!rule.hasBackground() || rule.background()->isTransparent() || rule.hasBox()
            || (!rule.hasNativeBorder() && !rule.border()->isOpaque()))
            w->setAttribute(Qt::WA_OpaquePaintEvent, false);

So if you want opaque paint event enabled, you need an opaque background, no box, and a native or opaque widget border. I don't know which condition fails, and don't plan to find out right now (QRenderRule is declared in an internal Qt .cpp file, not even a private header!). Alternatively you can cheat (link):

If you need to do some delayed initialization use the Polish event delivered to the event() function.

bool QWidget::event(QEvent *event)
{
    ...switch (event->type()) {
    ...case QEvent::Polish: {
        style()->polish(this);
        setAttribute(Qt::WA_WState_Polished);
        if (!QApplication::font(this).isCopyOf(QApplication::font()))
            d->resolveFont();
        if (!QApplication::palette(this).isCopyOf(QGuiApplication::palette()))
            d->resolvePalette();
    }
        break;
	}
...
    return true;

So we'd have to override QWidget::event(), call the parent function, then in case of QEvent::Polish ensure our widget is opaque.


How did I find QStyleSheetStyle::polish was turning off opacity? I tried running OBS under gdb:

tb VolumeMeter::VolumeMeter
r  (until break)
Thread 1 "obs" hit Breakpoint 1, VolumeMeter::VolumeMeter(QWidget*, obs_volmeter*, bool) [clone .constprop.0] (this=0x5555574117c0, obs_volmeter=0x555558639d60, vertical=false, parent=0x0) at /usr/src/debug/obs-studio/obs-studio-32.0.1-sources/frontend/widgets/VolumeMeter.cpp:386

- VolumeMeter = 0x5555574117c0

(gdb) n...
391             setAttribute(Qt::WA_OpaquePaintEvent, true);
(repeatedly type next until GDB advances to the next line instead of going backwards)
(gdb) p data->widget_attributes & (1<<Qt::WA_OpaquePaintEvent)
$5 = 16
(gdb) p &data->widget_attributes
$3 = (uint *) 0x555558798398
(gdb) watch *(uint *)0x555558798398
Hardware watchpoint 2: *(uint32_t)0x555558798398
(gdb) c
Continuing.

Then repeatedly continue, and every time it changes run p (new value) & (1<<Qt::WA_OpaquePaintEvent), until QStyleSheetStyle::polish makes it output 0.

Tracing icon CPU burn

Why is Fusion loading an icon? Is QFusionStyle::standardIcon() succeeding or failing? Why doesn't the Qt resource manager cache the icon it's loaded? I don't know yet.

nyanpasu64 avatar Oct 26 '25 02:10 nyanpasu64

I've written up a quick code patch to force WA_OpaquePaintEvent:

bool VolumeMeter::event(QEvent *event)
{
	bool ret = QWidget::event(event);
	switch (event->type()) {
	case QEvent::Polish: {
		// QWidget::event() calls QStyleSheetStyle::polish() which turns off opaque.
		// Turn it back on.
		setAttribute(Qt::WA_OpaquePaintEvent, true);
	}
	default:
		break;
	}
	return ret;
}

It works (in that CPU usage is now to 50% of a core and dominated by v4l2: capture│/usr/bin/obs), but has a rendering bug:

Image

We probably need a background color.

The VolumeMeter and QFrame have a transparent background color (#00000000): Image

while the VolControl has an opaque background: Image

I don't know what causes only this widget to paint its own background. All 3 widgets have backgroundRole = QPalette::Window, and none have autoFillBackground set. Moreover, setBackgroundRole(QPalette::Window); setAutoFillBackground(true) does not change the widget's appearance, and GammaRay both before and after the change show a single transparent background FillRectColor. If I remove setAttribute(Qt::WA_OpaquePaintEvent, true); and keep the background role/autofill, the CPU burn returns.

nyanpasu64 avatar Oct 26 '25 05:10 nyanpasu64

Why is this->palette().color(this->backgroundRole()) different for different widgets, and VolumeMeter's background is transparent? I may not have the answer yet, but I know what's doing it.

I verified that VolControl does not have a stylesheet which could supply its own background color. Looking at https://doc.qt.io/qt-6/qwidget.html#palette-prop we see:

Finally, the style always has the option of polishing the palette as it's assigned (see QStyle::polish()).

Yep that's what's doing it. If I print << QWidget::backgroundRole() << "=" << palette().color(QWidget::backgroundRole()) before and after calling super::event() upon QEvent::Polish, I see the following:

warning: VolControl background color from QPalette::Window = QColor(ARGB 1, 0.192157, 0.211765, 0.231373)
warning: to QPalette::Window = QColor(ARGB 1, 0.137255, 0.14902, 0.160784)
warning: VolumeMeter background color from QPalette::Window = QColor(ARGB 1, 0.192157, 0.211765, 0.231373)
warning: to QPalette::Window = QColor(ARGB 0, 0, 0, 0)

To figure out what was clearing the background color, I added std::raise(SIGTRAP); before passing QEvent::Polish to the base class, spent hours debugging the wrong class (VolControl has background color, VolumeMeter doesn't), then found that in QStyleSheetStyle::polish(QWidget *w), baseStyle()->polish(w) (QFusionStyle) doesn't change the color, unsetPalette(w); doesn't, but setPalette(w); does.

  • [ ] I'll have to trace what this method is doing, and why it makes VolumeMeter transparent but VolControl opaque, but that's a task for tomorrow.

I wonder if we can cheat and pull the color from the OBS theme CSS; this doesn't work in an arbitrary Qt app but does in OBS (fitting for a bug that only happens with stylesheets).

EDIT: It may be worthwhile editing the theme CSS to give this widget a background color.

nyanpasu64 avatar Oct 26 '25 10:10 nyanpasu64

Hey @nyanpasu64 thank you so much for this in-depth analysis. I am going to dig through all of this data today.

While I do that though, could you try testing out #12735? (Download available in Artifacts). I actually did a bit of adjustment to the volume meter rendering which includes how the background is done and I'm curious if it solves this indirectly.

Warchamp7 avatar Oct 29 '25 17:10 Warchamp7

To figure out what was clearing the background color, I added std::raise(SIGTRAP); before passing QEvent::Polish to the base class, spent hours debugging the wrong class (VolControl has background color, VolumeMeter doesn't), then found that in QStyleSheetStyle::polish(QWidget *w), baseStyle()->polish(w) (QFusionStyle) doesn't change the color, unsetPalette(w); doesn't, but setPalette(w); does.

The background color is being cleared by our stylesheet. We currently have background: transparent in the VolumeMeter {} style rule.

Warchamp7 avatar Oct 29 '25 18:10 Warchamp7

I do not get CPU burn on Windows. I don't know if VolumeMeter has Qt::WA_OpaquePaintEvent on Windows, or it's cleared but the parent dock widget doesn't burn CPU time loading icons at 60 Hz. I tried to find out but was unable to make it work.

There is no prebuilt GammaRay on Windows. I tried building OBS Studio master on Windows by following https://github.com/obsproject/obs-studio/wiki/build-instructions-for-windows, but CMake first required I install the older SDK 10.0.22621.0, then cmake --preset windows-x64 took 3 runs to succeed (the first couldn't find Detours and the second couldn't find SIMDe), and after building OBS Studio in Debug mode, pressing F5 failed to launch the program (instead popups about missing zlib.dll and zlibd1.dll).

On Linux, the Flatpak nightly build does not appear to have CPU burn (though I don't know if decoding icons still eats CPU time). I think the mainline Flatpak build had CPU burn initially (causing me to debug the issue on the Arch Linux build), so it may have been "fixed" as a side effect of the partial rewrite (though it's not yet merged).

nyanpasu64 avatar Oct 30 '25 06:10 nyanpasu64

testing out https://github.com/obsproject/obs-studio/pull/12735

I don't see any difference with this build vs 32.0.2 release flatpak (ca. 8x CPU jump with audio mixer visible).

Schlaefer avatar Oct 30 '25 08:10 Schlaefer

It seems that CMake found that scoop -> vcpkg had installed zlib, linked to its zlib stub libraries, but couldn't find its DLLs when running from Visual Studio (both Debug and RelWithDebInfo!). I've uninstalled vcpkg's zlib (along with like 8 packages including dependencies), deleted CMakeCache, rebuilt (again cmake --preset windows-x64 took three runs to succeed), and managed I am this close to uninstalling vcpkg altogether (it had broken Dolphin Emulator builds in the past).

Do you think fixing these problems with the build instructions is worth fixing?

EDIT: I have confirmed that the widgets (on master) have Qt::WA_OpaquePaintEvent set to false upon polishing. It's a bit contradictory asking VolumeMeter to be opaque, then setting its background to transparent in Qt CSS?

If I change it to:

VolumeMeter {
    background: var(--bg_base);
}

to match VolControl, then QEvent::Polish still turns off WA_OpaquePaintEvent, but if I force it on, it draws properly.

  • [ ] Will you consider upstreaming this change?

As to why it doesn't burn CPU on Windows... I can't actually identify the call stack with QDockWidget::paintEvent in the VS profiler, and even after loading pdbs from https://github.com/obsproject/obs-deps/releases/tag/2025-08-23, VS ~~can't break on QDockWidget::paintEvent~~ now it can? I can also breakpoint QFusionStyle::iconFromTheme which doesn't get hit, but given the unreliability of C++ debuggers, absence of evidence is not sufficient evidence of absence.

VolumeMeter::paintEvent is instead invoked through Qt6Widgets.dll!QWidgetRepaintManager::paintAndFlush():

    // Paint the rest with composition.
    if (repaintAllWidgets || !dirtyCopy.isEmpty()) {
        QWidgetPrivate::DrawWidgetFlags flags = QWidgetPrivate::DrawAsRoot | QWidgetPrivate::DrawRecursive
                | QWidgetPrivate::UseEffectRegionBounds;
        tlw->d_func()->drawWidget(store->paintDevice(), dirtyCopy, QPoint(), flags, nullptr, this);
    }
  • [x] On Linux, what's the call stack to VolumeMeter::paintEvent, and does it match CPU burning?

EDIT: the dock and the volume meter share QWidgetRepaintManager::paintAndFlush > QWidgetPrivate::drawWidget and nothing else I know for sure.

  • Hell, why is QFusionStyle::iconFromTheme called on Linux and is it not the case on Windows?

nyanpasu64 avatar Oct 30 '25 11:10 nyanpasu64

What's the function call chain leading up to icon loading?

  • QStyleSheetStyle::drawControl at Line 4442 is drawing a QStyle::ControlElement = CE_DockWidgetTitle.
    • We invoke QStyleSheetStyle::subElementRect with QStyle::SubElement = SE_DockWidgetTitleBarText.
  • QStyleSheetStyle::subElementRect at line 6402 delegates to the base style's QFusionStyle::subElementRect.
    • Looking at https://doc.qt.io/qt-6/qstyle.html#subElementRect, QStyleSheetStyle::subElementRect is supposed to be a pure function returning a rectangular region, not a repainting operation or decoding icons! Why is it burning CPU?
  • QFusionStyle::subElementRect at line 3467 invokes superclass QCommonStyle::subElementRect then adjusts the output.
  • QCommonStyle::subElementRect at line 3074 calls proxy()->standardIcon() (oops!) with QStyle::StandardPixmap = SP_TitleBarNormalButton.
  • QProxyStyle::standardIcon at line 380 delegates to the base style.
    • [ ] Is this called on Windows?
  • QFusionStyle::standardIcon at line 3538 calls QFusionStyle::iconFromTheme and passes along enum StandardPixmap = SP_TitleBarNormalButton.
  • QFusionStyle::iconFromThemeat line 3500-3530 builds a QIcon and adds various sizes of image from the ":/qt-project.org/styles/fusionstyle/images/" resource path.

Which of these function calls don't happen on Windows without the CPU burn? I haven't looked yet...

EDIT: OBS's Windows builds uses a prebuilt Qt 6.8.3, while OBS on Arch Linux uses 6.10.0. I don't know if Qt version differences affect whether it burns CPU loading icons.

nyanpasu64 avatar Oct 31 '25 17:10 nyanpasu64

Tracing layout and icon loading on Windows

On Windows we see a convoluted Russian nesting doll of delegation and inheritance to calculate a simple subElementRect() region... and yet it still ends up burning less CPU than failing to load icons.

Analyzing the stack trace...
  • QStyleSheetStyle::subElementRect(QStyle::SubElement = SE_DockWidgetTitleBarText) at line 6429 delegates to base QProxyStyle::subElementRect.
  • QProxyStyle::subElementRect at line ~~219~~ 218 delegates to base QWindows11Style::subElementRect.
    • This QProxyStyle is either missing on Linux, or the stack frame was missing from the backtrace.
  • QWindows11Style::subElementRect at line ~~1834~~ 1832 calls superclass QWindowsVistaStyle::subElementRect. (I didn't know Qt's Windows 11 style was a patch layer on the Vista Aero style?!)
  • QWindowsVistaStyle::subElementRect at line ~~4262~~ 4258 calls superclass QWindowsStyle::subElementRect.
  • QWindowsStyle::subElementRect at line 1774 calls superclass QCommonStyle::subElementRect before adjusting the output.
    • On Linux this is instead QFusionStyle::subElementRect, but both invoke the superclass method.
  • QCommonStyle::subElementRect at line 3102 enters if (canFloat) {, then calls proxy()->standardIcon() (oops!) with QStyle::StandardPixmap = SP_TitleBarNormalButton.

Now we're trying to calculate QStyle::standardIcon()...

  • QProxyStyle::standardIcon at line 380 delegates to base QWindowsVistaStyle::standardIcon.
  • QWindowsVistaStyle::standardIcon at line 4949 tests if d->dockFloat.isNull() (it's not), tests if widget && widget->isWindow() (it's not), then calls QWindowsStyle::standardIcon.
    • We skip altogether Linux's call to QFusionStyle::iconFromTheme, which was responsible for burning CPU on icon loading.
    • So we already know why Windows doesn't burn CPU loading icons... but let's keep digging out of curiosity.
  • QWindowsStyle::standardIcon at line 2292 calls superclass QCommonStyle::standardIcon. (Why is this function even present? Did it once do work or was planned to, and it's there for a stable ABI?)
  • QCommonStyle::standardIcon at line 6134 calls d->iconFromWindowsTheme() (null icon), calls d->iconFromApplicationTheme() (bails out because no theme name), d->iconFromMacTheme() (we're on Windows), then d->iconFromResourceTheme().

  • QCommonStylePrivate::iconFromResourceTheme calls addIconFiles(u"normalizedockup-", dockTitleIconSizes, icon).
  • template <typename T> static void addIconFiles() loops over QIcon::addFile() with arguments ":/qt-project.org/styles/commonstyle/images/normalizedockup-%d.png". It loads images of sizes dockTitleIconSizes = {10, 16, 20, 32, 48, 64}.
    • Unlike "fusion_normalizedockup-...", these images are all present with the correct hyphen separator (resources, file tree).

Why does addIconFiles() not burn CPU the way that QFusionStyle::iconFromTheme() does? Comparing the Windows call stack to Linux...

  • QIcon::addFile at line 1173 finds that alreadyAdded is false, and invokes QPixmapIconEngine::addFile.
  • QPixmapIconEngine::addFile initializes an ImageReader(fileName)QImageReader...
  • On Windows (OBS uses Qt 6.8.3), QImageReader::supportsOption is invoked from QPixmapIconEngine::bestMatchImageReader::supportsReadSize().
    • On Arch Linux (Qt 6.10.0), ImageReader::ImageReader line 44 calls QImageReader::supportsOption earlier in execution. This call was introduced in Qt 6.10.
  • In any case, Windows still invokes QImageReaderPrivate::initHandler, where Linux burns CPU at line 513. Windows never actually reaches the corresponding line 537. We do reach line 510 which invokes device->open(QIODevice::ReadOnly).
  • QFile::open at line 926 invokes QResourceFileEngine::open.
    • QResourceFileEngine implements QAbstractFileEngine, which effectively implements a file descriptor.
  • QResourceFileEngine::open at line 1431+ owns a single QResource (docs, it's basically a virtual file/folder inode).
  • Every operation on a QResource invokes QResourcePrivate::ensureInitialized.
    • Upon the initial call to QResourcePrivate::ensureInitialized, this->data is nullptr,
    • Subsequently, this->data (aka QResourceFileEngine.d.resource.d_ptr.d.data) holds an image starting with the ‰PNG header. (VS is interpreting binary data as Windows-1252.)
  • QResourcePrivate::ensureInitialized at line 392 does an unconst cast of this and invokes QResourcePrivate::load.
    • If QResourcePrivate::load succeeds, it sets related.append(res);. Subsequent calls to QResourcePrivate::ensureInitialized() will see if (!related.isEmpty()) and return immediately. ^ynybth
  • QResourcePrivate::load at line 338 iterates over resourceList() and invokes QResourceRoot::findNode on each QResourceRoot. This still matches the slow path from the Linux profiler.
    • On Windows, findNode succeeds on i=1 (the second resource root searched). (Somehow both roots have empty tree/names/payloads in the VS profiler.)

After spending some time familiarizing myself with the Visual Studio profiler, I recorded a 10 second OBS trace to better pick up rare function calls. QIcon::addFile() does eat about 5.89% of total used CPU time, though only 1.79% of used CPU time is in QResourcePrivate::ensureInitialized, and 1.39% of total CPU time is in subcall QResourceRoot::findNode.

  • This indicates that QStyle::subElementRect(SE_DockWidgetTitleBarText) is just expensive in general; even on Windows, a "good" platform with correct icon names, comparing QStyleSheetStyle::subElementRect to QWidgetPrivate::drawWidget() shows 300/1066 of CPU time spent drawing widgets is spent processing dock widget icons alone!
  • I've attached a profiler trace: Report20251101-0025.diagsession.zip. You'll need the Qt symbols from https://github.com/obsproject/obs-deps/releases/tag/2025-08-23. After opening this file in Visual Studio, in the .diagsession tab click "⚙️ Settings" and uncheck "Show Just My Code".
    • This is a new trace I made after debugging, so the numbers may not match. Irritatingly, in VS you can only save profiler traces made using Alt+F2, not the debugger's sidebar.

Causes and solutions for CPU burn on Linux

I think that loading icons while doing widget drawing is slow in general, and Qt shouldn't do it, but because Fusion has incorrect icon names, they fail to load and every QFile operation tries to look up the file again, wasting CPU in QResourceRoot::findNode. Evidently Qt doesn't detect the missing file and give up on loading icons. This is not a problem on Windows because the files are present, so QResourceRoot::findNode only gets called once per file (see ynybth).

  • This theory explains my observations, but assumes that hyphens and underscores are not interchangeable in Qt resource files (I did not verify).
  • [ ] We should report the bug to Qt, so they can check if fixing the icon names fixes CPU usage (while making sure icon rendering is still acceptable afterwards). I don't want to rebuild all of Qt on my own computer...
  • [ ] Could Qt change their code to not construct QIcon then spend additional time in QIcon::actualSize() just to find out how much space the icon takes away from the title bar text?
    • The actual title bar button is constructed in QDockWidgetPrivate::updateButtons() line 689-691.
    • Looking at the callers to QProxyStyle::standardIcon, it's exclusively QStyle::subElementRect(SE_DockWidgetTitleBarText) layout calculations. Icon loading from QDockWidgetPrivate::updateButtons() doesn't even show in the callers, since it's called upfront before I profiled steady-state CPU usage.
    • To make this change, Qt could add a virtual QDockWidgetLayout item for title bar text, make the layout compute the text rectangle, then QCommonStyle::subElementRect() would look it up. Alternatively subElementRect() could get a reference to the dwLayout->widgetForRole(QDockWidgetLayout::FloatButton) created in QDockWidgetPrivate::updateButtons().
      • This change is simple, but fully eliminating icon lookup during painting is a bigger task, since qcommonstyle.cpp has 19 occurrences of string proxy()->standardIcon!
    • The actual icon OBS uses for "pop out docked panel" doesn't even match either Qt's normalizedockup or fusion_normalizedockup resources!
  • [ ] Could Qt change QFile::open() and callees to not burn CPU time repeatedly looking up a missing resource, but just remember it's missing until a new resource root is added?
  • [ ] IMO you still should give VolumeMeter an opaque background: var(--bg_base) and force it as Qt::WA_OpaquePaintEvent; drawing parent widgets at 60 Hz is wasteful.
  • Does anyone use OBS's ability to undock panels?

Why are some Fusion icons named with underscores, but Qt attempts to load them with hyphens?
  • The files fusion_normalizedockup{-_}.{svg,png} were last modified https://github.com/qt/qtbase/commit/dfe5118f927a45c56d508ffa8662e05c2f99d68d.
  • They were created in https://github.com/qt/qtbase/commit/acdef6669f8e179ac14ff1be1d974e1230879c51 (Qt 6.7), with comment "re-add" implying they existed before but were removed.
    • QFusionStyle::iconFromTheme was also not present before this commit or Qt 6.7.
    • In fact QFusionStyle::iconFromTheme is a non-virtual method, and absent entirely from the base class and all of Qt 5!
  • Both Qt 5 and 6 have icons for normalizedockup-*.png (without the hyphen-underscore naming inconsistency), which look different from the Fusion variants.

  • Does resizing a window on Linux burn more CPU than Windows or slow down the framerate, even with Qt::WA_OpaquePaintEvent forced on, because it's trying to load a missing icon?
    • Well on Linux, resizing with a 1000Hz mouse burns a full CPU core, with a good chunk of CPU in subElementRect. I haven't tested Windows.
  • Why did I not get CPU burn in the PR flatpak, but Schlaefer did?
    • I recorded a trace in sysprof (I can't use samply for Flatpak apps): System Capture from 2025-11-01 00:44:26.zip
    • This says the flatpak does eat CPU, in the same places as before (QFile::open). Then why didn't I see it?
    • On Arch Linux, htop doesn't include Flatpak apps when viewing CPU usage by thread! I did try sudo htop which (sometimes?) showed OBS using CPU, though less than before the PR (15% of a core rather than 60%).
    • Is resourceList() longer (or more files in each root) in Arch OBS than the flatpak, or is QResourcePrivate::load called more times? I don't know and don't care to check.

nyanpasu64 avatar Nov 01 '25 08:11 nyanpasu64

This theory explains my observations, but assumes that hyphens and underscores are not interchangeable in Qt resource files (I did not verify).

I've made a test C++/Qt project at https://codeberg.org/nyanpasu64/fusion-resources, which confirms that QFile(":/qt-project.org/styles/fusionstyle/images/fusion_normalizedockup-%d.png") fails to open for %d != 16 or 32. This supports my theory for the cause of excessive CPU usage.

nyanpasu64 avatar Nov 01 '25 15:11 nyanpasu64

@nyanpasu64 Can you give https://github.com/obsproject/obs-studio/pull/12735 a try? The widget background should now be explicitly set and not overridden to transparent.

Warchamp7 avatar Nov 17 '25 21:11 Warchamp7

Sorry I haven't had a chance to retest that PR so far. I think the Flatpak build failed (the build logs just cut off at some point? I don't see an error message), and I didn't find the time to manually compile and run it locally. Additionally I think an opaque background would not resolve CPU burn, since Qt requires an opaque border as well as background for mysterious reasons. (I haven't found the time to trace that function with a debugger to find out which condition fails resulting in w->setAttribute(Qt::WA_OpaquePaintEvent, false);, but may do so at some point.)

I've reported the CPU burn upstream at https://qt-project.atlassian.net/browse/QTBUG-141673, but they closed the issue and told me to file individual bugs. I filed the icon filename mismatch at https://qt-project.atlassian.net/browse/QTBUG-142139, but did not report the "requires an opaque border" issue due to limited energy.

nyanpasu64 avatar Nov 24 '25 06:11 nyanpasu64