From ba29137ba1ca046d9205b2262602909f19de6a7b Mon Sep 17 00:00:00 2001 From: Hugh Perkins Date: Wed, 10 Jun 2026 06:59:37 -0700 Subject: [PATCH 1/5] [Lang] Make qd.static not bypass the pure-kernel purity check A captured module global wrapped in qd.static(...) previously skipped the [PURE.VIOLATION] check because both purity gates were guarded by `not is_in_static_scope`. That is unsound for fastcache: qd.static only controls compile-time evaluation and never puts the value into the cache key, so a static-wrapped global is still a purity violation (stale-cache hazard). Remove the static-scope exemption from both gates (build_Name and build_Attribute). Legitimate static metaprogramming over parameters/locals is unaffected, since those never set violates_pure. Document in fastcache.md that qd.static is not a purity escape hatch. --- docs/source/user_guide/fastcache.md | 2 ++ python/quadrants/lang/ast/ast_transformer.py | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/source/user_guide/fastcache.md b/docs/source/user_guide/fastcache.md index ab07483e97..ecbaf4642a 100644 --- a/docs/source/user_guide/fastcache.md +++ b/docs/source/user_guide/fastcache.md @@ -86,6 +86,8 @@ Sub-functions called by the kernel are also checked — they must not capture ex Other named constants (non-enum, non-module) captured from scope will raise a `QuadrantsCompilationError`, except for `UPPERCASE` names which emit a warning instead. +Wrapping a captured global in `qd.static(...)` does **not** exempt it from this check. `qd.static` only controls compile-time evaluation; it does not put the value into the cache key, so a `qd.static`-wrapped module global is still treated as a purity violation. To use such a constant in a fastcache kernel, pass it as a parameter (template primitive, `@qd.data_oriented` member, or dataclass field) or make it one of the allowed captures above. + ### 2. Supported parameter types Fastcache supports the following parameter types: diff --git a/python/quadrants/lang/ast/ast_transformer.py b/python/quadrants/lang/ast/ast_transformer.py index 509995b5b5..ab562b1b93 100644 --- a/python/quadrants/lang/ast/ast_transformer.py +++ b/python/quadrants/lang/ast/ast_transformer.py @@ -94,7 +94,9 @@ def build_Name(ctx: ASTTransformerFuncContext, node: ast.Name): if isinstance(node, (ast.stmt, ast.expr)) and isinstance(node.ptr, Expr): node.ptr.dbg_info = _qd_core.DebugInfo(ctx.get_pos_info(node)) node.ptr.ptr.set_dbg_info(node.ptr.dbg_info) - if ctx.is_pure and node.violates_pure and not ctx.static_scope_status.is_in_static_scope: + # ``qd.static`` is intentionally NOT a purity escape hatch: a captured module global is still flagged inside + # a static scope, since its value never enters the fastcache key regardless of static wrapping. + if ctx.is_pure and node.violates_pure: if isinstance(node.ptr, (float, int, Field)): if not _is_quadrants_internal_file(ctx.file): message = f"[PURE.VIOLATION] WARNING: Accessing global variable {node.id} {type(node.ptr)} {node.violates_pure_reason}" @@ -779,7 +781,8 @@ def build_Attribute(ctx: ASTTransformerFuncContext, node: ast.Attribute): node.violates_pure = node.value.violates_pure if node.violates_pure: node.violates_pure_reason = node.value.violates_pure_reason - if ctx.is_pure and node.violates_pure and not ctx.static_scope_status.is_in_static_scope: + # ``qd.static`` is intentionally NOT a purity escape hatch (see ``build_Name``). + if ctx.is_pure and node.violates_pure: if isinstance(node.ptr, (int, float, Field)): violation = True if violation and isinstance(node.ptr, enum.Enum): From 117167a47ff4706b4484330940bdad955a91e0b4 Mon Sep 17 00:00:00 2001 From: Hugh Perkins Date: Wed, 10 Jun 2026 09:25:18 -0700 Subject: [PATCH 2/5] [Lang] Extend pure-kernel purity check to captured strings A captured string only affects a fastcache kernel through compile-time qd.static branches, but its value never enters the cache key, so it is cache-unsafe in the same way as a captured int/float. Add str to the flagged types in build_Name and build_Attribute, and document it in fastcache.md. Update the tile and src_ll_cache tests that captured globals (bools, strings) inside qd.static in pure kernels to pass those values as template parameters so the values enter the cache key. --- python/quadrants/lang/ast/ast_transformer.py | 8 ++++-- .../lang/fast_caching/test_src_ll_cache.py | 8 +++--- tests/python/test_tile.py | 27 ++++++++++--------- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/python/quadrants/lang/ast/ast_transformer.py b/python/quadrants/lang/ast/ast_transformer.py index ab562b1b93..13c15308de 100644 --- a/python/quadrants/lang/ast/ast_transformer.py +++ b/python/quadrants/lang/ast/ast_transformer.py @@ -97,7 +97,10 @@ def build_Name(ctx: ASTTransformerFuncContext, node: ast.Name): # ``qd.static`` is intentionally NOT a purity escape hatch: a captured module global is still flagged inside # a static scope, since its value never enters the fastcache key regardless of static wrapping. if ctx.is_pure and node.violates_pure: - if isinstance(node.ptr, (float, int, Field)): + # ``str`` is included alongside the numeric/``Field`` types: a captured string only affects a kernel through + # compile-time ``qd.static`` branches, and its value never enters the fastcache key, so it is cache-unsafe + # in exactly the same way as a captured int/float. + if isinstance(node.ptr, (float, int, str, Field)): if not _is_quadrants_internal_file(ctx.file): message = f"[PURE.VIOLATION] WARNING: Accessing global variable {node.id} {type(node.ptr)} {node.violates_pure_reason}" if node.id.upper() == node.id: @@ -783,7 +786,8 @@ def build_Attribute(ctx: ASTTransformerFuncContext, node: ast.Attribute): node.violates_pure_reason = node.value.violates_pure_reason # ``qd.static`` is intentionally NOT a purity escape hatch (see ``build_Name``). if ctx.is_pure and node.violates_pure: - if isinstance(node.ptr, (int, float, Field)): + # ``str`` included for the same reason as in ``build_Name``: a captured string is cache-unsafe. + if isinstance(node.ptr, (int, float, str, Field)): violation = True if violation and isinstance(node.ptr, enum.Enum): violation = False diff --git a/tests/python/quadrants/lang/fast_caching/test_src_ll_cache.py b/tests/python/quadrants/lang/fast_caching/test_src_ll_cache.py index 711839cf5d..453ec5621e 100644 --- a/tests/python/quadrants/lang/fast_caching/test_src_ll_cache.py +++ b/tests/python/quadrants/lang/fast_caching/test_src_ll_cache.py @@ -294,14 +294,14 @@ def src_ll_cache_has_return_child(args: list[str]) -> None: @qd.pure @qd.kernel - def k1(a: qd.i32, output: qd.types.NDArray[qd.i32, 1]) -> bool: + def k1(a: qd.i32, output: qd.types.NDArray[qd.i32, 1], return_something: qd.Template) -> bool: output[0] = a - if qd.static(args_obj.return_something): + if qd.static(return_something): return True output = qd.ndarray(qd.i32, (10,)) if args_obj.return_something: - assert k1(3, output) + assert k1(3, output, args_obj.return_something) # Sanity check that the kernel actually ran, and did something. assert output[0] == 3 assert k1._primal.src_ll_cache_observations.cache_key_generated == args_obj.expect_used_src_ll_cache @@ -314,7 +314,7 @@ def k1(a: qd.i32, output: qd.types.NDArray[qd.i32, 1]) -> bool: with pytest.raises( qd.QuadrantsSyntaxError, match="Kernel has a return type but does not have a return statement" ): - k1(3, output) + k1(3, output, args_obj.return_something) print(TEST_RAN) sys.exit(RET_SUCCESS) diff --git a/tests/python/test_tile.py b/tests/python/test_tile.py index d71b514f92..554e6962ca 100644 --- a/tests/python/test_tile.py +++ b/tests/python/test_tile.py @@ -85,18 +85,18 @@ def test_zeros(TILE, make_tile, tdim, m_size, tensor_type, qd_dtype, use_zeros_a Ann = _ann(tensor_type, qd_dtype, 2) @qd.kernel(fastcache=True) - def k1(dst_arr: Ann, N: qd.Template): + def k1(dst_arr: Ann, N: qd.Template, use_alias: qd.Template): qd.loop_config(block_dim=N) tile_size = N for _ in range(tile_size): - if qd.static(use_zeros_alias): + if qd.static(use_alias): t = Tile.zeros() t._store(dst_arr, 0, tile_size, 0, tile_size) else: t = Tile() t._store(dst_arr, 0, tile_size, 0, tile_size) - k1(dst, tdim) + k1(dst, tdim, use_zeros_alias) np.testing.assert_allclose(dst.to_numpy(), np.zeros((tdim, tdim), dtype=np_dtype)) @@ -114,7 +114,7 @@ def test_eye(TILE, make_tile, tdim, m_size, tensor_type, qd_dtype, inplace): Ann = _ann(tensor_type, qd_dtype, 2) @qd.kernel(fastcache=True) - def k1(src_arr: Ann, dst_arr: Ann, N: qd.Template): + def k1(src_arr: Ann, dst_arr: Ann, N: qd.Template, inplace: qd.Template): qd.loop_config(block_dim=N) tile_size = N for _ in range(tile_size): @@ -129,7 +129,7 @@ def k1(src_arr: Ann, dst_arr: Ann, N: qd.Template): data = np.arange(tdim * tdim, dtype=np_dtype).reshape(tdim, tdim) + 100.0 src.from_numpy(data) - k1(src, dst, tdim) + k1(src, dst, tdim, inplace) np.testing.assert_allclose(dst.to_numpy(), np.eye(tdim, dtype=np_dtype)) @@ -904,7 +904,7 @@ def test_load_slice_errors(TILE, make_tile, tdim, m_size, bad_slice, match): dst = qd.ndarray(qd.f32, (tdim, tdim)) @qd.kernel(fastcache=True) - def k1(s: qd.types.NDArray[qd.f32, 2], d: qd.types.NDArray[qd.f32, 2], N: qd.Template): + def k1(s: qd.types.NDArray[qd.f32, 2], d: qd.types.NDArray[qd.f32, 2], N: qd.Template, bad_slice: qd.Template): qd.loop_config(block_dim=N) tile_size = N for _ in range(tile_size): @@ -920,7 +920,7 @@ def k1(s: qd.types.NDArray[qd.f32, 2], d: qd.types.NDArray[qd.f32, 2], N: qd.Tem d[0:tile_size, 0:tile_size] = t with pytest.raises(QuadrantsSyntaxError, match=match): - k1(src, dst, tdim) + k1(src, dst, tdim, bad_slice) @pytest.mark.parametrize( @@ -939,7 +939,7 @@ def test_store_slice_errors(TILE, make_tile, tdim, m_size, bad_slice, match): dst = qd.ndarray(qd.f32, (tdim, tdim)) @qd.kernel(fastcache=True) - def k1(s: qd.types.NDArray[qd.f32, 2], d: qd.types.NDArray[qd.f32, 2], N: qd.Template): + def k1(s: qd.types.NDArray[qd.f32, 2], d: qd.types.NDArray[qd.f32, 2], N: qd.Template, bad_slice: qd.Template): qd.loop_config(block_dim=N) tile_size = N for _ in range(tile_size): @@ -955,7 +955,7 @@ def k1(s: qd.types.NDArray[qd.f32, 2], d: qd.types.NDArray[qd.f32, 2], N: qd.Tem d[0:, 0:tile_size] = t with pytest.raises(QuadrantsSyntaxError, match=match): - k1(src, dst, tdim) + k1(src, dst, tdim, bad_slice) @test_utils.test(arch=qd.gpu) @@ -1101,7 +1101,7 @@ def test_vec_slice_errors(TILE, make_tile, tdim, m_size, bad_slice): dst = qd.ndarray(qd.f32, (tdim, tdim)) @qd.kernel(fastcache=True) - def k1(s: qd.types.NDArray[qd.f32, 2], d: qd.types.NDArray[qd.f32, 2], N: qd.Template): + def k1(s: qd.types.NDArray[qd.f32, 2], d: qd.types.NDArray[qd.f32, 2], N: qd.Template, bad_slice: qd.Template): qd.loop_config(block_dim=N) tile_size = N for _ in range(tile_size): @@ -1114,7 +1114,7 @@ def k1(s: qd.types.NDArray[qd.f32, 2], d: qd.types.NDArray[qd.f32, 2], N: qd.Tem d[0:tile_size, 0:tile_size] = t with pytest.raises(QuadrantsSyntaxError, match="both start and stop"): - k1(src, dst, tdim) + k1(src, dst, tdim, bad_slice) # ============================================================================= @@ -1325,7 +1325,8 @@ def test_shared_array_partial_cols(TILE, make_tile, tdim, m_size, partial_store, dst = qd.field(dtype=qd.f32, shape=(tdim, tdim)) @qd.kernel(fastcache=True) - def k1(src_f: qd.Template, dst_f: qd.Template, NCOLS: qd.i32, N: qd.Template): + def k1(src_f: qd.Template, dst_f: qd.Template, NCOLS: qd.i32, N: qd.Template, + partial_store: qd.Template, partial_load: qd.Template): qd.loop_config(block_dim=N) tile_size = N for _ in range(tile_size): @@ -1353,7 +1354,7 @@ def k1(src_f: qd.Template, dst_f: qd.Template, NCOLS: qd.i32, N: qd.Template): data = np.arange(tdim * tdim, dtype=np.float32).reshape(tdim, tdim) + 1.0 src.from_numpy(data) - k1(src, dst, NCOLS, tdim) + k1(src, dst, NCOLS, tdim, partial_store, partial_load) result = dst.to_numpy() np.testing.assert_allclose(result[:, :NCOLS], data[:, :NCOLS]) if partial_load: From b608273e0a717373cc6359b1bd9446ad22e27681 Mon Sep 17 00:00:00 2001 From: Hugh Perkins Date: Wed, 10 Jun 2026 09:35:04 -0700 Subject: [PATCH 3/5] [Misc] Apply black formatting to test_shared_array_partial_cols signature --- tests/python/test_tile.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/python/test_tile.py b/tests/python/test_tile.py index 554e6962ca..58e39a1d9d 100644 --- a/tests/python/test_tile.py +++ b/tests/python/test_tile.py @@ -1325,8 +1325,14 @@ def test_shared_array_partial_cols(TILE, make_tile, tdim, m_size, partial_store, dst = qd.field(dtype=qd.f32, shape=(tdim, tdim)) @qd.kernel(fastcache=True) - def k1(src_f: qd.Template, dst_f: qd.Template, NCOLS: qd.i32, N: qd.Template, - partial_store: qd.Template, partial_load: qd.Template): + def k1( + src_f: qd.Template, + dst_f: qd.Template, + NCOLS: qd.i32, + N: qd.Template, + partial_store: qd.Template, + partial_load: qd.Template, + ): qd.loop_config(block_dim=N) tile_size = N for _ in range(tile_size): From 3380bbd0c2ecb0e920840082e7ef9b14e2db5a3a Mon Sep 17 00:00:00 2001 From: Hugh Perkins Date: Thu, 11 Jun 2026 06:51:52 -0700 Subject: [PATCH 4/5] [Lang] Warn instead of raise for qd.static purity violations (transition) During a transition period, a captured global accessed inside a qd.static scope of a pure kernel now emits a warning rather than raising a QuadrantsCompilationError, giving downstream code time to migrate such constants to kernel parameters. Direct captured-global access (not wrapped in qd.static) still raises. Adds a test covering both the captured-name and captured-attribute paths, and updates the fastcache user guide. --- docs/source/user_guide/fastcache.md | 2 +- python/quadrants/lang/ast/ast_transformer.py | 7 +++- .../lang/fast_caching/test_pure_validation.py | 37 +++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/docs/source/user_guide/fastcache.md b/docs/source/user_guide/fastcache.md index ecbaf4642a..d7f615efcf 100644 --- a/docs/source/user_guide/fastcache.md +++ b/docs/source/user_guide/fastcache.md @@ -86,7 +86,7 @@ Sub-functions called by the kernel are also checked — they must not capture ex Other named constants (non-enum, non-module) captured from scope will raise a `QuadrantsCompilationError`, except for `UPPERCASE` names which emit a warning instead. -Wrapping a captured global in `qd.static(...)` does **not** exempt it from this check. `qd.static` only controls compile-time evaluation; it does not put the value into the cache key, so a `qd.static`-wrapped module global is still treated as a purity violation. To use such a constant in a fastcache kernel, pass it as a parameter (template primitive, `@qd.data_oriented` member, or dataclass field) or make it one of the allowed captures above. +Wrapping a captured global in `qd.static(...)` does **not** exempt it from this check. `qd.static` only controls compile-time evaluation; it does not put the value into the cache key, so a `qd.static`-wrapped global is still flagged — though during the current transition period this emits a warning rather than raising. To use such a constant in a fastcache kernel, pass it as a parameter (template primitive, `@qd.data_oriented` member, or dataclass field) or make it one of the allowed captures above. ### 2. Supported parameter types diff --git a/python/quadrants/lang/ast/ast_transformer.py b/python/quadrants/lang/ast/ast_transformer.py index 13c15308de..dfdae1b45c 100644 --- a/python/quadrants/lang/ast/ast_transformer.py +++ b/python/quadrants/lang/ast/ast_transformer.py @@ -103,7 +103,9 @@ def build_Name(ctx: ASTTransformerFuncContext, node: ast.Name): if isinstance(node.ptr, (float, int, str, Field)): if not _is_quadrants_internal_file(ctx.file): message = f"[PURE.VIOLATION] WARNING: Accessing global variable {node.id} {type(node.ptr)} {node.violates_pure_reason}" - if node.id.upper() == node.id: + # Transition period: violations inside a ``qd.static`` scope only warn (instead of raising) to give + # downstream code time to migrate such constants to kernel parameters. ``UPPERCASE`` names also warn. + if node.id.upper() == node.id or ctx.is_in_static_scope(): warnings.warn(message) else: raise exception.QuadrantsCompilationError(message) @@ -797,7 +799,8 @@ def build_Attribute(ctx: ASTTransformerFuncContext, node: ast.Attribute): violation = False if violation: message = f"[PURE.VIOLATION] WARNING: Accessing global var {node.attr} from outside function scope within pure kernel {node.value.violates_pure_reason}" - if node.attr.upper() == node.attr: + # Transition period: see ``build_Name`` — a ``qd.static`` scope downgrades the error to a warning. + if node.attr.upper() == node.attr or ctx.is_in_static_scope(): warnings.warn(message) else: raise exception.QuadrantsCompilationError(message) diff --git a/tests/python/quadrants/lang/fast_caching/test_pure_validation.py b/tests/python/quadrants/lang/fast_caching/test_pure_validation.py index 5d417377eb..45e8c1a0ac 100644 --- a/tests/python/quadrants/lang/fast_caching/test_pure_validation.py +++ b/tests/python/quadrants/lang/fast_caching/test_pure_validation.py @@ -282,3 +282,40 @@ def k1() -> qd.i32: with pytest.warns(UserWarning, match=r"\[PURE\.VIOLATION\]"): assert k1() == 32 + + +@test_utils.test() +def test_pure_validation_static_scope_warns(): + # Transition period: a captured global accessed inside a ``qd.static`` scope of a pure kernel only warns instead of + # raising, to give downstream code time to migrate such constants to kernel parameters. + assert qd.lang is not None + arch = qd.lang.impl.current_cfg().arch + qd.init(arch=arch, offline_cache=False) + + use_alias = True + + @qd.kernel(pure=True) + def k1() -> qd.i32: + ret = 0 + if qd.static(use_alias): + ret = 1 + return ret + + with pytest.warns(UserWarning, match=r"\[PURE\.VIOLATION\]"): + assert k1() == 1 + + class Cfg: + def __init__(self) -> None: + self.flag = True + + cfg = Cfg() + + @qd.kernel(pure=True) + def k2() -> qd.i32: + ret = 0 + if qd.static(cfg.flag): + ret = 1 + return ret + + with pytest.warns(UserWarning, match=r"\[PURE\.VIOLATION\]"): + assert k2() == 1 From a2b7aaa198aeac6384bc8f56b8490e8f707e77c7 Mon Sep 17 00:00:00 2001 From: Hugh Perkins Date: Wed, 17 Jun 2026 11:48:46 -0700 Subject: [PATCH 5/5] [Lang] Fix CI for pure-static warning change: test flakiness, str coverage, comment width - test_pure_validation_static_scope_warns: restrict to a single (CPU) arch. The purity check is an arch-independent AST analysis; running it across multiple archs in one xdist worker let a fastcache hit from one arch suppress the warning on the next, making pytest.warns flaky on Mac (arm64 after vulkan). - Add test_pure_validation_str covering the captured-str purity violation (str branch was previously untested). - Wrap two transition-period comments to <=120 chars (drop the em-dash). --- python/quadrants/lang/ast/ast_transformer.py | 6 +++--- .../lang/fast_caching/test_pure_validation.py | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/python/quadrants/lang/ast/ast_transformer.py b/python/quadrants/lang/ast/ast_transformer.py index dfdae1b45c..c1068430a8 100644 --- a/python/quadrants/lang/ast/ast_transformer.py +++ b/python/quadrants/lang/ast/ast_transformer.py @@ -103,8 +103,8 @@ def build_Name(ctx: ASTTransformerFuncContext, node: ast.Name): if isinstance(node.ptr, (float, int, str, Field)): if not _is_quadrants_internal_file(ctx.file): message = f"[PURE.VIOLATION] WARNING: Accessing global variable {node.id} {type(node.ptr)} {node.violates_pure_reason}" - # Transition period: violations inside a ``qd.static`` scope only warn (instead of raising) to give - # downstream code time to migrate such constants to kernel parameters. ``UPPERCASE`` names also warn. + # Transition period: violations inside a ``qd.static`` scope only warn instead of raising, giving + # downstream code time to migrate such constants to kernel params. ``UPPERCASE`` names also warn. if node.id.upper() == node.id or ctx.is_in_static_scope(): warnings.warn(message) else: @@ -799,7 +799,7 @@ def build_Attribute(ctx: ASTTransformerFuncContext, node: ast.Attribute): violation = False if violation: message = f"[PURE.VIOLATION] WARNING: Accessing global var {node.attr} from outside function scope within pure kernel {node.value.violates_pure_reason}" - # Transition period: see ``build_Name`` — a ``qd.static`` scope downgrades the error to a warning. + # Transition period (see ``build_Name``): ``qd.static`` scope downgrades this to a warning. if node.attr.upper() == node.attr or ctx.is_in_static_scope(): warnings.warn(message) else: diff --git a/tests/python/quadrants/lang/fast_caching/test_pure_validation.py b/tests/python/quadrants/lang/fast_caching/test_pure_validation.py index 45e8c1a0ac..a0f51bb70b 100644 --- a/tests/python/quadrants/lang/fast_caching/test_pure_validation.py +++ b/tests/python/quadrants/lang/fast_caching/test_pure_validation.py @@ -42,6 +42,20 @@ def k2(): k2() +@test_utils.test() +def test_pure_validation_str(): + # A captured ``str`` global is cache-unsafe in the same way as a captured int/float, so it must trigger a purity + # violation. Direct access (not wrapped in ``qd.static``) of a lowercase-named global raises. + s = "hello" + + @qd.kernel(pure=True) + def k1(): + print(s) + + with pytest.raises(qd.QuadrantsCompilationError): + k1() + + @test_utils.test() def test_pure_validation_field(): a = qd.field(qd.i32, (10,)) @@ -284,7 +298,10 @@ def k1() -> qd.i32: assert k1() == 32 -@test_utils.test() +# Restricted to a single (CPU) arch on purpose: the purity check is a Python-side AST analysis and is entirely +# arch-independent, and running it across multiple archs in one worker lets a fastcache hit from one arch suppress the +# warning on the next, which makes ``pytest.warns`` flaky. +@test_utils.test(arch=qd.cpu) def test_pure_validation_static_scope_warns(): # Transition period: a captured global accessed inside a ``qd.static`` scope of a pure kernel only warns instead of # raising, to give downstream code time to migrate such constants to kernel parameters.