Skip to content

[Data] (test branch) Batch ray.get of metadata + wide-schema release test - #63535

Closed
xinyuangui2 wants to merge 14 commits into
ray-project:masterfrom
xinyuangui2:batch-get-wide-schema-test
Closed

[Data] (test branch) Batch ray.get of metadata + wide-schema release test#63535
xinyuangui2 wants to merge 14 commits into
ray-project:masterfrom
xinyuangui2:batch-get-wide-schema-test

Conversation

@xinyuangui2

@xinyuangui2 xinyuangui2 commented May 20, 2026

Copy link
Copy Markdown
Contributor

What this PR is

A test branch combining:

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.get fallback (residual ~14 s of leaf get_objects time on 2000_tasks at 600 cols).

The current design replaces the single-pair pipeline with a queue-based one:

  • DataOpTask now holds a Deque[(block_ref, meta_ref)] of pending pairs (plus a partial-block stash) instead of two scalar _pending_*_ref slots.
  • 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 of on_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_tasks runs while peek_pending_pair() is not None: per ready task, issues one ray.get over the union of every surfaced meta_ref across every ready task, and seeds each task's cache with the result.
  • on_data_ready becomes a pure consumer of the queue + cache; the per-ref ray.get survives only as the fallback for direct callers / cache misses.

Result: one batched ray.get per scheduler iteration covers all pending metadata, with the per-ref fallback eliminated from the production path.

🤖 Generated with Claude Code

xinyuangui2 and others added 11 commits May 19, 2026 18:19
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>

@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 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.

Comment on lines +521 to +531
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

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

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.

Suggested change
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

Comment on lines +109 to +111
_proc = subprocess.Popen(cmd, stdout=_log_file, stderr=_log_file)
_log_file.write(f"py-spy pid: {_proc.pid}\n")
_log_file.flush()

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 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

xinyuangui2 and others added 3 commits May 20, 2026 09:25
- 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>
@xinyuangui2
xinyuangui2 force-pushed the batch-get-wide-schema-test branch from ab20395 to 5fad871 Compare May 20, 2026 19:22
@xinyuangui2 xinyuangui2 changed the title Batch get wide schema test [Data] (test branch) Batch ray.get of metadata + wide-schema release test May 20, 2026
@xinyuangui2 xinyuangui2 closed this Jun 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant