136 lines
8.0 KiB
Markdown
136 lines
8.0 KiB
Markdown
# Performance: the Wholphin review, and what came of it
|
||
|
||
An engineering note on how Memby's performance work compares with
|
||
[Wholphin](https://github.com/damontecres/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 20–200 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 `LaunchedEffect`s
|
||
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.
|