Skip to content

Commit 7f78b45

Browse files
authored
lint: refuse an array element passed to a device routine that runs a seq loop (#1816)
1 parent 511cda5 commit 7f78b45

3 files changed

Lines changed: 256 additions & 0 deletions

File tree

docs/documentation/contributing.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ Both human reviewers and AI code reviewers reference this section.
181181
- **`stp` vs `wp` mixing:** In mixed-precision mode, `stp` (storage) may be half-precision while `wp` (working) is double. Conversions between them must be intentional, especially in MPI pack/unpack and RHS accumulation.
182182
- **No double-precision intrinsics:** `dsqrt`, `dexp`, `dlog`, `dble`, `dabs`, `real(8)`, `real(4)` are forbidden. Use generic intrinsics with `wp` kind.
183183
- **MPI type matching:** `mpi_p` must match `wp`; `mpi_io_p` must match `stp`. Mismatches corrupt communicated data.
184+
- **Scalars into device routines that loop:** a `GPU_ROUTINE` containing any `GPU_LOOP` (itself or through what it calls) must be called with scalars, never an array element (`q%%sf(j,k,l)`, `alpha(i)`). Copy the element to a local first and receive results into a local. Cray OpenACC 19 to 21 miscompiles the pair silently at every routine level, OpenMP offload does not ([#1815](https://github.com/MFlowCode/MFC/issues/1815)); the linter enforces it inside kernels and device routines.
184185

185186
### Memory and Allocation
186187

toolchain/mfc/lint_source.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,139 @@ def check_checker_input_constraints(repo_root: Path) -> list[str]:
554554
return errors
555555

556556

557+
# Intrinsics and MFC function prefixes that make `name(args)` a value, not an array element.
558+
_VALUE_CALL_NAMES = re.compile(r"^(?:f_\w+|real|int|nint|abs|max|min|sqrt|exp|log|sign|merge|mod|huge|tiny|epsilon|size|lbound|ubound|present|allocated|associated)$", re.IGNORECASE)
559+
# `a(i)`, `q(i)%sf(j, k, l)`, `s%vf(1)%sf(k, l, m)`: an element reference and nothing else.
560+
_ELEMENT_ARG = re.compile(r"^([A-Za-z_]\w*)(?:\([^()]*\))?(?:%\w+(?:\([^()]*\))?)*\([^()]*\)$")
561+
_PROCEDURE_DECL = re.compile(r"^(?:(?:impure|pure|elemental|recursive|module|non_recursive)\s+)*(?:subroutine|function)\s+(\w+)", re.IGNORECASE)
562+
_PROCEDURE_END = re.compile(r"^end\s+(?:subroutine|function)\b", re.IGNORECASE)
563+
564+
565+
def _split_top_level(text: str) -> list[str]:
566+
"""Split an argument list at the commas outside parentheses, array constructors and strings."""
567+
parts, depth, quote, cur = [], 0, "", []
568+
for ch in text:
569+
if quote:
570+
if ch == quote:
571+
quote = ""
572+
elif ch in "'\"":
573+
quote = ch
574+
elif ch in "([":
575+
depth += 1
576+
elif ch in ")]":
577+
depth -= 1
578+
elif ch == "," and depth == 0:
579+
parts.append("".join(cur).strip())
580+
cur = []
581+
continue
582+
cur.append(ch)
583+
parts.append("".join(cur).strip())
584+
return parts
585+
586+
587+
def _statements(lines: list[str]):
588+
"""Yield (first line number, statement) with Fortran continuation lines joined."""
589+
buf, start = [], 0
590+
for i, line in enumerate(lines, 1):
591+
stripped = line.split("!", 1)[0].strip() if not line.strip().startswith("!") else ""
592+
if not stripped:
593+
continue
594+
if not buf:
595+
start = i
596+
buf.append(stripped.lstrip("&").rstrip("&").strip())
597+
if not stripped.endswith("&"):
598+
yield start, " ".join(buf)
599+
buf = []
600+
601+
602+
def _procedures(lines: list[str]):
603+
"""Yield (name, own line numbers, is device routine, has seq loop) per procedure.
604+
605+
Contained procedures nest; every line, directive and call belongs to the innermost one.
606+
"""
607+
stack = [] # [name, own lines, device, looped]
608+
for i, line in enumerate(lines, 1):
609+
stripped = line.strip()
610+
m = _PROCEDURE_DECL.match(stripped)
611+
if m and not stripped.lower().startswith("end"):
612+
stack.append([m.group(1), set(), False, False])
613+
if not stack:
614+
continue
615+
stack[-1][1].add(i)
616+
if "GPU_ROUTINE(" in stripped:
617+
stack[-1][2] = True
618+
elif "GPU_LOOP(" in stripped:
619+
stack[-1][3] = True
620+
elif _PROCEDURE_END.match(stripped):
621+
name, own, device, looped = stack.pop()
622+
yield name, own, device, looped
623+
624+
625+
def check_device_routine_element_args(repo_root: Path) -> list[str]:
626+
"""Flag an array element passed to a device routine that runs a seq loop.
627+
628+
CCE OpenACC (19.0.0 through 21.0.2, -O2; OpenMP offload is correct) miscompiles the pair: a
629+
routine containing any `GPU_LOOP`, called with an array element as an actual argument, reads
630+
the element as garbage and never writes it back. Either alone is fine, every `routine` level
631+
is affected, and the loop counts when it sits in anything the routine calls. Copy the element
632+
to a scalar before the call and receive results into a scalar. See
633+
.claude/rules/common-pitfalls.md and sbryngelson/compiler-bugs cce/acc-routine-element-by-reference.
634+
"""
635+
src_dir = repo_root / SRC_DIR
636+
files = {src: src.read_text(encoding="utf-8").splitlines() for src in _fortran_fpp_files(src_dir)}
637+
638+
device, looped, bodies = set(), set(), {}
639+
for lines in files.values():
640+
for name, own, is_device, has_loop in _procedures(lines):
641+
key = name.lower()
642+
if is_device:
643+
device.add(key)
644+
if has_loop:
645+
looped.add(key)
646+
bodies[key] = [stmt for n, stmt in _statements(lines) if n in own]
647+
if not device:
648+
return []
649+
# Any reference to a device routine by name, `call s_x(` or `y = f_x(`, is a call site.
650+
site_re = re.compile(r"(?<![\w%])(" + "|".join(map(re.escape, sorted(device))) + r")\s*\(", re.IGNORECASE)
651+
callees = {k: {m.group(1).lower() for stmt in v for m in site_re.finditer(stmt)} - {k} for k, v in bodies.items()}
652+
# A device routine that calls a looped routine carries the loop once CCE inlines it.
653+
tainted = looped & device
654+
while True:
655+
more = {r for r in device - tainted if callees.get(r, set()) & tainted}
656+
if not more:
657+
break
658+
tainted |= more
659+
660+
errors: list[str] = []
661+
for src, lines in files.items():
662+
rel = src.relative_to(repo_root)
663+
device_lines = set()
664+
for name, own, is_device, _ in _procedures(lines):
665+
if is_device:
666+
device_lines |= own
667+
kernel_depth = 0
668+
for line_no, stmt in _statements(lines):
669+
if "END_GPU_PARALLEL_LOOP" in stmt:
670+
kernel_depth = max(0, kernel_depth - 1)
671+
elif "GPU_PARALLEL_LOOP(" in stmt:
672+
kernel_depth += 1
673+
if kernel_depth == 0 and line_no not in device_lines:
674+
continue # host code passes elements freely
675+
for m in site_re.finditer(stmt):
676+
name = m.group(1).lower()
677+
if name not in tainted:
678+
continue
679+
depth, j = 1, m.end()
680+
while j < len(stmt) and depth:
681+
depth += {"(": 1, ")": -1}.get(stmt[j], 0)
682+
j += 1
683+
for arg in _split_top_level(stmt[m.end() : j - 1]):
684+
e = _ELEMENT_ARG.match(arg)
685+
if e and ":" not in arg and not _VALUE_CALL_NAMES.match(e.group(1)):
686+
errors.append(f" {rel}:{line_no} `{arg}` into `{name}` (a device routine with a seq loop): pass a scalar, see common-pitfalls.md")
687+
return errors
688+
689+
557690
def check_cluster_menu_slugs(repo_root: Path) -> list[str]:
558691
"""Keep the ``./mfc.sh load`` cluster menu in sync with toolchain/modules.
559692
@@ -614,6 +747,7 @@ def main():
614747
all_errors.extend(check_manual_registry_bcasts(repo_root))
615748
all_errors.extend(check_checker_input_constraints(repo_root))
616749
all_errors.extend(check_cluster_menu_slugs(repo_root))
750+
all_errors.extend(check_device_routine_element_args(repo_root))
617751

618752
if all_errors:
619753
print("Source lint failed:")

toolchain/mfc/test_lint_source.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from mfc.lint_source import (
44
_extract_bcast_roots,
5+
check_device_routine_element_args,
56
check_double_precision,
67
check_integer_wp,
78
check_manual_registry_bcasts,
@@ -148,3 +149,123 @@ def test_manual_residue_is_clean(tmp_path):
148149
_write_proxy(tmp_path, "simulation", body)
149150

150151
assert check_manual_registry_bcasts(tmp_path) == []
152+
153+
154+
_LOOPED = """ subroutine s_curve(rho, i, p)
155+
$:GPU_ROUTINE(parallelism='[seq]')
156+
real(wp), intent(in) :: rho
157+
integer, intent(in) :: i
158+
real(wp), intent(out) :: p
159+
integer :: it
160+
$:GPU_LOOP(parallelism='[seq]')
161+
do it = 1, 8
162+
p = p + rho
163+
end do
164+
end subroutine s_curve
165+
subroutine s_wrap(rho, i, p)
166+
$:GPU_ROUTINE(parallelism='[seq]')
167+
real(wp), intent(in) :: rho
168+
integer, intent(in) :: i
169+
real(wp), intent(out) :: p
170+
call s_curve(rho, i, p)
171+
end subroutine s_wrap
172+
subroutine s_plain(rho, i, p)
173+
$:GPU_ROUTINE(parallelism='[seq]')
174+
real(wp), intent(in) :: rho
175+
integer, intent(in) :: i
176+
real(wp), intent(out) :: p
177+
p = rho
178+
end subroutine s_plain
179+
"""
180+
181+
182+
def _KERNEL(body: str) -> str:
183+
return " $:GPU_PARALLEL_LOOP(collapse=3)\n " + body + "\n $:END_GPU_PARALLEL_LOOP()\n"
184+
185+
186+
def test_host_call_sites_are_not_flagged(tmp_path):
187+
_write_src(tmp_path, "simulation/m_x.fpp", _LOOPED + " call s_curve(q(1)%sf(j, k, l), 1, out(k, l, q))\n")
188+
assert check_device_routine_element_args(tmp_path) == []
189+
190+
191+
def test_element_into_looped_device_routine_is_flagged(tmp_path):
192+
_write_src(tmp_path, "simulation/m_x.fpp", _LOOPED + _KERNEL("call s_curve(q(1)%sf(j, k, l), 1, out(k, l, q))"))
193+
errors = check_device_routine_element_args(tmp_path)
194+
assert len(errors) == 2
195+
assert "q(1)%sf(j, k, l)" in errors[0] and "out(k, l, q)" in errors[1]
196+
197+
198+
def test_element_reaches_the_loop_through_a_caller(tmp_path):
199+
_write_src(tmp_path, "simulation/m_x.fpp", _LOOPED + _KERNEL("call s_wrap(pres, 1, blkmod(k, &\n & l, q))"))
200+
errors = check_device_routine_element_args(tmp_path)
201+
assert len(errors) == 1 and "s_wrap" in errors[0]
202+
203+
204+
def test_scalars_expressions_and_loopless_routines_pass(tmp_path):
205+
_write_src(
206+
tmp_path,
207+
"simulation/m_x.fpp",
208+
_LOOPED
209+
+ _KERNEL("call s_curve(alpha_rho(i)/max(alpha(i), sgm_eps), i, p_i)\n call s_plain(q(1)%sf(j, k, l), 1, out(k, l, q))\n call s_curve(real(q(1)%sf(j, k, l), wp), 1, p_i)"),
210+
)
211+
assert check_device_routine_element_args(tmp_path) == []
212+
213+
214+
def test_loop_inside_a_device_function_counts_and_propagates(tmp_path):
215+
src = """ function f_looped(x, i) result(y)
216+
$:GPU_ROUTINE(function_name='f_looped', parallelism='[seq]')
217+
real(wp), intent(in) :: x
218+
integer, intent(in) :: i
219+
real(wp) :: y
220+
integer :: it
221+
y = x
222+
$:GPU_LOOP(parallelism='[seq]')
223+
do it = 1, 8
224+
y = y + 1._wp
225+
end do
226+
end function f_looped
227+
subroutine s_via_function(x, i, y)
228+
$:GPU_ROUTINE(parallelism='[seq]')
229+
real(wp), intent(in) :: x
230+
integer, intent(in) :: i
231+
real(wp), intent(out) :: y
232+
y = f_looped(x, i)
233+
end subroutine s_via_function
234+
"""
235+
calls = "out(k, l, q) = f_looped(q(1)%sf(k, l, q), 1)\n call s_via_function(q(1)%sf(k, l, q), 1, tmp)\n tmp = f_looped(p_scalar, 1)"
236+
_write_src(tmp_path, "simulation/m_x.fpp", src + _KERNEL(calls))
237+
errors = check_device_routine_element_args(tmp_path)
238+
assert [e.split("`")[3] for e in errors] == ["f_looped", "s_via_function"]
239+
240+
241+
def test_constructor_commas_and_unprefixed_functions_and_contained_scoping(tmp_path):
242+
src = """ function g_looped(x) result(y)
243+
$:GPU_ROUTINE(function_name='g_looped', parallelism='[seq]')
244+
real(wp), intent(in) :: x
245+
real(wp) :: y
246+
integer :: it
247+
y = x
248+
$:GPU_LOOP(parallelism='[seq]')
249+
do it = 1, 8
250+
y = y + 1._wp
251+
end do
252+
end function g_looped
253+
subroutine s_outer(a, b)
254+
real(wp), intent(in) :: a
255+
real(wp), intent(out) :: b
256+
b = a
257+
contains
258+
subroutine s_inner(x, y)
259+
$:GPU_ROUTINE(parallelism='[seq]')
260+
real(wp), intent(in) :: x
261+
real(wp), intent(out) :: y
262+
y = g_looped(x)
263+
end subroutine s_inner
264+
end subroutine s_outer
265+
"""
266+
calls = "b = g_looped(q(1)%sf(k, l, q))\\n c = g_looped(sum([v(1), v(2)]))\\n call s_outer(q(1)%sf(k, l, q), tmp)"
267+
_write_src(tmp_path, "simulation/m_x.fpp", src + _KERNEL(calls))
268+
errors = check_device_routine_element_args(tmp_path)
269+
# the unprefixed function is found by name; the array constructor is not split into a fake element;
270+
# s_outer is not tainted by the loop that only its contained s_inner reaches (and is not a device routine)
271+
assert [e.split("`")[1] for e in errors] == ["q(1)%sf(k, l, q)"]

0 commit comments

Comments
 (0)