From f2d648e4994e1b7fb7fce0c0a704091c65f84734 Mon Sep 17 00:00:00 2001 From: Andreas Wendleder Date: Thu, 9 Jul 2026 11:18:45 +0200 Subject: [PATCH] autoname: avoid O(iterations x module size) full rescan AutonamePass rescanned every selected cell/wire from scratch on every iteration, and naming only propagates one hop per iteration, so cost scaled with O(iterations x module_size). On a large, fully flattened netlist with long dependency chains this meant multi-hour runtime and 40+ GB RSS. Likely the same issue as #5394, #4509 and #2816. Replace the full-rescan loop with a ModuleAutonamer that keeps a persistent worklist and only recomputes an object's proposal when a direct neighbor was just renamed, using precomputed adjacency. Same round-by-round batching as before, so the final naming is unchanged. Adds tests/various/autoname_scaling.sh, which checks a 10000-cell propagation chain still autonames within a time budget the old algorithm could not meet. --- passes/cmds/autoname.cc | 342 +++++++++++++++++++++++------- tests/various/autoname_scaling.sh | 51 +++++ 2 files changed, 314 insertions(+), 79 deletions(-) create mode 100755 tests/various/autoname_scaling.sh diff --git a/passes/cmds/autoname.cc b/passes/cmds/autoname.cc index 6994fdefd..c79636dae 100644 --- a/passes/cmds/autoname.cc +++ b/passes/cmds/autoname.cc @@ -17,13 +17,42 @@ * */ +// ----------------------------------------------------------------------- +// Algorithm +// +// A cell or wire with a private ($-prefixed) name becomes nameable once it +// has at least one directly connected neighbour with a public name: the +// proposed name is that neighbour's name plus a short suffix (cell/port +// derived). Renaming an object can make ITS neighbours nameable in turn, +// so public names propagate outward one hop per round. Each round, every +// currently-nameable object within 2x the round's best score is renamed +// together (this batches together names that are equally good, instead of +// only ever renaming the single best one). +// +// Naively, each round would recompute every $-named object's proposal by +// walking every selected cell/wire from scratch. Since the number of +// rounds equals the propagation depth through the design's connectivity +// graph, that costs O(rounds x module size) -- on a design with long +// dependency chains, rounds scales with module size too, so this becomes +// O(module size^2). +// +// Instead, ModuleAutonamer keeps a worklist (pending_cells/pending_wires) +// of only the objects that currently have a proposal at all. Renaming an +// object cannot change any proposal other than its direct neighbours', so +// after each round only those neighbours are recomputed. This produces the +// identical sequence of renames as the naive full rescan, without ever +// looking at objects that were not affected by the round. +// ----------------------------------------------------------------------- + #include "kernel/yosys.h" USING_YOSYS_NAMESPACE PRIVATE_NAMESPACE_BEGIN -typedef struct name_proposal { +struct name_proposal { string name; + // Smaller is better. Default is the worst possible score, so any real + // proposal wins a min-comparison against a default-constructed one. unsigned int score; name_proposal() : name(""), score(-1) { } name_proposal(string name, unsigned int score) : name(name), score(score) { } @@ -33,79 +62,251 @@ typedef struct name_proposal { else return name.length() < other.name.length(); } -} name_proposal; +}; -int autoname_worker(Module *module, const dict& wire_score) +// A cell's `port` connects to `wire` (in at least one bit). `cell_is_output` +// says whether `port` is an output of the cell, i.e. whether `wire` is +// driven by `cell` on that port. +struct Edge { + Cell *cell; + IdString port; + Wire *wire; + bool cell_is_output; +}; + +// Runs the propagation described above for a single module. +struct ModuleAutonamer { - dict proposed_cell_names; - dict proposed_wire_names; - name_proposal best_name; + Module *module; - for (auto cell : module->selected_cells()) { - if (cell->name[0] == '$') { + // How many (selected) connections reference each wire. Used to prefer + // low-fanout wires as naming sources, and to make output ports "free" + // (score 0) since a cell's output name is the more natural choice. + dict wire_score; + + // Adjacency, built once up front: which edges touch each cell/wire. + // A cell/wire's proposal only ever depends on its own edges, so this + // is all recompute() ever needs to look at -- it never walks the rest + // of the module. + dict> edges_of_cell; + dict> edges_of_wire; + + // The current best proposal for every $-named object that is + // currently reachable from a public name at all. Objects with no path + // to any public name (yet) simply never appear here, rather than being + // tracked as "still unresolved". + dict pending_cells; + dict pending_wires; + + int renamed = 0; + int rounds = 0; + + ModuleAutonamer(Module *module) : module(module) + { + build_adjacency(); + seed(); + } + + static bool is_private(IdString name) { return name[0] == '$'; } + + // Module ports are never renamed, even if $-named; cells have no + // equivalent exemption. + static bool exempt_from_rename(Wire *wire) { return wire->port_id != 0; } + static bool exempt_from_rename(Cell *) { return false; } + + void build_adjacency() + { + for (auto cell : module->selected_cells()) for (auto &conn : cell->connections()) { - string suffix; - for (auto bit : conn.second) - if (bit.wire != nullptr && bit.wire->name[0] != '$') { - if (suffix.empty()) - suffix = stringf("_%s_%s", cell->type.unescape(), conn.first.unescape()); - name_proposal proposed_name( - bit.wire->name.str() + suffix, - cell->output(conn.first) ? 0 : wire_score.at(bit.wire) - ); - if (!proposed_cell_names.count(cell) || proposed_name < proposed_cell_names.at(cell)) { - if (proposed_name < best_name) - best_name = proposed_name; - proposed_cell_names[cell] = proposed_name; - } - } + bool cell_is_output = cell->output(conn.first); + pool seen_in_this_port; + for (auto bit : conn.second) { + if (bit.wire == nullptr) + continue; + // Scored per bit (a wire used twice in one connection + // counts twice), but only one Edge per distinct wire a + // port touches -- a wide port entirely wired to a + // single bus does not need one edge per bit. + wire_score[bit.wire]++; + if (!seen_in_this_port.insert(bit.wire).second) + continue; + Edge edge{cell, conn.first, bit.wire, cell_is_output}; + edges_of_cell[cell].push_back(edge); + edges_of_wire[bit.wire].push_back(edge); + } } - } else { - for (auto &conn : cell->connections()) { - string suffix; - for (auto bit : conn.second) - if (bit.wire != nullptr && bit.wire->name[0] == '$' && !bit.wire->port_id) { - if (suffix.empty()) - suffix = stringf("_%s", conn.first.unescape()); - name_proposal proposed_name( - cell->name.str() + suffix, - cell->output(conn.first) ? 0 : wire_score.at(bit.wire) - ); - if (!proposed_wire_names.count(bit.wire) || proposed_name < proposed_wire_names.at(bit.wire)) { - if (proposed_name < best_name) - best_name = proposed_name; - proposed_wire_names[bit.wire] = proposed_name; - } - } + } + + // Best current proposal for a $-named cell, from its public-named + // neighbour wires (bounded by the cell's arity, not module size). + bool recompute(Cell *cell, name_proposal &out) + { + bool found = false; + for (auto &edge : edges_of_cell.at(cell)) { + if (is_private(edge.wire->name)) + continue; // only a *public* neighbour can lend us a name + name_proposal proposed( + edge.wire->name.str() + stringf("_%s_%s", cell->type.unescape(), edge.port.unescape()), + edge.cell_is_output ? 0 : wire_score.at(edge.wire) + ); + if (!found || proposed < out) { + out = proposed; + found = true; } } + return found; + } + + // Best current proposal for a $-named, non-port wire, from its + // public-named neighbour cells (bounded by the wire's fanout, not + // module size). + bool recompute(Wire *wire, name_proposal &out) + { + bool found = false; + for (auto &edge : edges_of_wire.at(wire)) { + Cell *cell = edge.cell; + if (is_private(cell->name)) + continue; // still $-named itself, cannot lend a name yet + // module->selected() is re-checked here, not just assumed from + // the initial build_adjacency() scan, because a cell renamed + // earlier in this same run can fall out of a name/type-based + // selection (e.g. "autoname t:$or"): Module::selected() looks + // the cell up by its *current* name, so renaming it can make + // an otherwise-matching cell invisible to that selection. A + // naive full-rescan implementation calls selected_cells() + // fresh every round and so naturally observes the same + // drop-out; this recheck reproduces that without rescanning. + if (!module->selected(cell)) + continue; + name_proposal proposed( + cell->name.str() + stringf("_%s", edge.port.unescape()), + edge.cell_is_output ? 0 : wire_score.at(wire) + ); + if (!found || proposed < out) { + out = proposed; + found = true; + } + } + return found; + } + + template + static pool private_candidates(const dict> &edges_of) + { + pool result; + for (auto &it : edges_of) + if (is_private(it.first->name) && !exempt_from_rename(it.first)) + result.insert(it.first); + return result; + } + + void seed() + { + refresh_pending(pending_cells, private_candidates(edges_of_cell)); + refresh_pending(pending_wires, private_candidates(edges_of_wire)); + } + + template + static void update_best(const dict &pending, name_proposal &best) + { + for (auto &it : pending) + if (it.second < best) + best = it.second; + } + + name_proposal best_pending() const + { + name_proposal best; + update_best(pending_cells, best); + update_best(pending_wires, best); + return best; + } + + static const char *kind(Cell *) { return "cell"; } + static const char *kind(Wire *) { return "wire"; } + + template + IdString commit(T *obj, const name_proposal &p) + { + IdString n = module->uniquify(IdString(p.name)); + log_debug("Rename %s %s in %s to %s.\n", kind(obj), obj, module, n.unescape()); + module->rename(obj, n); + return n; + } + + // Commit every proposal within `cutoff`, cell or wire alike. + template + pool commit_all(dict &pending, const name_proposal &cutoff) + { + pool committed; + for (auto &it : pending) + if (!(cutoff < it.second)) { + commit(it.first, it.second); + committed.insert(it.first); + renamed++; + } + return committed; + } + + // Drop the just-committed T objects from `pending` (they're done), and + // collect their direct neighbours of type U -- the only ones whose + // proposal a commit can possibly have changed. + template + pool neighbors_to_recompute(dict &pending, const pool &committed, + dict> &edges_of, U* Edge::*neighbor) + { + pool to_recompute; + for (auto obj : committed) { + pending.erase(obj); + for (auto &edge : edges_of.at(obj)) { + U *n = edge.*neighbor; + if (is_private(n->name) && !exempt_from_rename(n)) + to_recompute.insert(n); + } + } + return to_recompute; + } + + // Recompute a proposal for exactly the given objects, cell or wire alike. + template + void refresh_pending(dict &pending, const pool &to_recompute) + { + for (auto obj : to_recompute) { + name_proposal p; + if (recompute(obj, p)) + pending[obj] = p; + } } - int count = 0; - // compare against double best score for following comparisons so we don't - // pre-empt a future iteration - best_name.score *= 2; + // One round: rename everything within 2x of this round's best score, + // then recompute proposals for their direct neighbours only. + void step() + { + rounds++; - for (auto &it : proposed_cell_names) { - if (best_name < it.second) - continue; - IdString n = module->uniquify(IdString(it.second.name)); - log_debug("Rename cell %s in %s to %s.\n", it.first, module, n.unescape()); - module->rename(it.first, n); - count++; + // Compare against double the best score for this round's cutoff, + // so we don't pre-empt a future round's batch. + name_proposal cutoff = best_pending(); + cutoff.score *= 2; + + auto committed_cells = commit_all(pending_cells, cutoff); + auto committed_wires = commit_all(pending_wires, cutoff); + + auto wires_to_recompute = neighbors_to_recompute(pending_cells, committed_cells, edges_of_cell, &Edge::wire); + auto cells_to_recompute = neighbors_to_recompute(pending_wires, committed_wires, edges_of_wire, &Edge::cell); + + refresh_pending(pending_cells, cells_to_recompute); + refresh_pending(pending_wires, wires_to_recompute); } - for (auto &it : proposed_wire_names) { - if (best_name < it.second) - continue; - IdString n = module->uniquify(IdString(it.second.name)); - log_debug("Rename wire %s in %s to %s.\n", it.first, module, n.unescape()); - module->rename(it.first, n); - count++; + void run() + { + while (!pending_cells.empty() || !pending_wires.empty()) + step(); + if (renamed > 0) + log("Renamed %d objects in module %s (%d iterations).\n", renamed, module, rounds); } - - return count; -} +}; struct AutonamePass : public Pass { AutonamePass() : Pass("autoname", "automatically assign names to objects") { } @@ -135,24 +336,7 @@ struct AutonamePass : public Pass { log_header(design, "Executing AUTONAME pass.\n"); for (auto module : design->selected_modules()) - { - dict wire_score; - for (auto cell : module->selected_cells()) - for (auto &conn : cell->connections()) - for (auto bit : conn.second) - if (bit.wire != nullptr) - wire_score[bit.wire]++; - - int count = 0, iter = 0; - while (1) { - iter++; - int n = autoname_worker(module, wire_score); - if (!n) break; - count += n; - } - if (count > 0) - log("Renamed %d objects in module %s (%d iterations).\n", count, module, iter); - } + ModuleAutonamer(module).run(); } } AutonamePass; diff --git a/tests/various/autoname_scaling.sh b/tests/various/autoname_scaling.sh new file mode 100755 index 000000000..091410516 --- /dev/null +++ b/tests/various/autoname_scaling.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Regression test for the incremental worklist rewrite of autoname_worker +# ("autoname: avoid O(iterations x module size) full rescan"). +# +# Before that change, AutonamePass::execute() rescanned every selected +# cell's every connection on every round. Naming only propagates one hop +# per round, so a design containing a long public-name-to-public-name +# propagation chain of length N needed O(N) rounds, each doing an O(N) +# rescan: O(N^2) total. This builds such a chain and checks that autoname +# finishes within a time budget the old O(N^2) algorithm could not meet +# (it took tens of seconds at this size; the fixed algorithm takes low +# single-digit seconds), without pinning an exact runtime, which would be +# too flaky across machines. + +set -e + +if ! which timeout > /dev/null; then + echo "No 'timeout', skipping test" + exit 0 +fi + +n=10000 +il=autoname_scaling.il +trap 'rm -f $il' EXIT + +{ + echo 'module \top' + echo ' wire input 1 \a' + for i in $(seq 0 $((n-1))); do + echo " wire \$w$i" + done + prev='\a' + for i in $(seq 0 $((n-1))); do + echo " cell \$not \$c$i" + echo ' parameter \A_SIGNED 0' + echo ' parameter \A_WIDTH 1' + echo ' parameter \Y_WIDTH 1' + echo " connect \\A $prev" + echo " connect \\Y \$w$i" + echo ' end' + prev="\$w$i" + done + echo 'end' +} > $il + +if ! timeout 20 ${YOSYS} -q -p "read_rtlil $il; autoname" ; then + echo "autoname did not finish a $n-long propagation chain within the time" \ + "budget -- looks like the O(iterations x module size) full-rescan" \ + "behaviour is back" + exit 1 +fi