The read path costs about four times upstream's CPU per request #83

Closed
opened 2026-08-04 20:42:00 +02:00 by Claude · 4 comments
Collaborator

The read path costs about four times upstream's CPU per request, and that is the
whole of the throughput difference.

Measured on one host against a database copied from a running Jellyfin — 55,523
items, 43,466 UserData rows, 116,813 stream rows, 14 users. Both servers ran
natively from a release build, each pinned to its own four CPUs, each given its
own copy of the library, both answering the same eleven listing requests — the
ones both servers answer identically, so this is not a comparison of different
work. Five repetitions, medians below.

Throughput plateaus at a quarter of upstream's.

in flight upstream ours ours/upstream
1 18.6 rps 5.5 rps 0.29
2 33.2 12.3 0.37
4 54.8 15.8 0.29
8 53.4 14.6 0.27
16 52.7 13.3 0.25
32 53.0 13.4 0.25
64 53.5 13.5 0.25
128 53.8 13.4 0.25

Both saturate at four concurrent requests and both stay flat to 128, so neither
is falling over — they are simply CPU-bound at different levels.

The level is the CPU each spends per request. At saturation four cores are
fully busy on each side, so the ceiling is arithmetic:

upstream   4 cores / 74.8ms per request  = 53.5 rps   (measured 53.5)
ours       4 cores / 298ms  per request  = 13.4 rps   (measured 13.4)

Both predictions land on the measured plateau. There is no queueing effect, no
lock, no pool limit to look for: the throughput gap is the per-request CPU
gap, and nothing else contributes to it.

It is not that we send less. Bytes per response are within 1% at every point
— 42.4 KB against 42.4 KB. Both servers are serializing the same pages.

Latency follows. At concurrency 1, with nothing queued: p50 40ms upstream
against 120ms ours, p90 81ms against 504ms. Under a fixed offered rate the gap
becomes the difference between working and not — at 25 requests a second, a rate
upstream serves at 50ms p50, ours delivers 13.4 rps and a p50 of 11.9
seconds
, because it cannot take the rate at all and the queue grows for the
length of the run.

What we do spend it on is the open question. One measurement narrows it:
at concurrency 1 the process uses about 1.8 cores per request, so there is
already parallelism inside a single request, and it is not enough to hide the
cost. Candidates, in the order they are cheap to eliminate:

  • The People expansion — we return 22 cast rows per item on a fifty-item page
    where upstream returns none at all (see the sibling issue on fields a listing
    sends that upstream does not). Twenty-two joins per item, fifty items.
  • Whatever /Persons does, which on the same library never returns at all. If
    the by-name path is doing work proportional to the table rather than the page,
    the same shape may be under these listings.
  • Serialization of a 42 KB page, which is the one part both servers demonstrably
    do the same amount of.

Where we win, for completeness. Cold start to first answer is 617ms against
upstream's 3,546ms, and resident memory at idle is 10.8 MB against 165 MB. Under
load ours peaks higher — 1,160 MB against 905 MB — which is worth its own look.

To reproduce. Point both servers at their own copy of a library of this size
on the same host, pin each to a disjoint set of cores so neither starves the
other, warm both, then hold a fixed number of requests in flight against a
listing both answer identically and read cpu.stat for each:

systemd-run --user --scope -p AllowedCPUs=0-3 -- <upstream>
systemd-run --user --scope -p AllowedCPUs=4-7 -- <ours>

The per-request CPU figures above are usage_usec deltas from each scope
divided by that server's own completed responses. They include a warmup and a
correctness pass, so the absolute numbers are a little high on both sides; the
figures derived from the saturation plateau (74.8ms and 298ms) do not, and the
two agree.

