all_lessons/ray/02lesson 3 / 17

Part I - Core mental model

Tasks, Objects, and Futures

Lesson 01 argued why Ray exists: ordinary Python hits a wall, and the runtime's job is to spread arbitrary functions and objects across a cluster while the dependency graph is still being discovered at runtime. This lesson writes the first real program against that runtime and meets the one object everything else is built on — the ObjectRef. The thesis is small and load-bearing: f.remote(x) does not run a function and wait; it submits a node into a graph and returns a future immediately. Once you see that .remote() returns instantly and ray.get is the only place that blocks, the difference between a program that parallelizes and one that quietly runs serially comes down to where you put the ray.get.

Book source
Chapter 2, "Getting Started with Ray Core" — the first Ray API example and an overview of Ray Core primitives. Calibrated to current Ray: @ray.remote, .remote() returning an ObjectRef, ray.get, ray.put, and ray.wait.
Linear position
Prerequisite: Lesson 01 (Why Ray exists) — the runtime turns ordinary Python into a dynamically-built, distributed task graph, and the object store carries values between units of work.
New capability: You can write and reason about a real Ray Core program — submit a wave of tasks, build a dependency graph by passing refs as arguments, and choose the synchronization point (ray.get vs ray.wait) that decides how much parallelism you actually keep.
The plan
Five moves. (1) Replace a blocking call with .remote() and watch it return an ObjectRef now — this asynchrony is the whole source of parallelism. (2) See where ray.get blocks and deserializes, and why a ray.get inside a loop silently re-serializes your program. (3) Use ray.put to store one big value once and share its ref across many tasks. (4) Build the dependency graph by passing refs as arguments — and meet the auto-dereference gotcha for refs nested inside a list or dict. (5) Pick the task granularity with real overhead numbers, and use ray.wait to pipeline results past a long tail of stragglers.

1 · .remote() returns a future, not a value

Start from the line of code that changes everything. An ordinary function call is blocking: control does not return until the function has computed its answer.

def score(record):
    return expensive_model(record)      # ~50 ms of CPU work

result = score(records[0])              # BLOCKS for ~50 ms, then you hold the value

Mark the same function as a task — a stateless unit of work Ray can ship to any worker — by decorating it with @ray.remote, and call it with .remote(...). Now the call returns immediately, before any computation has happened, handing you an ObjectRef: a future, a typed promise that a value will exist at that address once some worker finishes producing it.

import ray
ray.init()                              # start or connect to a local cluster

@ray.remote                             # this function is now a task
def score(record):
    return expensive_model(record)      # still ~50 ms of CPU work

ref = score.remote(records[0])          # RETURNS NOW — no work has run yet
# type(ref) is ray.ObjectRef — a handle, not the answer
value = ray.get(ref)                    # THIS line blocks until the value exists

The difference is the entire mental model. The blocking call interleaves "submit work" and "wait for work" into one statement, so you can only ever have one piece of work in flight. The .remote() call separates them: you can fire thousands of tasks — accumulating thousands of ObjectRefs — before you block on any of them. Those tasks run concurrently across the cluster's workers while your driver (the process running your main()) keeps moving. Asynchrony is not a feature bolted onto Ray; it is Ray's parallelism, and it is bought entirely by .remote() refusing to wait.

# the canonical shape: a wave of submissions, then ONE block
refs = [score.remote(r) for r in records]   # 100,000 refs, returns in milliseconds
results = ray.get(refs)                      # block once, here, for the whole wave
The real contract of an ObjectRef
An ObjectRef is not the value and is not a copy of the value. It is a small, cheap, picklable address into the distributed object store (lesson 04 details that store; for now treat it as a shared, immutable key→bytes map). Passing a ref around costs bytes in the tens, never the size of the data it points at. The value behind it is immutable and computed at most once. Holding a ref also keeps the value alive: the object is reference-counted, and Ray will not evict it while a ref to it exists.

2 · ray.get blocks — and is where bytes come home

ray.get(ref) does two things, and both matter. First it blocks the calling process until the value is ready. Second, it is the point at which the value is deserialized back into the driver's heap — the bytes that lived in the object store are pulled to the caller's node (if not already local) and reconstructed into a real Python object. That second cost is invisible until your results are large: pulling a million 1 KB results back is moving a gigabyte through one process.

Because ray.get is the only blocking primitive in this lesson, where you place it is the single most consequential decision in a Ray program. The classic mistake is to place it inside the submission loop:

# ANTI-PATTERN: ray.get inside the loop — accidental serialization
results = []
for r in records:                       # 100,000 records
    ref = score.remote(r)               # submit one task...
    results.append(ray.get(ref))        # ...then immediately wait for it
# every iteration submits ONE task and blocks for it: zero overlap.
# total time ≈ 100,000 × 50 ms = 5,000 s ≈ 83 min — same as a serial loop,
# now PLUS per-task scheduling overhead. You paid for Ray and got nothing.
# FIX: submit the whole wave, collect refs, get ONCE
refs = [score.remote(r) for r in records]   # all 100,000 in flight
results = ray.get(refs)                      # one block; tasks overlap across workers
# with 50 worker cores: ≈ (100,000 / 50) × 50 ms = 100 s instead of 5,000 s — ~50× faster.

The worked contrast is the lesson in one stroke. The anti-pattern's ray.get is a synchronization barrier that re-imposes the very serialization Ray was meant to remove — it is the most common reason "I added @ray.remote and nothing got faster." The fix moves the single barrier to the end, after all work is submitted, so the 50 ms tasks run in parallel and the only serial part left is the final gather. Synchronization points define the shape of parallelism; minimize and delay them.

3 · ray.put — store a big value once, share its ref

When a task takes an argument, Ray must get that argument's bytes to whatever worker runs the task. For a small argument this is trivial. For a large argument reused by many tasks, the default behavior is wasteful: each .remote(big) call serializes big again and puts a fresh copy in the object store.

weights = load_model_weights()          # a 400 MB numpy array, used by every task

# WASTEFUL: each call re-serializes and re-stores the same 400 MB
refs = [predict.remote(weights, x) for x in batch]   # 1,000 tasks
# Ray implicitly puts `weights` up to 1,000 times → up to 400 GB of
# serialization + object-store churn for data that never changes.

ray.put(value) stores the value in the object store once and returns an ObjectRef you can hand to as many tasks as you like. Every task then shares the single stored copy — and tasks scheduled onto the same node read it zero-copy from shared memory, so passing the ref to 1,000 same-node tasks costs one materialization, not 1,000.

# RIGHT: put the big value once, pass the shared ref
weights_ref = ray.put(weights)          # 400 MB serialized ONE time → one ref

@ray.remote
def predict(w, x):                      # `w` arrives already dereferenced (see §4)
    return w @ x

refs = [predict.remote(weights_ref, x) for x in batch]   # 1,000 tasks share one copy
preds = ray.get(refs)
Worked number — the cost of forgetting ray.put
Take the 400 MB array and 1,000 tasks. Serializing 400 MB at, say, ~1 GB/s costs ~0.4 s per put. Implicit puts: up to 1,000 × 0.4 s ≈ 400 s of pure serialization plus the object store thrashing through 400 GB of transient copies (and very likely spilling to disk when it fills). With one ray.put: a single 0.4 s serialization, ~400 MB resident, shared by all 1,000 tasks — a ~1,000× reduction in serialization work. Rule of thumb: any argument that is both large (≥ a few MB) and reused across more than a couple of tasks should be a ray.put ref, not a raw value passed repeatedly.

4 · The dependency graph — passing refs builds edges

You do not build Ray's task graph by calling a scheduler API. You build it by passing one task's ref as another task's argument. When Ray sees a task whose argument is an ObjectRef, it records an edge: the downstream task depends on the upstream one and cannot be scheduled until that ref's value exists. The driver never has to materialize the intermediate value at all.

@ray.remote
def parse(path):
    return load_rows(path)              # returns a big table

@ray.remote
def summarize(rows):                    # takes the table directly
    return compute_stats(rows)

parsed   = [parse.remote(p) for p in paths]          # stage 1: N refs
summaries = [summarize.remote(ref) for ref in parsed] # stage 2: each depends on its parse
stats = ray.get(summaries)              # block once at the very end

Notice that summarize.remote(ref) is passed an ObjectRef, not a table. Two things follow. First, the table never travels through the driver — Ray can hand the parsed bytes directly to the worker that runs summarize (and prefers to run it on the node that already holds them; locality scheduling is lesson 04). Second, inside summarize the parameter rows is the actual table, not a ref: Ray auto-dereferences a top-level ObjectRef argument before the task body runs.

The gotcha: only TOP-LEVEL refs are auto-dereferenced
Auto-dereference applies only when the ObjectRef is itself the argument. A ref nested inside a list, tuple, or dict is not resolved — the task body receives the raw ObjectRef and must call ray.get on it explicitly.
@ray.remote
def good(rows):                 # rows is the TABLE — Ray dereferenced it
    return compute_stats(rows)

@ray.remote
def trap(batch):                # batch = [ref, ref, ref] — refs are NOT dereferenced
    rows = ray.get(batch)       # YOU must get them inside the task
    return [compute_stats(r) for r in rows]

good.remote(parse.remote(path))          # ✓ top-level ref → auto-dereferenced
trap.remote([parse.remote(p) for p in paths])  # ✗ list of refs → still refs inside
Forget the inner ray.get and you will operate on ObjectRef objects as if they were data — a confusing class of bug, because nothing errors at submission time. When you intend a fan-in of many upstream results, either pass them as separate top-level arguments or ray.get the collection inside the task on purpose.

5 · Granularity and ray.wait — the two knobs that decide your speedup

Submitting a task is not free. The runtime spends on the order of a few hundred microseconds to ~1 ms per task routing it, reserving resources, registering the result ref, and tracking the dependency. That overhead is fixed per task and runs against the work the task does. So the first knob is granularity: how much real work each task performs.

Worked number — where the serial loop wins
Say per-task overhead is ~0.5 ms and you have 1,000,000 items. Too fine (one item per task, 50 µs of work each): overhead is 0.5 ms on 50 µs of work — overhead is 0.5 / 0.05 = 10× the useful work, and 1,000,000 × 0.5 ms = 500 s of pure bookkeeping alone. A plain serial loop doing 1,000,000 × 50 µs = 50 s would beat the "parallel" version outright. The fix is batching: put 10,000 items in each task. Now you submit only 100 tasks; overhead is 100 × 0.5 ms = 0.05 s — negligible — while each task does 10,000 × 50 µs = 0.5 s of real work. Rule of thumb: size tasks so each runs at least tens of milliseconds, making overhead a few percent rather than the whole bill.
# batch many items per task so overhead is amortized
@ray.remote
def score_batch(records):               # 10,000 records per call
    return [expensive_model(r) for r in records]

chunks = [records[i:i+10_000] for i in range(0, len(records), 10_000)]
refs = [score_batch.remote(c) for c in chunks]   # 100 tasks, not 1,000,000
results = [y for part in ray.get(refs) for y in part]

The second knob is which results you wait for, and when. ray.get(refs) blocks until every ref is ready — so a single straggler (a task that runs 10× slower than the rest) holds the whole gather hostage, and your driver sits idle while the fast 99% of results pile up unused. ray.wait fixes this: it returns as soon as some tasks are done, splitting your list into (ready, not_ready) so you can process completed results immediately and loop on the rest. This pipelines work past the long tail.

# stream results as they finish — do not block on the slowest task
pending = [score_batch.remote(c) for c in chunks]   # 100 tasks in flight
while pending:
    # return as soon as 1 is done; 1 s safety timeout so we never hang
    ready, pending = ray.wait(pending, num_returns=1, timeout=1.0)
    for ref in ready:
        handle(ray.get(ref))            # consume each result the instant it lands
Worked number — why ray.wait beats ray.get on a long tail
Suppose 100 tasks: 99 finish in 1 s each and 1 straggler takes 30 s. With ray.get(all) the driver does nothing useful until t = 30 s, then processes all 100 results — and if each result needs ~0.2 s of driver-side handling, total wall time ≈ 30 + 100 × 0.2 = 50 s, of which 30 s was pure idle waiting. With the ray.wait loop, the 99 fast results arrive by ~1 s and the driver overlaps their 0.2 s handling with the straggler still running — by the time the straggler returns at 30 s, only its single result is left to handle. Wall time ≈ ~30.2 s, and the driver was busy the whole time instead of blocked. Same compute, ~20 s saved, because you stopped letting the slowest task gate the fast ones.

