@@ -165,11 +165,17 @@ def _as_serialization_array(serialization) -> np.ndarray:
165165 return np .ascontiguousarray (array )
166166
167167
168- def _copy_graph (graph : UndirectedGraph | RegionAdjacencyGraph ) -> UndirectedGraph :
169- copied = UndirectedGraph (int (graph .number_of_nodes ), int (graph .number_of_edges ))
170- if graph .number_of_edges :
171- copied .insert_edges (graph .uv_ids ())
172- return copied
168+ def _copy_graph (graph : UndirectedGraph | RegionAdjacencyGraph ) -> _core .UndirectedGraph :
169+ # `uv_ids()` always returns a unique list (graphs deduplicate on insert),
170+ # so we can use the bulk constructor that skips per-edge hash dedup —
171+ # significantly faster than `insert_edges` for large graphs. The result
172+ # is a ``_core.UndirectedGraph``; downstream code (objectives, solvers,
173+ # validators) uses base-class methods that work identically.
174+ if graph .number_of_edges == 0 :
175+ return _core .UndirectedGraph (int (graph .number_of_nodes ))
176+ return _core .UndirectedGraph .from_unique_edges (
177+ int (graph .number_of_nodes ), graph .uv_ids ()
178+ )
173179
174180
175181def _as_edge_costs (edge_costs , graph : UndirectedGraph | RegionAdjacencyGraph ) -> np .ndarray :
@@ -787,14 +793,22 @@ def __init__(
787793 overwrite_existing : bool = False ,
788794 initial_labels = None ,
789795 ):
790- base_graph = _copy_graph (graph )
796+ # The objective holds a reference to the user's base graph (no
797+ # defensive copy — the C++ ``Objective`` already keeps a const
798+ # reference). The user is expected to treat the input graph as
799+ # immutable while the objective is alive; mutations are visible to
800+ # the objective and may produce undefined behaviour.
801+ base_graph = graph
791802 base_costs = _as_edge_costs (edge_costs , base_graph )
792803
793- lifted_graph = UndirectedGraph (
794- int (base_graph .number_of_nodes ), int (base_graph .number_of_edges )
795- )
804+ # Use the bulk constructor for the lifted graph's base portion to
805+ # bypass the per-edge hash dedup that ``insert_edges`` performs.
796806 if int (base_graph .number_of_edges ) > 0 :
797- lifted_graph .insert_edges (base_graph .uv_ids ())
807+ lifted_graph = _core .UndirectedGraph .from_unique_edges (
808+ int (base_graph .number_of_nodes ), base_graph .uv_ids ()
809+ )
810+ else :
811+ lifted_graph = _core .UndirectedGraph (int (base_graph .number_of_nodes ))
798812
799813 weights_list = [base_costs .copy ()]
800814
@@ -953,6 +967,55 @@ def _add_lifted_edges(
953967 # In-place updates require a single flat working buffer; coalesce first.
954968 if len (weights_list ) > 1 :
955969 weights_list [:] = [np .ascontiguousarray (np .concatenate (weights_list ))]
970+
971+ # Fast path: bulk-insert and detect uniqueness from the row count delta.
972+ # For the typical case — ``lifted_uvs`` produced by
973+ # ``lifted_edges_from_affinities`` or by the BFS constructor — every row
974+ # is a brand-new edge, so the delta equals the input length and we can
975+ # append the weights array directly. No ``find_edges`` calls are needed.
976+ if not overwrite_existing :
977+ pre_count = int (lifted_graph .number_of_edges )
978+ lifted_graph .insert_edges (lifted_uvs )
979+ post_count = int (lifted_graph .number_of_edges )
980+ n_new = post_count - pre_count
981+
982+ if n_new == lifted_uvs .shape [0 ]:
983+ weights_list .append (
984+ np .ascontiguousarray (lifted_costs .astype (np .float64 , copy = False ))
985+ )
986+ return
987+
988+ # Some rows collided with existing edges or with each other. Use
989+ # find_edges to recover the per-row edge id (insertion is already
990+ # done; this is just a lookup).
991+ edge_ids = np .asarray (lifted_graph .find_edges (lifted_uvs ))
992+ lifted_costs_f64 = lifted_costs .astype (np .float64 , copy = False )
993+ working = weights_list [0 ]
994+
995+ collision_mask = edge_ids < pre_count
996+ if collision_mask .any ():
997+ np .add .at (
998+ working ,
999+ edge_ids [collision_mask ].astype (np .intp , copy = False ),
1000+ lifted_costs_f64 [collision_mask ],
1001+ )
1002+
1003+ new_mask = ~ collision_mask
1004+ if new_mask .any ():
1005+ slot = (edge_ids [new_mask ] - pre_count ).astype (np .int64 , copy = False )
1006+ new_weights = np .bincount (
1007+ slot , weights = lifted_costs_f64 [new_mask ], minlength = n_new
1008+ ).astype (np .float64 , copy = False )
1009+ else :
1010+ new_weights = np .zeros (n_new , dtype = np .float64 )
1011+
1012+ weights_list [0 ] = working
1013+ if n_new > 0 :
1014+ weights_list .append (new_weights )
1015+ return
1016+
1017+ # Slow path: per-row Python loop for ``overwrite_existing=True`` (rare).
1018+ # Order-sensitive last-write-wins semantics on collisions.
9561019 working = weights_list [0 ]
9571020 new_costs : list [float ] = []
9581021 for index in range (lifted_uvs .shape [0 ]):
@@ -964,10 +1027,7 @@ def _add_lifted_edges(
9641027 if int (lifted_graph .number_of_edges ) > pre :
9651028 new_costs .append (weight )
9661029 else :
967- if overwrite_existing :
968- working [edge ] = weight
969- else :
970- working [edge ] = working [edge ] + weight
1030+ working [edge ] = weight
9711031 if new_costs :
9721032 weights_list [0 ] = working
9731033 weights_list .append (np .asarray (new_costs , dtype = np .float64 ))
0 commit comments