Skip to content

Fix song select scrolling performance when user has many beatmaps loaded#37666

Merged
bdach merged 5 commits intoppy:masterfrom
peppy:fix-ss-perf
May 8, 2026
Merged

Fix song select scrolling performance when user has many beatmaps loaded#37666
bdach merged 5 commits intoppy:masterfrom
peppy:fix-ss-perf

Conversation

@peppy
Copy link
Copy Markdown
Member

@peppy peppy commented May 7, 2026

Also fixes wrong rank showing briefly in some scenarios.


I'm quite confused why the overhead is in the post-async-filter collection access, but it is. It occurs when using MaxBy (realm snapshot creation), but also when calling .Count on the collection, or even just accessing sender[0]. I tried everything, and ended up settling on simplifying the realm part enough that we can do post-filtering without much sweat or mess.

Before:

osu.2026-05-07.at.11.41.58.mp4

After:

osu.2026-05-07.at.11.41.17.mp4

Tested using this realm.

peppy added 2 commits May 7, 2026 20:40
The real killer here is filters on linked objects. Use the knowledge
that filtering down to a beatmap's full scores is already quite refined,
and move the slow parts out to post-filtering.
Already fast enough without this that I cannot see a difference, but
best to have it in place.
@peppy peppy changed the title Fix ss perf Fix song select scrolling performance when user has many beatmaps loaded May 7, 2026
@peppy peppy requested a review from a team May 7, 2026 13:24
@bdach bdach self-requested a review May 8, 2026 06:02
Comment thread osu.Game/Database/RealmAccess.cs Outdated
/// 51 2025-07-22 Add ScoreInfo.Pauses.
/// </summary>
private const int schema_version = 51;
private const int schema_version = 52;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason to bump schema version?

Adding [Indexed] to a property shouldn't require a schema version bump as per realm/realm-dotnet#3370 (comment).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah okay, I seem to recall that being a thing but wasn't sure. In which case this isn't required.

Comment thread osu.Game.Tests/Visual/SongSelect/TestScenePanelBeatmap.cs Fixed
Comment thread osu.Game/Screens/Select/PanelLocalRankDisplay.cs
@bdach
Copy link
Copy Markdown
Collaborator

bdach commented May 8, 2026

After some further staring I'm still not convinced that GetAllLocalScoresForUser() is in any way bad. The [Indexed] thing is good, and pulling the MaxBy() to .NET land is good, but I'm not wholly sold on redefining the subscription.

That said, my one-shot imprecise profiling results are not exactly super conclusive other than saying that pretty much any variant I jiggled towards is faster than master, and I'd rather not get mired in a profiling adventure, so this goes in I guess to be tested by users and reconsidered if it doesn't help? 🤷

@peppy
Copy link
Copy Markdown
Member Author

peppy commented May 8, 2026

That said, my one-shot imprecise profiling results are not exactly super conclusive other than saying that pretty much any variant I jiggled towards is faster than master, and I'd rather not get mired in a profiling adventure, so this goes in I guess to be tested by users and reconsidered if it doesn't help? 🤷

FWIW my best profiling was just scrolling in song select with that database in "sort by difficulty" (splitting out individual difficulties) and watching for scheduler spikes, as per videos in OP.

You can test with the original method and the modified one and see a diferenence of 2-10 milliseconds, hopefully.

@bdach bdach merged commit 08c02e2 into ppy:master May 8, 2026
7 of 10 checks passed
ShikkesoraSIM added a commit to ShikkesoraSIM/torii-osu that referenced this pull request May 8, 2026
Backports the song-select scrolling perf fix landed upstream as
ppy/osu#37666. Original symptom: scrolling song select with a large
beatmap library spent 2-10ms per tick inside Realm running a filter on
linked objects (`ScoreInfo.BeatmapInfo.ID` + `ScoreInfo.Ruleset.ShortName`),
because Realm has to walk each candidate's linked rows to evaluate
those predicates.

The rewrite moves the filter shape from "linked-object filter inside
Realm" to "flat field filter inside Realm + LINQ narrow in .NET":

  - Realm subscription now matches on the flat `BeatmapHash` column
    only (and `!s.DeletePending`). Realm can satisfy this without
    walking links.
  - The ruleset / user-id narrowing happens post-fetch on the much
    smaller candidate set, in plain LINQ.
  - Mirror upstream's `setRankFromScore(null)` reset on subscription
    re-arm — fixes the secondary bug ppy/osu#37661 where the previous
    beatmap's rank was briefly visible during transitions.