Two practical notes. Always pass a timeout to ray.wait in a loop so a dead or hung task can never block you forever. And raise num_returns when you want to batch consumption — num_returns=10 waits for ten results before returning, trading a little latency for fewer loop iterations. This first-N-done shape is exactly what RLlib uses to collect rollouts and what Ray Tune uses to harvest finished trials; you are learning the primitive those libraries are built on.

6 · Failure modes & checklist

Failure modes

  • ray.get in the loop. Submitting one task and immediately waiting for it serializes the whole program — Ray's overhead with none of its parallelism. Collect refs, get once.
  • Tasks too fine. Sub-millisecond tasks pay more in scheduling than they do in compute; 1,000,000 trivial tasks is 500 s of bookkeeping. Batch many items per task.
  • Re-passing a big value. A large argument passed raw to many tasks is implicitly re-serialized every call. ray.put it once and pass the shared ref.
  • Nested-ref confusion. A ref inside a list/dict is not auto-dereferenced; the task body silently receives ObjectRefs instead of data. ray.get them inside the task, or pass top-level.
  • Straggler blocking. ray.get(all) idles the driver until the slowest task finishes. Use ray.wait to pipeline the fast majority.
  • Ref leak in the driver. Holding millions of result refs (or never consuming them) keeps their objects pinned in the store and can fill it. Process and drop refs as you go.

Implementation checklist

  • Does each .remote() return before I need the value — am I keeping submission and waiting separate?
  • Is there exactly one (late) ray.get per wave, or did one sneak into a loop?
  • How long does one task run? If it is under ~10 ms, should I batch?
  • Which arguments are both large and reused — and are they ray.put refs?
  • Am I passing refs as top-level arguments, or burying them in a list/dict the task won't dereference?
  • Do task durations vary enough that ray.wait (with a timeout) beats ray.get?
  • Will the driver have to hold or pull back more bytes than it can fit?

Checkpoint exercise

Try it
You have 200,000 documents to embed; each embedding call is ~2 ms and uses a 1.5 GB model loaded as a numpy array. Sketch the Ray program. (1) Decide the batch size: at ~0.5 ms overhead per task, how many docs per task keeps overhead under 5%? (2) Where does the 1.5 GB model go — and how many times is it serialized in the naive version versus your version? (3) Embeddings trickle in at uneven speeds; write the synchronization as a ray.wait loop and say what its timeout protects against. Now flip an assumption: if each call were 2 seconds instead of 2 ms, which of your three decisions changes, and which stays? (Hint: granularity stops mattering; ray.put and ray.wait still do.)

Where this points next

Tasks are stateless: each one starts cold, and nothing survives between calls. That is exactly wrong for two common needs — a model you want to load once and reuse across thousands of calls, or a long-lived service that owns mutable state. Re-ray.putting or re-loading per task pays the initialization cost every time. Lesson 03 introduces the actor: a stateful worker process that owns a Python object, loads its model in __init__ once, and answers method calls against that retained state — at the cost of becoming a single-threaded bottleneck and a single point of failure. The object store and the ref machinery you learned here carry straight over; what changes is who holds the state.

Takeaway
f.remote(x) returns an ObjectRef — a future — immediately, and that refusal to wait is the entire source of Ray's parallelism: submit a wave of tasks, then block once. ray.get is the only blocking primitive and the point where bytes are deserialized back to the caller, so a ray.get inside a loop re-serializes your whole program. ray.put stores a large, reused value once and hands every task a shared ref; passing one task's ref as another's argument builds the dependency graph, and Ray auto-dereferences top-level refs but not refs nested in a list or dict. Task granularity must keep per-task overhead (~hundreds of µs to ~1 ms) a small fraction of real work — batch when tasks are tiny — and ray.wait lets you pipeline the fast majority of results past a long tail of stragglers instead of idling on the slowest task.

Interview prompts