Skip to content

Keep the GUI responsive with thousands of images: list caching, coalesced updates, async import - #389

Open
ferba24 wants to merge 10 commits into
Lymphatus:mainfrom
ferba24:perf/large-batch-responsiveness
Open

ferba24 wants to merge 10 commits into
Lymphatus:mainfrom
ferba24:perf/large-batch-responsiveness

Conversation

@ferba24

@ferba24 ferba24 commented Sep 6, 2026

Copy link
Copy Markdown

Summary

With a few thousand images in the list the GUI thread stutters during a batch and stalls at the end of it; clicking rows while compressing could freeze the window until the batch finished. This series removes the GUI-thread work that scales with the number of rows and moves the remaining blocking calls off the GUI thread. Compression throughput itself is unchanged (it is bound by libcaesium and, in "same folder" mode, by the per-image Recycle Bin operation).

Each commit is self-contained, builds on its own, and its message explains the why. The changes are meant to be behaviour-neutral except where noted below.

What changes, per commit

  1. Cache row display data and status icons, uniform row heights — no more HTML/QIcon rebuilds per repaint; setUniformRowHeights(true) (delegate already fixed the height at 24).
  2. Sort on cached keys, reuse one QCollator, O(1) duplicate check — lessThan no longer rebuilds strings or constructs a collator per comparison; contains() uses a ref-counted hash.
  3. Remove rows in ranges — one rowsRemoved per contiguous range; also fixes partial removal deleting the wrong rows (selectedIndexes() indexed by row).
  4. Coalesce row updates during compression — progressValueChanged (a count) was used as a row index and a final loop re-emitted dataChanged for every row; workers now report rows through a queue flushed every 150 ms as ranges; dynamic sorting is paused for the batch and applied once at the end.
  5. Dedicated thread pools, previews never block — compression on its own pool, previews on a single-thread pool, no waitForFinished() on the GUI thread, options/cache path captured on the GUI thread (the old lambda read widgets from a worker).
  6. Asynchronous import — folder scan and image probing in a worker with a cancellable, immediately visible progress dialog; dropEvent releases Explorer right away.
  7. Rename temp output into place — no second full write per image; preview-cache probe (a QSettings enumeration + two hashes per image) only when converting, where it actually matters.
  8. Keep the log file open — one open file behind a mutex, flush per line; closeLogFile() was a no-op.
  9. Windows: runtime DLLs next to the exe + windeployqt helper — a local build could not start (WinSparkle.dll was not found).
  10. Tests — QtTest coverage for the model, importer, compression flow (real libcaesium), logger, plus a stress harness (CAESIUM_STRESS_N, skip with CAESIUM_SKIP_STRESS=1).

Behaviour changes to be aware of

  • Default for multithreading_max_threads becomes min(idealThreadCount, 8) (libcaesium parallelises internally; each worker holds a decoded image). Existing saved preferences are untouched.
  • While a batch runs, the list is not re-sorted live (sorted once at the end) and drag-and-drop onto the list is disabled (appending rows during a batch was undefined behaviour). Imports requested during a batch are queued.
  • Partial "Remove" with a non-contiguous selection now removes the selected rows (previously wrong rows).

Measurements (Windows 11, Qt 6.8.2 MinGW, 20 threads, list sorted by "Saved", same-folder overwrite)

GUI-thread latency probed with SendMessageTimeout(WM_NULL) every 50 ms during the whole run:

Scenario Build Mean latency Max stall Stalls > 100 ms Compression time
5000 JPEG 640×480 main 25.2 ms 617 ms 81 78.1 s
5000 JPEG 640×480 this PR 0.2 ms 26 ms 0 77.9 s
1000 JPEG/PNG 2400×1600–3600×2400 main 6.8 ms 167 ms 8 24.6 s
1000 JPEG/PNG 2400×1600–3600×2400 this PR 0.5 ms 26 ms 0 22.2 s

Baseline stalls cluster at the end of the batch (the per-row dataChanged loop plus re-sorts by "Saved"). Clicking rows mid-batch on this branch keeps the window responsive and loads the preview of the last click.

Unit/integration tests: 76 cases plus the stress harness at N=1000 pass (ctest --test-dir build/tests).

Notes

Fernando added 10 commits September 6, 2026 11:16
With thousands of images in the list, every repaint of a row rebuilt the HTML
name string (including a palette lookup), re-created a QIcon from the SVG
resource for the status column and re-read mutable CImage fields that worker
threads write during a batch. Because the tree view had non-uniform row
heights, QTreeView also asked the delegate for a sizeHint of every column of
every row on each relayout.