The read path costs about four times upstream's CPU per request, and that is the whole of the throughput difference. Measured on one host against a database copied from a running Jellyfin — 55,523 items, 43,466 `UserData` rows, 116,813 stream rows, 14 users. Both servers ran natively from a release build, each pinned to its own four CPUs, each given its own copy of the library, both answering the same eleven listing requests — the ones both servers answer identically, so this is not a comparison of different work. Five repetitions, medians below. **Throughput plateaus at a quarter of upstream's.** | in flight | upstream | ours | ours/upstream | |---|---|---|---| | 1 | 18.6 rps | 5.5 rps | 0.29 | | 2 | 33.2 | 12.3 | 0.37 | | 4 | 54.8 | 15.8 | 0.29 | | 8 | 53.4 | 14.6 | 0.27 | | 16 | 52.7 | 13.3 | 0.25 | | 32 | 53.0 | 13.4 | 0.25 | | 64 | 53.5 | 13.5 | 0.25 | | 128 | 53.8 | 13.4 | 0.25 | Both saturate at four concurrent requests and both stay flat to 128, so neither is falling over — they are simply CPU-bound at different levels. **The level is the CPU each spends per request.** At saturation four cores are fully busy on each side, so the ceiling is arithmetic: upstream 4 cores / 74.8ms per request = 53.5 rps (measured 53.5) ours 4 cores / 298ms per request = 13.4 rps (measured 13.4) Both predictions land on the measured plateau. There is no queueing effect, no lock, no pool limit to look for: the throughput gap *is* the per-request CPU gap, and nothing else contributes to it. **It is not that we send less.** Bytes per response are within 1% at every point — 42.4 KB against 42.4 KB. Both servers are serializing the same pages. **Latency follows.** At concurrency 1, with nothing queued: p50 40ms upstream against 120ms ours, p90 81ms against 504ms. Under a fixed offered rate the gap becomes the difference between working and not — at 25 requests a second, a rate upstream serves at 50ms p50, ours delivers 13.4 rps and a p50 of **11.9 seconds**, because it cannot take the rate at all and the queue grows for the length of the run. **What we do spend it on is the open question.** One measurement narrows it: at concurrency 1 the process uses about 1.8 cores per request, so there is already parallelism inside a single request, and it is not enough to hide the cost. Candidates, in the order they are cheap to eliminate: - The `People` expansion — we return 22 cast rows per item on a fifty-item page where upstream returns none at all (see the sibling issue on fields a listing sends that upstream does not). Twenty-two joins per item, fifty items. - Whatever `/Persons` does, which on the same library never returns at all. If the by-name path is doing work proportional to the table rather than the page, the same shape may be under these listings. - Serialization of a 42 KB page, which is the one part both servers demonstrably do the same amount of. **Where we win, for completeness.** Cold start to first answer is 617ms against upstream's 3,546ms, and resident memory at idle is 10.8 MB against 165 MB. Under load ours peaks higher — 1,160 MB against 905 MB — which is worth its own look. **To reproduce.** Point both servers at their own copy of a library of this size on the same host, pin each to a disjoint set of cores so neither starves the other, warm both, then hold a fixed number of requests in flight against a listing both answer identically and read `cpu.stat` for each: systemd-run --user --scope -p AllowedCPUs=0-3 -- <upstream> systemd-run --user --scope -p AllowedCPUs=4-7 -- <ours> The per-request CPU figures above are `usage_usec` deltas from each scope divided by that server's own completed responses. They include a warmup and a correctness pass, so the absolute numbers are a little high on both sides; the figures derived from the saturation plateau (74.8ms and 298ms) do not, and the two agree.
Author
Collaborator

Three fixes land most of this. Processor time per request is now 3.5× lower across the seventeen reads a listing client makes, and the largest single cause was not any of the three candidates named above.

Measured on a copy of the same anchor (55,523 items, 43,466 UserData rows), one request at a time, as utime+stime deltas of the server process divided by the requests that produced them — not the harness's gated comparison, so these are our own before-and-after and not a ratio against upstream. All 29 responses captured across listings, by-name routes, search, filters and counts are byte-identical before and after.

route before after
/Items/Counts 541 ms 22.5 ms 24.0×
/Artists 478 63.5 7.5×
/Artists/AlbumArtists 398.5 62.0 6.4×
/Studios 401 74.0 5.4×
/MusicGenres 375.5 84.0 4.5×
/Genres 403 104.5 3.9×
/UserItems/Resume 93.5 25.5 3.7×
/Items (Movie,Episode, IsPlayed) 124 41.0 3.0×
/Items (DateCreated) 93 32.5 2.9×
/Items (Movie, page 1) 60.5 22.5 2.7×
/Items/Latest 101 39.5 2.6×
/Items/Filters2 108.5 43.5 2.5×
/Search/Hints 77 32.5 2.4×
/UserViews 28 16.5 1.7×
/Items (Episode, deep page) 119 78.0 1.5×
/Years 387.5 332.5 1.2×
/Persons 24 22.0 1.1×
total 3813 ms 1096.5 ms 3.5×

The database was never mapped, so a page was read once per connection

This is the one that touches every route, and it is not in the list of candidates above. Without mmap_size every read copies pages out of the file into a per-connection cache whose default 2 MiB holds almost none of a real library's indexes — so the same pages are read again for the next query, and again for the next pooled connection. Four configurations, same measurement:

CPU over 17 routes heap mapped
as shipped 2698 ms 514 MB 20 MB
cache_size 64 MiB 1470 ms 674 MB 20 MB
mmap_size 1 GiB 1120 ms 426 MB 369 MB
both 1109 ms 342 MB 369 MB

Raising the cache instead buys less and costs heap per connection. Mapped, the heap is smaller than it was — pages are read in place rather than copied — and what is counted resident beyond that is file-backed and reclaimable. Neither the file nor a lock is touched, so an upstream server against the same database is unaffected. The cost is that an I/O error inside the mapping arrives as SIGBUS rather than as an error.

