8000 Perf/message switch by valentin-morice · Pull Request #608 · hyprlab/hylki · GitHub
[go: up one dir, main page]

Skip to content

Perf/message switch - #608

Open
valentin-morice wants to merge 8 commits into
hyprlab:mainfrom
valentin-morice:perf/message-switch
Open

valentin-morice wants to merge 8 commits into
hyprlab:mainfrom
valentin-morice:perf/message-switch

Conversation

@valentin-morice
@valentin-morice valentin-morice commented Nov 6, 2026 •
Copy link
Copy Markdown
Contributor

Hi! Here's another round of performance improvements. I've added a table at the bottom of this comment with the before/after measurements.

What this fixes

After #570, opening a message changes the pane in one step, but the step could still take a while, longest on messages with large attachments or pictures in their body: from about 100 ms for a plain message to 360 ms for a conversation with a 19 MB spreadsheet and 760 ms for one with 3 MB of pictures in each message. While a big message was open, every other app event slowed down too, and clicking quickly through such messages delayed the next click.

I measured before changing anything (method below). The time went to work done several times over on the main thread: copying the open message and attachment bytes, asking for the same attachments again, rebuilding the drawer and its thumbnails, building and parsing pages of megabytes, and waiting for frames the freeze from #570 already covers. Each commit takes one of these away.

How

perf(reader): lend the toolbar's target instead of copying it

reply_target() returned an owned Message, and the window's view calls it, directly or through acts_on_selection, toolbar_star_lit, target_in_junk and snooze_offered, about seventeen times each time it updates, which is after every app event. With a 3 MB body that was 18 to 22 ms per event. It now returns Option<&Message>; the actions that keep the message clone it once.

perf(reader): ask for a message's attachments once per opening

Opening a conversation asked for each member's attachments from the opened message, from the conversation, and again when the related lookup answered, while the first requests were still in flight. Each answer carried the bytes, so a 19 MB file was read and handled three times. attachments_asked records what this opening has requested, and is cleared on the next selection. A conversation's drawer is also no longer replaced by the opened message's files alone; the single-message loader now runs for single messages only.

perf(attachments): share a file's bytes instead of copying them

Attachment::data is an Arc<[u8]> instead of a Vec<u8> (GalleryItem::data too), so handing a file to the cache, the drawer, a pop-out window or the gallery costs a reference count. Each arrival used to copy it three to five times on the main thread, about 10 ms per copy of 19 MB. Saving a file and rendering a PDF preview take the shared bytes as well.

fix(attachments): log an attachment by its name and size

The derived Debug wrote an attachment's bytes out one number at a time, and relm4 logs every component input at the info level: running with RUST_LOG=info held the main loop for up to 1.6 s on attachment-heavy conversations. Attachment and GalleryItem now show the file's name and size.

perf(attachments): build the drawer once and render each thumbnail once

The drawer was handed the whole list again as each member's files came in, eight to twelve times per opening, and rebuilt every time; each rebuild started the thumbnail renders still under way again. Four pictures were decoded 32 times, and five PDFs about 25 times one after another behind the PDF lock, so their previews trickled in for three seconds, into the next messages. The drawer now leaves a list it already shows alone (Attachment derives PartialEq, which compares the shared allocation first), a thumbnail requested while it renders waits for that render (join_render / finish_render), and the reader skips an unchanged set of card attachment rows.

perf(reader): skip the frame waits the still already covers

An opening waited for a GTK frame before handing the page to WebKit (three frames when the drawer came or went), then two animation frames before the page posted ready, then two more before the still was lifted. The first two keep a black or half-placed page from being seen, which the still from #570 already prevents, and the last wait makes sure the page is drawn. When the still is up (ReaderSwap::covered()), the page is handed over at once and stamped data-hylki-covered, which makes it post ready without waiting. Without a still, nothing changes.

fix(reader): take a page's ready report once

The 600 ms backstop for a page that never reports fired after every load, including those that did. Taken as a second report, it patched the page again, and if another message had been selected and its page had not started loading yet, it could lift that message's still early. A second report for the same page is now dropped.

perf(reader): serve a message's pictures apart from its page

