268 lines
13 KiB
Python
268 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
# Copyright (C) 2026 David (Corey) Herrera
|
|
"""
|
|
check_tensix_kernel_compile_time_arg_counts.py
|
|
Enforces the ONE guard `ft_tensix_compile_time_arg()` does not have. It indexes
|
|
`static const uint32_t values[] = {KERNEL_COMPILE_TIME_ARGS}` with no bounds check
|
|
(kernels/ft_tensix_kernel_args.h) -- MEASURED on the card's toolchain (SFPI GCC 15.1.0,
|
|
a second card host, 2026-08-18): an out-of-range read folds to a literal `0` at `-Os` and at `-O3`,
|
|
with NO diagnostic at any `-Warray-bounds` level. So `-Werror` catches nothing here --
|
|
there is no warning to promote -- and the argument COUNT written beside each kernel's
|
|
documented build line is the only thing standing between a short build and a kernel that
|
|
silently reads zero. This script is that guard, made real.
|
|
|
|
WHAT IT CHECKS. For every `kernels/*.c` source: the highest N in any
|
|
`ft_tensix_compile_time_arg(N)` it reads (directly or through a `#define` wrapper) versus
|
|
the number of comma-separated values the kernel's own documented `Build:` comment passes
|
|
as `CTARGS=` / `READER_CTARGS=` / `COMPUTE_CTARGS=` / `WRITER_CTARGS=`. A build line that
|
|
supplies fewer than N+1 values is a silent zero waiting to happen -- that nearly shipped
|
|
once already: resident_gdn_reader.c reads CONV_DEPTH at index 7, and a seven-argument
|
|
READER_CTARGS line (kernels/resident_gdn_reader.c's own header describes exactly this
|
|
build) would have made `conv_block_depth()` return 0 and refused every CONV descriptor.
|
|
|
|
Usage: check_tensix_kernel_compile_time_arg_counts.py [kernels-dir] (default ../kernels)
|
|
Needs: nothing. No card, no toolchain, no tt-metal source -- this reads C comments.
|
|
|
|
---------------------------------------------------------------------------
|
|
HOW THE BUILD LINE IS FOUND, AND WHY IT IS NOT A SIMPLE GREP
|
|
---------------------------------------------------------------------------
|
|
Every kernel documents its build under a `Build:` or `Build (...):` comment label
|
|
(build_tensix_kernel.sh / build_tensix_dataflow_kernel.sh for one kernel,
|
|
build_tensix_program.sh for a reader/compute/writer triad -- see those three scripts'
|
|
own headers). The label starts a STANZA that ends at the next blank comment line, which
|
|
matters: resident_gdn_reader.c's Build stanza is followed, after a blank line, by a prose
|
|
correction quoting a STALE, one-argument-short `READER_CTARGS=` value it does NOT build
|
|
with any more ("The round's spec wrote ... The 7 is corrected to 4 above"). Reading to the
|
|
next blank line, not to the end of the comment, is what keeps that stale value out of the
|
|
count this script trusts.
|
|
|
|
Inside a stanza, the actual invocation is whatever script name follows the literal word
|
|
`bash` -- not any `build_tensix_*.sh` mentioned in prose (vector_add_compute.c's stanza
|
|
opens with "Build (or build_tensix_program.sh for the whole triad at once):", which names
|
|
a script this kernel is NOT built with in that line). For a `build_tensix_program.sh`
|
|
stanza, which of READER_CTARGS/COMPUTE_CTARGS/WRITER_CTARGS belongs to THIS kernel is
|
|
read off the invocation's own positional `reader.c compute.c writer.c` argument order,
|
|
not assumed from the kernel's filename.
|
|
|
|
Every `KERNEL_COMPILE_TIME_ARGS`-shaped `NAME=` line inside a stanza is collected, and the
|
|
SMALLEST comma-count among them is what gets compared against N+1 -- a kernel with two
|
|
documented arms (resident_queue.c's RESIDENT and ONE-SHOT builds) must clear the guard in
|
|
BOTH, not just the first one this script happens to read.
|
|
|
|
---------------------------------------------------------------------------
|
|
WHAT THIS DELIBERATELY DOES NOT DO
|
|
---------------------------------------------------------------------------
|
|
It does not run a compiler and it does not open the card -- it is a doc-comment audit, so
|
|
it can be wrong if a build line drifts from what someone actually typed on the card host. It
|
|
narrows that gap the same way the rest of this tree does: `-DKERNEL_COMPILE_TIME_ARGS=`
|
|
IS the build line, so a kernel that changes it without updating the comment beside it is
|
|
the exact failure mode this script exists to catch on the next person's read, even if it
|
|
cannot catch it on the card host's own command line.
|
|
|
|
A `ft_tensix_compile_time_arg(N)` whose N is not a bare integer literal (a macro, an
|
|
expression) is reported and treated as a FAILURE rather than skipped -- this codebase has
|
|
none today, and silently passing an index this script cannot evaluate is exactly the
|
|
"quiet skip that reads as coverage" this program does not accept from a checker. Same
|
|
treatment for a kernel whose Build stanza cannot be resolved to a single CTARGS-shaped
|
|
value: it fails loudly, unless it is named in NO_BUILD_LINE_ALLOWLIST below with a reason,
|
|
which is the one case (resident_vector_writer_blocked.c) known to be a real gap today.
|
|
|
|
A kernel that reads NO compile-time arg passes without a build line: it has nothing a short
|
|
build could under-supply (dispatch_gate.c, resident_exit_only.c, resident_puller.c).
|
|
|
|
Status: written 2026-08-18. Verified to fail: a build line temporarily shortened by one
|
|
argument was caught, naming the kernel and the missing index, then restored --
|
|
see the commit this file landed in.
|
|
Issues: None
|
|
Todo: None
|
|
"""
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ARG_READ_RE = re.compile(r"ft_tensix_compile_time_arg\s*\(\s*([^)]*?)\s*\)")
|
|
BUILD_LABEL_RE = re.compile(r"^\s*\*+\s*Build\s*[:(]")
|
|
BLANK_COMMENT_LINE_RE = re.compile(r"^\s*\*+\s*$")
|
|
COMMENT_PREFIX_RE = re.compile(r"^\s*\*+\s?")
|
|
CTARGS_ASSIGN_RE = re.compile(r"\b(CTARGS|READER_CTARGS|COMPUTE_CTARGS|WRITER_CTARGS)=([0-9,]+)")
|
|
BASH_INVOKE_RE = re.compile(r"\bbash\s+(\S+)")
|
|
SINGLE_KERNEL_SCRIPTS = ("build_tensix_kernel.sh", "build_tensix_dataflow_kernel.sh")
|
|
PROGRAM_SCRIPT = "build_tensix_program.sh"
|
|
PROGRAM_ROLE_VARS = ("READER_CTARGS", "COMPUTE_CTARGS", "WRITER_CTARGS")
|
|
|
|
# Kernels whose header documents named compile-time args but no build invocation to check
|
|
# them against. Silently skipping these is the failure mode that makes a checker worthless,
|
|
# so each one must be named here with why, and shows up as a WARN on every run rather than
|
|
# vanishing from the report.
|
|
NO_BUILD_LINE_ALLOWLIST = {
|
|
"resident_vector_writer_blocked.c": (
|
|
"a measurement arm (Phase 6 fusion round), never shipped -- its own header lists "
|
|
"the five compile-time args by name but documents no build invocation for them"
|
|
),
|
|
}
|
|
|
|
|
|
def strip_comments(text):
|
|
"""Blank out /* */ and // comments, keeping line numbers intact for reporting."""
|
|
def blank_but_keep_newlines(match):
|
|
return re.sub(r"[^\n]", " ", match.group(0))
|
|
|
|
text = re.sub(r"/\*.*?\*/", blank_but_keep_newlines, text, flags=re.S)
|
|
text = re.sub(r"//[^\n]*", lambda m: " " * len(m.group(0)), text)
|
|
return text
|
|
|
|
|
|
def line_of(text, pos):
|
|
return text.count("\n", 0, pos) + 1
|
|
|
|
|
|
def highest_compile_time_arg_index(raw_text):
|
|
"""(max_index_or_None, [(bad_expr, line), ...]) over CODE only -- comments stripped so a
|
|
prose mention of ft_tensix_compile_time_arg() (several kernels discuss the very bug this
|
|
script guards against) is never mistaken for a real read."""
|
|
code = strip_comments(raw_text)
|
|
literal_indices = []
|
|
bad = []
|
|
for match in ARG_READ_RE.finditer(code):
|
|
expr = match.group(1).strip()
|
|
if re.fullmatch(r"\d+", expr):
|
|
literal_indices.append(int(expr))
|
|
else:
|
|
bad.append((expr if expr else "<empty>", line_of(code, match.start())))
|
|
return (max(literal_indices) if literal_indices else None), bad
|
|
|
|
|
|
def build_stanzas(raw_text):
|
|
"""Each documented Build block: (start_line, normalized_one_line_text)."""
|
|
lines = raw_text.split("\n")
|
|
stanzas = []
|
|
i = 0
|
|
while i < len(lines):
|
|
if BUILD_LABEL_RE.match(lines[i]):
|
|
start = i
|
|
block = []
|
|
while i < len(lines) and not BLANK_COMMENT_LINE_RE.match(lines[i]):
|
|
block.append(COMMENT_PREFIX_RE.sub("", lines[i]))
|
|
i += 1
|
|
normalized = " ".join(block).replace("\\", " ")
|
|
normalized = re.sub(r"\s+", " ", normalized).strip()
|
|
stanzas.append((start + 1, normalized))
|
|
else:
|
|
i += 1
|
|
return stanzas
|
|
|
|
|
|
def resolve_stanza(stanza_text, kernel_basename):
|
|
"""One stanza -> ('ok', count, detail) | ('cannot_verify', None, detail) | (None, ...)
|
|
if the stanza names no build_tensix_*.sh invocation at all (not this kernel's stanza)."""
|
|
invoke = BASH_INVOKE_RE.search(stanza_text)
|
|
script = invoke.group(1).rsplit("/", 1)[-1] if invoke else None
|
|
if script not in SINGLE_KERNEL_SCRIPTS and script != PROGRAM_SCRIPT:
|
|
return None, None, None
|
|
|
|
assigns = {}
|
|
for var, values in CTARGS_ASSIGN_RE.findall(stanza_text):
|
|
count = len([tok for tok in values.split(",") if tok != ""])
|
|
assigns.setdefault(var, []).append(count)
|
|
|
|
if script == PROGRAM_SCRIPT:
|
|
after = stanza_text[invoke.end():]
|
|
files = [Path(tok).name for tok in re.findall(r"(\S+\.c)\b", after)]
|
|
if kernel_basename not in files:
|
|
return "cannot_verify", None, (
|
|
"build_tensix_program.sh invocation found but does not name %s in its "
|
|
"reader/compute/writer argument list (%s)" % (kernel_basename, files or "none"))
|
|
role = files.index(kernel_basename)
|
|
if role >= len(PROGRAM_ROLE_VARS):
|
|
return "cannot_verify", None, (
|
|
"%s appears at position %d in the program invocation, past reader/compute/"
|
|
"writer" % (kernel_basename, role))
|
|
target_var = PROGRAM_ROLE_VARS[role]
|
|
else:
|
|
target_var = "CTARGS"
|
|
|
|
counts = assigns.get(target_var)
|
|
if not counts:
|
|
return "cannot_verify", None, (
|
|
"build_tensix_%s invocation found but no %s=<values> beside it"
|
|
% ("program.sh" if script == PROGRAM_SCRIPT else "kernel/dataflow_kernel.sh",
|
|
target_var))
|
|
return "ok", min(counts), "%s=%s (%d value(s))" % (
|
|
target_var, ",".join(str(c) for c in counts), min(counts))
|
|
|
|
|
|
def check_kernel(path):
|
|
"""Returns a dict describing one kernel's result; see main() for the fields used."""
|
|
raw_text = path.read_text()
|
|
max_index, bad_exprs = highest_compile_time_arg_index(raw_text)
|
|
needed = 0 if max_index is None else max_index + 1
|
|
|
|
if bad_exprs:
|
|
return {"status": "FAIL", "reason": "non-literal ft_tensix_compile_time_arg() index: "
|
|
"%s" % ", ".join("'%s' at line %d" % (e, ln) for e, ln in bad_exprs)}
|
|
|
|
stanzas = build_stanzas(raw_text)
|
|
resolved = None
|
|
cannot_verify_reason = None
|
|
for _, stanza_text in stanzas:
|
|
status, count, detail = resolve_stanza(stanza_text, path.name)
|
|
if status == "ok":
|
|
resolved = (count, detail)
|
|
break
|
|
if status == "cannot_verify" and cannot_verify_reason is None:
|
|
cannot_verify_reason = detail
|
|
|
|
if resolved is None:
|
|
if cannot_verify_reason:
|
|
return {"status": "FAIL", "reason": "build line found but not resolvable: %s"
|
|
% cannot_verify_reason}
|
|
if max_index is None:
|
|
return {"status": "OK", "reason": "reads no compile-time arg; nothing to under-supply"}
|
|
if path.name in NO_BUILD_LINE_ALLOWLIST:
|
|
return {"status": "WARN", "reason": (
|
|
"no discoverable build line to check against (needs %d compile-time "
|
|
"arg(s)) -- allowlisted: %s" % (needed, NO_BUILD_LINE_ALLOWLIST[path.name]))}
|
|
return {"status": "FAIL", "reason": (
|
|
"no discoverable build line -- reads up to compile-time arg index %s (needs %d "
|
|
"value(s)) and is not in NO_BUILD_LINE_ALLOWLIST"
|
|
% (max_index, needed))}
|
|
|
|
supplied, detail = resolved
|
|
if supplied < needed:
|
|
return {"status": "FAIL", "reason": (
|
|
"reads ft_tensix_compile_time_arg(%d) (needs %d value(s)) but its build line "
|
|
"supplies only %d: %s" % (max_index, needed, supplied, detail))}
|
|
return {"status": "OK", "reason": "needs %d, build line supplies %s" % (needed, detail)}
|
|
|
|
|
|
def main():
|
|
kernels = Path(sys.argv[1]) if len(sys.argv) > 1 else \
|
|
Path(__file__).resolve().parent.parent / "kernels"
|
|
sources = sorted(kernels.glob("*.c"))
|
|
if not sources:
|
|
print("FAIL: no kernel sources under %s" % kernels)
|
|
return 1
|
|
|
|
failures = 0
|
|
warnings = 0
|
|
for source in sources:
|
|
result = check_kernel(source)
|
|
if result["status"] == "FAIL":
|
|
failures += 1
|
|
print("FAIL %-42s %s" % (source.name, result["reason"]))
|
|
elif result["status"] == "WARN":
|
|
warnings += 1
|
|
print("WARN %-42s %s" % (source.name, result["reason"]))
|
|
else:
|
|
print("ok %-42s %s" % (source.name, result["reason"]))
|
|
|
|
print("=== %d kernel(s), %d failure(s), %d warning(s)" %
|
|
(len(sources), failures, warnings))
|
|
return 1 if failures else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|