Cache the display strings, the status and the compressed path per
CImageTreeItem (refreshed on the GUI thread), keep the five status pixmaps in
the model, cache the palette-dependent colour string, and enable
setUniformRowHeights(true) — the delegate already returns a fixed height of 24,
so this is visually neutral.

CImageTreeItem now owns its CImage through a std::shared_ptr so that a preview
job can keep the image alive after the row is removed (used by later commits).
CImageSortFilterProxyModel::lessThan called sourceModel()->data() for both
sides (rebuilding the HTML name string each time) and naturalLessThan
constructed a new QCollator in numeric mode for every comparison. Sorting
5000 rows means tens of thousands of comparisons, so importing a large folder
paused for seconds after the progress dialog closed.

Compare the cached display name (the exact string that was compared before,
so the resulting order is unchanged) and keep one thread_local QCollator.
Ratio/info comparisons use the cached values so that a header click during a
batch never reads fields a worker thread is writing.

CImageTreeModel::contains() scanned every row for each imported file, which
made a second import into a non-empty list O(existing x new). Keep a
reference-counted QHash of full paths instead (reference-counted so that
duplicate rows, which are still allowed, stay consistent on removal).
"Clear" selected every row and then called removeRows(row, 1) once per row.
Each call emitted its own rowsRemoved and itemsChanged, so the proxy model
rebuilt its mapping and the status bar recomputed the total size for every
single row: clearing a 5000-row list took seconds of GUI time.

Add CImageTreeModel::removeItems(rows), which groups the rows into
contiguous ranges (one beginRemoveRows/endRemoveRows per range) and emits
itemsChanged once. Removed items are now deleted (they leaked before).

While here, fix removeFiles() for partial selections: it iterated
selectedIndexes() (five entries per row) with a row-based index, so removing
a non-contiguous selection deleted the wrong rows. Use selectedRows() and
map each row once.
During a batch the QFutureWatcher's progressValueChanged signal was
connected to CImageTreeModel::emitDataChanged. That signal carries the
number of completed items, not a row, so the wrong row was refreshed on every
tick; to compensate, a lambda on finished() re-emitted dataChanged for every
row of the model, one at a time. With the list sorted by "Saved" each of those
emissions makes QSortFilterProxyModel re-sort, and with 5000 rows the GUI
thread stalled for hundreds of milliseconds per tick towards the end of the
batch (measured: up to 617 ms per stall, 81 stalls above 100 ms).

Move the batch orchestration into CImageTreeModel::compress(). Workers push
the started/finished row into a mutex-protected queue and post a flush to
the model's thread; a single-shot 150 ms timer applies the queued updates,
refreshes the per-row caches and emits one dataChanged per contiguous range.
flushPendingUpdates() drains the queue synchronously, which compressionFinished()
uses before showing the summary. MainWindow disables the proxy's dynamic
sorting for the duration of the batch and re-enables it at the end, which
sorts the list exactly once. The progress bar/label semantics are unchanged.

The QFutureWatcher is now created and connected once instead of being leaked
and re-connected on every batch.
Compression ran on QThreadPool::globalInstance(), resized to the thread
preference (default: all cores). Preview jobs used the same pool, so
selecting a row during a batch queued the preview behind every pending
compression, and the next selection called previewWatcher->waitForFinished()
on the GUI thread — the window froze until a pool thread became free, which
on a long batch means until the end.

- Compression gets its own QThreadPool (max threads = the existing
  preference, priority as before). The default for the preference becomes
  min(idealThreadCount, 8): libcaesium already parallelises internally and
  every worker holds a fully decoded image, so 24 threads on a 24-core box
  mostly oversubscribe and multiply peak memory. Saved preferences are kept.
- Previews run on a separate single-thread pool. previewImage() cancels the
  pending job and starts the new one without waiting; the compression options
  and the preview cache path are captured on the GUI thread (the old lambda
  read widgets from a worker) so a settings change while a preview is queued
  can no longer store a preview under the wrong cache key.
- Drops onto the list are disabled while a batch runs (rows appended during a
  batch were undefined behaviour), and closing the window during a batch
  cancels it and waits for the in-flight images without blocking the GUI
  before the cache is cleared.
importFiles() ran entirely on the GUI thread: for every file it opened a
QImageReader (the WebP handler reads the whole file), resolved the canonical
path twice, hashed the path and pumped the event loop through
QProgressDialog::setValue. Folder scanning happened before any progress UI,
and for drag-and-drop it happened inside dropEvent(), so Explorer's drag
source stayed blocked until the scan finished. Thousands of files on a slow
disk meant a long modal "Importing files..." with a frozen window.

Add Importer::buildImages() (probe + CImage construction with a cancel flag
and a progress callback) and a cancellable scanList() overload, and run scan
and probe in a QtConcurrent job with a QPromise for progress. The progress
dialog shows immediately in busy mode while scanning, switches to a real
range once the file list is known, and Cancel keeps what was imported so far,
as before. dropEvent() now only emits the dropped paths. Imports requested
while a batch is running are queued and started when it finishes;
"import and compress" from the command line compresses when the import
completes. Dedupe/skip semantics, folder map and root folder computation are
unchanged.

CImage: derive the directory from the canonical file path instead of a second
filesystem round-trip, and free the QImageReader before throwing
ImageTooBigException.
… when not converting

CImage::compress() wrote the result to a QTemporaryFile in the output folder
and then QFile::copy'd it to the final name, i.e. every output was written
twice. Rename it instead: the temp file lives in the same directory, so this
is a metadata operation. On Windows QTemporaryFile keeps its handle open, so
the rename has to go through the QTemporaryFile object itself (a static
QFile::rename fails with "cannot remove source file"); autoRemove is
disabled only after a successful rename, otherwise the destructor would
delete the final file. The skip-if-bigger path, which publishes the
*original* file, keeps copying.

Before calling libcaesium the code also looked for a cached preview:
getTemporaryPreviewFullPath() enumerates every compression_options key from
QSettings, hashes twice and calls mkpath — per image, from every worker
thread. When no format conversion happens the copied preview is overwritten
by c_compress anyway, so only do that probe when converting (where the temp
file is the conversion result and the cached preview replaces it).

setCompressedInfo() resolves the canonical path once instead of three times.
Logger::messageHandler opened, appended to and closed the log file for every
single message (plus a mkpath check). Batches with many unsupported or
failing files log from all worker threads at once, and with antivirus
scanning each open this became measurable.

Keep one QFile open behind a mutex, flush after every line (so a crash still
leaves the log complete), reopen when the date changes, and make
closeLogFile() actually close it — the old implementation inspected a fresh,
never-opened QFile and was a no-op. Open mode is unchanged (no
QIODevice::Text, so line endings stay as they were).
…qt helper

A local Windows build could not be started from the build directory:
caesium.dll and WinSparkle.dll stayed inside their ExternalProject prefixes
and the loader reported "WinSparkle.dll was not found". Copy both after
linking, and add scripts/deploy-windows.sh, which runs windeployqt so the
build folder is self-contained (Qt DLLs, platform and image-format plugins).
…stress harness

The test target only compiled Importer.cpp and had a single case. Add QtTest
coverage for the behaviour touched by the previous commits:

- CImageTreeModelTests / NewApiModelTests: batched inserts emit one
  rowsInserted, ranged removal, reference-counted contains(), cached display
  data, base-folder changes.
- SortProxyTests: natural order by name, folder grouping, numeric columns,
  no relayout for non-sort-column changes, sort cost guard.
- ImporterBuildImagesTests: metadata, unsupported/corrupt/empty files,
  duplicates kept, cancellation, monotonic progress, ownership.
- CImageCompressTests (real libcaesium): decodable output, skip-if-bigger
  with and without an existing output, keep dates, same-folder modes, keep
  structure, format conversion, failure paths, no temp leftovers, stale
  preview files never reach the output.
- CompressionFlowTests: per-row signals, statuses after a batch, only-failed
  retry, coalesced dataChanged, synchronous flush, cancellation, empty model,
  GUI reads during a batch.
- LoggerTests: throughput, concurrent writers, close/reopen, directory creation.
- StressTests: synthetic corpus (CAESIUM_STRESS_N, default 1000) exercising
  import, append+sort, dedupe, compress, overwrite, cancel, clear and a
  memory sample; skipped with CAESIUM_SKIP_STRESS=1.

The test binary links the real caesium.dll (copied next to it), embeds the
status icon resources, runs with QT_QPA_PLATFORM=offscreen and uses a
QApplication. Existing ImporterTests assertions were converted from Q_ASSERT
(a no-op in Release) to QVERIFY/QCOMPARE.
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