Skip to content

Commit f581cb8

Browse files
committed
merge main and resolve
Signed-off-by: Yee Hing Tong <wild-endeavor@users.noreply.github.com>
2 parents beef800 + 1b0e40a commit f581cb8

65 files changed

Lines changed: 2225 additions & 149 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""
2+
Content-based caching for the *root* task of a run.
3+
4+
`hash_flyte_dataframe.py` shows content-based caching between tasks: a driver produces a
5+
DataFrame and calls a cached consumer twice, and the second call hits. This example covers the
6+
other entrypoint — passing a locally-built DataFrame straight into `flyte.run(...)`, so the
7+
cached task *is* the root action of the run.
8+
9+
The two paths compute the cache key differently. A sub-action's key is computed by the
10+
controller in-process, which substitutes `Literal.hash` for the literal's contents. A root
11+
action's key is derived from the offloaded inputs, which reference the upload URI — and every
12+
`flyte.run` uploads the DataFrame to a fresh URI. So without a content hash the second run
13+
misses even though the bytes are identical.
14+
15+
Passing `hash_method=` to `DataFrame.from_local_sync` makes both runs agree: the key follows
16+
the content, not where it happened to land in blob storage.
17+
18+
Run it twice; `check_cache_hit` below asserts the second run returns the first run's value.
19+
"""
20+
21+
import pandas as pd
22+
23+
import flyte
24+
from flyte import Cache
25+
from flyte.io import DataFrame, HashFunction
26+
27+
img = flyte.Image.from_debian_base(name="flyte-root-hash").with_pip_packages("pandas", "pyarrow")
28+
29+
env = flyte.TaskEnvironment(
30+
"flyte_root_action_hash",
31+
image=img,
32+
resources=flyte.Resources(cpu="1", memory="2Gi"),
33+
)
34+
35+
SAMPLE_DATA = {"id": [1, 2, 3, 4, 5], "value": [100, 200, 300, 400, 500]}
36+
37+
38+
def hash_pandas_dataframe(df: pd.DataFrame) -> str:
39+
"""Content-based hash: the same rows always produce the same digest."""
40+
return str(pd.util.hash_pandas_object(df).sum())
41+
42+
43+
@env.task(cache=Cache(behavior="override", version_override="v1"))
44+
async def main(df: DataFrame) -> str:
45+
"""Cached root task.
46+
47+
The random number is the cache probe: it is regenerated on every real execution, so two
48+
runs returning the same string can only mean the second one was served from the cache.
49+
"""
50+
import random
51+
52+
pdf = await df.open(pd.DataFrame).all()
53+
return f"rows={len(pdf)}, total={pdf['value'].sum()}, random={random.randint(1, 1000000)}"
54+
55+
56+
def build_input() -> DataFrame:
57+
"""The DataFrame to submit, tagged with a content-based hash.
58+
59+
Without `hash_method` the cache key would follow the (per-run, always new) upload URI and
60+
the second run would miss.
61+
"""
62+
return DataFrame.from_local_sync(
63+
pd.DataFrame(SAMPLE_DATA),
64+
hash_method=HashFunction.from_fn(hash_pandas_dataframe),
65+
)
66+
67+
68+
if __name__ == "__main__":
69+
flyte.init_from_config()
70+
71+
# Two independent submissions of the same content. Each uploads to its own URI.
72+
run1 = flyte.run(main, df=build_input())
73+
print(f"Run 1: {run1.url}")
74+
run1.wait()
75+
result1 = run1.outputs()[0]
76+
77+
run2 = flyte.run(main, df=build_input())
78+
print(f"Run 2: {run2.url}")
79+
run2.wait()
80+
result2 = run2.outputs()[0]
81+
82+
print(f"\nRun 1: {result1}")
83+
print(f"Run 2: {result2}")
84+
if result1 == result2:
85+
print("\n✓ Cache hit — the new upload URI did not change the cache key.")
86+
else:
87+
print("\n✗ Cache miss — the root action's key still tracks the upload URI.")

examples/clustered/ddp_train_restart.py

Lines changed: 43 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,28 @@
11
"""
2-
DDP training that FAILS on attempt 0 and SUCCEEDS on the JobSet restart.
3-
4-
This is a regression test for the clustered-restart fix on this branch: when a
5-
JobSet restarts (``JOBSET_RESTART_ATTEMPT`` > 0), ``upload_outputs`` must first
6-
delete the stale ``error.pb`` written by the previous failed attempt. Otherwise
7-
the successful retry's outputs land alongside a leftover error file and the
8-
execution is still reported as FAILED.
9-
10-
Mechanics:
11-
- ``ClusterFailurePolicy(max_restarts=1)`` lets the JobSet restart once.
12-
- Attempt 0 (``JOBSET_RESTART_ATTEMPT`` unset / "0") raises -> writes error.pb.
13-
- Attempt 1 (``JOBSET_RESTART_ATTEMPT`` == "1") runs DDP and uploads outputs.
14-
With the fix, the stale error.pb is cleared and the run ends SUCCEEDED.
15-
Without the fix, the run ends FAILED despite the successful retry.
2+
DDP training that FAILS on the first torchrun attempt and SUCCEEDS on the in-pod restart.
3+
4+
Regression test for the stale ``error.pb`` cleanup in the clustered runtime
5+
(``flyte._internal.runtime.io.clear_stale_clustered_error``): whenever a clustered rank-0
6+
worker starts, it removes any ``error.pb`` an earlier restart left under the attempt's output
7+
prefix, before the task body runs. Without that cleanup a successful restart uploads
8+
``outputs.pb`` next to the leftover ``error.pb`` and the executor still reports the run FAILED.
9+
10+
The stale file is produced here without any backend help:
11+
- ``ClusterFailurePolicy(max_restarts=0)`` makes every attempt look terminal to the SDK's
12+
terminal-attempt gate (``JOBSET_RESTART_ATTEMPT 0 >= JOBSET_MAX_RESTARTS 0``), so the first
13+
failure writes ``error.pb`` right away and the worker exits 1.
14+
- ``PET_MAX_RESTARTS=1`` lets torchrun restart the worker group once inside the same pod.
15+
(``TorchRun(max_restarts=...)`` is not wired through to torchrun yet, so the env var is set
16+
directly; torchrun reads ``PET_<FLAG>`` for every CLI flag.)
17+
- Attempt 0 (``TORCHELASTIC_RESTART_COUNT`` == "0") raises on every rank.
18+
- Attempt 1 (``TORCHELASTIC_RESTART_COUNT`` == "1") trains and uploads outputs. Rank-0 logs
19+
"Removed stale ... error.pb" at startup.
20+
21+
Expected: run phase SUCCEEDED. Without the cleanup: FAILED with the attempt-0 error.
22+
23+
A JobSet-level restart (``ClusterFailurePolicy(max_restarts >= 1)``) goes through the same cleanup,
24+
but without free host-maintenance restarts the gate never writes a premature ``error.pb``, so that
25+
variant passes with or without the fix and is not a useful regression test.
1626
1727
Run:
1828
uv run python examples/clustered/ddp_train_restart.py
@@ -33,14 +43,14 @@
3343
)
3444

3545
# --- Knobs ---------------------------------------------------------------------------------------
36-
USE_GPU = True
37-
REPLICAS = 2 # pods (== nodes)
38-
NPROC_PER_NODE = 1 # processes (one per GPU) per pod => world_size = REPLICAS * NPROC_PER_NODE
46+
USE_GPU = False
47+
REPLICAS = 1 # one pod: torchrun's in-pod restart then needs no cross-node re-rendezvous
48+
NPROC_PER_NODE = 2 # processes per pod => world_size = REPLICAS * NPROC_PER_NODE
3949

4050
_BACKEND = "nccl" if USE_GPU else "gloo"
4151

4252
resources = (
43-
flyte.Resources(cpu=(2, 4), memory=("4Gi", "8Gi"), gpu="L4:1")
53+
flyte.Resources(cpu=(2, 4), memory=("4Gi", "8Gi"), gpu="L4:2") # one GPU per process (NPROC_PER_NODE)
4454
if USE_GPU
4555
else flyte.Resources(cpu=(1, 2), memory=("1Gi", "2Gi"))
4656
)
@@ -51,21 +61,28 @@
5161
resources=resources,
5262
replicas=REPLICAS,
5363
nproc_per_node=NPROC_PER_NODE,
54-
runtime=TorchRun(rdzv_backend="static", max_restarts=0),
55-
failure_policy=ClusterFailurePolicy(max_restarts=1), # allow ONE JobSet restart
64+
# max_restarts here is not wired through to torchrun yet; PET_MAX_RESTARTS below is what works today.
65+
runtime=TorchRun(rdzv_backend="static", max_restarts=1),
66+
failure_policy=ClusterFailurePolicy(max_restarts=0), # every attempt looks terminal to the SDK gate
67+
env_vars={"PET_MAX_RESTARTS": "1"}, # ONE in-pod torchrun restart (see module docstring)
5668
)
5769

5870

5971
@env.task
6072
async def train_ddp_with_restart(steps: int = 50, lr: float = 0.05) -> float:
61-
"""Fail on the first JobSet attempt, then train + return loss on the restart."""
62-
restart_attempt = int(os.environ.get("JOBSET_RESTART_ATTEMPT", "0") or "0")
73+
"""Fail on the first torchrun attempt, then train + return loss on the in-pod restart."""
74+
restart_attempt = int(os.environ.get("TORCHELASTIC_RESTART_COUNT", "0") or "0")
6375
rank = os.environ.get("RANK", "0")
64-
print(f"[rank {rank}] JOBSET_RESTART_ATTEMPT={restart_attempt}", flush=True)
76+
print(
77+
f"[rank {rank}] TORCHELASTIC_RESTART_COUNT={restart_attempt} "
78+
f"JOBSET_RESTART_ATTEMPT={flyte.ctx().restart_attempt}",
79+
flush=True,
80+
)
6581

66-
# Attempt 0 fails on every worker -> writes error.pb for the execution.
82+
# Attempt 0 fails on every rank -> rank-0 writes error.pb (the SDK gate sees 0 >= 0) and every
83+
# worker exits 1, so torchrun restarts the worker group in-pod.
6784
if restart_attempt == 0:
68-
raise RuntimeError("Intentional failure on attempt 0 to force a JobSet restart")
85+
raise RuntimeError("Intentional failure on torchrun attempt 0 to leave a stale error.pb behind")
6986

7087
import torch
7188
import torch.distributed as dist
@@ -120,5 +137,5 @@ async def train_ddp_with_restart(steps: int = 50, lr: float = 0.05) -> float:
120137
run = flyte.run(train_ddp_with_restart, steps=50)
121138
print("Run URL:", run.url)
122139
run.wait()
123-
# Expected WITH the fix: SUCCEEDED. Without it: FAILED (stale error.pb).
140+
# Expected WITH the cleanup: SUCCEEDED. Without it: FAILED (stale error.pb from attempt 0).
124141
print("Final phase:", run.phase)

examples/integration_tests.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,35 @@ async def test_advanced_local_tasks(flyte_client):
393393
await _run_and_wait(parallel_main_no_io, "test_advanced_local_tasks", q="hello")
394394

395395

396+
@pytest.mark.integration
397+
@pytest.mark.asyncio
398+
async def test_advanced_hash_root_action(flyte_client):
399+
"""Content-based caching must work when the cached task is the run's root action.
400+
401+
A sub-action's cache key is computed by the controller, which substitutes `Literal.hash`
402+
for the literal's contents; a root action's key is derived from the offloaded inputs, which
403+
reference the upload URI. Each `flyte.run` uploads to a fresh URI, so this only passes if
404+
the content hash — not the URI — drives the key.
405+
"""
406+
from examples.advanced.hash_root_action import build_input, main
407+
408+
results = []
409+
for i in (1, 2):
410+
# Rebuilt each time, so run 2 uploads identical bytes to a brand-new URI.
411+
run = await flyte.with_runcontext(log_level=logging.DEBUG).run.aio(main, df=build_input())
412+
print(f"\n[test_advanced_hash_root_action] Run {i}: {run.url}")
413+
run.wait()
414+
detail = await run.action.details()
415+
if detail.error_info:
416+
raise RuntimeError(f"Run {i} failed with error: {detail.error_info.message}")
417+
results.append(run.outputs()[0])
418+
419+
# The task embeds a fresh random number on every real execution, so identical outputs
420+
# mean run 2 was served from run 1's cache entry.
421+
assert results[0] == results[1], f"root action cache miss across a new upload URI: {results[0]!r} != {results[1]!r}"
422+
print(" Cache hit across a new upload URI\n")
423+
424+
396425
@pytest.mark.integration
397426
@pytest.mark.asyncio
398427
async def test_advanced_multi_loops(flyte_client):

plugins/agento11y/uv.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

plugins/bigquery/uv.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

plugins/codegen/uv.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

plugins/dask/uv.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

plugins/databricks/uv.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

plugins/echo/uv.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)