Skip to content

perf: Lazy ALTREP names for vertex/edge sequences - #2696

Open
schochastics wants to merge 2 commits into
perf/vs-list-cfrom
feat/lazy-altrep-names
Open

perf: Lazy ALTREP names for vertex/edge sequences#2696
schochastics wants to merge 2 commits into
perf/vs-list-cfrom
feat/lazy-altrep-names

Conversation

@schochastics

@schochastics schochastics commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Split 4 of 4, per #2696 (comment) — this is the "if needed, ALTREP on top" step. Rebased onto the stack, so the diff here is now only the ALTREP layer.

Base: #2888. Review order: #2886 -> #2887 -> #2888 -> this.

Part of #2695, relates to #1652.

Honest answer to "if needed": mostly not

With the first three PRs merged, ALTREP no longer moves the flagship case at all. Measured head-to-head (#2888 vs this PR, same machine, warm, 20-30 iterations):

#2888 (eager names) this PR (ALTREP)
max_cliques_named 2.667 ms 2.674 ms unchanged
max_cliques_sizes_named 3.144 ms 3.204 ms unchanged
all_simple_paths_named 1.939 ms 1.922 ms unchanged
ego_order2_named 4.427 ms 3.134 ms -29%
vs_subset_positional 0.111 ms 0.095 ms -14%

The reason max_cliques() sees nothing: once the construction loop is in C (#2888), building the names eagerly is just a run of SET_STRING_ELT against already-interned CHARSXPs. No allocation of new strings, no R-level copy. Laziness has almost nothing left to save.

Where it does help is where the name vectors are large relative to the work: ego(order = 2) builds 2000 sequences whose neighborhoods are a large fraction of the graph, and positional subsetting of a 50k-vertex sequence composes indices in O(1) instead of copying 10k names.

So this is a real but narrow win, and it is the one layer of the four that carries ongoing maintenance cost: a custom ALTREP class with Length/Dataptr/Dataptr_or_null/Extract_subset/Elt methods, and a dependence on R's ALTREP API staying stable. I'd be fine closing this if the maintenance cost isn't worth 29% on ego()#2886-#2888 already deliver 9.3× on the case #1652 was actually about.

What changed

A sequence's names attribute becomes an instance of an igraph_lazy_names ALTREP string class instead of a materialized character vector. data1 holds list(source, idx) — the graph's full name vector, shared by reference across every sequence of that graph, plus a 1-based index into it; data2 caches the materialized STRSXP once anything reads it.

  • V()/E() build their names through lazy_index_names().
  • Rx_igraph_vs_list() attaches the ALTREP directly instead of eagerly subsetting.
  • Extract_subset returns a fresh lazy vector with composed indices, so subsetting a sequence never materializes; it falls back to the default for index types it does not handle.
  • DATAPTR_RO/DATAPTR_OR_NULL are used rather than DATAPTR, which is non-API as of R 4.5.

Correctness

Same checks as #2888, re-run here: integer payloads with identical values and names against the return.vs.es = FALSE path, no names attribute at all on unnamed graphs, NA/out-of-range IDs map to NA_STRING, double ID vectors coerced, caller inputs unmutated, and get_vs_graph(seq) still NULL after rm(graph); gc().

Compiles without new warnings, clean under gctorture(TRUE).

Full suite: FAIL 0 | WARN 0 | SKIP 7 | PASS 9299.

Scope

Vertex sequences and the names of edge sequences. Edge vnames ("tail|head") are still built eagerly; a lazy vnames and a create_es_list() batch form remain follow-ups.

On return.vs.es

The original framing of this PR still holds, and is now better supported. #1652 was blocked on an 8x gap between the default and return.vs.es = FALSE that stibu81 called "unusable". After #2886-#2888 that gap is ~1.15x. The option's main justification is gone, so it can be deprecated and left inert rather than urgently removed — a separate decision, untouched by this stack.

@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

This is how benchmark results would change (along with a 95% confidence interval in relative change) if 21f3d4b is merged into main:

  • ✔️E_named: 17ms -> 16.5ms [-6.42%, +0.55%]
  • ❗🐌V_named: 296µs -> 329µs [+2.63%, +19.76%]
  • 🚀all_simple_paths_named: 21.4ms -> 5.44ms [-76.14%, -72.96%]
  • ✔️as_adjacency_matrix: 780ms -> 779ms [-1.09%, +0.88%]
  • ✔️as_biadjacency_matrix: 782ms -> 783ms [-0.98%, +1.35%]
  • ✔️as_data_frame_both: 1.68ms -> 1.68ms [-2.39%, +2.88%]
  • ✔️as_long_data_frame: 4.42ms -> 4.33ms [-8.24%, +3.82%]
  • 🚀ego_order2_named: 36.8ms -> 7.07ms [-82.68%, -78.94%]
  • ✔️es_attr_filter: 3.11ms -> 3.13ms [-2.78%, +4.11%]
  • ❗🐌graph_from_adjacency_matrix: 127ms -> 128ms [+0.05%, +2.89%]
  • ✔️graph_from_data_frame: 3.64ms -> 3.69ms [-0.31%, +3.15%]
  • 🚀head_of_named: 639µs -> 583µs [-12.31%, -5.18%]
  • 🚀max_cliques_named: 51.4ms -> 5.78ms [-89.76%, -87.76%]
  • 🚀max_cliques_sizes_named: 50.1ms -> 8.97ms [-83.86%, -80.3%]
  • ✔️vs_attr_filter: 1.82ms -> 1.87ms [-1.18%, +6.8%]
  • ❗🐌vs_by_name: 1.15ms -> 1.21ms [+1.37%, +8.1%]
  • 🚀vs_subset_positional: 1.53ms -> 1.15ms [-28.38%, -21.54%]
    Further explanation regarding interpretation and methodology can be found in the documentation.

@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

This is how benchmark results would change (along with a 95% confidence interval in relative change) if 79fadf6 is merged into main:

  • 🚀E_named: 16.7ms -> 16.3ms [-4.39%, -1.42%]
  • ❗🐌V_named: 280µs -> 294µs [+2.58%, +7.9%]
  • 🚀all_simple_paths_named: 19.9ms -> 5.45ms [-73.22%, -72.07%]
  • ✔️as_adjacency_matrix: 723ms -> 723ms [-0.5%, +0.33%]
  • ✔️as_biadjacency_matrix: 724ms -> 729ms [-0.08%, +1.53%]
  • ❗🐌as_data_frame_both: 1.49ms -> 1.52ms [+0.74%, +2.55%]
  • ✔️as_long_data_frame: 3.9ms -> 3.94ms [-1.44%, +3.81%]
  • 🚀ego_order2_named: 33ms -> 6.78ms [-80.08%, -78.84%]
  • ✔️es_attr_filter: 2.8ms -> 2.68ms [-10.31%, +2.25%]
  • ✔️graph_from_adjacency_matrix: 129ms -> 129ms [-0.92%, +0.39%]
  • ✔️graph_from_data_frame: 3.4ms -> 3.4ms [-0.5%, +0.59%]
  • 🚀head_of_named: 568µs -> 525µs [-9.29%, -5.6%]
  • 🚀max_cliques_named: 43.5ms -> 5.46ms [-87.93%, -86.93%]
  • 🚀max_cliques_sizes_named: 44.7ms -> 8.2ms [-82.51%, -80.78%]
  • ❗🐌vs_attr_filter: 1.53ms -> 1.58ms [+2.15%, +3.68%]
  • ❗🐌vs_by_name: 995µs -> 1.03ms [+0.89%, +6.57%]
  • 🚀vs_subset_positional: 1.38ms -> 1.09ms [-22.08%, -19.38%]
    Further explanation regarding interpretation and methodology can be found in the documentation.

consistent API.
}
\section{Related documentation in the C library}{
\href{https://igraph.org/c/html/0.10.17/igraph-Structural.html#igraph_biconnected_components}{\code{biconnected_components()}}, \href{https://igraph.org/c/html/0.10.17/igraph-Basic.html#igraph_vcount}{\code{vcount()}}, \href{https://igraph.org/c/html/0.10.17/igraph-Basic.html#igraph_edges}{\code{edges()}}, \href{https://igraph.org/c/html/0.10.17/igraph-Basic.html#igraph_get_eids}{\code{get_eids()}}, \href{https://igraph.org/c/html/0.10.17/igraph-Basic.html#igraph_ecount}{\code{ecount()}}

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.

should this be part of this diff?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

not sure why this is a diff here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

maybe it disappears when rerunning document

Comment thread R/iterators.R Outdated
Comment thread R/iterators.R
Comment thread R/iterators.R
Comment thread touchstone/script.R
benchmark_run(
expr_before_benchmark = {
library(igraph)
set.seed(42)

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.

why don't we use withr here? I'm genuinely curious.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Claude not a fan, maybe? I nudge to it 😆

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ah I think because these scripts are supposed to be quite dependency free? All that is needed is touchstone and igraph

@github-actions

Copy link
Copy Markdown
Contributor

This is how benchmark results would change (along with a 95% confidence interval in relative change) if 07598b9 is merged into main:

  • 🚀E_named: 16.4ms -> 15.8ms [-4.03%, -2.65%]
  • ❗🐌V_named: 260µs -> 268µs [+0.63%, +5.73%]
  • 🚀all_simple_paths_named: 18.9ms -> 5.08ms [-73.57%, -72.68%]
  • ✔️as_adjacency_matrix: 723ms -> 722ms [-0.49%, +0.23%]
  • ✔️as_biadjacency_matrix: 724ms -> 722ms [-0.69%, +0.15%]
  • ✔️as_data_frame_both: 1.42ms -> 1.43ms [-0.38%, +1.34%]
  • ❗🐌as_long_data_frame: 3.83ms -> 3.85ms [+0.13%, +0.97%]
  • 🚀ego_order2_named: 31.5ms -> 6.48ms [-80.15%, -78.7%]
  • 🚀es_attr_filter: 2.6ms -> 2.57ms [-1.82%, -0.38%]
  • ❗🐌graph_from_adjacency_matrix: 115ms -> 117ms [+0.25%, +2.45%]
  • 🚀graph_from_data_frame: 3.27ms -> 3.25ms [-1.18%, -0.02%]
  • 🚀head_of_named: 534µs -> 496µs [-9.15%, -5.36%]
  • 🚀max_cliques_named: 41.4ms -> 5.29ms [-88%, -86.45%]
  • 🚀max_cliques_sizes_named: 42.8ms -> 7.62ms [-82.84%, -81.56%]
  • ❗🐌vs_attr_filter: 1.47ms -> 1.5ms [+1.13%, +3.14%]
  • ❗🐌vs_by_name: 939µs -> 959µs [+0.93%, +3.31%]
  • 🚀vs_subset_positional: 1.31ms -> 1.03ms [-22.28%, -20.38%]
    Further explanation regarding interpretation and methodology can be found in the documentation.

@github-actions

Copy link
Copy Markdown
Contributor

This is how benchmark results would change (along with a 95% confidence interval in relative change) if bd02062 is merged into main:

  • 🚀E_named: 17.1ms -> 16.6ms [-5.22%, -0.32%]
  • ❗🐌V_named: 312µs -> 325µs [+0.64%, +7.94%]
  • 🚀all_simple_paths_named: 19.7ms -> 4.79ms [-76.64%, -74.74%]
  • ✔️as_adjacency_matrix: 714ms -> 708ms [-3.04%, +1.28%]
  • ✔️as_biadjacency_matrix: 695ms -> 687ms [-2.39%, +0.27%]
  • ✔️as_data_frame_both: 1.72ms -> 1.66ms [-14.41%, +7.24%]
  • ✔️as_long_data_frame: 4.27ms -> 4.29ms [-1.8%, +2.58%]
  • 🚀ego_order2_named: 33.8ms -> 6.94ms [-80.74%, -78.22%]
  • ✔️es_attr_filter: 2.79ms -> 2.77ms [-3.02%, +1.84%]
  • ✔️graph_from_adjacency_matrix: 112ms -> 110ms [-5.79%, +2.42%]
  • ✔️graph_from_data_frame: 3.46ms -> 3.48ms [-0.99%, +1.93%]
  • 🚀head_of_named: 627µs -> 587µs [-8.46%, -4.58%]
  • 🚀max_cliques_named: 46.7ms -> 5.82ms [-88.51%, -86.57%]
  • 🚀max_cliques_sizes_named: 47.1ms -> 9.11ms [-82%, -79.33%]
  • ❗🐌vs_attr_filter: 1.5ms -> 1.55ms [+0.27%, +6.44%]
  • ✔️vs_by_name: 1.06ms -> 1.06ms [-3.63%, +3.55%]
  • 🚀vs_subset_positional: 1.32ms -> 1.03ms [-24.08%, -19.1%]
    Further explanation regarding interpretation and methodology can be found in the documentation.

@krlmlr

krlmlr commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Conflicting now.

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

This is how benchmark results would change (along with a 95% confidence interval in relative change) if 63ddefd is merged into main:

  • 🚀E_named: 18.8ms -> 17.6ms [-9.45%, -3%]
  • ✔️V_named: 288µs -> 299µs [-3.13%, +11.11%]
  • 🚀all_simple_paths_named: 21.2ms -> 5.39ms [-76.98%, -72.23%]
  • ✔️as_adjacency_matrix: 823ms -> 835ms [-1.09%, +4.01%]
  • ✔️as_biadjacency_matrix: 762ms -> 763ms [-1.3%, +1.71%]
  • ✔️as_data_frame_both: 1.7ms -> 1.71ms [-1.47%, +3.24%]
  • ✔️as_long_data_frame: 4.25ms -> 4.28ms [-2.57%, +4.11%]
  • 🚀ego_order2_named: 34.1ms -> 6.82ms [-81.71%, -78.22%]
  • ✔️es_attr_filter: 2.79ms -> 2.73ms [-4.71%, +0.65%]
  • ✔️graph_from_adjacency_matrix: 126ms -> 124ms [-3.47%, +1.04%]
  • ✔️graph_from_data_frame: 3.73ms -> 3.7ms [-2.61%, +1.36%]
  • 🚀head_of_named: 579µs -> 550µs [-9.15%, -1%]
  • 🚀max_cliques_named: 49.2ms -> 5.72ms [-91.64%, -85.1%]
  • 🚀max_cliques_sizes_named: 45.7ms -> 6.7ms [-87.43%, -83.27%]
  • ✔️vs_attr_filter: 1.62ms -> 1.64ms [-2.25%, +5.55%]
  • ✔️vs_by_name: 1.02ms -> 1.04ms [-0.22%, +5.71%]
  • 🚀vs_subset_positional: 1.58ms -> 1.16ms [-28.37%, -24.75%]
    Further explanation regarding interpretation and methodology can be found in the documentation.

@krlmlr krlmlr 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.

Perhaps use an existing ALTREP class from sparsevctrs or stringfish? What's the performance tradeoff?

.Internal(inspect(letters))
#> @11ae9a930 16 STRSXP g1c7 [MARK,REF(1)] (len=26, tl=0)
#>   @12ba04b40 09 CHARSXP g1c1 [MARK,REF(452),gp=0x61] [ASCII] [cached] "a"
#>   @12cb694c8 09 CHARSXP g1c1 [MARK,REF(12),gp=0x61] [ASCII] [cached] "b"
#>   @12b013488 09 CHARSXP g1c1 [MARK,REF(426),gp=0x61] [ASCII] [cached] "c"
#>   @12baba488 09 CHARSXP g1c1 [MARK,REF(6),gp=0x61] [ASCII] [cached] "d"
#>   @12b059a10 09 CHARSXP g1c1 [MARK,REF(562),gp=0x61] [ASCII] [cached] "e"
#>   ...
.Internal(inspect(sparsevctrs::as_sparse_character(letters)))
#> @10d3a45a8 16 STRSXP g0c0 [] sparsevctrs_altrep_sparse_string (materialized=F, length=26)
.Internal(inspect(sparsevctrs::as_sparse_character(letters)[1:3]))
#> @10d3dc2a8 16 STRSXP g0c0 [] sparsevctrs_altrep_sparse_string (materialized=F, length=3)

Created on 2026-07-09 with reprex v2.1.1

@schochastics

Copy link
Copy Markdown
Contributor Author

Re: reusing an existing ALTREP class (sparsevctrs / stringfish)

Short version: neither fits, because this class isn't really a "string vector" — it's an indexed gather-view over a shared parent vector, which is a different ALTREP pattern than what either library implements.

What this class actually is

igraph_lazy_names stores data1 = list(source, idx), where source is the graph's full name vector (shared by reference across every sequence of that graph) and idx is a small 1-based integer index. The logical value is source[idx] — a gather (permutation-with-repeats) of the shared source. Two properties fall out of this:

  1. O(1) construction, O(len(idx)) memory per sequence — building e.g. 30k cliques attaches 30k tiny integer idx vectors plus one shared source. No strings copied, no CHARSXP touched.
  2. O(1) composable subsettingExtract_subset just does idx <- idx[i] and returns a new view; no name copying.

Why sparsevctrs doesn't fit

sparse_character compresses a vector with one dominant default value (non-defaults + positions + scalar default + length). Vertex/edge names are a gather from a shared source — generally all distinct, no dominant default — so the sparsity ratio is ~1.0 and you'd store every value plus every position: strictly worse than a plain character vector. It also has no shared-parent concept, so each sequence materializes its own strings (loses win #1), and [ copies rather than composing an index (loses win #2).

Why stringfish doesn't fit

sf_vec is an ALTREP STRSXP backed by its own std::vector<sfstring>, built to bypass R's global string hash table and keep string ops in ALTREP form. It owns its own copy of the strings, so constructing one from source[idx] materializes the gather at build time — back to per-sequence string work for all 30k sequences (loses win #1). No shared source, no index-composition on subset (loses win #2). Its actual value (skipping R's string cache) is orthogonal to what we're optimizing: we're avoiding building the vector at all, not building it more cheaply.

Dependency cost (independent of fit)

igraph is LinkingTo: cpp11 only, with a deliberately lean import list. stringfish would add Rcpp (linking + compiled runtime) into a cpp11 codebase; sparsevctrs adds a tidymodels-orbit dep that we'd import specifically to not use its core feature. For a base-of-the-ecosystem package, adding a dependency to replace ~180 lines of self-contained C we already control is a poor trade even before the fit problem.

Summary

Approach Construct 30k seqs Mem/seq Subset v[i] New dep
igraph_lazy_names (this PR) O(1)/seq, no strings tiny int idx + shared source O(1) view none
sparsevctrs materialize all strings ≥ full char vec (no sparsity) copy tidymodels dep
stringfish materialize all strings full string copy/seq copy Rcpp dep

Recommendation: keep the custom class. The abstraction we need — a lazy indexed view into a shared vector — is genuinely bespoke; neither library exposes it, and both would regress exactly the construction-time and subsetting costs. The closest published analog is R's own deferred_string ALTREP (used for as.character() on int/real) and the general "row-index view" pattern — not a string-storage class.

@github-actions

Copy link
Copy Markdown
Contributor

This is how benchmark results would change (along with a 95% confidence interval in relative change) if 0b9c07c is merged into main:

  • 🚀E_named: 16.5ms -> 15.8ms [-5.55%, -2.78%]
  • ❗🐌V_named: 222µs -> 241µs [+4.07%, +12.67%]
  • 🚀all_simple_paths_named: 14.2ms -> 3.92ms [-73.4%, -71.54%]
  • ✔️as_adjacency_matrix: 582ms -> 582ms [-1.31%, +1.42%]
  • ✔️as_biadjacency_matrix: 564ms -> 558ms [-2.54%, +0.52%]
  • ✔️as_data_frame_both: 1.25ms -> 1.23ms [-2.74%, +0.8%]
  • ✔️as_long_data_frame: 3.31ms -> 3.26ms [-3.71%, +0.92%]
  • 🚀ego_order2_named: 24.7ms -> 5.7ms [-77.64%, -76.21%]
  • ✔️es_attr_filter: 2.33ms -> 2.3ms [-2.16%, +0.34%]
  • ✔️graph_from_adjacency_matrix: 93.2ms -> 92.2ms [-7.42%, +5.32%]
  • ✔️graph_from_data_frame: 2.8ms -> 2.77ms [-2.52%, +0.96%]
  • 🚀head_of_named: 486µs -> 460µs [-8.02%, -2.84%]
  • 🚀max_cliques_named: 32.1ms -> 4.68ms [-86.19%, -84.68%]
  • 🚀max_cliques_sizes_named: 33.3ms -> 5.64ms [-83.74%, -82.35%]
  • ✔️vs_attr_filter: 1.25ms -> 1.27ms [-1.36%, +4.43%]
  • ❗🐌vs_by_name: 788µs -> 814µs [+1.35%, +5.01%]
  • 🚀vs_subset_positional: 861µs -> 777µs [-12.14%, -7.49%]
    Further explanation regarding interpretation and methodology can be found in the documentation.

@krlmlr

krlmlr commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Let's split this in three separate PRs:

  • lapply() -> create_vs_list()
  • C implementation of create_vs_list() without ALTREP
  • If needed, ALTREP on top

@github-actions

Copy link
Copy Markdown
Contributor

This is how benchmark results would change (along with a 95% confidence interval in relative change) if b9e1823 is merged into main:

  • 🚀E_named: 20.1ms -> 19.2ms [-6.67%, -2.35%]
  • ❗🐌V_named: 436µs -> 476µs [+3.92%, +14.58%]
  • 🚀all_simple_paths_named: 22.3ms -> 5.82ms [-74.97%, -72.76%]
  • ✔️as_adjacency_matrix: 920ms -> 924ms [-1.12%, +2.15%]
  • ✔️as_biadjacency_matrix: 878ms -> 875ms [-1.09%, +0.44%]
  • ✔️as_data_frame_both: 1.76ms -> 1.76ms [-1.88%, +2.31%]
  • ✔️as_long_data_frame: 4.24ms -> 4.31ms [-0.32%, +3.61%]
  • 🚀ego_order2_named: 38ms -> 7.63ms [-80.67%, -79.19%]
  • ✔️es_attr_filter: 3.11ms -> 3.06ms [-4.68%, +1.55%]
  • ✔️graph_from_adjacency_matrix: 160ms -> 163ms [-0.46%, +4.61%]
  • ✔️graph_from_data_frame: 3.75ms -> 3.8ms [-0.12%, +2.68%]
  • 🚀head_of_named: 661µs -> 621µs [-7.79%, -4.18%]
  • 🚀max_cliques_named: 52.4ms -> 5.93ms [-92.87%, -84.5%]
  • 🚀max_cliques_sizes_named: 50.5ms -> 7.08ms [-87.38%, -84.6%]
  • ✔️vs_attr_filter: 1.85ms -> 1.88ms [-0.2%, +3.37%]
  • ✔️vs_by_name: 1.21ms -> 1.23ms [-0.34%, +4.18%]
  • 🚀vs_subset_positional: 1.68ms -> 1.27ms [-26.74%, -22.53%]
    Further explanation regarding interpretation and methodology can be found in the documentation.

@krlmlr

krlmlr commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Did we agree to split this PR?

@schochastics

Copy link
Copy Markdown
Contributor Author

Yes, I just did not have the time yet to do anything

A vertex/edge sequence's `names` attribute is now an instance of an
`igraph_lazy_names` ALTREP string class instead of a materialized
character vector. It holds a reference to the graph's full name vector
plus a 1-based index into it, and only builds the actual names when an
element is touched (printing, named indexing, `as_ids()`). Subsetting
stays lazy too: `Extract_subset` composes the indices in O(1) rather
than copying a slice of the names.

`V()`/`E()` build their names through `lazy_index_names()`, and
`Rx_igraph_vs_list()` attaches the ALTREP directly instead of eagerly
subsetting the name vector.

On top of the shared weak reference, create_vs_list() and the C
construction loop, this is a smaller and more selective win than the
earlier layers -- it only pays off where the name vectors themselves
are large or never read:

  ego_order2_named       4.43ms -> 3.13ms   (-29%)
  vs_subset_positional   0.111ms -> 0.095ms (-14%)
  max_cliques_named      2.67ms -> 2.67ms   (unchanged)
  all_simple_paths_named 1.94ms -> 1.92ms   (unchanged)

DATAPTR_RO/DATAPTR_OR_NULL are used rather than DATAPTR, which is
non-API as of R 4.5.

Correctness verified: integer payloads with identical values and names,
no names attribute on unnamed graphs, NA/out-of-range IDs map to
NA_STRING, inputs unmutated, and the shared weakref still releases the
graph after rm()+gc(). Full suite passes; clean under gctorture(TRUE).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@schochastics
schochastics force-pushed the feat/lazy-altrep-names branch from bce51e9 to 04bf69b Compare September 4, 2026 11:14
@schochastics schochastics changed the title perf: Speed up vertex/edge sequence construction (~10× for large result sets) perf: Lazy ALTREP names for vertex/edge sequences Sep 4, 2026
@schochastics
schochastics changed the base branch from main to perf/vs-list-c September 4, 2026 11:14
@schochastics

Copy link
Copy Markdown
Contributor Author

Split done, per @krlmlr's #2696 (comment). The stack:

PR layer base max_cliques (named, 200 nodes)
main 24.7 ms
#2886 share the graph weak-reference, set attributes in one pass main 7.8 ms (3.2×)
#2887 lapply() -> create_vs_list() #2886 5.6 ms (4.4×)
#2888 C create_vs_list(), no ALTREP #2887 2.65 ms (9.3×)
#2696 ALTREP on top #2888 2.67 ms (unchanged)

Two notes on the split as requested:

There is a fourth PR, and it goes first. Profiling the split showed the largest single win — the shared weak reference plus one-pass attribute setting — is independent of all three requested steps and accounts for most of the total. It is #2886, ~15 lines in iterators.R, no codegen and no C.

The "if needed" on ALTREP resolves to "mostly not". Once the construction loop is in C, eager names are just SET_STRING_ELT over interned CHARSXPs, so max_cliques() sees nothing from laziness. It still buys 29% on ego(order = 2) and 14% on positional subsetting of a large sequence — real, but narrow against the cost of maintaining a custom ALTREP class. Details and numbers in the rewritten description above; I am happy to close #2696 if that trade does not look worth it.

Each PR was verified independently: full suite FAIL 0 | WARN 0 | SKIP 7 | PASS 9299, and #2888/#2696 additionally checked against the return.vs.es = FALSE numeric path and under gctorture(TRUE).

@schochastics

Copy link
Copy Markdown
Contributor Author

These four are now a native GitHub stack (stack #2890), so the stack map is on each PR and #2886#2888 can be merged from the stack UI — retargeting and rebasing of the layers above cascade automatically, including under this repo's squash merges. The "Base:" lines in the descriptions are just there for reading order.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

This is how benchmark results would change (along with a 95% confidence interval in relative change) if 04bf69b is merged into perf/vs-list-c:

  • ✔️E_named: 245ms -> 241ms [-6.66%, +3.33%]
  • ✔️V_named: 453ms -> 455ms [-1.45%, +2.49%]
  • 🚀all_simple_paths_named: 144ms -> 130ms [-11.03%, -8.54%]
  • ✔️as_adjacency_matrix: 255ms -> 252ms [-2.69%, +0.62%]
  • ✔️as_biadjacency_matrix: 271ms -> 270ms [-1.12%, +1.02%]
  • ❗🐌as_data_frame_both: 240ms -> 244ms [+0.79%, +2.65%]
  • ❗🐌as_long_data_frame: 196ms -> 199ms [+0.06%, +2.69%]
  • 🚀ego_order2_named: 161ms -> 133ms [-19.31%, -15.47%]
  • 🚀es_attr_filter: 257ms -> 252ms [-3.27%, -0.64%]
  • ✔️graph_from_adjacency_matrix: 302ms -> 300ms [-3.5%, +2.65%]
  • ✔️graph_from_data_frame: 271ms -> 272ms [-1.02%, +2.03%]
  • 🚀head_of_named: 181ms -> 163ms [-10.85%, -8.77%]
  • ✔️max_cliques_named: 27.2ms -> 27.3ms [-1.55%, +2.21%]
  • ✔️max_cliques_sizes_named: 222ms -> 225ms [-1.6%, +4.17%]
  • ❗🐌vs_attr_filter: 272ms -> 294ms [+6.87%, +9.73%]
  • ❗🐌vs_by_name: 235ms -> 256ms [+7.36%, +10.19%]
  • 🚀vs_subset_positional: 238ms -> 129ms [-46.44%, -44.94%]
    Further explanation regarding interpretation and methodology can be found in the documentation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants