Fix GUI freezes on large netlists, a nullptr crash in Net, and complete the plugin manager bindings - #635
Merged
Conversation
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
enabled auto-merge (squash)
August 12, 2026 07:41
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>
joern274
approved these changes
Aug 12, 2026
joern274
left a comment
Contributor
There was a problem hiding this comment.
Explicit OK for GUI modifications
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 withbeginResetModel()andendResetModel(), butcreateChildItem()emittedbeginInsertRows()andendInsertRows()for every single item in between. Besides being undefined per theQAbstractItemModelcontract, it made the attachedQSortFilterProxyModelremap 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 aQSortFilterProxyModelover 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
mIsModifyingtofalseunconditionally, clobbering thetruethatpopulateTree()had set, so the guard inModuleWidgetwas only in effect for the very first item.mIsModifyingwas never initialized in the constructor either.Unfolding a large module took seconds. Without uniform row heights,
QTreeViewPrivate::itemHeight()measures every row individually throughQTreeView::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 belowitemHeight(), of which 2345 end inQTextEngine::shapeText()and 821 inCTFontGetGlyphsForCharacters(): 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 throughtriggerSelection(). A sample showspopulateTree()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
nullptrpin, reachable from PythonNet::remove_source(Gate*, const GatePin*)andNet::remove_destination(Gate*, const GatePin*)dereferenced the pin without checking it: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_preprocessingandnetlist_utils, and they are bound to Python, where this was enough to terminate the interpreter:is_a_sourceandis_a_destinationalready guarded both arguments; the two removal overloads now do the same, so the call returnsFalseand 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 equalGatePinof 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 fourNetlookups and inGate::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.hcarried sevenTODO Python bindingmarkers. All are now resolved:get_cli_plugin_flags()andget_ui_plugin_flags()return adict.add_model_changed_callback()andremove_model_changed_callback()let a Python callable observe plugins being loaded and unloaded.get_plugin_instance()gained theinitializeandsilentparameters. 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()andget_plugin_features()required binding the types they use, soProgramOptions,ProgramArguments,FacExtensionInterfacewith itsFeatureenum, andplugin_manager::PluginFeatureare now bound as well.Two details worth a look during review:
ProgramOptions::add_flags()is new.add()takes its flags as astd::initializer_list, which cannot be built from a container at runtime and is therefore not bindable. Retyping the parameter tostd::vectoris not an option either:add({"-h", "--help"}, "...")then becomes ambiguous, becausestd::stringhas 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 nameadd_flags(), to which both existing overloads delegate. All 87 existing call sites are untouched, and Python bindsadd_flags()asadd().ProgramArguments::get_original_arguments()is deliberately not bound.ProgramArgumentsstores theargvpointer without owning the strings, which no longer exist onceparse()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 astd::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 withNode::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 andevaluate()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_trojanandtoy_cipherexamples 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 callboolean_function(),value.ONE,get_variables(),get_truth_table(),is_constant_zero(),to_dnf()andoptimize(), none of which are bound any more. The C++ suite undertests/netlistcovers the same ground.Testing
remove_sourceandremove_destination, covering removal, the wrong pin, anullptrpin, anullptrgate, a gate that is not connected, and removing twice. They previously had no direct coverage at all, which is what the removedTODO testcomments asked for. The other six of those TODOs were stale: the string overloads, the endpoint overloads, and both pin overloads ofis_a_sourceandis_a_destinationare already covered.dict, thatget_plugin_instance(name, False)skips initialization andget_plugin_instance(name, True, True)returnsNonefor an unknown plugin without logging, and that options can be added with one or several flags, parsed, queried, removed and merged.runTest-netandrunTest-gatepass in full, and the wider suite is unchanged.QSortFilterProxyModelbenchmark, 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