This is worth weighing against the RSS note above: under load ours peaks higher — 1,160 MB against 905 MB. That peak is heap, and this makes it smaller.

A by-name page was described one entry at a time

/Artists spent 397 statements on fifty names — fifty image reads each binding a single id, fifty user-data reads, and a Permissions read twice per entry. This is the per-item read assemble::many already existed to replace, and the by-name routes were simply not using it. The page is now resolved once and described whole.

/Artists   397 -> 23 statements
/Genres    281 -> 17
/Studios   396 -> 17

/Items/Counts ran the narrowed query eleven times

Once per kind, and each run re-derived the user's libraries: 33 TopParentId scans and 11 counts for twelve numbers. Upstream sums one grouped query; so do we now — 90 statements to 9.

The value narrowing collected every id before it filtered

named_in and values_in narrowed with ItemValuesMap.ItemId IN (SELECT id ...), which fixes the order of work: every item the query selects is materialised first and the mapping probed once per id. On a listing that narrows to nothing that is the whole library — 279 ms of a 334 ms /Artists. Written as a join, SQLite flattens it and picks its own order, driving from the value type when the query is broad and from the items when it is narrow. Both directions matter: an earlier attempt that forced the value-driven order made /Items/Filters2 (narrowed to Movie) worse, 117 ms to 304 ms.

What is still open

  • /Years — 332 ms, barely moved. It reads every row in the library to keep one integer column, then spends two queries per year resolving the item standing for it (144 statements). It needs the same treatment as the others; the wrinkle is that upstream orders these by the items rather than by the year, so the existing distinct-year projection is not a drop-in.
  • user_libraries costs three index scans per request, on every route. Deciding which libraries a user may see reads every descendant of each of their views — 16,230 ancestor rows for one library — to return a single id. It cannot be made cheaper without an index that is not in upstream's schema, so it wants caching or one call per request instead of one per view.
  • Heap is ~510 MB after a few hundred requests. Reduced, not explained.
  • The concurrency story is unmeasured here. Everything above is one request at a time. Whether the plateau moves needs a harness run, and the two runs attempted today were both disturbed — the first by me killing its server by mistake, the second by my own profiling sharing the host.
Three fixes land most of this. Processor time per request is now **3.5× lower** across the seventeen reads a listing client makes, and the largest single cause was not any of the three candidates named above. Measured on a copy of the same anchor (55,523 items, 43,466 `UserData` rows), one request at a time, as `utime+stime` deltas of the server process divided by the requests that produced them — not the harness's gated comparison, so these are **our own before-and-after and not a ratio against upstream**. All 29 responses captured across listings, by-name routes, search, filters and counts are byte-identical before and after. | route | before | after | | |---|---|---|---| | `/Items/Counts` | 541 ms | 22.5 ms | 24.0× | | `/Artists` | 478 | 63.5 | 7.5× | | `/Artists/AlbumArtists` | 398.5 | 62.0 | 6.4× | | `/Studios` | 401 | 74.0 | 5.4× | | `/MusicGenres` | 375.5 | 84.0 | 4.5× | | `/Genres` | 403 | 104.5 | 3.9× | | `/UserItems/Resume` | 93.5 | 25.5 | 3.7× | | `/Items` (Movie,Episode, IsPlayed) | 124 | 41.0 | 3.0× | | `/Items` (DateCreated) | 93 | 32.5 | 2.9× | | `/Items` (Movie, page 1) | 60.5 | 22.5 | 2.7× | | `/Items/Latest` | 101 | 39.5 | 2.6× | | `/Items/Filters2` | 108.5 | 43.5 | 2.5× | | `/Search/Hints` | 77 | 32.5 | 2.4× | | `/UserViews` | 28 | 16.5 | 1.7× | | `/Items` (Episode, deep page) | 119 | 78.0 | 1.5× | | `/Years` | 387.5 | 332.5 | 1.2× | | `/Persons` | 24 | 22.0 | 1.1× | | **total** | **3813 ms** | **1096.5 ms** | **3.5×** | ### The database was never mapped, so a page was read once per connection This is the one that touches every route, and it is not in the list of candidates above. Without `mmap_size` every read copies pages out of the file into a per-connection cache whose default 2 MiB holds almost none of a real library's indexes — so the same pages are read again for the next query, and again for the next pooled connection. Four configurations, same measurement: | | CPU over 17 routes | heap | mapped | |---|---|---|---| | as shipped | 2698 ms | 514 MB | 20 MB | | `cache_size` 64 MiB | 1470 ms | 674 MB | 20 MB | | `mmap_size` 1 GiB | 1120 ms | 426 MB | 369 MB | | both | 1109 ms | 342 MB | 369 MB | Raising the cache instead buys less and costs heap per connection. Mapped, the **heap is smaller than it was** — pages are read in place rather than copied — and what is counted resident beyond that is file-backed and reclaimable. Neither the file nor a lock is touched, so an upstream server against the same database is unaffected. The cost is that an I/O error inside the mapping arrives as `SIGBUS` rather than as an error. This is worth weighing against the RSS note above: *under load ours peaks higher — 1,160 MB against 905 MB*. That peak is heap, and this makes it smaller. ### A by-name page was described one entry at a time `/Artists` spent **397 statements** on fifty names — fifty image reads each binding a single id, fifty user-data reads, and a `Permissions` read twice per entry. This is the per-item read `assemble::many` already existed to replace, and the by-name routes were simply not using it. The page is now resolved once and described whole. /Artists 397 -> 23 statements /Genres 281 -> 17 /Studios 396 -> 17 ### `/Items/Counts` ran the narrowed query eleven times Once per kind, and each run re-derived the user's libraries: 33 `TopParentId` scans and 11 counts for twelve numbers. Upstream sums one grouped query; so do we now — 90 statements to 9. ### The value narrowing collected every id before it filtered `named_in` and `values_in` narrowed with `ItemValuesMap.ItemId IN (SELECT id ...)`, which fixes the order of work: every item the query selects is materialised first and the mapping probed once per id. On a listing that narrows to nothing that is the whole library — 279 ms of a 334 ms `/Artists`. Written as a join, SQLite flattens it and picks its own order, driving from the value type when the query is broad and from the items when it is narrow. Both directions matter: an earlier attempt that forced the value-driven order made `/Items/Filters2` (narrowed to `Movie`) *worse*, 117 ms to 304 ms. ### What is still open - **`/Years` — 332 ms, barely moved.** It reads every row in the library to keep one integer column, then spends two queries per year resolving the item standing for it (144 statements). It needs the same treatment as the others; the wrinkle is that upstream orders these by the *items* rather than by the year, so the existing distinct-year projection is not a drop-in. - **`user_libraries` costs three index scans per request, on every route.** Deciding which libraries a user may see reads every descendant of each of their views — 16,230 ancestor rows for one library — to return a single id. It cannot be made cheaper without an index that is not in upstream's schema, so it wants caching or one call per request instead of one per view. - **Heap is ~510 MB after a few hundred requests.** Reduced, not explained. - **The concurrency story is unmeasured here.** Everything above is one request at a time. Whether the plateau moves needs a harness run, and the two runs attempted today were both disturbed — the first by me killing its server by mistake, the second by my own profiling sharing the host.
Author
Collaborator

Re-measured with the three fixes in, same host, same anchor, five repetitions, medians. The finding in this issue no longer holds: the read path now costs less processor time per request than upstream, not four times more.

Run 20260805T083531Z — ours bc2a104, no uncommitted changes; upstream v12.0-rc3-49-g7215950bd4, artifact 9a0e3959c847.

Processor per request

before now
upstream 74.8 ms 98.3 ms
ours 298 ms 57.3 ms
ours ÷ upstream 3.98 0.58

Counted per server over the agreement pass, the warmup and every measurement — 34,884 responses ours against 24,090 upstream, each divided by its own count. The absolute figures are not comparable with the ones above them: that window includes warmup, and the timed set is 13 requests rather than 11.

Throughput

in flight upstream ours now before
1 16.6 rps 28.5 rps 1.72 0.29
2 29.6 49.1 1.66 0.37
4 43.7 68.8 1.57 0.29
8 42.9 73.5 1.71 0.27
16 40.1 73.6 1.84 0.25
32 38.9 72.9 1.87 0.25
64 39.5 72.5 1.83 0.25
128 40.2 72.0 1.79 0.25

The plateau moved from ~13.4 rps to ~72.5 rps while upstream's stayed at ~39.5. Bytes per response are within 1% at every point, so this is not faster because it sent less.

The arithmetic that explained the old ceiling still explains the new one: 4 cores ÷ 55 ms ≈ 72 rps.

Latency, and the case this issue called out

At concurrency 1, p50 25.0 ms against upstream's 49.9 ms — where it was 120 ms against 40 ms.

The issue's sharpest example was that at 25 requests a second, a rate upstream served at 50 ms p50, we delivered 13.4 rps and a p50 of 11.9 seconds. Under a fixed offered rate now:

offered upstream ours
25/s 24.8 rps, p50 53.5 ms 24.9 rps, p50 28.7 ms
50/s 42.0 rps, p50 1576.9 ms 49.9 rps, p50 37.4 ms
100/s 42.3 rps, p50 13728 ms 70.8 rps, p50 4394 ms

At 50/s we take the offered rate with a 37 ms median and upstream is the one that cannot — its p50 is 42× ours there.

Memory, which was the other open question

Under load ours peaks higher — 1,160 MB against 905 MB — which is worth its own look. That reversed too:

before now
ours peak 1,160 MB 923 MB
upstream peak 905 MB 1,046 MB
ours idle 10.8 MB 24.5 MB
upstream idle 165 MB 166 MB

Mapping the database moved reads off the heap, so the peak fell even though 369 MB of file-backed pages are now counted resident. Idle rose 14 MB, still under a sixth of upstream's.

Cold start unchanged at 621 ms against 3,652 ms.

What this does not say

The gate is unchanged: 13 of 21 requests timed, 8 still dropped for disagreeing. They are the same eight, and they are #79, #80 and #81 — Fields=People populated where upstream leaves it empty, /Items/Counts two out, /Genres carrying UserData, the IsPlayed+DatePlayed ordering. Several are heavy requests, so the timed set is not the whole read path, and fixing those correctness issues will change what is measured here. A ratio under 1 on thirteen requests is not a claim about the fourteenth.

Two reads are also known to be untouched by this work and remain slow in absolute terms — /Years still reads every row in the library, and deciding which libraries a user may see still costs three index scans per request on every route. Neither is in the timed set often enough to move these numbers, and both are worth doing.

Re-measured with the three fixes in, same host, same anchor, five repetitions, medians. **The finding in this issue no longer holds: the read path now costs less processor time per request than upstream, not four times more.** Run `20260805T083531Z` — ours `bc2a104`, no uncommitted changes; upstream `v12.0-rc3-49-g7215950bd4`, artifact `9a0e3959c847`. ### Processor per request | | before | now | |---|---|---| | upstream | 74.8 ms | 98.3 ms | | ours | 298 ms | **57.3 ms** | | ours ÷ upstream | 3.98 | **0.58** | Counted per server over the agreement pass, the warmup and every measurement — 34,884 responses ours against 24,090 upstream, each divided by its own count. The absolute figures are not comparable with the ones above them: that window includes warmup, and the timed set is 13 requests rather than 11. ### Throughput | in flight | upstream | ours | now | before | |---|---|---|---|---| | 1 | 16.6 rps | 28.5 rps | **1.72** | 0.29 | | 2 | 29.6 | 49.1 | 1.66 | 0.37 | | 4 | 43.7 | 68.8 | 1.57 | 0.29 | | 8 | 42.9 | 73.5 | 1.71 | 0.27 | | 16 | 40.1 | 73.6 | 1.84 | 0.25 | | 32 | 38.9 | 72.9 | 1.87 | 0.25 | | 64 | 39.5 | 72.5 | 1.83 | 0.25 | | 128 | 40.2 | 72.0 | 1.79 | 0.25 | The plateau moved from ~13.4 rps to ~72.5 rps while upstream's stayed at ~39.5. Bytes per response are within 1% at every point, so this is not *faster because it sent less*. The arithmetic that explained the old ceiling still explains the new one: 4 cores ÷ 55 ms ≈ 72 rps. ### Latency, and the case this issue called out At concurrency 1, p50 25.0 ms against upstream's 49.9 ms — where it was 120 ms against 40 ms. The issue's sharpest example was that at 25 requests a second, a rate upstream served at 50 ms p50, we delivered 13.4 rps and a p50 of **11.9 seconds**. Under a fixed offered rate now: | offered | upstream | ours | |---|---|---| | 25/s | 24.8 rps, p50 53.5 ms | 24.9 rps, **p50 28.7 ms** | | 50/s | 42.0 rps, p50 **1576.9 ms** | 49.9 rps, **p50 37.4 ms** | | 100/s | 42.3 rps, p50 13728 ms | 70.8 rps, p50 4394 ms | At 50/s we take the offered rate with a 37 ms median and upstream is the one that cannot — its p50 is 42× ours there. ### Memory, which was the other open question *Under load ours peaks higher — 1,160 MB against 905 MB — which is worth its own look.* That reversed too: | | before | now | |---|---|---| | ours peak | 1,160 MB | **923 MB** | | upstream peak | 905 MB | 1,046 MB | | ours idle | 10.8 MB | 24.5 MB | | upstream idle | 165 MB | 166 MB | Mapping the database moved reads off the heap, so the peak fell even though 369 MB of file-backed pages are now counted resident. Idle rose 14 MB, still under a sixth of upstream's. Cold start unchanged at 621 ms against 3,652 ms. ### What this does not say **The gate is unchanged: 13 of 21 requests timed, 8 still dropped for disagreeing.** They are the same eight, and they are #79, #80 and #81 — `Fields=People` populated where upstream leaves it empty, `/Items/Counts` two out, `/Genres` carrying `UserData`, the `IsPlayed`+`DatePlayed` ordering. Several are heavy requests, so **the timed set is not the whole read path, and fixing those correctness issues will change what is measured here.** A ratio under 1 on thirteen requests is not a claim about the fourteenth. Two reads are also known to be untouched by this work and remain slow in absolute terms — `/Years` still reads every row in the library, and deciding which libraries a user may see still costs three index scans per request on every route. Neither is in the timed set often enough to move these numbers, and both are worth doing.
Author
Collaborator

The exact gate, from run 20260805T083531Z, so the timed set and what is missing from it are written down rather than re-derived. 21 requests replayed, 13 timed, 8 dropped.

The 13 that are timed

These are what every number in this issue describes.

id request
movies-page-1 /Items?Recursive=true&IncludeItemTypes=Movie&SortBy=SortName&SortOrder=Ascending&Limit=50
episodes-page-1 /Items?…&IncludeItemTypes=Episode&SortBy=SortName&SortOrder=Ascending&Limit=50
episodes-deep-page the same with &StartIndex=10000
series-with-user-data /Items?…&IncludeItemTypes=Series&SortBy=SortName&Limit=50&Fields=Overview,ItemCounts
unplayed-movies /Items?…&IncludeItemTypes=Movie&Filters=IsUnplayed&SortBy=SortName&Limit=50
latest /Items/Latest
resume /UserItems/Resume?Limit=12&MediaTypes=Video&Fields=PrimaryImageAspectRatio
persons-page-1 /Persons?Limit=50&SortBy=SortName
persons-deep-page /Persons?Limit=50&SortBy=SortName&StartIndex=20000
studios /Studios?Limit=50&SortBy=SortName
artists /Artists?Limit=50&SortBy=SortName
search-hints /Search/Hints?SearchTerm=a&Limit=20
filters /Items/Filters2?IncludeItemTypes=Movie

The 8 that disagree, and what owns each

Every difference in the run classified by field — not sampled — so a request whose only blocker is fixed enters the timed set.

id differences owned by enters the timed set when
counts 1× ItemCount #79 #79
everything-by-date-added 1× TotalRecordCount #79 #79
views 17, all downstream of the missing playlists view #80 #80
genres 45× UserData #81 #81
episodes-deep-page-fields 44× People, 9× MediaStreams[].Score #81 #81
movies-page-1-fields 50× People, 13× Score, 33× UserData.Key #81 and #84 both
played-descending 1808, all downstream of Id differing on 50/50 #78 #78
audio-by-album-artist 1200, all downstream of Id differing on 50/50 #78 #78

Six of the eight are blocked by a single issue each. #79 and #81 are worth two and three requests respectively, and #79 is two rows.

Why this matters for the numbers above

