Skip to content

perf: resolving a full IRI is linear in the namespace count, so a PROV-O read with one directory per record is no longer quadratic in prov's share - #706

Merged
trungdong merged 7 commits into
mainfrom
perf/3.3.0-iri-resolution
Oct 29, 2026
Merged

trungdong merged 7 commits into
mainfrom
perf/3.3.0-iri-resolution

Conversation

@trungdong

Copy link
Copy Markdown
Owner

Makes resolving a full IRI linear in the number of registered namespaces, so reading RDF whose IRIs sit in many directories and that declares no prefixes stops being quadratic in prov's share. Closes #691.

Two scans grew with the namespace count on every new IRI. NamespaceManager tested every registered namespace to find the one that compacts a full IRI, and the PROV-O reader walked every binding of the source graph, including the one rdflib mints for each new directory, for every IRI the document could not name. A read with one record per directory minted a namespace per record, so both scans ran over a list that grew with the input.

The manager now keeps an index of the namespaces its dict holds, by URI length, and tests iri[:n] for each distinct length. One match is the scan's answer by construction, and two or more (nested namespaces, or two prefixes at one URI) fall back to the unchanged scan. The default namespace keeps its non-empty-local-part rule and the #599 parent-scope rule still runs first. A manager pickled by 3.2.3 has no index, so __setstate__ builds one; a current pickle and a deep copy carry theirs.

decode_document reads the source graph's bindings once, when it starts, and the graph lookup scans that snapshot. A binding the reader mints later is for a namespace it has already registered on the document, which resolves the IRI first, so the snapshot gives the same answers. An IRI the document cannot compact (one with no colon, or a blank-node label) reads the live bindings, as does any direct call to decode_container or decode_rdf_representation.