The worker writes pictures shown by Content-ID into the body as data: URIs, so a few photos make a body of megabytes. Each card's document went through about eight passes over it (remote content, dark colors, the reader's sheet, the CSP, escaping into srcdoc), and WebKit parsed the result twice: a conversation of two such messages made a 6.3 MB page. src/services/inline_images.rs (new) lifts large data:image/…;base64 URIs out of each card before those passes and serves them from memory under a hylki-inline: scheme, registered like the remote content scheme (secure, CORS-enabled, in the view's allowlist) and decoded off the main thread. The same page is now 143 KB.

  • Reader View is built from the body as before and its pictures are lifted after it; printing builds its own document and keeps the data: URIs.
  • Save Image and Copy Image resolve lifted pictures (links::own_picture).
  • A picture copied out of the reader with its text keeps the hylki-inline: URL. The composer converts it to a data: URI as it lands, as it already did for pasted blob: images, so a sent message carries its own bytes.
  • hylki-inline: counts as the message's own content, not remote content, and is allowed in the frame's img-src whether remote content is allowed or not.

Timing

From the selection to the first frame painted with the new message, median of three runs. The 22 newest conversations of a real IMAP inbox were opened top to bottom (first open), then bottom to top (already in memory).

Opened First open, before → after Again, before → after
Single message, plain 101–127 → 83–102 ms 111–146 → 81–118 ms
Single message, 4 KB or 544 KB attached 112 → 97–103 ms 140–171 → 104–137 ms
Conversation of 2, plain 135 → 99 ms 121 → 101 ms
Conversation of 9, plain 254 → 247 ms 254 → 238 ms
Conversation of 2, 2.9 MB attached 165 → 124 ms 150 → 111 ms
Conversation of 2, 19 MB attached 358 → 153 ms 186 → 108 ms
Conversation of 8, five PDFs and two pictures 291 → 265 ms 279 → 208 ms
Conversation of 2, 1.9 MB page with pictures 234 → 151 ms 197 → 142 ms
Conversation of 2, 6.3 MB page with pictures 757 → 264 ms 451 → 247 ms
All 22 conversations 4641 → 3384 ms 3834 → 2707 ms

Stepping through the same 22 every 250 ms, 19 to 20 now paint before the next selection (15 to 16 before), and the selection after the 6.3 MB conversation is no longer delayed (13 to 206 ms before).

Measured with temporary logging that is not part of this branch: a release build without LTO, a copy of the profile kept offline, on a hidden X display with software rendering, so absolute times on a GPU differ.

Testing

  • cargo test: all pass. The golden documents changed only for hylki-inline: in the frame's CSP and the sizing script's data-hylki-covered check; the editor's script pin is updated for the paste.js change.
  • New tests: lifting pictures (sizes, terminators, non-images, the same picture twice), coalesced thumbnail renders, and an attachment's Debug output.
  • Real engine, run by hand: the_wrapper_in_a_real_engine_sizes_frames_and_refuses_injected_script and the new a_lifted_picture_in_a_real_engine_shows_and_fetches, which checks that a frame shows a lifted picture and that a page can fetch it, as the composer does. Each passes run alone; GTK starts on one thread per process, so the two cannot share a run.
  • Used on a real IMAP account, on a GPU.

The toolbar asks for the message it acts on about seventeen times each time the window redraws, which it does after every app event, and each ask copied the whole message, body included. With pictures in its body a message runs to megabytes: opening one such conversation spent around 200 ms copying it. reply_target now returns a reference, and the actions that keep the message clone it once.
Opening a conversation asked for each member's attachments from three places: the opened message, its conversation, and the related lookup when it answered, while the first asks were still on their way. Each answer carried the files' bytes and was copied on the main thread, so a 19 MB spreadsheet was read and handled three times. The reader now remembers which messages it has asked about since the selection, and a conversation's drawer is no longer replaced by the opened message's files alone.
An attachment held its bytes in a Vec, and the reader passes the same files to its cache, its drawer, every redraw of a conversation's drawer, popped-out windows and the gallery. Each hand-over copied them on the main thread: about 10 ms per copy of a 19 MB file, five copies or more per arrival. The bytes are now an Arc<[u8]>, so passing a file along is a reference count. Saving a file and rendering a PDF preview take the shared bytes too, rather than a copy of their own.
Attachment and GalleryItem derived Debug, which writes their bytes out number by number. relm4 logs every component input at the info level, so running with RUST_LOG=info turned each drawer or gallery update into megabytes of text and held the main loop for up to 1.6 s on attachment-heavy conversations. Both now show the file's name and size.
A conversation's drawer was handed its whole list again as each member's files came in, eight to twelve times per opening, and rebuilt its grid every time. Each rebuild started the renders of thumbnails still under way again: four pictures were decoded 32 times, and five PDFs about 25 times, one after another behind the PDF lock, so their pages trickled in for three seconds. The drawer now leaves a list it already shows alone, the reader does the same for the cards' attachment rows, and a thumbnail asked for while it renders waits for that render.
Opening a message waited for frames three times over: before handing the page to WebKit (three frames when the drawer came or went), two animation frames before the page reported ready, and two more before the still was lifted. The first two keep a black or half-placed page from being seen, which cannot happen under the still, and the last wait already makes sure the page is drawn. A page loaded under the still is now handed over at once and reports ready without waiting. Without a still nothing changes.
The backstop for a page that never reports ready fires 600 ms after every load, including the ones that did report. Taken for a second report, it patched the page again, and when another message was opened in that window and its page had not started loading yet, it could lift that message's still early. A second report for the same page is now dropped.
The worker writes the pictures a message shows into its body as base64 data: URIs, so a few photos make a body of megabytes. The reader went over each body eight times to build its page, and WebKit parsed the result twice: a conversation of two such messages made a 6 MB page and took 750 ms to open. Large pictures are now lifted out before those passes and served from memory under a hylki-inline: scheme. Save Image and Copy Image find them there, and the composer turns a pasted one back into a data: URI.
hyprlab added a commit that referenced this pull request Nov 15, 2026
The toolbar asks for the message it acts on about seventeen times each time the window redraws, which it does after every app event, and each ask copied the whole message, body included. With pictures in its body a message runs to megabytes: opening one such conversation spent around 200 ms copying it. reply_target now lends the open message, and the actions that keep it clone it once.

Co-Authored-By: Valentin Morice <100000204+valentin-morice@users.noreply.github.com>
hyprlab added a commit that referenced this pull request Nov 15, 2026
Opening a conversation asked for each member's attachments from three places: the opened message, its conversation, and the related lookup when it answered, while the first asks were still on their way. Each answer carried the files' bytes and was copied on the main thread, so a 19 MB spreadsheet was read and handled three times. The reader now remembers which messages it has asked about since the selection, and a conversation's drawer is no longer replaced by the opened message's files alone.

Co-Authored-By: Valentin Morice <100000204+valentin-morice@users.noreply.github.com>
hyprlab added a commit that referenced this pull request Nov 15, 2026
An attachment held its bytes in a Vec, and the reader passes the same files to its cache, its drawer, every redraw of a conversation's drawer, popped-out windows and the gallery. Each hand-over copied them on the main thread: about 10 ms per copy of a 19 MB file, five copies or more per arrival. The bytes are now an Arc<[u8]>, so passing a file along is a reference count. Saving a file and rendering a PDF preview take the shared bytes too, rather than a copy of their own.

Co-Authored-By: Valentin Morice <100000204+valentin-morice@users.noreply.github.com>
hyprlab added a commit that referenced this pull request Nov 15, 2026
Attachment and GalleryItem derived Debug, which writes their bytes out number by number. relm4 logs every component input at the info level, so running with RUST_LOG=info turned each drawer or gallery update into megabytes of text and held the main loop for up to 1.6 s on attachment-heavy conversations. Both now show the file's name and size.

Co-Authored-By: Valentin Morice <100000204+valentin-morice@users.noreply.github.com>
hyprlab added a commit that referenced this pull request Nov 15, 2026
A conversation's drawer was handed its whole list again as each member's files came in, eight to twelve times per opening, and rebuilt its grid every time. Each rebuild started the renders of thumbnails still under way again: four pictures were decoded 32 times, and five PDFs about 25 times, one after another behind the PDF lock, so their pages trickled in for three seconds. The drawer now leaves a list it already shows alone, the reader does the same for the cards' attachment rows, and a thumbnail asked for while it renders waits for that render.

Co-Authored-By: Valentin Morice <100000204+valentin-morice@users.noreply.github.com>
hyprlab added a commit that referenced this pull request Nov 15, 2026
Opening a message waited for frames three times over: before handing the page to WebKit (three frames when the drawer came or went), two animation frames before the page reported ready, and two more before the still was lifted. The first two keep a black or half-placed page from being seen, which cannot happen under the still, and the last wait already makes sure the page is drawn. A page loaded under the still is now handed over at once and reports ready without waiting. Without a still nothing changes.

Co-Authored-By: Valentin Morice <100000204+valentin-morice@users.noreply.github.com>
hyprlab added a commit that referenced this pull request Nov 15, 2026
The backstop for a page that never reports ready fires 600 ms after every load, including the ones that did report. Taken for a second report, it patched the page again, and when another message was opened in that window and its page had not started loading yet, it could lift that message's still early. A second report for the same page is now dropped.

Co-Authored-By: Valentin Morice <100000204+valentin-morice@users.noreply.github.com>
hyprlab added a commit that referenced this pull request Nov 15, 2026
The worker writes the pictures a message shows into its body as base64 data: URIs, so a few photos make a body of megabytes. The reader went over each body eight times to build its page, and WebKit parsed the result twice: a conversation of two such messages made a 6 MB page and took 750 ms to open. Large pictures are now lifted out before those passes and served from memory under a hylki-inline: scheme. Save Image and Copy Image find them there, and the composer turns a pasted one back into a data: URI.

Co-Authored-By: Valentin Morice <100000204+valentin-morice@users.noreply.github.com>
hyprlab added a commit that referenced this pull request Nov 15, 2026
@hyprlab
hyprlab commented Nov 15, 2026
Copy link
Copy Markdown
Owner

Merged! thanks for the PR, this will ship in the next beta for testing

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

0