[Data] (test branch) Batch ray.get of metadata + wide-schema release test - #63535
[Data] (test branch) Batch ray.get of metadata + wide-schema release test#63535xinyuangui2 wants to merge 14 commits into
Conversation
Two coupled changes to the worker_scaling release tests: 1) Fix the hang. PR ray-project#63420 set num_cpus=0.5 on the actor pool and halved the cluster files, accounting only for actor CPU and omitting the ReadRange operator. On the resulting 32-node m5.2xlarge cluster (256 vCPU) the 500-actor pool packs 16-per-node = full saturation; Ray Core can't place a single num_cpus=1 ReadRange task; the Data resource manager reserves 64 slots anyway and the operators deadlock. The runtime logs: WARNING resource_manager.py:891 -- Cluster resources are not enough to run any task from TaskPoolMapOperator[ReadRange]. The job may hang forever unless the cluster scales up. Revert to the simpler num_cpus=1 + un-halved cluster sizes: variant | broken (master) | this PR --------|-----------------|-------- 500 | 32 nodes | 63 1000 | 63 | 125 2000 | 125 | 250 5000 | 313 | 625 Each variant fits args.num_workers vCPU into the cluster with a small margin for ReadRange. The 0.5-CPU packing was the proximate cause of the hang and didn't actually save cluster cost once the schedule-loop overhead made ReadRange a CPU consumer of its own. 2) Add a driver-side py-spy profiler. release/nightly_tests/dataset/ pyspy_profiler.py is a focused subset of the rayturbo profiling package (driver only, speedscope output). The benchmark wires it in via the PYSPY_ENABLED env var: PYSPY_ENABLED=1 python worker_scaling_benchmark.py ... produces a `pyspy_driver.speedscope.json` under --profile-output-dir (default /tmp/worker_scaling_profile). This makes the scheduler- thread profile reproducible from any release-test run without needing rayturbo's full profiling stack, which is what we used to validate the schema-cache + batched-ray.get optimizations in ray-project#63462 and ray-project#63483. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
If `PYSPY_S3_DEST` env var is set (e.g. `s3://my-bucket/run-abc/`), `pyspy_profiler.stop()` uploads both `pyspy_driver.speedscope.json` and `pyspy_driver.log` under that prefix once py-spy has finished writing them. Unset → artifacts stay local, same as before. Mirrors the rayturbo telemetry upload step; bucket/prefix is configurable rather than hardcoded so the public release-test can wire it up without anyscale-specific defaults. Uses boto3 (Ray already depends on it) and swallows upload errors so a transient S3 problem doesn't fail the benchmark. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walks back the num_cpus=1 + full-size cluster choice from the first commit and tries a tighter packing instead. The +1 empty node gives Ray Core a guaranteed home for ReadRange tasks (8 contiguous vCPU on a clean node) regardless of how densely the actor pool packs the rest of the cluster, which should avoid the hang we saw with num_cpus=0.5 + 0 spare nodes. | variant | actors vCPU | packed nodes | +1 empty | |---------|-------------|--------------|----------| | 500 | 250 | 32 | 33 | | 1000 | 500 | 63 | 64 | | 2000 | 1000 | 125 | 126 | | 5000 | 2500 | 313 | 314 | This is ~50% smaller than the num_cpus=1 sizes from the prior commit. If CI confirms it doesn't hang, this is the better final state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ray Data's default scheduling strategy is SPREAD (python/ray/data/context.py:96), which evenly distributes the actor pool across all worker nodes. The Path B sizes (33/64/126/314) put ~15.6-15.9 actors per node, which under SPREAD means some nodes get exactly 16 actors (= 8 vCPU = full) and the rest get 15 (= 7.5 vCPU used, 0.5 vCPU free). ReadRange tasks at num_cpus=1 need ≥1 contiguous vCPU and can't fit on either type of node — same deadlock as the original ray-project#63420 halved-cluster issue. Cap actors-per-node at 14 (= 7 vCPU = 1 vCPU free) so SPREAD always leaves room for ReadRange: | variant | broken Path B | this commit | |---------|---------------|-------------| | 500 | 33 nodes | 36 | | 1000 | 64 | 72 | | 2000 | 126 | 143 | | 5000 | 314 | 358 | (Sizes = ⌈actors / 14⌉. Still 40-43 % smaller than num_cpus=1 with SPREAD-safe sizing would require.) Also: print the S3 destination at profiler-start time when PYSPY_S3_DEST is set, so users tailing the driver log can see where the artifact will land without waiting for stop(). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sets PYSPY_ENABLED=1 and PYSPY_S3_DEST for all 8 worker_scaling
matrix entries (500/1000/2000/5000 × actors/tasks). Each invocation
uploads its driver speedscope and log to a per-variant + per-job
S3 prefix:
s3://xgui-scheduling-loop-test/release-test/
worker_scaling_<N>_<type>/<ANYSCALE_JOB_ID>/
Tradeoffs of committing this default:
- Bucket name is in the public Ray repo. The bucket is user-owned
but contents are debug artifacts only. Anyone reviewing can swap
the URL to a different bucket without code changes; nothing in
the helper code depends on this specific bucket.
- Profiling runs on every weekly invocation, adding ~MB-scale
artifact uploads. That's tiny relative to other release-test cost.
Falls back to a 'local' folder if ANYSCALE_JOB_ID isn't set so a
local rerun is also captured without overwriting prior runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switches from the personal s3://xgui-scheduling-loop-test/ to
the existing Ray Data release-test bucket. ray-benchmark-data is
already used throughout release_data_tests.yaml for input
fixtures (TPC-H, etc.) and once for a write test (release_data_tests
line 118), so it's an established home for this kind of artifact.
Per-variant, per-job-ID prefixing is unchanged:
s3://ray-benchmark-data/release-test-profiles/
worker_scaling_<N>_<type>/<ANYSCALE_JOB_ID>/
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ray-benchmark-data/ is read-only for the release-test role — the worker_scaling_500_actors run failed to upload with: User: arn:aws:sts::188439194153:assumed-role/ray-autoscaler-v1/... is not authorized to perform: s3:PutObject on resource: ray-benchmark-data/release-test-profiles/... ray-release-automation-results is where the wrapper itself successfully uploads release_test_out.json / metrics_test_out.json / output.json, so the role is known to have PutObject there. Note: the workload itself succeeded — 500 actors × num_cpus=0.5 on the 36-node SPREAD-safe cluster ran in 56s with max_scheduling_loop_duration_s = 0.55s. This commit only fixes the S3 destination. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ray-release-automation-results is writable by the release-test role but not readable by individual users, so artifacts uploaded there aren't inspectable. xgui-scheduling-loop-test is in the same AWS account (188439194153) as the release-test cluster, so the role can still write there, and the user owns it for read access. Bucket-policy authorization for cross-role writes is being sorted out separately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The release-test cluster IAM role (ray-autoscaler-v1) does not have PutObject on xgui-scheduling-loop-test; switch to the staging-cloud data bucket that rayturbo already uses for telemetry uploads. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps the synthetic schema from 100+200 to 200+400 (matching rayturbo's production-shape width) so the release test surfaces metadata-transfer overhead (ray.get of BlockMetadataWithSchema) on master. With the previous 300-column shape, ray.get was only 2% of the scheduler thread; at 600 columns we expect it to reappear as a visible hot spot. Timeout bumped to 2700s to give the 5000-actor variant headroom under the wider schema. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request optimizes the Ray Data streaming executor by implementing batched metadata prefetching to reduce ray.get overhead. It introduces a peek_pending_pair method to PhysicalOperator and updates process_completed_tasks to retrieve metadata for multiple tasks in a single call. Additionally, the PR adds a pyspy_profiler utility for driver-side profiling in release tests and adjusts various nightly test configurations and benchmark parameters. Review feedback suggests using ray.wait with a zero timeout during prefetching to prevent scheduler stalls and recommends handling FileNotFoundError when launching the py-spy subprocess to improve robustness.
| try: | ||
| bytes_list = ray.get( | ||
| meta_refs_to_prefetch, timeout=METADATA_GET_TIMEOUT_S | ||
| ) | ||
| prefetched_meta_bytes = dict(zip(meta_refs_to_prefetch, bytes_list)) | ||
| except ray.exceptions.GetTimeoutError: | ||
| # One or more refs weren't ready in time. Tasks whose meta_ref | ||
| # isn't in `prefetched_meta_bytes` will fall back to the per-ref | ||
| # ray.get path inside `on_data_ready` (which has its own | ||
| # timeout-handling and retry-next-iteration semantics). | ||
| pass |
There was a problem hiding this comment.
Issuing a batched ray.get with a 1-second timeout directly in the scheduler thread can cause significant stalls if any of the metadata objects are not yet available (e.g., due to slow network propagation). Since process_completed_tasks is called frequently, it's safer to use ray.wait with timeout=0 to only prefetch the objects that are already locally available. This avoids blocking the scheduler while still providing the batching benefit for ready metadata.
| try: | |
| bytes_list = ray.get( | |
| meta_refs_to_prefetch, timeout=METADATA_GET_TIMEOUT_S | |
| ) | |
| prefetched_meta_bytes = dict(zip(meta_refs_to_prefetch, bytes_list)) | |
| except ray.exceptions.GetTimeoutError: | |
| # One or more refs weren't ready in time. Tasks whose meta_ref | |
| # isn't in `prefetched_meta_bytes` will fall back to the per-ref | |
| # ray.get path inside `on_data_ready` (which has its own | |
| # timeout-handling and retry-next-iteration semantics). | |
| pass | |
| # Use ray.wait with timeout=0 to only prefetch what's already ready, | |
| # avoiding stalling the scheduler thread. | |
| ready_meta_refs, _ = ray.wait( | |
| meta_refs_to_prefetch, | |
| num_returns=len(meta_refs_to_prefetch), | |
| timeout=0, | |
| ) | |
| if ready_meta_refs: | |
| try: | |
| bytes_list = ray.get(ready_meta_refs) | |
| prefetched_meta_bytes = dict(zip(ready_meta_refs, bytes_list)) | |
| except Exception: | |
| # Fallback to per-ref ray.get in on_data_ready if anything goes wrong. | |
| pass |
| _proc = subprocess.Popen(cmd, stdout=_log_file, stderr=_log_file) | ||
| _log_file.write(f"py-spy pid: {_proc.pid}\n") | ||
| _log_file.flush() |
There was a problem hiding this comment.
If the py-spy executable is not installed or not in the system PATH, subprocess.Popen will raise a FileNotFoundError. It's better to catch this exception to prevent the entire benchmark from crashing when profiling is enabled but the tool is missing.
try:
_proc = subprocess.Popen(cmd, stdout=_log_file, stderr=_log_file)
_log_file.write(f"py-spy pid: {_proc.pid}\n")
_log_file.flush()
except FileNotFoundError:
print("Error: 'py-spy' executable not found. Profiling will not start.")
_log_file.write("Error: 'py-spy' executable not found.\n")
_log_file.close()
_log_file = None
_proc = None
return- Drop the PYSPY_S3_DEST env knob; hardcode the telemetry bucket in pyspy_profiler.py and accept an explicit s3_prefix in stop(). - Catch FileNotFoundError/OSError from subprocess.Popen so a missing py-spy binary doesn't crash the benchmark, and close the log file on that failure path. - Make stop() safe to call even when start() never took ownership of a process, ensuring the log file is always closed. Signed-off-by: xgui <xgui@anyscale.com>
After the BlockMetadataWithSchema schema-deserialization cache landed (ray-project#63462), the next-largest cost on the StreamingExecutor scheduling thread is the per-task `ray.get(meta_ref, timeout=METADATA_GET_TIMEOUT_S)` call inside `on_data_ready`. py-spy on the wide-schema worker_scaling benchmark showed ~12% of scheduler-thread CPU in `ray.get_objects`, dominated by the ~1-2 ms fixed per-call overhead (Cython arg processing, GIL ping, RPC to the local raylet, msgpack wrapper deserialization). Each scheduler iteration can ingest hundreds of completed tasks, so the per-call overhead multiplies. Mitigation: `process_completed_tasks` now prefetches the metadata bytes for all ready DataOpTasks in a single batched `ray.get`. Specifically: 1. `DataOpTask.peek_pending_pair()` (new): polls the streaming generator non-blockingly and surfaces the next (block_ref, meta_ref) pair if one is immediately available, setting `_pending_block_ref` / `_pending_meta_ref`. StopIteration / meta-wait-timeout edge cases fall through to the existing handling in `on_data_ready`. 2. `process_completed_tasks` calls `peek_pending_pair` for every ready DataOpTask, gathers the surfaced meta_refs into one list, and issues one `ray.get(meta_refs, timeout=METADATA_GET_TIMEOUT_S)`. 3. `on_data_ready` gains an optional `prefetched_meta_bytes` dict parameter. When the dict contains the current `_pending_meta_ref`, the bytes are consumed (popped) and used directly; otherwise the existing per-ref `ray.get` path runs with the standard timeout and warning behavior. So tasks whose meta_ref wasn't surfaced by peek (e.g. still in transit when the batched get fired) gracefully fall back. This collapses N×(~1-2 ms) per-call overhead per scheduler iteration into roughly one batched call. On a 1000-actor wide-schema workload that should bring the scheduling-loop's `ray.get` cost down from ~12% to a small constant. Added two unit tests in test_streaming_executor.py covering the prefetch hit and miss paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: xgui <xgui@anyscale.com>
ab20395 to
5fad871
Compare
What this PR is
A test branch combining:
batch-meta-ray-get) — batched ray.get of block metadata in the StreamingExecutor scheduler thread.wide-schema-profiler) — 600-column release-test schema + driver-side py-spy profiler upload.Used for one-off release-test runs to validate #63483's scheduler-thread speedup on the production-shape (600-col) workload. Not for merge — both underlying PRs land independently.
New batch-get design (reflected here at HEAD
5fad871408)The previous version of #63483 batched only the first pending pair per ready task, leaving Case 1 (within-task generator drain) on the per-ref
ray.getfallback (residual ~14 s of leafget_objectstime on 2000_tasks at 600 cols).The current design replaces the single-pair pipeline with a queue-based one:
DataOpTasknow holds aDeque[(block_ref, meta_ref)]of pending pairs (plus a partial-block stash) instead of two scalar_pending_*_refslots.peek_pending_pair()is drainable — callable in a loop to pull every pair the streaming generator currently has ready into the queue. It also detects task termination via flags handled at the end ofon_data_ready.cache_prefetched_meta_bytes(dict)lets the caller seed the task's cache; the bytes survive across iterations and are consumed in lockstep with the queue.process_completed_tasksrunswhile peek_pending_pair() is not None:per ready task, issues oneray.getover the union of every surfaced meta_ref across every ready task, and seeds each task's cache with the result.on_data_readybecomes a pure consumer of the queue + cache; the per-refray.getsurvives only as the fallback for direct callers / cache misses.Result: one batched
ray.getper scheduler iteration covers all pending metadata, with the per-ref fallback eliminated from the production path.🤖 Generated with Claude Code