Skip to content

Bound processing pipeline memory use#3191

Open
poodlewars wants to merge 1 commit into
masterfrom
aseaton/12201416606/column-stats-memory-no-scripts
Open

Bound processing pipeline memory use#3191
poodlewars wants to merge 1 commit into
masterfrom
aseaton/12201416606/column-stats-memory-no-scripts

Conversation

@poodlewars

@poodlewars poodlewars commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

In the processing pipeline we currently kick off reads eagerly, meaning that if the in-memory processing does not complete quickly, large numbers of SegmentInMemory objects accumulate in the CPU executor's queue. Creating column stats manually considers all the data in a symbol, so we can end up holding the entire symbol's data in memory.

Implementation

This PR adds a new "admission handler" to control memory use in the processing pipeline. This affects all QueryBuilder operations, not just column stats. We now create a special "admission handler" object that provides segment read futures. Importantly, it creates these as promises and only kicks off work for them later, unlike the current eager read pipeline. This design means that everything downstream of the segment load futures in the processing pipeline can be used as-is.

The admission handler only allows K processing units to be live in memory at a time. As each unit finishes it calls on_unit_complete (when the MemSegmentProcessingTask completes). This kicks off the load of the next unit's segments. Importantly this is not a blocking operation, else it could deadlock.

To give an example, with processing units with an overlap:

P = [{0, 1}, {1, 2}]

we first call admit_initial to load processing unit 0, i.e. segments 0, 1. When this unit is complete, the component manager frees segment 0 but not segment 1. At this point on_unit_complete fires. This calls admit(processing_unit=1) which calls fire(segment=1) (no-op) and fire(segment=2) which kicks off a segment load.

We also make sure that we effectively folly::window over the IO work that we kick off, rather than letting it all pile up on the IO queues when K is large, which gives worse queuing behaviour and regressed performance compared to folly::window.

For testing, I add a class SegmentResidencyTracker that measures how many segments were live in memory over an operation. This is compiled in to release builds but is a no-op - it only does anything in testing. Its cost is just reading a boolean that's always false.

Impact

Measurements

Each table cell is peak ru_maxrss (MiB) / wall time (s) for one operation. Every cell is a separate process running a single operation, so ru_maxrss (peak RSS over the process lifetime) is a clean high-water mark for that one operation.
master has no admission bound, so it has a single column. fixes varies the residency budget K (VersionStore.NumProcessingUnitsLive, the max processing units in flight):
K=0 is the kill switch (bound disabled), K=8 a tight bound, K=default the computed default; the read window W (VersionStore.SegmentReadWindow) is left at its default of 2*io.
Each build is run under three process allocators: glibc default, glibc with MALLOC_ARENA_MAX=4, and tcmalloc (LD_PRELOAD). IO:CPU is the IO/CPU threadpool sizes (VersionStore.NumIOThreads / NumCPUThreads).

Data

One symbol, f64_100m_100c_zeros, on PURE (S3): 100,000,000 rows x 100 float64 data columns (f_0..f_99), all zeros, plus an int64 category column cycling through values 1-5,
indexed by a nanosecond DatetimeIndex at 1-second frequency. Written with default slicing (100k rows/segment, all columns in one column slice), giving 1000 row-slice segments.

Each segment is about 80MiB decoded, the symbol is about 80GiB decoded.

Operations

  • filter: read with QueryBuilder()[q["f_0"] == -1.0]. -1.0 never occurs (data is zeros), so the result is empty and does not contribute to RSS
  • column_stats: build MINMAX column statistics for each row-slice
  • resample: read with QueryBuilder().resample("30D").agg(sum) summing all 100 float columns into ~30-day buckets (a few dozen output rows).
  • groupby: read with QueryBuilder().groupby("category").agg(sum) summing all 100 float columns across the 5 category groups (5 output rows).

Findings

  • Allocator makes a big difference glibc default inflates peak RSS badly as there are so many arenas on the build servers, arena cap or tcmalloc cut RSS significantly
    (column_stats IO=64 master: 33745 glibc -> 10575 arena4 -> 7472 tcmalloc).
    The best config is the bound plus tcmalloc: filter 64:96 K=8 tcmalloc = 713 MiB vs 4782 master glibc (6.7x); column_stats K=8 tcmalloc = 1641 vs 33745 (20x).
  • K=0 reproduces master timings so the kill switch works

filter

glibc default — peak MiB / wall s

IO:CPU master fixes K=0 fixes K=8 fixes K=default
8:12 754 / 3.85 782 / 3.67 767 / 4.62 775 / 3.36
16:24 1310 / 2.73 1331 / 2.38 1242 / 5.77 1317 / 2.76
64:96 4782 / 1.96 4763 / 2.00 3528 / 6.15 4769 / 2.17

MALLOC_ARENA_MAX=4 — peak MiB / wall s

IO:CPU master fixes K=0 fixes K=8 fixes K=default
8:12 760 / 3.52 717 / 3.38 751 / 5.78 729 / 3.13
16:24 1246 / 2.28 1242 / 1.90 805 / 2.84 1214 / 1.99
64:96 3684 / 1.60 4042 / 1.68 813 / 2.78 3742 / 1.62

tcmalloc — peak MiB / wall s

IO:CPU master fixes K=0 fixes K=8 fixes K=default
8:12 724 / 2.79 721 / 2.89 774 / 3.24 693 / 2.79
16:24 1296 / 2.36 1257 / 1.94 709 / 2.82 1255 / 2.18
64:96 3679 / 1.73 3977 / 1.60 713 / 3.03 3904 / 1.63

column_stats

glibc default — peak MiB / wall s

IO:CPU master fixes K=0 fixes K=8 fixes K=default
8:12 2420 / 5.22 2456 / 3.91 1699 / 7.73 2459 / 3.88
16:24 4102 / 3.19 5301 / 3.02 2187 / 7.85 3597 / 3.00
64:96 33745 / 2.89 34669 / 2.64 4396 / 8.95 17860 / 2.52

MALLOC_ARENA_MAX=4 — peak MiB / wall s

IO:CPU master fixes K=0 fixes K=8 fixes K=default
8:12 2182 / 5.30 2239 / 4.75 1699 / 8.39 2223 / 4.88
16:24 2908 / 3.78 3120 / 3.33 1870 / 8.08 3026 / 3.51
64:96 10575 / 3.09 10629 / 2.94 1755 / 7.08 10684 / 2.99

tcmalloc — peak MiB / wall s

IO:CPU master fixes K=0 fixes K=8 fixes K=default
8:12 1993 / 5.32 2110 / 5.26 1703 / 7.94 2072 / 5.49
16:24 2806 / 4.03 2849 / 3.92 1705 / 8.12 2716 / 3.58
64:96 7472 / 2.44 8256 / 2.44 1641 / 8.23 7972 / 2.49

resample

glibc default — peak MiB / wall s

IO:CPU master fixes K=0 fixes K=8 fixes K=default
8:12 27738 / 4.41 28330 / 4.39 17616 / 5.80 29181 / 4.37
16:24 36420 / 3.58 41407 / 3.52 17735 / 5.57 39342 / 3.56
64:96 67305 / 2.73 72794 / 2.55 19555 / 5.53 69743 / 2.57

MALLOC_ARENA_MAX=4 — peak MiB / wall s

IO:CPU master fixes K=0 fixes K=8 fixes K=default
8:12 26605 / 4.82 29255 / 4.54 17156 / 6.22 27049 / 4.78
16:24 34450 / 3.79 35539 / 3.94 17807 / 6.02 33574 / 3.85
64:96 47546 / 3.79 46191 / 3.81 17166 / 6.22 47240 / 3.83

tcmalloc — peak MiB / wall s

IO:CPU master fixes K=0 fixes K=8 fixes K=default
8:12 23101 / 5.87 24464 / 5.66 16538 / 6.00 25449 / 5.47
16:24 30853 / 4.97 30146 / 4.47 16505 / 5.82 33760 / 4.58
64:96 37698 / 4.55 38581 / 4.52 16488 / 6.10 39270 / 4.60

groupby

glibc default — peak MiB / wall s

IO:CPU master fixes K=0 fixes K=8 fixes K=default
8:12 100648 / 17.63 105814 / 17.37 78832 / 19.61 80688 / 17.29
16:24 106685 / 19.40 106876 / 18.42 79327 / 19.26 82510 / 17.33
64:96 114506 / 18.82 117136 / 20.62 80876 / 19.89 93084 / 17.56

MALLOC_ARENA_MAX=4 — peak MiB / wall s

IO:CPU master fixes K=0 fixes K=8 fixes K=default
8:12 81761 / 17.31 81812 / 17.45 79306 / 20.35 81388 / 19.81
16:24 82330 / 19.95 82285 / 17.57 79413 / 27.60 82442 / 18.47
64:96 83657 / 18.57 83466 / 18.87 79150 / 26.88 82258 / 19.10

tcmalloc — peak MiB / wall s

IO:CPU master fixes K=0 fixes K=8 fixes K=default
8:12 85505 / 17.78 85473 / 19.62 83491 / 27.06 85458 / 17.39
16:24 88473 / 17.07 89082 / 17.07 83612 / 21.27 89188 / 15.67
64:96 92356 / 16.78 92444 / 16.03 83513 / 22.00 92484 / 17.13

Alternatives

#3138 - discarded as it leaves the CPU pool idle, and is difficult to read (in that the mechanism that bounds memory use is not explicit).

Originally I wanted to pass a semaphore in to batch_read_uncompressed, and acquire it for segment reads, and release it after a segment is processed, like compacting staged data does, always allowing at least n_col_slices to be loaded. This did not work because we kick off all the segment reads in parallel so your n_col_slices budget can be split across segments in different processing units while leaving no processing unit able to load completely and continue. This PR is instead explicit about allowing loads per processing unit.

@poodlewars poodlewars added patch Small change, should increase patch version no-release-notes This PR shouldn't be added to release notes. labels Jun 23, 2026
@poodlewars poodlewars force-pushed the aseaton/12201416606/column-stats-memory-no-scripts branch from c4fe938 to cf370d6 Compare June 23, 2026 13:33
@poodlewars poodlewars marked this pull request as ready for review June 23, 2026 13:40
@claude

claude Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

ArcticDB Code Review Summary

The implementation was reworked since the previous review (force-push + squash). The earlier design hooked residency tracking into ComponentManager/DecodeSliceTask; the current design introduces ProcessingUnitAdmissionHandler, which gates segment reads behind unfulfilled promises and admits at most K processing units at a time (residency budget) while keeping a folly::window-equivalent read window.

I re-reviewed the new design in full. No blocking correctness, memory-safety, or concurrency issues found. Specifically verified: admit_initial_processing_units is correctly sequenced after the .ensure(on_processing_unit_complete) chain (so a completing unit always admits the next, i.e. no deadlock, confirmed by the tiny-thread-pool tests); read failures still free the read-window slot and mark the unit complete; shared segments are read exactly once; shared_from_this/shared-ptr captures keep the handler alive across the async reads; and the default residency limit IO read-ahead term (>=4io) exceeds the read window (2io), so normal read/QueryBuilder paths do not regress to fewer in-flight reads. Two points remain worth confirming:

PR Title and Description

  • no-release-notes label may be incorrectly applied. This PR materially reduces peak memory for processing-pipeline operations (per the description, ~7.8 GiB to ~1.1 GiB for create_column_stats) across all QueryBuilder operations, and adds two user-tunable config keys (VersionStore.NumProcessingUnitsLive, VersionStore.SegmentReadWindow). That is a user-facing behavioural change, which per the guidelines should carry release notes rather than no-release-notes. Please confirm the label is intended.

Code Quality (non-blocking)

  • util::SegmentResidencyTracker is documented as test-only instrumentation but is now compiled into and invoked from production paths: SegmentInMemoryImpl::mark_from_disk() (called from DecodeSliceTask::decode_into_slice) and the SegmentInMemoryImpl destructor. The cost when disabled is negligible (the destructor short-circuits on the from_disk_ flag before touching the atomic; mark_from_disk does one relaxed atomic load per decoded segment), so this is acceptable, flagging only for awareness that test instrumentation now lives permanently in the decode/release paths. (Author has indicated intent to enable-by-default and measure via ASV in a follow-up PR.)

@poodlewars poodlewars changed the title Bound column stats memory use Bound column stats creation memory use Jun 23, 2026
Comment thread cpp/arcticdb/version/test/test_admission_handler.cpp
Comment thread cpp/arcticdb/processing/component_manager.cpp Outdated
Comment thread cpp/arcticdb/util/segment_residency_tracker.hpp
@poodlewars

poodlewars commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator Author

Do some more convinving testing of the new memory use
Done, see PR description.
We may want to tune the default so the memory bound kicks in sooner.

Always use the admission handler (polymorphic or semaphore size = n processing units)
Done, outside of the batch path

Is it valid to use IO thread count for the number of processing units to load? Should we also consider the CPU thread count?
I think so. This matches the old behaviour. Since users can configure the IO thread count and CPU thread count independently, I think we have to control the bound off the IO thread count for that configuration to make any sense.

We could take the max of the current setting and the CPU pool size, or twice the CPU pool size to give slack like the current approach (the idea being to keep the hopper full, try not to have consumers sat waiting on an empty queue). This would help if processing units have a large size (very col sliced) such that the IO pool creates only one processing unit at a time. The CPU pool works on units, so would be mostly idle with this workload.

Later
(Singleton?) admission handler across symbols so it works properly with batch reads. Could either change the non-batch path or not.

@poodlewars poodlewars changed the title Bound column stats creation memory use Bound processing pipeline memory use Jul 3, 2026
…pipeline

Column stats creation previously read and processed every row slice of a
symbol concurrently, with no bound on how many decompressed segments could
be resident at once. This adds an admission handler that windows how many
processing units are live at a time, tracks segment residency via
SegmentInMemoryImpl, and applies it throughout the pipeline rather than
just column stats. A config kill switch
(VersionStore.NumProcessingUnitsLive=0) disables the bound entirely.
@poodlewars poodlewars force-pushed the aseaton/12201416606/column-stats-memory-no-scripts branch from ddbb212 to 16ac857 Compare July 3, 2026 12:08
@poodlewars poodlewars requested review from IvoDD and alexowens90 July 3, 2026 15:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-release-notes This PR shouldn't be added to release notes. patch Small change, should increase patch version

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant