Skip to content

Commit 8581604

Browse files
committed
perf(layer ops, lazy proxies): implement cashing for supra index and related operations and for nx accessor + add them to benchmarks
1 parent 5e2304a commit 8581604

7 files changed

Lines changed: 145 additions & 8 deletions

File tree

annnet/core/_Layers.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ def set_aspects(self, aspects, elem_layers: dict[str, list[str]] | None = None):
193193

194194
self._rebuild_all_layers_cache()
195195
self._drop_unused_placeholder_layers()
196+
self._supra_index_cache = None
196197

197198
def set_elementary_layers(self, layers_by_aspect: dict[str, list[str]]):
198199
"""Declare concrete elementary layer values for existing aspects."""
@@ -511,6 +512,27 @@ def iter_vertex_layers(self, u: str):
511512

512513
def _build_supra_index(
513514
self, restrict_layers: list[tuple[str, ...]] | None = None
515+
) -> tuple[dict, list]:
516+
"""Return ``(nl_to_row, row_to_nl)`` for the supra layout, cached.
517+
518+
The index is a full scan + sort of the vertex-entity population, so it is
519+
memoised per ``restrict_layers`` key and reused until a structural
520+
mutation clears ``_supra_index_cache`` (via ``_mark_matrix_dirty``).
521+
The returned dict/list are shared — callers treat them as read-only.
522+
"""
523+
cache = self._supra_index_cache
524+
if cache is None:
525+
cache = {}
526+
self._supra_index_cache = cache
527+
key = None if restrict_layers is None else frozenset(tuple(x) for x in restrict_layers)
528+
entry = cache.get(key)
529+
if entry is None:
530+
entry = self._compute_supra_index(restrict_layers)
531+
cache[key] = entry
532+
return entry
533+
534+
def _compute_supra_index(
535+
self, restrict_layers: list[tuple[str, ...]] | None = None
514536
) -> tuple[dict, list]:
515537
if restrict_layers is not None:
516538
R = {tuple(x) for x in restrict_layers}

annnet/core/_derive.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ def invalidate_sparse_caches(g, formats=None) -> None:
9898
cache_manager = getattr(g, '_cache_manager', None)
9999
if cache_manager is not None:
100100
cache_manager.invalidate(list(formats))
101+
g._supra_index_cache = None
101102

102103

103104
# ---------------------------------------------------------------------------

annnet/core/_state.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,5 +99,6 @@ def init_state(g, *, directed=None, v=0, e=0, aspects=None) -> None:
9999

100100
# Version clock (dirty signal for derived sparse caches).
101101
g._version = 0
102+
g._supra_index_cache = None
102103

103104
g.vertex_aligned = False

annnet/core/backend_accessors/nx_accessor.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,24 @@
22

33
from typing import TYPE_CHECKING
44
import inspect
5+
from functools import lru_cache
56

67
from ._base import _BackendAccessorBase
78

89
if TYPE_CHECKING:
910
from ..graph import AnnNet
1011

1112

13+
@lru_cache(maxsize=4096)
14+
def _cached_signature(fn):
15+
"""inspect.signature is expensive; NX callables are stable, so memoise it."""
16+
return inspect.signature(fn)
17+
18+
19+
# name -> resolved networkx callable (global; the nx module set is static).
20+
_NX_CALLABLE_CACHE: dict = {}
21+
22+
1223
class _NXBackendAccessor(_BackendAccessorBase):
1324
"""NetworkX backend accessor attached to an AnnNet instance."""
1425

@@ -108,7 +119,7 @@ def wrapper(*args, **kwargs):
108119
args, kwargs = self._replace_owner_graph(args, kwargs, nxG)
109120

110121
try:
111-
sig = inspect.signature(nx_callable)
122+
sig = _cached_signature(nx_callable)
112123
bound = sig.bind_partial(*args, **kwargs)
113124
except Exception: # noqa: BLE001
114125
bound = None
@@ -151,9 +162,13 @@ def __dir__(self):
151162
return sorted(set(super().__dir__()) | self._callable_names(*self._nx_candidates()))
152163

153164
def _resolve_nx_callable(self, name: str):
165+
cached = _NX_CALLABLE_CACHE.get(name)
166+
if cached is not None:
167+
return cached
154168
for mod in self._nx_candidates():
155169
attr = getattr(mod, name, None)
156170
if callable(attr):
171+
_NX_CALLABLE_CACHE[name] = attr
157172
return attr
158173
raise AttributeError(f"networkx has no callable '{name}'")
159174

@@ -185,7 +200,7 @@ def _nx_candidates(self):
185200
def _needed_edge_attrs(self, target, kwargs) -> set:
186201
needed = set()
187202
try:
188-
params = inspect.signature(target).parameters
203+
params = _cached_signature(target).parameters
189204
except Exception: # noqa: BLE001
190205
params = {}
191206
if 'weight' in params:
@@ -290,15 +305,17 @@ def _coerce_vertices_in_bound(self, bound, nxG, label_field: str | None):
290305
)
291306

292307
def _map_output_vertices(self, obj):
293-
_id_to_row, row_to_id = self._vertex_row_maps()
294-
295308
def map_id(value):
296309
# NetworkX returns vertex IDs as the backend graph's node values.
297310
# Our backend nodes are vertex-ID strings, so strings pass through
298311
# unchanged. Integers that NX produced from a relabel-to-int pass
299-
# are mapped back to their vertex IDs.
300-
if isinstance(value, int) and not isinstance(value, bool) and value in row_to_id:
301-
return row_to_id[value]
312+
# are mapped back to their vertex IDs. Resolve each int in O(1) via
313+
# the graph's row->entity index instead of materialising the whole
314+
# row->id map on every call.
315+
if isinstance(value, int) and not isinstance(value, bool):
316+
vid = self._vertex_row_to_id(value)
317+
if vid is not None:
318+
return vid
302319
return value
303320

304321
return self._map_nested_output(obj, map_id)

annnet/core/graph.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2031,8 +2031,15 @@ def _matrix(self, value) -> None:
20312031
self._matrix_dirty = False
20322032

20332033
def _mark_matrix_dirty(self) -> None:
2034-
"""Flag the incidence cache for lazy rebuild from records."""
2034+
"""Flag the incidence cache for lazy rebuild from records.
2035+
2036+
Also drops the cached supra (vertex-layer) index: it is derived from the
2037+
vertex-entity population, which every structural mutation routes through
2038+
here (add / remove / rekey), so this is the single reliable invalidation
2039+
point (``_version`` does not bump on removes or ``set_aspects``).
2040+
"""
20352041
self._matrix_dirty = True
2042+
self._supra_index_cache = None
20362043

20372044
def X(self):
20382045
"""Return the sparse incidence matrix.

benchmarks/report.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,20 @@ def render(payload, report_path, plots_dir=None):
379379
'Algorithms / traversal',
380380
'Directional neighbor + edge queries and matrix-derived traversals.',
381381
)
382+
lines += _feature_section(
383+
records,
384+
'layers',
385+
'Layers (supra index + supra operations)',
386+
'Vertex-layer index and supra-matrix ops; nl_to_row must stay ~O(1) in the '
387+
'supra size (cached index).',
388+
)
389+
lines += _feature_section(
390+
records,
391+
'backend_proxy',
392+
'Backend proxies (G.nx.*)',
393+
'Per-call wrapper tax with the converted graph cached; must stay ~O(1) in '
394+
'vertex count (O(1) output mapping).',
395+
)
382396
lines += _io_formats(payload)
383397
lines += _adapters(payload)
384398
lines += _plots(records, plots_dir)

benchmarks/workloads.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,4 +480,79 @@ def _build_hist():
480480
note='per-vertex incident edge lists',
481481
)
482482
)
483+
484+
# --- layers: supra (vertex-layer) index + supra operations --------------
485+
# Guards the cached supra index: nl_to_row must stay ~O(1) as V_M grows.
486+
n_layers = 3
487+
n_lv = max(50, min(scale.vertices // 20, 20_000)) # vertices per layer
488+
with warnings.catch_warnings():
489+
warnings.simplefilter('ignore')
490+
Gml = AnnNet(directed=True)
491+
Gml.layers.set_aspects(['layer'], {'layer': [f'L{k}' for k in range(n_layers)]})
492+
coords = list(Gml.layers.iter_layers())
493+
for aa in coords:
494+
Gml.add_edges(
495+
[
496+
{'source': (f'm{i}', aa), 'target': (f'm{(i + 1) % n_lv}', aa), 'weight': 1.0}
497+
for i in range(n_lv)
498+
]
499+
)
500+
aa0 = coords[0]
501+
v_m = n_lv * n_layers
502+
recs.append(
503+
rec(
504+
'layers',
505+
'nl_to_row',
506+
time=harness.time_repeat(lambda: Gml.layers.nl_to_row('m0', aa0)),
507+
note=f'single vertex-layer lookup (V_M={v_m}); must be ~O(1)',
508+
)
509+
)
510+
recs.append(
511+
rec(
512+
'layers',
513+
'build_supra_index',
514+
time=harness.time_repeat(lambda: Gml.layers._build_supra_index()),
515+
note='full supra index (cache hit after first build)',
516+
)
517+
)
518+
recs.append(
519+
rec(
520+
'layers',
521+
'supra_adjacency',
522+
time=harness.time_oneshot(
523+
lambda: Gml.layers.supra_adjacency(), samples=max(3, samples // 2)
524+
),
525+
note=f'supra-adjacency over {v_m} vertex-layer nodes',
526+
)
527+
)
528+
recs.append(
529+
rec(
530+
'layers',
531+
'subgraph_from_layer',
532+
time=harness.time_oneshot(
533+
lambda: Gml.layers.subgraph_from_layer_tuple(aa0), samples=max(3, samples // 2)
534+
),
535+
note='single-layer induced subgraph',
536+
)
537+
)
538+
539+
# --- backend proxy: G.nx.* per-call wrapper tax on a cache hit ----------
540+
# Guards the O(1) output mapping: this must stay flat as V grows.
541+
try:
542+
import networkx as _nx # noqa: F401
543+
544+
with warnings.catch_warnings():
545+
warnings.simplefilter('ignore')
546+
G.nx.number_of_nodes(G) # prime the conversion cache
547+
recs.append(
548+
rec(
549+
'backend_proxy',
550+
'nx_call_cache_hit',
551+
time=harness.time_repeat(lambda: G.nx.number_of_nodes(G)),
552+
note='G.nx.* wrapper tax with conversion cached; must be ~O(1) in V',
553+
)
554+
)
555+
except ImportError:
556+
pass
557+
483558
return recs

0 commit comments

Comments
 (0)