Skip to content

Commit 9bf106a

Browse files
committed
[Graph] Add tests for member-ndarray yield_on= / graph_do_while + schema-v3 round-trip
Follows up on the member-ndarray support commit with a wider test surface: - Behavioural yield/resume for `yield_on=params.flag` (dataclass mirror of the existing data_oriented test). - Error paths for `yield_on=self.nonexistent_attr`, `qd.graph_do_while(self.nonexistent_attr)`, and `yield_on=self.scalar_attr` (non-ndarray) -- pins the user-facing diagnostic for the attribute forms. - Nested `qd.graph_do_while(self.outer)` containing `qd.graph_do_while(self.inner)` -- exercises the level-table machinery with `@qd.data_oriented` member ndarrays end-to-end. - Direct ``CacheValue`` round-trip unit test for schema v3 (`cachevalue-v3-ast-resolved-ids`): covers the new 3-tuple `graph_do_while_levels` + `checkpoint_yield_on_args` / `checkpoint_yield_on_cpp_arg_ids` / `checkpoint_user_labels_by_cp_id` fields the loader/storer now plumb through. - Cross-process fast-cache restore test for a `@qd.kernel(graph=True, checkpoints=True, fastcache=True)` kernel with `yield_on=self.flag` -- without the schema-v3 restore the launch path's `forward_yield_on_table_to_ctx` would be a no-op and yield/resume would silently break on fast-cached checkpoint kernels. Also fixes an existing `test_src_hasher_store_validate` assertion that indexed `src_hasher.load(...)` by position; the loader now returns a `CacheValue` (or `None`) so the test is updated to use attribute access plus asserts the new default-empty fields are present on the round-tripped object.
1 parent f785888 commit 9bf106a

3 files changed

Lines changed: 345 additions & 6 deletions

File tree

tests/python/quadrants/lang/fast_caching/test_src_hasher.py

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,19 +109,17 @@ def get_fileinfos(functions: list[Callable]) -> list[_wrap_inspect.FunctionSourc
109109

110110
kernel_cache_key = "I'm a kernel cache key"
111111

112-
load_res = src_hasher.load(fast_cache_key)
113-
assert load_res[0] is None and load_res[1] is None
112+
assert src_hasher.load(fast_cache_key) is None
114113

115114
some_used_vars = {"fee", "fi", "fo"}
116115
src_hasher.store(kernel_cache_key, fast_cache_key, fileinfos, some_used_vars)
117116

118117
def assert_loaded(cache_key: str) -> None:
119118
res = src_hasher.load(cache_key)
120-
assert res[0] is not None and res[1] is not None
119+
assert res is not None and res.frontend_cache_key == kernel_cache_key
121120

122121
def assert_not_loaded(cache_key: str) -> None:
123-
res = src_hasher.load(cache_key)
124-
assert res[0] is None and res[1] is None
122+
assert src_hasher.load(cache_key) is None
125123

126124
assert_loaded(fast_cache_key)
127125

@@ -136,7 +134,66 @@ def assert_not_loaded(cache_key: str) -> None:
136134

137135
assert_not_loaded("abcdefg")
138136

139-
assert src_hasher.load(fast_cache_key)[0] == some_used_vars
137+
loaded = src_hasher.load(fast_cache_key)
138+
assert loaded is not None
139+
assert loaded.used_py_dataclass_parameters == some_used_vars
140+
# The new schema-v3 AST-resolved fields default to empty for kernels with no graph_do_while / checkpoint
141+
# metadata, exercising the BaseModel default path on round-trip.
142+
assert loaded.graph_do_while_levels is None
143+
assert loaded.checkpoint_yield_on_args == []
144+
assert loaded.checkpoint_yield_on_cpp_arg_ids == []
145+
assert loaded.checkpoint_user_labels_by_cp_id == []
146+
147+
148+
@test_utils.test()
149+
def test_src_hasher_store_validate_round_trips_schema_v3_metadata(
150+
monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, temporary_module
151+
) -> None:
152+
"""Schema v3 (`cachevalue-v3-ast-resolved-ids`) added AST-resolved arg-id fields to the persisted ``CacheValue``
153+
so the launch path can forward them after a fast-cache restore (which skips AST transformation). This test
154+
pins the round-trip for the new fields -- ``graph_do_while_levels`` as 3-tuples carrying ``cond_cpp_arg_id``,
155+
plus ``checkpoint_yield_on_args`` / ``checkpoint_yield_on_cpp_arg_ids`` / ``checkpoint_user_labels_by_cp_id``.
156+
Without this, a schema bug (wrong tuple arity, dropped field, mis-typed BaseModel default) would only surface
157+
via a hard-to-debug functional regression in a fast-cached checkpoint / graph_do_while kernel."""
158+
test_files_path = pathlib.Path("tests/python/quadrants/lang/fast_caching/test_files")
159+
160+
offline_cache_path = tmp_path / "cache"
161+
temp_import_path = tmp_path / "temp_import"
162+
temp_import_path.mkdir(exist_ok=True)
163+
164+
qd_init_same_arch(offline_cache_file_path=str(offline_cache_path))
165+
166+
monkeypatch.syspath_prepend(temp_import_path)
167+
shutil.copy2(test_files_path / "child_diff_base.py", temp_import_path / "child_diff_schema_v3.py")
168+
mod = temporary_module("child_diff_schema_v3")
169+
info, _src = _wrap_inspect.get_source_info_and_src(mod.f1.fn)
170+
fileinfos = [info]
171+
fast_cache_key = src_hasher.create_cache_key(False, info, [], [])
172+
assert fast_cache_key is not None
173+
174+
gdw_levels = [("self.outer", -1, 4), ("self.inner", 0, 5)]
175+
cp_yield_args = ["self.flag", None, "params.flag"]
176+
cp_yield_cpp_ids = [3, -1, 7]
177+
cp_user_labels = [10, None, 20]
178+
179+
src_hasher.store(
180+
"kernel_cache_key_v3",
181+
fast_cache_key,
182+
fileinfos,
183+
{"used_var"},
184+
graph_do_while_levels=gdw_levels,
185+
checkpoint_yield_on_args=cp_yield_args,
186+
checkpoint_yield_on_cpp_arg_ids=cp_yield_cpp_ids,
187+
checkpoint_user_labels_by_cp_id=cp_user_labels,
188+
)
189+
190+
loaded = src_hasher.load(fast_cache_key)
191+
assert loaded is not None
192+
assert loaded.frontend_cache_key == "kernel_cache_key_v3"
193+
assert loaded.graph_do_while_levels == [("self.outer", -1, 4), ("self.inner", 0, 5)]
194+
assert loaded.checkpoint_yield_on_args == cp_yield_args
195+
assert loaded.checkpoint_yield_on_cpp_arg_ids == cp_yield_cpp_ids
196+
assert loaded.checkpoint_user_labels_by_cp_id == cp_user_labels
140197

141198

142199
# Should be enough to run these on cpu I think, and anything involving

tests/python/test_checkpoint.py

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,24 @@
1919
(IF conditional node count) are guarded behind ``_is_checkpoint_if_path_native``.
2020
"""
2121

22+
import os
23+
import pathlib
24+
import subprocess
25+
import sys
2226
from enum import IntEnum
2327

2428
import numpy as np
29+
import pydantic
2530
import pytest
2631

2732
import quadrants as qd
2833
from quadrants.lang import impl
2934

3035
from tests import test_utils
3136

37+
TEST_RAN = "test ran"
38+
RET_SUCCESS = 42
39+
3240

3341
def _on_cuda():
3442
return impl.current_cfg().arch == qd.cuda
@@ -927,6 +935,99 @@ def step(params: Params):
927935
assert len(cpp_ids) == 1 and cpp_ids[0] >= 0
928936

929937

938+
@test_utils.test()
939+
def test_checkpoint_yield_on_dataclass_member_yields_and_resumes():
940+
"""Behavioural round-trip for `yield_on=params.flag` -- mirror of the `self.flag` test below, using a
941+
`@dataclasses.dataclass` kernel parameter instead of a `@qd.data_oriented` owner. The dataclass-member access
942+
is pre-rewritten to a flattened parameter, so verifying the full yield/resume contract end-to-end is the only
943+
way to confirm the right ndarray is wired up at launch."""
944+
import dataclasses # pylint: disable=import-outside-toplevel
945+
946+
if not _supports_checkpoint_yield_resume():
947+
pytest.skip("backend does not implement checkpoint yield/resume")
948+
N = 4
949+
950+
@dataclasses.dataclass
951+
class Params:
952+
x: qd.types.NDArray[qd.i32, 1]
953+
flag: qd.types.NDArray[qd.i32, 0]
954+
955+
@qd.kernel(graph=True, checkpoints=True)
956+
def step(params: Params):
957+
with qd.checkpoint(7, yield_on=params.flag):
958+
for i in range(params.x.shape[0]):
959+
params.x[i] = params.x[i] + 1
960+
params.flag[()] = 1
961+
with qd.checkpoint(8, yield_on=params.flag):
962+
for i in range(params.x.shape[0]):
963+
params.x[i] = params.x[i] + 10
964+
965+
params = Params(
966+
x=qd.ndarray(qd.i32, shape=(N,)),
967+
flag=qd.ndarray(qd.i32, shape=()),
968+
)
969+
params.x.from_numpy(np.zeros(N, dtype=np.int32))
970+
params.flag.from_numpy(np.array(0, dtype=np.int32))
971+
status = step(params)
972+
assert status.yielded
973+
assert status.checkpoint == 7
974+
np.testing.assert_array_equal(params.x.to_numpy(), np.ones(N, dtype=np.int32))
975+
params.flag.from_numpy(np.array(0, dtype=np.int32))
976+
status = step.resume(from_checkpoint=8)
977+
assert not status.yielded
978+
np.testing.assert_array_equal(params.x.to_numpy(), np.full(N, 11, dtype=np.int32))
979+
980+
981+
@test_utils.test()
982+
def test_checkpoint_yield_on_member_nonexistent_attribute_raises():
983+
"""`yield_on=self.nonexistent_attr` (attribute does not exist on the `@qd.data_oriented` owner) must raise a
984+
user-facing `QuadrantsSyntaxError` at the `with` site -- the AST-time resolver wraps the underlying attribute
985+
lookup failure in the same `does not resolve to an ndarray kernel parameter` diagnostic as the bare-name
986+
nonexistent case, so users see one consistent error pattern."""
987+
N = 4
988+
989+
@qd.data_oriented
990+
class Sim:
991+
def __init__(self):
992+
self.x = qd.ndarray(qd.i32, shape=(N,))
993+
self.flag = qd.ndarray(qd.i32, shape=())
994+
995+
@qd.kernel(graph=True, checkpoints=True)
996+
def step(self):
997+
with qd.checkpoint(0, yield_on=self.nonexistent_flag): # type: ignore[attr-defined]
998+
for i in range(self.x.shape[0]):
999+
self.x[i] = self.x[i] + 1
1000+
1001+
sim = Sim()
1002+
with pytest.raises(qd.QuadrantsSyntaxError, match="does not resolve to an ndarray kernel parameter"):
1003+
sim.step()
1004+
1005+
1006+
@test_utils.test()
1007+
def test_checkpoint_yield_on_member_non_ndarray_attribute_raises():
1008+
"""`yield_on=self.scalar` where `self.scalar` is a Python int (not an ndarray) must raise the same
1009+
`does not resolve to an ndarray kernel parameter` diagnostic -- the AST-time resolver builds the expression
1010+
but rejects it because the resulting Expr is not an `ExternalTensorExpression`. Pinning this so future
1011+
refactors of the resolver can't silently accept non-ndarray attributes and crash later in the launcher."""
1012+
N = 4
1013+
1014+
@qd.data_oriented
1015+
class Sim:
1016+
def __init__(self):
1017+
self.x = qd.ndarray(qd.i32, shape=(N,))
1018+
self.scalar = 7
1019+
1020+
@qd.kernel(graph=True, checkpoints=True)
1021+
def step(self):
1022+
with qd.checkpoint(0, yield_on=self.scalar):
1023+
for i in range(self.x.shape[0]):
1024+
self.x[i] = self.x[i] + 1
1025+
1026+
sim = Sim()
1027+
with pytest.raises(qd.QuadrantsSyntaxError, match="does not resolve to an ndarray kernel parameter"):
1028+
sim.step()
1029+
1030+
9301031
@test_utils.test()
9311032
def test_checkpoint_yield_on_data_oriented_member_yields_and_resumes():
9321033
"""Behavioural round-trip for `yield_on=self.flag`: setting the member flag from inside the kernel yields, and
@@ -967,6 +1068,102 @@ def step(self):
9671068
np.testing.assert_array_equal(sim.x.to_numpy(), np.full(N, 11, dtype=np.int32))
9681069

9691070

1071+
# Module-level kernel for the fastcache-restoration test below. Lives outside any test so the child subprocess can
1072+
# import the test module and reach it without re-creating the (closure-captured) outer scope. The kernel has to be
1073+
# annotated with `fastcache=True` (=> implies `pure`) and lifted out of any decorator-bound owner so it qualifies
1074+
# for the src_ll_cache path. We model the data_oriented owner as the `_FastcacheYieldOnSelfCheckpoint` class below.
1075+
1076+
1077+
@qd.data_oriented
1078+
class _FastcacheYieldOnSelfCheckpoint:
1079+
def __init__(self, n: int):
1080+
self.x = qd.ndarray(qd.i32, shape=(n,))
1081+
self.flag = qd.ndarray(qd.i32, shape=())
1082+
1083+
@qd.kernel(graph=True, checkpoints=True, fastcache=True)
1084+
def step(self):
1085+
with qd.checkpoint(0, yield_on=self.flag):
1086+
for i in range(self.x.shape[0]):
1087+
self.x[i] = self.x[i] + 1
1088+
1089+
1090+
class _FastcacheCheckpointArgs(pydantic.BaseModel):
1091+
arch: str
1092+
offline_cache_file_path: str
1093+
expect_loaded_from_fastcache: bool
1094+
1095+
1096+
def _fastcache_checkpoint_child(args: list[str]) -> None:
1097+
args_obj = _FastcacheCheckpointArgs.model_validate_json(args[0])
1098+
qd.init(
1099+
arch=getattr(qd, args_obj.arch),
1100+
offline_cache=True,
1101+
offline_cache_file_path=args_obj.offline_cache_file_path,
1102+
src_ll_cache=True,
1103+
)
1104+
1105+
N = 8
1106+
sim = _FastcacheYieldOnSelfCheckpoint(N)
1107+
sim.x.from_numpy(np.zeros(N, dtype=np.int32))
1108+
sim.flag.from_numpy(np.array(0, dtype=np.int32))
1109+
sim.step()
1110+
np.testing.assert_array_equal(sim.x.to_numpy(), np.ones(N, dtype=np.int32))
1111+
1112+
primal = type(sim).step._primal
1113+
# The schema-v3 fast-cache restore path must repopulate `checkpoint_yield_on_args` and
1114+
# `checkpoint_yield_on_cpp_arg_ids` from the cached `CacheValue` (since AST transformation is skipped on a
1115+
# cache hit). A regression here would surface as an empty `_forward_yield_on_table_to_ctx` call, silently
1116+
# breaking yield/resume on fast-cached checkpoint kernels.
1117+
labels = primal.checkpoint_yield_on_args
1118+
cpp_ids = primal.checkpoint_yield_on_cpp_arg_ids
1119+
assert (
1120+
labels and len(labels) == 1 and labels[0] is not None and "flag" in labels[0]
1121+
), f"checkpoint_yield_on_args should round-trip with one slot containing 'flag', got {labels!r}"
1122+
assert (
1123+
len(cpp_ids) == 1 and cpp_ids[0] >= 0
1124+
), f"checkpoint_yield_on_cpp_arg_ids should round-trip with one valid id, got {cpp_ids!r}"
1125+
assert primal.checkpoint_user_labels_by_cp_id == [
1126+
0
1127+
], f"checkpoint_user_labels_by_cp_id should round-trip as [0], got {primal.checkpoint_user_labels_by_cp_id!r}"
1128+
assert primal.src_ll_cache_observations.cache_loaded == args_obj.expect_loaded_from_fastcache, (
1129+
f"cache_loaded={primal.src_ll_cache_observations.cache_loaded!r} but expected "
1130+
f"{args_obj.expect_loaded_from_fastcache!r}"
1131+
)
1132+
1133+
print(TEST_RAN)
1134+
sys.exit(RET_SUCCESS)
1135+
1136+
1137+
@test_utils.test()
1138+
def test_checkpoint_fastcache_restores_self_member_yield_on(tmp_path: pathlib.Path):
1139+
"""After a fast-cache restore in a fresh process, a `@qd.kernel(graph=True, checkpoints=True, fastcache=True)`
1140+
kernel with `yield_on=self.flag` must repopulate `checkpoint_yield_on_args` /
1141+
`checkpoint_yield_on_cpp_arg_ids` / `checkpoint_user_labels_by_cp_id` from the persisted ``CacheValue`` --
1142+
not from the AST transformer, which is skipped on a cache hit. Without the schema-v3 round-trip the launch
1143+
path's `forward_yield_on_table_to_ctx` would be a no-op and yield/resume would silently break for fast-cached
1144+
checkpoint kernels."""
1145+
assert qd.lang is not None
1146+
arch = qd.lang.impl.current_cfg().arch.name
1147+
env = dict(os.environ)
1148+
env["PYTHONPATH"] = "."
1149+
1150+
for expect_loaded in [False, True]:
1151+
args_obj = _FastcacheCheckpointArgs(
1152+
arch=arch,
1153+
offline_cache_file_path=str(tmp_path / "cache"),
1154+
expect_loaded_from_fastcache=expect_loaded,
1155+
)
1156+
cmd_line = [sys.executable, __file__, _fastcache_checkpoint_child.__name__, args_obj.model_dump_json()]
1157+
proc = subprocess.run(cmd_line, capture_output=True, text=True, env=env)
1158+
if proc.returncode != RET_SUCCESS:
1159+
print(" ".join(cmd_line))
1160+
print(proc.stdout)
1161+
print("-" * 100)
1162+
print(proc.stderr)
1163+
assert TEST_RAN in proc.stdout
1164+
assert proc.returncode == RET_SUCCESS
1165+
1166+
9701167
# ----------------------------------------------------------------------------------------------------------------------
9711168
# CUDA-native introspection (slice 1c).
9721169
# ----------------------------------------------------------------------------------------------------------------------
@@ -1001,3 +1198,11 @@ def k(x: qd.types.ndarray(qd.i32, ndim=1), flag: qd.types.ndarray(qd.i32, ndim=0
10011198
assert (
10021199
_num_checkpoints_on_last_call() == 3
10031200
), f"expected 3 IF conditional nodes (2 implicit + 1 explicit), got {_num_checkpoints_on_last_call()}"
1201+
1202+
1203+
# Subprocess dispatch for fast-cache restoration tests above (mirrors the pattern in `test_graph_do_while.py`). The
1204+
# parent test invokes us via `subprocess.run([sys.executable, __file__, <child_fn_name>, <json_args>])` so the
1205+
# child runs in a fresh interpreter with a clean `qd.init` -- the only way to exercise the cross-process fast-cache
1206+
# load path that ``Kernel._try_load_fastcache`` takes after a previous run has populated the on-disk cache.
1207+
if __name__ == "__main__":
1208+
globals()[sys.argv[1]](sys.argv[2:])

0 commit comments

Comments
 (0)