| #!/usr/bin/env python3 | |
| """Flag IR emission whose order depends on C++ argument evaluation order. | |
| Building an op directly inside the argument list of another op-creating call: | |
| Value cond = stablehlo::OrOp::create( | |
| rewriter, loc, | |
| stablehlo::CompareOp::create(rewriter, loc, n, one, LE), | |
| stablehlo::CompareOp::create(rewriter, loc, s, one, LE)); | |
| is a hazard when *two or more* arguments do it. C++ leaves the evaluation | |
| order of function arguments unspecified, so which compare is inserted into the | |
| block first is the compiler's choice. The `or`'s operands are fixed, but the | |
| textual order of the two compares is not: the same source emits them one way | |
| on macOS and the other on Linux, which makes generated IR -- and any CHECK | |
| line pinning it -- build-dependent. | |
| The fix is always the same: bind each op to a local first, so the source | |
| fixes the order. | |
| Value nSmall = stablehlo::CompareOp::create(rewriter, loc, n, one, LE); | |
| Value sSmall = stablehlo::CompareOp::create(rewriter, loc, s, one, LE); | |
| Value cond = stablehlo::OrOp::create(rewriter, loc, nSmall, sSmall); | |
| An "emitter" is a call that inserts an op: `X::create(...)`, | |
| `builder.create<X>(...)`, `rewriter.replaceOpWithNewOp<X>(...)`, or a call to | |
| a function or lambda in this tree whose own body does one of those (helpers | |
| such as `makeI64Constant`). Lambda *literals* passed as arguments do not | |
| count -- their body runs inside the callee, after argument evaluation. | |
| The second check is the same problem from a different source: iterating a | |
| container that orders by pointer or hash value rather than by insertion. | |
| DenseMap<LLVMFuncOp, SmallVector<CallOpInterface>> kernelLaunches; | |
| ... | |
| for (auto &launch : kernelLaunches) // order depends on pointer values | |
| ... create ops ... | |
| This one is worse than the argument-order case: DenseMap/DenseSet order can | |
| differ between two runs of the same binary, not just between toolchains. The | |
| fix is a MapVector/SetVector, or sorting before the loop. Creating ops in the | |
| body is an error; merely collecting elements into a vector is reported as a | |
| warning, since whether that order reaches the output takes a human to judge. | |
| Suppress a false positive with `// NOLINT(emission-order)` on any line of the | |
| flagged expression. | |
| """ | |
| import argparse | |
| import os | |
| import re | |
| import sys | |
| # A call that inserts an op into the IR. | |
| MLIR_EMITTER = r"\b\w+::create\s*(?=\()|\bcreate\s*<|\breplaceOpWithNewOp\s*<" | |
| # LLVM's IRBuilder has the same hazard; opt in with --include-llvm-builder. | |
| # `Create*` only counts when the receiver is a builder declared in this file | |
| # (see builder_names) or when it is one of LLVM's static Instruction::Create | |
| # factories, which insert into a block themselves. | |
| LLVM_STATIC_EMITTER = r"\b[A-Z]\w*(?:Inst|Block|Node)::Create\s*(?=\()" | |
| BUILDER_DECL_RE = re.compile( | |
| r"\b(?:IRBuilder|IRBuilderBase|OpBuilder|PatternRewriter|IRRewriter|" | |
| r"RewriterBase|ImplicitLocOpBuilder)\s*(?:<[^;{}()]*>)?\s*" | |
| r"(?:const\s*)?[&*]?\s*(\w+)\s*[(,;=)]" | |
| ) | |
| EMITTER_RE = re.compile(MLIR_EMITTER) | |
| INCLUDE_LLVM = False | |
| # Statements/expressions that are not calls we should look into. | |
| KEYWORDS = { | |
| "if", | |
| "for", | |
| "while", | |
| "switch", | |
| "catch", | |
| "return", | |
| "sizeof", | |
| "do", | |
| "else", | |
| "and", | |
| "or", | |
| "not", | |
| "decltype", | |
| "static_assert", | |
| "assert", | |
| "new", | |
| "delete", | |
| "throw", | |
| "case", | |
| "template", | |
| "operator", | |
| "noexcept", | |
| "constexpr", | |
| "alignof", | |
| } | |
| LAMBDA_ASSIGN_RE = re.compile( | |
| r"\b(?:auto|const\s+auto)\s*&?\s*(\w+)\s*=\s*\[[^\]]*\]\s*" | |
| r"(?:\([^)]*\))?\s*(?:mutable\s*)?(?:->[^{;]*)?\{" | |
| ) | |
| CALL_RE = re.compile(r"(?<![\w])(\w+(?:::\w+)*)\s*(?:<[^<>;{}]*>)?\s*\(") | |
| LAMBDA_ARG_RE = re.compile(r"^\s*\[[^\]]*\]\s*(?:\(|\{|mutable|->)") | |
| SUPPRESS_RE = re.compile(r"NOLINT\(\s*emission-order\s*\)") | |
| SOURCE_SUFFIXES = (".cpp", ".cc", ".h", ".hpp") | |
| SKIP_DIRS = {".git", "build", "third_party", "external"} | |
| # Containers whose iteration order is a function of pointer or hash values | |
| # rather than of insertion order. | |
| UNORDERED_TYPE_RE = re.compile( | |
| r"(?<![\w:])(?:llvm::|std::)?" | |
| r"(?:SmallPtrSet|SmallPtrSetImpl|SmallDenseMap|SmallDenseSet|DenseMap|" | |
| r"DenseMapBase|DenseSet|StringMap|StringSet|unordered_map|unordered_set|" | |
| r"unordered_multimap|unordered_multiset)\s*<" | |
| ) | |
| RANGE_FOR_RE = re.compile(r"\bfor\s*\(\s*[^;{}]*?\s:\s*([^)]*)\)") | |
| # Anything in a loop body that turns iteration order into program output. | |
| ORDER_SENSITIVE_RE = re.compile( | |
| r"\b(?:push_back|emplace_back|append|emitError|emitWarning|emitRemark)\s*\(" | |
| ) | |
| def strip_noise(src): | |
| """Blank out comments, strings and char literals, preserving offsets.""" | |
| out = list(src) | |
| i, n = 0, len(src) | |
| while i < n: | |
| c = src[i] | |
| if c == "/" and i + 1 < n and src[i + 1] == "/": | |
| while i < n and src[i] != "\n": | |
| out[i] = " " | |
| i += 1 | |
| elif c == "/" and i + 1 < n and src[i + 1] == "*": | |
| out[i] = out[i + 1] = " " | |
| i += 2 | |
| while i + 1 < n and not (src[i] == "*" and src[i + 1] == "/"): | |
| if src[i] != "\n": | |
| out[i] = " " | |
| i += 1 | |
| if i + 1 < n: | |
| out[i] = out[i + 1] = " " | |
| i += 2 | |
| elif c in "\"'": | |
| quote = c | |
| out[i] = " " | |
| i += 1 | |
| while i < n and src[i] != quote: | |
| if src[i] == "\\": | |
| out[i] = " " | |
| i += 1 | |
| if i < n and src[i] != "\n": | |
| out[i] = " " | |
| i += 1 | |
| if i < n: | |
| out[i] = " " | |
| i += 1 | |
| else: | |
| i += 1 | |
| return "".join(out) | |
| def match_paren(src, open_idx): | |
| depth = 0 | |
| for i in range(open_idx, len(src)): | |
| if src[i] == "(": | |
| depth += 1 | |
| elif src[i] == ")": | |
| depth -= 1 | |
| if depth == 0: | |
| return i | |
| return -1 | |
| def match_brace(src, open_idx): | |
| depth = 0 | |
| for i in range(open_idx, len(src)): | |
| if src[i] == "{": | |
| depth += 1 | |
| elif src[i] == "}": | |
| depth -= 1 | |
| if depth == 0: | |
| return i | |
| return -1 | |
| def split_args(src, lo, hi): | |
| """Split [lo, hi) on top-level commas, ignoring () [] {} and <> nesting.""" | |
| args, depth, angle, start = [], 0, 0, lo | |
| for i in range(lo, hi): | |
| c = src[i] | |
| if c in "([{": | |
| depth += 1 | |
| elif c in ")]}": | |
| depth -= 1 | |
| elif c == "<" and depth == 0: | |
| angle += 1 | |
| elif c == ">" and depth == 0 and angle > 0: | |
| angle -= 1 | |
| elif c == "," and depth == 0 and angle == 0: | |
| args.append((start, i)) | |
| start = i + 1 | |
| args.append((start, hi)) | |
| return args | |
| def builder_names(src): | |
| """Names bound to an IR builder / rewriter in `src`.""" | |
| return set(BUILDER_DECL_RE.findall(src)) | |
| def emitter_for(src): | |
| """The emitter pattern for one file. | |
| With --include-llvm-builder, `Create*` counts only on a receiver this file | |
| declares as a builder, so an unrelated `CreateFoo()` helper is not mistaken | |
| for IR emission. | |
| """ | |
| if not INCLUDE_LLVM: | |
| return EMITTER_RE | |
| alts = [MLIR_EMITTER, LLVM_STATIC_EMITTER] | |
| names = builder_names(src) | |
| if names: | |
| joined = "|".join(sorted(map(re.escape, names))) | |
| alts.append(rf"\b(?:{joined})\s*(?:\.|->)\s*Create[A-Z]\w*\s*(?=\()") | |
| return re.compile("|".join(alts)) | |
| def collect_helpers(src, emitter_re=None): | |
| """Names of functions and lambdas in `src` whose body inserts ops.""" | |
| EMITTER_RE = emitter_re or emitter_for(src) | |
| names = set() | |
| for m in LAMBDA_ASSIGN_RE.finditer(src): | |
| open_idx = src.index("{", m.end() - 1) | |
| close = match_brace(src, open_idx) | |
| if close > 0 and EMITTER_RE.search(src[open_idx:close]): | |
| names.add(m.group(1)) | |
| for m in re.finditer(r"(?<![\w.>])(\w+)\s*\(", src): | |
| name = m.group(1) | |
| if name in KEYWORDS or len(name) < 3: | |
| continue | |
| close = match_paren(src, m.end() - 1) | |
| if close < 0: | |
| continue | |
| tail = src[close + 1 : close + 40] | |
| if not re.match(r"\s*(?:const\s*)?(?:noexcept\s*)?\{", tail): | |
| continue | |
| open_idx = close + 1 + tail.index("{") | |
| body_end = match_brace(src, open_idx) | |
| if body_end > 0 and EMITTER_RE.search(src[open_idx:body_end]): | |
| names.add(name) | |
| return names | |
| def iter_sources(roots): | |
| for root in roots: | |
| if os.path.isfile(root): | |
| yield root | |
| continue | |
| for dirpath, dirnames, filenames in os.walk(root): | |
| dirnames[:] = [ | |
| d for d in dirnames if d not in SKIP_DIRS and not d.startswith("bazel-") | |
| ] | |
| for name in sorted(filenames): | |
| if name.endswith(SOURCE_SUFFIXES): | |
| yield os.path.join(dirpath, name) | |
| def check_file(path, helper_re): | |
| raw = open(path, encoding="utf-8", errors="replace").read() | |
| src = strip_noise(raw) | |
| EMITTER_RE = emitter_for(src) | |
| def emits(text): | |
| if LAMBDA_ARG_RE.match(text): | |
| return False # lambda literal: its body runs inside the callee | |
| return bool(EMITTER_RE.search(text)) or bool( | |
| helper_re and helper_re.search(text) | |
| ) | |
| findings, reported_lines = [], set() | |
| for m in CALL_RE.finditer(src): | |
| if m.group(1).split("::")[-1] in KEYWORDS: | |
| continue | |
| open_idx = src.index("(", m.end() - 1) | |
| close = match_paren(src, open_idx) | |
| if close < 0: | |
| continue | |
| args = split_args(src, open_idx + 1, close) | |
| if len(args) < 2: | |
| continue | |
| emitting = [a for a in args if emits(src[a[0] : a[1]])] | |
| if len(emitting) < 2: | |
| continue | |
| line = src.count("\n", 0, m.start()) + 1 | |
| end_line = src.count("\n", 0, close) + 1 | |
| if SUPPRESS_RE.search(raw[m.start() : close + 1]): | |
| continue | |
| # only report the outermost expression on a given line | |
| if line in reported_lines: | |
| continue | |
| reported_lines.add(line) | |
| findings.append( | |
| ( | |
| line, | |
| end_line, | |
| m.group(1), | |
| " ".join(raw[m.start() : close + 1].split())[:200], | |
| ) | |
| ) | |
| return findings | |
| def match_angle(src, open_idx): | |
| depth = 0 | |
| for i in range(open_idx, len(src)): | |
| if src[i] == "<": | |
| depth += 1 | |
| elif src[i] == ">": | |
| depth -= 1 | |
| if depth == 0: | |
| return i | |
| elif src[i] in ";{}": | |
| return -1 | |
| return -1 | |
| def brace_spans(src): | |
| """(start, end) of every brace pair, innermost last for a given point.""" | |
| stack, spans = [], [] | |
| for i, c in enumerate(src): | |
| if c == "{": | |
| stack.append(i) | |
| elif c == "}" and stack: | |
| spans.append((stack.pop(), i)) | |
| return spans | |
| def unordered_decls(src): | |
| """(name, pos, scope_start, scope_end) per unordered-container declaration. | |
| Names are scoped to the innermost enclosing braces so that a parameter such | |
| as `SmallPtrSetImpl<Block *> &block` in one function does not make every | |
| other `block` in the file look like a hash container. | |
| """ | |
| spans = brace_spans(src) | |
| decls = [] | |
| for m in UNORDERED_TYPE_RE.finditer(src): | |
| close = match_angle(src, m.end() - 1) | |
| if close < 0: | |
| continue | |
| decl = re.match(r"\s*(?:const\s*)?[&*]?\s*(\w+)", src[close + 1 :]) | |
| if not decl: | |
| continue | |
| pos = m.start() | |
| scope = min( | |
| (s for s in spans if s[0] < close < s[1]), | |
| key=lambda s: s[1] - s[0], | |
| default=(0, len(src)), | |
| ) | |
| decls.append((decl.group(1), pos, scope[0], scope[1])) | |
| return decls | |
| def iterated_name(expr): | |
| """The identifier a range-for actually iterates over, if any. | |
| `xs`, `*xs`, `xs.keys()`, `llvm::reverse(xs)` -> "xs". | |
| """ | |
| expr = expr.strip() | |
| call = re.fullmatch(r"[\w:]+\s*\(\s*([^()]*?)\s*\)", expr) | |
| if call: | |
| expr = call.group(1).strip() | |
| m = re.match(r"[*&]?\s*(\w+)\s*(?:\.\w+\s*\(\s*\)\s*)?$", expr) | |
| return m.group(1) if m else None | |
| def check_container_iteration(path, helper_re): | |
| """Loops over an unordered container that turn its order into output.""" | |
| raw = open(path, encoding="utf-8", errors="replace").read() | |
| src = strip_noise(raw) | |
| EMITTER_RE = emitter_for(src) | |
| decls = unordered_decls(src) | |
| if not decls: | |
| return [] | |
| findings = [] | |
| for m in RANGE_FOR_RE.finditer(src): | |
| name = iterated_name(m.group(1)) | |
| if name is None: | |
| continue | |
| at = m.start() | |
| hit = next( | |
| ( | |
| n | |
| for n, pos, lo, hi in decls | |
| if n == name and pos < at and lo <= at <= hi | |
| ), | |
| None, | |
| ) | |
| if hit is None: | |
| continue | |
| # A braced body runs to its matching brace; a brace-less one is the | |
| # single statement that follows. | |
| rest = src[m.end() :] | |
| lead = len(rest) - len(rest.lstrip()) | |
| if rest[lead : lead + 1] == "{": | |
| open_idx = m.end() + lead | |
| close = match_brace(src, open_idx) | |
| if close < 0: | |
| continue | |
| else: | |
| close = src.find(";", m.end()) | |
| if close < 0: | |
| continue | |
| body = src[m.end() : close] | |
| # Iteration order only becomes instruction order when the insertion | |
| # point is shared across iterations. A builder constructed inside the | |
| # body anchors each op to its own item, and a static Foo::Create | |
| # factory may not be inserted at all until something else places it -- | |
| # both are reported, but as warnings. | |
| local_builders = builder_names(body) | |
| strong = False | |
| for em in EMITTER_RE.finditer(body): | |
| text = em.group() | |
| recv = re.match(r"(\w+)\s*(?:\.|->)", text) | |
| if recv and recv.group(1) in local_builders: | |
| continue | |
| if re.match(r"[A-Z]\w*(?:Inst|Block|Node)::Create", text): | |
| continue | |
| if "::create" in text: # MLIR: builder is the first argument | |
| after = body[em.end() :] | |
| paren = after.find("(") | |
| first = ( | |
| re.match(r"\s*\(?\s*(\w+)", after[paren:]) if paren >= 0 else None | |
| ) | |
| if first and first.group(1) in local_builders: | |
| continue | |
| strong = True | |
| break | |
| by_helper = bool(helper_re and helper_re.search(body)) | |
| emits = bool(EMITTER_RE.search(body)) or by_helper | |
| strong = strong or by_helper | |
| if not (emits or ORDER_SENSITIVE_RE.search(body)): | |
| continue | |
| if SUPPRESS_RE.search(raw[m.start() : close + 1]): | |
| continue | |
| line = src.count("\n", 0, m.start()) + 1 | |
| end_line = src.count("\n", 0, close) + 1 | |
| # Creating ops in the body puts the order straight into the IR. Merely | |
| # collecting into a vector may or may not reach the output, so it is | |
| # reported but does not fail the run. | |
| findings.append( | |
| ( | |
| line, | |
| end_line, | |
| hit, | |
| " ".join(raw[m.start() : m.end()].split()), | |
| "error" if (emits and strong) else "warning", | |
| ) | |
| ) | |
| return findings | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter | |
| ) | |
| parser.add_argument("roots", nargs="+", help="directories or files to check") | |
| parser.add_argument( | |
| "--github", | |
| action="store_true", | |
| help="also emit GitHub Actions error annotations", | |
| ) | |
| parser.add_argument( | |
| "--include-llvm-builder", | |
| action="store_true", | |
| help="also treat LLVM IRBuilder Create* calls as " | |
| "emitters (same hazard, separate cleanup)", | |
| ) | |
| args = parser.parse_args() | |
| if args.include_llvm_builder: | |
| global INCLUDE_LLVM | |
| INCLUDE_LLVM = True | |
| files = list(iter_sources(args.roots)) | |
| helpers = set() | |
| stripped = {} | |
| for path in files: | |
| stripped[path] = strip_noise( | |
| open(path, encoding="utf-8", errors="replace").read() | |
| ) | |
| helpers |= collect_helpers(stripped[path]) | |
| helpers -= KEYWORDS | |
| helper_re = ( | |
| re.compile( | |
| r"(?<![\w.>])(?:" + "|".join(sorted(map(re.escape, helpers))) + r")\s*\(" | |
| ) | |
| if helpers | |
| else None | |
| ) | |
| errors, warnings = 0, 0 | |
| def report(path, line, end_line, msg, snippet, severity="error"): | |
| nonlocal errors, warnings | |
| if severity == "error": | |
| errors += 1 | |
| else: | |
| warnings += 1 | |
| print(f"{path}:{line}: {severity}: {msg}\n {snippet}") | |
| if args.github: | |
| print( | |
| f"::{severity} file={path},line={line},endLine={end_line}," | |
| f"title=Nondeterministic IR emission order::{msg}" | |
| ) | |
| for path in files: | |
| for line, end_line, callee, snippet in check_file(path, helper_re): | |
| report( | |
| path, | |
| line, | |
| end_line, | |
| f"{callee}(...) builds two or more ops in one argument list; " | |
| f"evaluation order is unspecified, so the emitted IR order " | |
| f"is compiler-dependent. Bind each to a local first.", | |
| snippet, | |
| ) | |
| for line, end_line, name, snippet, severity in check_container_iteration( | |
| path, helper_re | |
| ): | |
| what = ( | |
| "creates ops while iterating" | |
| if severity == "error" | |
| else "collects the elements of" | |
| ) | |
| report( | |
| path, | |
| line, | |
| end_line, | |
| f"loop {what} `{name}`, but that container orders by pointer " | |
| f"or hash value rather than by insertion, so its order can " | |
| f"differ between runs. Use MapVector/SetVector, or sort first.", | |
| snippet, | |
| severity, | |
| ) | |
| if warnings: | |
| print( | |
| f"\n{warnings} loop(s) over an unordered container feed something " | |
| f"order-sensitive but do not create ops directly; review whether " | |
| f"the order reaches the output.", | |
| file=sys.stderr, | |
| ) | |
| if errors: | |
| print( | |
| f"\n{errors} nondeterministic emission site(s). See the guidance in " | |
| f"each message, or add `// NOLINT(emission-order)` if the order " | |
| f"provably cannot be observed.", | |
| file=sys.stderr, | |
| ) | |
| return 1 | |
| print(f"checked {len(files)} files: no nondeterministic emission sites") | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |