PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
 
Loading...
Searching...
No Matches
audit_profiling_coverage.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Enforce that every eligible C function in src/ is profiled and closes its timer on every return.
3
4Rules (docs/pages/09_Monitor_Reference.md, profiling):
5- every function defined in src/*.c that is not `static inline` and is not on the exclusion
6 list below begins exactly one timer, PROFILE_FUNCTION_BEGIN or PROFILE_HELPER_BEGIN;
7- every explicit `return` and `PetscFunctionReturn`/`PetscFunctionReturnVoid` after the timer's
8 BEGIN is immediately preceded by PROFILE_FUNCTION_END or PROFILE_HELPER_END, and a function whose
9 body can fall off its end ends with one; error returns inside SETERRQ/PetscCall/CHKERRQ are
10 not explicit returns and are exempt;
11- no file defines or reads `__FUNCT__`: timers, logging and PETSc's error traces all take the
12 name from `__func__`.
13
14A missed END leaves the function's PETSc event open, after which its calls are no longer recorded.
15"""
16
17from __future__ import annotations
18
19import re
20import sys
21from pathlib import Path
22
23REPO_ROOT = Path(__file__).resolve().parents[2]
24SOURCES = sorted((REPO_ROOT / "src").glob("*.c"))
25
26#: Functions deliberately untimed, with the reason. Keep reasons specific.
27EXCLUDED = {
28 "main": "process entry; runs before PetscInitialize and after PetscFinalize",
29 "RuntimeShutdownSignalHandler": "asynchronous signal handler; PETSc calls are not async-signal-safe",
30 "get_log_level": "read by every logging macro; a timer would tax every log site",
31 "is_function_allowed": "read by every logging macro that passes the level check",
32 "set_allowed_functions": "configures logging before the profiler exists",
33 "_ProfilingRegister": "the profiler itself",
34 "_ProfilingEnsureStarted": "the profiler itself",
35 "_ProfilingNameListed": "the profiler itself",
36 "_ProfilingHelperEnabled": "the profiler itself",
37 "_ProfilingIsPicurvClass": "the profiler itself",
38 "_ProfilingEventTotals": "the profiler itself",
39 "_ProfilingSnapshotStep": "the profiler itself",
40 "_ProfilingReduceSection": "the profiler itself",
41 "_CompareNames": "qsort comparator inside the profiler's report",
42 "_CompareProfiledFunctionRanks": "qsort comparator inside the profiler's report",
43 "ProfilingInitialize": "the profiler itself",
44 "ProfilingResetTimestepCounters": "the profiler itself",
45 "ProfilingLogTimestepSummary": "the profiler itself",
46 "ProfilingFinalize": "the profiler itself; it reads every event's totals",
47}
48
49#: Whole files deliberately untimed: dependency-free leaf modules that include only petscsys.h.
50EXCLUDED_FILES = {
51 "src/checksum.c": "SHA-256 and digest byte helpers; a leaf module with no PICurv headers",
52 "src/statistics_moments.c": "per-sample moment arithmetic called per cell per step; a leaf module with no PICurv headers",
53}
54
55BEGIN_RE = re.compile(r"\bPROFILE_(?:FUNCTION|HELPER)_BEGIN\b")
56END_TAIL_RE = re.compile(r"PROFILE_(?:FUNCTION|HELPER)_END\s*;\s*$")
57RETURN_RE = re.compile(r"\b(?:return\b|PetscFunctionReturn(?:Void)?\s*\‍()")
58FUNCT_RE = re.compile(r"\b__FUNCT__\b")
59CONTROL_WORDS = {"if", "for", "while", "switch", "return", "sizeof", "do", "else"}
60
61
62def blank_comments_and_strings(text: str) -> str:
63 """!
64 @brief Replace comments, string and character literals with spaces, keeping offsets and newlines.
65 @param[in] text C source.
66 @return Text of the same length in which only code remains.
67 """
68 out = list(text)
69 i, n = 0, len(text)
70 while i < n:
71 c = text[i]
72 if text.startswith("//", i):
73 j = text.find("\n", i)
74 j = n if j < 0 else j
75 for k in range(i, j):
76 out[k] = " "
77 i = j
78 elif text.startswith("/*", i):
79 j = text.find("*/", i + 2)
80 j = n if j < 0 else j + 2
81 for k in range(i, j):
82 if out[k] != "\n":
83 out[k] = " "
84 i = j
85 elif c in "\"'":
86 j = i + 1
87 while j < n and text[j] != c:
88 j += 2 if text[j] == "\\" else 1
89 for k in range(i + 1, min(j, n)):
90 if out[k] != "\n":
91 out[k] = " "
92 i = j + 1
93 elif c == "#" and (i == 0 or text[i - 1] == "\n"):
94 j = i
95 while True: # Preprocessor line, with continuations
96 e = text.find("\n", j)
97 e = n if e < 0 else e
98 if e > 0 and text[e - 1] == "\\":
99 j = e + 1
100 continue
101 break
102 for k in range(i, e):
103 out[k] = " "
104 i = e
105 else:
106 i += 1
107 return "".join(out)
108
109
110def find_functions(code: str):
111 """!
112 @brief Locate top-level function definitions in comment- and string-blanked C code.
113 @param[in] code Output of blank_comments_and_strings().
114 @return List of (name, signature_start, body_open, body_close, is_static_inline).
115 """
116 functions = []
117 depth, i, n = 0, 0, len(code)
118 stmt_start = 0
119 while i < n:
120 c = code[i]
121 if c == "{":
122 if depth == 0:
123 head = code[stmt_start:i]
124 m = re.search(r"\b([A-Za-z_]\w*)\s*\‍(([^;{}]*)\‍)\s*$", head, re.S)
125 if m and m.group(1) not in CONTROL_WORDS and not re.search(r"[=]\s*$", head.split("(")[0]):
126 j, d = i + 1, 1
127 while j < n and d:
128 d += (code[j] == "{") - (code[j] == "}")
129 j += 1
130 sig = head[: m.start()]
131 words = set(re.findall(r"\b\w+\b", sig))
132 functions.append((m.group(1), stmt_start, i, j - 1, "static" in words and "inline" in words))
133 i, stmt_start = j, j
134 continue
135 depth += 1
136 elif c == "}":
137 depth -= 1
138 if depth == 0:
139 stmt_start = i + 1
140 elif c == ";" and depth == 0:
141 stmt_start = i + 1
142 i += 1
143 return functions
144
145
146def check_function(code: str, body_open: int, body_close: int):
147 """!
148 @brief Check one function body against the timer rules.
149 @param[in] code Blanked source.
150 @param[in] body_open Offset of the body's opening brace.
151 @param[in] body_close Offset of the body's closing brace.
152 @return List of problem strings with source offsets.
153 """
154 body = code[body_open + 1 : body_close]
155 problems = []
156 begins = len(BEGIN_RE.findall(body))
157 if begins != 1:
158 problems.append((body_open, f"has {begins} timer BEGIN macros, expected 1"))
159 return problems
160 timer_start = BEGIN_RE.search(body).start()
161 for m in RETURN_RE.finditer(body):
162 if m.start() < timer_start:
163 continue # An early return before the timer starts closes nothing
164 before = body[: m.start()].rstrip()
165 if not END_TAIL_RE.search(before):
166 problems.append((body_open + 1 + m.start(), "return not preceded by PROFILE_*_END"))
167 if not ends_in_return(body) and not END_TAIL_RE.search(body.rstrip()):
168 problems.append((body_close, "body can fall off its end without PROFILE_*_END"))
169 return problems
170
171
172def ends_in_return(body: str) -> bool:
173 """!
174 @brief Whether a function body's last top-level statement is a return.
175 @details A body ending in a block (`}`) or in any other statement can fall off its end.
176 @param[in] body Blanked function body, without its braces.
177 @return True when the final statement returns.
178 """
179 tail = body.rstrip()
180 if not tail.endswith(";"):
181 return False
182 depth, start = 0, 0
183 for i, c in enumerate(tail[:-1]):
184 if c in "([{":
185 depth += 1
186 elif c in ")]}":
187 depth -= 1
188 if c == "}" and depth == 0:
189 start = i + 1
190 elif c == ";" and depth == 0:
191 start = i + 1
192 return bool(RETURN_RE.match(tail[start:].lstrip()))
193
194
195def line_of(text: str, offset: int) -> int:
196 """!
197 @brief 1-based line number of an offset.
198 @param[in] text Source text.
199 @param[in] offset Character offset.
200 @return Line number.
201 """
202 return text.count("\n", 0, offset) + 1
203
204
205def main() -> int:
206 """!
207 @brief Run the audit over src/*.c, include/*.h and tests/c, print problems.
208 @return 0 when clean, 1 otherwise.
209 """
210 problems = []
211 seen = set()
212 for path in SOURCES:
213 text = path.read_text(encoding="utf-8")
214 code = blank_comments_and_strings(text)
215 rel = path.relative_to(REPO_ROOT)
216 if str(rel) in EXCLUDED_FILES:
217 continue
218 for name, _, body_open, body_close, static_inline in find_functions(code):
219 seen.add(name)
220 if static_inline or name in EXCLUDED:
221 continue
222 for offset, msg in check_function(code, body_open, body_close):
223 problems.append(f"{rel}:{line_of(text, offset)}: {name}: {msg}")
224 for path in sorted((REPO_ROOT / "src").glob("*.c")) + sorted((REPO_ROOT / "include").glob("*.h")) + sorted((REPO_ROOT / "tests" / "c").glob("*.[ch]")):
225 text = path.read_text(encoding="utf-8")
226 for m in FUNCT_RE.finditer(blank_comments_and_strings(text)):
227 problems.append(f"{path.relative_to(REPO_ROOT)}:{line_of(text, m.start())}: uses __FUNCT__; use __func__")
228 stale = sorted(set(EXCLUDED) - seen)
229 for name in stale:
230 problems.append(f"tests/tooling/audit_profiling_coverage.py: excluded function '{name}' no longer exists")
231 for p in problems:
232 print(p)
233 if problems:
234 print(f"\nFound {len(problems)} profiling coverage issue(s).", file=sys.stderr)
235 return 1
236 print("Profiling coverage audit passed: every eligible src/ function times itself and closes its timer.")
237 return 0
238
239
240if __name__ == "__main__":
241 sys.exit(main())
find_functions(str code)
Locate top-level function definitions in comment- and string-blanked C code.
bool ends_in_return(str body)
Whether a function body's last top-level statement is a return.
str blank_comments_and_strings(str text)
Replace comments, string and character literals with spaces, keeping offsets and newlines.
int main()
Run the audit over src/*.c, include/*.h and tests/c, print problems.
check_function(str code, int body_open, int body_close)
Check one function body against the timer rules.
int line_of(str text, int offset)
1-based line number of an offset.
Head of a generic C-style linked list.
Definition variables.h:496