Skip to content

[Data] External shuffle repartition release benchmark (manual) - #65609

Open
ShockYoungCHN wants to merge 41 commits into
ray-project:masterfrom
ShockYoungCHN:ext-shuffle-release-test
Open

[Data] External shuffle repartition release benchmark (manual)#65609
ShockYoungCHN wants to merge 41 commits into
ray-project:masterfrom
ShockYoungCHN:ext-shuffle-release-test

Conversation

@ShockYoungCHN

Copy link
Copy Markdown
Contributor

Summary

  • Add bench_shuffle.py: keyed TPC-H lineitem repartition benchmark comparing SHUFFLE_V2 in-memory vs external file-transport.
  • Add manual release tests repartition_lineitem_in-memory and repartition_lineitem_external (512GB, 500 partitions, 32-worker all-to-all cluster).

Purpose

Benchmark-only PR, separate from ext-shuffle-3-planner (#65499). Trigger via buildkite/release Rebuild with filter:

name:repartition_lineitem

Test plan

  • buildkite/release → Rebuild → filter name:repartition_lineitem_in-memory
  • buildkite/release → Rebuild → filter name:repartition_lineitem_external
  • Compare wall time / throughput in Buildkite artifacts (TEST_OUTPUT_JSON)

Made with Cursor

ShockYoungCHN and others added 29 commits August 15, 2026 00:54
Keep upstream's external runtime, add map/reduce ops and tasks from
ext-2, and wire use_external_hash_shuffle under ShuffleStrategy.SHUFFLE_V2
(on-disk vs in-memory). Adapt ops to main's estimate_object_store_usage
API and register smoke/repartition tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Clarify that the non-external SHUFFLE_V2 transport is the object-store
path, not a separate "in-memory" mode.

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Match hash-shuffle v2 so Write and other fused sinks see the full
partition output in one apply_transform instead of per-block.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Document *blocks/Returns for map task, drop bare return in reduce for
pydoclint, and add the standard pytest __main__ entry to smoke/repartition.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Rename fetch helper, drop unused accum_bytes, use assert for size check,
and refresh stale docs (fsync, mmap, handle fields).

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Decode appends tables only. Concat happens in reduce_fn, matching
hash-shuffle v2 instead of an untested env-gated combine.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
concat_tables leaves one chunk per shard; Table.nbytes walks them all.
Always combine_chunks on the reduce_fn result so later size_bytes is cheap.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Move combine_chunks back to decode (always on, no env). Do not combine
again after reduce_fn.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Keep map flush before rename. Fetch no longer fsyncs the staging file.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Drop the thin _fetch_region wrapper; keep (base, size) on the future map.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Match the external_shuffle_* naming of tasks/runtime.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
decoded nbytes can be 0 while rows exist (null-typed columns). Record
per-partition num_rows on the map handle and use that for the reduce
empty fast path; keep decoded_bytes for memory estimates.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Match v2's ObjectRef/remote stubs and pass BlockMetadata.input_files as a tuple.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…ores

Cast ShuffleFileServer to ActorClass at .options sites, resolve map
handles via get_waitable, and declare extra ShuffleHandle bookkeeping keys.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Dropping the types only hid the ray.types vs includes.object_ref stub split.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Production operators use inline pyrefly ignores like v2; tests construct
actors with ActorClass[Any]/ActorHandle[Any] like the GPU aggregate tests.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…atements

Keep ignores on the previous line so wrapping ray.put does not detach them.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…nore

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…shuffle-3-planner

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…mport order

- ExternalHashShuffleMapOp now reads data_context.shuffle_input_batch_bytes
  instead of hardcoding a 1 GiB pre-map merge threshold, matching ShuffleMapOp.
- Update context.py docstring to note the field applies to external shuffle too.
- Add test_shuffle_input_batch_bytes_controls_map_task_batching to smoke tests.
- Fix ruff isort violations: reorder external_shuffle_* imports before shuffle_*
  in operator_fusion.py and plan_all_to_all_op.py; add missing blank line in
  test_hash_shuffle_external_repartition.py.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <noreply@cursor.com>
Restore the one-knob lineitem repartition benchmark on a dedicated
branch, updated for current APIs: ShuffleStrategy.SHUFFLE_V2 plus
use_external_hash_shuffle (was HASH_SHUFFLE / older v3 flag names).

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <noreply@cursor.com>
…ternal

Add repartition_lineitem_{in-memory,external} (frequency: manual) on the
existing 32-worker all-to-all cluster. Workload is TPC-H lineitem
~512GB keyed repartition via bench_shuffle.py, with Benchmark JSON
output so Buildkite/release can collect results.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <noreply@cursor.com>
@ShockYoungCHN
ShockYoungCHN requested a review from a team as a code owner August 20, 2026 06:18

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces support for the external (file-transport) hash-shuffle variant in Ray Data's SHUFFLE_V2 strategy. It updates the physical planner and operator fusion rules to handle the external map and reduce operators, adds comprehensive correctness and smoke tests, and introduces a new benchmark script (bench_shuffle.py) along with release test configurations. The review feedback highlights several critical improvements for the benchmark script, such as ensuring distributed cleanup of local parquet files on worker nodes, raising an error on fleet scaling timeouts, preventing blocking when object store memory is untracked, and ensuring the parent directory for the results JSON is created to avoid late-stage crashes. Additionally, a minor docstring mismatch in a test helper was identified.

Comment on lines +99 to +100
if write_parquet:
shutil.rmtree(output_path, ignore_errors=True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

In a distributed Ray cluster, calling write_parquet with a local path (like /tmp/shuffle_output) will write files to the local filesystems of the worker nodes where the write tasks run. Calling shutil.rmtree(output_path) on the driver node only deletes the directory on the driver, leaving the written parquet files on the worker nodes' local disks. This can lead to disk space leaks on the workers.\n\nTo fix this, you should clean up the directory on all active nodes in the cluster using a Ray remote function. The same applies to lines 128-129.

    if write_parquet:\n        try:\n            @ray.remote\n            def _cleanup(p):\n                import shutil\n                shutil.rmtree(p, ignore_errors=True)\n            ray.get([_cleanup.options(resources={f"node:{node['NodeManagerAddress']}": 0.01}).remote(output_path) for node in ray.nodes() if node.get("Alive")])\n        except Exception:\n            shutil.rmtree(output_path, ignore_errors=True)

Comment on lines +145 to +154
def _wait_for_fleet(target_cpu: int, timeout_s: int = 1200) -> None:
print(f"Waiting for {target_cpu} CPU to come online ...", flush=True)
deadline = time.perf_counter() + timeout_s
while time.perf_counter() < deadline:
cur = ray.cluster_resources().get("CPU", 0)
if cur >= target_cpu:
break
time.sleep(10)
final = ray.cluster_resources().get("CPU", 0)
print(f"Fleet ready: {final:.0f} CPU", flush=True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If the fleet does not reach the target_cpu count within timeout_s, the function currently prints the final CPU count and exits silently. This can lead to running the benchmark with fewer resources than expected, which will produce skewed or incorrect benchmark results. It is safer to raise a RuntimeError if the target CPU count is not met.

def _wait_for_fleet(target_cpu: int, timeout_s: int = 1200) -> None:\n    print(f"Waiting for {target_cpu} CPU to come online ...", flush=True)\n    deadline = time.perf_counter() + timeout_s\n    while time.perf_counter() < deadline:\n        cur = ray.cluster_resources().get("CPU", 0)\n        if cur >= target_cpu:\n            break\n        time.sleep(10)\n    final = ray.cluster_resources().get("CPU", 0)\n    if final < target_cpu:\n        raise RuntimeError(\n            f"Timed out waiting for {target_cpu} CPUs. Only {final:.0f} CPUs came online."\n        )\n    print(f"Fleet ready: {final:.0f} CPU", flush=True)

Comment on lines +64 to +74
def wait_for_object_store_to_drain(threshold_pct=20, timeout_s=180, poll_s=5):
deadline = time.perf_counter() + timeout_s
while time.perf_counter() < deadline:
mem = ray.cluster_resources().get("object_store_memory", 1)
avail = ray.available_resources().get("object_store_memory", 0)
used_pct = (1 - avail / mem) * 100 if mem > 0 else 0
if used_pct < threshold_pct:
return
print(f" draining object store ({used_pct:.0f}% used)...", flush=True)
time.sleep(poll_s)
print(f" object store drain timed out after {timeout_s}s", flush=True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If "object_store_memory" is not present in ray.cluster_resources() (which can happen in certain test or local environments), used_pct will default to 100% and the function will block/time out for the full timeout_s (180 seconds). We should check if "object_store_memory" is tracked in the cluster resources before waiting.

def wait_for_object_store_to_drain(threshold_pct=20, timeout_s=180, poll_s=5):\n    if "object_store_memory" not in ray.cluster_resources():\n        return\n    deadline = time.perf_counter() + timeout_s\n    while time.perf_counter() < deadline:\n        mem = ray.cluster_resources().get("object_store_memory", 1)\n        avail = ray.available_resources().get("object_store_memory", 0)\n        used_pct = (1 - avail / mem) * 100 if mem > 0 else 0\n        if used_pct < threshold_pct:\n            return\n        print(f"    draining object store ({used_pct:.0f}% used)...", flush=True)\n        time.sleep(poll_s)\n    print(f"    object store drain timed out after {timeout_s}s", flush=True)

Comment on lines +323 to +324
with open(args.result_json, "w") as f:
json.dump(slim, f, indent=2)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If args.result_json is specified with a path whose parent directory does not exist, writing the JSON will fail with a FileNotFoundError. Since this benchmark can run for a long time, crashing at the very end due to a missing directory is highly frustrating. We should ensure the parent directory is created before writing.

        os.makedirs(os.path.dirname(os.path.abspath(args.result_json)), exist_ok=True)\n        with open(args.result_json, "w") as f:\n            json.dump(slim, f, indent=2)

Comment on lines +90 to +95
"""Wire map→reduce, drive to completion, return (map_out, reduce_out, reduce_op).

Caller must shut down ``reduce_op`` (and its upstream map / InputDataBuffer)
via the returned reduce op's input chain, or use the try/finally in tests.
Returns ``(map_output, reduce_output, reduce_op, map_op, upstream)``.
"""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The docstring for _drive_external_shuffle has a mismatch with the actual return values. It states that it returns (map_out, reduce_out, reduce_op) (3 elements) in the summary, and (map_output, reduce_output, reduce_op, map_op, upstream) (5 elements) in the Returns section. However, the actual implementation returns 4 elements: map_output, reduce_op, map_op, upstream. This should be corrected to avoid confusion.

    """Wire map→reduce, drive to completion, return (map_output, reduce_op, map_op, upstream).\n\n    Caller must shut down ``reduce_op`` (and its upstream map / InputDataBuffer)\n    via the returned reduce op's input chain, or use the try/finally in tests.\n    Returns ``(map_output, reduce_op, map_op, upstream)``.\n    """

@ray-gardener ray-gardener Bot added data Ray Data-related issues release-test release test community-contribution Contributed by the community labels Aug 20, 2026
ShockYoungCHN and others added 9 commits August 20, 2026 02:33
Add a dedicated all-to-all compute config with m5.2xlarge workers
(instead of m5.4xlarge) and point the manual repartition_lineitem
benchmark at it. target-cpu 256 still matches 32x8 vCPUs.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <noreply@cursor.com>
Match prior Anyscale bench_shuffle runs: stream shuffled output to
local parquet instead of materialize(), which spills badly on 32x
m5.2xlarge when holding ~512GB in the object store.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <noreply@cursor.com>
Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <noreply@cursor.com>
Use 512 partitions for the 512GB case, add a 1TB/1024-partition
manual matrix, dump RESULT metrics into result.json as-is, and set
object_manager client_connection_num/rpc_threads_num to 8.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <noreply@cursor.com>
map_groups already uses keyed repartition under SHUFFLE_V2, so external
is a DataContext flag. Expose --shuffle-transport on groupby_benchmark
and add a fixed_size SF1000 / 7M-group manual matrix matching the v2 bar.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <noreply@cursor.com>
Match prior Anyscale defaults (100KB) for repartition_lineitem and
map_groups_sf1000 manual release tests.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <noreply@cursor.com>
Assert ExternalHashShuffle* appears in the plan, print CONFIG/PLAN_OPS/
CONSUME timings, and enable --stats on map_groups_sf1000 so read vs
shuffle vs map_groups walls are visible in the next release run.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <noreply@cursor.com>
V1 aggregator path is still wired via ShuffleStrategy.HASH_SHUFFLE.
Expose --shuffle v1 in bench_shuffle and include it in the 512GB and
1TB manual matrices alongside in-memory and external.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <noreply@cursor.com>
Move 512GB and 1TB manual benches back to 32x m5.4xlarge
(fixed_size_all_to_all_compute.yaml), target-cpu 512, keep write_parquet
and the v1 HASH_SHUFFLE matrix entry.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <noreply@cursor.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.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 5930aa4. Configure here.

- RAYTEST_FAIL_ON_DEAD_NODES=1
- RAY_object_manager_client_connection_num=8
- RAY_object_manager_rpc_threads_num=8
cluster_compute: fixed_size_all_to_all_compute.yaml

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Orphaned m5.2xlarge compute config

Low Severity

Switching repartition_lineitem_* to fixed_size_all_to_all_compute.yaml leaves fixed_size_all_to_all_compute_m5_2xlarge.yaml with no remaining references, while its header still claims it is used by this release benchmark.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5930aa4. Configure here.

ShockYoungCHN and others added 3 commits August 21, 2026 15:13
Add --data-size-gb limit to groupby_benchmark and a manual release matrix
map_groups_512gb_{in-memory,external} on m5.4 to compare spill/CPU vs
the SF1000/500-partition map_groups bar.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <noreply@cursor.com>
1 TiB / 1024 partitions on m5.4 (SF10000 lineitem limit), matching the
repartition_lineitem_1tb scale while keeping only in-memory and external.

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <noreply@cursor.com>
Replace the mistaken SF10000+limit / 512GB-limit benches with a full
SF1000 matrix over 512 and 1024 partitions (in-memory + external).

Signed-off-by: yuanzhuo <yuanzhuoyang@gmail.com>
Co-authored-by: Cursor <noreply@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-contribution Contributed by the community data Ray Data-related issues release-test release test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant