From 987f4dee1ecdf07569d4f28eeabf4515924fc45b Mon Sep 17 00:00:00 2001 From: Ed_ Date: Sat, 11 Jul 2026 14:48:57 -0400 Subject: [PATCH] preparing for a big refactor --- scripts/passes/static_analysis.lua | 547 +++++++++++++++-------------- 1 file changed, 288 insertions(+), 259 deletions(-) diff --git a/scripts/passes/static_analysis.lua b/scripts/passes/static_analysis.lua index 7fb9c6b..facabff 100644 --- a/scripts/passes/static_analysis.lua +++ b/scripts/passes/static_analysis.lua @@ -129,8 +129,20 @@ local OUTPUT_EXTENSION = ".static_analysis.txt" --- Used by the checks to convert per-token offsets in the body to lina numbers relative to the start of `body`. --- The atom's source-line of the body-start is added by the caller. --- +-- Memoization cache for build_body_line_index / tokenize_body. +-- Atom bodies are immutable for the duration of a validate() pass (they come from src.scan which is set once by scan-source), +-- so the same body string is safe to use as a cache key. Each call site (5 check_* functions + analyze_atom_paths) +-- was re-running the full O(body_len) scan on the SAME body. After memoization, the first call pays the O(body_len) cost, +-- every subsequent call on the same body returns the cached table in O(1). +-- +-- (Future-proofing: if a caller ever mutates the returned tokens / line-index tables, the cache aliasing means those +-- mutations would leak across atoms. Current callers all read-only; safe today.) +local _body_line_index_cache = {} +local _tokenize_body_cache = {} + --- Simple line-counting: count `\n` chars from offset 1 up to the offset; that count + 1 is the line number (1-based). local function build_body_line_index(body) + if _body_line_index_cache[body] ~= nil then return _body_line_index_cache[body] end local index = {} local len = #body local newline_count = 0 @@ -144,6 +156,7 @@ local function build_body_line_index(body) end -- Offsets beyond the body still resolve to the final line index[len + 1] = newline_count + 1 + _body_line_index_cache[body] = index return index end @@ -171,6 +184,7 @@ end --- `body_rel_offset` is the char offset within `body` of the start of the token. --- Callers add it to the atom's `body_off` to get an absolute source position for line tracking. local function tokenize_body(body) + if _tokenize_body_cache[body] ~= nil then return _tokenize_body_cache[body] end local out = {} local len = #body local rel = 1 @@ -183,7 +197,7 @@ local function tokenize_body(body) if rel > len then break end -- Find comma/newline/semicolon after this token. - -- Read balanced groups so commas inside parens/braces/brackets aren't treated as separators. + -- Read balanced groups so commas inside parens/braces/brackets aren't treated as separators. -- Comments / strings are skipped. local scan = rel while scan <= len do @@ -193,9 +207,9 @@ local function tokenize_body(body) if c == 59 then break end -- ';' if c == 40 then local _, a = duffle.read_parens (body, scan); scan = a -- '(' elseif c == 123 then local _, a = duffle.read_braces (body, scan); scan = a -- '{' - elseif c == 91 then local _, a = duffle.read_brackets (body, scan); scan = a -- '[' + elseif c == 91 then local _, a = duffle.read_brackets (body, scan); scan = a -- '[' elseif c == 34 or c == 39 then scan = duffle.skip_str_or_cmt(body, scan) + 1 -- '"' or '\'' - else + else scan = scan + 1 end end @@ -213,6 +227,7 @@ local function tokenize_body(body) end rel = scan end + _tokenize_body_cache[body] = out return out end @@ -277,16 +292,17 @@ end --- If count < the minimum declared in `duffle.GTE_PIPELINE_LATENCY[X]`, record a finding. --- Aliases are resolved against the lookup table directly; if a macro name is not in the table, emit a soft warning --- (the user might have added a new gte_cmdw_* but not updated duffle.lua). -local function check_gte_pipeline_fill(atoms, findings, line_of) - for _, a in ipairs(atoms) do - local tokens = tokenize_body(a.body) - local line_in_body = build_body_line_index(a.body) - local tn = #tokens - local ti = 1 - while ti <= tn do - check_one_gte_cmdw(a, tokens[ti].tok, tokens, ti, line_in_body, findings) - ti = ti + 1 - end +--- Per-atom: walk this atom's tokens, check every `gte_cmdw_*` for pipeline-fill compliance. +--- Signature changed from `(atoms, findings)` to `(atom, findings)` in Stage 1B of the plex move: +--- the per-atom iteration now lives in validate()'s single loop; each check is a per-atom predicate. +local function check_gte_pipeline_fill(atom, findings) + local tokens = tokenize_body(atom.body) + local line_in_body = build_body_line_index(atom.body) + local tn = #tokens + local ti = 1 + while ti <= tn do + check_one_gte_cmdw(atom, tokens[ti].tok, tokens, ti, line_in_body, findings) + ti = ti + 1 end end @@ -299,7 +315,7 @@ end --- --- Empty bodies are not currently flagged — runtime infrastructure atoms like `MipsAtom_(yield) { mac_yield() }` --- and `MipsAtom_(tape_exit) { jump_reg(rret_addr), nop }` are valid as-is; mac_yield at the end is the contract. -local function check_mac_yield_uniformity(atoms, findings) +local function check_mac_yield_uniformity(atom, findings) -- Per-kind semantics: -- MipsAtom_ (baked atom): exactly 1 mac_yield at the end of the body. Control transfer is the atom's job. -- MipsAtomComp_ (bare static-array component): ZERO mac_yield. @@ -308,88 +324,86 @@ local function check_mac_yield_uniformity(atoms, findings) -- Same reasoning -- it's a function returning a MipsAtom slice, invoked from a parent atom. -- -- The GTE pipeline-fill check applies to all 3 kinds (see check_gte_pipeline_fill). Only the mac_yield rule branches on kind. - for _, a in ipairs(atoms) do - local tokens = tokenize_body(a.body) - local line_in_body = build_body_line_index(a.body) + local tokens = tokenize_body(atom.body) + local line_in_body = build_body_line_index(atom.body) - local count = 0 - local last_idx = 0 - for tok_idx, t in ipairs(tokens) do - local tok = t.tok - -- Match `mac_yield(...)` or just `mac_yield`. The bareword - -- variant is rare in modern style but tolerated. - if tok:match("^mac_yield%s*%(") or tok == "mac_yield" then - count = count + 1 - last_idx = tok_idx + local count = 0 + local last_idx = 0 + for tok_idx, t in ipairs(tokens) do + local tok = t.tok + -- Match `mac_yield(...)` or just `mac_yield`. The bareword + -- variant is rare in modern style but tolerated. + if tok:match("^mac_yield%s*%(") or tok == "mac_yield" then + count = count + 1 + last_idx = tok_idx + end + end + local function line_for(idx) + return atom.line + line_in_body[tokens[idx].rel] + end + + if atom.kind == "atom" then + -- Baked atom: exactly 1 yield at the end. + if count == 0 then + findings[#findings + 1] = { + atom = atom.name, + line = atom.line, + check = "mac_yield_uniformity", + kind = "warning", + msg = string.format( + "%s at line %d has no `mac_yield()`; every atom must hand control to the next via mac_yield at end", + atom.name, atom.line), + } + elseif count > 1 then + findings[#findings + 1] = { + atom = atom.name, + line = line_for(last_idx), + check = "mac_yield_uniformity", + kind = "warning", + msg = string.format( + "%s at line %d has %d `mac_yield()` calls; exactly 1 is allowed", + atom.name, line_for(last_idx), count), + } + elseif last_idx < #tokens then + -- 1 call, but not the last token. We DON'T fail if the post-token is just `nop` or `nop2` or a branch with `, nop` delay slot. + -- It's the standard "yield, then BD nop" idiom. + local post_non_nop = false + for search_idx = last_idx + 1, #tokens do + local t = tokens[search_idx].tok + if t ~= "" and t ~= "nop" and t ~= "nop2" + and not t:match("%,%s*nop%)%s*$") then + post_non_nop = true + break + end end - end - local function line_for(idx) - return a.line + line_in_body[tokens[idx].rel] - end - - if a.kind == "atom" then - -- Baked atom: exactly 1 yield at the end. - if count == 0 then + if post_non_nop then findings[#findings + 1] = { - atom = a.name, - line = a.line, - check = "mac_yield_uniformity", - kind = "warning", - msg = string.format( - "%s at line %d has no `mac_yield()`; every atom must hand control to the next via mac_yield at end", - a.name, a.line), - } - elseif count > 1 then - findings[#findings + 1] = { - atom = a.name, + atom = atom.name, line = line_for(last_idx), check = "mac_yield_uniformity", kind = "warning", msg = string.format( - "%s at line %d has %d `mac_yield()` calls; exactly 1 is allowed", - a.name, line_for(last_idx), count), - } - elseif last_idx < #tokens then - -- 1 call, but not the last token. We DON'T fail if the post-token is just `nop` or `nop2` or a branch with `, nop` delay slot. - -- It's the standard "yield, then BD nop" idiom. - local post_non_nop = false - for search_idx = last_idx + 1, #tokens do - local t = tokens[search_idx].tok - if t ~= "" and t ~= "nop" and t ~= "nop2" - and not t:match("%,%s*nop%)%s*$") then - post_non_nop = true - break - end - end - if post_non_nop then - findings[#findings + 1] = { - atom = a.name, - line = line_for(last_idx), - check = "mac_yield_uniformity", - kind = "warning", - msg = string.format( - "%s at line %d has `mac_yield()` at token %d/%d; the yield must be the LAST non-nop token in the body", - a.name, line_for(last_idx), last_idx, #tokens), - } - end - end - else - -- Component (comp_bare or comp_proc): ZERO yields. - -- The parent atom does the yield. - -- A yield inside a component would either be dead code (bare) or prematurely terminate the function (proc). - -- Both are bugs. - if count > 0 then - findings[#findings + 1] = { - atom = a.name, - line = line_for(last_idx), - check = "mac_yield_uniformity", - kind = "warning", - msg = string.format( - "%s at line %d is a %s component but has %d `mac_yield()` call(s); components must not yield (the parent atom does)", - a.name, line_for(last_idx), a.kind, count), + "%s at line %d has `mac_yield()` at token %d/%d; the yield must be the LAST non-nop token in the body", + atom.name, line_for(last_idx), last_idx, #tokens), } end end + else + -- Component (comp_bare or comp_proc): ZERO yields. + -- The parent atom does the yield. + -- A yield inside a component would either be dead code (bare) or prematurely terminate the function (proc). + -- Both are bugs. + if count > 0 then + findings[#findings + 1] = { + atom = atom.name, + line = line_for(last_idx), + check = "mac_yield_uniformity", + kind = "warning", + msg = string.format( + "%s at line %d is a %s component but has %d `mac_yield()` call(s); components must not yield (the parent atom does)", + atom.name, line_for(last_idx), atom.kind, count), + } + end end end -- ════════════════════════════════════════════════════════════════════════════ @@ -407,77 +421,73 @@ end --- 1. Body MUST contain one `load_word(R_*, R_TapePtr, O_(Binds_X, field))` per field of Binds_X. Missing field = error. --- 2. Body MUST contain an `add_ui_self(R_TapePtr, S_(Binds_X))` (or equivalent advance by the struct's byte count). Missing = error. --- 3. atom_bind(Binds_X) where Binds_X doesn't exist = error. -local function check_abi_handoff(atoms, atom_infos, binds_index, findings) - local info_by_atom = {} - for _, info in ipairs(atom_infos) do - info_by_atom[info.atom_name] = info +--- Per-atom: verify the atom body reads every field of its `Binds_X` from R_TapePtr and advances R_TapePtr by S_(Binds_X). +--- Signature changed in Stage 1B: takes `(atom, pipe_ctx, findings)` where `pipe_ctx` carries the cross-atom +--- `info_by_atom` + `binds_index` tables (built once by validate() before the per-atom loop). +--- Per-atom iteration now lives in validate(); this is a per-atom predicate. +local function check_abi_handoff(atom, pipe_ctx, findings) + local info = pipe_ctx.info_by_atom[atom.name] + if not info or not info.binds then return end + local binds_name = info.binds + local binds = pipe_ctx.binds_index[binds_name] + if not binds then + findings[#findings + 1] = { + atom = atom.name, line = atom.line, + check = "abi_handoff", kind = "error", + msg = string.format("%s at line %d has `atom_bind(%s)` but no `typedef Struct_(%s)` declaration found in source", + atom.name, atom.line, binds_name, binds_name), + } + return end - - for _, a in ipairs(atoms) do - local info = info_by_atom[a.name] - if info and info.binds then - local binds_name = info.binds - local binds = binds_index[binds_name] - if not binds then - findings[#findings + 1] = { - atom = a.name, line = a.line, - check = "abi_handoff", kind = "error", - msg = string.format("%s at line %d has `atom_bind(%s)` but no `typedef Struct_(%s)` declaration found in source", - a.name, a.line, binds_name, binds_name), - } - else - local tokens = tokenize_body(a.body) - local line_in_body = build_body_line_index(a.body) - local found_field_set = {} - local found_advance = false - local bind_re = "O_%(" .. binds_name .. ",%s*([%w_]+)%s*%)" - for _, t in ipairs(tokens) do - local tok = t.tok - if tok:match("^load_word%s*%(") then - if tok:find("R_TapePtr", 1, true) and tok:find("O_(" .. binds_name .. ",", 1, true) then - local field = tok:match(bind_re) - -- scan: load_word(R_*, R_TapePtr, O_(, )) - if field then - found_field_set[field] = true - else - local body_line = a.line + line_in_body[t.rel] - findings[#findings + 1] = { - atom = a.name, line = body_line, - check = "abi_handoff", kind = "error", - msg = string.format("%s at line %d has load_word(R_TapePtr, O_(%s, )); expected O_(%s, )", - a.name, body_line, binds_name, binds_name), - } - end - end - end - if tok:find("R_TapePtr", 1, true) - and tok:find("S_(" .. binds_name .. ")", 1, true) then - -- scan: add_ui_self(R_TapePtr, S_()) - found_advance = true - end - end - - for _, f in ipairs(binds.fields) do - if not found_field_set[f.name] then - findings[#findings + 1] = { - atom = a.name, line = a.line, - check = "abi_handoff", kind = "error", - msg = string.format("%s at line %d binds %s but never loads field `%s` from R_TapePtr (expected O_(%s, %s))", - a.name, a.line, binds_name, f.name, binds_name, f.name), - } - end - end - - if not found_advance then + local tokens = tokenize_body(atom.body) + local line_in_body = build_body_line_index(atom.body) + local found_field_set = {} + local found_advance = false + local bind_re = "O_%(" .. binds_name .. ",%s*([%w_]+)%s*%)" + for _, t in ipairs(tokens) do + local tok = t.tok + if tok:match("^load_word%s*%(") then + if tok:find("R_TapePtr", 1, true) and tok:find("O_(" .. binds_name .. ",", 1, true) then + local field = tok:match(bind_re) + -- scan: load_word(R_*, R_TapePtr, O_(, )) + if field then + found_field_set[field] = true + else + local body_line = atom.line + line_in_body[t.rel] findings[#findings + 1] = { - atom = a.name, line = a.line, + atom = atom.name, line = body_line, check = "abi_handoff", kind = "error", - msg = string.format("%s at line %d binds %s but never advances R_TapePtr by S_(%s) (= %d bytes / %d words)", - a.name, a.line, binds_name, binds_name, binds.bytes, binds.bytes / 4), + msg = string.format("%s at line %d has load_word(R_TapePtr, O_(%s, )); expected O_(%s, )", + atom.name, body_line, binds_name, binds_name), } end end end + if tok:find("R_TapePtr", 1, true) + and tok:find("S_(" .. binds_name .. ")", 1, true) then + -- scan: add_ui_self(R_TapePtr, S_()) + found_advance = true + end + end + + for _, f in ipairs(binds.fields) do + if not found_field_set[f.name] then + findings[#findings + 1] = { + atom = atom.name, line = atom.line, + check = "abi_handoff", kind = "error", + msg = string.format("%s at line %d binds %s but never loads field `%s` from R_TapePtr (expected O_(%s, %s))", + atom.name, atom.line, binds_name, f.name, binds_name, f.name), + } + end + end + + if not found_advance then + findings[#findings + 1] = { + atom = atom.name, line = atom.line, + check = "abi_handoff", kind = "error", + msg = string.format("%s at line %d binds %s but never advances R_TapePtr by S_(%s) (= %d bytes / %d words)", + atom.name, atom.line, binds_name, binds_name, binds.bytes, binds.bytes / 4), + } end end @@ -495,68 +505,67 @@ end --- - Atoms containing a `mac_(...)` call whose name is not in duffle.GP0_MACRO_CONTRIB emit a "new macro; update duffle.GP0_MACRO_CONTRIB" advisory. --- --- Applies only to `kind = "atom"` (baked atoms). Components don't emit full primitives. -local function check_gpu_portstore_shape(atoms, findings) - for _, a in ipairs(atoms) do - if a.kind == "atom" then - local tokens = tokenize_body(a.body) - local line_in_body = build_body_line_index(a.body) - local cmd_byte = nil - local cmd_line = nil - local contrib = 0 - local saw_format = false - local saw_prim_write = false - for _, t in ipairs(tokens) do - local tok = t.tok - -- Match `mac_format__color(...)` and strip `_color` - -- to get the bare shape suffix (f3 / g4 / etc). - local shape = tok:match("^mac_format_([%w_]+)_color%s*%(") or tok:match("^mac_format_([%w_]+)_color%s*$") - if shape and duffle.GP0_CMD_BY_SHAPE[shape] then - if not cmd_byte then - cmd_byte = duffle.GP0_CMD_BY_SHAPE[shape] - cmd_line = a.line + line_in_body[t.rel] - end - saw_format = true - local contrib_key = "mac_format_" .. shape .. "_color" - local n = duffle.GP0_MACRO_CONTRIB[contrib_key] - if n then contrib = contrib + n end - end - local gte_store = tok:match("^mac_gte_store_[%w_]+") - if gte_store then - local n = duffle.GP0_MACRO_CONTRIB[gte_store] - if n then contrib = contrib + n end - end - local ot_tag = tok:match("^mac_insert_ot_tag_([%w_]+)") - if ot_tag then - local n = duffle.GP0_MACRO_CONTRIB["mac_insert_ot_tag_" .. ot_tag] - if n then contrib = contrib + n end - end - if tok:match("^store_word%s*%(") and tok:find("R_PrimCursor", 1, true) then - saw_prim_write = true - end - end - +--- Per-atom: detect which GP0 primitive the atom is emitting, sum the macro contributions, +--- compare to the expected packet size. Signature changed in Stage 1B: `(atom, findings)`. +local function check_gpu_portstore_shape(atom, findings) + if atom.kind ~= "atom" then return end + local tokens = tokenize_body(atom.body) + local line_in_body = build_body_line_index(atom.body) + local cmd_byte = nil + local cmd_line = nil + local contrib = 0 + local saw_format = false + local saw_prim_write = false + for _, t in ipairs(tokens) do + local tok = t.tok + -- Match `mac_format__color(...)` and strip `_color` + -- to get the bare shape suffix (f3 / g4 / etc). + local shape = tok:match("^mac_format_([%w_]+)_color%s*%(") or tok:match("^mac_format_([%w_]+)_color%s*$") + if shape and duffle.GP0_CMD_BY_SHAPE[shape] then if not cmd_byte then - if saw_prim_write and not saw_format then - findings[#findings + 1] = { - atom = a.name, line = a.line, - check = "gpu_portstore_shape", kind = "warning", - msg = string.format("%s at line %d writes to R_PrimCursor via raw store_word(...)" - .. " but uses no `mac_format_*_color`; the cmd byte + word count cannot be auto-validated." - .. " Consider migrating to `mac_format_X_color` + `mac_gte_store_X_post_*` + `mac_insert_ot_tag_X`.", - a.name, a.line), - } - end - else - local expected = duffle.GP0_CMD_SIZE[cmd_byte] - if contrib ~= expected then - findings[#findings + 1] = { - atom = a.name, line = cmd_line or a.line, - check = "gpu_portstore_shape", kind = "error", - msg = string.format("%s at line %d emits GP0 0x%02X with %d prim word(s); expected %d (cmd 0x%02X total = %d)", - a.name, cmd_line or a.line, cmd_byte, contrib, expected, cmd_byte, expected), - } - end + cmd_byte = duffle.GP0_CMD_BY_SHAPE[shape] + cmd_line = atom.line + line_in_body[t.rel] end + saw_format = true + local contrib_key = "mac_format_" .. shape .. "_color" + local n = duffle.GP0_MACRO_CONTRIB[contrib_key] + if n then contrib = contrib + n end + end + local gte_store = tok:match("^mac_gte_store_[%w_]+") + if gte_store then + local n = duffle.GP0_MACRO_CONTRIB[gte_store] + if n then contrib = contrib + n end + end + local ot_tag = tok:match("^mac_insert_ot_tag_([%w_]+)") + if ot_tag then + local n = duffle.GP0_MACRO_CONTRIB["mac_insert_ot_tag_" .. ot_tag] + if n then contrib = contrib + n end + end + if tok:match("^store_word%s*%(") and tok:find("R_PrimCursor", 1, true) then + saw_prim_write = true + end + end + + if not cmd_byte then + if saw_prim_write and not saw_format then + findings[#findings + 1] = { + atom = atom.name, line = atom.line, + check = "gpu_portstore_shape", kind = "warning", + msg = string.format("%s at line %d writes to R_PrimCursor via raw store_word(...)" + .. " but uses no `mac_format_*_color`; the cmd byte + word count cannot be auto-validated." + .. " Consider migrating to `mac_format_X_color` + `mac_gte_store_X_post_*` + `mac_insert_ot_tag_X`.", + atom.name, atom.line), + } + end + else + local expected = duffle.GP0_CMD_SIZE[cmd_byte] + if contrib ~= expected then + findings[#findings + 1] = { + atom = atom.name, line = cmd_line or atom.line, + check = "gpu_portstore_shape", kind = "error", + msg = string.format("%s at line %d emits GP0 0x%02X with %d prim word(s); expected %d (cmd 0x%02X total = %d)", + atom.name, cmd_line or atom.line, cmd_byte, contrib, expected, cmd_byte, expected), + } end end end @@ -758,42 +767,42 @@ local function analyze_atom_paths(atom) local branch_count = 0 for _ in pairs(branches) do branch_count = branch_count + 1 end - return { - cycles_min = cycles_min, - cycles_max = cycles_max, - branches = branch_count, - paths = path_count, - has_loops = has_loops, - unknown_macros = unknown_list, - } + -- Mutate the pre-allocated `atom.paths` slot in place (caller owns the table). + -- Mega-struct move: a single source of truth for all per-atom path-analysis data, + -- instead of returning a fresh table that would just get copied onto 5 atom fields. + -- If a caller ever DIDN'T pre-allocate (legacy code path), fall back to a fresh slot. + local p = atom.paths or {} + p.cycles_min = cycles_min + p.cycles_max = cycles_max + p.branches = branch_count + p.paths = path_count + p.has_loops = has_loops + p.unknown_macros = unknown_list + atom.paths = p end --- Per-source check that emits one finding per unknown macro seen --- (deduplicated across atoms so the warning section doesn't get spammed with N copies of "macro X not in duffle.INSTRUCTION_LATENCY"). -local function check_per_atom_cycle_budget(atoms, findings) - local unknown_seen = {} - for _, a in ipairs(atoms) do - local tokens = tokenize_body(a.body) - local unknown_set = {} - for _, t in ipairs(tokens) do - local _, _, unknown = token_cycles(t.tok) - if unknown then - unknown_set[t.tok:match("^([%w_]+)") or "?"] = true - end - end - local unknown_macros = {} - for macro_name in pairs(unknown_set) do unknown_macros[#unknown_macros + 1] = macro_name end - for _, name in ipairs(unknown_macros) do - if not unknown_seen[name] then - unknown_seen[name] = a.line - findings[#findings + 1] = { - atom = a.name, line = a.line, - check = "per_atom_cycle_budget", kind = "warning", - msg = string.format("%s at line %d uses macro `%s` which is not in duffle.INSTRUCTION_LATENCY; " - .. "cycle count will be +%d per call (best-case). Add an entry to duffle.INSTRUCTION_LATENCY.", - a.name, a.line, name, duffle.UNKNOWN_INSTRUCTION_CYCLES), - } - end +--- Reuses `analyze_atom_paths`'s per-atom unknown_macros discovery (it's the canonical place that walks tokens +--- and computes per-token cycle costs). We just sort + emit. +--- Per-atom: emit one finding per unknown macro seen, deduplicated across atoms (so the warning +--- section doesn't get spammed with N copies of "macro X not in duffle.INSTRUCTION_LATENCY"). +--- Reuses `analyze_atom_paths`'s per-atom unknown_macros discovery (it's the canonical place that walks tokens +--- and computes per-token cycle costs). We just sort + emit. +--- Signature changed in Stage 1B: `(atom, pipe_ctx, findings)` — the `unknown_seen` dedup table lives on +--- `pipe_ctx` so it persists across the per-atom loop in validate(). +local function check_per_atom_cycle_budget(atom, pipe_ctx, findings) + local p = atom.paths or {} + for _, name in ipairs(p.unknown_macros or {}) do + if not pipe_ctx.unknown_seen[name] then + pipe_ctx.unknown_seen[name] = atom.line + findings[#findings + 1] = { + atom = atom.name, line = atom.line, + check = "per_atom_cycle_budget", kind = "warning", + msg = string.format("%s at line %d uses macro `%s` which is not in duffle.INSTRUCTION_LATENCY; " + .. "cycle count will be +%d per call (best-case). Add an entry to duffle.INSTRUCTION_LATENCY.", + atom.name, atom.line, name, duffle.UNKNOWN_INSTRUCTION_CYCLES), + } end end end @@ -816,22 +825,42 @@ local function validate(ctx, src) binds_index[b.name] = b end - local findings = {} - check_gte_pipeline_fill(atoms, findings) - check_mac_yield_uniformity(atoms, findings) - check_abi_handoff(atoms, atom_infos, binds_index, findings) - check_gpu_portstore_shape(atoms, findings) - check_per_atom_cycle_budget(atoms, findings) + -- pipe_ctx: the cross-atom shared state for the per-atom pipeline (Fleury "expose structure"). + -- Pre-allocated here, mutated by each per-atom check call below. Replaces the per-check + -- local tables that used to live inside each check_* function body. + -- info_by_atom — atom_name -> atom_info (built once; check_abi_handoff reads it) + -- binds_index — Binds_X -> binds struct (built once; check_abi_handoff reads it) + -- unknown_seen — macro_name -> first atom line (accumulated across atoms; check_per_atom_cycle_budget dedups) + local info_by_atom = {} + for _, info in ipairs(atom_infos) do + info_by_atom[info.atom_name] = info + end + local pipe_ctx = { + info_by_atom = info_by_atom, + binds_index = binds_index, + unknown_seen = {}, + } - -- Path-aware cycle-budget output: attach per-path cycle data to each atom. + -- THE per-atom pipeline. ONE iteration of atoms; the 5 check_* functions + analyze_atom_paths + -- all run here, sharing a single tokenize_body + build_body_line_index per body. + -- Plex move: every piece of state derived from an atom body lives on `atom.paths` (the per-atom mega-struct); + -- readers (analyze_atom_paths, the 5 checks, the renderers) all consume `atom.paths`, not the raw `atoms` list. + -- Stage 1B: each check_* now takes `(atom, ...)` instead of `(atoms, findings)` — no more single-atom `{a}` shim. + local findings = {} for _, a in ipairs(atoms) do - local p = analyze_atom_paths(a) - a.paths = p - a.cycles_min = p.cycles_min - a.cycles_max = p.cycles_max - a.branch_count = p.branches - a.unknown_macros = p.unknown_macros - a.has_loops = p.has_loops + a.paths = a.paths or {} + a.paths.tokens = tokenize_body(a.body) + a.paths.line_in_body = build_body_line_index(a.body) + + -- analyze_atom_paths fills the *cycles / branches / has_loops / unknown_macros* fields of a.paths. + analyze_atom_paths(a) + + -- Run all 5 checks on this one atom. Each is now a per-atom predicate. + check_gte_pipeline_fill(a, findings) + check_mac_yield_uniformity(a, findings) + check_abi_handoff(a, pipe_ctx, findings) + check_gpu_portstore_shape(a, findings) + check_per_atom_cycle_budget(a, pipe_ctx, findings) end local errors = {} @@ -987,16 +1016,16 @@ local function emit_module_static_analysis_txt(ctx, dir, dir_sources, atoms, fin -- Sort atoms by max cycles descending for quick scanning. local sorted = {} for _, a in ipairs(atoms) do sorted[#sorted + 1] = a end - table.sort(sorted, function(x, y) return (x.cycles_max or 0) > (y.cycles_max or 0) end) + table.sort(sorted, function(x, y) return ((x.paths or {}).cycles_max or 0) > ((y.paths or {}).cycles_max or 0) end) for _, a in ipairs(sorted) do local p = a.paths or {} local br_count = p.branches or 0 local path_count = p.paths or 0 local loops_tag = p.has_loops and " [loop!]" or "" local unknown_tag = "" - if a.unknown_macros and #a.unknown_macros > 0 then + if p.unknown_macros and #p.unknown_macros > 0 then unknown_tag = string.format(" [unknown: %s]", - table.concat(a.unknown_macros, ", ")) + table.concat(p.unknown_macros, ", ")) end local name_label = a.name if multi_source and a.source_path then