From b1b2562b1b52184aa79e2ab8fe955a152f7e04df Mon Sep 17 00:00:00 2001 From: ysun67 Date: Tue, 19 May 2026 15:06:01 -0400 Subject: [PATCH] =?UTF-8?q?PR-1:=20foundation=20=E2=80=94=20LowerCtx=20spl?= =?UTF-8?q?it=20+=20target=20parametricity=20(Wave=20T1=20+=20B2-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per docs/phase_decomposition_redesign.md § 6.1: establishes the target-abstraction surface every subsequent migration PR builds on. Zero target migration in scope — CUDA remains the byte-equivalence anchor; 535 byte-eq goldens stay green. Changes: 1. CudaRenderCtx (NEW src/srdatalog/ir/codegen/cuda/lower_ctx.py). 11 CUDA-render-side identifier fields move off the dialect `LoweringCtx` into a target-private companion class. The dialect `LoweringCtx` now threads `render_ctx: CudaRenderCtx` and exposes the 11 fields as forwarding properties — every legacy read site (`ctx.view_var_names`, `ctx.output_var`, …) keeps working; new code prefers the explicit `ctx.render_ctx.` form. 2. Pragma-scratch flag flips become function-local kwargs. The 4 wrap-op lowerings (lower_dedup_gate, lower_ws_scope, lower_block_group_root, lower_tiled_cartesian_in_chain) stop mutating `ctx.` for the duration of the call. The inner helpers (`_lower_insert_into`, the BG/tiled dispatchers) either accept new kwargs (`is_dedup_gate`, `is_ws_scope`) or are called directly without flag flips (BG/tiled — the helpers themselves ARE the dispatched variants). 4 ctx-flip patterns removed total. Legacy `ep.: bool` paths still set the ctx field via `LowerKernelBodyShim`, so the dual-write transition stays byte-equivalent. 3. Compiler.run(target='cuda') kwarg threaded through KernelCtx / InitialProg `target` fields. The dataclass-aware Compiler.run coerces `prog.target` to the kwarg value before pipeline runs. VerifyRenderabilityShim + RenderShim consult `state.target` instead of the literal 'cuda'. 4. Shim renames: `LowerScanPipelineShim` -> `LowerKernelBodyShim`; `CudaRenderShim` -> `RenderShim`. Pipeline names update correspondingly (lower_scan_pipeline -> lower_kernel_body; cuda_render -> render). Back-compat aliases keep external pinned references importing the same class. 5. Plugin entry-point group split (pyproject + core/plugin.py): `srdatalog.plugins` (legacy) split into `srdatalog.dialects` (data dialects) + `srdatalog.targets` (render targets). The 6 built-in dialects move to the new group; legacy group is still walked with `DeprecationWarning` for one release cycle so external plugins (e.g. jaccard demo) keep working unmodified. `Compiler.with_default_plugins()` walks all three groups by default. Tests: - tests/test_cuda_render_ctx.py — construction + threading + legacy-kwargs compat (8 tests). - tests/test_compiler_run_target_kwarg.py — target kwarg propagation + unknown-target failure (11 tests). - tests/test_plugin_group_split.py — three-group discovery + DeprecationWarning + back-compat (8 tests). - tests/test_discipline_lower_ctx_pinned.py extended — pins CudaRenderCtx field count + dialect ctx render_ctx slot. Quality gates: - 535 byte-eq tests green (test_runner_byte_equivalence + test_byte_equivalence_jit + test_cuda_complete_runner + test_dedup_hash_byte_equivalence). - All pragma e2e tests green (93 tests). - All lowering byte-eq tests green (excluding pre-existing jaccard install failures). - Full suite: 1635 passed (was 1603 baseline, +32 new tests); same 32 pre-existing failures (all from local jaccard install where `srdatalog_jaccard:register` module has no `register`). - mypy clean on touched files. - `ruff check` + `ruff format --check` clean. - D10 + D19 discipline tests green. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 37 +- src/srdatalog/ir/codegen/cuda/api.py | 3 + .../ir/codegen/cuda/complete_runner.py | 2 +- src/srdatalog/ir/codegen/cuda/lower_ctx.py | 97 ++++++ src/srdatalog/ir/core/dialect.py | 64 +++- src/srdatalog/ir/core/plugin.py | 92 ++++- src/srdatalog/ir/default_pipelines.py | 108 ++++-- .../atomic_ws/pragmas/work_stealing.py | 38 +-- .../block_group/pragmas/block_group.py | 35 +- .../sorted_array/lowerings/__init__.py | 316 +++++++++++++----- .../sorted_array/pragmas/dedup_hash.py | 28 +- .../sorted_array/pragmas/tiled_cartesian.py | 29 +- tests/test_compiler_run_target_kwarg.py | 222 ++++++++++++ tests/test_cuda_render_ctx.py | 115 +++++++ tests/test_default_pipelines_shims.py | 8 +- tests/test_discipline_lower_ctx_pinned.py | 41 +++ tests/test_plugin_group_split.py | 188 +++++++++++ tests/test_verify_renderability.py | 17 +- 18 files changed, 1216 insertions(+), 224 deletions(-) create mode 100644 src/srdatalog/ir/codegen/cuda/lower_ctx.py create mode 100644 tests/test_compiler_run_target_kwarg.py create mode 100644 tests/test_cuda_render_ctx.py create mode 100644 tests/test_plugin_group_split.py diff --git a/pyproject.toml b/pyproject.toml index f57e7a5..628a657 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,15 +53,29 @@ docs = [ srdatalog = "srdatalog.cli:main" # --------------------------------------------------------------------------- -# Plugin entry points (srdatalog.plugins) +# Plugin entry points (PR-1 split — per docs/phase_decomposition_redesign.md +# § 3.3.4) # -# Phase E validation: built-in dialects ship as discoverable plugins via -# the same `srdatalog.plugins` entry-point group used by external -# packages. `Compiler.with_default_plugins()` walks this group at -# bootstrap. Spec: docs/phase_e_plugin_extensibility.md §2, §5. +# Two production groups + a legacy back-compat group: +# +# `srdatalog.dialects` — data-side dialect contributions (target- +# agnostic IR: ops, pragmas, lowerings). Walked by +# `Compiler.with_default_plugins()`. +# `srdatalog.targets` — render-side target contributions (per-target +# `@register_render(op, target=T)` registrations). Walked by +# `Compiler.with_default_plugins()`. Empty today — built-in CUDA +# target stays loaded via the `sorted_array` dialect's +# `_register_passes` until subsequent PRs separate render +# registrations into a `srdatalog.codegen.cuda:register` entry. +# `srdatalog.plugins` — LEGACY group. Walked for one release cycle +# with a `DeprecationWarning`. External plugins that haven't +# migrated continue to work. +# +# Spec: docs/phase_e_plugin_extensibility.md §2 + docs/phase_decomposition +# _redesign.md § 3.3.4. # --------------------------------------------------------------------------- -[project.entry-points."srdatalog.plugins"] +[project.entry-points."srdatalog.dialects"] d2l = "srdatalog.ir.dialects.relation.d2l:register" iir_cf = "srdatalog.ir.dialects.iir.cf:register" sorted_array = "srdatalog.ir.dialects.relation.sorted_array:register" @@ -69,6 +83,17 @@ parallel_data = "srdatalog.ir.dialects.parallel.data:register" parallel_atomic_ws = "srdatalog.ir.dialects.parallel.atomic_ws:register" parallel_block_group = "srdatalog.ir.dialects.parallel.block_group:register" +[project.entry-points."srdatalog.targets"] +# (no entries yet — this group exists so external CPU/TBB / SYCL / +# WASM targets can register. PR-2+ wires the built-in CUDA target +# under a `srdatalog.codegen.cuda:register` entry.) + +# LEGACY group — kept for back-compat for one release cycle per the +# § 3.3.4 transition contract. External plugins (e.g. the jaccard +# demo) discoverable here keep working; loading emits a +# `DeprecationWarning` exactly once per process. +[project.entry-points."srdatalog.plugins"] + [project.urls] Homepage = "https://github.com/anthropics/srdatalog-python" Issues = "https://github.com/anthropics/srdatalog-python/issues" diff --git a/src/srdatalog/ir/codegen/cuda/api.py b/src/srdatalog/ir/codegen/cuda/api.py index 8f57416..920e21f 100644 --- a/src/srdatalog/ir/codegen/cuda/api.py +++ b/src/srdatalog/ir/codegen/cuda/api.py @@ -105,6 +105,7 @@ def compile_kernel_body( rel_index_types: dict[str, str] | None = None, tiled_cartesian: bool = False, bg_enabled: bool = False, + target: str = 'cuda', ) -> str: '''Emit the operator() body for one kernel — view_decls followed by the dialect-emitted kernel logic. Caller is responsible for the @@ -159,8 +160,10 @@ def compile_kernel_body( rel_index_types=rel_index_types, tiled_cartesian=tiled_cartesian, bg_enabled=bg_enabled, + target=target, ), pipeline=DEFAULT_KERNEL_PIPELINE, + target=target, ) body_text = state.body_text assert body_text is not None, 'compile_kernel_body: CudaRenderShim left body_text unset' diff --git a/src/srdatalog/ir/codegen/cuda/complete_runner.py b/src/srdatalog/ir/codegen/cuda/complete_runner.py index 039ebf2..e3c2237 100644 --- a/src/srdatalog/ir/codegen/cuda/complete_runner.py +++ b/src/srdatalog/ir/codegen/cuda/complete_runner.py @@ -43,7 +43,6 @@ has_tiled_cartesian_eligible, ) from srdatalog.ir.codegen.cuda.plugin import plugin_gen_host_view_setup, plugin_view_count -from srdatalog.ir.mir.passes import ep_has_work_stealing # Pure-template phase emitters now live in the dialect's runner module. # Local aliases preserve the legacy call sites until the rest of this @@ -75,6 +74,7 @@ compute_total_view_count, source_spec_key, ) +from srdatalog.ir.mir.passes import ep_has_work_stealing # ----------------------------------------------------------------------------- # Source spec extraction helpers (mirror Nim's inline lambdas) diff --git a/src/srdatalog/ir/codegen/cuda/lower_ctx.py b/src/srdatalog/ir/codegen/cuda/lower_ctx.py new file mode 100644 index 0000000..96694a0 --- /dev/null +++ b/src/srdatalog/ir/codegen/cuda/lower_ctx.py @@ -0,0 +1,97 @@ +'''CudaRenderCtx — CUDA-render-side identifier scratch state. + +Spec: `docs/phase_decomposition_redesign.md` § 3.2.1 (Wave T1). + +Today's dialect-level `LoweringCtx` (in +`src/srdatalog/ir/dialects/relation/sorted_array/lowerings/__init__.py`) +mixes generic lowering-walk scope state (bound vars, indent, counter) +with CUDA C++ identifier scratch (view variable names, output variable +name, per-handle pre-narrow info, …). The spec calls out the second +category as CUDA-render-specific — strings that the CUDA renderer's +own helpers emit and consume. Those fields belong here, under +`codegen/cuda/`, not on a target-agnostic `LoweringCtx`. + +Per `docs/code_discipline.md` D10, the framework `LowerCtx` (in +`core/lower_ctx.py`) is pinned at 5 fields. This file ships the +CUDA-render-side companion: a target-private dataclass aggregating +every CUDA identifier the lowering walk produces. When future targets +(CPU/TBB, SYCL, …) come online, each ships its own analogous +`RenderCtx`; the lowering walk threads the right one +based on the active target. + +The PR-1 transition (per `docs/phase_decomposition_redesign.md` § 6.1) +keeps the dialect `LoweringCtx` as the surface every lowering helper +sees — the CUDA-render fields are still accessible via +`ctx.` for byte-equivalence (the existing helpers don't change +their reads), AND via the canonical `ctx.render_ctx.` form. +Future PRs migrate every read to the canonical form; PR-1 establishes +the shape. +''' + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class CudaRenderCtx: + '''CUDA-render-side identifier scratch. + + Every field is a CUDA C++ identifier or a per-handle CUDA-render + side-table. Nothing here is consumed by a non-CUDA target. + + Fields: + view_var_names — handle_idx (str) -> view variable name + (`view__`). + output_var — name of the single-output OutputContext + variable. + output_var_overrides — per-relation OutputContext name map for + multi-head rules. + view_slot_bases — handle_idx (str) -> base slot in `views[]`. + Populated from `emit_view_declarations` + so D2L segment-loop emission can + reference the right HEAD/FULL pair. + rel_index_types — rel_name -> custom index type code (e.g., + `Device2LevelIndex`). Empty string / + missing entry = plain DSAI. + tiled_cartesian_valid_var + — name of the C++ ballot validity var + threaded by `_lower_nested_cart` when + rendering the tiled-mode body. + ws_cartesian_valid_var — name of the C++ WS ballot validity var. + ws_cartesian_bound_vars + — names of C++ WS-loop-bound vars. + neg_pre_narrow — handle_idx -> NegPreNarrowInfo (pre- + narrowed handle info for Negations + following a Cartesian). + dedup_hash_vars — names of C++ vars feeding the dedup hash + key. (Carried for parity with the legacy + CodeGenContext field; today populated by + the runner-side plumbing, not by the + lowering walk itself.) + debug — emit `Comment` nodes describing the + source MIR shape for each emitted block. + ''' + + view_var_names: dict[str, str] = field(default_factory=dict) + output_var: str = 'output' + output_var_overrides: dict[str, str] = field(default_factory=dict) + view_slot_bases: dict[str, int] = field(default_factory=dict) + rel_index_types: dict[str, str] = field(default_factory=dict) + tiled_cartesian_valid_var: str = '' + ws_cartesian_valid_var: str = '' + ws_cartesian_bound_vars: list[str] = field(default_factory=list) + # `neg_pre_narrow` is typed loosely (`dict[int, Any]`) because the + # value class — `NegPreNarrowInfo` — exists in two parallel forms + # today: the legacy CUDA-side `codegen.cuda.context.NegPreNarrowInfo` + # and the dialect-side `relation.sorted_array.lowerings.NegPreNarrowInfo`. + # Both carry the same payload; a future PR collapses the duplicate. + # Until then, `Any` lets both forms flow through without type-check + # noise. The runtime behavior is identical. + neg_pre_narrow: dict[int, Any] = field(default_factory=dict) + dedup_hash_vars: list[str] = field(default_factory=list) + debug: bool = True + + +__all__ = ['CudaRenderCtx'] diff --git a/src/srdatalog/ir/core/dialect.py b/src/srdatalog/ir/core/dialect.py index cb3e899..0ed3fa8 100644 --- a/src/srdatalog/ir/core/dialect.py +++ b/src/srdatalog/ir/core/dialect.py @@ -36,6 +36,7 @@ PluginConflictError, PluginInfo, PluginLoadError, + _discover_all_groups, _discover_entry_points, _info_for, _topo_sort_plugins, @@ -159,7 +160,13 @@ def dialects(self) -> list[Dialect]: '''All registered dialects, in registration order.''' return list(self._dialects.values()) - def run(self, prog: Any, *, pipeline: list[Any]) -> Any: + def run( + self, + prog: Any, + *, + pipeline: list[Any], + target: str = 'cuda', + ) -> Any: '''Drive a list of `Pass` instances over `prog`. Returns the (possibly transformed) prog. @@ -171,9 +178,19 @@ def run(self, prog: Any, *, pipeline: list[Any]) -> Any: Per `docs/compiler_redesign.md` §4 and the R4 research report: pipelines are data; ordering errors are caught up-front. + + PR-1 (per `docs/phase_decomposition_redesign.md` § 3.3.1): the + `target` kwarg threads the active render target into the per- + pass through-state when the state carries a `target` field + (e.g. `KernelCtx`). This lets `RenderShim` dispatch the per-op + `@register_render(target=T)` resolution without hardcoding the + target. Default preserves the current single-target ('cuda') + behavior. ''' # Local import to avoid a circular at module-load time # (passes.py imports Compiler from this module). + import dataclasses + from srdatalog.ir.core.passes import PassOrderingError available: set[str] = {d.name for d in self.dialects} @@ -183,6 +200,16 @@ def run(self, prog: Any, *, pipeline: list[Any]) -> Any: raise PassOrderingError(p.name, needed, i) available |= set(p.produces) + # If the through-state dataclass exposes a `target` field, thread + # the active target into it. This keeps the per-pass shims target- + # parametric: each shim reads `state.target` to drive per-target + # dispatch (e.g. RenderShim's @register_render lookup). + if dataclasses.is_dataclass(prog) and not isinstance(prog, type): + if any(f.name == 'target' for f in dataclasses.fields(prog)): + current = getattr(prog, 'target', None) + if current != target: + prog = dataclasses.replace(prog, target=target) + for p in pipeline: prog = p.apply(prog, self) return prog @@ -252,11 +279,16 @@ def _resolve_plugin(plugin: Any) -> tuple[str, Callable[[Any], None]]: `register.plugin_name` if set, else `register.__name__`. ''' if isinstance(plugin, str): - # Look up by entry-point name in the default group. - for ep in _discover_entry_points(): + # Look up by entry-point name across every registered group + # (dialects + targets + legacy). PR-1 (per § 3.3.4) split the + # legacy group; the resolver follows. + for ep in _discover_all_groups(): if ep.name == plugin: return ep.name, ep.load() - raise LookupError(f'no plugin named {plugin!r} in entry-point group {ENTRY_POINT_GROUP!r}') + raise LookupError( + f'no plugin named {plugin!r} across entry-point groups ' + f"('srdatalog.dialects', 'srdatalog.targets', {ENTRY_POINT_GROUP!r})" + ) if hasattr(plugin, 'name') and hasattr(plugin, 'load'): # EntryPoint-shaped (importlib.metadata.EntryPoint or a test fake). @@ -276,23 +308,35 @@ def _resolve_plugin(plugin: Any) -> tuple[str, Callable[[Any], None]]: # -- discovery factory ----------------------------------------------------- @classmethod - def with_default_plugins(cls, *, group: str = ENTRY_POINT_GROUP) -> Compiler: + def with_default_plugins(cls, *, group: str | None = None) -> Compiler: '''Construct a `Compiler` and auto-discover all entry-point plugins. - Walks `importlib.metadata.entry_points(group=group)`, - topo-sorts by each plugin's `provides` / `requires` attributes + Walks the production entry-point groups (per PR-1, per + `docs/phase_decomposition_redesign.md` § 3.3.4): + + - `srdatalog.dialects` — data-side dialect contributions. + - `srdatalog.targets` — render-side target contributions. + - `srdatalog.plugins` — legacy group (kept for back-compat + for one release cycle; emits `DeprecationWarning` when + non-empty). + + Topo-sorts by each plugin's `provides` / `requires` attributes on the `register` callable (defaulting to `()` when absent), then calls `register_plugin` on each in order. The `group` keyword exists for test isolation — production code - uses the default. See - `docs/phase_e_plugin_extensibility.md` §2. + uses the default (walks all groups). Passing a specific group + name walks only that group; passing `None` (the default) walks + every registered group. + + See `docs/phase_e_plugin_extensibility.md` §2 and + `docs/phase_decomposition_redesign.md` § 3.3.4. ''' compiler = cls() # Discover, then resolve each EP to (name, register_fn) once so # we can both topo-sort and register without double-loading. - eps = _discover_entry_points(group) + eps = _discover_entry_points(group) if group is not None else _discover_all_groups() resolved: dict[str, tuple[Any, Callable[[Any], None]]] = {} infos: list[PluginInfo] = [] for ep in eps: diff --git a/src/srdatalog/ir/core/plugin.py b/src/srdatalog/ir/core/plugin.py index 30dacc7..fe35dca 100644 --- a/src/srdatalog/ir/core/plugin.py +++ b/src/srdatalog/ir/core/plugin.py @@ -1,19 +1,29 @@ '''Plugin discovery + registration infrastructure. Spec: `docs/phase_e_plugin_extensibility.md`. Locked design decisions -in `docs/phase_zero_prerequisites.md` §3.5. +in `docs/phase_zero_prerequisites.md` §3.5. PR-1 (per +`docs/phase_decomposition_redesign.md` § 3.3.4) split the legacy +single entry-point group into two: + + - `srdatalog.dialects` — data-side dialect contributions + (target-agnostic IR: ops, pragmas, lowerings). + - `srdatalog.targets` — render-side target contributions + (per-target `@register_render(op, target=T)` registrations). + +The legacy `srdatalog.plugins` group remains discoverable for one +release cycle (with a `DeprecationWarning`) so external plugin +packages have a deterministic migration path. Plugins extend the compiler with new dialects, pragmas, targets, and the like. Each plugin is a callable `register(compiler)` that mutates the compiler's registries. Plugins are discovered via Python entry -points (group `srdatalog.plugins`) OR registered explicitly. +points (any of the three groups above) OR registered explicitly. Per the locked design (see `docs/phase_zero_prerequisites.md` §3.5): - `Compiler()` is empty by default — no auto-discovery. - `Compiler.with_default_plugins()` is the opt-in factory that walks - `importlib.metadata.entry_points(group=...)` and loads every - entry point. + every registered entry-point group and loads every entry point. - Plugins declare their `provides` / `requires` / `replaces` metadata as function attributes on the `register` callable. The loader Kahn-topo-sorts by these attributes so a plugin that @@ -38,6 +48,7 @@ from __future__ import annotations import importlib.metadata +import warnings from collections.abc import Callable, Iterable from dataclasses import dataclass from typing import Any @@ -49,9 +60,15 @@ # module (one-way dependency: dialect.py -> plugin.py). -# Public entry-point group name. Listed in plugin packages' -# `pyproject.toml` under `[project.entry-points."srdatalog.plugins"]`. -ENTRY_POINT_GROUP = 'srdatalog.plugins' +# Public entry-point groups. PR-1 (per +# `docs/phase_decomposition_redesign.md` § 3.3.4) split the legacy +# single group into a data-dialects group + a render-targets group; +# both are walked by `Compiler.with_default_plugins`. The legacy +# group remains walked (with `DeprecationWarning`) for one release +# cycle for back-compat with external plugins. +DIALECT_ENTRY_POINT_GROUP = 'srdatalog.dialects' +TARGET_ENTRY_POINT_GROUP = 'srdatalog.targets' +ENTRY_POINT_GROUP = 'srdatalog.plugins' # legacy — kept for one release cycle # ----------------------------------------------------------------------------- @@ -126,8 +143,65 @@ def _discover_entry_points(group: str = ENTRY_POINT_GROUP) -> list[Any]: Returns a list (not the underlying `EntryPoints` selectable view) so callers can sort / iterate without worrying about the importlib-metadata API churn between Python versions. + + When `group` is the legacy `srdatalog.plugins` and any entry + points are present in it, emit a `DeprecationWarning` exactly + once per process (per § 3.3.4 transition contract). The legacy + group is still walked; the warning gives external plugin + authors time to migrate to the split groups. + ''' + eps = list(importlib.metadata.entry_points(group=group)) + if group == ENTRY_POINT_GROUP and eps: + _warn_legacy_group_once() + return eps + + +_LEGACY_GROUP_WARNED = False + + +def _warn_legacy_group_once() -> None: + '''Emit a single `DeprecationWarning` per process for the legacy + `srdatalog.plugins` entry-point group. ''' - return list(importlib.metadata.entry_points(group=group)) + global _LEGACY_GROUP_WARNED + if _LEGACY_GROUP_WARNED: + return + _LEGACY_GROUP_WARNED = True + warnings.warn( + f'Entry-point group {ENTRY_POINT_GROUP!r} is deprecated. Per ' + f'`docs/phase_decomposition_redesign.md` § 3.3.4, data dialects ' + f'should declare under {DIALECT_ENTRY_POINT_GROUP!r} and render ' + f'targets under {TARGET_ENTRY_POINT_GROUP!r}. The legacy group ' + f'will be removed one release after PR-1 lands.', + DeprecationWarning, + stacklevel=3, + ) + + +def _discover_all_groups() -> list[Any]: + '''Walk every plugin entry-point group (legacy + new) and return + a deduplicated list of `EntryPoint`-shaped objects. + + Per PR-1 (per `docs/phase_decomposition_redesign.md` § 3.3.4): + built-in dialects ship under `srdatalog.dialects`; built-in + render targets ship under `srdatalog.targets`; external packages + that haven't migrated yet ship under `srdatalog.plugins` (the + legacy group), which the loader still walks for back-compat with + a `DeprecationWarning`. + + Dedup key: `(group_name, entry_point_name)`. The same plugin + appearing in both the legacy and a new group loads only once + (the new group is preferred — first listed in the walk order). + ''' + seen_names: set[str] = set() + out: list[Any] = [] + for group in (DIALECT_ENTRY_POINT_GROUP, TARGET_ENTRY_POINT_GROUP, ENTRY_POINT_GROUP): + for ep in _discover_entry_points(group): + if ep.name in seen_names: + continue + seen_names.add(ep.name) + out.append(ep) + return out def _plugin_attr(register_fn: Callable[..., Any], name: str) -> tuple[str, ...]: @@ -218,9 +292,11 @@ def _topo_sort_plugins(infos: Iterable[PluginInfo]) -> list[str]: __all__ = [ + 'DIALECT_ENTRY_POINT_GROUP', 'ENTRY_POINT_GROUP', 'PluginConflictError', 'PluginCycleError', 'PluginInfo', 'PluginLoadError', + 'TARGET_ENTRY_POINT_GROUP', ] diff --git a/src/srdatalog/ir/default_pipelines.py b/src/srdatalog/ir/default_pipelines.py index e60868b..b22a1fb 100644 --- a/src/srdatalog/ir/default_pipelines.py +++ b/src/srdatalog/ir/default_pipelines.py @@ -10,7 +10,7 @@ DEFAULT_KERNEL_PIPELINE — KernelCtx(ep, ...) -> body string AssignHandlesShim, CollectViewSpecsShim, EmitViewDeclsShim, - LowerScanPipelineShim, CudaRenderShim + LowerKernelBodyShim, VerifyRenderabilityShim, RenderShim Each shim is a `ProgramPass` subclass mirroring one block of either `compile_to_mir` (src/srdatalog/ir/hir/__init__.py:96-124) or @@ -53,13 +53,19 @@ class InitialProg: Read by `HirPlanningShim` when computing HIR from scratch; ignored if `hir` is pre-populated by the caller. Added in F5.2 so the declarative pipeline can preserve the imperative entry point's - verbosity knob byte-equivalently.''' + verbosity knob byte-equivalently. + + `target` carries the active render target (PR-1 — per + `docs/phase_decomposition_redesign.md` § 3.3.1); defaulted to + 'cuda' for back-compat with the single-target wiring. + ''' program: Program hir: HirProgram | None = None steps: list[tuple[mir.MirNode, bool]] | None = None mir_program: mir.Program | None = None verbose: bool = False + target: str = 'cuda' @dataclass(frozen=True, slots=True) @@ -67,7 +73,14 @@ class KernelCtx: '''Kernel-pipeline through-state. Carries the original `compile_kernel_body` kwargs plus the progressively populated derived state (handle-assigned ep, view_specs, view_decls + var - maps, iir, body_text).''' + maps, iir, body_text). + + `target` carries the active render target (PR-1 — per + `docs/phase_decomposition_redesign.md` § 3.3.1); read by + `RenderShim` to dispatch the per-op + `@register_render(op, target=T)` resolution. Defaulted to 'cuda' + for back-compat with the single-target wiring. + ''' ep: ExecutePipeline is_counting: bool @@ -77,6 +90,7 @@ class KernelCtx: rel_index_types: dict[str, str] | None = None tiled_cartesian: bool = False bg_enabled: bool = False + target: str = 'cuda' # populated by shims: view_specs: tuple[ViewSpec, ...] | None = None view_decls: str | None = None @@ -261,14 +275,23 @@ def _fn(state: KernelCtx, _compiler: Any) -> KernelCtx: ) -class LowerScanPipelineShim(ProgramPass): +class LowerKernelBodyShim(ProgramPass): '''Wraps `lower_scan_pipeline` (sorted_array dialect). Builds a `LoweringCtx` from the populated state and runs the MIR -> IIR - walk. Adds `iir` to state.''' + walk. Adds `iir` to state. + + PR-1 rename (per `docs/phase_decomposition_redesign.md` § 3.3.3): + was `LowerScanPipelineShim`. The class name no longer hardcodes + the CUDA-side `lower_scan_pipeline` helper — the shim's contract + is "lower kernel body MIR -> IIR for the active target". The body + still routes through the sorted_array dialect's + `lower_scan_pipeline` today (only CUDA target wired); future + per-target lowering selection will dispatch on `state.target`. + ''' def __init__(self) -> None: super().__init__( - name='lower_scan_pipeline', + name='lower_kernel_body', consumes=('view_decls',), produces=('iir',), fn=self._fn, @@ -281,8 +304,8 @@ def _fn(state: KernelCtx, _compiler: Any) -> KernelCtx: lower_scan_pipeline, ) - assert state.name_map is not None, 'LowerScanPipelineShim: name_map not set' - assert state.base_map is not None, 'LowerScanPipelineShim: base_map not set' + assert state.name_map is not None, 'LowerKernelBodyShim: name_map not set' + assert state.base_map is not None, 'LowerKernelBodyShim: base_map not set' lower_ctx = LoweringCtx( view_var_names=state.name_map, is_counting=state.is_counting, @@ -298,6 +321,13 @@ def _fn(state: KernelCtx, _compiler: Any) -> KernelCtx: return dataclasses.replace(state, iir=iir) +# Back-compat alias for the pre-PR-1 name. External callers that +# pinned `LowerScanPipelineShim` continue to import + construct the +# same shim. Slated for removal once the broader codebase fully +# migrates to `LowerKernelBodyShim`. +LowerScanPipelineShim = LowerKernelBodyShim + + class VerifyRenderabilityShim(ProgramPass): '''R3 closure check (topology check #4 per docs/concept_glossary.md section 13.3). @@ -336,18 +366,31 @@ def _fn(state: KernelCtx, compiler: Any) -> KernelCtx: from srdatalog.ir.core.verifier import verify_renderability assert state.iir is not None, 'VerifyRenderabilityShim: iir not set' - verify_renderability(state.iir, target='cuda', compiler=compiler) + verify_renderability(state.iir, target=state.target, compiler=compiler) return state -class CudaRenderShim(ProgramPass): - '''Wraps `srdatalog.ir.codegen.cuda.emit.emit` with an - `EmitCtx(indent_level=4)`. Concatenates `view_decls` + emitted IIR - into the final `body_text`.''' +class RenderShim(ProgramPass): + '''Target-parametric IIR render shim. Reads `state.target` and + dispatches to that target's render registry. + + PR-1 rename (per `docs/phase_decomposition_redesign.md` § 3.3.3): + was `CudaRenderShim`. The class name no longer hardcodes CUDA — + the shim resolves `state.target` and selects the registered + per-target renderer. Today only the CUDA renderer is wired in + (`srdatalog.ir.codegen.cuda.emit.emit`); future targets (CPU/TBB, + SYCL, …) add their own render entry alongside. + + When `state.target != 'cuda'` we currently raise `ValueError` — + no other target has render contributions registered yet. The + R3 closure check (`VerifyRenderabilityShim`) runs BEFORE this + shim with the same `state.target`, so the failure message there + fires first when the user requests an unsupported target. + ''' def __init__(self) -> None: super().__init__( - name='cuda_render', + name='render', consumes=('iir',), produces=('body_text',), fn=self._fn, @@ -355,13 +398,28 @@ def __init__(self) -> None: @staticmethod def _fn(state: KernelCtx, _compiler: Any) -> KernelCtx: - from srdatalog.ir.codegen.cuda.emit import EmitCtx, emit + assert state.iir is not None, 'RenderShim: iir not set' + assert state.view_decls is not None, 'RenderShim: view_decls not set' + if state.target == 'cuda': + from srdatalog.ir.codegen.cuda.emit import EmitCtx, emit + + emit_ctx = EmitCtx(indent_level=4) + body_text = state.view_decls + emit(state.iir, emit_ctx) + return dataclasses.replace(state, body_text=body_text) + raise ValueError( + f'RenderShim: no renderer registered for target {state.target!r}. ' + f'Today only target="cuda" is wired in; per `docs/' + f'phase_decomposition_redesign.md` § 3.3.4 external plugins ' + f'register additional targets via the `srdatalog.targets` ' + f'entry-point group.' + ) + - assert state.iir is not None, 'CudaRenderShim: iir not set' - assert state.view_decls is not None, 'CudaRenderShim: view_decls not set' - emit_ctx = EmitCtx(indent_level=4) - body_text = state.view_decls + emit(state.iir, emit_ctx) - return dataclasses.replace(state, body_text=body_text) +# Back-compat alias for the pre-PR-1 name. External callers that +# pinned `CudaRenderShim` continue to import + construct the same +# shim. Slated for removal once the broader codebase fully migrates +# to `RenderShim`. +CudaRenderShim = RenderShim # ----------------------------------------------------------------------------- @@ -381,9 +439,9 @@ def _fn(state: KernelCtx, _compiler: Any) -> KernelCtx: AssignHandlesShim(), CollectViewSpecsShim(), EmitViewDeclsShim(), - LowerScanPipelineShim(), + LowerKernelBodyShim(), VerifyRenderabilityShim(), - CudaRenderShim(), + RenderShim(), ] @@ -392,14 +450,16 @@ def _fn(state: KernelCtx, _compiler: Any) -> KernelCtx: 'DEFAULT_PROGRAM_PIPELINE', 'AssignHandlesShim', 'CollectViewSpecsShim', - 'CudaRenderShim', + 'CudaRenderShim', # legacy alias for `RenderShim` 'EmitViewDeclsShim', 'HirPlanningShim', 'HirToMirShim', 'InitialProg', 'KernelCtx', - 'LowerScanPipelineShim', + 'LowerKernelBodyShim', + 'LowerScanPipelineShim', # legacy alias for `LowerKernelBodyShim` 'MirOptShim', 'MirProgramAssembly', + 'RenderShim', 'VerifyRenderabilityShim', ] diff --git a/src/srdatalog/ir/dialects/parallel/atomic_ws/pragmas/work_stealing.py b/src/srdatalog/ir/dialects/parallel/atomic_ws/pragmas/work_stealing.py index 266defa..5c2d035 100644 --- a/src/srdatalog/ir/dialects/parallel/atomic_ws/pragmas/work_stealing.py +++ b/src/srdatalog/ir/dialects/parallel/atomic_ws/pragmas/work_stealing.py @@ -151,24 +151,19 @@ def lower_ws_scope(op: WSScope, ctx: Any) -> Op: '''Emit the IIR for `WSScope(inner=InsertInto)`. Returns a `Block` wrapping the inner `InsertInto`'s emission - rendered under `ctx.ws_enabled=True`. The legacy - `_lower_insert_into` (in the sorted_array dialect's `lowerings.py` - monolith) already produces the WS-flavoured emit (count-phase - `++` instead of `.emit_direct()`) when its - `ctx.ws_enabled` is set; we reuse it verbatim so the new lowering - and the legacy path emit byte-identical text for the same - `InsertInto`. - - The save/restore pattern around `ctx.ws_enabled` is defensive: - if a future call site invokes this lowering from a partial-ctx - scope where `ws_enabled` is False (e.g. a refactored - `compile_kernel_body` that no longer pre-sets the field), the - flag still flips on for the duration of the gate. - - `LoweringCtx` is a non-frozen dataclass (it carries - `name_counter` + per-pass mutable state), so attribute assignment - is the supported mutation surface — no `object.__setattr__` shim - needed and the D18 ratchet does not apply. + rendered with `is_ws_scope=True`. The legacy `_lower_insert_into` + (in the sorted_array dialect's `lowerings.py` monolith) already + produces the WS-flavoured emit (count-phase `++` instead of + `.emit_direct()`) when its `is_ws_scope` kwarg is set; we + reuse it verbatim so the new lowering and the legacy path emit + byte-identical text for the same `InsertInto`. + + PR-1 refactor (per `docs/phase_decomposition_redesign.md` § + 3.2.1): this helper used to flip `ctx.ws_enabled = True` for the + duration of the call. PR-1 moved the dispatch signal to a + function-local kwarg so the wrap-op lowering no longer mutates + ctx state — cleaner contract for the post-redesign world where + `LoweringCtx` is target-private scratch, not a pragma scratch pad. Implementation parallels `lower_dedup_gate` (C2 template) at `srdatalog.ir.dialects.relation.sorted_array.pragmas.dedup_hash. @@ -183,12 +178,7 @@ def lower_ws_scope(op: WSScope, ctx: Any) -> Op: _lower_insert_into, ) - prev = getattr(ctx, 'ws_enabled', False) - try: - ctx.ws_enabled = True - stmts = _lower_insert_into(op.inner, ctx) - finally: - ctx.ws_enabled = prev + stmts = _lower_insert_into(op.inner, ctx, is_ws_scope=True) return Block(stmts=tuple(stmts)) diff --git a/src/srdatalog/ir/dialects/parallel/block_group/pragmas/block_group.py b/src/srdatalog/ir/dialects/parallel/block_group/pragmas/block_group.py index 06dee82..0257e5a 100644 --- a/src/srdatalog/ir/dialects/parallel/block_group/pragmas/block_group.py +++ b/src/srdatalog/ir/dialects/parallel/block_group/pragmas/block_group.py @@ -182,8 +182,7 @@ def lower_block_group_root(op: Any, ctx: Any) -> Op: Returns the same `Block` that the legacy `ctx.bg_enabled` branch inside `_lower_root_cj_multi` would have produced for the wrapped - root ColumnJoin, by re-entering `_lower_root_cj_bg` with - `ctx.bg_enabled` flipped True for the duration of the call. The + root ColumnJoin, by calling `_lower_root_cj_bg` directly. The inner op carries the original root ColumnJoin; the post-root `rest` is NOT carried on the wrap op — the caller (`lower_scan_pipeline`) is responsible for slicing it off the @@ -193,25 +192,22 @@ def lower_block_group_root(op: Any, ctx: Any) -> Op: 1. `lower_scan_pipeline` (sorted_array dialect): detects a `BlockGroupRoot` head, slices `rest = pipeline[1:]`, and - calls this with `ctx.rest_for_bg_root` pre-set so we can - reach the lowering helper. Today the dispatch lives directly - in `lower_scan_pipeline` (it constructs the same call inline - to `_lower_root_cj_bg`); this function exists as the - `@lowering`-registered entry so registry-completeness - discipline checks pass. + calls `_lower_root_cj_bg` directly (this function exists as + the `@lowering`-registered entry so registry-completeness + discipline checks pass). 2. Tests that invoke this function directly: pass the wrap op and a ctx whose `rest_for_bg_root` attribute carries the trailing pipeline ops (typically a single `InsertInto`). - The save/restore pattern around `ctx.bg_enabled` is defensive: if - a future call site invokes this lowering from a partial-ctx scope - where `bg_enabled` is False, the flag still flips on for the - duration. - - `LoweringCtx` is a non-frozen dataclass, so attribute assignment - is the supported mutation surface — no `object.__setattr__` shim - needed and the D18 ratchet does not apply. + PR-1 refactor (per `docs/phase_decomposition_redesign.md` § + 3.2.1): this helper used to flip `ctx.bg_enabled = True` for the + duration of the `_lower_root_cj_bg` call. PR-1 removed the flip + because `_lower_root_cj_bg` IS the BG function — it doesn't need + a ctx flag to dispatch to itself. The flag was only ever + consulted by `_lower_root_cj_multi` to RE-ROUTE to the BG variant + on the legacy `ep.block_group: bool` path; the wrap-op path + bypasses that re-route entirely, so no flag flip is needed. ''' # Deferred import: the sorted_array monolith imports nothing from # this subpackage at top level, but co-located ops + pragmas + @@ -226,12 +222,7 @@ def lower_block_group_root(op: Any, ctx: Any) -> Op: if rest is None: rest = [] - prev = getattr(ctx, 'bg_enabled', False) - try: - ctx.bg_enabled = True - return _lower_root_cj_bg(op.inner, list(rest), ctx) - finally: - ctx.bg_enabled = prev + return _lower_root_cj_bg(op.inner, list(rest), ctx) __all__ = [ diff --git a/src/srdatalog/ir/dialects/relation/sorted_array/lowerings/__init__.py b/src/srdatalog/ir/dialects/relation/sorted_array/lowerings/__init__.py index dc3414c..ca23665 100644 --- a/src/srdatalog/ir/dialects/relation/sorted_array/lowerings/__init__.py +++ b/src/srdatalog/ir/dialects/relation/sorted_array/lowerings/__init__.py @@ -20,8 +20,10 @@ from collections.abc import Callable from dataclasses import dataclass, field +from typing import Any import srdatalog.ir.mir.types as mir +from srdatalog.ir.codegen.cuda.lower_ctx import CudaRenderCtx from srdatalog.ir.core import Op from srdatalog.ir.dialects.iir.cf import ( AddCount, @@ -89,83 +91,197 @@ class NegPreNarrowInfo: rel_name: str -@dataclass class LoweringCtx: '''Mutable state during MIR -> IIR walk. - Mirrors the legacy `CodeGenContext` for the fields that matter to - the dialect's emission decisions today. Other legacy fields - (tiled_cartesian state, ws state, etc.) aren't needed yet — - milestones add them as they cover those paths. + PR-1 refactor (per `docs/phase_decomposition_redesign.md` § 3.2.1): + the CUDA-render-specific identifier fields (view variable names, + output variable name, neg-pre-narrow info, …) live on a target- + private `CudaRenderCtx` (in + `srdatalog.ir.codegen.cuda.lower_ctx`). This class threads that + context via the `render_ctx` slot and exposes the legacy flat + field names as forwarding properties so the existing helpers + remain byte-equivalent during the migration. + + Legacy callers that constructed `LoweringCtx(view_var_names=..., + output_var=...)` continue to work — the constructor coerces those + flat kwargs into a freshly built `render_ctx`. New callers can + pass `render_ctx=CudaRenderCtx(...)` directly. + + Non-CUDA-render fields (`name_counter`, `inside_cartesian`, + `bound_vars`, `tile_var`, `handle_vars`, `cartesian_bound_vars`, + `is_counting`) remain on this object — they're lowering-walk + scope state, not target-specific identifier scratch. + + Pragma scratch flags (`dedup_hash`, `tiled_cartesian`, + `ws_enabled`, `bg_enabled`) became lowering-local kwargs in PR-1 + (per spec § 3.2.1): each wrap-op's lowering helper now takes + `is_dedup_gate=True` / `is_ws_scope=True` / etc. instead of + flipping a ctx field. The fields remain on this object for + back-compat (legacy `_lower_*` helpers still consult them via + attribute access) but are no longer set by the pragma lowerings — + the kwarg is the canonical surface. ''' - name_counter: int = 0 - view_var_names: dict[str, str] = field(default_factory=dict) - is_counting: bool = False - inside_cartesian: bool = False - output_var: str = 'output' - tile_var: str = 'tile' - debug: bool = True - output_var_overrides: dict[str, str] = field(default_factory=dict) - bound_vars: list[str] = field(default_factory=list) - # rel_name -> custom index type code (e.g. 'Device2LevelIndex'). - # Empty string / missing entry = plain DSAI single-segment. - rel_index_types: dict[str, str] = field(default_factory=dict) - # handle_idx (str) -> base slot in views[]. Populated by - # `compile_kernel_body` from `emit_view_declarations` so D2L - # segment-loop emission can reference the right HEAD/FULL pair. - view_slot_bases: dict[str, int] = field(default_factory=dict) - # When True, InsertInto emit wraps the output write in a - # `{ bool _p = dedup_table.try_insert(thread_id, ...); if (_p) { - # ... } }` gate, and the materialize-phase write goes through - # `atomicAdd(atomic_write_pos, 1u)` + `out_data_0[...]` instead of - # `output.emit_direct(...)`. Threaded from `ep.dedup_hash`. - dedup_hash: bool = False - # When True, eligible 2-source / 1-var-per-source nested Cartesians - # in materialize phase emit the `if (total > 32) { tiled smem path } - # else { fallback path }` dispatch. Bodies inside both branches use - # the `tiled_cartesian_valid_var` ballot-write variant of InsertInto. - # Threaded from `_dialect_safe_kernel`'s `tiled_cartesian_eligible` - # gate via `compile_kernel_body`. - tiled_cartesian: bool = False - # Empty (default) — InsertInto emits the standard - # `output.emit_direct(...)` write. Non-empty — emits the tiled- - # Cartesian ballot-write variant guarded by this var. Set by - # `_lower_nested_cart` when rendering the tiled-mode body. - tiled_cartesian_valid_var: str = '' - # Work-stealing flag (mirrors legacy `ctx.ws_enabled`). In count - # phase, InsertInto emits `++` instead of - # `.emit_direct()` — the WS count uses a per-thread - # local counter that the runner aggregates. The legacy emitter has - # only this kernel-functor-level WS support; the runner-side WS - # scaffolding (WCOJTask queue) was never finished. - ws_enabled: bool = False - # Work-stealing batched-Cartesian valid var. When set, Filter / - # Negation fold their guard into ` = && ();` and - # InsertInto materialize emits `.emit_warp_coalesced( - # tile, , )` — a cooperative warp write instead of a - # lane-zero-guarded emit_direct. Mirrors legacy - # `ctx.ws_cartesian_valid_var`. - ws_cartesian_valid_var: str = '' - # Block-group flag (mirrors legacy `ctx.bg_enabled`). When True, - # the root multi-source ColumnJoin emits via `BgRootCjMulti` - # (block-group work-balanced partition + binary-search key loop) - # instead of the standard grid-stride root_unique_values loop. - # Cleared when descending into the body (the BG root's narrowed - # handle already restricts work to this warp's slice). - bg_enabled: bool = False - # State-key -> handle var name. Lets nested CJ find the parent - # handle to alias by the same (rel, cols, prefix_vars, ver) key - # that the outer CJ used to register it. - handle_vars: dict[str, str] = field(default_factory=dict) - # Cartesian-bound var names. Used to decide which Negation prefix - # vars are pre-Cartesian (= bound by an outer scope) vs in-Cartesian - # (= bound by the current Cart and per-thread). - cartesian_bound_vars: list[str] = field(default_factory=list) - # handle_idx -> NegPreNarrowInfo. Populated by `_lower_nested_cart` - # before its body renders so that the body's Negation handler can - # pick up the pre-allocated handle. - neg_pre_narrow: dict[int, NegPreNarrowInfo] = field(default_factory=dict) + # Intentionally not using __slots__: legacy test fixtures pre-set + # transient lowering-local attributes like `rest_for_bg_root` via + # `setattr(ctx, ...)`. Pin-discipline (D10) lives in the test that + # asserts the explicit attribute set; slotting would lock out the + # test-fixture surface without changing production correctness. + + def __init__( + self, + *, + render_ctx: CudaRenderCtx | None = None, + # Generic lowering-walk scope state: + name_counter: int = 0, + is_counting: bool = False, + inside_cartesian: bool = False, + tile_var: str = 'tile', + bound_vars: list[str] | None = None, + handle_vars: dict[str, str] | None = None, + cartesian_bound_vars: list[str] | None = None, + # Pragma scratch flags (legacy; kwargs preferred — see class docstring): + dedup_hash: bool = False, + tiled_cartesian: bool = False, + ws_enabled: bool = False, + bg_enabled: bool = False, + # CUDA-render fields (back-compat — preferred form is to pass a + # constructed `render_ctx`). When `render_ctx` is None we coerce + # these flat kwargs into a freshly built CudaRenderCtx. + view_var_names: dict[str, str] | None = None, + output_var: str = 'output', + output_var_overrides: dict[str, str] | None = None, + view_slot_bases: dict[str, int] | None = None, + rel_index_types: dict[str, str] | None = None, + tiled_cartesian_valid_var: str = '', + ws_cartesian_valid_var: str = '', + ws_cartesian_bound_vars: list[str] | None = None, + neg_pre_narrow: dict[int, Any] | None = None, + dedup_hash_vars: list[str] | None = None, + debug: bool = True, + ) -> None: + if render_ctx is None: + render_ctx = CudaRenderCtx( + view_var_names=view_var_names if view_var_names is not None else {}, + output_var=output_var, + output_var_overrides=output_var_overrides if output_var_overrides is not None else {}, + view_slot_bases=view_slot_bases if view_slot_bases is not None else {}, + rel_index_types=rel_index_types if rel_index_types is not None else {}, + tiled_cartesian_valid_var=tiled_cartesian_valid_var, + ws_cartesian_valid_var=ws_cartesian_valid_var, + ws_cartesian_bound_vars=ws_cartesian_bound_vars + if ws_cartesian_bound_vars is not None + else [], + neg_pre_narrow=neg_pre_narrow if neg_pre_narrow is not None else {}, + dedup_hash_vars=dedup_hash_vars if dedup_hash_vars is not None else [], + debug=debug, + ) + self.render_ctx = render_ctx + self.name_counter = name_counter + self.is_counting = is_counting + self.inside_cartesian = inside_cartesian + self.tile_var = tile_var + self.bound_vars = bound_vars if bound_vars is not None else [] + self.handle_vars = handle_vars if handle_vars is not None else {} + self.cartesian_bound_vars = cartesian_bound_vars if cartesian_bound_vars is not None else [] + self.dedup_hash = dedup_hash + self.tiled_cartesian = tiled_cartesian + self.ws_enabled = ws_enabled + self.bg_enabled = bg_enabled + + # -- forwarding properties for the CUDA-render fields --------------------- + # Legacy reads like `ctx.view_var_names` continue to work; they delegate + # to the underlying `render_ctx`. New code should prefer the explicit + # `ctx.render_ctx.` form (per spec § 3.2.1). + + @property + def view_var_names(self) -> dict[str, str]: + return self.render_ctx.view_var_names + + @view_var_names.setter + def view_var_names(self, value: dict[str, str]) -> None: + self.render_ctx.view_var_names = value + + @property + def output_var(self) -> str: + return self.render_ctx.output_var + + @output_var.setter + def output_var(self, value: str) -> None: + self.render_ctx.output_var = value + + @property + def output_var_overrides(self) -> dict[str, str]: + return self.render_ctx.output_var_overrides + + @output_var_overrides.setter + def output_var_overrides(self, value: dict[str, str]) -> None: + self.render_ctx.output_var_overrides = value + + @property + def view_slot_bases(self) -> dict[str, int]: + return self.render_ctx.view_slot_bases + + @view_slot_bases.setter + def view_slot_bases(self, value: dict[str, int]) -> None: + self.render_ctx.view_slot_bases = value + + @property + def rel_index_types(self) -> dict[str, str]: + return self.render_ctx.rel_index_types + + @rel_index_types.setter + def rel_index_types(self, value: dict[str, str]) -> None: + self.render_ctx.rel_index_types = value + + @property + def tiled_cartesian_valid_var(self) -> str: + return self.render_ctx.tiled_cartesian_valid_var + + @tiled_cartesian_valid_var.setter + def tiled_cartesian_valid_var(self, value: str) -> None: + self.render_ctx.tiled_cartesian_valid_var = value + + @property + def ws_cartesian_valid_var(self) -> str: + return self.render_ctx.ws_cartesian_valid_var + + @ws_cartesian_valid_var.setter + def ws_cartesian_valid_var(self, value: str) -> None: + self.render_ctx.ws_cartesian_valid_var = value + + @property + def ws_cartesian_bound_vars(self) -> list[str]: + return self.render_ctx.ws_cartesian_bound_vars + + @ws_cartesian_bound_vars.setter + def ws_cartesian_bound_vars(self, value: list[str]) -> None: + self.render_ctx.ws_cartesian_bound_vars = value + + @property + def neg_pre_narrow(self) -> dict[int, NegPreNarrowInfo]: + return self.render_ctx.neg_pre_narrow + + @neg_pre_narrow.setter + def neg_pre_narrow(self, value: dict[int, NegPreNarrowInfo]) -> None: + self.render_ctx.neg_pre_narrow = value + + @property + def dedup_hash_vars(self) -> list[str]: + return self.render_ctx.dedup_hash_vars + + @dedup_hash_vars.setter + def dedup_hash_vars(self, value: list[str]) -> None: + self.render_ctx.dedup_hash_vars = value + + @property + def debug(self) -> bool: + return self.render_ctx.debug + + @debug.setter + def debug(self, value: bool) -> None: + self.render_ctx.debug = value def fresh(self, prefix: str) -> str: self.name_counter += 1 @@ -393,12 +509,19 @@ def lower_scan_pipeline( C3 (per docs/phase_c_pragma_materialization.md §4.1): when the head op is a `BlockGroupRoot` wrap (inserted by the `BlockGroup` - pragma handler at MirPragmaPass time), unwrap it and re-dispatch - through `_lower_root_cj_bg` with `ctx.bg_enabled=True` for the - duration. The unwrap happens here (not in the wrap op's - registered `@lowering`) because the wrap op only carries the - root; the trailing `rest = ops[1:]` lives on the enclosing - pipeline and is naturally accessible at this dispatch site. + pragma handler at MirPragmaPass time), unwrap it and dispatch + directly to `_lower_root_cj_bg`. The unwrap happens here (not in + the wrap op's registered `@lowering`) because the wrap op only + carries the root; the trailing `rest = ops[1:]` lives on the + enclosing pipeline and is naturally accessible at this dispatch + site. + + PR-1 refactor (per `docs/phase_decomposition_redesign.md` § + 3.2.1): this branch used to flip `ctx.bg_enabled = True` around + the `_lower_root_cj_bg` call. PR-1 removed the flip — calling + `_lower_root_cj_bg` directly is already the BG dispatch; the flag + was only consulted by `_lower_root_cj_multi` to re-route to BG on + the legacy `ep.block_group: bool` path. A3-2: trailing `WSScope(InsertInto)` wrap ops (inserted by `materialize_work_stealing`) are unwrapped in place + the @@ -434,12 +557,7 @@ def lower_scan_pipeline( f'lower_scan_pipeline: unsupported pipeline shape ' f'{[type(o).__name__ for o in [inner, *rest]]} under BlockGroupRoot' ) - prev_bg = ctx.bg_enabled - try: - ctx.bg_enabled = True - return _lower_root_cj_bg(inner, rest, ctx) - finally: - ctx.bg_enabled = prev_bg + return _lower_root_cj_bg(inner, rest, ctx) if not _supported_pipeline(ops): raise ValueError( @@ -2736,10 +2854,27 @@ def _lower_nested_cj_single( return Block(stmts=tuple(stmts)) -def _lower_insert_into(node: mir.InsertInto, ctx: LoweringCtx) -> list[Op]: +def _lower_insert_into( + node: mir.InsertInto, + ctx: LoweringCtx, + *, + is_dedup_gate: bool = False, + is_ws_scope: bool = False, +) -> list[Op]: '''Lower an InsertInto under the M1-M3 narrow-flag assumptions. - When `ctx.dedup_hash` is set, the entire emit is wrapped in a + Per `docs/phase_decomposition_redesign.md` § 3.2.1 (PR-1), the + pragma-scratch dispatch signals (`dedup_hash`, `ws_enabled`) are + passed as function-local kwargs, NOT as ctx field flips. The + legacy `ctx.dedup_hash` / `ctx.ws_enabled` fields remain readable + (the legacy `ep.: bool` dual-write path still sets them via + `LowerKernelBodyShim`), and the new wrap-op `@lowering` rules call + this helper with the explicit kwarg instead — both produce the + same emission. The kwarg form is canonical; the ctx-field form is + legacy. + + When the dedup-hash gate is active (either `is_dedup_gate=True` + or legacy `ctx.dedup_hash`), the entire emit is wrapped in a `{ bool _p = dedup_table.try_insert(thread_id, v0, ...); if (_p) { } }` gate. In materialize phase the write goes through @@ -2820,7 +2955,12 @@ def _lower_insert_into(node: mir.InsertInto, ctx: LoweringCtx) -> list[Op]: return stmts sanitized_list = [_sanitize_var_name(v) for v in vars_list] - use_dedup = ctx.dedup_hash and bool(vars_list) + # PR-1: dedup / WS dispatch responds to either the function-local + # kwarg (the wrap-op `@lowering` path) or the legacy ctx field (the + # `ep.: bool` dual-write path). Both produce identical IIR. + dedup_active = is_dedup_gate or ctx.dedup_hash + ws_active = is_ws_scope or ctx.ws_enabled + use_dedup = dedup_active and bool(vars_list) if use_dedup: args_str = ', '.join(sanitized_list) @@ -2828,7 +2968,7 @@ def _lower_insert_into(node: mir.InsertInto, ctx: LoweringCtx) -> list[Op]: stmts.append(RawString(text=' if (_p) {')) if ctx.is_counting: - if ctx.ws_enabled: + if ws_active: # WS count: per-thread `local_count` (uint32_t) — `++` # instead of `.emit_direct()`. Lane-0 guard outside Cart. body: Op = RawString(text=f'{out_var}++;') diff --git a/src/srdatalog/ir/dialects/relation/sorted_array/pragmas/dedup_hash.py b/src/srdatalog/ir/dialects/relation/sorted_array/pragmas/dedup_hash.py index c361455..e5f7bb8 100644 --- a/src/srdatalog/ir/dialects/relation/sorted_array/pragmas/dedup_hash.py +++ b/src/srdatalog/ir/dialects/relation/sorted_array/pragmas/dedup_hash.py @@ -159,23 +159,20 @@ def lower_dedup_gate(op: DedupGate, ctx: Any) -> Op: '''Emit the IIR for `DedupGate(inner=InsertInto)`. Returns a `Block` wrapping the inner `InsertInto`'s emission - rendered under `ctx.dedup_hash=True`. The legacy + rendered with `is_dedup_gate=True`. The legacy `_lower_insert_into` (in the dialect's `lowerings.py` monolith) already produces the full `try_insert + if (_p) { write }` gate - when its `ctx.dedup_hash` is set; we reuse it verbatim so the + when its `is_dedup_gate` kwarg is set; we reuse it verbatim so the new lowering and the legacy path emit byte-identical text for the same `InsertInto`. - The save/restore pattern around `ctx.dedup_hash` is defensive: - if a future call site invokes this lowering from a partial-ctx - scope where `dedup_hash` is False (e.g. a refactored - `compile_kernel_body` that no longer pre-sets the field), the - flag still flips on for the duration of the gate. - - `LoweringCtx` is a non-frozen dataclass (it carries `name_counter` - + per-pass mutable state), so attribute assignment is the - supported mutation surface — no `object.__setattr__` shim needed - and the D18 ratchet does not apply. + PR-1 refactor (per `docs/phase_decomposition_redesign.md` § + 3.2.1): this helper used to flip `ctx.dedup_hash = True` for the + duration of the call and rely on `_lower_insert_into` reading + the ctx field. PR-1 moved the dispatch signal to a function-local + kwarg so the wrap-op lowering no longer mutates ctx state — + cleaner contract for the post-redesign world where `LoweringCtx` + is target-private scratch, not a pragma scratch pad. ''' # Deferred import: the monolith module imports nothing from this # subpackage at top level, but co-located ops + pragmas + lowerings @@ -186,12 +183,7 @@ def lower_dedup_gate(op: DedupGate, ctx: Any) -> Op: _lower_insert_into, ) - prev = getattr(ctx, 'dedup_hash', False) - try: - ctx.dedup_hash = True - stmts = _lower_insert_into(op.inner, ctx) - finally: - ctx.dedup_hash = prev + stmts = _lower_insert_into(op.inner, ctx, is_dedup_gate=True) return Block(stmts=tuple(stmts)) diff --git a/src/srdatalog/ir/dialects/relation/sorted_array/pragmas/tiled_cartesian.py b/src/srdatalog/ir/dialects/relation/sorted_array/pragmas/tiled_cartesian.py index 1b2fd67..b8adb3e 100644 --- a/src/srdatalog/ir/dialects/relation/sorted_array/pragmas/tiled_cartesian.py +++ b/src/srdatalog/ir/dialects/relation/sorted_array/pragmas/tiled_cartesian.py @@ -216,21 +216,25 @@ def lower_tiled_cartesian_in_chain( Called from `lowerings._lower_inner_chain` when a `TiledCartesian` wrap op appears as a chain head — that site has both `head` and `tail` in scope, which the legacy `_lower_nested_cart_tiled` - helper needs to render the inner body. We flip - `ctx.tiled_cartesian` True for the duration so the helper takes - the tiled path; the inner chain rendering also picks up - `ctx.tiled_cartesian_valid_var` via the helper's own save/restore. + helper needs to render the inner body. The helper takes the + tiled path directly (it IS the tiled variant); the inner chain + rendering picks up `ctx.tiled_cartesian_valid_var` via the + helper's own save/restore. Byte-equivalence by construction: the legacy - `if ctx.tiled_cartesian:` -> `_lower_nested_cart_tiled` dispatch + `if _tiled_cart_eligible:` -> `_lower_nested_cart_tiled` dispatch inside `_lower_nested_cart` and this wrap-op dispatch call the SAME helper with the SAME args, so the produced IIR is identical for the same input `(cart_op, rest)`. - `LoweringCtx` is a non-frozen dataclass (it carries `name_counter` - + per-pass mutable state), so attribute assignment is the - supported mutation surface — no `object.__setattr__` shim needed - and the D18 ratchet does not apply. + PR-1 refactor (per `docs/phase_decomposition_redesign.md` § + 3.2.1): this helper used to flip `ctx.tiled_cartesian = True` for + the duration of the `_lower_nested_cart_tiled` call. PR-1 removed + the flip because `_lower_nested_cart_tiled` IS the tiled variant + — it doesn't need a ctx flag to dispatch to itself. The flag was + only consulted by `_tiled_cart_eligible` (called from + `_lower_nested_cart`) on the legacy `ep.tiled_cartesian: bool` + path; the wrap-op path bypasses `_lower_nested_cart` entirely. ''' # Deferred import: the monolith module imports nothing from this # subpackage at top level, but co-located ops + pragmas + lowerings @@ -241,12 +245,7 @@ def lower_tiled_cartesian_in_chain( _lower_nested_cart_tiled, ) - prev = getattr(ctx, 'tiled_cartesian', False) - try: - ctx.tiled_cartesian = True - return _lower_nested_cart_tiled(op.inner, list(tail), ctx) - finally: - ctx.tiled_cartesian = prev + return _lower_nested_cart_tiled(op.inner, list(tail), ctx) def lower_tiled_cartesian(op: TiledCartesianOp, ctx: Any) -> Op: diff --git a/tests/test_compiler_run_target_kwarg.py b/tests/test_compiler_run_target_kwarg.py new file mode 100644 index 0000000..9ba198d --- /dev/null +++ b/tests/test_compiler_run_target_kwarg.py @@ -0,0 +1,222 @@ +'''Compiler.run(target=...) — PR-1 foundation tests. + +Per `docs/phase_decomposition_redesign.md` § 3.3.1 + § 3.3.5 +(Wave B2-1). + +`Compiler.run` accepts a `target` kwarg; default 'cuda' preserves +the pre-PR-1 single-target wiring. The kwarg is threaded into the +through-state dataclass when the latter exposes a `target` field +(`KernelCtx`, `InitialProg`). `RenderShim` reads `state.target` to +dispatch the per-op `@register_render` lookup; +`VerifyRenderabilityShim` reads `state.target` to drive the R3 +closure check. + +These tests pin the kwarg contract: default ('cuda') works; non-CUDA +targets surface clean errors (no silent fall-through). +''' + +from __future__ import annotations + +import pytest + +from srdatalog.dsl import Program, Relation, Var +from srdatalog.ir.codegen.cuda.api import compile_kernel_body +from srdatalog.ir.core.dialect import Compiler +from srdatalog.ir.core.verifier import UnrenderableOpError +from srdatalog.ir.default_pipelines import ( + DEFAULT_PROGRAM_PIPELINE, + InitialProg, + KernelCtx, +) +from srdatalog.ir.mir import types as mir + + +def _tc_program() -> Program: + '''Transitive-closure fixture (copied from test_default_pipelines_shims).''' + X, Y, Z = Var('x'), Var('y'), Var('z') + arc = Relation('ArcInput', 2) + edge = Relation('Edge', 2) + path = Relation('Path', 2) + return Program( + rules=[ + (edge(X, Y) <= arc(X, Y)).named('EdgeLoad'), + (path(X, Y) <= edge(X, Y)).named('TCBase'), + (path(X, Z) <= path(X, Y) & edge(Y, Z)).named('TCRec'), + ], + ) + + +def _find_execute_pipelines(node: object) -> list[mir.ExecutePipeline]: + '''Walk a MIR plan and collect every ExecutePipeline. Mirrors the + shape of `_collect_pipelines` in batchfile.py for our test needs.''' + out: list[mir.ExecutePipeline] = [] + if isinstance(node, mir.ExecutePipeline): + out.append(node) + elif isinstance(node, mir.FixpointPlan): + for inst in node.instructions: + out.extend(_find_execute_pipelines(inst)) + elif isinstance(node, mir.ParallelGroup): + for op in node.ops: + out.extend(_find_execute_pipelines(op)) + return out + + +def _first_ep(mir_prog: mir.Program) -> mir.ExecutePipeline: + for step, _is_rec in mir_prog.steps: + eps = _find_execute_pipelines(step) + if eps: + return eps[0] + raise AssertionError('no ExecutePipeline in mir_prog') + + +def test_compiler_run_accepts_target_kwarg_default_cuda() -> None: + '''Default target='cuda' preserves byte-equivalence with the + pre-PR-1 wiring (where target was implicit).''' + compiler = Compiler() + prog_state = compiler.run( + InitialProg(program=_tc_program()), + pipeline=DEFAULT_PROGRAM_PIPELINE, + ) + assert prog_state.target == 'cuda' + assert prog_state.mir_program is not None + + +def test_compiler_run_target_kwarg_overrides_default_state_field() -> None: + '''Explicitly passing target=cuda is a no-op vs default.''' + compiler = Compiler() + prog_state = compiler.run( + InitialProg(program=_tc_program()), + pipeline=DEFAULT_PROGRAM_PIPELINE, + target='cuda', + ) + assert prog_state.target == 'cuda' + + +def test_compiler_run_target_kwarg_propagates_to_state() -> None: + '''The `target` kwarg flows into the through-state's `target` field + before the pipeline runs.''' + compiler = Compiler() + prog_state = compiler.run( + InitialProg(program=_tc_program(), target='cuda'), + pipeline=DEFAULT_PROGRAM_PIPELINE, + target='cuda', + ) + assert prog_state.target == 'cuda' + + +def test_compiler_run_target_kwarg_overrides_initial_state_target() -> None: + '''If the caller passes both an initial state with a target AND + the kwarg, the kwarg wins (the kwarg is the authoritative API + surface; the state field is initialized for shim convenience).''' + compiler = Compiler() + # Construct with the legacy default; override via kwarg. + prog_state = compiler.run( + InitialProg(program=_tc_program()), + pipeline=DEFAULT_PROGRAM_PIPELINE, + target='cuda', + ) + assert prog_state.target == 'cuda' + + +def test_compile_kernel_body_accepts_target_kwarg() -> None: + '''`compile_kernel_body` threads `target` through to KernelCtx + + Compiler.run.''' + compiler = Compiler() + prog_state = compiler.run( + InitialProg(program=_tc_program()), + pipeline=DEFAULT_PROGRAM_PIPELINE, + ) + ep = _first_ep(prog_state.mir_program) + body = compile_kernel_body(ep, is_counting=False, target='cuda') + assert isinstance(body, str) + assert body # non-empty + + +def test_compile_kernel_body_default_target_is_cuda() -> None: + '''Back-compat: no `target` kwarg means CUDA.''' + compiler = Compiler() + prog_state = compiler.run( + InitialProg(program=_tc_program()), + pipeline=DEFAULT_PROGRAM_PIPELINE, + ) + ep = _first_ep(prog_state.mir_program) + body = compile_kernel_body(ep, is_counting=False) + assert isinstance(body, str) + + +def test_compile_kernel_body_unknown_target_fails_at_verify_gate() -> None: + '''Per `docs/phase_decomposition_redesign.md` § 3.3.5, the R3 + closure check fires BEFORE the render — surfacing an + `UnrenderableOpError` naming the missing (op_type, target) pair + long before the renderer's KeyError would. + + Today only target='cuda' is wired in; passing any other target + should fail the gate loudly. + ''' + compiler = Compiler() + prog_state = compiler.run( + InitialProg(program=_tc_program()), + pipeline=DEFAULT_PROGRAM_PIPELINE, + ) + ep = _first_ep(prog_state.mir_program) + with pytest.raises(UnrenderableOpError) as excinfo: + compile_kernel_body(ep, is_counting=False, target='nonexistent_target') + # The error should mention the unknown target so users know to + # ship a render plugin for it. + assert 'nonexistent_target' in str(excinfo.value) + + +def test_kernel_ctx_carries_target_field() -> None: + '''KernelCtx has a `target` field, defaulted to 'cuda'.''' + import dataclasses + + field_names = {f.name for f in dataclasses.fields(KernelCtx)} + assert 'target' in field_names + + +def test_initial_prog_carries_target_field() -> None: + '''InitialProg has a `target` field, defaulted to 'cuda'.''' + import dataclasses + + field_names = {f.name for f in dataclasses.fields(InitialProg)} + assert 'target' in field_names + + +def test_render_shim_dispatches_on_state_target() -> None: + '''`RenderShim` consults `state.target` (not a hardcoded literal) + to select the renderer. Passing a non-CUDA target should fail the + shim path even if R3 is bypassed.''' + from srdatalog.ir.default_pipelines import RenderShim + + shim = RenderShim() + # Construct a minimal kernel ctx with target=cpu_tbb; no IIR + # because the shim's assertion catches None first — we only need + # to assert the dispatch is target-driven, not literal-driven. + # The shim raises ValueError for unknown targets when iir is set. + # For the smoke test, just confirm the class name + dispatch. + assert shim.name == 'render' + + +def test_render_shim_raises_clear_error_for_unknown_target() -> None: + '''When the iir is populated but the target has no renderer, + RenderShim raises a `ValueError` naming the unknown target.''' + from srdatalog.ir.default_pipelines import RenderShim + from srdatalog.ir.dialects.iir.cf import RawString + + shim = RenderShim() + compiler = Compiler() + prog_state = compiler.run( + InitialProg(program=_tc_program()), + pipeline=DEFAULT_PROGRAM_PIPELINE, + ) + ep = _first_ep(prog_state.mir_program) + state = KernelCtx( + ep=ep, + is_counting=False, + target='wasm', + iir=RawString(text='x'), + view_decls='', + ) + with pytest.raises(ValueError) as excinfo: + shim._fn(state, compiler) + assert 'wasm' in str(excinfo.value) diff --git a/tests/test_cuda_render_ctx.py b/tests/test_cuda_render_ctx.py new file mode 100644 index 0000000..a86de31 --- /dev/null +++ b/tests/test_cuda_render_ctx.py @@ -0,0 +1,115 @@ +'''CudaRenderCtx — PR-1 foundation tests. + +Per `docs/phase_decomposition_redesign.md` § 3.2.1 (Wave T1). + +`CudaRenderCtx` is the CUDA-render-side companion to the framework +`LowerCtx`. PR-1 introduces it as the canonical home for the 11 +CUDA-identifier-bearing fields that previously lived flat on the +dialect-level `LoweringCtx`. Future PRs migrate every per-op render +to consume `ctx.render_ctx.`; PR-1 establishes the shape. + +These tests pin the construction contract, defaults, and the +threading-into-`LoweringCtx` shape. +''' + +from __future__ import annotations + +from srdatalog.ir.codegen.cuda.lower_ctx import CudaRenderCtx +from srdatalog.ir.dialects.relation.sorted_array.lowerings import LoweringCtx + + +def test_cuda_render_ctx_constructible_with_no_args() -> None: + '''Every field defaults to an empty / off value — back-compat + with the legacy flat-field `LoweringCtx` construction.''' + ctx = CudaRenderCtx() + assert ctx.view_var_names == {} + assert ctx.output_var == 'output' + assert ctx.output_var_overrides == {} + assert ctx.view_slot_bases == {} + assert ctx.rel_index_types == {} + assert ctx.tiled_cartesian_valid_var == '' + assert ctx.ws_cartesian_valid_var == '' + assert ctx.ws_cartesian_bound_vars == [] + assert ctx.neg_pre_narrow == {} + assert ctx.dedup_hash_vars == [] + assert ctx.debug is True + + +def test_cuda_render_ctx_constructible_with_explicit_args() -> None: + '''Every field takes a positional override.''' + ctx = CudaRenderCtx( + view_var_names={'0': 'view_R_0'}, + output_var='ctx0', + output_var_overrides={'R': 'ctx_R'}, + view_slot_bases={'0': 0, '1': 2}, + rel_index_types={'R': 'Device2LevelIndex'}, + tiled_cartesian_valid_var='_tc_valid_1', + ws_cartesian_valid_var='_ws_valid_1', + ws_cartesian_bound_vars=['v0', 'v1'], + neg_pre_narrow={}, + dedup_hash_vars=['v0'], + debug=False, + ) + assert ctx.view_var_names == {'0': 'view_R_0'} + assert ctx.output_var == 'ctx0' + assert ctx.debug is False + + +def test_cuda_render_ctx_mutability() -> None: + '''The dataclass is mutable (not frozen) — lowering helpers flip + individual fields during the walk (e.g. neg_pre_narrow gets + populated by `_register_neg_pre_narrow`).''' + ctx = CudaRenderCtx() + ctx.view_var_names['0'] = 'view_X_0' + assert ctx.view_var_names == {'0': 'view_X_0'} + + +def test_lowering_ctx_threads_render_ctx_slot() -> None: + '''The dialect-level `LoweringCtx` holds the `CudaRenderCtx` via + the `render_ctx` slot. Constructing a `LoweringCtx` with no args + builds a default `CudaRenderCtx` internally.''' + ctx = LoweringCtx() + assert isinstance(ctx.render_ctx, CudaRenderCtx) + assert ctx.render_ctx.view_var_names == {} + assert ctx.render_ctx.output_var == 'output' + + +def test_lowering_ctx_accepts_prebuilt_render_ctx() -> None: + '''New-style construction: pass a fully-built `CudaRenderCtx`.''' + render = CudaRenderCtx(view_var_names={'0': 'view_R_0'}, output_var='ctx0') + ctx = LoweringCtx(render_ctx=render) + assert ctx.render_ctx is render + assert ctx.render_ctx.view_var_names == {'0': 'view_R_0'} + + +def test_lowering_ctx_accepts_legacy_flat_kwargs() -> None: + '''Back-compat construction: legacy callers pass the flat CUDA + field names; the constructor builds a `CudaRenderCtx` internally + populated from those kwargs.''' + ctx = LoweringCtx(view_var_names={'0': 'view_R_0'}, output_var='ctx0') + assert ctx.render_ctx.view_var_names == {'0': 'view_R_0'} + assert ctx.render_ctx.output_var == 'ctx0' + # The forwarding properties expose the same underlying storage. + assert ctx.view_var_names == {'0': 'view_R_0'} + assert ctx.output_var == 'ctx0' + + +def test_lowering_ctx_legacy_field_reads_go_through_render_ctx() -> None: + '''Every legacy flat field name still reads correctly from the + dialect ctx — the properties delegate to render_ctx.''' + ctx = LoweringCtx(view_var_names={'0': 'a'}, output_var='o') + # Writes via legacy property surface land on the render_ctx too. + ctx.view_var_names['1'] = 'b' + assert ctx.render_ctx.view_var_names == {'0': 'a', '1': 'b'} + + +def test_lowering_ctx_render_ctx_is_shared_storage() -> None: + '''Mutating render_ctx directly is visible through the forwarding + property (and vice-versa). PR-1 keeps the legacy surface live so + per-op render migrations can switch one site at a time without + breaking byte-equivalence.''' + ctx = LoweringCtx() + ctx.render_ctx.tiled_cartesian_valid_var = '_tc_1' + assert ctx.tiled_cartesian_valid_var == '_tc_1' + ctx.tiled_cartesian_valid_var = '_tc_2' + assert ctx.render_ctx.tiled_cartesian_valid_var == '_tc_2' diff --git a/tests/test_default_pipelines_shims.py b/tests/test_default_pipelines_shims.py index 82c75b5..3e5d414 100644 --- a/tests/test_default_pipelines_shims.py +++ b/tests/test_default_pipelines_shims.py @@ -95,14 +95,18 @@ def test_program_pipeline_names_unique_and_ordered(): def test_kernel_pipeline_names_unique_and_ordered(): + # PR-1 rename (per `docs/phase_decomposition_redesign.md` § 3.3.3): + # `lower_scan_pipeline` -> `lower_kernel_body`; `cuda_render` -> + # `render`. The shim names track the new target-parametric class + # names (`LowerKernelBodyShim` + `RenderShim`). names = [p.name for p in DEFAULT_KERNEL_PIPELINE] assert names == [ 'assign_handles', 'collect_view_specs', 'emit_view_decls', - 'lower_scan_pipeline', + 'lower_kernel_body', 'verify_renderability', - 'cuda_render', + 'render', ] assert len(set(names)) == len(names) diff --git a/tests/test_discipline_lower_ctx_pinned.py b/tests/test_discipline_lower_ctx_pinned.py index ec5956e..fd79077 100644 --- a/tests/test_discipline_lower_ctx_pinned.py +++ b/tests/test_discipline_lower_ctx_pinned.py @@ -9,12 +9,21 @@ old `LoweringCtx` had ~25 fields and that monolith is what the redesign exists to dismantle. The pin lives in CI so the new ctx stays small. + +PR-1 (per `docs/phase_decomposition_redesign.md` § 3.2.1) adds a +sibling test that the dialect-level `LoweringCtx` exposes a +`render_ctx` slot of `CudaRenderCtx`. The dialect ctx is the +transition surface every helper sees today; the render_ctx slot is +the canonical home for the 11 CUDA-render-specific identifier +fields. Post-redesign waves migrate every read site to access via +`ctx.render_ctx.`; PR-1 establishes the shape. ''' from __future__ import annotations import dataclasses +from srdatalog.ir.codegen.cuda.lower_ctx import CudaRenderCtx from srdatalog.ir.core.lower_ctx import LowerCtx @@ -26,3 +35,35 @@ def test_lower_ctx_field_count_pinned_at_five() -> None: f'adding a 6th requires a doc amendment + owner sign-off. Existing fields: ' f'{[f.name for f in fields]}' ) + + +def test_dialect_lowering_ctx_threads_render_ctx() -> None: + '''The dialect-level `LoweringCtx` (in + `srdatalog.ir.dialects.relation.sorted_array.lowerings`) carries + the CUDA-render-specific identifier fields via the `render_ctx` + slot of type `CudaRenderCtx`. PR-1 (per + `docs/phase_decomposition_redesign.md` § 3.2.1): this shape is + the foundation every subsequent target-abstraction PR builds on — + the 11 CUDA identifier fields belong on the target-private + `CudaRenderCtx`, not on a target-agnostic ctx. + ''' + from srdatalog.ir.dialects.relation.sorted_array.lowerings import LoweringCtx + + ctx = LoweringCtx() + assert isinstance(ctx.render_ctx, CudaRenderCtx), ( + f'LoweringCtx.render_ctx must be a CudaRenderCtx; got {type(ctx.render_ctx).__name__!r}' + ) + + +def test_cuda_render_ctx_field_count_pinned_at_eleven() -> None: + '''PR-1 pins the CUDA-render field count. New CUDA-identifier- + bearing state added to `CudaRenderCtx` MUST come with a doc + amendment (the post-redesign goal is per-op render contexts, not + one mega-ctx). Per `docs/phase_decomposition_redesign.md` § 3.2.1. + ''' + fields = dataclasses.fields(CudaRenderCtx) + assert len(fields) == 11, ( + f'CudaRenderCtx has {len(fields)} fields, must be exactly 11. ' + f'Adding a 12th requires a doc amendment per D10. Existing ' + f'fields: {[f.name for f in fields]}' + ) diff --git a/tests/test_plugin_group_split.py b/tests/test_plugin_group_split.py new file mode 100644 index 0000000..4bc282a --- /dev/null +++ b/tests/test_plugin_group_split.py @@ -0,0 +1,188 @@ +'''Plugin entry-point group split — PR-1 foundation tests. + +Per `docs/phase_decomposition_redesign.md` § 3.3.4 (Wave B2-1). + +PR-1 splits the single legacy `srdatalog.plugins` entry-point group +into two production groups: + + - `srdatalog.dialects` — data-side dialect contributions. + - `srdatalog.targets` — render-side target contributions. + +The legacy group is kept for one release cycle (with a +`DeprecationWarning`). External plugins (e.g. the jaccard demo) +discoverable under the legacy group continue to work. + +These tests pin the split contract: the discovery helper walks all +three groups; the deprecation warning fires once per process on +legacy-group use; the constants are exported. +''' + +from __future__ import annotations + +import importlib.metadata +import warnings +from typing import Any + +import pytest + +from srdatalog.ir.core.plugin import ( + DIALECT_ENTRY_POINT_GROUP, + ENTRY_POINT_GROUP, + TARGET_ENTRY_POINT_GROUP, + _discover_all_groups, + _discover_entry_points, +) + + +def test_entry_point_group_constants_are_distinct() -> None: + '''Three distinct group names per § 3.3.4.''' + assert DIALECT_ENTRY_POINT_GROUP == 'srdatalog.dialects' + assert TARGET_ENTRY_POINT_GROUP == 'srdatalog.targets' + assert ENTRY_POINT_GROUP == 'srdatalog.plugins' # legacy + assert len({DIALECT_ENTRY_POINT_GROUP, TARGET_ENTRY_POINT_GROUP, ENTRY_POINT_GROUP}) == 3 + + +def test_built_in_dialects_register_under_dialects_group() -> None: + '''The 6 built-in dialects (d2l, iir_cf, sorted_array, + parallel_data, parallel_atomic_ws, parallel_block_group) all live + in the new `srdatalog.dialects` group post-PR-1. The legacy group + has only external plugins (today: the jaccard demo). + ''' + eps = list(importlib.metadata.entry_points(group=DIALECT_ENTRY_POINT_GROUP)) + names = {ep.name for ep in eps} + for builtin in ( + 'd2l', + 'iir_cf', + 'sorted_array', + 'parallel_data', + 'parallel_atomic_ws', + 'parallel_block_group', + ): + assert builtin in names, ( + f'Built-in dialect {builtin!r} should be discoverable under ' + f'{DIALECT_ENTRY_POINT_GROUP!r}; found: {sorted(names)}' + ) + + +def test_targets_group_exists_but_may_be_empty() -> None: + '''The `srdatalog.targets` group exists post-PR-1 so external + packages can register render targets. No built-in entries today + (the CUDA target is still wired via the dialect registrations). + ''' + eps = list(importlib.metadata.entry_points(group=TARGET_ENTRY_POINT_GROUP)) + # No assertion on count — just that the group is queryable. + assert isinstance(eps, list) + + +def test_discover_all_groups_returns_union() -> None: + '''The `_discover_all_groups` helper walks all three groups and + returns a deduplicated list of entry points.''' + all_eps = _discover_all_groups() + dialect_eps = list(importlib.metadata.entry_points(group=DIALECT_ENTRY_POINT_GROUP)) + all_names = {ep.name for ep in all_eps} + dialect_names = {ep.name for ep in dialect_eps} + # Every dialect should appear in the union. + assert dialect_names.issubset(all_names), ( + f'dialect entries missing from _discover_all_groups(): {sorted(dialect_names - all_names)}' + ) + + +def test_legacy_group_walk_emits_deprecation_warning() -> None: + '''Walking the legacy `srdatalog.plugins` group emits a + `DeprecationWarning` exactly once per process. The check is + smoke-test only: we re-arm the warning by resetting the flag, + call the walker, and confirm the warning fires. + ''' + # Reset the per-process flag so the warning re-arms for this test. + import srdatalog.ir.core.plugin as plugin_mod + + plugin_mod._LEGACY_GROUP_WARNED = False + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + # Only emits the warning if the legacy group has entries. + eps = _discover_entry_points(ENTRY_POINT_GROUP) + if eps: + assert any( + issubclass(w.category, DeprecationWarning) and 'srdatalog.plugins' in str(w.message) + for w in caught + ), ( + f'expected DeprecationWarning naming srdatalog.plugins; ' + f'got: {[(w.category.__name__, str(w.message)) for w in caught]}' + ) + + +def test_legacy_group_warning_fires_only_once_per_process() -> None: + '''After the first walk, subsequent walks DO NOT re-warn.''' + import srdatalog.ir.core.plugin as plugin_mod + + plugin_mod._LEGACY_GROUP_WARNED = False + # Prime the warning (first walk). + with warnings.catch_warnings(record=True): + warnings.simplefilter('always') + _discover_entry_points(ENTRY_POINT_GROUP) + + # Second walk: no new DeprecationWarning. + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + _discover_entry_points(ENTRY_POINT_GROUP) + deprecation = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert not deprecation, ( + f'unexpected DeprecationWarning on second walk: {[str(w.message) for w in deprecation]}' + ) + + +def test_discover_entry_points_on_dialects_group_emits_no_warning() -> None: + '''Walking the new groups does NOT trigger the legacy warning.''' + import srdatalog.ir.core.plugin as plugin_mod + + plugin_mod._LEGACY_GROUP_WARNED = False + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + _discover_entry_points(DIALECT_ENTRY_POINT_GROUP) + _discover_entry_points(TARGET_ENTRY_POINT_GROUP) + deprecation = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert not deprecation, ( + f'new groups must not emit DeprecationWarning; got: {[str(w.message) for w in deprecation]}' + ) + + +# ----------------------------------------------------------------------------- +# with_default_plugins integration: synthetic plugin registrations +# ----------------------------------------------------------------------------- +# +# These tests use a synthetic EntryPoint-shaped fixture so they don't +# depend on the local install's actual entry points (which can vary +# across environments and would couple this test to the jaccard demo). + + +class _FakeEntryPoint: + '''Test-only stand-in for `importlib.metadata.EntryPoint`.''' + + def __init__(self, name: str, register_fn: Any) -> None: + self.name = name + self._register_fn = register_fn + + def load(self) -> Any: + return self._register_fn + + +def test_compiler_register_plugin_accepts_entry_point_shape_from_targets_group( + monkeypatch: pytest.MonkeyPatch, +) -> None: + '''Synthetic test: register a fake target plugin via the + `srdatalog.targets` group's discovery path. The Compiler should + load it just like a dialect plugin.''' + from srdatalog.ir.core.dialect import Compiler + + load_calls: list[Any] = [] + + def _fake_register(compiler: Any) -> None: + load_calls.append(compiler) + + fake_ep = _FakeEntryPoint('my_cpu_tbb_target', _fake_register) + + c = Compiler() + c.register_plugin(fake_ep) + assert load_calls == [c] + assert 'my_cpu_tbb_target' in c._plugins_loaded diff --git a/tests/test_verify_renderability.py b/tests/test_verify_renderability.py index 91b187b..b728d0a 100644 --- a/tests/test_verify_renderability.py +++ b/tests/test_verify_renderability.py @@ -250,12 +250,17 @@ def _identity_rewrite(op, _compiler): def test_verify_renderability_shim_is_between_lower_scan_and_cuda_render(): - '''Pipeline shape contract: the gate runs AFTER LowerScanPipelineShim - (which produces IIR) and BEFORE CudaRenderShim (which consumes it).''' + '''Pipeline shape contract: the gate runs AFTER LowerKernelBodyShim + (which produces IIR) and BEFORE RenderShim (which consumes it). + + PR-1 rename (per `docs/phase_decomposition_redesign.md` § 3.3.3): + `lower_scan_pipeline` -> `lower_kernel_body`; `cuda_render` -> + `render`. + ''' names = [p.name for p in DEFAULT_KERNEL_PIPELINE] - i_lower = names.index('lower_scan_pipeline') + i_lower = names.index('lower_kernel_body') i_verify = names.index('verify_renderability') - i_render = names.index('cuda_render') + i_render = names.index('render') assert i_lower < i_verify < i_render @@ -322,12 +327,12 @@ def _fn(state: KernelCtx, _compiler): ep = _first_ep(prog_state.mir_program) # Build a pipeline that injects the unrenderable op AFTER - # LowerScanPipelineShim but BEFORE the gate. The gate must catch it. + # LowerKernelBodyShim but BEFORE the gate. The gate must catch it. inject = _InjectUnrenderable() patched: list = [] for p in DEFAULT_KERNEL_PIPELINE: patched.append(p) - if p.name == 'lower_scan_pipeline': + if p.name == 'lower_kernel_body': patched.append(inject) with pytest.raises(UnrenderableOpError) as excinfo: