Skip to content

Highly reduce the CPU by setting the ogre workers to 0 (backport #1302)#1307

Open
mergify[bot] wants to merge 1 commit into
gz-rendering8from
mergify/bp/gz-rendering8/pr-1302
Open

Highly reduce the CPU by setting the ogre workers to 0 (backport #1302)#1307
mergify[bot] wants to merge 1 commit into
gz-rendering8from
mergify/bp/gz-rendering8/pr-1302

Conversation

@mergify

@mergify mergify Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

🦟 Bug fix

Summary

gz-rendering's ogre2 backend has historically created one Ogre SceneManager worker thread per logical CPU core. For the scenes Gazebo typically renders this is a net loss: the worker threads burn several CPU cores on per-frame synchronization while barely changing — and sometimes worsening — frame latency, and the rendered image is bit-identical with or without them.

This PR changes the default to 0 worker threads (Ogre runs the passes inline on the main thread, with no thread pool and no barrier), and adds an opt-in environment variable for the rare scenes (tens of thousands of objects) that still benefit from threading. It also adds a small headless cpu_bench example used to characterize the problem so the result is reproducible.

Why 0

Passing 0 does not create a degenerate zero-thread state. The constructor splits the meaning of "0" into two fields (OgreSceneManager.cpp:147-148):

mNumWorkerThreads( std::max<size_t>( numWorkerThreads, 1u ) ), // 0 -> clamped to 1
mForceMainThread ( numWorkerThreads == 0u ? true : false ),    // 0 -> run inline

So with 0: mForceMainThread = true (no real threads, no barrier — every fireWorkerThreadsAndWait() takes the updateWorkerThreadImpl(0) inline branch at :4636/2204/2242/4663), while mNumWorkerThreads is clamped to 1.
Therefore getNumWorkerThreads() returns 1 (OgreSceneManager.h:1001), mQueuedRenderablesPerThread.resize(1) (OgreRenderQueue.cpp:103) allocates exactly one bucket, and the main thread fills it as thread 0. Nothing is sized
to empty, no work is skipped — which is exactly why the rendered output is bit-identical.

What the worker threads actually do (and why removing them helps)

Each rendered frame, the SceneManager runs a series of per-frame passes and fans every one of them out to all worker threads through a pthread barrier. The passes are dispatched via the RequestType work items (OgreMain/src/OgreSceneManager.cpp):

Pass RequestType OgreSceneManager lines
Animations UPDATE_ALL_ANIMATIONS OgreSceneManager.cpp:1705
Node transforms UPDATE_ALL_TRANSFORMS :1752, :1800
World bounds UPDATE_ALL_BOUNDS :1877
LOD selection UPDATE_ALL_LODS :1923/1930
Light list (2 phases) BUILD_LIGHT_LIST01 / 02 :2179/2207, :2240/2245
Frustum culling CULL_FRUSTUM :4648/4654

Each dispatch goes through fireWorkerThreadsAndWait() (OgreSceneManager.cpp:4633), which costs two barrier syncs — one to wake the pool, one to wait for completion.

What the workers are not parallelizing

The work the threads do is genuinely parallelizable prep (transform/bounds/LOD/light-list/cull). It is not the draw submission: the render-queue build is per-thread (OgreRenderQueue.cpp:103,210, each thread fills its own
mQueuedRenderablesPerThread bucket), but RenderQueue::render() — the HLMS constant-buffer fills and actual draw-call submission — is serial on the main thread (single GL/Vulkan context in ogre-next 2.x).

Testing and numbers

All on a 16-logical-core machine. CORES BUSY = process CPU-seconds / wall seconds (so 1.00 ≈ one core fully busy). "old" = previous default (16 worker threads = cores); "fix" = new default (0, inline). Render output verified bit-identical in every case (480000/480000 lit pixels match across thread counts).

Headline — cpu_bench, 400 boxes

Scenario old (16 threads) fix (0 threads) reduction
400 boxes, uncapped (~500 fps) 4.61 cores 1.00 cores −78%
400 boxes, capped 60 fps 0.65 cores 0.16 cores −76%
400 boxes, capped 30 fps 0.32 cores 0.09 cores −71%
jetty_demo GUI (heavy real scene, no sensors) 1.16 cores 0.86 cores −26%

Thread-count sweep — cpu_bench, CPU ms/frame (the lever)

N (boxes) 16 8 4 2 1 0 (inline) reduction (16→0)
400 9.16 6.6 5.6 3.27 2.14 1.72 −81%
1600 10.7 ~3.3 −69%
3600 17.2 8.8 −49% (16→1)
10000 (heavy) 31.7 17.7 −44%

Wall time stays essentially flat across all thread counts (≈1.6–2.0 ms at N=400; ≈13–14 ms at N=10000, where T=8 13.3 vs T=16 14.0 vs inline 14.1 — within ~6%). In other words, threading almost never wins latency but always costs CPU, and T=8 beats T=16 on both CPU and wall — the old T=cores default was already past the point of diminishing returns. The win grows with core count, so 32-/64-core machines were hurt most and gain most.

Final-build sanity check (env=16 vs default 0): N=400 CPU 8.43 → 1.77 ms (−79%); N=1600 10.94 → 2.82 ms (−74%); wall tied; render identical.

cpu_bench example

examples/cpu_bench/ — a standalone headless N-box benchmark that isolates per-frame CPU vs. wall time, with knobs for object count, shadows, per-frame pose dirtying, frustum culling, frame-rate cap, and render size. Reports wall min/median/mean, CPU/frame, a CORES BUSY figure (CPU_seconds / wall_seconds), and a grep-friendly CSV line. This is the tool used to produce the measurements above.

Backport Policy

There is the question on backporting the setting to existing releases. Given than it could potentially benefit all the running simulations a lot, I would tend to change it and provide the setting as a fallback.

  • This is safe to backport to the following versions:
    • Jetty
    • Ionic
    • Harmonic
    • Fortress
  • This should not be backported
  • I am not sure
  • Other (fill in yourself)

Checklist

  • Signed all commits for DCO
  • Added a screen capture or video to the PR description that demonstrates the fix (as needed)
  • Added tests
  • Updated documentation (as needed)
  • Updated migration guide (as needed)
  • Consider updating Python bindings (if the library has them)
  • codecheck passed (See contributing)
  • All tests passed (See test coverage)
  • Updated Bazel files (if adding new files). Created an issue otherwise.
  • While waiting for a review on your PR, please help review another open pull request to support the maintainers
  • Was GenAI used to generate this PR? If so, make sure to add "Generated-by" to your commits. (See this policy for more info.)

Generated-by: Claude Opus 4.6

Note to maintainers: Remember to use Squash-Merge and edit the commit message to match the pull request summary while retaining Signed-off-by and Generated-by messages.

Backports: If this is a backport, please use Rebase and Merge instead.


This is an automatic backport of pull request #1302 done by Mergify.

@mergify mergify Bot requested a review from iche033 as a code owner June 29, 2026 10:28
@mergify mergify Bot added the conflicts label Jun 29, 2026
@mergify

mergify Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

Cherry-pick of 32b2508 has failed:

On branch mergify/bp/gz-rendering8/pr-1302
Your branch is up to date with 'origin/gz-rendering8'.

You are currently cherry-picking commit 32b25082.
  (fix conflicts and run "git cherry-pick --continue")
  (use "git cherry-pick --skip" to skip this patch)
  (use "git cherry-pick --abort" to cancel the cherry-pick operation)

Changes to be committed:
	new file:   examples/cpu_bench/CMakeLists.txt
	new file:   examples/cpu_bench/Main.cc
	new file:   examples/cpu_bench/README.md

Unmerged paths:
  (use "git add <file>..." to mark resolution)
	both modified:   Migration.md
	both modified:   ogre2/src/Ogre2Scene.cc

To fix up this pull request, you can check it out locally. See documentation: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/checking-out-pull-requests-locally

@github-actions github-actions Bot added the 🎵 harmonic Gazebo Harmonic label Jun 29, 2026
@j-rivero j-rivero force-pushed the mergify/bp/gz-rendering8/pr-1302 branch from 79179e9 to 0f6368c Compare June 30, 2026 15:53
Historically this was set to the number of logical CPU cores. For the scenes Gazebo typically renders that is a net loss: each of those passes fans out to every worker thread through a pthread barrier (~20-30 barrier syncs per rendered frame) regardless of how little work there is, with no early-out for low object counts.

Default to 0, which makes Ogre run these passes inline on the main thread (no worker threads, no barrier). Very large scenes (tens of thousands of objects) can opt back into threading via GZ_RENDERING_OGRE2_WORKER_THREADS:
  unset / "0" -> run inline on the main thread (default)
  "N"         -> use N worker threads

Generated-by: Claude Opus 4.8 / Claude Sonnet 4.6

---------

Signed-off-by: Jose Luis Rivero <jrivero@honurobotics.com>
(cherry picked from commit 32b2508)
@j-rivero j-rivero force-pushed the mergify/bp/gz-rendering8/pr-1302 branch from 0f6368c to 61423d0 Compare June 30, 2026 16:03
@j-rivero

Copy link
Copy Markdown
Contributor

Required some manual modifications, @ahcorde could you please give a look?

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

Labels

Projects

Status: Inbox

Development

Successfully merging this pull request may close these issues.

2 participants