Skip to content

Memlet access policies - #2593

Open
tbennun wants to merge 14 commits into
mainfrom
memlet-schedules
Open

tbennun wants to merge 14 commits into
mainfrom
memlet-schedules

Conversation

@tbennun

@tbennun tbennun commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator

A memlet serves two purposes at the same time. It produces an address, and it moves bytes. Currently, the default is copy-on-access, recomputing the offset at every access. This PR adds Memlet.access_policy, which allows to modify that. At code generation, the access policy is lowered to LibraryNodes (just like the copy node today) and can now represent concepts such as pointer incrementation (cursors), buffer resource descriptors, TMA-generated reads, or general address-generation/iterators/accessors.

tbennun and others added 4 commits September 14, 2026 14:25
…ursors)

Add `Memlet.schedule`, a `MemletSchedule` record describing how a leaf memlet's
addressing is realized in generated code. Schedules are descriptive only and are
lowered to ordinary SDFG constructs in the code-generation window; code
generation has no schedule-specific paths. Two kinds:

- `CopyOnAccess` (default, not serialized): the behavior every SDFG had so far,
  full offset at every access, movement at the access / via CopyLibraryNode.
- `LoopCursor`: the offset is affine in an enclosing LoopRegion's induction
  variable (step, invariant base, lane part, cursor type, optional share_key).

Passes (`dace/transformation/passes/memlet_schedules.py`):
- `ScheduleLoopCursors` (analysis): attaches `LoopCursor` records to affine leaf
  memlets (scope gpu|all, arrays, cursor_type, overwrite).
- `LowerMemletSchedules` (codegen window, run automatically by `generate_code`
  after InsertExplicitCopies and before expansion): dispatches to each kind's
  `lower`. Loop cursors become one loop-carried integer symbol per cursor class
  (assigned in the loop's init statement, advanced in its update statement), a
  flat `Reference` per array set once at SDFG entry, and the memlets are
  rewritten to `flat[cursor + immediate]`; non-contiguous reads use a window
  `Reference` set per iteration with the memlet's original shape. Classes are
  anchored at the lowest member offset (or a stencil's centre), nest-invariant
  symbolic terms are immediates, loop nests chain cursors (one add per level),
  successive inner nests share the outer loop's cursors, names are
  deterministic, stale schedules are dropped with a warning, idempotent.

Generic infrastructure changes: LoopRegion init/update code blocks may hold
several assignments (emitted in the for header, counted as defined symbols,
declared by the frame; `loop_analysis.get_assignments`); Reference `set` casts
to the reference's pointer type so read-only (const) nested-SDFG inputs can be
referenced; InsertExplicitCopies carries a schedule onto the lifted operand.

Docs: IR paragraph on memlet schedules, API index entries. Tests: default
schedule, analysis/lowering/serialization/staleness/idempotence, GPU code
generation, loop-nest chaining, window references, and two CPU stress programs
(3-D stencil on a padded window with a nonzero offset; K loop with two
successive I/J nests), compiled and checked numerically.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…generic leaves)

- MemletSchedule kinds are plain dataclasses; JSON (de)serialization is generic
  in the base class, driven by field metadata (kind='symbolic' / 'dtype').
- `cursor_type` is an Optional[dtypes.typeclass] (None = automatic int32/int64)
  on LoopCursor and a TypeClassProperty on ScheduleLoopCursors; the cursor
  symbol is registered with that type, so any integer typeclass works.
- Leaf memlets are found structurally as the innermost edge of a memlet path
  (the endpoint away from the data's access node is not a map entry/exit),
  instead of a list of node types; this also covers access nodes inside a map
  fed through the map entry (new test). Path rewriting adds the new memlet
  path before removing the old one, so scope nodes and connectors are never
  orphaned in between.
- GPU scope filter uses the existing dace.sdfg.scope.is_devicelevel_gpu.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ization round trip)

Rewritten memlets used a bare sympy Symbol for the cursor, which serializes as
"$name" on the first save and as "symbol(name, dtype=...)" after a round trip,
so the save/load/save check of DACE_testing_serialization (the CI autoopt job)
failed. The cursor table now creates the typed DaCe symbol once, when it
registers the cursor, and returns the object; memlet subsets and chained init
expressions use it directly instead of re-creating symbols from the name. Two
tests compared subsets textually and depended on term order; they now compare
symbolically.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@FlorianDeconinck

FlorianDeconinck commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Ongoing testing on GEOS' FV3 Dynamics core routine of D_SW.

Testing the LoopCursor schedule for memlets (via ScheduleLoopCursors)

In the case of single K (vertical) loop and many J I (horizontal) dual-loop where code within the kernel is heavily dependent on if condition to restrict computation further

  • 35% speed up while keeping validation

Overall this looks like it's clearly superior for serial CPU code

@FlorianDeconinck

Copy link
Copy Markdown
Contributor

Ongoing testing on GEOS' FV3 Dynamics core routine of D_SW.

Testing the LoopCursor schedule for memlets (via ScheduleLoopCursors)

In the case of single K (vertical) loop and many J I (horizontal) dual-loop where code within the kernel is heavily dependent on if condition to restrict computation further

  • 35% speed up while keeping validation

Overall this looks like it's clearly superior for serial CPU code

After more thorough look, ForLoop already to direct referencing per For axis, which seems to lead to a similar speed up. The transform remains valid (as in it validates numerics) but it adds a large amount of pointer/integer that bloats the asm

@tbennun tbennun changed the title Memlet schedules Memlet access policies Sep 30, 2026
"Access policy" better describes what the record is: how a leaf memlet's
addressing is realized, not an execution schedule. Renames the module
(dace.sdfg.memlet_access_policy), the base class (MemletAccessPolicy), the
Memlet property (Memlet.access_policy), the passes module and passes
(AssignLoopCursors, LowerMemletAccessPolicies), tests and documentation.

The access_policy and wcr_nonatomic properties are now in the
"Code Generation" category.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tbennun

tbennun commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

cscs-ci run

@tbennun
tbennun marked this pull request as ready for review September 30, 2026 19:28
The test rebuilt the cursor symbol from its name, which yields a
default-typed symbol, while the lowering declares the cursor with its
actual dtype (int64 when the extent is not provably 31-bit). The
comparison only held while a symbol's dtype was not part of its
identity; compare against the declared symbols instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@tbennun tbennun added this to the 2.0 milestone Oct 6, 2026
ThrudPrimrose and others added 8 commits October 7, 2026 20:10
Remove the yapf pre-commit hook, .style.yapf and the yapf pin in the linting group.
Add the ruff-format hook next to ruff-check. line-length and quote-style carry over the yapf settings; notebooks stay unformatted as before.
ruff honours fmt: off/on at statement level only, so the parametrize decorator uses a trailing fmt: skip.
Files the branch only reformatted take main's version. The branch's changes to
control_flow.py, cpu.py, framecode.py, memlet.py, state.py and
insert_explicit_copies.py are reapplied on top of main's versions.

No-view nested SDFG contract: a nested SDFG connector now receives the whole
container (an equivalent descriptor, passed without an offset), so its binding
memlet is no longer a cursor leaf. Rerouting it to a window reference broke
descriptor equivalence. AssignLoopCursors/LowerMemletAccessPolicies now skip
nested SDFG bindings; the memlets inside the nested SDFG remain leaves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A nested SDFG receives the whole container, so a memlet path continues
through the chain of nested SDFGs to the leaf memlets inside them. Loop
cursors are now assigned and lowered on these global paths: a leaf memlet
in a nested SDFG may be addressed relative to a loop of an enclosing SDFG.
Its offset is restated in the loop SDFG's symbols through the symbol
mappings (symbols defined inside a nested SDFG become per-iteration
atoms), the cursor is created on the loop and passed down as a symbol,
and the immediate is restated in the leaf SDFG's symbols, adding symbol
mapping entries where a nested SDFG does not receive a symbol yet.

Fixes the tests that failed with simplification disabled, where map
bodies stay nested SDFGs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in the squashed ruff lint migration (#2671) and later fixes.
Conflicts were resolved by normalizing base and branch with main's pinned ruff
(0.15.5, check --fix + format) before a three-way merge. In the CPU codegen for
setting a reference, the branch's cast to the reference's pointer type is kept
over main's f-string rewrite of the uncast line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Applies the pinned ruff 0.15.5 safe fixes and hand-fixes a Union type alias
(UP007) and a set comprehension (C416).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@DJAntivenom

Copy link
Copy Markdown

Just as an FYI: As part of my master's thesis I'm working on TMA support and I will be adding the corresponding access policy :)

return expr


def _statements(block) -> list:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should keep type hints always, block: CodeBlock and return type would be list[CodeBlock] ?

Comment thread doc/sdfg/ir.rst
for (i = 0, __dace_cur_A_for_1 = 3, __dace_cur_B_for_1 = 0; i < N;
i = i + 1, __dace_cur_A_for_1 = __dace_cur_A_for_1 + 16, __dace_cur_B_for_1 = __dace_cur_B_for_1 + 1) {
__dace_flat_B[__dace_cur_B_for_1] = __dace_flat_A[__dace_cur_A_for_1] + 2 * __dace_flat_A[(__dace_cur_A_for_1 + 4)];
}

@ThrudPrimrose ThrudPrimrose Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think having both versions' generated code (copy-on-access and ptr-increment) would nice for documentation I think


_SCOPE_NODES = (nodes.EntryNode, nodes.ExitNode)
_GPU_KERNEL_SCHEDULES = (dtypes.ScheduleType.GPU_Device, dtypes.ScheduleType.GPU_Persistent)
_LANE_SCHEDULES = (dtypes.ScheduleType.GPU_ThreadBlock, dtypes.ScheduleType.GPU_ThreadBlock_Dynamic)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is the 3rd or 4th PR where we need to move around the horrible default GPU_SCHEDULES type which is just GPU_ThreadBlock, which is massively confusing. (Including new gpu codegen and new gpu offloading pass).

I think we should update the dtypes to have GPU_KERNEL_SCHEDULES in dtypes.py, and to replace GPU_SCHEDULES with a better name, such as GPU_LANE_SCHEDULES.

To avoid an accidental conflict I propose not using GPU_SCHEDULES but GPU_KERNEL_SCHEDULES and GPU_LANE_SCHEDULES

from dace.transformation import transformation
from dace.transformation.passes.analysis import loop_analysis

_SCOPE_NODES = (nodes.EntryNode, nodes.ExitNode)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice addition to dtypes.py or a similar file.

"""Number of elements of ``subset`` if it is a contiguous run in memory (at most one dimension of size > 1,
with unit step and unit stride), otherwise ``None``."""
if not isinstance(subset, Range):
return None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we removed Indices, this function should only receive Subsets at this stage?

d = wide[0]
if sp.sympify(subset.ranges[d][2]) != 1 or sp.sympify(desc.strides[d]) != 1:
return None
return sp.sympify(sizes[d])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we achieve the same thing by querying Subset.is_contiguous_subset(array) in a cond check, and if true return subset.num_elements()?

Seem equivalent to me

*within* an iteration and therefore belong to the per-access immediate, never to the cursor."""
result: set[str] = set()
for region in loop.all_control_flow_regions(recursive=False):
if isinstance(region, LoopRegion) and region is not loop and region.loop_variable:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we include maps that are sequential here?

def lane_symbols(state: SDFGState, node: nodes.Node) -> set[str]:
"""Parameters of enclosing maps that code generation assigns to threads/lanes (GPU_ThreadBlock/GPU_Warp
maps, or a map directly nested in a GPU kernel map -- the ``[0:W, 0:64]`` wave/lane convention)."""
sdict = state.scope_dict()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this handle the nested GPU device maps?

Because from what I have seen something like this happens:

for i in [0:N:1]: at GPU Device
  for j1 in [0:M1:1]: at GPU Device
     body1
  for j2 in [0:M2:1]: at GPU Device
     body2

Then codegen implicitly generates a map that is like:

for i, j in [0:N:1, 0:max(M1,M2):1]: at GPU Device
  if j <= M1:
     body1
  if j <= M2:
     body2

So I think while this check works for cases having tblock map and not, it will have issues when we have nested gpu device maps?

To mention: #2534 (#2534 ), this pass always fixes nested GPU device maps and makes them into a single gpu device map that is easier to reason about, so maybe we merge that PR, and then ensure there are no nested gpu device maps when we run this?

Also if we have > 3 dimensions then then all upper dimensions are flattened into a z index, and then this loop should be ok.

@@ -0,0 +1,1060 @@
# Copyright 2019-2026 ETH Zurich and the DaCe authors. All rights reserved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although I do not see an issue in the implementation (and well let's be honest, if there is a bug here it will come out really fast and we can then fix it), I think the organization of the files is very hard for me.

I would propose something like this, especially because we expect to have more access policies implemented over time:

dace/
├── sdfg/
│   ├── memlet_access_policy.py          # MemletAccessPolicy base class
│   └── memlet_access_policies/
│       ├── __init__.py
│       ├── copy_on_access.py            # CopyOnAccess
│       └── loop_cursor.py                   # LoopCursor
│
└── transformation/
    └── passes/
        ├── memlet_schedules/
        │   ├── __init__.py
        │   ├── helpers.py                          # Global memlet-path utilities
        │   └── assign_loop_cursors.py   # AssignLoopCursors
        │
        └── memlet_access_policy_lowering.py # LowerMemletAccessPolicies

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a note current structure is like this:

dace/
├── memlet.py
│   └── Memlet.access_policy
│
├── sdfg/
│   └── memlet_access_policy.py (can be split)
│       ├── MemletAccessPolicy
│       ├── CopyOnAccess
│       └── LoopCursor
│
│
└── transformation/
    └── passes/
        ├── memlet_access_policies.py (Imho feels too long)
        │   ├── AssignLoopCursors
        │   ├── LowerMemletAccessPolicies
        │   └── Loop-cursor lowering helpers
        │
        │
        └── __init__.py
            └── Exports both access-policy passes

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants