Files
memby/PERFORMANCE.md
T

8.0 KiB
Raw Blame History

Performance: the Wholphin review, and what came of it

An engineering note on how Memby's performance work compares with Wholphin, which techniques were taken from it, and what the licence requires. It is a record of decisions, not a tutorial — the reasoning behind each individual mechanism lives beside the code in CLAUDE.md.

Licensing position

Memby is GPL-2.0. Wholphin is GPL-2.0. Adapting Wholphin source into Memby is therefore permitted outright, provided the combined work remains under GPLv2 and attribution is preserved. Both obligations are met in NOTICE, which names Wholphin, names the Memby files adapted from it, records that Memby's modifications are its own, and notes that Wholphin's own profiling code descends from Jellyfin Android TV.

Two practical rules were followed:

  • No verbatim copying. Every technique below was reimplemented against Memby's own architecture. That is a deliberate choice rather than a licence requirement — Memby's repository is dual-path (gateway and direct-to-Emby) and its player is a single PlayerActivity with a pre-roll, credits pane and trailer chain, so ported code would have needed more reshaping than rewriting.
  • Attribution attaches to techniques, not only to code. PlayerEngine.kt and TrickplayPreview.kt carry in-file attribution because their design is Wholphin's even though the source is not. Anything reproduced independently is marked below so the distinction stays legible.

Comparison

Wholphin's speed comes from a small number of decisions rather than from micro-optimisation, and Memby had already absorbed most of them before this review:

Technique Wholphin Memby
One shared OkHttp connection pool across API, artwork and media yes yes — data/remote/HttpStack.kt
Media bytes pulled through the app's own HTTP client yes yes — PlayerEngine.streamClient, adapted
Constant-bitrate seeking for containers with no usable seek table yes yes — PlayerEngine.extractorsFactory, adapted
Seek-preview thumbnails from a server-side index tile sheets BIF, independently implemented — data/Trickplay.kt
Cached home rendered before the network answers yes yes — HomeCache, per profile
Server-side artwork resizing rather than full-resolution downloads yes yes — maxWidth/quality on both paths
Request de-duplication and single-flighting yes yes — repository-wide, including focus prefetches
Speculative resolution while a card is focused partial yes — two-wave warm, metadata then detail page
Pre-opened connection to the media host n/a (same host as API) new, see below

The last row is the one real divergence, and it is architectural rather than a matter of discipline. In Wholphin the API and the media stream are the same Jellyfin host, so the connection is already warm by the time anybody presses Play. In Memby's gateway mode they are two different machines: everything except video goes to the gateway, and video direct-plays from Emby. Wholphin gets that warm connection for free; Memby had to be told to open one.

Bottleneck identified

Memby's own measurements (Chromecast with Google TV, NAS gateway, MembyPlayback trace) had already established the shape of a resume: prepare() → first frame is over 90% of it, stream negotiation ~120 ms, app startup ~365 ms. Within that, the largest single term was cold versus warm connection to Emby — the same file at the same seek position took 4177 ms on the first playback of a session and 2004 ms on the second. The first playback pays DNS, the TCP handshake, the TLS handshake and Emby's file open, none of which the second pays.

Two things that look like causes and are not: subtitle auto-selection costs 20200 ms, and the seek itself about 900 ms.

What was implemented

data/remote/StreamWarmer.kt — opens a connection to the machine video comes from before any video is requested. It works only because of the shared pool: a connection opened by an unrelated HEAD request is the one ExoPlayer picks up later, so nothing is handed over but the address.

The design constraints that shaped it are recorded in CLAUDE.md; in brief, it warms the host and never the title (warming the film's first byte would put a delivery for a title nobody watched into somebody's server history, which is the same objection that stops this app warming PlaybackInfo on focus); it remembers the address across process restarts, since on the gateway path a resolved stream URL is the only thing that ever names Emby and the slowest playback was always the first one after a cold start; it is warmed both at application start and when card focus settles on a playable item; and every failure is silent, because it is a hint whose worst case is the behaviour that existed before it.

This is reproduced independently. Wholphin has no equivalent — it does not have the problem.

performance/StartupTrace.kt — the launch and navigation timings the playback trace did not cover: home_visible, first_row_visible, home_interactive as cumulative launch milestones, and detail_visible as a repeatable span. Debug-only, and every entry point returns before allocating on a release build. Milestones are recorded from LaunchedEffects rather than composable bodies, so measuring the launcher cannot itself be a reason the launcher recomposes.

PlaybackTrace.AUDIO_START — recorded from a media3 AnalyticsListener, which is the only interface reporting the moment the audio position genuinely begins advancing. It is worth having beside first_frame rather than inferred from it: a bitstreamed surround track waits on the receiver to lock to the format, and a track with no hardware decoder falls back to software. Either leaves a picture running silently for a moment, which a viewer reads as broken rather than slow, and which no video-side mark would show.

What was reviewed and deliberately left alone

Most of the brief's checklist was already satisfied, and saying so is more useful than changing working code:

  • Artwork is already resized server-side (maxWidth and quality on both the gateway proxy and direct Emby paths), cached in a memory cache sized at 25% and a 128 MB disk cache, decoded to hardware bitmaps, and fetched over the shared pool with crossfade off.
  • Home startup already renders HomeCache before the network answers, decodes that cache off the main thread, skips unchanged writes, and refreshes sections in parallel.
  • Compose is already disciplined in the ways that matter here: HomeViewModel exposes three distinctUntilChanged projections rather than one state object, animated values are read only in draw and layout lambdas, and lists deduplicate keys rather than folding in an index.
  • Request cancellation and de-duplication are already repository-wide, with focus prefetches single-flighted on the repository's own scope precisely so a cancelled prefetch does not abort the request a press is about to want.
  • Returning from playback already restores tab, season, grid offset and focused band via ui/detail/DetailPosition.kt, and deliberately re-requests the item because the episode just watched is now watched.

No obsolete workaround was found that the new work makes redundant; the connection warm adds a capability rather than replacing a compensation.

Measuring it

adb logcat -s MembyStartup MembyPlayback

MembyStartup prints launch milestones as stage=cumulative(+delta) and detail spans as detail_visible=Nms. MembyPlayback prints one event=first_frame line per launch carrying the full stage breakdown.

The connection warm is a network effect and cannot be measured by a unit test. The honest comparison is first-playback-of-a-session against second, on one television, server and title — the pairing the original 4177/2004 ms figure came from. :benchmark's PlaybackBenchmark measures the same span off trace sections when a signed-in television is available; it needs a real Emby, which is why it is excluded from ordinary CI.