Skip to content

pager-hydration: reproducible experiment for pager impact on hydration - #37721

Draft
antiguru wants to merge 2 commits into
MaterializeInc:mainfrom
antiguru:pager-hydration-experiment
Draft

pager-hydration: reproducible experiment for pager impact on hydration#37721
antiguru wants to merge 2 commits into
MaterializeInc:mainfrom
antiguru:pager-hydration-experiment

Conversation

@antiguru

Copy link
Copy Markdown
Member

Captures the pager hydration experiment so it is reproducible.

Measures the impact of the column-paged batcher's spill-to-disk mechanism (the "pager") on hydration time, comparing a paged-batcher replica against the legacy columnation batcher across TPCH scale factors.

Contents:

  • doc/developer/design/20260716_pager_hydration_experiment.md — design and rationale.
  • misc/experiments/pager-hydration/run.py — driver for a staging or cloud region over pgwire; two replicas differing only by name, with the flag mapping applied out of band via scoped per-replica system parameters.
  • misc/experiments/pager-hydration/run_local.py — variant for a local bin/environmentd, flipping the pager flag globally and sequentially since scoped parameters are not reproducible locally.
  • misc/experiments/pager-hydration/README.md — prerequisites, invocation, outputs, and Grafana series for both runners.

Draft: infra to reproduce the measurement, not a product change.

🤖 Generated with Claude Code

antiguru and others added 2 commits July 17, 2026 12:09
Design doc and driver for measuring the column-paged batcher's spill-to-disk
("pager") impact on hydration time versus the legacy batcher, across TPCH scale
factors on staging. Uses ticking sources for honest re-hydration timing and
per-replica flag overrides keyed by replica name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
run_local.py drives the experiment against a local bin/environmentd, flipping
the pager flag globally and sequentially since scoped per-replica parameters are
not reproducible locally. README documents both the staging (run.py) and local
paths, the out-of-band flag setup, invocation, outputs, and Grafana series, so
the experiment is reproducible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@antiguru

Copy link
Copy Markdown
Member Author

Resume config: clean-slate pager variant ladder (SF100)

Notes to resume the pager hydration re-measurement in a fresh session.

Why we are re-running

The earlier pager numbers (~1.1–1.5×) were contaminated by a stale compute_dataflow_max_inflight_bytes_cc = 128 MiB override that put the persist granular_backpressure operator in the hydration path. That made hydration read-bound (~90 MB/s) for both flavors, hiding the batcher cost. With the override removed (reads ~1.3 GB/s), a clean SF100 run showed pager_on ≈ 1.96× slower than legacy (geomean over objects >5s), with zero managed spill (mz_column_pager_paged_bytes_out_total = 0) and near-identical peak memory (543 vs 556 GiB). So that ~2× is pure in-memory batcher-path CPU cost, not spill I/O. Prior-run CSVs: .experiment/pager-clean-sf100/{results,windows}.csv (local, git-excluded).

That run used a non-default mixture (spill on, lz4 on, pageout off, budget 0.85). This ladder restarts from the code defaults and turns on one layer at a time.

Code defaults (from src/compute-types/src/dyncfgs.rs)

enable_column_paged_batcher          = false   (legacy columnation)
enable_column_paged_batcher_spill    = false
column_paged_batcher_lz4             = false
column_paged_batcher_swap_pageout    = false
column_paged_batcher_budget_fraction = 0.05    (budget = max(mem_limit*fraction, 128 MiB))

The flags are a gated stack: spill requires enable, lz4 requires spill, pageout requires lz4 (and the swap backend), budget applies only when spill is on.

Variant ladder (one layer per replica)

replica name enable spill lz4 pageout isolates
legacy baseline, all code defaults
paged paged data structure, resident-only (no spill) — pure structure cost
paged_spill + pager eviction, uncompressed spill
paged_lz4 + lz4 compression on spill
paged_pageout + eager MADV_PAGEOUT (full stack)

Scoped override each replica-name segment sets (full set to reach the rung, since env-wide is all default-off):

  • legacy — none (inherits defaults)
  • pagedenable_column_paged_batcher=true
  • paged_spillenable_column_paged_batcher=true, enable_column_paged_batcher_spill=true
  • paged_lz4enable_column_paged_batcher=true, enable_column_paged_batcher_spill=true, column_paged_batcher_lz4=true
  • paged_pageoutenable_column_paged_batcher=true, enable_column_paged_batcher_spill=true, column_paged_batcher_lz4=true, column_paged_batcher_swap_pageout=true

Leave column_paged_batcher_budget_fraction at the default 0.05 everywhere. On M.1-8xlarge that is ~23.5 GiB budget vs a ~543 GiB working set, so the spill rungs actually spill hard (the 0.85 run spilled nothing). That is what makes paged_spill / paged_lz4 / paged_pageout meaningful.