Every answer is the one main gives: a 20,000-sequence differential of the namespace manager (registrations, defaults, aliases, child and attached bundles, pickles and copies) and an 862-input differential of PROV-O reads (the RDF fixtures, the issue's N-Triples, multi-bundle TriG, rebound defaults and random graphs, at six hash seeds) found no difference in names, documents, namespaces, warnings or exceptions. NamespaceManager's docstring now says that namespaces are registered through add_namespace and set_default_namespace and that the dict's own write methods bypass its indexes; a namespace written into the dict directly no longer takes part in compaction.

The issue also proposed clearing the resolve cache per prefix instead of whole. That was built and measured, gained nothing once the graph snapshot was in place, and served a stale name in three cases, so add_namespace keeps clearing the whole cache and spec 5's F15 and section 4.4 are amended.

The issue's probe (N-Triples, one record per directory, five runs, medians, CPython 3.12.8):

Directories main this branch
500 0.142 s 0.043 s
2,000 2.037 s 0.547 s

The rest of the 2,000-directory read is rdflib's own minting (compute_qname).

ab.py main --python 3.12 on CPython 3.12.8, garbage collection off, load average 2.2:

benchmark                                                      base min   head min   change
benchmarks/test_bench.py::test_construct                         0.0445     0.0454   +2.0%
benchmarks/test_bench.py::test_deserialize[json]                 0.0776     0.0761   -2.0%
benchmarks/test_bench.py::test_deserialize[jsonld]               0.0811     0.0813   +0.3%
benchmarks/test_bench.py::test_deserialize[provn]                0.2184     0.2205   +1.0%
benchmarks/test_bench.py::test_deserialize[rdf]                  0.6585     0.6544   -0.6%
benchmarks/test_bench.py::test_deserialize[xml]                  0.1341     0.1339   -0.1%
benchmarks/test_bench.py::test_equality                          0.0387     0.0376   -2.8%
benchmarks/test_bench.py::test_prov_to_graph                     0.1740     0.1687   -3.1%
benchmarks/test_bench.py::test_serialize[json]                   0.0519     0.0520   +0.1%
benchmarks/test_bench.py::test_serialize[jsonld]                 0.0556     0.0557   +0.2%
benchmarks/test_bench.py::test_serialize[provn]                  0.0284     0.0279   -1.8%
benchmarks/test_bench.py::test_serialize[rdf]                    0.8105     0.8130   +0.3%
benchmarks/test_bench.py::test_serialize[xml]                    0.1047     0.1030   -1.6%
benchmarks/test_bench.py::test_unified                           0.0582     0.0584   +0.3%
OK

The benchmark document has few namespaces, so its rows do not move; the gain shows on the probe's shape.

…I through an index of its URIs by length, so a read with many namespaces stops scanning them all (spec 5, 4.4)

The scan tested every registered namespace for each new IRI, so a PROV-O read with one directory per record was quadratic. The index answers when one namespace matches and falls back to the scan when several do, so every answer is the scan's.
…ource graph's bindings per decode, so the prefixes it mints no longer lengthen every later lookup (spec 5, 4.4)

The loop walked every binding in the graph, including the one rdflib mints for each new directory, for every IRI the document could not name. A minted binding's namespace is registered on the document before any later lookup, so the snapshot gives the same answers.
…e, the index and docstring comments say what the dict holds, a current pickle round-trips through compaction, and a shape test pins that a read with many namespaces scans none (spec 5, 4.4)

The compacting lookup walks either the single indexed candidate or, for several, the dict's own order through one loop, so the rule that a default namespace needs a non-empty local part lives in one place. The comments say the dict's order rather than registration order, because a replaced default keeps its first position. The copied-manager test gains a protocol 5 pickle with the index intact, the PROV-O reader types its per-store bindings by Store, and test_a_read_with_many_namespaces_scans_none counts calls to the manager's values() across 50 and 200 registrations.
…dex, with add_namespace keeping the whole cache clear (spec 5, 4.4)

PR 29 built all three parts of 4.4 and measured the per-prefix cache clear at no gain once the graph index was in place, while it served a stale name in three cases the spec's argument missed: an alias rebound later, a child whose ancestor binds the IRI's scheme, and a standalone bundle attached after caching. The maintainer dropped it; 4.4, row 29, the 29 tests bullet and F15 now say what the PR ships, with its measured figures.
…s bindings through a nesting-safe context manager and reads the live bindings for an IRI the document cannot compact, and the length index drops its unindex branch for a rebuild at the one site that replaces a namespace (spec 5, 4.4)

The reader read the source graph's bindings per store at the first lookup, which let a binding minted mid-decode answer a colon-less or blank-node-style IRI differently from `main`. It now takes one snapshot when the outermost decode starts, uses it only for an IRI the document can compact, and reads the live bindings for any other, so every answer stays `main`'s. The length index is written when a prefix is first bound and rebuilt from the dict's values when the default namespace is replaced, which is the only replacing write, and the class docstring names the write methods that bypass it. The tests pin the snapshot lifetime, compare documents in the test body, and read a name through the public `valid_qualified_name`.
…amespace manager builds its length index in one place, with the tests built from the constructor (spec 5, 4.4)

The PROV-O reader reads the source's namespace bindings once when decode_document starts and releases them in a finally, so decode_container and decode_rdf_representation read the live bindings as they do on main and the nesting-safe context manager, the store import and the contextlib import go. The dispatch moves into one private method to keep decode_document at or below main's complexity. The namespace manager drops its store helper for a plain item assignment and an index call in add_namespace, builds the index from the constructor through the reindex, and its docstring covers every write method of the dict. The namespace tests build managers through the constructor, the lookup rows drop their module constants for PROV.uri and the shared Turtle prefixes, the colon-less row goes through decode_document, the between-reads row adds a binding after a decode_document and reads outside it, and the duplicate protocol 2 pickle row is gone.
@codacy-production

codacy-production Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 31 complexity

Metric Results
Complexity 31

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@coveralls

coveralls commented Oct 29, 2026 •

Copy link
Copy Markdown

Coverage Status

coverage: 98.416% (+0.01%) from 98.405% — perf/3.3.0-iri-resolution into main

…lper, so Codacy's Semgrep pickle rule is suppressed as test_identifier_slots.py does (spec 5, 4.4)

Codacy Cloud's Semgrep flagged the copy test's pickle.loads and pickle.dumps calls, which round-trip a document the test built itself; one helper holds the call under the repository's nosec B301 - nosemgrep mark.
@trungdong
trungdong merged commit 48667d3 into main Oct 29, 2026
23 checks passed
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.

PROV-O reader: reading RDF with many IRI directories and no prefixes is quadratic

2 participants