Skip to content

Optimization: Parallelize GXS message deserialization using OpenMP (4x speedup) - #246

Open
jolavillette wants to merge 7 commits into
RetroShare:masterfrom
jolavillette:feature/gxs-openmp
Open

Optimization: Parallelize GXS message deserialization using OpenMP (4x speedup)#246
jolavillette wants to merge 7 commits into
RetroShare:masterfrom
jolavillette:feature/gxs-openmp

Conversation

@jolavillette

@jolavillette jolavillette commented Jan 19, 2026

Copy link
Copy Markdown
Contributor

Code by Antigravity

This PR requires RetroShare pr/3136

Description

This PR significantly optimizes the loading performance of GXS services (Channels, Forums, Boards) by parallelizing the message deserialization process.
Profiling identified RsGenExchange::getMsgData as a major bottleneck during the loading phase, where deserialization was performed sequentially on a single thread. This PR introduces OpenMP to parallelize this workload across available CPU cores.

Changes

  • Global OpenMP Support ([retroshare.pri], see corresponding RetroShare PR
    Enabled -fopenmp compiler and linker flags globally for Linux and Windows (MSYS2) builds. This ensures consistent OpenMP support across libretroshare and the GUI executable.
  • Parallel Deserialization (libretroshare): Refactored the main loop in RsGenExchange::getMsgData to use #pragma omp parallel for. This allows concurrent deserialization of RsGxsMsgItem objects.

Performance Results

Benchmarks performed on an Intel Xeon E3-1230 v6 (4 cores / 8 threads) on Ubuntu 24.04, and on an Intel 4790K (4 cores / 8 threads) on Windows 10
Example: Deserialization time on Xeon:
| Dataset Size | Serial (Before) | Parallel (After) | Speedup |
| Large (~6000 items) | ~1900 ms | ~440 ms | 4.3x
| Medium (~3000 items) | ~265 ms | ~65 ms | 4.0x
| Small (~500 items) | ~388 ms | ~80 ms | 4.8x

Impact

  • Reduced Loading Time**: The "User Perceived" total loading time for large groups is reduced by more than 50% (e.g., from ~13s to ~6s) on Win10 4790K for a channel with 6000 records.
  • Scalability: The performance gain scales linearly with the number of physical cores.
  • Scope: Applies to all GXS-based services (Channels, Forums, Boards, Wikis).

Notes

  • UI performance was also analyzed. Moving std::sort operations from the UI thread to a worker thread was tested but yielded negligible gains (~50ms) compared to Qt's rendering cost. Therefore, UI-specific changes were reverted to maintain code simplicity.

@csoler

csoler commented Jan 19, 2026

Copy link
Copy Markdown
Contributor

openmp is a great tool for parallelizing (I use if often), but you need to make sure that

  • there's no static involved below the parallel calls
  • the code below is 100% re-entrant. In particular, is it the case for sqlcipher?
    According to what you can find on the net the following is unsafe for sqlite (and therefore sqlcipher):
  • Recursive calls into SQLCipher using the same sqlite3*
  • Using a connection simultaneously from multiple threads without coordination
    So be make sure that your code does things properly. It may work on some machines and not on other machines.

@jolavillette

jolavillette commented Jan 19, 2026

Copy link
Copy Markdown
Contributor Author

Note that in this PR only rsgenexchange is changed.
All otther involved files have been modified only to trace the code and measure the speed improvement.

So here is what Antigravity says:

"I have reviewed the OpenMP parallelized code in libretroshare/src/gxs/rsgenexchange.cc (around line 1559) considering Cyril's feedback.

Safety Analysis:

No static variables involved below the parallel loop calls mSerialiser->deserialise(...).
I verified the call chain: RsSerialiser ->RsServiceSerializer ->RsGxsForumSerialiser (for forums).
These methods utilize local contexts (SerializeContext) and do not modify the state of the serializer object itself.
Memory allocation (new RsItem) uses the standard allocator (thread-safe) or a static mutex (if DO_STATISTICS is enabled), so it is safe.

100% Re-entrant:
The deserialization methods (RsTypeSerializer::serial_process) operate on memory buffers passed as arguments.
No shared memory areas are modified without locks (the results vector tempItems is accessed via disjoint indices).

SQLCipher (Recursive/Simultaneous usage):
The database access (mDataAccess->getMsgData) is performed BEFORE the parallel loop.
The loop itself performs only CPU-intensive processing (binary deserialization) in memory. It does not touch SQLite/SQLCipher.

Conclusion: The code appears to comply with the stated constraints and should not cause crashes or corruption related to SQLCipher or static variables. The parallelization here is strictly computational (deserialization).

However, please ensure that all services using RsGenExchange (Forums, Channels, etc.) strictly use "clean" serializers (like the Forums one I verified), which follows the standard pattern in the current codebase."

@jolavillette
jolavillette force-pushed the feature/gxs-openmp branch 4 times, most recently from 1f06ab1 to dc46a88 Compare January 27, 2026 21:25
@jolavillette

Copy link
Copy Markdown
Contributor Author

remove debug messages

@jolavillette
jolavillette force-pushed the feature/gxs-openmp branch 3 times, most recently from 1c04192 to bc8693f Compare February 12, 2026 07:36
@jolavillette
jolavillette force-pushed the feature/gxs-openmp branch 3 times, most recently from 40ec79e to 5478934 Compare February 18, 2026 22:02
@jolavillette
jolavillette force-pushed the feature/gxs-openmp branch 2 times, most recently from 0cb7ea0 to ab28158 Compare April 21, 2026 01:47
@jolavillette
jolavillette force-pushed the feature/gxs-openmp branch 2 times, most recently from 42d6a9a to 8b21be6 Compare May 25, 2026 08:13
@jolavillette
jolavillette force-pushed the feature/gxs-openmp branch 3 times, most recently from cdcd4f7 to 216c9f9 Compare June 7, 2026 12:43
@jolavillette
jolavillette force-pushed the feature/gxs-openmp branch 3 times, most recently from cd1bd82 to 65ef595 Compare June 16, 2026 21:16
@jolavillette
jolavillette force-pushed the feature/gxs-openmp branch 2 times, most recently from 5b6b7a0 to 9c67065 Compare July 7, 2026 12:24
@jolavillette
jolavillette force-pushed the feature/gxs-openmp branch 2 times, most recently from 814fc13 to 91c9d47 Compare July 8, 2026 20:10
@jolavillette
jolavillette force-pushed the feature/gxs-openmp branch 4 times, most recently from b23c7c8 to 9985bd8 Compare July 24, 2026 10:45
@jolavillette
jolavillette force-pushed the feature/gxs-openmp branch 3 times, most recently from e21bd29 to e554361 Compare July 27, 2026 20:05
@jolavillette
jolavillette force-pushed the feature/gxs-openmp branch 6 times, most recently from 34eba18 to 8c6aa02 Compare August 12, 2026 10:38
jolavillette and others added 4 commits August 16, 2026 18:26
…ry per group

Reading the meta of a whole group runs SELECT ... WHERE grpId=?, which
INDEX_MESSAGES_GRPID serves with one row lookup per message. Since the
payload blob lives in the same row, those lookups are scattered over the
whole file: warming up the cache of N groups costs N passes of random
I/O over a database that is hundreds of megabytes.

When more than one group still needs a cold full read, read the meta of
every message in a single sequential scan instead and fill every
per-group cache from it. The file is then read in physical order, and
the cost no longer grows with the number of groups.

Measured on a synthetic database of the same shape and size as a real
gxsforums_db (235 MB, 23 KB rows, 20 groups), cold cache:

  20 per-group queries   29449 ms
  one sequential scan     2146 ms      13.7x

and the scan does not get more expensive as groups are added, where the
per-group path grows linearly with them. On a node subscribed to
hundreds of forums this is the difference between tens of seconds of
startup and a fixed couple of seconds.

Nothing else changes: same columns, same cache contents, same values
returned. Callers and public API are untouched, and no database schema
or format is modified.

Trade-off: the scan fills the cache for groups that were not requested
yet. That is the same memory the cache reaches as soon as those groups
are browsed, but it is reached up front rather than progressively.

The scan reports itself through the existing opt-in profiler, so the
gain is verifiable on a real profile rather than taken on trust:

    GXS-PROF loadAllMsgMetaInOneScan  db=gxsforums_db groups=571 metas=48213 in 2100ms

Stacked on the channel loading branch: both reshape the same function,
and this one reuses the profiler introduced there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ed slices

The warm-up scan was triggered synchronously inside retrieveGxsMsgMetaData
by the second cold whole-group request, and ran under mDbMutex in one go.
Cold page cache, it was measured at up to 57 s on a real gxsforums_db
(235 MB): the caller -- possibly asking for a handful of metas from one
group -- and every other reader of the service froze for that long at
startup.

Keep the trigger and the sequential scan, but run it on a dedicated
thread in slices of 4096 rows by increasing rowid, taking mDbMutex only
for the duration of one slice so readers interleave. Until the scan
completes, cold groups keep being served by the indexed per-group query.
Messages stored while the scan runs are cached by storeMessage() itself,
so rowid reuse after deletions cannot leave a hole. The thread is joined
in the destructor before the DB is closed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One line per database (rows, slices, duration) so the background warm-up
can be observed and validated from the logs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A fixed 4096-row slice held mDbMutex for ~10 s on a cold large-row
database (23492 forum metas warmed in 6 slices of ~10 s each), stalling
single-group readers for that long -- the very stall the background scan
exists to avoid. Start at 256 rows and rescale each slice towards a
250 ms target, clamped to [64, 4096] rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jolavillette and others added 3 commits August 22, 2026 20:11
…s published

publishMsgs() builds a grpMetas map out of msgChangeMap and then calls
RsDataService::retrieveGxsGrpMetaData() unconditionally. When no message
was published - which is the case on virtually every tick - that map is
empty, and an empty map means "retrieve ALL groups" for the data service:
a full group table scan (with the associated decryption cost when the grp
meta cache is cold).

That happens with mGenMtx held, so the whole GXS service is frozen for the
duration, and so is any GUI call that needs mGenMtx. Observed on a client
with 878 forums: a msg meta retrieval held the data service mutex for 35 s,
publishMsgs() blocked on it while holding mGenMtx, and the GUI thread hung
27 s inside RsGenExchange::getDefaultSyncPeriod() while building the forums
help string.

Guard the call the same way processRecvdMessages() already does.
The database work is finished when the loop starts; what remains is
pure in-memory decoding, one item per message, independent of each
other. Run it through an OpenMP parallel for: results land in a
pre-sized array so the loop shares no mutable state, and are merged
serially afterwards, which keeps the output order stable and reports
deserialisation errors from a single thread instead of interleaving
lines from the pool.

mSerialiser is used concurrently and must remain stateless/re-entrant;
its declaration now says so. Builds without OpenMP ignore the pragma
and run the loop serially, unchanged.

Restacked on top of perf/gxs-channel-loading, whose version filtering
and move semantics reshape the same loop: the two changes are
complementary (fewer items to decode, then decoded in parallel).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds -fopenmp where the qmake build compiles and links libretroshare.
The previous version of this change also dropped rs_deep_forums_index
from the xapian link condition in use_libretroshare.pri; that was
unrelated and would have broken deep-forum-index builds, so it is not
carried over. CMake builds do not set the flag yet: there the pragma is
ignored and the loop runs serially, which is correct, just not
parallel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

2 participants