Setup for the fresh session

  1. Clear the current env-wide overrides for the five pager flags so they read as code defaults. They appear to be LD-managed (ALTER SYSTEM RESET did not stick for a sibling flag), so clear them in the flag system, then verify against mz_internal.mz_overridden_system_parameters (expect no pager rows).
  2. Set REPLICAS = ["legacy","paged","paged_spill","paged_lz4","paged_pageout"] in misc/experiments/pager-hydration/run.py. It only measures replicas it creates; the segment names must match. All five hydrate in parallel in the shared test cluster, one M.1-8xlarge each (5 machines concurrently, but fast).
  3. Resume the source: ALTER CLUSTER ldgen_sf100 SET (REPLICATION FACTOR 1) and wait for the source to report running (run.py polls this). Re-pause to RF 0 when done.
  4. Run: python3 run.py --scale-factors 100 --trials 3 --size M.1-8xlarge --config <writable mz.toml> --mz-bin <built mz> --region aws/us-east-1 --profile staging --outdir <dir>. Resumable (completed cells skip).

Metrics per replica window (windows.csv gives replica id + start/end)

  • Hydration time: mz_internal.mz_compute_hydration_times.time_ns (+ wall-clock from mz_hydration_statuses).
  • Peak memory (RAM+swap): max_over_time(mz_memory_limiter_memory_usage_bytes{cluster_environmentd_materialize_cloud_replica_id="<uNNN>"}[...]).
  • Managed spill volume: mz_column_pager_paged_bytes_out_total{...} (expect 0 for legacy/paged, >0 for the spill rungs at budget 0.05).
  • Prometheus datasource uid Ks85Oh14z; namespace environment-4bd00568-7de9-4df4-90b4-5af076164555-0.

Optional complement: a Polar Signals on-CPU profile of paged vs legacy during hydration pinpoints which batcher functions carry the extra CPU. On-CPU is the right tool here since the cost is not spill-wait.

Environment note: staging is on clean upstream/main (build 7f6c52776, runtime-identical to the doc-only #37717 ff2ddcc36), backpressure disabled, no instrumentation.

@antiguru

Copy link
Copy Markdown
Member Author

Ladder run: first results + on-CPU root cause (SF100, M.1-8xlarge)

First trial of the variant ladder is in (initial, n=1, directional). Setup validated end-to-end: all four rungs hydrate, per-replica scoped flags resolve correctly via LaunchDarkly replica-name segments.

The entire overhead is the paged data structure. Spill and lz4 add nothing.

Geomean hydration time vs legacy, over objects with legacy hydration > 5s (n=24; time_ns for indexes, wall-clock for MVs):

rung geomean median Δ from previous rung
legacy 1.00× 1.00×
paged (structure, resident, no spill) 1.85× 1.91× +85%
paged_spill 1.87× 1.93× +1.6%
paged_lz4 1.88× 1.93× +0.5%

The ~1.85× lands entirely on the paged rung, before any spill.
The overhead is a near-constant multiplier (1.85–2.1×) across every object, indexes and MVs alike, so it is proportional to arrangement-build work rather than object-specific.
This confirms and localizes the earlier ~1.96× clean-run number: pure in-memory CPU path, not disk I/O and not compression.

Root cause (Polar Signals, on-CPU, paged vs legacy, same hydration window)

The column pager allocates each chunk as a large RawVec, so every chunk round-trips through jemalloc's large-object extent allocator. legacy does not.

ColumnPager::page<((Row,Row),Timestamp,Diff)>          ~1m06.5s cumulative
  -> RawVecInner::try_allocate_in -> _rjem_je_large_palloc
    -> arena_extent_alloc_large -> pa_alloc -> ecache_alloc -> extent_recycle   ~71s
frame paged legacy meaning
extent_recycle / extent_split_impl / extent_try_coalesce ~71–73s ~0 jemalloc large-extent churn from ColumnPager::page
push_datum::<CompactBytes> 31.2s (11.4s flat) paged packs rows into per-row CompactBytes
ColumnationStack::copy 36.7s (11.5s flat) legacy arena bump-copy
memcmp 12.4s legacy join/consolidate compares

legacy backs the row spine with a columnation arena: a few big allocations, bump-pointer copy, no per-chunk allocator traffic.
The paged batcher instead does a large allocation per chunk, so extent management (~71s cumulative) becomes the single largest cost and the bulk of the delta.

Actionable lever: cut ColumnPager::page's large-allocation frequency (chunk-buffer pooling, larger chunks, or an arena-style backing).
The existing column_paged_batcher_use_pool flag (off) looks like the intended mitigation and is worth its own rung.

Caveats and next steps

  • n=1, initial scenario only. Full run is 3 trials plus rehydrate, in progress.
  • On M.1-8xlarge the working set (~543 GiB) only barely exceeds RAM (470 GiB), so spill is shallow. Deep-swap re-runs on M.1-3xlarge then M.1-2xlarge are queued to force real spill pressure, where the spill/lz4 rungs should finally matter.
  • paged_pageout is omitted this run: column_paged_batcher_swap_pageout is not Replica-scoped in the deployed binary, so the rung cannot bind a per-replica override (tracked in CPU-177).

@antiguru

Copy link
Copy Markdown
Member Author

Consolidated results: pressure is the whole story (SF100, three machine sizes)

Ran the four-rung ladder at three replica sizes to sweep memory pressure, holding the working set (~543 GiB) fixed and shrinking RAM. Correction to the previous comment: that used the trial-1 (n=1) number for paged on M.1-8xlarge (1.85x); the full three-trial figure is 2.12x. Trial 1 was the low outlier.

Hydration time vs legacy (geomean, objects with legacy > 5s)

initial scenario:

box RAM / working set paged paged_spill paged_lz4 n
M.1-8xlarge (470 GiB) ~1.15x 2.12x 2.13x 2.15x 3 trials
M.1-3xlarge (182 GiB) ~3x 1.23x 1.28x 1.29x 3 trials
M.1-2xlarge (121 GiB) ~4.5x 1.05x (median 0.88x) 1.15x 1.19x 1 trial

rehydrate scenario (same trend, slightly lower):

box paged paged_spill paged_lz4
M.1-8xlarge 1.84x 1.87x 1.88x
M.1-3xlarge 1.17x 1.23x 1.21x

The paged batcher's relative penalty collapses as memory pressure rises: 2.12x when the data fits RAM, 1.23x at 3x RAM, and 1.05x (median 0.88x, i.e. faster than legacy) at 4.5x RAM. On the big box legacy runs mostly resident and paged's fixed allocation overhead dominates. On the small boxes legacy has no managed spill, thrashes kernel swap, and slows down enough that the pager reaches parity. Legacy did not OOM at either smaller size.

Managed spill fired hard and is cheap. lz4 buys ~41% for ~1%.

Per-replica mz_column_pager_paged_bytes_out_total over the hydration window (trial-1 sample):

box legacy paged paged_spill paged_lz4
M.1-3xlarge 0 0 603 GB 358 GB
M.1-2xlarge 0 0 1.84 TB 1.08 TB

legacy and paged (resident) do zero managed spill, as designed.
paged_spill spilled up to 1.84 TB, yet costs only ~5-10% over paged in time, so managed spill is close to free.
paged_lz4 spills ~41% fewer bytes (358 vs 603 GB, 1.08 vs 1.84 TB) at ~1% extra time, so lz4 is a large footprint win for almost nothing.

Where the fixed overhead comes from (on-CPU, paged vs legacy)

The 2.12x on the big box is jemalloc large-extent churn. ColumnPager::page allocates each chunk as a large RawVec, so every chunk round-trips the large-object extent allocator; legacy uses a columnation arena (bump-copy, no per-chunk allocator traffic).

ColumnPager::page<((Row,Row),Timestamp,Diff)>          ~1m06.5s cumulative
  -> RawVecInner::try_allocate_in -> _rjem_je_large_palloc
    -> arena_extent_alloc_large -> pa_alloc -> ecache_alloc -> extent_recycle   ~71s

extent_recycle + extent_split_impl + extent_try_coalesce is ~71-73s on paged and ~0 on legacy.
Lever: cut ColumnPager::page's large-allocation frequency (chunk-buffer pooling, larger chunks, or arena backing).
The existing column_paged_batcher_use_pool flag (off) is the likely mitigation and is worth its own rung.

Caveats

  • M.1-2xlarge is n=1: staging JWKS auth blips ("Error fetching JWKS") aborted trials 2-3. The trend is monotonic and consistent, but the 4.5x row wants a repeat.
  • paged_pageout rung omitted: column_paged_batcher_swap_pageout is not Replica-scoped in the deployed binary, so it cannot bind a per-replica override (CPU-177).

@antiguru

Copy link
Copy Markdown
Member Author

New pager (build 376309617): the pool fixes the resident-path overhead

Re-ran the ladder on the new pooled batcher and compared to the old pager, same workload (SF100, TPCH, three-rung ladder legacy / paged / paged_spill), same env. The old pager's ~2.12x on a RAM-resident box was jemalloc large-extent churn from a large RawVec per chunk (earlier comment). The new buffer pool reuses chunk slots, and it removes that cost.

Hydration time vs legacy (geomean, objects with legacy > 5s)

box regime rung old pager new pager
M.1-8xlarge resident (~1.15x RAM) paged 2.12x 0.95x
M.1-8xlarge resident paged_spill 2.13x 1.47x
M.1-3xlarge deep swap (~3x RAM) paged 1.23x 1.12x
M.1-3xlarge deep swap paged_spill 1.28x 1.24x

rehydrate tracks initial: 8xlarge paged 1.84x -> 1.05x, 3xlarge paged 1.17x -> 1.13x.

The resident-regime paged rung went from 2.12x (worse than legacy) to 0.95x (faster than legacy). The buffer pool eliminated the per-chunk large-allocation traffic that dominated the old profile, so the columnar-native batcher no longer pays an allocator tax to build arrangements.

Managed spill

Spill volume via the new pool metrics (mz_column_pool_extent_bytes_written_total, trial-1):

box paged (no managed spill) paged_spill
M.1-8xlarge 0 299 GB
M.1-3xlarge 0 752 GB

paged_spill costs more than paged when RAM nearly suffices (8xlarge 1.47x vs 0.95x): spilling ~299 GB has a price when the working set is only slightly over RAM. Under real pressure (3xlarge) the gap narrows (1.24x vs 1.12x). The pool also holds a compressed-but-resident tier (peaked ~38 GB on 3xlarge, under the pool_rss_target_fraction = 0.25 ceiling) as headroom above the slot budget before paging to the device.

eager_backing is a no-win for hydration

Swept eager_backing off vs on, env-wide, on M.1-3xlarge:

rung eager off eager on
paged 1.12x 1.16x
paged_spill 1.24x 1.27x

Eager write-behind fired (~185k eager-backs) but slightly increased hydration time and did not grow the resident tier (~38 GB either way). Hydration pressure is one-shot and chunks are short-lived, so pre-compressing them is wasted CPU. Keep eager_backing off for hydration-bound workloads.

Open

paged is now at parity-to-slightly-faster than legacy, not a decisive win. The allocator tax is gone, so what remains is the columnar batcher's own build path vs columnation's arena. Profiling that next.

Notes

  • n=3 trials on both boxes, both scenarios. pageout rung dropped (flag removed in this build; lz4/swap_pageout replaced by the pool + eager_backing + pool_rss_target_fraction).
  • spill_worker_count was pinned to 2 for the experiment env (the env fallthrough resolved 0, which evicts inline and convoys workers).

@antiguru

Copy link
Copy Markdown
Member Author

Re-run on the rebased stack: the pooled batcher is at parity, and the spill cost is a thread-count convoy

Re-ran the three-rung ladder (legacy / paged / paged_spill) on the current buffer-pool stack, #37805 head d504a43bff, deployed as v26.38.0-dev.0--pr.gd504a43bff.... Same workload as every prior round (SF100 TPCH, 18 base indexes plus indexed views and MVs for Q03/05/09/18/21), both scenarios, one trial per cell.

Hydration time vs legacy (geomean, objects with legacy > 5s)

box regime rung initial rehydrate previous round
M.1-8xlarge resident, ~1.15x RAM paged 1.03x 1.01x 0.95x
M.1-8xlarge resident paged_spill 1.54x 1.61x 1.47x
M.1-3xlarge deep swap, ~3x RAM paged 1.14x 1.15x 1.12x
M.1-3xlarge deep swap paged_spill 1.34x 1.40x 1.24x

Every cell reproduces the previous round within single-trial noise. column_chunk_compress_min_depth (new, default 1) and the whole-chunk extract pass-through did not move this workload, and the pressure gradient still holds: the pooled batcher's relative cost shrinks as the box shrinks, because legacy's kernel-swap thrash degrades faster than the pool's overhead does.

The headline is unchanged and worth stating plainly. paged is at parity with legacy, not ahead of it.

Why paged only ties legacy

Closing the open item from the previous comment. On-CPU profiles of paged against legacy on the resident box, taken on the earlier pooled build, show the old jemalloc large-extent churn gone: extent_recycle fell from 71s to 1.9s. What replaces it, and cancels the columnar win:

  • CompactBytes::extend_from_slice, 7.4s flat. Every row is byte-copied into a growable chunk buffer, where columnation bump-appends into an arena for approximately nothing. This is the dominant residual.
  • Eager madvise / do_madvise, about 16s against roughly 3s of lazy reclaim on legacy.
  • ColumnChunk::settle, 4.5s against 2.3s.
  • tcache_alloc_small_hard, 4.9s. The allocation pressure moved from large to small.

The reading is that the pressure moved rather than disappeared: columns on the wire hit the roughly 2 MiB ColumnBuilder ship threshold, so the chunk buffers themselves become repeated 2 MiB allocations. The candidate fix is to source those buffers from the pool instead of the allocator, and the clearest single lever is the per-row byte copy.

The paged_spill cost is a convoy on the compression threads

Instrumented the resident-box window with node and container metrics. The spill rung is not disk-bound and not compute-bound.

  • CPU totals over the shared window are within 1% across all three rungs (legacy 19746u/5279s, paged 19757u/5159s, paged_spill 20880u/4311s) while paged_spill takes 1.5x longer to hydrate. The extra time is off-CPU.
  • legacy and paged ramp to 0.96 and 0.98 of the 62-core box, with 23% to 25% of the whole box in kernel, which is the fault and swap machinery. paged_spill never exceeds 0.66 and is still working when the other two have finished.
  • Device traffic favors the spill rung: writes and reads of 238/57 GB against legacy 290/65 GB and paged 324/84 GB. Device busy peaks at 1.0 for paged_spill against 2.6 and 2.9. Bandwidth is not the wall.
  • mz_column_pool_spill_in_flight sits pinned at 62, exactly the worker count and one entry per worker, for about seven minutes, then drops to zero.

The arithmetic matches a service-rate ceiling. 314,950 compressing evictions over roughly 660s is 477 per second, or 239 per thread per second at about 950 KB compressed each, which is close to two cores of lz4 and to the observed sustained 600 MB/s of extent writes.

An A/B confirms it. Raising column_paged_batcher_spill_worker_count from 2 to 8 on the experiment environment, changing nothing else:

rung 2 threads 8 threads
paged initial 1.03x 1.05x
paged rehydrate 1.01x 1.03x
paged_spill initial 1.54x 1.22x
paged_spill rehydrate 1.61x 1.24x

The paged rung is untouched, as expected, since without the spill gate it never installs the pool. The ladder's own window also fell from 670s to 542s, because paged_spill was the straggler. With 8 threads the rung spills more (434 GB over 458k evictions, against 298 GB over 315k) and still finishes sooner, since draining faster frees budget faster.

So the default of 2 is mis-sized for hydration on a wide replica. It is a fixed service rate facing a producer count that scales with workers, so the convoy worsens as replicas get wider. Scaling the thread count with worker count is the obvious change to consider.

Other observations

  • evictions_cheap is 0 in every run. With eager_backing off nothing is pre-backed, so every eviction pays compression inline of the budget.
  • admissions_budget, admissions_steal and admissions_denied are all 0, so there is no re-admission thrash. writes_elided reaches 3.07M, so most chunks die before their backing write completes.
  • Peak working set is identical across the three rungs at roughly 470 GiB, the box's RAM. The pool buys no peak-memory headroom here.
  • Managed spill defers kernel swap rather than removing it: 0 GiB until late in the window, then 87 GiB, against 130 GiB for legacy and 142 GiB for paged. Major faults per second peak at 542 for legacy, 113k for paged, and 13.8k for paged_spill.

Where main stands

Worth recording, because it is easy to get wrong when picking a build to measure. main carries the pool (#37718), its configuration and metrics (#37719), the budget fix (#37824), UnloadChunk (#37954) and ColumnChunk (#37955), but not the compute arrange adoption and not the pager deletion. On main the enable_column_paged_batcher gate still routes Col2ValPagedBatcher, which is ColumnMergeBatcher over column_pager::PagedColumn, the pre-pool pager. Measuring the pooled path needs a build from this stack.

Notes

  • One trial per cell, so treat single cells as directional. The previous round's 8xlarge reading moved by 0.27x between one trial and three.
  • compute_hydration_concurrency is 1 in the experiment environment, as it was for the earlier rounds, so the comparison holds.
  • The backpressure override stays unset, so the read path is not the confound it once was.
  • column_paged_batcher_spill_worker_count has been restored to 2, the sources are back at replication factor 0, and the test clusters are torn down.
  • The runner now carries analyze.py, which computes these ratios. It reproduces the previously published figures from the stored CSV of the earlier round. Per-object hydration_seconds measures elapsed time inside a shared parallel window, so only per-object ratios aggregated geometrically are meaningful, and an index's own time_ns is preferred where it exists.

@antiguru

antiguru commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

Hydration time budget: the out-of-core axis tops out near 1.6x, and we have already collected most of it

Stepping back from tuning knobs. The goal for this work was roughly a 2x hydration improvement, and the best measured result so far is about 5% against the legacy batcher. This comment decomposes where hydration time actually goes, so each lever can be given a ceiling rather than another sweep.

All numbers are SF100 on M.1-8xlarge (62 workers, 470 GiB), build d504a43bff, compute_hydration_concurrency = 4, one trial per cell.

Method

For each rung, its wall-clock span is the maximum per-object elapsed time in the measurement window, and its CPU is the process rusage counters mz_metrics_libc_ru_utime_seconds_total and _stime_seconds_total over the same window. Against a budget of span x 62 core-seconds that splits hydration into three buckets:

  • utime: application work (decode, exchange, sort and consolidate, copies, compression)
  • stime: kernel work (page fault handling, madvise, swap writeback, syscalls)
  • the remainder: cores that were stalled, either blocked or with no work available

The budget, initial scenario

config span utime stime stalled
legacy 486s 63% 25% 12%
paged 606s 46% 23% 31%
paged_spill, 2 spill threads 775s 38% 12% 50%
paged_spill, 128 spill threads 465s 80% 17% 2%

The spill work tunes the stalled bucket. In the best configuration that bucket is already down to 2%, so there is essentially nothing left to recover there.

Composed ceilings

Starting from the legacy baseline of 486s and removing each cost entirely:

lever implied floor cumulative gain
remove every stall, same CPU work 428s 1.14x
also remove all kernel and paging time 306s 1.59x
run at the lowest utime any rung achieved (17,267 core-seconds) 278s 1.75x

The second row is the one that matters. Legacy is 63% utime, so removing the other 37% entirely, meaning every stall and every kernel cycle, leaves 486s x 0.63 = 306s. That is 1.59x, and it is the ceiling of the whole out-of-core axis.

2x is 243s, which across 62 cores is a total budget of 15,066 core-seconds. Legacy's utime alone is 19,092. So 2x requires three things at once: eliminate all paging, eliminate all stalls, and cut application CPU by a further 21%. Only the first two are on this axis.

Two refinements, both in the pessimistic direction:

  • Not all of the 25% stime is reclaimable. It mixes paging with the syscalls hydration needs to read the snapshot from persist at all. Paging is the bulk of it on this workload, since legacy's kernel profile is dominated by do_translation_fault (1m22s), handle_mm_fault (1m12s) and do_try_to_free_pages (1m9s), but the floor is above zero.
  • Dividing utime by 62 assumes the application work spreads perfectly across all workers with no dependency serialization. The 2% stall measured on paged_spill at 128 threads says that holds on this workload, but it is an assumption, and it is the first one to break on a skewed or dependency-heavy dataflow.

Where the remaining time is

In the best configuration 80% of the budget is utime. Ranked hot paths from the on-CPU profile of the compute worker threads: the timely exchange push path, consolidation sorts, Row and CompactBytes copies, parquet decode, decNumber decimal handling, and lz4 encode on the spill rung. Those are the only levers with 2x-scale headroom.

Spill worker sweep, for the record

Sweeping column_paged_batcher_spill_worker_count on the same shape, paged_spill versus legacy:

threads workers per thread initial large objects CPU floor collapse
2 31:1 1.57x 1.77x 1.8 cores ~4 min
4 15.5:1 1.54x 1.70x 2.9 cores ~2 min
8 7.8:1 1.12x 1.08x 3.3 cores ~1 min
16 3.9:1 1.22x 1.27x not sampled not sampled
128 0.5:1 1.03x 0.95x none none

The rehydrate scenario tracks it: 1.47x, 1.42x, 1.22x, 1.06x, 1.10x for the same ladder.

The inflection is sharp and sits between 4 and 8 threads. Above 8 the curve is flat within single-trial noise, with 8, 16 and 128 all landing in a 1.03x to 1.22x band across both scenarios. Two threads is simply under-provisioned rather than there being a useful workers-per-thread ratio to tune toward. The mechanism is visible directly in pod CPU: with 2 threads the replica sits at the spill-thread count for minutes at a time, because every worker blocks in ColumnChunk::settle waiting for pool admission and only the spill threads keep running. At 128 threads it never drops below about 45 cores. Turning compression off at 2 threads gives 1.29x, so lz4 is roughly half the per-eviction cost, but threads are the stronger lever: 128 threads with compression beats 2 threads without it.

Also worth recording, since it reconciles this experiment with the upsert campaign's TPCH ladder: at 8 threads and above the per-object size gradient inverts. Large objects (legacy over 100s) go from 1.77x to 0.95x and start winning, while small objects settle at a flat tax around 1.14x. That is the same signature the campaign reported on a 200cc replica, which runs about 6 workers and therefore never had a convoy at 2 threads. The whole discrepancy between the two reports was workers per spill thread.

Two limits on this analysis

The profiling backend here exposes only parca_agent:samples:count:cpu:nanoseconds:delta and memory:inuse_space. There is no off-CPU or wallclock profile type, so the stalled bucket can only be bounded by subtraction, not attributed by stack. Separately, the flamegraph root cumulative is not additive across series (two thread filters returned the same total as the unfiltered pod), so hot functions can be ranked but not converted into a reliable percentage split of utime.

Suggested next step

Get a trustworthy utime breakdown for a single legacy hydration of the largest object, split into decode, exchange, sort and consolidate, and copy. That is cheap next to another staging sweep and it decides the question this budget raises: whether a 35% cut in application CPU is plausible, or whether the hydration target should be restated at something like 1.5x from the pool and paging work and the remaining effort redirected at the dataflow itself.

@antiguru

Copy link
Copy Markdown
Member Author

The same budget in absolute terms: the pool trades kernel work for user work at an unfavourable rate

The percentages in the previous comment have different denominators, since each rung has its own span. Redone in core-seconds, against legacy and paged_spill at 128 spill threads from the same run.

legacy span 486s gives a budget of 30,132 core-seconds, paged_spill span 465s gives 28,830.

bucket legacy paged_spill @128 delta
utime 19,092 23,098 +4,006
stime 7,417 5,019 -2,398
stalled 3,623 713 -2,910
total CPU 26,509 28,117 +1,608

The pool removed 5,308 core-seconds of paging and stall, and spent 4,006 of them back on application CPU. The net is 1,302 core-seconds, which at 62 cores is exactly the 21 seconds of wall clock observed (486s to 465s). The apparent move from 63% to 80% utime is mostly the denominator shrinking plus utime growing, not work disappearing.

This raises the floor rather than lowering it

legacy paged_spill @128
floor, utime / 62 308s 373s
actual span 486s 465s
distance above own floor 178s (58%) 92s (25%)

The spill rung is far more efficient against its own ceiling, 125% of floor versus 158%, but its ceiling is 65s worse. A perfected legacy at 308s beats a perfected paged_spill at 373s by 17%. On this axis the pool cannot win unless it stops adding utime.

The added utime scales with bytes spilled

Within-run deltas of paged_spill against that run's own legacy, which is the reliable comparison since it shares a window and a machine:

run spill threads codec extents written utime delta stime delta
2-thread 2 lz4 389 GB +1,710 -87
2-thread, no codec 2 identity roughly double the bytes +1,380 +1,275
128-thread 128 lz4 771 GB +4,006 -2,398

Both lz4 configurations land near 4 to 5 core-seconds of utime per GB of extents written. That produces a tension worth naming: raising the thread count doubled the spill volume, 389 GB to 771 GB, because draining faster frees budget faster and provokes more eviction churn. The change that removed the stall also inflated the floor. Going from 2 to 128 threads cut the span by 310s and raised the floor by 75s, from 298s to 373s.

Turning the codec off at the same thread count moved the delta from +1,710 to +1,380, roughly 20%, so compression is the minority of the pool's added CPU. The remaining 80% is pool bookkeeping and copying, the settle path, CompactBytes and slot management. That revises the earlier recommendation in this thread: threads are the lever for the stall, but on the floor it is the copies that dominate, and they have no flag.

Caveat on those two small numbers. legacy utime itself varied across runs, 16,744 then 17,705 then 19,092, a 14% spread. The within-run deltas are sound, but comparing +1,710 against +1,380 across two different runs sits close to that noise, so treat the 20% split as indicative. Settling it needs both codec settings as two replica rungs in one run, which the harness supports since column_chunk_compress_min_depth is replica-scoped.

Effect on the floor analysis

The 1.59x ceiling is unchanged, since it rests on legacy being 63% utime. What this adds is that the pool does not move that ceiling. It moves the system efficiently toward a worse one. Making the pool a net win on hydration requires cutting its per-GB copy cost, which is the same CompactBytes and copy family already identified as the paged residual in the earlier profiling comment.

@antiguru

Copy link
Copy Markdown
Member Author

Managed spill has a crossover at ~4.5x working set to RAM, and every earlier number in this thread was confounded by hydration concurrency

Two corrections to what I posted above, both of which change the conclusion rather than refine it.

The 1.59x ceiling was a property of the box, not of hydration

The time budget I published earlier (63% utime / 25% stime / 12% stalled, ceiling 1.59x) was measured only on M.1-8xlarge, where the working set is barely above RAM. The split depends entirely on memory pressure:

box working set : RAM utime stime stalled ceiling
M.1-8xlarge 1.16x 63% 25% 12% 1.59x
M.1-3xlarge 2.98x 24.0% 10.7% 65.3% 4.17x
M.1-2xlarge 4.49x 18.5% 9.1% 72.4% 5.40x

On a large box paging shows up as stime, since the device keeps up with reclaim. On a starved box it shows up as stall, because threads block on faults. Major faults over the same workload: 23.4M on the 8xlarge against 97.1M on the 3xlarge. So the kernel bucket understates paging exactly where paging hurts most, and the 1.59x figure should not be quoted as a property of hydration.

compute_hydration_concurrency dominated every result

The default is 4. Managed spill is nearly insensitive to it, legacy is not:

box legacy hc=4 legacy hc=1 spill hc=4 spill hc=1
M.1-3xlarge 2867.6s 1378.5s 1552.7s 1513.5s
M.1-2xlarge 5747.2s 3617.4s 2615.5s 2679.7s
M.1-1.5xlarge 7801.5s 6190.2s 4305.5s 4287.8s

Spill moves 0.4% to 2.6% across the two settings. Legacy moves 1.26x to 2.08x. Every hc=4 comparison therefore mixes the pager's effect with legacy's concurrency penalty, and at 2.98x pressure the apparent 1.85x win was entirely the latter: at hc=1 legacy is the faster rung there.

This also retires the one capability claim. On 1600cc at hc=4 legacy failed to hydrate 14 of 28 objects inside 12000s while every spill rung finished in about 1120s. At hc=1 legacy completed all 28.

The crossover, measured

Eleven cells, all at hc=1, legacy against spill with 8 spill threads, initial scenario, one trial each. The x-axis is measured pressure, peak container_memory_swap plus peak container_memory_working_set_bytes on the legacy rung divided by RAM.

pressure SF size span legacy/spill per-object geomean sum time_ns winner
2.31x 10 M.1-small 0.83 1.18 1.25 legacy
2.87x 200 M.1-8xlarge 0.72 1.31 1.30 legacy
3.39x 30 M.1-large 0.89 1.15 1.15 legacy
4.28x 30 M.1-medium 0.97 1.10 1.05 legacy
4.49x 10 M.1-xsmall 0.88 1.23 1.15 legacy
4.67x 100 M.1-2xlarge 1.35 0.92 0.77 spill
4.74x 200 M.1-4xlarge 1.27 0.97 0.82 spill
5.40x 30 M.1-small 1.36 0.89 0.76 spill
6.02x 100 M.1-1.5xlarge 1.44 0.79 0.69 spill
6.04x 200 M.1-3xlarge 1.58 0.77 0.63 spill
6.95x 10 M.1-micro 1.33 0.80 0.52 spill, both incomplete

All three metrics flip between 4.49x and 4.67x. Points from a 20x range of scale factor interleave monotonically instead of forming four separate curves, so pressure is the axis rather than data volume. The sharpest check is the 6.02x and 6.04x pair, SF100 on a 91 GiB box against SF200 on a 182 GiB box: span 1.44 against 1.58, geomean 0.79 against 0.77, summed time_ns 0.69 against 0.63.

Why the crossover exists

The pool adds application CPU everywhere: 21% on the 8xlarge, 33% on the 3xlarge, 32% on the 2xlarge. That raises its own floor, so below the crossover the added CPU is not repaid. Above it the stall bucket is large enough to pay for it. The pool buys no capacity: peak swap is within 2.5% across rungs on any box with headroom, so it changes which pages leave RAM and when, not how many.

Practical consequences

  • column_paged_batcher_spill_worker_count defaults to 2, and that is the single worst setting measured. On the 8xlarge it made spill 1.54x slower than legacy. The knee is near 8 workers per spill thread, and 8, 16 and 48 threads are flat within noise at 62, 24 and 16 workers. Scale it with worker count, something like max(2, workers/4).
  • Enable spill above roughly 4.5x working set to RAM, and leave it off below that, where it is a regression.
  • column_paged_batcher_lz4 and column_paged_batcher_swap_pageout are still present as environment overrides but were deleted from the code on this build. Compression is controlled by column_chunk_compress_min_depth, default 1, so it is on.

Caveats

Every cell is one trial, and legacy's own utime varied 14% across repeated runs earlier in this thread. The three SF30 cells overlapped an SF200 ingest and sit slightly closer to parity than their neighbours, so they are the ones to repeat first. Working set cannot be predicted from scale factor: it is not linear in SF, and the same workload wants less memory on a smaller box because the batcher budget scales with physical RAM, so the axis has to be measured rather than computed. M.1-micro is the only genuine capacity failure found, and both rungs failed there, legacy at 21 of 28 objects and spill at 25.

@antiguru

Copy link
Copy Markdown
Member Author

Disk ratio does nothing, the crossover is parity at 4.5x rather than a win, and the threshold depends on hydration concurrency

Follow-up to the pressure sweep above. Three results, one of which walks back a number I posted there.

More disk buys the pager nothing

Seven new cells at hc=1, legacy against spill with 8 spill threads, initial, one trial each. Each row holds cpu and memory fixed and varies only disk_limit, which also changes the instance family: cc and M.1 select instance category r generation 6, D.1 selects i8g, DE.1 selects i8ge.

Row B, 2 cpu / 15.16 GiB, SF10, about 4.49x measured pressure:

size disk:mem heap limit legacy spill legacy/spill per-object geomean
100cc 1.5:1 38 GiB 16/28, 21 restarts 17/28, 18 restarts n/a n/a
M.1-xsmall 6:1 106 GiB 1390s 1412s 0.98 1.11
D.1-xsmall 24:1 379 GiB 1499s 1510s 0.99 1.12
DE.1-xsmall 96:1 1471 GiB 1631s 1577s 1.03 1.07

Flat across a 16x span of disk allocation and three instance families. Absolute times drift slightly worse with more disk, which is plausibly the storage-optimised families trading per-core performance for NVMe, but at one trial per cell that is not worth asserting.

The 100cc cell was a crash loop rather than a slow run. Its heap limit is 15.16 + 22.7 = 38 GiB and both rungs peaked at 41 to 42 GiB, so the memory limiter terminated and recreated them about twenty times each and hydration restarted every time. Its spans are meaningless.

The 4.49x point is parity, not a win

Adding the two new cells at that pressure to the two we already had gives four independent measurements:

pressure cells ratio
2.31 to 3.91x 5 cells 0.72 to 0.94, legacy faster
4.28x SF30 on M.1-medium 0.97
4.49x SF10 on M.1-xsmall twice, D.1-xsmall, DE.1-xsmall 0.88, 0.98, 0.99, 1.03
4.67 to 6.04x 5 cells 1.27 to 1.58, spill faster

They straddle 1.0 and average about 0.97. So 4.5x is where the two rungs draw level, and the crossover interval of 4.49x to 4.67x that I quoted above is narrower than the measurement noise. M.1-xsmall repeated at identical settings gave 0.88 and then 0.98, an 11% swing on the ratio, and the four cells at 4.49x span 17%.

What survives is the ordering, which is clean across a 20x range of scale factor and three instance families. Useful margin starts at 4.67x and grows monotonically to 1.58x at 6x, so a rule with headroom above the noise is 5x, not 4.5x.

At the shipped default the crossover is much lower

Everything above is at compute_hydration_concurrency = 1. At the default of 4:

pressure legacy spill ratio
1.16x 486.4s 465.2s 1.05
1.16x on 3200cc 489.2s 494.4s 0.99
2.98x 2867.6s 1552.7s 1.85
4.49x 5747.2s 2558.8s 2.25
5.97x 7801.5s 4305.5s 1.81

Spill is concurrency-invariant within 3% on every box measured, legacy degrades 1.26x to 2.08x going from hc=1 to hc=4. So the threshold is a property of the pair, not of the pager: at hc=4 the crossover sits somewhere between 1.16x and 2.98x, a gap we never measured.

That makes managed spill and a lower hydration concurrency two alternative fixes for overlapping ground. Keeping hc=4 means the pager pays off from roughly 2x pressure and an "enable above 5x" rule leaves wins on the table. Lowering concurrency on pressured replicas recovers most of legacy's loss for one dyncfg, and then the pager only earns its keep above 5x. Two cells in the 1.16x to 2.98x band at hc=4 would settle which.

One outlier excluded, and why

1600cc at about 3.0x pressure showed legacy 2.0x slower than its controls at identical cpu, memory and worker count (2106s against 1054s on M.1-4xlarge and 930s on D.1-4xlarge), moving 1020 GB of block I/O against 517 and 523 GB. Peak throughput was the highest of the six cells at 1062 MB/s, so the device was not the constraint, and both rungs averaged about the same sustained rate, so it moved twice the bytes for the same swap residency.

I no longer think this is a disk effect. Row B says disk ratio does nothing, and the limiter demonstrably works: 100cc restarted 21 times at an 11% overshoot. Yet 1600cc peaked at roughly 724 GiB against a 587.5 GiB heap limit, a 23% overshoot, with zero restarts. collect_heap_limit (src/clusterd/src/usage_metrics.rs:104) takes min(cgroup, physical) for the swap term, so with no cgroup swap limit the effective limit becomes node-sized rather than size-defined. That would let a cc replica swap well past its budget and would explain the doubled I/O without any device-size effect. Worth a look independently of this benchmark.

What would improve the numbers

Not another replica size. Exporting the node's SwapTotal and the effective heap limit as metrics, and repeat trials on the cells that carry the conclusion. As things stand the headline effect near the crossover and the run-to-run noise are the same size.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant