|
19 | 19 | (IF conditional node count) are guarded behind ``_is_checkpoint_if_path_native``. |
20 | 20 | """ |
21 | 21 |
|
| 22 | +import os |
| 23 | +import pathlib |
| 24 | +import subprocess |
| 25 | +import sys |
22 | 26 | from enum import IntEnum |
23 | 27 |
|
24 | 28 | import numpy as np |
| 29 | +import pydantic |
25 | 30 | import pytest |
26 | 31 |
|
27 | 32 | import quadrants as qd |
28 | 33 | from quadrants.lang import impl |
29 | 34 |
|
30 | 35 | from tests import test_utils |
31 | 36 |
|
| 37 | +TEST_RAN = "test ran" |
| 38 | +RET_SUCCESS = 42 |
| 39 | + |
32 | 40 |
|
33 | 41 | def _on_cuda(): |
34 | 42 | return impl.current_cfg().arch == qd.cuda |
@@ -927,6 +935,99 @@ def step(params: Params): |
927 | 935 | assert len(cpp_ids) == 1 and cpp_ids[0] >= 0 |
928 | 936 |
|
929 | 937 |
|
| 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 | + |
930 | 1031 | @test_utils.test() |
931 | 1032 | def test_checkpoint_yield_on_data_oriented_member_yields_and_resumes(): |
932 | 1033 | """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): |
967 | 1068 | np.testing.assert_array_equal(sim.x.to_numpy(), np.full(N, 11, dtype=np.int32)) |
968 | 1069 |
|
969 | 1070 |
|
| 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 | + |
970 | 1167 | # ---------------------------------------------------------------------------------------------------------------------- |
971 | 1168 | # CUDA-native introspection (slice 1c). |
972 | 1169 | # ---------------------------------------------------------------------------------------------------------------------- |
@@ -1001,3 +1198,11 @@ def k(x: qd.types.ndarray(qd.i32, ndim=1), flag: qd.types.ndarray(qd.i32, ndim=0 |
1001 | 1198 | assert ( |
1002 | 1199 | _num_checkpoints_on_last_call() == 3 |
1003 | 1200 | ), 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