Skip to content

Fix GUI freezes on large netlists, a nullptr crash in Net, and complete the plugin manager bindings - #635

Merged
julianspeith merged 11 commits into
masterfrom
bugfix/misc
Aug 12, 2026
Merged

Fix GUI freezes on large netlists, a nullptr crash in Net, and complete the plugin manager bindings#635
julianspeith merged 11 commits into
masterfrom
bugfix/misc

Conversation

@julianspeith

@julianspeith julianspeith commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

A collection of unrelated fixes, kept in one branch as they are all self contained. The first group came out of profiling the GUI on the OpenTitan benchmark of the HAWKEYE artifacts, 424866 gates in a single module, the rest out of going through the documentation.

The GUI froze or stalled on large netlists

Selecting a module hung the GUI indefinitely. ModuleModel::populateTree() correctly brackets the population with beginResetModel() and endResetModel(), but createChildItem() emitted beginInsertRows() and endInsertRows() for every single item in between. Besides being undefined per the QAbstractItemModel contract, it made the attached QSortFilterProxyModel remap its source rows once per inserted row, which is quadratic in the number of items.

A sample taken while the GUI was busy put 10543 of 16692 main thread samples below QAbstractItemModel::endInsertRows(), and it was still in the same place at 100% CPU nine minutes later. Measured separately with a QSortFilterProxyModel over a plain list model, populating with a row signal per item takes 0.84 s for 50000 items, 3.29 s for 100000 and 14.71 s for 200000, while populating within a plain model reset is too fast to measure. OpenTitan's top module holds all of its 424866 gates directly, so this was about 1.8e11 units of work instead of 4.2e5.

The same two functions also reset mIsModifying to false unconditionally, clobbering the true that populateTree() had set, so the guard in ModuleWidget was only in effect for the very first item. mIsModifying was never initialized in the constructor either.

Unfolding a large module took seconds. Without uniform row heights, QTreeViewPrivate::itemHeight() measures every row individually through QTreeView::indexRowSizeHint(), which asks the item delegate for a size hint, which lays out and shapes the item text. A sample taken while unfolding shows 5094 of 8032 main thread samples below itemHeight(), of which 2345 end in QTextEngine::shapeText() and 821 in CTFontGetGlyphsForCharacters(): the view spent the unfold shaping gate names just to learn how tall their rows are. All rows of the four affected views are plain single line text in one font.

The module elements tree was rebuilt twice per selection. SelectionDetailsWidget::handleSelectionUpdate() drives the details widget directly and also populates the selection tree, whose current index change drives the very same widget again through triggerSelection(). A sample shows populateTree() running three times for one selection, 321, 313 and 307 samples of 1358 in that branch, each building one item per gate and per net of the module.

Crash on a nullptr pin, reachable from Python

Net::remove_source(Gate*, const GatePin*) and Net::remove_destination(Gate*, const GatePin*) dereferenced the pin without checking it:

std::find_if(..., [gate, pin](auto ep) { return ep->get_gate() == gate && *ep->get_pin() == *pin; });

The string overloads never reach that state, because they reject an unknown pin name before delegating. The pin overloads, however, are called directly by netlist_preprocessing and netlist_utils, and they are bound to Python, where this was enough to terminate the interpreter:

net.is_a_source(gate, None)      # -> False
net.remove_source(gate, None)    # -> process dies

is_a_source and is_a_destination already guarded both arguments; the two removal overloads now do the same, so the call returns False and logs a warning.

Pin lookup compared pins by value instead of by identity

The same lambdas compared *ep->get_pin() == *pin, so a distinct but equal GatePin of another gate type would match. Every pin of a gate comes from that gate's type, so identity is what these lookups mean. Changed in the four Net lookups and in Gate::get_fan_in_endpoint / get_fan_out_endpoint.

Net::operator== keeps comparing by value on purpose: it compares endpoints of two different nets, which do not share pin objects.

Missing Python bindings of the plugin manager

plugin_manager.h carried seven TODO Python binding markers. All are now resolved:

  • get_cli_plugin_flags() and get_ui_plugin_flags() return a dict.
  • add_model_changed_callback() and remove_model_changed_callback() let a Python callable observe plugins being loaded and unloaded.
  • get_plugin_instance() gained the initialize and silent parameters. The other half of that TODO, "bindings for different types", needed no work: pybind11 already returns the plugin's own type once that plugin's Python module has been imported, and the base interface otherwise. The docstring now says so.
  • add_existing_options_description(), get_cli_plugin_options(), get_plugin_path(), has_valid_file_extension() and get_plugin_features() required binding the types they use, so ProgramOptions, ProgramArguments, FacExtensionInterface with its Feature enum, and plugin_manager::PluginFeature are now bound as well.