Diverges from upstream in one deliberate way: we DO NOT add the
`[Indexed]` attribute on `ScoreInfo.BeatmapHash` that the original PR
includes for an extra ~10-30% bump on the same query. Adding it would
require bumping the Realm schema 51 → 52, which Torii explicitly avoids
to preserve compatibility with vanilla osu! lazer opening shared
realm folders (see RealmAccess.cs schema_version comment +
RealmDowngrader). The filter rewrite alone — which the PR description
calls "the real killer" — applies cleanly here.

Test step also ported: TestLocalRank now starts from a null-rank state
so transitions out of "no rank yet" are part of the regression matrix.
ShikkesoraSIM added a commit to ShikkesoraSIM/torii-osu that referenced this pull request May 8, 2026
ShikkesoraSIM added a commit to ShikkesoraSIM/torii-osu that referenced this pull request May 8, 2026
…s + screen context

Turns the hiccup dashboard from "Unknown — see context fields" noise
into actionable observability. Per-record context now includes screen
+ visible overlays + the last 16 cross-codebase events leading up to
the stall, and the cause heuristic chooses the freshest correlated
breadcrumb instead of just falling back to "Unknown".

A typical post-A hiccup record reads as:

    {
      "frame_ms": 187.4,
      "current_screen": "SongSelectV2",
      "visible_overlays": ["ChatOverlay"],
      "likely_cause": "Realm query on update thread 50 ms ago — update-thread (87 ms)",
      "recent_events": [
        {"kind": "input.click",          "detail": "..."},
        {"kind": "carousel.filter",      "detail": "text='abc' reset=true"},
        {"kind": "realm.run",            "detail": "update-thread (87 ms)"},
        {"kind": "api.request.start",    "detail": "GET /scores/123"}
      ],
      ...
    }

Pieces shipped
--------------

* `HiccupBreadcrumbs` static sink (new). Single null-check fast path
  when the logger is OFF — sprinkling `HiccupBreadcrumbs.Add(...)`
  across hot paths costs nothing in production builds. Volatile
  reference cell + ring-buffer atomic increment on the hot path; safe
  to call from any thread. The logger registers itself on load and
  unregisters on dispose.

* `OsuGame.CurrentTopScreen` + `OsuGame.RegisteredFocusedOverlays`
  internal accessors. Read-only views over the existing screen stack
  + focused-overlay list, so the logger can capture screen + visible-
  overlays context without reflection or breaking encapsulation.

* Hookpoints emitting breadcrumbs:
    * Screen push / exit (OsuGame.screenPushed / screenExited).
    * Overlay show / hide (every OsuFocusedOverlayContainer registered
      via either RegisterBlockingOverlay or loadComponentSingleFile —
      hookOverlayBreadcrumbs binds to State changes).
    * API requests, both start + end with duration (APIRequest.Perform).
      Catches sync waits that would show as "request in flight" at
      hiccup time.
    * RealmAccess.Run / Run<T> on the update thread, with duration ≥1ms.
      Catches the realm-query-stalls-the-UI-thread pattern that
      ppy/osu#37666 fixed for the song-select rank panel.
    * BeatmapCarousel.Filter — first instrumented operation in the
      song-select hot path; more carousel hooks to come.
    * BeatmapManager / ScoreManager / SkinManager PostNotification —
      proxies user-visible notifications into breadcrumbs so an import
      / score completion / skin change is visible in the recent-events
      window.

* Threshold default bumped 33 ms → 80 ms. The 33-ms floor was
  capturing every borderline frame (vsync misses, harmless Gen0 GCs)
  and flooding the dashboard with noise. 80 ms means "the user
  definitely felt this stutter". Configurable as before via the
  ThresholdMs property; the constant is the default-when-not-set.

* `guessCause` rewritten. Walks the recent-events ring buffer newest
  first within a 500 ms correlation window and picks the most
  specific breadcrumb-driven reason. Falls back to GC counts and
  frame_ms only when no breadcrumb explains the stall. Reads like a
  switch over kinds — easy to extend with more breadcrumb sources.

* `currentScreenName` + `collectVisibleOverlays` now actually return
  values (the v1 stubs returned null because the tree-walk was
  blocked by `protected` InternalChildren). They read from OsuGame's
  new internal accessors instead.

What's NOT in this commit (deferred)
------------------------------------

Path B (stack-trace sampling on stalls) needs more investigation
than fits cleanly into one session. The right approach is probably
EventPipe via DiagnosticsClient + the TraceEvent parsing library,
which is a ~25MB dep we'd be adding to the desktop bundle. Path A
ships first; if data quality is still insufficient after we have
real breadcrumb-flavoured records flowing in, we revisit.

Doesn't touch the existing client uploader pipeline, the server
ingest endpoint, or the dashboard UI — Path A's record fields are
already on the schema (recent_events / current_screen /
visible_overlays were nullable strings / json on the table from the
v1 ingest commit). The dashboard's modal already renders them; with
Path A populated they'll just suddenly have content.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

3 participants