The timed set is not a random 13 of 21. It excludes:

  • both Fields=…People… requests, which are the heaviest reads in the scenario and the ones where we do work upstream does not (#81). Fixing that moves them into the set and makes them cheaper, so the ratio should improve twice over.
  • /Items/Counts, now 22.5 ms of processor against 541 ms before, entirely unmeasured against upstream over a two-row count gap.
  • audio-by-album-artist, the only audio listing in the scenario — so the music path contributes nothing to any figure here.

A ratio of 0.58 on thirteen requests is not a claim about the twenty-one. Closing #78 through #81 and #84 is what makes it one.

The exact gate, from run `20260805T083531Z`, so the timed set and what is missing from it are written down rather than re-derived. 21 requests replayed, 13 timed, 8 dropped. ### The 13 that are timed These are what every number in this issue describes. | id | request | |---|---| | `movies-page-1` | `/Items?Recursive=true&IncludeItemTypes=Movie&SortBy=SortName&SortOrder=Ascending&Limit=50` | | `episodes-page-1` | `/Items?…&IncludeItemTypes=Episode&SortBy=SortName&SortOrder=Ascending&Limit=50` | | `episodes-deep-page` | the same with `&StartIndex=10000` | | `series-with-user-data` | `/Items?…&IncludeItemTypes=Series&SortBy=SortName&Limit=50&Fields=Overview,ItemCounts` | | `unplayed-movies` | `/Items?…&IncludeItemTypes=Movie&Filters=IsUnplayed&SortBy=SortName&Limit=50` | | `latest` | `/Items/Latest` | | `resume` | `/UserItems/Resume?Limit=12&MediaTypes=Video&Fields=PrimaryImageAspectRatio` | | `persons-page-1` | `/Persons?Limit=50&SortBy=SortName` | | `persons-deep-page` | `/Persons?Limit=50&SortBy=SortName&StartIndex=20000` | | `studios` | `/Studios?Limit=50&SortBy=SortName` | | `artists` | `/Artists?Limit=50&SortBy=SortName` | | `search-hints` | `/Search/Hints?SearchTerm=a&Limit=20` | | `filters` | `/Items/Filters2?IncludeItemTypes=Movie` | ### The 8 that disagree, and what owns each Every difference in the run classified by field — not sampled — so a request whose only blocker is fixed enters the timed set. | id | differences | owned by | enters the timed set when | |---|---|---|---| | `counts` | 1× `ItemCount` | #79 | #79 | | `everything-by-date-added` | 1× `TotalRecordCount` | #79 | #79 | | `views` | 17, all downstream of the missing playlists view | #80 | #80 | | `genres` | 45× `UserData` | #81 | #81 | | `episodes-deep-page-fields` | 44× `People`, 9× `MediaStreams[].Score` | #81 | #81 | | `movies-page-1-fields` | 50× `People`, 13× `Score`, 33× `UserData.Key` | #81 **and** #84 | both | | `played-descending` | 1808, all downstream of `Id` differing on 50/50 | #78 | #78 | | `audio-by-album-artist` | 1200, all downstream of `Id` differing on 50/50 | #78 | #78 | Six of the eight are blocked by a single issue each. **#79 and #81 are worth two and three requests respectively**, and #79 is two rows. ### Why this matters for the numbers above The timed set is not a random 13 of 21. It excludes: - **both `Fields=…People…` requests**, which are the heaviest reads in the scenario and the ones where we do work upstream does not (#81). Fixing that moves them into the set *and* makes them cheaper, so the ratio should improve twice over. - **`/Items/Counts`**, now 22.5 ms of processor against 541 ms before, entirely unmeasured against upstream over a two-row count gap. - **`audio-by-album-artist`**, the only audio listing in the scenario — so the music path contributes nothing to any figure here. A ratio of 0.58 on thirteen requests is not a claim about the twenty-one. Closing #78 through #81 and #84 is what makes it one.
Author
Collaborator

Re-measured with the gate cleared. The finding this issue reports is gone, and so is the reason the earlier correction in this thread was flattering.

Run 20260811T211300Z — ours ba8f44e, no uncommitted changes; upstream v12.0-rc3-49-g7215950bd4, artifact 9a0e3959c847. Five repetitions, medians, each server pinned to its own four CPUs, one copy of the library each, 56,337 items.

The gate

60 requests replayed, 50 timed, 10 dropped. The comment above listed 21 replayed and 13 timed; the request set is now the id-free one from #82, and every request that was dropped for #78, #79, #80, #81 or #84 is in the timed set.

The ten still dropped are #86. None of them belongs to a closed issue.

Processor time per request

this issue after the first three fixes now
upstream 74.8 ms 98.3 ms 273.1 ms
ours 298 ms 57.3 ms 43.9 ms
ours ÷ upstream 3.98 0.58 0.16

Counted per server over the agreement pass, the warmup and every measurement — 43,233 responses ours against 15,122 upstream, each divided by its own count.

Upstream's figure nearly tripled, and that is the part worth reading carefully. It is not a regression on upstream's side: the earlier runs measured an upstream that could not resolve a single person, genre, studio or artist, because the run gave both servers a metadata directory outside their data directories (see the comment above, and f064438). An upstream answering People: [] is not doing the work being compared. On top of that the timed set now includes the heaviest requests in the scenario — the ones that ask for a cast — which the old gate dropped.

So neither the 3.98 nor the 0.58 was measured against an upstream doing the work. The three ratios above are over three different timed sets and are not comparable with each other; only the last one is over a set both servers answer identically with the fixture arranged correctly.

Throughput

in flight upstream ours ours ÷ upstream
1 6.1 rps 35.0 rps 5.7
2 10.4 61.4 5.9
4 16.1 88.6 5.5
8 15.3 100.1 6.6
16 14.2 99.9 7.1
32 14.4 98.8 6.9
64 15.0 97.0 6.5
128 14.7 96.9 6.6

Both still saturate at four concurrent requests and both hold flat to 128.

It is not that we send less — we send more. 58.2 KB per response against upstream's 55.4 KB. That 5% is itself worth a look, and it is the opposite of the failure mode the measurement exists to catch.

Latency

At concurrency 1, nothing queued: p50 21.6 ms against upstream's 37.3 ms.

Under a fixed offered rate, the example this issue made its sharpest point with has inverted:

offered upstream ours
25/s 14.5 rps, p50 4,972 ms 25.0 rps, p50 24.7 ms
50/s 15.0 rps, p50 23,286 ms 49.9 rps, p50 25.2 ms
100/s 15.1 rps, p50 53,913 ms 97.9 rps, p50 171.4 ms

At 25 a second — the rate this issue reported upstream serving at a 50 ms median — upstream now delivers 14.5 rps and a median of five seconds, because it cannot take the rate over this request set and the queue grows for the length of the run. Ours takes it at 25 ms.

Memory and cold start

upstream ours
idle 219.0 MB 24.0 MB
peak under load 1,636 MB 890 MB
cold start to first answer 3,541 ms 619 ms

The peak reversed with the earlier mapping change and has stayed reversed.

What this does not say

  • There is still no control. A request doing provably identical work on both sides would tell a real difference from a measurement artefact, and there isn't one — the login happens once, outside the timed set. That is now the largest gap in the method, and it is the reason none of these numbers should be quoted without the sentence above them.
  • Ten of sixty requests are excluded because the two servers disagree about them (#86). Two of those are filters selecting a different set of rows, so the excluded work is not a random sample of the read path.
  • The processor figures span the agreement pass and the warmup while only measured requests divide into them, so they are high on both sides by different amounts.
  • One host, one library, one request set.

Closing: the read path does not cost four times upstream's CPU per request, and the measurement that said so has been corrected at its root rather than argued with.

**Re-measured with the gate cleared. The finding this issue reports is gone, and so is the reason the earlier correction in this thread was flattering.** Run `20260811T211300Z` — ours `ba8f44e`, no uncommitted changes; upstream `v12.0-rc3-49-g7215950bd4`, artifact `9a0e3959c847`. Five repetitions, medians, each server pinned to its own four CPUs, one copy of the library each, 56,337 items. ## The gate **60 requests replayed, 50 timed, 10 dropped.** The comment above listed 21 replayed and 13 timed; the request set is now the id-free one from #82, and every request that was dropped for #78, #79, #80, #81 or #84 is in the timed set. The ten still dropped are #86. None of them belongs to a closed issue. ## Processor time per request | | this issue | after the first three fixes | now | |---|---|---|---| | upstream | 74.8 ms | 98.3 ms | **273.1 ms** | | ours | 298 ms | 57.3 ms | **43.9 ms** | | ours ÷ upstream | 3.98 | 0.58 | **0.16** | Counted per server over the agreement pass, the warmup and every measurement — 43,233 responses ours against 15,122 upstream, each divided by its own count. **Upstream's figure nearly tripled, and that is the part worth reading carefully.** It is not a regression on upstream's side: the earlier runs measured an upstream that could not resolve a single person, genre, studio or artist, because the run gave both servers a metadata directory outside their data directories (see the comment above, and `f064438`). An upstream answering `People: []` is not doing the work being compared. On top of that the timed set now includes the heaviest requests in the scenario — the ones that ask for a cast — which the old gate dropped. **So neither the 3.98 nor the 0.58 was measured against an upstream doing the work.** The three ratios above are over three different timed sets and are not comparable with each other; only the last one is over a set both servers answer identically with the fixture arranged correctly. ## Throughput | in flight | upstream | ours | ours ÷ upstream | |---|---|---|---| | 1 | 6.1 rps | 35.0 rps | 5.7 | | 2 | 10.4 | 61.4 | 5.9 | | 4 | 16.1 | 88.6 | 5.5 | | 8 | 15.3 | 100.1 | 6.6 | | 16 | 14.2 | 99.9 | 7.1 | | 32 | 14.4 | 98.8 | 6.9 | | 64 | 15.0 | 97.0 | 6.5 | | 128 | 14.7 | 96.9 | 6.6 | Both still saturate at four concurrent requests and both hold flat to 128. **It is not that we send less — we send more.** 58.2 KB per response against upstream's 55.4 KB. That 5% is itself worth a look, and it is the opposite of the failure mode the measurement exists to catch. ## Latency At concurrency 1, nothing queued: p50 21.6 ms against upstream's 37.3 ms. Under a fixed offered rate, the example this issue made its sharpest point with has inverted: | offered | upstream | ours | |---|---|---| | 25/s | 14.5 rps, p50 **4,972 ms** | 25.0 rps, p50 **24.7 ms** | | 50/s | 15.0 rps, p50 23,286 ms | 49.9 rps, p50 25.2 ms | | 100/s | 15.1 rps, p50 53,913 ms | 97.9 rps, p50 171.4 ms | At 25 a second — the rate this issue reported upstream serving at a 50 ms median — upstream now delivers 14.5 rps and a median of five seconds, because it cannot take the rate over this request set and the queue grows for the length of the run. Ours takes it at 25 ms. ## Memory and cold start | | upstream | ours | |---|---|---| | idle | 219.0 MB | **24.0 MB** | | peak under load | 1,636 MB | **890 MB** | | cold start to first answer | 3,541 ms | **619 ms** | The peak reversed with the earlier mapping change and has stayed reversed. ## What this does not say - **There is still no control.** A request doing provably identical work on both sides would tell a real difference from a measurement artefact, and there isn't one — the login happens once, outside the timed set. That is now the largest gap in the method, and it is the reason none of these numbers should be quoted without the sentence above them. - **Ten of sixty requests are excluded** because the two servers disagree about them (#86). Two of those are filters selecting a different set of rows, so the excluded work is not a random sample of the read path. - The processor figures span the agreement pass and the warmup while only measured requests divide into them, so they are high on both sides by different amounts. - One host, one library, one request set. Closing: the read path does not cost four times upstream's CPU per request, and the measurement that said so has been corrected at its root rather than argued with.
Sign in to join this conversation.
No description provided.