Skip to content

perf: SG-43585: Performance improvement of pyevaluate and pyexec - #1375

Merged
bernie-laberge merged 5 commits into
AcademySoftwareFoundation:mainfrom
bernie-laberge:perf/qt6-webchannel-latency
Aug 31, 2026
Merged

perf: SG-43585: Performance improvement of pyevaluate and pyexec#1375
bernie-laberge merged 5 commits into
AcademySoftwareFoundation:mainfrom
bernie-laberge:perf/qt6-webchannel-latency

Conversation

@bernie-laberge

@bernie-laberge bernie-laberge commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Credit: the viewport refactor at the heart of this PR is Cédrik Fuoco's work — moving the
RV viewport off QOpenGLWidget and onto a native QOpenGLWindow, and every knock-on change that
followed from it (context sharing, presentation/second-output, blocking overlay). The additional
commits here are the startup-ordering fix and documentation on top of that foundation.

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 any
redundant 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 QGLWidget was removed in Qt6, so the viewport became a
QOpenGLWidget. A QOpenGLWidget renders to an offscreen FBO and forces the whole top-level window
onto 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 (new
GLWindow) embedded via QWidget::createWindowContainer. GLView becomes a plain QWidget facade
that delegates to it, so nothing outside RvCommon had to change.

Measured with the mre_plugin reproducer at a 150 ms interactive cadence (playback + real session
work in the handler):

Platform Before After
Linux (X11) ~15 ms ~1.4 ms ~10×
macOS ~7 ms ~0.9 ms ~8×
Windows not measured

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:

Tree What it is mean latency
A (main) QOpenGLWidget viewport ~15 ms
B one-line heartbeat fix, still QOpenGLWidget ~15 ms
C (this PR) native GL window + ordering fix ~1.4 ms

B − 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 deliberately
not 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() each
frame (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 QWebEngineView works, media loads; bridge latency
measured 15 → 1.4 ms at 150 ms cadence.

macOS — launches cleanly, session init/teardown and media load work, docked QWebEngineView
works; bridge latency measured 7 → 0.9 ms.

Not yet covered — please weigh this in review:

  • Windows has not been re-exercised since the startup-ordering commit, which changes launch
    ordering. It is also the platform whose synchronous initializeGL() masked both original startup
    bugs, so it is the most likely place for a surprise.
  • macOS feature sweep and an HDPI / mixed-DPI multi-monitor pass are still pending.
  • Presentation mode / second output was reworked to share a context explicitly and wants an
    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

  • New GLWindow : QOpenGLWindow (GLWindow.h/.cpp, 372 new lines) — takes over initializeGL,
    resizeGL, paintGL, event, eventProcessingTimeout, absolutePosition, readPixels,
    stopProcessingEvents, firstPaintCompleted, devicePixelRatioF.
  • GLView becomes a plain QWidget facade (−748 lines) — keeps sizeHint,
    minimumSizeHint, rvGLFormat, setContentSize; everything else forwards to m_glWindow. Its
    public surface is unchanged, so no caller outside RvCommon was touched.
  • QTGLVideoDevice gains a window-backed path — two constructors, m_view XOR m_window, plus
    backing-agnostic glShareContext() / glSurfaceFormat() so callers no longer care which backing
    the control view uses.

Knock-on changes forced by the new surface — these are the parts most worth a careful read:

  1. Qt::AA_ShareOpenGLContexts is now set before the QApplication (a documented QtWebEngine
    requirement). 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 that
    crashed with Multithread GPU Upload enabled.
  2. Presentation / second outputDesktopVideoDevice::open() no longer dereferences the control
    device's (now-null) QOpenGLWidget; ScreenView shares a QOpenGLContext through the new
    accessors.
  3. UI blocking overlay reworked from a raster child widget into a frameless translucent
    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 fixRvDocument::initializeSession() used to run from
GLWindow::initializeGL(), so constructing the RvSession (and loading every package, which runs Mu
and Python) depended on when Qt delivered the viewport's first expose. The platforms disagree on
that: on macOS a QOpenGLWindow has no GL context until first exposed, so the session was built with
no current context and aborted in IPCore::Shader::initGLSLVersion(); on Linux/X11 show() is
async, so doc->session() returned null a few lines later and RV segfaulted in
Session::queryAndStoreGLInfo() with this == nullptr (confirmed under gdb); Windows appeared to
escape both because initializeGL() fires synchronously during construction there. Now
RvApplication::newSessionFromFiles() calls initializeSession() right after show(), and
initializeSession() makes the viewport context current itself. There is a single RvDocument
creation site, so that is the whole ordering, and initializeGL() no longer calls back into the
document.

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 viewport
    window is a QObject child of the top-level's QWidgetWindow; with the hardware Qt Quick backend,
    adding a QWebEngineView makes Qt destroy and recreate that native subtree, deleting the embedded
    window rather than reparenting it, after which Qt's own QWindowContainer dereferences the window
    it 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

  • Native-window vs widget stacking. Anything that assumed it could draw over the viewport as a
    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.
  • HDPI. Coordinate translation now goes through the container widget while rendering goes through
    the window, so devicePixelRatio paths deserve a mixed-DPI pass.
  • Windows startup ordering, per the testing section above.

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

@bernie-laberge bernie-laberge changed the title Perf/qt6 webchannel latency perf: Perf/qt6 webchannel latency Aug 10, 2026
@bernie-laberge
bernie-laberge force-pushed the perf/qt6-webchannel-latency branch from 5a52bdf to ded106d Compare August 10, 2026 18:12
@bernie-laberge bernie-laberge changed the title perf: Perf/qt6 webchannel latency perf: SG-43585: Performance improvement of pyevaluate and pyexec Aug 10, 2026
@bernie-laberge
bernie-laberge marked this pull request as ready for review August 10, 2026 18:40
@bernie-laberge
bernie-laberge force-pushed the perf/qt6-webchannel-latency branch from ded106d to 5897889 Compare August 25, 2026 19:06
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
bernie-laberge force-pushed the perf/qt6-webchannel-latency branch from 5897889 to f00840f Compare August 25, 2026 21:01
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
bernie-laberge merged commit a8498fd into AcademySoftwareFoundation:main Aug 31, 2026
20 of 22 checks passed
@bernie-laberge
bernie-laberge deleted the perf/qt6-webchannel-latency branch August 31, 2026 13:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant