Performance & Scaling Bug Report

Ray OutOfMemoryError: Task was killed due to the node running low on memory

Our Ray preprocessing job for image embeddings failed after an hour with OutOfMemoryError. Ray schedules tasks by CPU count, so it packed 16 tasks that each needed 3 GB onto nodes with 30 GB. Declaring memory per task, smaller blocks and a right-sized object store fixed it.

Ryan Pham Ryan Pham @ryan_bugs · October 29, 2025 · 6 min read · 8 Views
Banner: Ray tasks killed by the memory monitor because too many were scheduled per node

We use Ray on Kubernetes (KubeRay) to preprocess images before computing embeddings: decode, resize, normalise, and write to Parquet. The job ran for about an hour and then failed, with workers dying repeatedly:

ray.exceptions.OutOfMemoryError: Task was killed due to the node running low on memory.
Memory on the node (IP: 10.42.3.17, ID: 8c2f...) where the task (task ID: a41b..., name=preprocess_batch,
pid=3812, memory used=3.12GB) was running was 29.61GB / 30.89GB (0.958591), which exceeds the memory
usage threshold of 0.95. Ray killed this worker (ID: 5d0e...) because it was the most recently scheduled
task; to see more information about memory usage on this node, use `ray logs raylet.out -ip 10.42.3.17`.

Retries hit the same limit, and after the retry budget was exhausted the job failed.

Reproducing it

The Ray dashboard showed each worker node running 16 preprocess_batch tasks at once, one per CPU, with each task's memory growing to about 3 GB at peak. 16 times 3 GB is 48 GB on a node with 30 GB. The first part of the dataset contained small thumbnails; after an hour, the job reached a folder of 24-megapixel product photos.

What we ruled out

  • A memory leak. Per-task memory returned to baseline after each batch.
  • Object store spilling as the main cause. The object store was busy but spilling to disk worked; the kills were for worker heap memory.

Root cause

Ray schedules tasks by declared resources. Our task declared only num_cpus=1 (the default), so Ray placed as many tasks on a node as it had CPUs, without considering memory. Each task loaded a batch of 64 full-resolution images into memory as decoded arrays, about 3 GB for large photos. Ray's memory monitor protects the node from the Linux OOM killer by killing workers when node memory use passes 95 percent, which it did.

What is happening under the hood

Ray has two kinds of memory on each node:

  • Worker heap memory: what your task's Python process allocates. Not limited by Ray unless you declare a memory resource, which Ray uses for scheduling (it does not enforce a hard limit per process).
  • Object store memory: shared memory for objects passed between tasks, 30 percent of node memory by default, with spilling to disk when full.

The memory monitor checks node memory periodically and, above the threshold (RAY_memory_usage_threshold, default 0.95), kills a worker, preferring retriable tasks and the most recently scheduled ones. Without memory declarations, the scheduler has no idea that tasks are memory-heavy.

Environment

Component Version / value
Ray 2.40, KubeRay operator 1.2
Worker pods 16 vCPU, 32 GiB memory limit (30.89 GB visible to Ray)
Task @ray.remote function, batches of 64 images
Data 3.2M images, 30 KB thumbnails to 12 MB photos

Minimal reproduction

import numpy as np, ray

@ray.remote
def heavy():
    a = np.ones((3 * 1024**3) // 8)      # ~3 GB
    return a.sum()

ray.get([heavy.remote() for _ in range(64)])   # many run concurrently per node -> OOM kills

The fix

1. Declare memory per task. We measured peak memory per batch for the largest images and declared it, so Ray schedules at most as many tasks as fit:

@ray.remote(num_cpus=1, memory=3.5 * 1024**3)
def preprocess_batch(paths: list[str]) -> str:
    ...

On a 30 GB node, this allows 8 concurrent tasks instead of 16, leaving room for the raylet and object store.

2. Smaller, size-aware batches. Batches are now built by total pixel count rather than a fixed number of images, so a batch of large photos contains fewer images:

def batches_by_pixels(items, max_pixels=400_000_000):
    batch, total = [], 0
    for path, w, h in items:
        if batch and total + w * h > max_pixels:
            yield batch
            batch, total = [], 0
        batch.append(path)
        total += w * h
    if batch:
        yield batch

Images are also resized as soon as they are decoded, so the full-resolution array is released early.

3. Right-size the object store. The job passes small file paths between tasks, not image data, so we lowered object store memory for these workers to leave more for heap:

# KubeRay worker group rayStartParams
rayStartParams:
  object-store-memory: "4000000000"

4. Move to Ray Data for the pipeline, which streams blocks through stages with backpressure and lets us set memory per operator. That was the longer-term change; the first three fixes made the existing job reliable.

Alternatives and trade-offs

  • Bigger nodes. More memory per CPU solves it with money; memory-optimised instances were not available in our quota at the time.
  • Raise the memory threshold. Moves the kill closer to the kernel OOM killer, which kills less selectively and can take down the raylet. We kept the default.
  • Fewer CPUs per task declaration (for example num_cpus=2) as an indirect memory limit. Works, but wastes CPU when tasks are not memory-heavy.

How to test the fix

We ran the job against a sample with the 5,000 largest images first. Peak node memory stayed at 78 percent, no tasks were killed, and throughput per node was only 12 percent lower than the original configuration on small images.

Confirming in production

  • The full 3.2M-image job completed in 5 hours 40 minutes with zero memory-monitor kills.
  • Ray dashboard shows 8 concurrent preprocessing tasks per node and stable memory.

Regression guard

  • Every Ray task and actor in our shared library must declare memory; a decorator wrapper enforces it.
  • An alert on Ray's task failure metrics with OutOfMemoryError as the reason.
  • A pre-flight sample run on the largest inputs for new datasets.

Security implications

None directly. Large image datasets include user-uploaded content; the workers run with read access to the source bucket and write access only to the output prefix, so a crash or misconfiguration cannot overwrite source data.

Prevention checklist

  • Declare memory for memory-heavy Ray tasks and actors.
  • Size batches by data volume, not item count.
  • Release large objects early; resize or downsample immediately after decoding.
  • Size the object store for what you actually pass between tasks.
  • Test on the largest inputs before the full run.

References

Add a label

#python #kubernetes #memory #ray #distributed-computing
Written by
Ryan Pham @ryan_bugs

I build and run the platforms other engineers ship on: CI/CD pipelines, Kubernetes clusters, GPU inference services, LLM features in production, and the cloud bills that come with all of it. I have also spent years writing and operating automated trading systems, where a rounding bug or a missed websocket message is not a log line but a position you did not intend to hold. Each post here starts from a real failure: the alert, the exact error, what we ruled out, the root cause, and how we proved the fix. Same path every time: Investigate, Explain, Fix, Verify. No hype, no price predictions, just engineering.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.