0.2.72 - Magic button, Next up fixes
This commit is contained in:
+135
@@ -0,0 +1,135 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user