Skip to content

[Data] [DO NOT MERGE] arrow-rs Parquet reader A/B — treatment arm (release-test trigger) - #65406

Open
AarryaSaraf wants to merge 137 commits into
ray-project:masterfrom
AarryaSaraf:arrow-rs-on-64985
Open

[Data] [DO NOT MERGE] arrow-rs Parquet reader A/B — treatment arm (release-test trigger)#65406
AarryaSaraf wants to merge 137 commits into
ray-project:masterfrom
AarryaSaraf:arrow-rs-on-64985

Conversation

@AarryaSaraf

Copy link
Copy Markdown
Contributor

Draft PR whose only purpose is to trigger the release pipeline for the multi-node
arrow-rs vs PyArrow A/B (the reader from #65117, ported onto #64985's planner).
This branch is stacked on unmerged #64985, so its diff includes that PR's commits —
do not review or merge. Baseline arm: see the companion draft PR for
arrow-rs-ab-baseline-64985.

🤖 Generated with Claude Code

goutamvenkat-anyscale and others added 30 commits July 23, 2026 22:58
…et V2

Replace the blind, size-based ParquetFileChunker + RoundRobinPartitioner path
for Parquet V2 reads with a FooterFileIndexer that reads each file's Parquet
footer on a pool of FooterReader actors (SPREAD across the cluster) and packs
row groups into read units with an online, locality-preserving bin packer.

This makes read units row-group-accurate instead of size-estimated, and adds
predicate pushdown + row-group skipping and limit pushdown into listing: the
optimizer rules mirror the pushed predicate/projection/limit from the ReadFiles
scanner onto the upstream ListFiles (sync_list_files_pushdown), so the footer
actors prune row groups by statistics, size only projected columns, and stop
listing early under a limit. Optional row-group coalescing + split-packing are
supported and gated (no-op by default).

The footer indexer reuses the generic FileIndexer abstraction (ListFiles drives
it via the ordinary list_files transform, yields_read_units=True so it lists in
a single task and skips the partitioner). The blind ParquetFileChunker /
ParquetFileChunkMetadata and their size-based row-group range helpers are
removed; the reader now reads exactly the row groups a bin names.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Goutam <goutam@anyscale.com>
LimitPushdownRule._push_limit_down returned early ("no row-preserving ops")
when a Limit was directly above ReadFiles, so it never applied the per-block
limit -> the scanner limit and (footer path) ListFiles.limit were left unset.
The footer indexer then read every file's footer regardless of the limit.

Push the per-block limit into a directly-attached ReadFiles (keeping the Limit
on top for exact enforcement), which also mirrors the limit onto ListFiles via
sync_list_files_pushdown so the footer indexer stops listing early. Scoped to
ReadFiles to keep other sources unchanged.

read_parquet(s3://...).limit(10_000) on a 260-file dataset: ~9.8s -> ~3.1s.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Goutam <goutam@anyscale.com>
Follow-up refinements on the footer-based row-group chunking path:

- Carry projection-scoped uncompressed_size on ParquetRowGroupChunkMetadata
  so the reader sizes batches from footer stats ListFiles already read,
  instead of re-reading the footer in the read task.
- Read the exact physical row groups a bin assigned via explicit
  row_group_ids fragments (_fragments_from_row_group_ids), scanning a
  file's groups together unless row hashing needs per-group offsets.
- Size Arrow's process-wide IO/CPU thread pools per read task so a scan
  can issue many concurrent column/range fetches against S3
  (RAY_DATA_PARQUET_READER_IO_THREAD_COUNT / _CPU_COUNT).
- Add RAY_DATA_PARQUET_FOOTER_MAX_INFLIGHT_BATCHES to bound the in-flight
  footer-batch window independently of actor count.
- Use a fixed fallback bin budget (RAY_DATA_PARQUET_FOOTER_BIN_BYTES,
  default 1 GiB) for read-task packing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Goutam <goutam@anyscale.com>
A shared bin at or over cap can never accept another positive-size item,
so leaving it in the pool just burns one of the max_shared_open_bins
slots and delays draining. Seal and evict it immediately from both the
whole-item fast path and the best-fit split loop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Goutam <goutam@anyscale.com>
`PushdownCountFiles` rebuilt `ListFiles` by deep-copying the existing
indexer and overwriting its `_file_chunker`. `FooterFileIndexer`
subclasses `NonSamplingFileIndexer`, so the `isinstance` assert passed --
but it overrides `list_files` outright and never reads the chunker, so the
swap was a silent no-op.

The result on `count_parquet_fixed_size` (10k files): listing still
footer-swept every file, and because `count()` projects to zero columns
every row group measured 0 bytes, so the bin cap was never reached and the
whole dataset collapsed into one bin -- one manifest block, one count task
that then re-read all 10k footers serially. 30.5s -> 219.8s.

Build the plain indexer explicitly via a new
`NonSamplingFileIndexer.as_whole_file_indexer()`, and bail out rather than
assert when the indexer isn't one (the assert was stripped under -O, which
is the same failure mode as this bug). This restores one manifest row per
file and parallel count tasks, and removes a latent over-count: the bin
packer emits one row per path per bin, so a file spanning bins would have
been counted more than once.

Adds the first test coverage for the rule, including an exact-type
assertion on the rebuilt indexer -- an isinstance check is what let the
footer indexer through.

Also in this commit:
- lower the bin-packing default from 1 GiB to 64 MiB
- expose `max_shared_open_bins` via
  `RAY_DATA_PARQUET_BIN_PACKING_MAX_SHARED_OPEN_BINS`
- experiment env vars on the `wide_schema_pipeline` release test
- `map_benchmark` reads via V2 instead of pinning V1

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Goutam <goutam@anyscale.com>
…gets

## Listing parallelism

`ListFiles` forced single-task listing whenever the indexer sets
`yields_read_units` -- i.e. always, for Parquet V2 -- on the grounds that
the bin packer "must see the whole file stream to pack globally". But the
V1 partitioner it replaced also runs inside each listing task, so global
packing was never the status quo, and the cost is real: a read that hands
in explicit paths loses listing parallelism entirely.

`distributed_training` passes 200 explicit parquet paths. Master sharded
them across 200 listing tasks (3.00s); the footer path did all 200 footer
reads in one (5.81s), ~65% of that test's +15% regression, with block
count and block size otherwise identical to master.

Drop `yields_read_units` from the `should_parallelize` condition. Only the
shuffle-RNG case still needs a single task. Reads that pass one directory
-- the common case -- shard to one task either way and are unaffected.

The footer reader pool is provisioned per `list_files` call, so it is now
per listing task rather than cluster-wide. Documented as such on
`RAY_DATA_PARQUET_FOOTER_NUM_ACTORS`, and `distributed_training` sets it
to 1 since each of its tasks reads a single footer.

## Per-test bin budgets

The 64 MiB default is right for some datasets and badly wrong for others,
because the useful budget tracks per-file projected bytes -- which ranges
from 0.45 MiB to 3.87 GiB across the suite, and varies with the query's
projection on the same table. Measured from parquet footers and verified
against the V1 partitioner's block counts:

- read_large_parquet         4 GiB    (3.87 GiB/file; 64 MiB -> 5768 tasks)
- write_parquet, joins,      1.25 GiB (~1.17 GiB/file, full schema)
  aggregate_groups, map_groups
- tpch_q20                   192 MiB  (160 MiB/file, 4-column projection)
- streaming_split            36 MiB   (V1 landed on ~31 MB blocks here)

The map_groups value also fixes four outright failures, not just a
slowdown: `map_groups_*_sort_shuffle_pull_based` OOM'd the driver at
64 MiB. Pull-based shuffle holds `input_blocks * output_blocks` object
refs on the driver, so at 2500 blocks that is 17.5 GiB (observed 21.6 GiB
RSS, cgroup kill). At 1.25 GiB it is ~95 blocks and 0.03 GiB.

`wide_schema` gets no budget: those datasets are 10-40 MB total across
9-27 single-row-group files, so every budget from 64 MiB up packs them
into one bin. They need a minimum-block-count floor instead. `mix` gets
no budget either -- it already matches V1 granularity -- only a narrower
reader pool, since it opens 8 datasets over the same 513-file path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Goutam <goutam@anyscale.com>
…tunable

## Revert: parallel listing for the footer indexer

71a1bf2 dropped `yields_read_units` from `should_parallelize` so a read
handing in explicit paths would shard listing across tasks. Release build
102070 shows that was wrong.

`FooterFileIndexer.list_files` pays a fixed cost *per call* -- provisioning
a reader-actor pool, building a packer, serializing manifests -- so N tasks
pay it N times instead of splitting it. On `distributed_training` (200
explicit paths) each of 200 listing tasks took 55.9s, 50.7s of it in block
generation, against 5.35s for one task doing all 200. Aggregate listing
work went 5.35 -> 11,180 task-seconds. `distributed_training.chaos` went
+21% -> +463% vs master; `.regular` +15% -> +28%.

Restore the original condition. Parallel listing here needs a pool shared
across tasks, not one per task, which is a larger change.

## RAY_DATA_PARQUET_FOOTER_RESULT_BATCH_SIZE

`read_footers` has always taken `result_batch_size` to amortize driver-side
object-store fetches, but the indexer never passed it, so it defaulted to
1: a directory of N files costs N fetches on the single listing task
(7.4k for the imagenet dataset). Plumb it through as an env-backed setting,
default 1 so behavior is unchanged.

Note it batches *within* one `read_footers` call, so it saturates at
`RAY_DATA_PARQUET_FOOTER_BATCH_SIZE`; both have to move together.

## Release test config

- `streaming_split`: batch sizes 50/50, cutting driver fetches ~7.4k -> ~149.
  ListFiles dominates this test (15.5s of a 26.6s run) and its cost tracks
  results pulled back, not footer IO -- raising the actor pool 32 -> 128
  left block generation flat at ~7 ms/block.
  Bin budget reverted to the default: 36 MiB was matching V1's block size,
  but V1 only lands on ~31 MB blocks because its 5x encoding estimate
  over-counts by 5x on image data that does not compress. Matching it
  doubles the block count and makes the dominant cost worse.
- `distributed_training`: footer actors 1 -> 20. 200 paths at a batch size
  of 10 is 20 batches, so actors past the 20th are never dispatched to.

Also carried: `@ray.method(num_returns="streaming")` on `read_footers`,
re-reading the actor count at construction so env overrides apply after
import, a `None`-size guard in `sample_files`, and `None`-safety in the
bin packer's heavy path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Goutam <goutam@anyscale.com>
Release-test tuning for the Parquet V2 footer read path, plus one real
regression the benchmark itself introduced.

map_batches: 26cdef2 added ``num_cpus=0.99`` to ``read_parquet`` in
map_benchmark.py. The fusion rule canonicalizes an unspecified ``num_cpus``
to 1, so 0.99 makes the read incompatible with the downstream map_batches
ops and they stop fusing. Unfused, every read output crosses the object
store: 1.13 TiB on sf1000 lineitem and 484.5s of block serialization
against 0.6s fused, taking the test from 141s to 335s vs master. Drop the
arg and pin a 1 GiB bin budget -- the 64 MiB default is worst-case on this
table (three 24.36 MiB row groups exceed it, so bins take two and sit 24%
empty, giving 24,432 read tasks against master's 1,000).

aggregate_groups: 32 MiB on both shuffle strategies. First setting to move
fixed_size/84-groups/sort_shuffle, stuck at 220-259s across four builds
under every other budget; 32 MiB brings it to 192s. This is the opposite
direction from map_groups, which wants 1.25 GiB on the same dataset.

tpch_q1: 192 MiB, matching q20 on the same table.

Also corrects comments that described master's read path as V1. Master is
DSv2 with the round-robin partitioner for every suite except map_batches,
which is V1 there because DEFAULT_USE_DATASOURCE_V2 is False on master and
ray-project#64821 (branch-only) flips it.

Tests: pytest python/ray/data/tests/datasource/test_parquet.py::test_parquet_read_spread
python/ray/data/tests/test_predicate_pushdown.py -- 51 passed.

AI assistance was used for this change.

Signed-off-by: Goutam <goutam@anyscale.com>
aggregate_groups: move the bin budget from the block to a per-variant
setting via matrix.adjustments, with bin_packing_bytes as a fourth matrix
variable. Only fixed_size/sort_shuffle/84-groups needed retuning -- it ran 211s
at 32 MiB in release build #102329 against master's ~145s, and 137s at 256 MiB.
The other seven variants keep 32 MiB, where they were measured healthy in that
same build (+10.2% to -30.7%), rather than move onto an unmeasured value.

The cost being tuned is per-block sort-shuffle overhead, not bytes: at 32 MiB
the scan emitted 4x the ListFiles outputs and ~1.5x the blocks into the shuffle
(Sort Sample 3050 vs master's 2000) for an identical 20.8GB object-store peak
and identical row count.

cross_az_map_batches_autoscaling_iptable_failure_injection: pin 1 GiB. These
tests moved from the V1 to the V2 read path when the use_datasource_v2=False
pin came out of map_benchmark.py; on V2 at the 64 MiB default the object-store
peak doubled (310GB -> 616GB) and runtime went 304s -> 725s. The larger budget
is a mitigation and is not yet measured.

Also request num_cpus=0 for FooterReaderActor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Goutam <goutam@anyscale.com>
Conflict in release/release_data_tests.yaml: master added a third
shuffle_strategy (hash_shuffle_v2) to the aggregate_groups matrix, while this
branch had converted that block from matrix.setup to matrix.adjustments so each
variant can carry its own bin budget. Kept both -- the four new hash_shuffle_v2
variants are enumerated as adjustments alongside the existing eight.

The new variants get the 64 MiB branch default rather than the 32 MiB the other
variants carry: 32 MiB is a deliberate pin for variants that were measured at
it in release build #102329, and hash_shuffle_v2 has never run on this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Goutam <goutam@anyscale.com>
…get to 128MiB

The Parquet footer indexer's default 32-actor pool times out (and trips Ray's
"too many worker processes" warning, polluting doctest output) when many test
targets run in parallel under CI. Rather than sprinkling per-module env writes,
set the pool size to 1 in one place per test surface:

- New python/ray/data/test.bzl wraps py_test/py_test_module_list/doctest to
  inject RAY_DATA_PARQUET_FOOTER_NUM_ACTORS=1 into every Data test target's env
  (caller-supplied env still wins).
- python/ray/data/tests/conftest.py and doctest_pytest_plugin.py setdefault the
  same var so non-bazel pytest runs match.
- python/ray/train/v2 gets shared _TRAIN_V2_TEST_ENV dicts plus a conftest
  setdefault, replacing the repeated per-target env literals.
- Drops the now-redundant os.environ write from test_predicate_pushdown.py.

Also raises the fallback bin-packing budget from 64MiB to 128MiB when
target_max_block_size is unset, and fixes the write_lance docstring example to
pass SaveMode.OVERWRITE so repeated doctest runs don't fail on an existing
dataset.

AI assistance was used for this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Goutam <goutam@anyscale.com>
Signed-off-by: Goutam <goutam@anyscale.com>
…ault

Release build #102597 raised the footer indexer's fallback bin budget from
64 to 128 MiB. Comparing each test's delta-vs-its-own-master between #102329
and #102597, 24 of the 38 tests running on the fallback default got worse and
14 improved. This pins the 24 back to 64 MiB explicitly; the 14 keep following
the raised default.

The split is directional rather than arbitrary: among Parquet tests the
regressions are read-dominated (read_parquet_autoscaling -20.9% -> +3.1%,
read_large_parquet_autoscaling -8.1% -> +32.9%, count_parquet_autoscaling
-15.4% -> -1.7%) while the improvements are shuffle/sort-heavy
(random_shuffle_chaos +67.8% -> +14.9%, sort_fixed_size +22.2% -> +2.1%). Same
tension the aggregate_groups and map_groups comments already describe.

read_from_uris, iceberg_benchmark and iter_batches are split out of their
matrices so only the affected variant is pinned and the others keep following
the default. training_ingest_benchmark is pinned at block level with
s3_read_images_gpu exempted.

Caveats recorded in the per-test comments:

- Eight of the 24 pins are inert. read_images_*, read_tfrecords and the
  s3_url_image_* / s3_read_images_cpu ingest variants do not read Parquet. mix
  reads 513 single-row-group files of ~88 MB, which pack one file per bin at
  both 64 and 128 MiB. These tests did move, but not because of this knob.
- mix and distributed_training.chaos define their own runtime_env, which
  replaces the DEFAULTS list, so they already ran without the RAYTEST_* guards
  (and chaos without FOOTER_NUM_ACTORS=20). Left as-is rather than changed
  under cover of this commit.
- The two builds differ by 46 merged master commits, so these deltas are not a
  controlled A/B of the budget alone.

Verified by expanding the yaml through the same DEFAULTS/variations/matrix
logic ray_release.config uses, before and after: test name set identical at
199, all 24 targets at 64 MiB, no non-target budget changed, no test lost or
had an env var altered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Goutam <goutam@anyscale.com>
…moke test

Extends the pattern from 74e599b to two more test surfaces. The Parquet
footer indexer's production default of 32 actors times out and trips Ray's
"too many worker processes" warning when many test targets run in parallel
under CI.

- python/ray/air/BUILD.bazel gets three shared env dicts (_AIR_TEST_ENV,
  _AIR_TEST_ENV_TRAIN_V1, _AIR_TEST_ENV_TF) replacing the repeated per-target
  literals, so all 20 py_test targets under python/ray/air/tests get
  RAY_DATA_PARQUET_FOOTER_NUM_ACTORS=1. The three dicts preserve the existing
  RAY_TRAIN_V2_ENABLED 1-vs-0 split, which six of those targets depend on.
- python/ray/air/tests/conftest.py setdefaults the same var so non-bazel pytest
  runs match. This also covers tests/execution, which has no conftest of its
  own.
- //release:xgboost_train_batch_inference_benchmark_smoke_test gets the var
  inline; a shared dict would be indirection for a single target.

Verified by resolving each air target's env before and after: 20/20 now set the
var and no target had any other env value change.

Not included: the py_doctest[air] target, whose glob excludes tests/**, and the
four sibling smoke tests in release/BUILD.bazel that carry the same
RAY_TRAIN_V2_ENABLED=1 env (air_benchmark_gpu_batch_inference_parquet in
particular reads Parquet and will likely want the same treatment).

AI assistance was used for this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Goutam <goutam@anyscale.com>
…ld for map_batches release test

Signed-off-by: Goutam <goutam@anyscale.com>
ListFiles.predicate / projected_columns / limit were mirrored onto the op by
hand from three separate rules (predicate, projection and limit pushdown).
Nothing enforced the invariant those copies rest on -- that ListFiles never
prunes by more than the downstream ReadFiles actually applies -- so a future
rule that rewrote or dropped the scanner's predicate without re-syncing would
leave ListFiles pruning row groups the reader would have kept, dropping rows
with no error.

Replace the mirroring with DeriveListFilesPushdown, which recomputes every
ListFiles' state from its consuming ReadFiles scanner. It runs in
LogicalOptimizer._post_optimize, after the rule loop reaches a fixed point, and
derives unconditionally in both directions: a ListFiles whose consumer is not a
ReadFiles is reset to no constraints. The worst a future rule can now cause is
listing more than it needs to.

Also replace the getattr(scanner, "predicate", None) access with abstract
pushed_predicate() / pushed_limit() accessors on the scanner mixins, mirroring
the existing pruned_column_names(), so a field rename is a type error rather
than a silent no-op.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Goutam <goutam@anyscale.com>
…e_env

deep_update merges dicts but overwrites lists, so a test that declares its own
cluster.byod.runtime_env replaces the DEFAULTS list rather than extending it.
Pinning a bin budget therefore silently dropped RAY_DATA_DEBUG_RESOURCE_MANAGER
and all three RAYTEST_FAIL_ON_* flags: 27 tests had stopped failing on worker
OOM, dead nodes and spilling. It also split failure semantics across halves of
the same test -- read_large_parquet_autoscaling kept the flags while its
fixed_size twin lost them.

Re-list the four inherited entries in every affected block, leaving the two
that override them deliberately (heterogeneous_memory_batch_inference_
multitenancy and cross_az_...) alone. Verified by expanding both configs: the
resolved failure flags and cluster_compute of all 199 tests now match master
exactly.

Also drop the redundant cluster_compute on iter_batches_pyarrow. It pinned the
same value DEFAULTS supplies, so the baseline was never broken, but keeping it
would stop the variant tracking its former matrix siblings if DEFAULTS moves.
Document the list-replacement trap at the DEFAULTS block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Goutam <goutam@anyscale.com>
Nothing exercised the two push-downs together, and nothing used nulls. The
combination is where the fully_matched classification matters: listing stops
early once the num_rows of fully-matched row groups reaches the pushed limit,
so counting a non-surviving row would make Limit return fewer rows than asked
for, with no error. Parquet min/max statistics are computed over non-null
values only, which makes a group whose non-null values all satisfy the filter
look fully matched by bounds alone.

Arrow's row-group pruning turns out to be null-aware -- it declines to prune
such a group by ~filter, so the classification is already correct -- but that
was untested. Add one parameterized end-to-end test. The fixture is
deliberately lopsided at 10 survivors per 100 rows: the early stop is evaluated
per file, so a fixture with a small shortfall passes even when the
classification is wrong, the last file's overshoot covering the deficit. With
the misclassification injected, the 100- and 200-row limits fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Goutam <goutam@anyscale.com>
…ion test

pyrefly resolved `replace(read_files.input_dependencies[0], predicate=...)`
against `LogicalOperator` and `read_files.scanner.push_filters(...)` against the
base `Scanner`, neither of which carries those fields or methods. Route both
through narrowing helpers, and take `Plan` in `_list_files_of` since that is
what `Optimizer.optimize` returns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Goutam <goutam@anyscale.com>
…ests

A matrix cannot vary ``byod.runtime_env`` per setup value, so the variants that
need their own tuning are spelled out. Rendered test names are unchanged.

- q1 autoscaling: keep the 192 MiB bin budget of the fixed_size variants and
  scale up at 60% utilization instead of the 0.75 default. Both autoscaling
  variants land at 364-385s regardless of shuffle strategy while master runs
  hash_shuffle in 249s, which points at scale-up latency, not shuffle cost.
- q17 autoscaling: 64 MiB. Smaller bins mean more read tasks, which give the
  autoscaler demand to act on earlier; the fixed_size variant is ~5x faster on
  the same query at the default budget.
- q22 fixed_size/hash_shuffle: 16 MiB. At the 128 MiB default this sf100 scan
  collapses into a handful of bins and starves a 23-second query of read
  parallelism (+76.7% vs master in #102732, +72.0% in #102650).

Each spelled-out entry re-lists the four inherited RAYTEST_FAIL_ON_* /
RAY_DATA_DEBUG_RESOURCE_MANAGER vars, since a test-level runtime_env replaces
the DEFAULTS list rather than extending it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Goutam <goutam@anyscale.com>
The target ran two full ``TorchTrainer.fit()`` runs (2 workers, 3 epochs) plus
a ``ray.init`` per parameterization inside small's 60s budget, and timed out at
60.3s on both attempts in CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Goutam <goutam@anyscale.com>
``ParquetFileFragment.subset`` and ``.metadata`` open the file to read its
footer, so on remote storage they fail with the same transient errors the rest
of the read path already retries -- an sf1000 lineitem read died on "AWS Error
NETWORK_CONNECTION ... curlCode: 28" inside ``subset``. Route both through
``call_with_retry`` with the context's ``retried_io_errors``.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Goutam <goutam@anyscale.com>
tpch_q18_autoscaling_hash_shuffle_v2 has failed two builds running: in #102732
the workload completed in 2075.9s and was then failed by
RAYTEST_FAIL_ON_SPILLING on 4.62 GiB spilled against a 17.75 GiB object-store
peak (61.6% utilization) -- spill under moderate pressure rather than a cluster
out of memory, which is the shape a smaller bin budget is most likely to help.
Smaller bins also give the autoscaler demand to act on earlier, as for q17.

tpch_q21_autoscaling_hash_shuffle_v2 fails the same way and is left at the
default as a control. The matrix cannot vary ``byod.runtime_env`` per setup
value, so the two scaling variants are spelled out; rendered test names are
unchanged, and the autoscaling entry re-lists the four inherited
RAYTEST_FAIL_ON_* / RAY_DATA_DEBUG_RESOURCE_MANAGER vars.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Goutam <goutam@anyscale.com>
Signed-off-by: Goutam <goutam@anyscale.com>
Introduce an optional native (Rust/PyO3) Parquet reader for the
DataSource V2 read path, gated by a new DataContext flag
`use_arrow_rs_parquet_reader` (default False, active only under
`use_datasource_v2`). When enabled, `ParquetScanner.create_reader()`
returns `ArrowRsParquetFileReader`, which footer-reads each file via the
`ray_data_arrow_rs` crate and decodes supported row groups natively,
falling back to the PyArrow reader for unsupported files/types/
filesystems. This targets scheduler-invisible Parquet decode memory
(ray#49158): the native path bounds the decode working set instead of
materializing whole decoded row groups.

Changes:
- New Rust crate `ray_data_arrow_rs` (maturin/PyO3, out of the Bazel
  build) exposing native row-group reads over an Arrow C-stream.
- New `ArrowRsParquetFileReader` + `native_metadata` helpers.
- Base `FileReader` refactor (behavior-preserving): extract
  `_split_columns`/`_postprocess`/`_dispatch_fragment_reads` for reuse.
- `DataContext.use_arrow_rs_parquet_reader` flag + scanner toggle.
- Full parity/fallback test suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
Review fixes:
- predicate.rs: mixed int/float comparison returns None (incomparable,
  keep the row group) when the integer is outside f64's exactly-
  representable range, so a precision-lossy promotion can't wrongly
  prune a matching row group.
- lib.rs: release the GIL (py.allow_threads) around blocking footer /
  metadata I/O in read_row_groups, read_row_groups_s3, read_metadata,
  read_metadata_s3, and select_row_groups so Ray's fragment-pool
  threads decode/fetch in parallel instead of serializing on the GIL.
- native_metadata.py / arrow_rs reader: strip a leading "s3://" before
  the bucket/key split (defensive; pyarrow paths are scheme-less).
- parquet_file_reader.py: restore the arrow_rs_* tuning-kwarg pop +
  validation in __init__ (dropped when the file was reconciled to
  upstream) so ArrowRsParquetFileReader._tuning works and typo'd knobs
  raise a clear ValueError.
- arrow_rs reader: _filesystem_supported now treats filesystem=None as
  the default local filesystem, matching the metadata/read paths, so
  eligible local reads no longer silently fall back to PyArrow.

Lint: apply ruff import-ordering fixes in the reader and its test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
stat_min_max mapped every INT32/INT64 page statistic to a signed
Value::Int, ignoring the column's logical type. For a UINT_32/UINT_64
column, Parquet orders stats by unsigned comparison, so a value with the
high bit set (e.g. u32 max) is stored as a negative i32/i64 and reads
back inverting min/max — can_match could then prune a row group that
actually contains matching rows, silently dropping data (the pruning
layer's soundness contract is "never prune a group that might match").
DECIMAL (unscaled int != the decimal literal) and DATE/TIME/TIMESTAMP
(encoding may not match the predicate literal) have the same hazard.

Gate the INT32/INT64 arms on is_plain_signed_int(descr), which consults
the ColumnDescriptor's logical/converted type and only allows genuine
signed integers; everything else returns no bound (conservative keep).
Adds Rust unit tests covering signed/unsigned/date cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
end-of-file-fixer (pre-commit) requires exactly one trailing newline.
Ran the full pre-commit suite over every file in the PR this time so no
later hook stage surprises CI again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
goutamvenkat-anyscale added a commit that referenced this pull request Sep 2, 2026
## Why

The last step of the split of #64985: flip Parquet V2 to the footer read
path, delete the blind one, and tune the release tests so this can be
validated by a real release run.

Everything it depends on has landed — footer types and the bin packer
(#65210), the `FooterReader` actor pool (#65273), the indexer plus
`count()` fix and packer-as-partitioner (#65596), reader IO tuning
(#65806), and test env pinning (#65169). The path has been reachable
behind `RAY_DATA_PARQUET_ENABLE_FOOTER_INDEXER` since #65596; this makes
it the only path.

## Flow

```mermaid
flowchart LR
  RP[read_parquet] --> FF["FooterFileIndexer<br/>+ OnlineBinPacker"]
  RP -.->|deleted| PC["ParquetFileChunker<br/>size-estimated chunks<br/>+ parquet_chunker_target_chunk_size"]
  style PC stroke-dasharray: 4 4
```

## What changed

**The flip** — `RAY_DATA_PARQUET_ENABLE_FOOTER_INDEXER` is gone from
both call sites. `_get_file_indexer()` always returns
`FooterFileIndexer`; `get_file_partitioner()` always returns
`OnlineBinPacker`.

**The deletions** — `ParquetFileChunker`, `ParquetFileChunkMetadata`,
`_calculate_row_group_range`, `_fragments_from_chunk_metadata`, and the
tests covering only that path.

**The config it left behind** —
`DataContext.parquet_chunker_target_chunk_size` and
`DEFAULT_PARQUET_CHUNKER_TARGET_CHUNK_SIZE` were read *only* by
`ParquetFileChunker.__init__`, so they would otherwise stay declared and
documented while doing nothing. Verified dead by grep across `.py` /
`.yaml` / `.rst` / `.md`.

**Reader pool defaults** — `RAY_DATA_PARQUET_READER_IO_THREAD_COUNT`
128, `RAY_DATA_PARQUET_READER_CPU_COUNT` 32.

## Release tests tuned

Budgets are per-test overrides of `RAY_DATA_PARQUET_BIN_PACKING_BYTES`
(uncompressed bytes of row-group data per read task; default 128 MiB).
The shape of the tuning: reads over few large groups want **large**
bins, because small bins shatter the read into short tasks the executor
cannot dispatch fast enough and the cluster idles; shuffle-bound reads
over many groups want **small** bins.

Rows whose variants all share the same tuning are collapsed and marked.

| release test | bin budget | other tuning |
| --- | --- | --- |
| `aggregate_groups_fixed_size_hash_shuffle_column02 column14` | 32 MiB
| — |
| `aggregate_groups_fixed_size_hash_shuffle_column08 column13 column14`
| 128 MiB | — |
| `aggregate_groups_fixed_size_shuffle_v2_column02 column14` | 64 MiB |
— |
| `aggregate_groups_fixed_size_shuffle_v2_column08 column13 column14` |
512 MiB | — |
| `aggregate_groups_fixed_size_sort_shuffle_pull_based_column02
column14` | 32 MiB | — |
| `aggregate_groups_fixed_size_sort_shuffle_pull_based_column08 column13
column14` | 256 MiB | — |
| `count_parquet_fixed_size` | 64 MiB | — |
| `cross_az_map_batches_autoscaling_iptable_failure_injection` | 1 GiB |
— |
| `distributed_training` | — | footer actors `20` |
| `flat_map` | 64 MiB | — |
| `iceberg_benchmark_overwrite` | 64 MiB | — |
| `iter_batches_pyarrow` | 64 MiB | — |
| `iter_torch_batches` | 64 MiB | — |
| `joins_{{dataset}}_{{join_type}} *(all 4 variants)*` | 1.25 GiB | — |
| `map` | 64 MiB | — |
| `map_batches_fixed_size_{{compute}}_{{format}}_{{repeat_map_batches}}
*(all 7 variants)*` | 1 GiB | scale-up threshold `0.6` |
| `map_groups_fixed_size_{{shuffle_strategy}}_{{columns}} *(all 6
variants)*` | 1.25 GiB | — |
| `mix` | — | footer actors `4` |
| `random_shuffle_fixed_size` | 64 MiB | — |
| `read_large_parquet_fixed_size` | 1 GiB | — |
| `read_parquet_fixed_size` | 64 MiB | — |
| `read_tfrecords` | 64 MiB | — |
| `streaming_split` | 64 MiB | footer batch `50`, result batch `50` |
| `to_tf` | 64 MiB | — |
| `tpch_q1_fixed_size_{{shuffle_strategy}} *(all 2 variants)*` | 192 MiB
| — |
| `training_ingest_benchmark` | 64 MiB | — |
| `wide_schema_pipeline_nested_structs` | 2 MiB | footer batch `1`,
footer actors `21`, io threads `5000` |
| `wide_schema_pipeline_objects` | 4.2 MiB | footer batch `1`, footer
actors `9`, io threads `5000` |
| `wide_schema_pipeline_primitives` | 256 KiB | footer batch `1`, footer
actors `27`, io threads `5000` |
| `wide_schema_pipeline_tensors` | 39 MiB | footer batch `1`, footer
actors `22`, io threads `5000` |
| `write_parquet` | 1.25 GiB | — |

**Three matrices are split**, because a matrix cannot vary
`byod.runtime_env` per value and their variants want different budgets:
`wide_schema_pipeline_{{data_type}}` into four entries, and one variant
each out of `iceberg_benchmark_{{mode}}` and `iter_batches_{{format}}`.
`aggregate_groups` keeps its matrix and carries the budget as a matrix
dimension with explicit adjustments. **Rendered test names are unchanged
— 121 before and after** — so release baselines still line up.

**Not carried over** from the original branch: 7 previously-pinned
entries no longer exist — the autoscaling variants removed in #65506,
plus `tpch_q20` and `tpch_q22`.

Any entry gaining its own `runtime_env` re-lists the four `DEFAULTS`
guards verbatim: `deep_update` overwrites lists rather than merging
them, so declaring one silently drops the OOM / dead-node / object-store
checks. (17 entries on master are already missing guards for this
reason; pre-existing, not touched here.)

`map_benchmark.py` also drops its `use_datasource_v2 = False` pin, which
existed because V2 was spilling.

> Budgets originally measured against the pre-decoupling design and
refined since. Packing moved between stages in #65596 but the packer and
its inputs did not, so bins should be unchanged — a release run against
this PR is what confirms that. The wide-schema tests are the first place
to look.

## Behavior change worth reviewing

**Read-task sizing no longer comes from `override_num_blocks` /
`read_op_min_num_blocks` for Parquet.** Bins are sized by
`RAY_DATA_PARQUET_BIN_PACKING_BYTES`, which is *also* not derived from
`DataContext.target_max_block_size`. That decoupling is deliberate —
read units are row-group-accurate rather than size-estimated — but it
means the two knobs users reach for first no longer affect Parquet read
parallelism. Deriving the budget from `target_max_block_size` is worth
considering as a follow-up; the per-test overrides above are the
evidence that a single global default does not fit every workload.

## Testing

```
$ python -m pytest -q python/ray/data/tests/datasource/test_parquet.py
286 passed, 6 skipped in 521.25s

$ python -m pytest -q python/ray/data/tests/datasource/test_read_parquet_v2.py
32 passed

$ python -m pytest -q python/ray/data/tests/unit/datasource_v2/ python/ray/data/_internal/datasource_v2/tests/
181 passed

$ python -m pytest -q python/ray/data/tests/test_predicate_pushdown.py \
                     python/ray/data/tests/test_execution_optimizer_limit_pushdown.py
78 passed
```

The 286-test parquet suite is the one that matters — it runs the full
read surface with the footer path as the default rather than behind a
flag. The release YAML is additionally checked for parse validity,
unresolved `{{...}}` in any pin, duplicate `byod:` keys, and
rendered-name stability.

## Stack

Final step of an 11-step split of #64985 (43 files / +3234 −719, not
reviewable as one unit).

- #65167 `[1]` · #65168 `[2]` · #65214 `[3]` · #65210 `[4]` · #65273
`[5]` · #65596 `[6]` · #65806 `[8]` · #65169 `[9]` — all merged
- `[7]` (count push-down fix) was absorbed into #65596
- `[10]` (release tuning) is folded into this PR, so a release run
exercises the flip and its tuning together
- **this PR** `[11/11]`

---
Not a duplicate. File-level overlap check: #63158 none; #65142 overlaps
`file_indexer.py` / `context.py`; #65406 / #65407 (arrow-rs reader A/B)
overlap broadly but are explicitly `[DO NOT MERGE]` benchmark branches
carrying the footer work out-of-tree.
AI assistance was used; every line reviewed by me and tests run locally.

---------

Signed-off-by: Goutam <goutam@anyscale.com>
…e read loss

Ray+real-S3 sweep of read-task concurrency through the box's core count,
both readers, with a /proc runnable-thread sampler — discriminates CPU/thread
oversubscription (runnable ~= HW threads at the cliff) from stalling
(runnable low while wall explodes) and from no-repro.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
Comment thread release/nightly_tests/dataset/arrow_rs_probe/concurrency_cliff_probe.py Outdated
AarryaSaraf and others added 7 commits September 2, 2026 17:56
The reader is chosen from the driver DataContext singleton, fixed at
first ray.data import and serialized to tasks (it survives
ray.shutdown() and overrides worker runtime_env env vars), so the
in-process arm loop ran every cell with the first arm's reader. Each
cell now runs in a fresh interpreter with the flag env set before
import plus an explicit DataContext set; RAY_ADDRESS is stripped so
cells on an Anyscale workspace start a local Ray from this checkout
instead of joining the platform cluster. Also hold the aggregated
dataset so read-task counts survive into .stats() (ds.sum() discards
the executed plan's stats).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
… workspace raylet

Plain ray.init() auto-discovers a running cluster through
/tmp/ray/ray_current_cluster even with RAY_ADDRESS unset; on an
Anyscale workspace that is the platform raylet (Ray 2.57/Py3.11) and
the cell dies with a version mismatch against this checkout's venv.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
…able

The synthetic fixtures didn't reproduce M92 (150 bin-packed ~2s tasks
vs the release's 5780 ~0.9s row-group tasks; both arms network-capped
at ~1.9 GB/s above N~36, rs 0.94-1.36 with runnable p90 <=36). Next
cell = the actual large-parquet dataset (104 files x 56 x ~69 MB row
groups = the release granularity), which needs a schema-appropriate
consume column.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
…decode

The sum consume gets projection-pushed into the read (the rlp sweep
fetched one double column, ~40 of 402 GB uncompressed). The release
read_large_parquet tests consume via --iter-bundles with no projection;
mirror that. The 64 MiB bin variant additionally needs
RAY_DATA_PARQUET_BIN_PACKING_BYTES=67108864 in the environment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
At 64 MiB bin granularity (the release read_large_parquet_autoscaling
regime) every single-row-group read task built a fresh object_store
client — a cold connection pool paying DNS + full TLS handshakes before
the footer fetch. Stack sampling on an m5.24xlarge showed read workers
blocked in NativeS3Store.open_file -> load_meta_s3 for ~2/3 of task
wall while the box sat idle (runnable p50 = 3/96), walls 2.88x PyArrow
at 48-way concurrency: 5769 cold client builds for 104 distinct files.

connect_native_s3 now caches clients per process, keyed by (bucket,
full connection config incl. credentials) — the same lifetime pyarrow's
serialized S3FileSystem client has in a reused Ray worker. Rotated
credentials change the key, so staleness is structural; the table is
size-capped. Multi-file bins behave as before (first open builds, rest
reuse) plus cross-task reuse. RAY_DATA_ARROW_RS_S3_CLIENT_CACHE=0
restores the uncached per-call behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
… 31)

Fresh 2-CPU local cluster per arm in a fresh subprocess, one-file S3 read
at concurrency=1; the read worker's USS (smaps_rollup) idle / 20 Hz peak /
settled after two consecutive reads. The rs-pa delta on the settled floor
is the M84 ~90-105 MB constant; read ray-project#2 separates a one-time constant from
per-task retention growth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
… sampler

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
Comment thread release/nightly_tests/dataset/arrow_rs_probe/worker_constant_probe.py Outdated
# auto-discover a workspace raylet via /tmp/ray/ray_current_cluster).
ray.init(
address="local", num_cpus=2, include_dashboard=False, logging_level="ERROR"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Footer pool can stall two-CPU cluster

Medium Severity

The cell pins a 2-CPU cluster but leaves RAY_DATA_PARQUET_FOOTER_NUM_ACTORS at the default of 32 one-CPU actors. ListFiles already holds one CPU while creating that pool, so the remaining CPU can go to a footer actor that never receives the single-file batch, blocking listing and leaving the read unscheduled.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b3583bf. Configure here.

Review find (medium, valid): the worker census ran once after a single
dummy task, so a read task landing on a worker forked afterwards was
never sampled — idle/peak/after USS would come from an idle worker.
The M99 run self-certifies (chosen pid's after-read-1 USS 353.6/503.5 MB
vs a ~52 MB idle floor proves the censused pid did both reads), but the
planned multi-read soak would be far more exposed.

Fix: the sampler now rescans /proc every 0.5 s for new ray:: pids,
records first-seen USS as each pid's baseline, and the result carries
two integrity flags: read_worker_late_spawn (baseline may include read
work) and reads_on_same_worker (read-1/read-2 attribution is only
per-worker-meaningful when one pid grew on both).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
Comment thread release/nightly_tests/dataset/arrow_rs_probe/worker_constant_probe.py Outdated
Comment thread release/nightly_tests/dataset/arrow_rs_probe/worker_constant_probe.py Outdated
AarryaSaraf and others added 5 commits September 3, 2026 13:42
Second review find (high, valid): growth-based selection (settled USS
minus first-seen baseline) cannot identify a worker first seen mid-read
- its baseline already holds decode buffers, so after the buffers drop
its delta reads ~0 or negative and a prestarted idle pid's noise wins
the argmax, reporting an idle process as the read worker.

Fix: positive identification. Ray retitles an executing worker
ray::<TaskName> (read tasks: ray::ReadFilesParquetV2), and the sampler
already polls cmdlines every 0.5 s - it now records every title a pid
ever shows, and the read worker is any pid that bore the read-op title.
Growth selection survives only as a fallback for a sub-0.5 s read the
title poll could miss, and the result reports read_worker_id_method
plus n_read_task_workers so a fallback or multi-worker cell is visible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
…OW_RS_MALLOC_TRIM_EOS)

Second allocator lever for the arrow-rs Parquet reader, alongside the
existing mallopt one (RAY_DATA_ARROW_RS_MALLOC_TRIM). Off by default.

Why a second lever: the box soak (arrow_rs_docs findings M48) showed
glibc keeping the reader's freed decode heap resident under task churn
(idle-worker USS +492 MiB over ~100 tasks). mallopt(M_TRIM_THRESHOLD, 0)
collapses that floor (R 0.08) but costs 24-36% wall (M61), and the
threshold value is a dead knob because any explicit setting disables
glibc's dynamic mmap threshold (M64). Calling malloc_trim(0) ONCE at the
end of each read task's stream returns the same retained pages while the
allocator behaves normally during decode. This is the ship candidate the
next release A/B (4 arms: pa / rs / rs+trim / rs+eos) measures.

Mechanics: DataContext.arrow_rs_malloc_trim_eos (env
RAY_DATA_ARROW_RS_MALLOC_TRIM_EOS); read() now wraps the former body
(_read_split) in try/finally so the trim fires exactly once per stream,
whichever path (native or fallback) served it, including early consumer
close. libc symbol resolved once per worker via ctypes; non-Linux and
non-glibc are no-ops with a one-time warning.

Test: knob off never trims; knob on trims once per read() over 2 files
and once on an early-closed generator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
…eos)

gen_2x2_release_tests.py grows a second matrix, --matrix alloc (now the
default; --matrix 2x2 still emits the original one byte-for-byte):

  {pa, rs, rstrim, rseos} x original fleet over the 8 memory, 8 sustained
  and 3 control targets, plus 3 wall targets kept as the release-scale
  confirmation of the M97/M98 wall fix; single-node cells only for
  read_large_parquet_autoscaling (M75/M86) and map_groups col02 (M90).
  22 targets -> 88 multi + 8 single = 96 manual entries.

Arms are one env prefix each on the run script, like the reader flag:
  rstrim  RAY_DATA_ARROW_RS_MALLOC_TRIM=1      box-confirmed mechanism
                                               probe, known wall price
  rseos   RAY_DATA_ARROW_RS_MALLOC_TRIM_EOS=1  ship candidate
Both knobs are read only by the arrow-rs reader, so they are inert on
the pa path by construction (validate() also refuses them without the
reader flag).

Names stay <parent>_2x2_<arm>_<topology>, so the pa/rs cells continue the
results-DB history from build 105711 and one filter (name:.*_2x2_.*)
selects the whole matrix. The block header records which matrix it
holds so --check validates against the right expected set.

release_data_tests.yaml regenerated (marker block only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
Under RAY_DATA_ARROW_RS_MALLOC_TRIM_EOS the col02 map_groups fleet cell ran
2.37x pyarrow wall in build 106096 (findings M107) while the same lever
closed every retention row elsewhere. One run cannot tell "the trim slowed
the tasks" from "the autoscaler packed 44 workers/node", so surface the
trim's cost per task:

- _maybe_trim_at_stream_end() returns the seconds the malloc_trim(0) took;
  ArrowRsParquetFileReader accumulates it and hands it over through a new
  generic Reader.pop_task_stats() hook (default: nothing).
- ReadFiles transform folds it into ReadFilesTaskStats.trim_wall_s (a new
  field; contained in decode_wall_s since the finalizer runs inside the
  iterator's final next()).
- OpRuntimeMetrics gains the read_task_trim_wall_s per-task distribution.

Tests: metrics fold + reader pop/drain semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
…s arm

Three instrument defects found reading build 106096 (arrow_rs_docs
2026-09-04.md), fixed so the next build answers them:

- benchmark.collect_operator_metrics took the FIRST operator containing
  "Read" as the headline; TPC-H q17 has two reads and the headline was the
  2-task `part` read. Now: `read_operators` lists every Read op compactly
  (tasks, wall, bytes, max_uss/decoded/peak_batch/trim per-task quantiles)
  and the `read_*` headline is the Read op with the most tasks (ties: plan
  order). Also exports the new read_task_trim_wall_s distribution.
- node_memory_monitor summed worker USS per sample, so the sustained (p50)
  column on a 10 s test tracked how many workers were alive, not what each
  held. Now also reports, for the peak-USS node, workers_p50/max and the
  per-worker mean USS p50, plus a compact per-node list and the sampler
  interval.
- release_regression_probe / tpch_probe had the arms hardcoded ("pa","rs").
  Now --arms pa,rs,rseos[,rstrim] (env sets in ARMS), --cpus 24,48,96 runs
  each non-tpch cell once per local num_cpus (the M107 concurrency sweep),
  --monitor-interval sets the sampler period; the table prints one row per
  arm with a trim p50 column. run_release_regressions.sh passes ARMS, CPUS,
  MONITOR_INTERVAL through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
Comment thread python/ray/data/_internal/planner/plan_read_files_op.py Outdated
…table is yielded

ReadFilesTaskStats.trim_wall_s is only known when the reader's stream ends
(the arrow-rs eos malloc_trim runs in the reader's finalizer, i.e. inside
the final next()), which is AFTER the last table was yielded. A block's
stats snapshot is pickled when the block leaves the task, and a table that
completes a block (buffer >= target_max_block_size but under the 1.5x slice
limit -> emitted whole, no remainder) is followed by no flush block, so the
_update after the loop reached the driver only when the shaping buffer
happened to hold a remainder. read_task_trim_wall_s then read 0 while the
trim actually ran, hiding exactly the cost the metric exists to expose.

Fix: one-table lookahead in the per-manifest loop. The next table is pulled
before the previous one is yielded, so the StopIteration (and the reader's
pop_task_stats drain) is folded into task_stats before the final yield.
Memory-neutral: the previous table stayed bound in this frame during the
next decode already.

Regression test: one numeric file under the 2048-row batch floor (one table,
one task), target = 0.8x its bytes so that table completes the only block;
asserts num_blocks == 1 and read_task_trim_wall_s.max > 0. Fails on the old
loop with 0.0 > 0.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 13 total unresolved issues (including 12 from previous reviews).

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 650e98f. Configure here.

Comment thread python/ray/data/_internal/planner/plan_read_files_op.py
AarryaSaraf and others added 8 commits September 4, 2026 13:09
The col02 concurrency sweep (findings M111) showed arrow-rs read tasks
running 1.4-3.9x longer than PyArrow's at 24-96 concurrent tasks per node
while using 0.64x the CPU, with the reader iterator itself a flat ~3 s per
task and the rest of the task duration unaccounted for. Split it:

- ReadFilesTaskStats.yield_wall_s: wall spent inside the planner's yield
  (output-buffer shaping, block build, object-store put, streaming-generator
  backpressure). Disjoint from decode_wall_s.
- ReadFilesTaskStats.first_table_wall_s: task start to first decoded table
  (reader construction + first next()), the per-task fixed cost.

Both flow through OpRuntimeMetrics distributions (read_task_yield_wall_s,
read_task_first_table_wall_s) into the benchmark result.json per-task dists
and read_operators p50/max. Task duration - decode - yield = start-up and
teardown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
release_regression_probe folds TEST_OUTPUT_JSON into each cell record as
rec['bench']; tpch_probe only kept the CELL_JSON walls, so its summary.json
had no per-operator wall/cpu, per-task dists or node-mem fields even though
every tpch cell writes them (2026-09-04 q17 leg). Same fold now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
… propagates

The one-table lookahead (650e98f) only yielded a decoded table after the
NEXT next() returned or the stream ended. A later decode failure (IO error,
corrupt page) therefore dropped a table that had already decoded fine, whereas
before the lookahead that table had already left the task. Observable under
DataContext.max_errored_blocks, where a tolerated task keeps its earlier
blocks: the held table was missing from the output.

Catch the non-StopIteration exception, yield the pending table (timed into
yield_wall_s like every other yield), then re-raise.

Regression test: one file, a 32-row first row group of a dictionary-encoded
constant string (one arrow-rs batch, above a 0.9x block target so it completes
a block on its own) followed by a row group whose first data page is zeroed
behind an intact footer; with max_errored_blocks=1 the 32 rows must survive.
arrow-rs only: pyarrow's scanner reads row groups concurrently and raises
before the first batch, so no table precedes the error on that arm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
…ry target)

The fixed-size twin of read_large_parquet is the one release read shape with
no concurrent stage, i.e. the fleet counterpart of the box's clean
read_parquet_binned win (M116) and the rlp re-baseline row (M75). It was
missing from the alloc matrix; adding it as a memory target gives the flip
gate a fixed-pool read control next to the autoscaling one. Regenerated
release_data_tests.yaml: 4 additive entries (pa / rs / rstrim / rseos, multi),
100 generated cells, --check passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
TODO 34f: the col02 rseos fleet cell ran 2.37 once, and one run cannot
separate "the trim slowed the tasks" from "the autoscaler packed 44 workers
per node". The release form has no repeat field; the runner reads a per-test
`repeated_run` key (release/ray_release/buildkite/step.py), so the generator
now emits it for the alloc-matrix gate cells: col02 (multi + single) and the
new read_large_parquet_fixed_size twin, 3 runs each. 12 cells x 3 runs;
everything else stays at 1. --check passes, 100 generated entries unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
… form

Parent test names carry spaces ("column02 column14"); TARGETS and
ALLOC_REPEATED use the '+' form, so the col02 cells missed the repeat in
61ef8bf (4 of 12 cells tagged). Compare on base_name. Now 12 cells carry
repeated_run: 3 -- col02 multi + single and read_large_parquet_fixed_size,
4 arms each.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
…e walls

The per-task reader timers (decode / first_table / yield wall) leave ~5 s of
a read task's duration unexplained on the sf10 lineitem shape — outside the
reader iterator, not CPU (M117-M119). Ray Data already tracks block
generation and serialization wall (object_creation_dur_s per output) and
the operator's submission / output backpressure wall; collect_operator_metrics
now records those op totals next to the per-task distributions so the residual
splits into serialization vs the rest with no reader change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
Two single-file probes behind findings M120: plasma_ser_micro.py times
Ray serialization + ray.put of the same Parquet row groups decoded by the
crate and by PyArrow in one process; plasma_put_micro.py times ray.put
inside N concurrent Ray tasks with rusage fault/CPU counters, arms run
back-to-back in one Ray instance. They showed the per-task block
serialization cost is the fresh object store's first-touch page faults
(7 s cold, 0.2 s warm, either arm), not the arrow-rs block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Aarrya <aarrya.saraf@anyscale.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

data Ray Data-related issues release-test release test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants