Skip to content

Commit 71a1bf2

Browse files
[Data] Parallelize ListFiles for the footer indexer; per-test bin budgets
## 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>
1 parent 26cdef2 commit 71a1bf2

4 files changed

Lines changed: 93 additions & 15 deletions

File tree

python/ray/data/_internal/datasource_v2/listing/file_indexer.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,7 @@ def yields_read_units(self) -> bool:
4242
manifests that still need size-balanced partitioning downstream. An
4343
indexer that bin-packs internally (e.g. the footer-based Parquet indexer,
4444
which reads footers and packs row groups into ~one-block manifests)
45-
returns ``True``; ``ListFiles`` then skips the partitioner and runs
46-
listing as a single task so packing sees the whole file stream.
45+
returns ``True``; ``ListFiles`` then skips the partitioner.
4746
"""
4847
return False
4948

python/ray/data/_internal/datasource_v2/listing/footer_file_indexer.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,12 @@
2828

2929
logger = logging.getLogger(__name__)
3030

31-
# A pool of footer-reading actors spread across the cluster. Footer reads are
32-
# network-bound, so several actors each driving many concurrent reads keeps IO
33-
# from bottlenecking on a single node.
31+
# Footer-reading actors spread across the cluster, provisioned **per ListFiles
32+
# task**. Footer reads are network-bound, so several actors each driving many
33+
# concurrent reads keeps IO from bottlenecking on a single node. ``ListFiles``
34+
# shards user-supplied paths across parallel listing tasks, so a read that hands
35+
# in many explicit paths stands up this many actors per shard -- lower it for
36+
# those, rather than assuming it is a cluster-wide total.
3437
_DEFAULT_NUM_ACTORS = env_integer("RAY_DATA_PARQUET_FOOTER_NUM_ACTORS", 32)
3538
_DEFAULT_IO_CONCURRENCY = env_integer("RAY_DATA_PARQUET_FOOTER_IO_CONCURRENCY", 128)
3639
# Files per ``read_footers`` call. Small footers -> batch several per task to
@@ -104,7 +107,7 @@ def __init__(
104107
@property
105108
def yields_read_units(self) -> bool:
106109
# list_files already emits bin-packed read units, so ListFiles skips the
107-
# partitioner and lists in a single task (global packing + one pool).
110+
# partitioner. Packing is per listing task, not global.
108111
return True
109112

110113
def list_files(

python/ray/data/_internal/planner/plan_list_files_op.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,18 @@ def plan_list_files_op(
7171
shuffle_config = op.shuffle_config_factory()
7272

7373
# Some indexers (e.g. the footer-based Parquet indexer) already emit
74-
# bin-packed read units from ``list_files`` -- they need the whole file
75-
# stream on one task to pack globally, and there's nothing left to partition.
74+
# bin-packed read units from ``list_files``, so there's nothing left for a
75+
# partitioner to do. They still list in parallel: each task bin-packs the
76+
# slice of the path list it was handed, which is the same per-task locality
77+
# a partitioner gets when listing is split.
7678
yields_read_units = indexer.yields_read_units
7779

80+
# One task is required only when shuffle needs a single global RNG over the
81+
# whole listing. Note that per-call indexer resources -- the footer indexer's
82+
# reader-actor pool -- are provisioned per listing task, so a read handing in
83+
# many explicit paths should size that pool accordingly.
84+
should_parallelize = shuffle_config is None
85+
7886
transform_fns: List[MapTransformFn] = [
7987
BlockMapTransformFn(
8088
partial(
@@ -120,12 +128,7 @@ def plan_list_files_op(
120128
map_op = MapOperator.create(
121129
map_transformer,
122130
_create_input_data_buffer(
123-
op,
124-
data_context,
125-
# A single task is required when shuffle needs one global RNG over
126-
# the full listing, or when the indexer bin-packs read units itself
127-
# (it must see the whole file stream to pack globally).
128-
should_parallelize=shuffle_config is None and not yields_read_units,
131+
op, data_context, should_parallelize=should_parallelize
129132
),
130133
data_context,
131134
name="ListFiles",

release/release_data_tests.yaml

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@
4646
python: "3.10"
4747
cluster:
4848
anyscale_sdk_2026: true
49+
byod:
50+
runtime_env:
51+
# Files here are ~3.9 GiB uncompressed with ~69 MiB row groups. The
52+
# default bin budget can't fit two row groups, so every one becomes its
53+
# own read task (~5.8k tasks for 103 files). 4 GiB packs a file per bin,
54+
# matching the whole-file granularity the V1 partitioner produced.
55+
- RAY_DATA_PARQUET_BIN_PACKING_BYTES=4294967296
4956
cluster_compute: "{{scaling}}_cpu_compute.yaml"
5057

5158
matrix:
@@ -113,6 +120,13 @@
113120
python: "3.10"
114121
cluster:
115122
anyscale_sdk_2026: true
123+
byod:
124+
runtime_env:
125+
# sf1000 lineitem is read with its full 17-column schema: ~1.16 GiB
126+
# uncompressed per file over 1000 files. 1.25 GiB packs ~one file per
127+
# bin (~943 read tasks vs ~1000 blocks under V1); the default budget
128+
# would produce ~18k tasks.
129+
- RAY_DATA_PARQUET_BIN_PACKING_BYTES=1342177280
116130
run:
117131
timeout: 3600
118132
script: >
@@ -201,6 +215,12 @@
201215
- RAYTEST_FAIL_ON_WORKER_OOM=1
202216
- RAYTEST_FAIL_ON_DEAD_NODES=1
203217
- RAYTEST_FAIL_ON_SPILLING=0
218+
# ``groupby_benchmark`` reads sf100 lineitem with its full 17-column
219+
# schema: ~1.17 GiB uncompressed per file over 100 files. 1.25 GiB packs
220+
# ~one file per bin (~95 read tasks), matching the whole-file
221+
# granularity of the V1 partitioner. The default budget yields ~2500
222+
# read tasks, which starves the downstream shuffle.
223+
- RAY_DATA_PARQUET_BIN_PACKING_BYTES=1342177280
204224
cluster_compute: "{{scaling}}_all_to_all_compute.yaml"
205225

206226
run:
@@ -228,6 +248,9 @@
228248
- RAYTEST_FAIL_ON_WORKER_OOM=1
229249
- RAYTEST_FAIL_ON_DEAD_NODES=1
230250
- RAYTEST_FAIL_ON_SPILLING=0
251+
# Same sf100 lineitem full-schema read as the aggregate variants; see
252+
# the note there for why 1.25 GiB.
253+
- RAY_DATA_PARQUET_BIN_PACKING_BYTES=1342177280
231254
cluster_compute: "{{scaling}}_all_to_all_compute.yaml"
232255

233256
run:
@@ -269,6 +292,10 @@
269292
- RAYTEST_FAIL_ON_WORKER_OOM=1
270293
- RAYTEST_FAIL_ON_DEAD_NODES=1
271294
- RAYTEST_FAIL_ON_SPILLING=0
295+
# Both sides are read with their full schema: ~1.17 GiB uncompressed
296+
# per lineitem file. 1.25 GiB packs ~one file per bin (~95 read tasks
297+
# vs 100 blocks under V1).
298+
- RAY_DATA_PARQUET_BIN_PACKING_BYTES=1342177280
272299
cluster_compute: fixed_size_100_cpu_compute.yaml
273300

274301
matrix:
@@ -304,7 +331,16 @@
304331
- RAYTEST_FAIL_ON_SPILLING=1
305332
# S3 tensor data was written by Ray 2.49-2.54 using cloudpickle.
306333
- RAY_DATA_AUTOLOAD_CLOUDPICKLE_TENSOR_METADATA=1
307-
- RAY_DATA_PARQUET_BIN_PACKING_BYTES=39000000
334+
# No bin budget override here. These datasets are 10-40 MB in total
335+
# across 9-27 single-row-group files, so every budget from 64 MiB up
336+
# packs them into a single bin -- except `tensors`, whose 37.25 MiB row
337+
# groups already exceed the 64 MiB default's two-per-bin threshold and
338+
# so land one per bin on their own. Splitting `primitives` (0.45 MiB
339+
# per file), `nested_structs` (1.3 MiB) and `objects` (4.2 MiB) needs a
340+
# minimum-block-count floor, not a byte budget.
341+
#
342+
# These datasets are small enough that the footer-reader pool is pure
343+
# startup cost; keep it narrow and spread the few files across it.
308344
- RAY_DATA_PARQUET_FOOTER_NUM_ACTORS=22
309345
- RAY_DATA_PARQUET_FOOTER_BATCH_SIZE=1
310346
- RAY_DATA_PARQUET_READER_IO_THREAD_COUNT=5000
@@ -328,6 +364,17 @@
328364
python: "3.10"
329365
cluster:
330366
anyscale_sdk_2026: true
367+
byod:
368+
runtime_env:
369+
# imagenet parquet: 7416 single-row-group files of ~8.15 MiB. The V1
370+
# partitioner sized buckets off a 5x encoding-ratio estimate, which
371+
# over-counts for already-compressed image bytes, so it landed on ~4
372+
# files (~31 MB) per block. The footer path measures the real bytes, so
373+
# the default budget produces ~66 MB blocks -- 2x coarser. With
374+
# ``equal=True`` the splitter buffers whole blocks, so coarser blocks
375+
# mean more read-ahead is thrown away when the consumers stop. 36 MiB
376+
# reproduces the ~4-files-per-block granularity.
377+
- RAY_DATA_PARQUET_BIN_PACKING_BYTES=37748736
331378
run:
332379
timeout: 300
333380
wait_for_nodes:
@@ -359,6 +406,17 @@
359406
python: "3.10"
360407
cluster:
361408
anyscale_sdk_2026: true
409+
byod:
410+
runtime_env:
411+
# No bin budget override: at the default this dataset (513 single-row-
412+
# group files of ~88 MB) already packs ~one file per bin, matching the
413+
# granularity the V1 partitioner produced.
414+
#
415+
# This benchmark opens 8 datasets over the same path, so the footer
416+
# path costs 8 x 513 footer reads and 8 x 32 reader actors -- work V1
417+
# never did, and enough to dominate the ~40s `random_mix` variants.
418+
# Narrow the pool; the per-actor IO concurrency still covers 513 files.
419+
- RAY_DATA_PARQUET_FOOTER_NUM_ACTORS=4
362420
cluster_compute: dataset_mixing/compute_8_cpu.yaml
363421
run:
364422
timeout: 600
@@ -404,6 +462,12 @@
404462
anyscale_sdk_2026: true
405463
byod:
406464
post_build_script: byod_install_mosaicml.sh
465+
runtime_env:
466+
# This benchmark hands in 200 explicit parquet paths, so ListFiles
467+
# shards them into 200 tasks of one path each. The footer reader pool is
468+
# provisioned per listing task, so the default would stand up 32 actors
469+
# to read a single footer. One actor per task is all this shape needs.
470+
- RAY_DATA_PARQUET_FOOTER_NUM_ACTORS=1
407471
cluster_compute: dataset/multi_node_train_16_workers.yaml
408472

409473
run:
@@ -1443,6 +1507,15 @@
14431507

14441508
cluster:
14451509
anyscale_sdk_2026: true
1510+
byod:
1511+
runtime_env:
1512+
# q20 projects 4 of lineitem's 17 columns -> ~160 MiB uncompressed per
1513+
# file. At the default budget that splits each file ~4 ways (400 blocks
1514+
# vs the 100 the V1 partitioner produced), and the extra blocks become
1515+
# extra shuffle-map tasks feeding HashAggregate. 192 MiB packs one file
1516+
# per bin. Per-query value: the same table under q21's 2-column
1517+
# projection is only ~92 MiB per file.
1518+
- RAY_DATA_PARQUET_BIN_PACKING_BYTES=201326592
14461519
cluster_compute: "{{scaling}}_all_to_all_compute.yaml"
14471520

14481521
run:

0 commit comments

Comments
 (0)