perf: SG-43585: Performance improvement of pyevaluate and pyexec - #1375
Merged
bernie-laberge merged 5 commits intoAug 31, 2026
Merged
Conversation
bernie-laberge
force-pushed
the
perf/qt6-webchannel-latency
branch
from
August 10, 2026 18:12
5a52bdf to
ded106d
Compare
bernie-laberge
marked this pull request as ready for review
August 10, 2026 18:40
bernie-laberge
requested review from
cedrik-fuoco-adsk and
eloisebrosseau
as code owners
August 10, 2026 18:40
bernie-laberge
force-pushed
the
perf/qt6-webchannel-latency
branch
from
August 25, 2026 19:06
ded106d to
5897889
Compare
After the Qt5->Qt6 migration, JS<->Python web-panel bridge round-trips (pyevaluate / pyexec) became slow and grew unbounded whenever the handler did RV work such as clearSession() / addSourceVerbose(). The bridge is synchronous on the Qt main (GUI) thread, so any redundant work RV does on that thread directly delays delivery of queued bridge calls. Three sources of avoidable GUI-thread work are addressed: - Redundant edit-mode churn. clearSession() sets the default view twice with force=true; each before/after view-change event toggled the same view edit mode off and back on (up to 4x per clear), rebuilding menus and event tables for no net change. - Settle-window repaint storm. A static askForRedraw() started a 2-second window that kept isUpdating() true, so the ~120 Hz heartbeat kept repainting for ~2 s after the single needed frame had been drawn. In Qt5 each QGLWidget swapped its own back buffer cheaply; in Qt6 every redraw forces a full-window QOpenGLWidget recomposite + present. - Per-presented-frame full-window composite. The viewport now renders as a native QOpenGLWindow (new GLWindow) embedded via QWidget::createWindowContainer instead of a QOpenGLWidget, so the top-level QMainWindow is no longer forced onto the OpenGL RHI backend and the viewport presents on its own surface. Docked QWebEngineView panels render on the platform-default backend and QT_QUICK_BACKEND= software is no longer needed. GLView becomes a plain QWidget host delegating to GLWindow; QTGLVideoDevice gains a window-backed path. Also fixed, exposed by the surface change: - DesktopVideoDevice::open no longer dereferences the control device's (now-null) QOpenGLWidget; ScreenView shares a QOpenGLContext via new backing-agnostic QTGLVideoDevice::glShareContext()/glSurfaceFormat(). - Qt::AA_ShareOpenGLContexts is set (documented QtWebEngine requirement) so second-output and upload-worker GL surfaces share with the viewport, and a null-context dereference in newSharedContextWorkerDevice that crashed with Multithread GPU Upload enabled is removed. - The UI blocking overlay is reworked from a raster child widget into a frameless translucent top-level window so it can dim and block input over the native viewport. - crashpad's MSVC toolset is pinned to the parent build (fixes LNK2019). - Session initialization is guarded on a current GL context: constructing an RvSession queries GL_SHADING_LANGUAGE_VERSION, which aborts with no current context. A QOpenGLWindow has no context until first exposed, so the constructor's retry now runs only when GLWindow::initializeGL() already bailed out early (Windows), leaving macOS to initialize from initializeGL() itself. Without this RV aborted at launch on macOS. Validated on Windows (release): flat bridge latency under playback spam, all web panels render docked, and viewport/events/drag-drop/annotations/ presentation/blocking-overlay all work. Validated on macOS: launch and session init/teardown. macOS feature and HDPI passes still pending. Co-authored-by: Cédrik Fuoco <cedrik.fuoco@autodesk.com> Signed-off-by: Cédrik Fuoco <cedrik.fuoco@autodesk.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Bernard Laberge <bernard.laberge@autodesk.com> fix: SG-43585: initialize the session outside the GL callback RvDocument::initializeSession() ran from GLWindow::initializeGL(), so creating the RvSession -- and with it loading every package, which runs Mu and Python -- depended on when Qt happened to deliver the viewport window's first expose. That is not something the platforms agree on, and it broke two of them differently. Linux (X11): QWidget::show() is asynchronous, so no expose had arrived by the time RvApplication::newSessionFromFiles() reached doc->session() a few lines later. That returned null and RV segfaulted at startup in Session::queryAndStoreGLInfo() with this == nullptr, before any web panel or script was involved. Confirmed under gdb, and by the crash simply moving to the next use of the null session (rebuildSessionFromFiles) when that call was skipped. macOS: a QOpenGLWindow has no GL context until it is first exposed, so the session was constructed with no current context and RV aborted in IPCore::Shader::initGLSLVersion() when glGetString(GL_SHADING_LANGUAGE_VERSION) returned null. Windows appeared to escape both because initializeGL() fires synchronously while the window is being constructed there. Have the application drive it instead: newSessionFromFiles() calls initializeSession() immediately after show(), and initializeSession() makes the viewport context current itself before constructing the RvSession. There is a single RvDocument creation site, so this is the whole ordering. initializeGL() no longer calls back into the document, which also retires the m_sessionInitPending retry that worked around the same fragility from the other end. Validated on Linux: launches cleanly, and a docked QWebEngineView works. On macOS: launches cleanly and media loads. Not yet exercised on Windows, where this changes startup ordering as well. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Bernard Laberge <bernard.laberge@autodesk.com> docs: SG-43585: record why macOS keeps the software Qt Quick backend The QT_QUICK_BACKEND comments no longer described reality. Both files still explained the setting as a Qt 5.12.1 workaround for QWebEngineView conflicting with the QGLWidget viewport, which has not been the reason since the viewport became a native QOpenGLWindow. src/bin/apps/rv/main.cpp (Windows, Linux): drop the block outright. The call was already commented out, so all that remained was a commented-out line plus a rationale for a decision the code no longer makes. Linux has since confirmed the setting is not needed there -- it launches and runs docked web panels without it. src/bin/nsapps/RV/main.cpp (macOS): the setting stays, and the comment now says why, because it is load bearing rather than vestigial. The viewport window is embedded with QWidget::createWindowContainer, making it a QObject child of the top level window's QWidgetWindow. With the hardware Qt Quick backend, adding a QWebEngineView makes Qt destroy and recreate that native subtree; the viewport window is deleted with it rather than reparented, and QWindowContainer then dereferences the window it just lost, so RV segfaults inside Qt. Measured on macOS with the four line reproducer now quoted in the comment: software backend survives 2/2, hardware backend crashes 2/2. The cost is that macOS web panels composite in software, so macOS gets the cheap viewport repaint half of this work but not the hardware web panel half. Noted in the comment so the asymmetry with Windows and Linux reads as deliberate. Comment-only; no compiled behaviour changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Bernard Laberge <bernard.laberge@autodesk.com>
bernie-laberge
force-pushed
the
perf/qt6-webchannel-latency
branch
from
August 25, 2026 21:01
5897889 to
f00840f
Compare
QWidget::createWindowContainer() parents the viewport QOpenGLWindow to the top-level QWidgetWindow. Qt destroys and recreates that window whenever a widget is reparented into it -- QWidget::setParent() -> destroy() -> ~QWidgetWindow -- and ~QObject deletes its child QWindows, so the viewport went with it. Adding a QWebEngineView to a layout is enough to trigger that, which the Flow Production Tracking packages do during session init, so RV aborted at startup in Session::askForRedraw() -> QTGLVideoDevice::redraw() -> update() on freed memory: a pure virtual call in the packaged build, an access violation on the 0xfeeefeee fill locally. QObject::destroyed is emitted before ~QObject deletes its children, so GLView watches the top-level's window and detaches the viewport in time to keep it instead of rebuilding it -- the GL context, the video device wiring and the already uploaded textures all survive. Rebuilding was not viable anyway: a QWindowContainer whose window is gone has no safe disposal point, since deleteLater() leaves it reachable for a layout pass that runs synchronously inside the same reparent, and deleting it immediately faults inside Qt's own teardown. The container leaves the widget tree for the same interval, because QWindowContainer::parentWasMoved() dereferences the top-level's windowHandle() without checking it and that is null until Qt recreates the window. Both are restored on the next turn of the event loop. The watch is armed from the constructor: RV shows the document and runs its session initialisation in a single call stack, so there is no turn of the event loop in which a deferred hook could be installed. QTGLVideoDevice::m_window also becomes a QPointer and the backing fallbacks tolerate having neither backing, so a viewport that does go away can no longer be dereferenced.
parentWindowDestroyed() takes the container out of the widget tree for one turn of the event loop. If the document is destroyed during that window the container has no parent widget, so ~QWidget does not take it along: it survives as a stray top-level owning the viewport window, whose raw back-pointer to the just-deleted QTGLVideoDevice is then used by GLWindow::event() and paintGL(). Clear the window's device pointer and destroy the detached container before the device goes away.
GLView realizes the top-level's native window up front, which pins the window's composition to OpenGL before any render-to-texture widget joins the tree. Qt Quick's RHI backend defaults to Direct3D 11 on Windows, and a QQuickWidget cannot obtain a QRhi from a window using a different graphics API, so anything Quick-based in an RV window draws nothing and logs "The top-level window is not using the expected graphics API for composition" followed by "Attempted to render scene with no rhi". QWebEngineView is the visible case -- its page is rendered by a QQuickWidget -- so plugin web panels came up blank. Setting QSG_RHI_BACKEND from main() does not work: the backend is already resolved by the time any code there runs, so only the environment RV was launched with is honoured, whether the variable is set through qputenv() or the CRT. QQuickWindow::setGraphicsApi() is the documented application-side control and is honoured when called before the first QQuickWindow exists. QSG_RHI_BACKEND still overrides it, so the backend stays selectable for debugging. Windows only: Qt Quick already defaults to OpenGL on Linux. macOS defaults to Metal and may need the same, which is untested.
Porting the viewport from QOpenGLWidget to a native QOpenGLWindow carried
GLView's mouse-enter focus line across as requestActivate(), the nearest
looking QWindow API:
case QEvent::Enter:
requestActivate();
QWidget::setFocus(), which it replaced, only delivers focus when the top-level
is already active; otherwise it just records the window's focus child and does
nothing observable. QWindow::requestActivate() on a native child window is an
activation request for the whole top-level, so merely hovering the player
activated and raised the main window -- QA saw RV's Console drop behind it and
lose focus. On Windows QWindowsWindow::requestActivateWindow() also takes an
AttachThreadInput/SetForegroundWindow path when RV is not the active
application, so a hover could pull RV to the foreground from another
application entirely. macOS lost focus but not stacking because
QCocoaWindow::requestActivateWindow() does makeKeyWindow without orderFront.
Hover now moves widget focus to the view instead. The container is GLView's
focus proxy, so this is the same route RvDocument and the Mu commands already
use, and QWindowContainer forwards that focus to the embedded window -- but
only ever from a window that is already active.
It is skipped when this window already holds focus: QWindowContainer clears the
container's widget focus once it has handed focus over, so a repeat FocusIn
would take its "return to the normal focus chain" branch and push the keyboard
to the next widget in the tab chain. QWidget::setFocus()'s own focusWidget()
early-out did that for us on the widget-based viewport.
The FocusIn case also lacked a break and fell into the hover case. Qt has
already made the window focused by the time FocusIn is delivered, so there was
nothing to activate; it now only drops modifier state that went stale while the
keyboard was elsewhere.
bernie-laberge
merged commit Aug 31, 2026
a8498fd
into
AcademySoftwareFoundation:main
20 of 22 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Linked issues
No GitHub issue — tracked internally as SG-43585
Summarize your change.
After the Qt5 → Qt6 migration, JS ↔ Python web-panel bridge round-trips (
pyevaluate/pyexec)became slow whenever the JavaScript handler did real RV work such as
clearSession()/addSourceVerbose(). The bridge is delivered synchronously on the Qt main (GUI) thread, so anyredundant work RV does on that thread directly delays queued bridge calls.
Nothing about the bridge itself changed between Qt5 and Qt6 — QWebChannel, QtWebEngine, the
2-second
askForRedraw()settle window and the 120 Hz heartbeat are all identical in both trees.The one difference that matters is that
QGLWidgetwas removed in Qt6, so the viewport became aQOpenGLWidget. AQOpenGLWidgetrenders to an offscreen FBO and forces the whole top-level windowonto the OpenGL/RHI path, so every viewport repaint recomposites every widget in the window —
toolbars, docks, and the web panel — and presents the whole window. Same redundant redraws as Qt5,
roughly 10× the cost each, on exactly the thread the bridge needs.
This PR gives the viewport its own surface back: it now renders as a native
QOpenGLWindow(newGLWindow) embedded viaQWidget::createWindowContainer.GLViewbecomes a plainQWidgetfacadethat delegates to it, so nothing outside
RvCommonhad to change.Measured with the
mre_pluginreproducer at a 150 ms interactive cadence (playback + real sessionwork in the handler):
Describe the reason for the change.
A user reported that web-panel bridge calls became slow after the Qt6 migration. A three-way
isolation on Linux at interactive cadence pinned the cause to the surface, not the bridge and not
the repaint count:
main)QOpenGLWidgetviewportQOpenGLWidgetB − A ≈ 0, C − B ≈ −13.6 ms. The entire win is the surface change. A separate one-line
heartbeat fix (
isUpdating()→isPlaying() || m_wantsRedraw) was evaluated and deliberatelynot included here: it measured no benefit at interactive cadence, and it risks stopping
animation for anything that redraws during the settle window without calling
askForRedraw()eachframe (cache/buffer progress indicators first). It can land separately on its own merits.
Describe what you have tested and on which operating system.
Windows (Release) — the viewport refactor itself: flat bridge latency under playback spam, all
web panels render docked, and viewport / events / drag-and-drop / annotations / presentation /
blocking-overlay all work.
Linux (X11) — launches cleanly, docked
QWebEngineViewworks, media loads; bridge latencymeasured 15 → 1.4 ms at 150 ms cadence.
macOS — launches cleanly, session init/teardown and media load work, docked
QWebEngineViewworks; bridge latency measured 7 → 0.9 ms.
Not yet covered — please weigh this in review:
ordering. It is also the platform whose synchronous
initializeGL()masked both original startupbugs, so it is the most likely place for a surprise.
end-to-end test on real multi-display hardware.
Add a list of changes, and note any that might need special attention during the review.
The refactor
GLWindow : QOpenGLWindow(GLWindow.h/.cpp, 372 new lines) — takes overinitializeGL,resizeGL,paintGL,event,eventProcessingTimeout,absolutePosition,readPixels,stopProcessingEvents,firstPaintCompleted,devicePixelRatioF.GLViewbecomes a plainQWidgetfacade (−748 lines) — keepssizeHint,minimumSizeHint,rvGLFormat,setContentSize; everything else forwards tom_glWindow. Itspublic surface is unchanged, so no caller outside
RvCommonwas touched.QTGLVideoDevicegains a window-backed path — two constructors,m_viewXORm_window, plusbacking-agnostic
glShareContext()/glSurfaceFormat()so callers no longer care which backingthe control view uses.
Knock-on changes forced by the new surface — these are the parts most worth a careful read:
Qt::AA_ShareOpenGLContextsis now set before theQApplication(a documented QtWebEnginerequirement). It also lets second-output and upload-worker GL surfaces share with the viewport
without an ordering-sensitive
setShareContext(), and retires a null-context dereference thatcrashed with Multithread GPU Upload enabled.
DesktopVideoDevice::open()no longer dereferences the controldevice's (now-null)
QOpenGLWidget;ScreenViewshares aQOpenGLContextthrough the newaccessors.
top-level window. A native window renders above sibling raster widgets regardless of
raise()order, so the old overlay could no longer dim the viewport.
Startup-ordering fix —
RvDocument::initializeSession()used to run fromGLWindow::initializeGL(), so constructing theRvSession(and loading every package, which runs Muand Python) depended on when Qt delivered the viewport's first expose. The platforms disagree on
that: on macOS a
QOpenGLWindowhas no GL context until first exposed, so the session was built withno current context and aborted in
IPCore::Shader::initGLSLVersion(); on Linux/X11show()isasync, so
doc->session()returned null a few lines later and RV segfaulted inSession::queryAndStoreGLInfo()withthis == nullptr(confirmed under gdb); Windows appeared toescape both because
initializeGL()fires synchronously during construction there. NowRvApplication::newSessionFromFiles()callsinitializeSession()right aftershow(), andinitializeSession()makes the viewport context current itself. There is a singleRvDocumentcreation site, so that is the whole ordering, and
initializeGL()no longer calls back into thedocument.
QT_QUICK_BACKEND=software— deliberately asymmetric, please do not "clean this up":src/bin/apps/rv/main.cpp(Windows, Linux) — the block is dropped. It was already commented out,and Linux has since confirmed the setting is not needed.
src/bin/nsapps/RV/main.cpp(macOS) — the setting stays and is load bearing. The viewportwindow is a
QObjectchild of the top-level'sQWidgetWindow; with the hardware Qt Quick backend,adding a
QWebEngineViewmakes Qt destroy and recreate that native subtree, deleting the embeddedwindow rather than reparenting it, after which Qt's own
QWindowContainerdereferences the windowit lost. Measured with the four-line reproducer now quoted in the comment: software survives 2/2,
hardware crashes 2/2. The cost is that macOS web panels composite in software — macOS gets the
cheap-viewport half of this win, not the hardware-web-panel half.
Also in the diff: crashpad's MSVC toolset is pinned to the parent build (fixes an LNK2019).
Known risk areas for review
sibling widget needs checking — the blocking overlay already had to become a top-level window.
Annotations, HUD-style widgets and full-screen transitions are the places to look.
the window, so
devicePixelRatiopaths deserve a mixed-DPI pass.If possible, provide screenshots.
A slide deck walking through the refactor class by class is attached:
SG-43585_viewport_refactor.pptx.SG-43585_viewport_refactor.pptx