Two details worth a look during review:

ProgramOptions::add_flags() is new. add() takes its flags as a std::initializer_list, which cannot be built from a container at runtime and is therefore not bindable. Retyping the parameter to std::vector is not an option either: add({"-h", "--help"}, "...") then becomes ambiguous, because std::string has an iterator-pair constructor and the braced list is a viable argument for the single-flag overload as well. The vector-based implementation is therefore exposed under the new name add_flags(), to which both existing overloads delegate. All 87 existing call sites are untouched, and Python binds add_flags() as add().

ProgramArguments::get_original_arguments() is deliberately not bound. ProgramArguments stores the argv pointer without owning the strings, which no longer exist once parse() has returned to Python.

Faster Boolean function comparison

BooleanFunction::operator< compared two functions of equal node count by building their reverse polish notation strings and comparing those. The symbolic state keeps its variables in a std::map<BooleanFunction, BooleanFunction>, so every variable lookup during evaluation formatted several strings, and since all keys are single variable nodes the node count never discriminated. Comparing the nodes directly is equivalent, Node::operator< is already a field wise comparison consistent with Node::operator==. Neither of the two maps keyed by a Boolean function is ever iterated, so the changed order is not observable.

Together with two smaller fixes in the same path, constant_propagation() copying the value vector of every operand twice and evaluate() validating its inputs by comparing every input name against every node, this takes a truth table of a 50 node function over 8 variables from 5.17 ms to 3.81 ms. PR #636 builds on this.

HAWKEYE S-box database and the crypto_trojan example

The S-box database is now copied into the build directory so that it is found at runtime, the way the gate library definitions already are, and the crypto_trojan and toy_cipher examples were refreshed. identify_sbox() returning an empty string means that no S-box of the database matched, which is not an error; the documentation said otherwise.

Removed the stale Python binding tests

tests/python_binding/ is not referenced by the build or by any workflow, so nothing has run these files in a long time. They would not run either: they still call boolean_function(), value.ONE, get_variables(), get_truth_table(), is_constant_zero(), to_dnf() and optimize(), none of which are bound any more. The C++ suite under tests/netlist covers the same ground.

Testing

  • Added direct tests for the two pin overloads of remove_source and remove_destination, covering removal, the wrong pin, a nullptr pin, a nullptr gate, a gate that is not connected, and removing twice. They previously had no direct coverage at all, which is what the removed TODO test comments asked for. The other six of those TODOs were stale: the string overloads, the endpoint overloads, and both pin overloads of is_a_source and is_a_destination are already covered.
  • Verified from Python that the crash is gone, that the plugin callback fires once per plugin and stops after removal, that both flag getters return a dict, that get_plugin_instance(name, False) skips initialization and get_plugin_instance(name, True, True) returns None for an unknown plugin without logging, and that options can be added with one or several flags, parsed, queried, removed and merged.
  • runTest-net and runTest-gate pass in full, and the wider suite is unchanged.
  • The GUI changes are verified by measurement rather than by a test, since the item models have no test coverage. The quadratic behaviour was reproduced and the fix confirmed with a standalone QSortFilterProxyModel benchmark, and the row height and duplicate rebuild costs were each identified in a sample of the running GUI. Worth exercising by hand on a large netlist: select the top module, unfold it, then select a module, a gate and the same module again, to confirm the details still populate correctly.

🤖 Generated with Claude Code

julianspeith and others added 4 commits August 11, 2026 19:34
Net::remove_source(Gate*, const GatePin*) and its destination counterpart
dereferenced the pin without checking it, so passing a nullptr crashed. The
string overloads never reach that state because they reject unknown pin names
first, but the pin overloads are called directly by netlist_preprocessing and
netlist_utils, and they are exposed to Python, where net.remove_source(g, None)
terminated the interpreter. Their is_a_source and is_a_destination counterparts
already guarded both arguments, so add the same guards for consistency.

While looking up an endpoint, Net and Gate compared the pin by value rather than
by pointer identity, so a distinct but equal GatePin of another gate type would
match. All pins of a gate come from its gate type, so identity is what these
lookups mean. Net::operator== keeps comparing by value, since it compares
endpoints of two different nets that do not share pin objects.

Add direct tests for the pin overloads of remove_source and remove_destination
covering removal, the wrong pin, a nullptr pin, a nullptr gate, a gate that is
not connected, and removing twice. They previously had no direct coverage at
all, which the removed TODOs asked for. The remaining six TODOs were stale: the
string overloads, the endpoint overloads, and both pin overloads of is_a_source
and is_a_destination are already covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five of the seven Python binding TODOs in plugin_manager.h are addressed:

* get_cli_plugin_flags() and get_ui_plugin_flags() are bound and return a dict.
* add_model_changed_callback() and remove_model_changed_callback() are bound, so
  a Python callable can be notified about loaded and unloaded plugins.
* get_plugin_instance() gained the initialize and silent parameters. The other
  half of that TODO, bindings for different plugin types, needs no work:
  pybind11 already returns the plugin's own type once the Python module of that
  plugin has been imported, and the base interface otherwise. The docstring now
  says so.

The remaining two TODOs, add_existing_options_description() and
get_cli_plugin_options(), both take or return a ProgramOptions, which has no
Python bindings at all. They keep a TODO that names that prerequisite instead of
just saying "TODO Python binding".

Verified from Python: the callback fires once per plugin on load and stops after
removal, both flag getters return a dict, get_plugin_instance(name, False) skips
initialization, and get_plugin_instance(name, True, True) returns None for an
unknown plugin without logging an error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the Python bindings of plugin_manager by binding the types it depends
on:

* ProgramOptions and ProgramArguments, needed by add_existing_options_description()
  and get_cli_plugin_options().
* FacExtensionInterface including its Feature enum, and plugin_manager::PluginFeature,
  needed by get_plugin_features().

With those in place, add_existing_options_description(), get_cli_plugin_options(),
get_plugin_path(), has_valid_file_extension(), and get_plugin_features() are bound,
and plugin_manager.h no longer carries any TODO.

Two points worth noting:

ProgramOptions::add() takes its flags as a std::initializer_list, which cannot be
built from a container at runtime and is therefore not bindable. Changing the
parameter to a std::vector is not an option either, because a call such as
add({"-h", "--help"}, "...") then becomes ambiguous: std::string has an
iterator-pair constructor, so the braced list is a viable argument for the
single-flag overload as well. The vector-based implementation is therefore
exposed under the new name add_flags(), to which both existing overloads
delegate, so that all 87 call sites keep working unchanged. Python binds
add_flags() as add().

ProgramArguments::get_original_arguments() is deliberately not bound.
ProgramArguments stores the argv pointer without owning the strings, which no
longer exist once parse() has returned to Python, so exposing it would hand out
a dangling pointer.

Verified from Python: options can be added with one or several flags, parsed
from an argument list, queried, removed, and merged into another set; plugin
features report the parser of the Verilog plugin with its .v extension.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@julianspeith
julianspeith enabled auto-merge (squash) August 12, 2026 07:41
julianspeith and others added 7 commits August 12, 2026 10:18
Selecting a module made the GUI hang for minutes on large designs. Selecting
the top module of the OpenTitan benchmark, which holds all of its 424866 gates
directly, froze HAL indefinitely.

ModuleModel::populateTree() correctly brackets the population with
beginResetModel() and endResetModel(), but createChildItem() emitted
beginInsertRows()/endInsertRows() for every single item in between. Besides
being undefined per the QAbstractItemModel contract, it made the attached
SelectionTreeProxyModel re-map its source rows once per inserted row, which is
quadratic in the number of items.

Measured with a QSortFilterProxyModel over a plain list model, populating with
a row signal per item takes 0.84 s for 50000 items, 3.29 s for 100000 and
14.71 s for 200000, while populating within a plain model reset is too fast to
measure. Extrapolated to 424866 items that is more than a minute of pure proxy
bookkeeping for a bare model, and considerably more for real tree items.

createChildItem() and removeChildItem() now skip their row signals while a
reset is in progress, since the reset already tells the views to re-read
everything.

Both functions also reset mIsModifying to false unconditionally, clobbering the
true that populateTree() had set, so the guard in ModuleWidget was only in
effect for the very first item. They now restore the previous value.
mIsModifying was never initialized in the constructor either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Unfolding the top module of the OpenTitan benchmark, which holds all of its
424866 gates directly, kept the GUI busy for seconds.

A sample of the GUI taken while unfolding shows 5094 of 8032 main thread
samples below QTreeViewPrivate::itemHeight, reached from updateScrollBars()
when the expand is applied. Without uniform row heights that function measures
rows individually through QTreeView::indexRowSizeHint(), which asks the item
delegate for a size hint, which lays out and shapes the item text. 2345 samples
end up in QTextEngine::shapeText() and 821 in CTFontGetGlyphsForCharacters(),
so the view spends most of the unfold shaping gate names just to learn how tall
their rows are.

All rows of these views are plain single line text in one font, so their height
is constant and QTreeViewPrivate::itemHeight() can return it directly.

Applies to the four views that can hold one item per gate or net of a netlist:
the selection details tree, the module elements tree, the module widget tree
and the module pins tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add the S-box database from the HAWKEYE artifacts to the plugin and copy
it into the build directory at build time, analogous to how the gate
library definitions are handled. The plugin's .gitignore is an allowlist,
so the database needs an explicit exception to be tracked.

Turn the placeholder py/hawkeye.py of the crypto_trojan example into a
working script that detects state register candidates, isolates the round
function, and identifies its S-box. On this netlist it reports AES.

Both crypto_trojan and toy_cipher shipped outdated copies of their gate
library that predate the c_* gate type properties: 101 of 163 cells in
lsi_10k and 44 of 273 in XILINX_UNISIM were missing them. As a result,
any analysis selecting gates by property found nothing in those projects.
In crypto_trojan this made netlist_preprocessing::unify_ff_outputs fail
with "gate library does not contain an inverter gate", leaving the FD1
Q/QN dual outputs in place and causing errors during S-box identification.
Both libraries are refreshed from the shipped definitions.

Also correct the documentation of identify_sbox: it returns an empty
string when no S-box of the database matches, and only reports an error
if the candidate could not be analyzed. Both the C++ and the Python
documentation previously described a no-match as an error.
Selecting the top module of the OpenTitan benchmark took over a second.

A sample of the GUI shows ModuleModel::populateTree() running three times for a
single selection, 321, 313 and 307 samples of 1358 in that branch. Once for the
selection details tree and twice for the module elements tree, because
SelectionDetailsWidget::handleSelectionUpdate() drives the details widget
directly and also populates the selection tree, whose current index change
drives the very same details widget through triggerSelection().

Each of those runs builds one tree item per gate and per net of the module,
about 825000 items for the OpenTitan top module. Two thirds of the time goes
into the net items in moduleAssignNets(), the rest into the gate items in
addRecursively() and into destroying the previous tree.

ModuleElementsTree already tracks the module it displays, so it can skip the
rebuild if it is asked for the same one again. The model keeps itself up to
date through the netlist relay, so the rebuild is not what refreshes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A sample of a HAWKEYE S-box identification run spent 96% of the main thread in
BooleanFunction::compute_truth_table(), which evaluates the function once per
truth table row.

Three fixes, measured on a 50 node function over 8 variables, 256 rows per
truth table, 5.17 ms per table before:

BooleanFunction::operator< compared two functions of equal node count by
building their reverse polish notation strings and comparing those. The
symbolic state keeps its variables in a std::map<BooleanFunction,
BooleanFunction>, so every variable lookup during evaluation formatted several
strings, and since all keys are single variable nodes the node count never
discriminated. Comparing the nodes directly is equivalent, Node::operator< is
already a field wise comparison consistent with Node::operator==. Neither of
the two maps keyed by BooleanFunction is ever iterated, so the changed order is
not observable. 5.17 ms -> 4.56 ms.

SymbolicExecution::constant_propagation() copied the value vector of every
operand twice, once into a local and once into the vector of values. Some of
the cases modify the values in place so one copy is still needed. 4.56 ms ->
3.94 ms.

BooleanFunction::evaluate() validated the input sizes by comparing every input
name against every node, which is a string comparison per pair on every call.
Walking the nodes once and looking up each variable is equivalent. 3.94 ms ->
3.81 ms.

That is 26% overall. The remaining cost is dominated by allocation, every node
evaluation builds a std::vector<BooleanFunction> of operands and a
BooleanFunction result, each owning a vector of nodes that own a string and a
vector of values.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tests/python_binding/ is not referenced by the build or by any workflow, so
nothing has run these files in a long time. They would not run either: they
still call boolean_function(), value.ONE, get_variables(), get_truth_table(),
is_constant_zero(), to_dnf() and optimize() on the netlist and Boolean function
classes, none of which are bound any more.

Rather than leave tests that cannot pass and are not executed, drop them. The
C++ suite under tests/netlist covers the same ground.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers the changes of this branch that had no entry yet: the two GUI hangs on
large designs, the duplicate rebuild of the module elements tree, the faster
Boolean function comparison, the shipped HAWKEYE S-box database, and the
removal of the stale Python binding tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@julianspeith julianspeith changed the title Fix nullptr crash in Net pin removal and complete the plugin manager bindings Fix GUI freezes on large netlists, a nullptr crash in Net, and complete the plugin manager bindings Aug 12, 2026

@joern274 joern274 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explicit OK for GUI modifications

@julianspeith
julianspeith merged commit bdd4682 into master Aug 12, 2026
4 checks passed
@julianspeith
julianspeith deleted the bugfix/misc branch August 12, 2026 13:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants