diff --git a/.gitignore b/.gitignore index 0728338..c7c7c3d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.vscode +Cargo.lock # Generated by Cargo # will have compiled files and executables debug diff --git a/AGENTS.md b/AGENTS.md index 2e8a084..53bc987 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,179 +9,121 @@ decomposes a graph by edge label into one Boolean adjacency matrix per label. ## Repository Layout -``` -pathrex/ -├── Cargo.toml # Crate manifest (edition 2024) -├── build.rs # Links LAGraph + LAGraphX; optionally regenerates FFI bindings -├── src/ -│ ├── lib.rs # Modules: eval, formats, graph, rpq, sparql, utils, lagraph_sys -│ ├── main.rs # Binary entry point (placeholder) -│ ├── lagraph_sys.rs # FFI module — includes generated bindings -│ ├── lagraph_sys_generated.rs# Bindgen output (checked in, regenerated in CI) -│ ├── utils.rs # Public helpers: CountingBuilder, CountOutput, VecSource, -│ │ # grb_ok! and la_ok! macros, build_graph -│ ├── graph/ -│ │ ├── mod.rs # Core traits (GraphBuilder, GraphDecomposition, GraphSource, -│ │ │ # Backend, Graph), error types, RAII wrappers, GrB init -│ │ └── inmemory.rs # InMemory marker, InMemoryBuilder, InMemoryGraph -│ ├── eval/ -│ │ └── mod.rs # Evaluator, PreparedEvaluator, ResultCount traits -│ ├── rpq/ -│ │ ├── mod.rs # RPQ query types, RpqError, RPQ marker subtraits -│ │ ├── nfarpq.rs # NfaRpqEvaluator (LAGraph_RegularPathQuery) -│ │ └── rpqmatrix.rs # Matrix-plan RPQ evaluator -│ ├── sparql/ -│ │ └── mod.rs # parse_rpq / extract_rpq → RpqQuery (spargebra) -│ └── formats/ -│ ├── mod.rs # FormatError enum, re-exports -│ ├── csv.rs # Csv — CSV → Edge iterator (CsvConfig, ColumnSpec) -│ ├── mm.rs # MatrixMarket directory loader (vertices.txt, edges.txt, *.txt) -│ └── rdf.rs # Rdf — unified RDF parser (N-Triples, Turtle) → Edge iterator -├── tests/ -│ ├── inmemory_tests.rs # Integration tests for InMemoryBuilder / InMemoryGraph -│ ├── mm_tests.rs # Integration tests for MatrixMarket format -│ ├── nfarpq_tests.rs # Integration tests for NfaRpqEvaluator -│ └── rpqmatrix_tests.rs # Integration tests for matrix-plan RPQ evaluator -├── pathrex-sys/deps/ -│ └── LAGraph/ # Git submodule (SparseLinearAlgebra/LAGraph) -└── .github/workflows/ci.yml # CI: build GraphBLAS + LAGraph, cargo build & test +Paths below are relative to the Pathrex workspace root, not to the enclosing +RPQ_bench repository. + +```text +. +├── Cargo.toml # Workspace, edition 2024, MSRV 1.90 +├── pathrex/ +│ ├── Cargo.toml # Rust API; optional bench feature enables the CLI +│ ├── src/ +│ │ ├── lib.rs # Public modules and pathrex_sys re-export +│ │ ├── bin/pathrex.rs # CLI entry point (requires bench) +│ │ ├── cli/ # Arguments, loading, dispatch, benchmarking, output +│ │ ├── eval/mod.rs # Evaluator, PreparedEvaluator, ResultCount +│ │ ├── graph/ +│ │ │ ├── mod.rs # Graph traits, backend handle, errors +│ │ │ ├── inmemory.rs # In-memory graph, CSR/CSC stores, metadata +│ │ │ └── wrappers.rs # Native RAII wrappers and initialization +│ │ ├── rpq/ +│ │ │ ├── mod.rs # Endpoint, PathExpr, RpqQuery, errors +│ │ │ ├── nfarpq.rs # NFA evaluator +│ │ │ └── rpqmatrix/ +│ │ │ ├── eval.rs # RPQMatrix preparation +│ │ │ ├── expr.rs # Query translation and native plan materialization +│ │ │ ├── plan.rs # RpqPlan language and rewrite rules +│ │ │ ├── optimize.rs # Strategy selection, EGraphOptimizer, extraction +│ │ │ ├── cost.rs # Cost models +│ │ │ ├── sampling.rs # Induced-subgraph sampling +│ │ │ ├── stats.rs # Count-vector statistics +│ │ │ └── result.rs # PreparedRpqMatrix and execution results +│ │ ├── sparql/mod.rs # SPARQL to RpqQuery +│ │ ├── formats/ # CSV, MatrixMarket, RDF sources +│ │ └── utils.rs # FFI error macros and test helpers +│ └── tests/ # Integration tests and Git LFS fixtures +├── pathrex-sys/ +│ ├── Cargo.toml +│ ├── build.rs # Fetch/build GraphBLAS, build/link LAGraph +│ ├── src/ +│ │ ├── lib.rs # Raw FFI API +│ │ └── lagraph_sys_generated.rs +│ └── deps/LAGraph/ # Native dependency submodule with RPQ extensions +└── .github/workflows/ # CI and release workflows ``` ## Build & Dependencies -### System prerequisites - -| Dependency | Purpose | -|---|---| -| **cmake** | Building GraphBLAS and LAGraph from source | -| **git** | Fetching pinned GraphBLAS source at build time | -| **C/C++ toolchain** | Compiling GraphBLAS and LAGraph (gcc or clang) | -| **OpenMP runtime** | Linked dynamically: `libgomp` on Linux, `libomp` on macOS, `/openmp` on MSVC | -| **libclang-dev / clang** | Required by `bindgen` when `regenerate-bindings` feature is active | - -SuiteSparse:GraphBLAS no longer needs to be installed system-wide. It is -fetched, built statically, and linked into the binary by `pathrex-sys/build.rs`. - -### Building - -```bash -# Ensure the LAGraph submodule is present -git submodule update --init --recursive - -# Build pathrex. First cold build clones GraphBLAS and runs cmake; takes -# ~2-10 minutes depending on core count. Subsequent builds reuse the -# GraphBLAS source tree under target/.../graphblas-src/ and the cmake -# build dir, so they are incremental. -cargo build - -# Run tests (no LD_LIBRARY_PATH needed — everything but the OpenMP -# runtime is statically linked) -cargo test --workspace -``` - -### How `pathrex-sys/build.rs` handles linking - -[`pathrex-sys/build.rs`](pathrex-sys/build.rs) performs three jobs: - -1. **GraphBLAS fetch.** Clones SuiteSparse:GraphBLAS at the pin defined by - the `GRAPHBLAS_TAG` constant (currently `v10.3.1`) into - `$OUT_DIR/graphblas-src/` via `git clone --depth=1 --branch `. A - sentinel file `/.pathrex-fetched` containing the tag string marks - a completed clone; if the pin is bumped, the sentinel mismatches and - the clone is wiped and retried. If the directory exists without a - sentinel (interrupted earlier clone), it is also wiped before retrying. - -2. **Native build + linking.** Drives cmake twice — once for GraphBLAS, - once for the `pathrex-sys/deps/LAGraph` submodule: - - - GraphBLAS flags: `BUILD_SHARED_LIBS=OFF`, `BUILD_STATIC_LIBS=ON`, - `GRAPHBLAS_BUILD_STATIC_LIBS=ON` (belt-and-braces), - `GRAPHBLAS_USE_JIT=OFF` (no runtime C compiler required), - `GRAPHBLAS_COMPACT=OFF` (full FactoryKernels for performance), - `GRAPHBLAS_USE_OPENMP=ON`, `GRAPHBLAS_USE_CUDA=OFF`, - `SUITESPARSE_DEMOS=OFF`, `BUILD_TESTING=OFF`, `Release` profile. - - LAGraph flags: `BUILD_SHARED_LIBS=OFF`, `BUILD_STATIC_LIBS=ON`, - `BUILD_TESTING=OFF`, plus `CMAKE_PREFIX_PATH=` - and `GRAPHBLAS_ROOT=` so LAGraph's - `find_package(GraphBLAS)` picks up our static build instead of any - system one. - - Static archives land in `$OUT_DIR/.../out/lib/` (or `lib64/` on - Fedora-family distros — both candidates are probed by `pick_libdir`). - - Emits `cargo:rustc-link-lib=static=lagraphx`, - `cargo:rustc-link-lib=static=lagraph`, - `cargo:rustc-link-lib=static=graphblas`. Order matters: `lagraphx` - references symbols from `lagraph`'s utility module; both reference - `graphblas`. - - Emits OS-specific runtime libraries: `gomp`+`pthread`+`dl`+`m` on - Linux, `omp`+`pthread` on macOS, nothing explicit on MSVC. Override - via `RUSTFLAGS` if your toolchain ships a different OpenMP runtime - (e.g. `libomp` on Linux + clang). - -3. **docs.rs guard.** If the `DOCS_RS` environment variable is set, the - entire native build is skipped. docs.rs sandboxes block all network - access (so the `git clone` would fail) and have strict time/memory - limits; rustdoc only needs to compile Rust code, not link or execute - it. - -4. **Optional FFI binding regeneration** (feature `regenerate-bindings`). - When the feature is active, `regenerate_bindings()` runs `bindgen` - against `deps/LAGraph/include/LAGraph.h`, - `deps/LAGraph/include/LAGraphX.h`, and the GraphBLAS install tree's - `include/suitesparse/GraphBLAS.h`. The generated Rust file is written - to [`pathrex-sys/src/lagraph_sys_generated.rs`](pathrex-sys/src/lagraph_sys_generated.rs). - Only a curated allowlist of GraphBLAS/LAGraph types and functions is - exposed (see the `allowlist_*` calls in `pathrex-sys/build.rs`). - -### Feature flags - -| Feature | Effect | -|---|---| -| `regenerate-bindings` | Runs `bindgen` at build time to regenerate `src/lagraph_sys_generated.rs` from `LAGraph.h`, `LAGraphX.h` (both from `deps/LAGraph/include`) and `GraphBLAS.h`. Without this feature the checked-in bindings are used as-is. | - -### Pre-generated FFI bindings - -The file `src/lagraph_sys_generated.rs` is checked into version control. CI -regenerates it with `--features regenerate-bindings`. **Do not hand-edit this file.** +The [local build guide](README.md#local-build-linux) is the canonical source of +installation and build commands. Run them from this workspace root. + +- MSRV: **Rust 1.90**, inherited by both crates from the workspace manifest. + Edition 2024 alone would require 1.85, but the current resolved dependency + set includes `ordered-float 5.5.0` (MSRV 1.90) and RDF/SPARQL dependencies + requiring 1.87. +- Resolver 3 prefers dependency versions compatible with the declared MSRV. +- Native prerequisites: Git, CMake, a C/C++ compiler and an OpenMP runtime. + The documented Linux configuration uses GCC and `libgomp`. +- Git LFS is needed to obtain integration-test fixtures. +- `cargo build --workspace` builds the libraries. + `cargo build --release --features bench` also builds + `target/release/pathrex`. The CLI is not built without `bench`. +- `pathrex-sys/build.rs` fetches GraphBLAS at `GRAPHBLAS_TAG` + (currently `v10.3.1`) and builds GraphBLAS and LAGraph statically. + A system installation of either library and `LD_LIBRARY_PATH` are not required. + The first native build requires network access; later builds reuse + `$OUT_DIR/graphblas-src/` and CMake build artifacts. +- `pathrex-sys/regenerate-bindings` is optional and requires Clang/libclang. + It rewrites + [`pathrex-sys/src/lagraph_sys_generated.rs`](pathrex-sys/src/lagraph_sys_generated.rs) + using the bundled LAGraph headers and the just-built GraphBLAS headers. + Ordinary builds use the checked-in bindings. **Do not hand-edit them.** +- `DOCS_RS` skips native building for rustdoc; it is not an option for building + a runnable binary. +- `Cargo.lock` is currently ignored. Preserve it for experiments and use + `--locked` after the initial resolution to retain the dependency set. ## Architecture & Key Abstractions ### Edge -[`Edge`](src/graph/mod.rs:158) is the universal currency between format parsers and graph +[`Edge`](pathrex/src/graph/mod.rs) is the universal currency between format parsers and graph builders: `{ source: String, target: String, label: String }`. ### GraphSource trait -[`GraphSource`](src/graph/mod.rs:168) is implemented by any data source that knows how to +[`GraphSource`](pathrex/src/graph/mod.rs) is implemented by any data source that knows how to feed itself into a specific [`GraphBuilder`]: -- [`apply_to(self, builder: B) -> Result`](src/graph/mod.rs:169) — consumes the +- [`apply_to(self, builder: B) -> Result`](pathrex/src/graph/mod.rs) — consumes the source and returns the populated builder. -[`Csv`](src/formats/csv.rs), [`MatrixMarket`](src/formats/mm.rs), and [`Rdf`](src/formats/rdf.rs) -implement `GraphSource` (see [`src/graph/inmemory.rs`](src/graph/inmemory.rs)), so they +[`Csv`](pathrex/src/formats/csv.rs), [`MatrixMarket`](pathrex/src/formats/mm.rs), and [`Rdf`](pathrex/src/formats/rdf.rs) +implement `GraphSource` (see [`pathrex/src/graph/inmemory.rs`](pathrex/src/graph/inmemory.rs)), so they can be passed to [`GraphBuilder::load`] and [`Graph::try_from`]. ### GraphBuilder trait -[`GraphBuilder`](src/graph/mod.rs:173) accumulates edges and produces a -[`GraphDecomposition`](src/graph/mod.rs:193): +[`GraphBuilder`](pathrex/src/graph/mod.rs) accumulates edges and produces a +[`GraphDecomposition`](pathrex/src/graph/mod.rs): -- [`load>(self, source: S)`](src/graph/mod.rs:183) — primary entry point; +- [`load>(self, source: S)`](pathrex/src/graph/mod.rs) — primary entry point; delegates to `GraphSource::apply_to`. -- [`build(self)`](src/graph/mod.rs:188) — finalise into an immutable graph. +- [`build(self)`](pathrex/src/graph/mod.rs) — finalise into an immutable graph. `InMemoryBuilder` also exposes lower-level helpers outside the trait: -- [`push_edge(&mut self, edge: Edge)`](src/graph/inmemory.rs:83) — ingest one edge. -- [`with_stream(self, stream: I)`](src/graph/inmemory.rs:93) — consume an +- [`push_edge(&mut self, edge: Edge)`](pathrex/src/graph/inmemory.rs) — ingest one edge. +- [`with_stream(self, stream: I)`](pathrex/src/graph/inmemory.rs) — consume an `IntoIterator>`. -- [`push_grb_matrix(&mut self, label, matrix: GrB_Matrix)`](src/graph/inmemory.rs:106) — accept - a pre-built `GrB_Matrix` for a label, wrapping it in an `LAGraph_Graph` immediately. + +The MatrixMarket loader uses crate-private `extend_prebuilt`, +`extend_prebuilt_csc`, and `extend_metadata` helpers to install already loaded +matrices and their statistics. ### Backend trait & Graph\ handle -[`Backend`](src/graph/mod.rs:221) associates a marker type with a concrete builder/graph pair: +[`Backend`](pathrex/src/graph/mod.rs) associates a marker type with a concrete builder/graph pair: ```rust pub trait Backend { @@ -190,60 +132,71 @@ pub trait Backend { } ``` -[`Graph`](src/graph/mod.rs:233) is a zero-sized handle parameterised by a `Backend`: +[`Graph`](pathrex/src/graph/mod.rs) is a zero-sized handle parameterised by a `Backend`: -- [`Graph::::builder()`](src/graph/mod.rs:238) — returns a fresh `InMemoryBuilder`. -- [`Graph::::try_from(source)`](src/graph/mod.rs:242) — builds a graph from a single +- [`Graph::::builder()`](pathrex/src/graph/mod.rs) — returns a fresh `InMemoryBuilder`. +- [`Graph::::try_from(source)`](pathrex/src/graph/mod.rs) — builds a graph from a single source in one call. -[`InMemory`](src/graph/inmemory.rs:27) is the concrete backend marker type. +[`InMemory`](pathrex/src/graph/inmemory.rs) is the concrete backend marker type. ### GraphDecomposition trait -[`GraphDecomposition`](src/graph/mod.rs:193) is the read-only query interface: +[`GraphDecomposition`](pathrex/src/graph/mod.rs) is the read-only query interface: -- [`get_graph(label)`](src/graph/mod.rs:197) — returns `Arc` for a given edge label. -- [`get_node_id(string_id)`](src/graph/mod.rs:200) / [`get_node_name(mapped_id)`](src/graph/mod.rs:203) — bidirectional string ↔ integer dictionary. -- [`num_nodes()`](src/graph/mod.rs:204) — total unique nodes. +- [`get_graph(label)`](pathrex/src/graph/mod.rs) — returns `Arc` for a given edge label. +- [`get_node_id(string_id)`](pathrex/src/graph/mod.rs) / [`get_node_name(mapped_id)`](pathrex/src/graph/mod.rs) — bidirectional string ↔ integer dictionary. +- [`num_nodes()`](pathrex/src/graph/mod.rs) — total unique nodes. +- [`get_graph_with_storage(label, storage)`](pathrex/src/graph/mod.rs) — preferred + CSR/CSC orientation, defaulting to `get_graph` for backends without both. +- [`get_metadata()`](pathrex/src/graph/mod.rs) — optional cached matrix statistics. -### Generic evaluator abstraction (`src/eval/`) +### Generic evaluator abstraction (`pathrex/src/eval/`) -[`src/eval/mod.rs`](src/eval/mod.rs) defines query-language-agnostic evaluator traits: +[`pathrex/src/eval/mod.rs`](pathrex/src/eval/mod.rs) defines query-language-agnostic evaluator traits: -- [`Evaluator`](src/eval/mod.rs) uses associated types for `Query`, `Result`, `Error`, and +- [`Evaluator`](pathrex/src/eval/mod.rs) uses associated types for `Query`, `Result`, `Error`, and `Prepared`. The graph backend stays a method-level generic (`G: GraphDecomposition`) so one evaluator type can run against any graph backend selected at the call site. -- [`PreparedEvaluator`](src/eval/mod.rs) represents prepared `(query, graph)` state that can be +- [`PreparedEvaluator`](pathrex/src/eval/mod.rs) represents prepared `(query, graph)` state that can be executed repeatedly, which is used by benchmark timing loops. -- [`ResultCount`](src/eval/mod.rs) is separate from `Evaluator::Result`; only CLI runners that +- [`ResultCount`](pathrex/src/eval/mod.rs) is separate from `Evaluator::Result`; only CLI runners that need counts require this bound, leaving room for future evaluators with richer result types. ### InMemoryBuilder / InMemoryGraph -[`InMemoryBuilder`](src/graph/inmemory.rs:36) is the primary `GraphBuilder` implementation. -It collects edges in RAM, then [`build()`](src/graph/inmemory.rs:131) calls +[`InMemoryBuilder`](pathrex/src/graph/inmemory.rs) is the primary `GraphBuilder` implementation. +It collects edges in RAM, then [`build()`](pathrex/src/graph/inmemory.rs) calls GraphBLAS to create one `GrB_Matrix` per label via COO format, wraps each in an -`LAGraph_Graph`, and returns an [`InMemoryGraph`](src/graph/inmemory.rs:174). +`LAGraph_Graph`, and returns an [`InMemoryGraph`](pathrex/src/graph/inmemory.rs). Multiple CSV sources can be chained with repeated `.load()` calls; all edges are merged into a single graph. +The MatrixMarket loader populates both CSR and CSC matrix stores, along with +`GraphMetadata` containing a `MatrixMetadata` per label: dimension, nonzero +count, and numbers of nonempty rows and columns. `MatrixStatsMode::Basic` +adds row/column count vectors; `Extended` also adds the MNC singleton-related +vectors. The CLI requests `Extended` for MNC and `None` for other strategies. +CSV/RDF loading does not populate these cached statistics or a separate CSC store; +consumers use matrix/statistics fallbacks when needed. + **Node ID representation:** Internally, `InMemoryBuilder` uses `HashMap` for `id_to_node` (changed from `Vec` to support sparse/pre-assigned IDs from MatrixMarket). -The [`set_node_map()`](src/graph/inmemory.rs:67) method allows bulk-installing a node mapping, +The [`set_node_map()`](pathrex/src/graph/inmemory.rs) method allows bulk-installing a node mapping, which is used by the MatrixMarket loader. ### Format parsers -Three built-in parsers are available, each yielding -`Iterator>` and pluggable into -`GraphBuilder::load()` via `GraphSource` (see [`src/graph/inmemory.rs`](src/graph/inmemory.rs)). +Three data sources implement `GraphSource` and can be passed to +`GraphBuilder::load()` (see [`pathrex/src/graph/inmemory.rs`](pathrex/src/graph/inmemory.rs)). +CSV and RDF provide edge streams; MatrixMarket loads matrices directly. #### `Csv` -[`Csv`](src/formats/csv.rs) parses delimiter-separated edge files. +[`Csv`](pathrex/src/formats/csv.rs) parses delimiter-separated edge files. -Configuration is via [`CsvConfig`](src/formats/csv.rs:17): +Configuration is via [`CsvConfig`](pathrex/src/formats/csv.rs): | Field | Default | Description | |---|---|---| @@ -253,40 +206,40 @@ Configuration is via [`CsvConfig`](src/formats/csv.rs:17): | `has_header` | `true` | Whether the first row is a header | | `delimiter` | `b','` | Field delimiter byte | -[`ColumnSpec`](src/formats/csv.rs:11) is either `Index(usize)` or `Name(String)`. +[`ColumnSpec`](pathrex/src/formats/csv.rs) is either `Index(usize)` or `Name(String)`. Name-based lookup requires `has_header: true`. #### MatrixMarket directory format -[`MatrixMarket`](src/formats/mm.rs) loads an edge-labeled graph from a directory with: +[`MatrixMarket`](pathrex/src/formats/mm.rs) loads an edge-labeled graph from a directory with: -- `vertices.txt` — one line per node: ` <1-based-index>` on disk; [`get_node_id`](src/graph/mod.rs:200) returns the matching **0-based** matrix index +- `vertices.txt` — one line per node: ` <1-based-index>` on disk; [`get_node_id`](pathrex/src/graph/mod.rs) returns the matching **0-based** matrix index - `edges.txt` — one line per label: ` <1-based-index>` (selects `n.txt`) - `.txt` — MatrixMarket adjacency matrix for label with index `n` Names in mapping files may be written with SPARQL-style angle brackets (e.g. ``). -[`parse_index_map`](src/formats/mm.rs) strips a single pair of surrounding `<`/`>` so +[`parse_index_map`](pathrex/src/formats/mm.rs) strips a single pair of surrounding `<`/`>` so dictionary keys match short labels (`Article1`), aligning with IRIs after -[`RpqQuery::strip_base`](src/rpq/mod.rs) on SPARQL-derived queries. +[`RpqQuery::strip_base`](pathrex/src/rpq/mod.rs) on SPARQL-derived queries. -The loader uses [`LAGraph_MMRead`](src/lagraph_sys.rs) to parse each `.txt` file into a +The loader uses [`LAGraph_MMRead`](pathrex-sys/src/lib.rs) to parse each `.txt` file into a `GrB_Matrix`, then wraps it in an `LAGraph_Graph`. Vertex indices from `vertices.txt` are -converted to 0-based and installed via [`InMemoryBuilder::set_node_map()`](src/graph/inmemory.rs:67). +converted to 0-based and installed via [`InMemoryBuilder::set_node_map()`](pathrex/src/graph/inmemory.rs). Helper functions: -- [`load_mm_file(path)`](src/formats/mm.rs:39) — reads a single MatrixMarket file into a - `GrB_Matrix`. -- [`parse_index_map(path)`](src/formats/mm.rs:81) — parses ` ` lines; indices must be **>= 1** and **unique** within the file. +- [`load_mm_file(path)`](pathrex/src/graph/wrappers.rs) — reads a single MatrixMarket file + into a `GraphblasMatrix`; re-exported by `formats::mm`. +- [`parse_index_map(path)`](pathrex/src/formats/mm.rs) — parses ` ` lines; indices must be **>= 1** and **unique** within the file. -`MatrixMarket` implements `GraphSource` in [`src/graph/inmemory.rs`](src/graph/inmemory.rs) (see the `impl` at line 215): `vertices.txt` maps are converted from 1-based file indices to 0-based matrix ids before [`set_node_map`](src/graph/inmemory.rs:67); `edges.txt` indices are unchanged for `n.txt` lookup. +`MatrixMarket` implements `GraphSource` in [`pathrex/src/graph/inmemory.rs`](pathrex/src/graph/inmemory.rs): `vertices.txt` maps are converted from 1-based file indices to 0-based matrix ids before [`set_node_map`](pathrex/src/graph/inmemory.rs); `edges.txt` indices are unchanged for `n.txt` lookup. #### `Rdf` — Unified RDF Parser -[`Rdf`](src/formats/rdf.rs) is a unified parser for RDF formats using `oxttl` and `oxrdf`. -It supports both **N-Triples** (`.nt`) and **Turtle** (`.ttl`) formats via the [`RdfFormat`](src/formats/rdf.rs) enum. +[`Rdf`](pathrex/src/formats/rdf.rs) is a unified parser for RDF formats using `oxttl` and `oxrdf`. +It supports both **N-Triples** (`.nt`) and **Turtle** (`.ttl`) formats via the [`RdfFormat`](pathrex/src/formats/rdf.rs) enum. -Each triple `(subject, predicate, object)` becomes an [`Edge`](src/graph/mod.rs:158) where: +Each triple `(subject, predicate, object)` becomes an [`Edge`](pathrex/src/graph/mod.rs) where: - `source` — subject IRI or blank-node ID (`_:label`). - `target` — object IRI or blank-node ID; triples whose object is an RDF @@ -295,9 +248,9 @@ Each triple `(subject, predicate, object)` becomes an [`Edge`](src/graph/mod.rs: Constructor: -- [`Rdf::from_path(path)`](src/formats/rdf.rs) — auto-detects format from file extension (`.nt` → N-Triples, `.ttl` → Turtle). Parses in parallel using memory-mapping and rayon. +- [`Rdf::from_path(path)`](pathrex/src/formats/rdf.rs) — auto-detects format from file extension (`.nt` → N-Triples, `.ttl` → Turtle). Parses in parallel using memory-mapping and rayon. -Format detection via [`RdfFormat::from_path(path)`](src/formats/rdf.rs): +Format detection via [`RdfFormat::from_path(path)`](pathrex/src/formats/rdf.rs): | Extension | Format | |---|---| @@ -316,10 +269,10 @@ let graph = Graph::::try_from( )?; ``` -### SPARQL parsing (`src/sparql/mod.rs`) +### SPARQL parsing (`pathrex/src/sparql/mod.rs`) -The [`sparql`](src/sparql/mod.rs) module uses the [`spargebra`](https://crates.io/crates/spargebra) -crate to parse SPARQL 1.1 query strings and build a pathrex-native [`RpqQuery`](src/rpq/mod.rs) +The [`sparql`](pathrex/src/sparql/mod.rs) module uses the [`spargebra`](https://crates.io/crates/spargebra) +crate to parse SPARQL 1.1 query strings and build a pathrex-native [`RpqQuery`](pathrex/src/rpq/mod.rs) for RPQ evaluators. **Supported query form:** `SELECT` queries with exactly one triple or property @@ -333,102 +286,55 @@ SELECT ?x ?y WHERE { ?x /* ?y . } Key public items: -- [`parse_rpq(sparql)`](src/sparql/mod.rs) — parses a SPARQL string with - `SparqlParser` and returns an [`RpqQuery`](src/rpq/mod.rs). -- [`extract_rpq(query)`](src/sparql/mod.rs) — validates a parsed [`spargebra::Query`] is a - `SELECT` with a single path pattern and returns an [`RpqQuery`](src/rpq/mod.rs). +- [`parse_rpq(sparql)`](pathrex/src/sparql/mod.rs) — parses a SPARQL string with + `SparqlParser` and returns an [`RpqQuery`](pathrex/src/rpq/mod.rs). +- [`extract_rpq(query)`](pathrex/src/sparql/mod.rs) — validates a parsed [`spargebra::Query`] is a + `SELECT` with a single path pattern and returns an [`RpqQuery`](pathrex/src/rpq/mod.rs). Use this when you construct a custom [`SparqlParser`](https://docs.rs/spargebra) (e.g. with prefix declarations) and call `parse_query` yourself. -- [`ExtractError`](src/sparql/mod.rs) — error enum for extraction failures +- [`ExtractError`](pathrex/src/sparql/mod.rs) — error enum for extraction failures (`NotSelect`, `NotSinglePath`, `UnsupportedSubject`, `UnsupportedObject`, - `VariablePredicate`). Converts to [`RpqError::Extract`](src/rpq/mod.rs) via `#[from]`. + `VariablePredicate`). Converts to [`RpqError::Extract`](pathrex/src/rpq/mod.rs) via `#[from]`. -Call [`RpqQuery::strip_base`](src/rpq/mod.rs) when graph edge labels are short names +Call [`RpqQuery::strip_base`](pathrex/src/rpq/mod.rs) when graph edge labels are short names and the parsed query contains full IRIs sharing a common prefix. The module handles spargebra's desugaring of sequence paths (`?x // ?y`) from a chain of BGP triples back into a single path expression. -### RPQ evaluation (`src/rpq/`) - -The [`rpq`](src/rpq/mod.rs) module provides an abstraction for evaluating -Regular Path Queries (RPQs) over edge-labeled graphs using GraphBLAS/LAGraph. - -Key public items: - -- [`Endpoint`](src/rpq/mod.rs) — `Variable(String)` or `Named(String)` (IRI string). -- [`PathExpr`](src/rpq/mod.rs) — `Label`, `Sequence`, `Alternative`, `ZeroOrMore`, - `OneOrMore`, `ZeroOrOne`. -- [`RpqQuery`](src/rpq/mod.rs) — `{ subject, path, object }` using the types above; - [`strip_base(&mut self, base)`](src/rpq/mod.rs) removes a shared IRI prefix from - named endpoints and labels. -- [`RpqEvaluator`](src/rpq/mod.rs) — marker subtrait over - [`Evaluator`](src/eval/mod.rs), preserving the RPQ-facing - trait name while the generic evaluator hierarchy lives in `src/eval/`. -- [`PreparedRpq`](src/rpq/mod.rs) — marker subtrait over - [`PreparedEvaluator`](src/eval/mod.rs). -- [`RpqError`](src/rpq/mod.rs) — unified error type for RPQ parsing and evaluation: - `Parse` (SPARQL syntax), `Extract` (query extraction), `UnsupportedPath`, - `VertexNotFound`, and `Graph` (wraps [`GraphError`](src/graph/mod.rs) for - label-not-found and GraphBLAS/LAGraph failures). - -[`NfaRpqResult`](src/rpq/nfarpq.rs) wraps a [`GraphblasVector`] of reachable **target** -vertices. When the subject is a variable, every vertex is used as a source and -`LAGraph_RegularPathQuery` returns the union of targets — individual `(source, target)` -pairs are not preserved. - -#### `RpqMatrixEvaluator` (`src/rpq/rpqmatrix.rs`) - -[`RpqMatrixEvaluator`](src/rpq/rpqmatrix.rs) compiles [`PathExpr`] into a Boolean matrix plan -over label adjacency matrices and runs [`LAGraph_RPQMatrix`]. It returns -[`RpqMatrixResult`](src/rpq/rpqmatrix.rs): the path-relation `nnz` plus a -[`GraphblasMatrix`] duplicate of the result matrix (full reachability relation for the path). -Subject/object do not filter the matrix; a named subject is only validated to exist. -Bound objects are not supported yet ([`RpqError::UnsupportedPath`]). -[`NTriples`](src/formats/nt.rs:51) parses [W3C N-Triples](https://www.w3.org/TR/n-triples/) -RDF files using `oxttl` and `oxrdf`. Each triple `(subject, predicate, object)` becomes an -[`Edge`](src/graph/mod.rs:158) where: +### RPQ evaluation (`pathrex/src/rpq/`) -- `source` — subject IRI or blank-node ID (`_:label`). -- `target` — object IRI or blank-node ID; triples whose object is an RDF - literal yield `Err(FormatError::LiteralAsNode)` (callers may filter these out). -- `label` — full predicate IRI string (including fragment `#…` when present). - -Constructor: - -- [`NTriples::new(reader)`](src/formats/nt.rs:56) — parses the stream; each predicate IRI is copied verbatim to the edge label. -### SPARQL parsing (`src/sparql/mod.rs`) - -The [`rpq`](src/rpq/mod.rs) module provides an abstraction for evaluating +The [`rpq`](pathrex/src/rpq/mod.rs) module provides an abstraction for evaluating Regular Path Queries (RPQs) over edge-labeled graphs using GraphBLAS/LAGraph. Key public items: -- [`Endpoint`](src/rpq/mod.rs) — `Variable(String)` or `Named(String)` (IRI string). -- [`PathExpr`](src/rpq/mod.rs) — `Label`, `Sequence`, `Alternative`, `ZeroOrMore`, +- [`Endpoint`](pathrex/src/rpq/mod.rs) — `Variable(String)` or `Named(String)` (IRI string). +- [`PathExpr`](pathrex/src/rpq/mod.rs) — `Label`, `Sequence`, `Alternative`, `ZeroOrMore`, `OneOrMore`, `ZeroOrOne`. -- [`RpqQuery`](src/rpq/mod.rs) — `{ subject, path, object }` using the types above; - [`strip_base(&mut self, base)`](src/rpq/mod.rs) removes a shared IRI prefix from +- [`RpqQuery`](pathrex/src/rpq/mod.rs) — `{ subject, path, object }` using the types above; + [`strip_base(&mut self, base)`](pathrex/src/rpq/mod.rs) removes a shared IRI prefix from named endpoints and labels. -- [`RpqEvaluator`](src/rpq/mod.rs) — marker subtrait over - [`Evaluator`](src/eval/mod.rs), preserving the RPQ-facing - trait name while the generic evaluator hierarchy lives in `src/eval/`. -- [`PreparedRpq`](src/rpq/mod.rs) — marker subtrait over - [`PreparedEvaluator`](src/eval/mod.rs). -- [`RpqError`](src/rpq/mod.rs) — unified error type for RPQ parsing and evaluation: +- [`RpqEvaluator`](pathrex/src/rpq/mod.rs) — marker subtrait over + [`Evaluator`](pathrex/src/eval/mod.rs), preserving the RPQ-facing + trait name while the generic evaluator hierarchy lives in `pathrex/src/eval/`. +- [`PreparedRpq`](pathrex/src/rpq/mod.rs) — marker subtrait over + [`PreparedEvaluator`](pathrex/src/eval/mod.rs). +- [`RpqError`](pathrex/src/rpq/mod.rs) — unified error type for RPQ parsing and evaluation: `Parse` (SPARQL syntax), `Extract` (query extraction), `UnsupportedPath`, - `VertexNotFound`, and `Graph` (wraps [`GraphError`](src/graph/mod.rs) for + `VertexNotFound`, and `Graph` (wraps [`GraphError`](pathrex/src/graph/mod.rs) for label-not-found and GraphBLAS/LAGraph failures). -#### `NfaRpqEvaluator` (`src/rpq/nfarpq.rs`) +#### `NfaRpqEvaluator` (`pathrex/src/rpq/nfarpq.rs`) -[`NfaRpqEvaluator`](src/rpq/nfarpq.rs) implements [`RpqEvaluator`] by: +[`NfaRpqEvaluator`](pathrex/src/rpq/nfarpq.rs) implements [`RpqEvaluator`] by: -1. Converting a [`PathExpr`] into an [`Nfa`](src/rpq/nfarpq.rs) via Thompson's - construction ([`Nfa::from_path_expr()`](src/rpq/nfarpq.rs)). -2. Eliminating ε-transitions via epsilon closure ([`NfaBuilder::epsilon_closure()`](src/rpq/nfarpq.rs)). +1. Building a finite-state machine from `PathExpr` with `rustfst` concatenation, + union, and closure operations in `build_fst`. +2. Removing ε-transitions with `rustfst::rm_epsilon` and extracting an + [`Nfa`](pathrex/src/rpq/nfarpq.rs) in `Nfa::from_path_expr`. 3. Building one `LAGraph_Graph` per NFA label transition - ([`Nfa::build_lagraph_matrices()`](src/rpq/nfarpq.rs)). + ([`Nfa::build_lagraph_matrices()`](pathrex/src/rpq/nfarpq.rs)). 4. Calling [`LAGraph_RegularPathQuery`] with the NFA matrices, data-graph matrices, start/final states, and source vertices. @@ -440,26 +346,79 @@ appear in extracted paths. Subject/object resolution: [`Endpoint::Variable`] means "all vertices"; [`Endpoint::Named`] resolves to a single vertex via -[`GraphDecomposition::get_node_id()`](src/graph/mod.rs:200). +[`GraphDecomposition::get_node_id()`](pathrex/src/graph/mod.rs). +The source constraint selects starting vertices; a fixed object filters the +reachable-target vector after the native call. -[`NfaRpqResult`](src/rpq/nfarpq.rs) wraps a [`GraphblasVector`] of reachable **target** +[`NfaRpqResult`](pathrex/src/rpq/nfarpq.rs) wraps a [`GraphblasVector`] of reachable **target** vertices. When the subject is a variable, every vertex is used as a source and `LAGraph_RegularPathQuery` returns the union of targets — individual `(source, target)` pairs are not preserved. -#### `RpqMatrixEvaluator` (`src/rpq/rpqmatrix.rs`) - -[`RpqMatrixEvaluator`](src/rpq/rpqmatrix.rs) compiles [`PathExpr`] into a Boolean matrix plan -over label adjacency matrices and runs [`LAGraph_RPQMatrix`]. It returns -[`RpqMatrixResult`](src/rpq/rpqmatrix.rs): the path-relation `nnz` plus a -[`GraphblasMatrix`] duplicate of the result matrix (full reachability relation for the path). -Subject/object do not filter the matrix; a named subject is only validated to exist. -Bound objects are not supported yet ([`RpqError::UnsupportedPath`]). - -### CLI dispatch (`src/cli/dispatch.rs`) - -With the `bench` feature enabled, [`src/cli/dispatch.rs`](src/cli/dispatch.rs) is the single -mapping from [`Algo`](src/cli/args.rs) variants to concrete evaluator types. `dispatch_query` +#### RpqMatrixEvaluator (`pathrex/src/rpq/rpqmatrix/`) + +The [RPQMatrix module](pathrex/src/rpq/rpqmatrix/mod.rs) is a directory, not the +former `rpqmatrix.rs` file. Its pipeline is: + +1. [`RpqMatrixEvaluator::prepare`](pathrex/src/rpq/rpqmatrix/eval.rs) selects a + storage orientation and translates the query with `query_to_expr`. +2. [`query_to_expr`](pathrex/src/rpq/rpqmatrix/expr.rs) builds a + `RecExpr`. A label is a Boolean adjacency matrix; sequence is + Boolean multiplication; alternative is union; star is reflexive transitive + closure. One-or-more is translated to a sequence with star. + Zero-or-one is currently rejected by RPQMatrix. +3. [`EGraphOptimizer`](pathrex/src/rpq/rpqmatrix/optimize.rs) implements the + internal `RpqOptimizer` interface. Except for `NoOpt`, it expands equivalent + expressions with `egg::Runner` and the rules in + [`plan.rs`](pathrex/src/rpq/rpqmatrix/plan.rs), then selects an expression + with `egg::Extractor` and a cost model from + [`cost.rs`](pathrex/src/rpq/rpqmatrix/cost.rs). + The cost model is used for extraction, not to execute the final query. +4. [`materialize_with_storage`](pathrex/src/rpq/rpqmatrix/expr.rs) turns the + selected expression into a flat `Vec`, borrowing graph matrices + and creating owned endpoint-selector matrices. +5. [`PreparedRpqMatrix::execute`](pathrex/src/rpq/rpqmatrix/result.rs) calls + `LAGraph_RPQMatrix`, duplicates the result matrix and releases temporary plan + results. It can be called repeatedly without rerunning optimization. + +**Fixed endpoints are supported and filter the result.** For a path relation +`R`, a named vertex `v` is represented by a diagonal selector `D_v` with a +single true entry at `(v, v)`: + +| Subject / object | Matrix expression | +|---|---| +| variable / variable | `R` | +| named `u` / variable | `D_u × R` | +| variable / named `v` | `R × D_v` | +| named `u` / named `v` | `D_u × R × D_v` | + +An unknown named vertex produces `RpqError::VertexNotFound`. +For a variable subject and fixed object, preparation requests **CSC** matrices; +other endpoint combinations request **CSR**. This changes storage, not query +semantics. The MatrixMarket loader stores both orientations. +Backends without a CSC copy may return their default matrix through +`GraphDecomposition::get_graph_with_storage`. + +[`RpqMatrixResult`](pathrex/src/rpq/rpqmatrix/result.rs) contains the filtered +relation matrix and its pair count `nnz`. Its `ResultCount` implementation +reports the number of distinct reachable targets (nonempty columns), not the +pair count. + +The implemented CLI strategies are `none`, `join`, `metaac`, `mnc`, +`hybrid`, `pang-hybrid`, and `sampling`. `RandomOpt`, `Simple`, and +`Wander` remain unsupported internal enum variants. Sampling evaluates +induced-subgraph matrices for estimates; the final selected plan executes on +the full graph. + +Fixed-object and fixed-subject/object behavior is covered by +[`pathrex/tests/rpqmatrix_tests.rs`](pathrex/tests/rpqmatrix_tests.rs); +[`eval.rs`](pathrex/src/rpq/rpqmatrix/eval.rs) also tests optimized fixed-endpoint +queries. + +### CLI dispatch (`pathrex/src/cli/dispatch.rs`) + +With the `bench` feature enabled, [`pathrex/src/cli/dispatch.rs`](pathrex/src/cli/dispatch.rs) is the single +mapping from [`Algo`](pathrex/src/cli/args.rs) variants to concrete evaluator types. `dispatch_query` and `dispatch_bench` each perform one exhaustive `match` per requested algorithm, then call generic runners (`run_query_for_evaluator` and `run_bench_for_evaluator`) that are monomorphized for the selected evaluator. @@ -470,80 +429,81 @@ arm, one `dispatch_bench` arm, an `impl Evaluator` for the evaluator type, and a ### FFI layer -[`lagraph_sys`](src/lagraph_sys.rs) exposes raw C bindings for GraphBLAS and -LAGraph. Safe Rust wrappers live in [`graph::mod`](src/graph/mod.rs): +[`lagraph_sys`](pathrex-sys/src/lib.rs) exposes raw C bindings for GraphBLAS and +LAGraph. The API is re-exported as `pathrex::lagraph_sys` by +[`pathrex/src/lib.rs`](pathrex/src/lib.rs). Rust RAII wrappers live in +[`graph::wrappers`](pathrex/src/graph/wrappers.rs): -- [`LagraphGraph`](src/graph/mod.rs:48) — RAII wrapper around `LAGraph_Graph` (calls +- [`LagraphGraph`](pathrex/src/graph/wrappers.rs) — RAII wrapper around `LAGraph_Graph` (calls `LAGraph_Delete` on drop). Also provides - [`LagraphGraph::from_coo()`](src/graph/mod.rs:85) to build directly from COO arrays. -- [`GraphblasVector`](src/graph/mod.rs:128) — RAII wrapper around `GrB_Vector` + [`LagraphGraph::from_coo()`](pathrex/src/graph/wrappers.rs) to build directly from COO arrays. +- [`GraphblasVector`](pathrex/src/graph/wrappers.rs) — RAII wrapper around `GrB_Vector` (derives `Debug`). -- [`GraphblasMatrix`](src/graph/mod.rs) — RAII wrapper around `GrB_Matrix` (`dup` + `free` on drop). -- [`ensure_grb_init()`](src/graph/wrappers.rs:11) — internal one-time `LAGraph_Init` via +- [`GraphblasMatrix`](pathrex/src/graph/wrappers.rs) — RAII wrapper around `GrB_Matrix` (`dup` + `free` on drop). +- [`ensure_grb_init()`](pathrex/src/graph/wrappers.rs) — internal one-time `LAGraph_Init` via `std::sync::Once`. Called automatically by RAII-wrapped constructors (`LagraphGraph::from_coo`, `LagraphGraph::from_matrix`, `ThreadScope::enter`) and by `load_mm_file`. Crate-private; no other code should call it. -### Macros & helpers (`src/utils.rs`) +### Macros & helpers (`pathrex/src/utils.rs`) Two `#[macro_export]` macros handle FFI error mapping: -- [`grb_ok!(expr)`](src/utils.rs:138) — evaluates a GraphBLAS call inside `unsafe`, maps the +- [`grb_ok!(expr)`](pathrex/src/utils.rs) — evaluates a GraphBLAS call inside `unsafe`, maps the `i32` return to `Result<(), GraphError::GraphBlas(info)>`. -- [`la_ok!(fn::path(args…))`](src/utils.rs:167) — evaluates a LAGraph call, automatically +- [`la_ok!(fn::path(args…))`](pathrex/src/utils.rs) — evaluates a LAGraph call, automatically appending the required `*mut i8` message buffer, and maps failure to `GraphError::LAGraph(info, msg)`. A convenience function is also provided: -- [`build_graph(edges)`](src/utils.rs:184) — builds an `InMemoryGraph` from a +- [`build_graph(edges)`](pathrex/src/utils.rs) — builds an `InMemoryGraph` from a slice of `(&str, &str, &str)` triples (source, target, label). Used by integration tests. ## Coding Conventions -- **Rust edition 2024**. +- **Rust edition 2024; MSRV 1.90**. - Error handling via `thiserror` derive macros; three main error enums: - [`GraphError`](src/graph/mod.rs:15), [`FormatError`](src/formats/mod.rs:24), - and [`RpqError`](src/rpq/mod.rs:78). + [`GraphError`](pathrex/src/graph/mod.rs), [`FormatError`](pathrex/src/formats/mod.rs), + and [`RpqError`](pathrex/src/rpq/mod.rs). - `FormatError` converts into `GraphError` via `#[from] FormatError` on the `GraphError::Format` variant. - `GraphError` converts into `RpqError` via `#[from] GraphError` on the `RpqError::Graph` variant, enabling `?` propagation in evaluators. -- Unsafe FFI calls are confined to `lagraph_sys`, `graph/mod.rs`, - `graph/inmemory.rs`, `rpq/nfarpq.rs`. All raw pointers are wrapped in - RAII types that free resources on drop. +- Raw FFI declarations live in `pathrex-sys`. Native calls also occur in the graph + wrappers, the in-memory loader, NFA evaluation, and RPQMatrix expression, + statistics, sampling, and execution modules. Resource ownership and cleanup + must be explicit; prefer the existing RAII wrappers. - `unsafe impl Send + Sync` is provided for `LagraphGraph`, `GraphblasVector`, and `GraphblasMatrix` because GraphBLAS handles are thread-safe after init. - Unit tests live in `#[cfg(test)] mod tests` blocks inside each module. - Integration tests that need GraphBLAS live in [`tests/inmemory_tests.rs`](tests/inmemory_tests.rs), - [`tests/mm_tests.rs`](tests/mm_tests.rs), [`tests/nfarpq_tests.rs`](tests/nfarpq_tests.rs). + Integration tests that need GraphBLAS live in [`pathrex/tests/inmemory_tests.rs`](pathrex/tests/inmemory_tests.rs), + [`pathrex/tests/mm_tests.rs`](pathrex/tests/mm_tests.rs), [`pathrex/tests/nfarpq_tests.rs`](pathrex/tests/nfarpq_tests.rs). ## Testing -```bash -# Run all tests (LAGraph installed system-wide) -LD_LIBRARY_PATH=/usr/local/lib cargo test --verbose +From the workspace root: -# If LAGraph is NOT installed system-wide (only built in the submodule): -LD_LIBRARY_PATH=deps/LAGraph/build/src:deps/LAGraph/build/experimental:/usr/local/lib cargo test --verbose +```bash +git lfs pull +cargo test --workspace --features bench +cargo clippy --workspace --all-targets --features bench -- -D warnings ``` -Tests in `src/graph/mod.rs` use `CountingBuilder` / `CountOutput` / `VecSource` from -[`src/utils.rs`](src/utils.rs) — these do **not** call into GraphBLAS and run without -native libraries. - -Tests in `src/formats/csv.rs` and `src/formats/rdf.rs` are pure Rust and need no native dependencies. +No `LD_LIBRARY_PATH` is needed for the statically built GraphBLAS and LAGraph. +Native libraries are built automatically by the sys crate even when running +tests with Cargo. Integration fixtures are under +[`pathrex/tests/testdata/`](pathrex/tests/testdata). -Tests in `src/sparql/mod.rs` are pure Rust and need no native dependencies. +Focused regression checks for fixed endpoints: -Tests in `src/rpq/nfarpq.rs` (NFA construction unit tests) are pure Rust and need no -native dependencies. +```bash +cargo test -p pathrex --features bench --test rpqmatrix_tests test_bound +cargo test -p pathrex --features bench optimizers_preserve_results +``` -Tests in `src/graph/inmemory.rs`, [`tests/inmemory_tests.rs`](tests/inmemory_tests.rs), -[`tests/mm_tests.rs`](tests/mm_tests.rs), [`tests/nfarpq_tests.rs`](tests/nfarpq_tests.rs), -and [`tests/rpqmatrix_tests.rs`](tests/rpqmatrix_tests.rs) call real GraphBLAS/LAGraph and -require the native libraries to be present. +Use `--locked` when reproducing a previously resolved build. ## CI @@ -556,7 +516,8 @@ runs on every push and PR across `stable`, `beta`, and `nightly` toolchains: `pathrex-sys/build.rs` clones GraphBLAS at the pinned tag, builds it statically, builds LAGraph statically against it, and regenerates FFI bindings. -4. `cargo test --workspace --verbose` — runs the full test suite. No +4. `cargo test --workspace --verbose` — runs tests without the optional CLI + feature; CLI tests require `--features bench`. No `LD_LIBRARY_PATH` is needed because GraphBLAS and LAGraph are linked statically; only the OpenMP runtime (`libgomp`) is dynamic and is already on the default loader path. diff --git a/Cargo.toml b/Cargo.toml index e2e6f30..fc4bd82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,10 @@ [workspace] -resolver = "2" +resolver = "3" members = ["pathrex-sys", "pathrex"] [workspace.package] edition = "2024" -rust-version = "1.85" +rust-version = "1.90" license = "MIT" repository = "https://github.com/SparseLinearAlgebra/pathrex" homepage = "https://github.com/SparseLinearAlgebra/pathrex" diff --git a/README.md b/README.md index 79e682c..f9e55a5 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,84 @@ ## Architecture -[Architecture diagram](docs/arc.png) +The workspace contains the Rust API and CLI in `pathrex/` and the native +build and FFI bindings in `pathrex-sys/`. See the +[architecture and contributor guide](AGENTS.md#architecture--key-abstractions) +for the current module layout and query preparation pipeline. + +## Local build (Linux) + +The minimum supported Rust version (MSRV) is **1.90**, not merely the version +that introduced edition 2024. The current dependency set includes +`ordered-float 5.5.0`, which requires Rust 1.90; the RDF/SPARQL dependencies +also require a newer compiler than 1.85. Both workspace crates inherit the +`rust-version` declared in the root `Cargo.toml`. +The workspace uses [Cargo resolver 3](https://doc.rust-lang.org/edition-guide/rust-2024/cargo-resolver.html), +which prefers dependency versions compatible with that declared Rust version. + +On Debian/Ubuntu, install the native build tools: + +```bash +sudo apt-get update +sudo apt-get install -y build-essential cmake git git-lfs +rustup toolchain install 1.90.0 --profile minimal +``` + +Run the following commands **from the Pathrex workspace root** (the directory +containing this README; `Databases/pathrex` when using RPQ_bench): + +```bash +git submodule update --init --recursive +git lfs pull + +# CLI and benchmark support; the bench feature is required for the binary. +cargo +1.90.0 build --release --features bench +./target/release/pathrex --help +./target/release/pathrex bench --help + +# Unit, integration, and documentation tests, including the CLI. +cargo +1.90.0 test --workspace --features bench +``` + +For library-only development, use `cargo +1.90.0 build --workspace` instead of +the CLI build above. + +Git LFS supplies the integration-test fixtures. An ordinary build uses the +checked-in FFI bindings and **does not require Clang/libclang**. The build script +fetches SuiteSparse:GraphBLAS at the pinned tag `v10.3.1`, builds it and the +LAGraph submodule as static libraries, and links them automatically. No +system-wide GraphBLAS/LAGraph installation or `LD_LIBRARY_PATH` is needed; +the GCC OpenMP runtime (`libgomp` on Linux) remains a dynamic dependency. +The first build requires network access for Cargo dependencies and GraphBLAS +and performs a substantial native compilation. Subsequent builds reuse the +files under `target/`. Reduce Cargo's job count with `-j 2` if memory is limited. + +When building from the **RPQ_bench root**, the equivalent CLI build is: + +```bash +cargo +1.90.0 build --release --manifest-path Databases/pathrex/Cargo.toml --features bench +Databases/pathrex/target/release/pathrex --help +``` + +`Cargo.lock` is currently ignored by this repository. Preserve the generated +lockfile with the experiment artifacts and use `--locked` for subsequent builds +and tests to keep dependency versions unchanged. The MSRV statement refers to +the current dependency set, not to every future version allowed by the manifests. + +### Optional binding regeneration + +Only regenerate bindings when the native API changes: + +```bash +sudo apt-get install -y clang libclang-dev +cargo +1.90.0 build --workspace --features pathrex-sys/regenerate-bindings +``` + +This command rewrites `pathrex-sys/src/lagraph_sys_generated.rs`. Do not edit that +file manually. If bindgen reports `stddef.h` missing, check that Clang and +libclang come from compatible installations and remove stale include-path +overrides such as `BINDGEN_EXTRA_CLANG_ARGS`; ordinary builds can use the +checked-in bindings without regenerating them. ## Features @@ -23,6 +100,8 @@ RDF (Turtle / N-Triples). - **SPARQL frontend**: parses `SELECT` queries with a single triple/property-path pattern. +- **RPQMatrix endpoint constraints**: variable endpoints, a fixed subject, a + fixed object, or both fixed endpoints; endpoint selectors are part of the plan. - **Benchmarking** with [`criterion`](https://crates.io/crates/criterion): per-query timing, JSON output, checkpoint/resume, optional HTML plots. - **Reusable Rust library** with backend-agnostic `Graph`, `GraphSource`, @@ -97,6 +176,8 @@ Subcommands: | `-q`, `--queries ` | Queries file (see format below). | | `-a`, `--algo ` | Algorithm(s). Repeat to run several. | | `-b`, `--base-iri []` | Optional `BASE ` to prepend to each query. Bare `--base-iri` uses `http://example.org/`. | +| `-p`, `--rpqmatrix-optimizer ` | RPQMatrix optimizer: `none`, `join`, `metaac`, `mnc`, `hybrid`, `pang-hybrid`, or `sampling`. | + `query` adds `-o, --output ` to write JSON. diff --git a/pathrex-sys/README.md b/pathrex-sys/README.md index 6db536e..c9c2ee6 100644 --- a/pathrex-sys/README.md +++ b/pathrex-sys/README.md @@ -26,6 +26,10 @@ directory. ## System requirements +The workspace MSRV is Rust 1.90. See the +[local build guide](../README.md#local-build-linux) for installation, CLI build, +and test commands. + | Dependency | Purpose | |---|---| | **cmake** | Building GraphBLAS and LAGraph from source | diff --git a/pathrex-sys/build.rs b/pathrex-sys/build.rs index 7b7ce96..c17768a 100644 --- a/pathrex-sys/build.rs +++ b/pathrex-sys/build.rs @@ -52,6 +52,7 @@ fn main() { let lagraph_src = manifest_dir.join(LAGRAPH_REL_PATH); assert_lagraph_submodule_present(&lagraph_src); + watch_lagraph_sources(&lagraph_src); let graphblas_src = fetch_graphblas(&out_dir); let graphblas_install = build_graphblas_static(&graphblas_src); @@ -77,6 +78,44 @@ fn assert_lagraph_submodule_present(lagraph_src: &Path) { } } +fn watch_lagraph_sources(lagraph_src: &Path) { + println!( + "cargo:rerun-if-changed={}", + lagraph_src.join("CMakeLists.txt").display() + ); + + for rel_dir in ["Config", "cmake_modules", "include", "src", "experimental"] { + let dir = lagraph_src.join(rel_dir); + if dir.exists() { + watch_files_with_extensions(&dir, &["c", "h", "cmake", "in", "txt"]); + } + } +} + +fn watch_files_with_extensions(dir: &Path, extensions: &[&str]) { + let entries = std::fs::read_dir(dir) + .unwrap_or_else(|e| panic!("failed to read directory {}: {e}", dir.display())); + + for entry in entries { + let path = entry + .unwrap_or_else(|e| panic!("failed to read entry in {}: {e}", dir.display())) + .path(); + + if path.is_dir() { + watch_files_with_extensions(&path, extensions); + continue; + } + + let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else { + continue; + }; + + if extensions.contains(&ext) { + println!("cargo:rerun-if-changed={}", path.display()); + } + } +} + /// Clone SuiteSparse:GraphBLAS at [`GRAPHBLAS_TAG`] into /// `$OUT_DIR/graphblas-src/`. Returns the path to the source tree. /// @@ -292,6 +331,7 @@ fn regenerate_bindings(graphblas_install: &Path) { .allowlist_item("GrB_Info") .allowlist_function("GrB_Matrix_new") .allowlist_function("GrB_Matrix_nvals") + .allowlist_function("GrB_Matrix_nrows") .allowlist_function("GrB_Matrix_dup") .allowlist_function("GrB_Matrix_free") .allowlist_function("GrB_Matrix_extractElement_BOOL") @@ -304,6 +344,7 @@ fn regenerate_bindings(graphblas_install: &Path) { .allowlist_function("GrB_vxm") .allowlist_item("LAGRAPH_MSG_LEN") .allowlist_item("RPQMatrixOp") + .allowlist_item("RPQMatrixStorage") .allowlist_type("RPQMatrixPlan") .allowlist_type("LAGraph_Graph") .allowlist_type("LAGraph_Kind") @@ -317,7 +358,14 @@ fn regenerate_bindings(graphblas_install: &Path) { .allowlist_function("LAGraph_Cached_AT") .allowlist_function("LAGraph_MMRead") .allowlist_function("LAGraph_RPQMatrix") + .allowlist_function("LAGraph_RPQMatrix_SetGlobalStorageOrientation") + .allowlist_function("LAGraph_RPQMatrix_SetStorageOrientation") + .allowlist_function("LAGraph_RPQMatrix_DupWithStorageOrientation") .allowlist_function("LAGraph_RPQMatrix_reduce") + .allowlist_function("LAGraph_RPQMatrix_sample_.*") + .allowlist_function("LAGraph_RPQMatrix_reduce_count_vector") + .allowlist_function("LAGraph_RPQMatrix_extended_count_vectors") + .allowlist_function("LAGraph_RPQMatrix_count_vector_.*") .allowlist_function("LAGraph_DestroyRpqMatrixPlan") .allowlist_function("LAGraph_RPQMatrix_label") .allowlist_function("LAGraph_RPQMatrix_Free") diff --git a/pathrex-sys/deps/LAGraph b/pathrex-sys/deps/LAGraph index bc00497..ee97ead 160000 --- a/pathrex-sys/deps/LAGraph +++ b/pathrex-sys/deps/LAGraph @@ -1 +1 @@ -Subproject commit bc004979161db08389f52e2eff1e84e5cac42a64 +Subproject commit ee97ead31f9c1bd43cfce6e5cf1c624eded2628c diff --git a/pathrex-sys/src/lagraph_sys_generated.rs b/pathrex-sys/src/lagraph_sys_generated.rs index 1a9188f..49da24c 100644 --- a/pathrex-sys/src/lagraph_sys_generated.rs +++ b/pathrex-sys/src/lagraph_sys_generated.rs @@ -158,6 +158,9 @@ unsafe extern "C" { unsafe extern "C" { pub fn GrB_Matrix_dup(C: *mut GrB_Matrix, A: GrB_Matrix) -> GrB_Info; } +unsafe extern "C" { + pub fn GrB_Matrix_nrows(nrows: *mut GrB_Index, A: GrB_Matrix) -> GrB_Info; +} unsafe extern "C" { pub fn GrB_Matrix_nvals(nvals: *mut GrB_Index, A: GrB_Matrix) -> GrB_Info; } @@ -320,6 +323,28 @@ pub struct RPQMatrixPlan { pub mat: GrB_Matrix, pub res_mat: GrB_Matrix, } +#[repr(u32)] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] +pub enum RPQMatrixStorage { + RPQ_MATRIX_STORAGE_CSC = 1, + RPQ_MATRIX_STORAGE_CSR = 2, +} +unsafe extern "C" { + pub fn LAGraph_RPQMatrix_SetGlobalStorageOrientation(storage: RPQMatrixStorage) -> GrB_Info; +} +unsafe extern "C" { + pub fn LAGraph_RPQMatrix_SetStorageOrientation( + mat: GrB_Matrix, + storage: RPQMatrixStorage, + ) -> GrB_Info; +} +unsafe extern "C" { + pub fn LAGraph_RPQMatrix_DupWithStorageOrientation( + dst: *mut GrB_Matrix, + src: GrB_Matrix, + storage: RPQMatrixStorage, + ) -> GrB_Info; +} unsafe extern "C" { pub fn LAGraph_RPQMatrix( nnz: *mut GrB_Index, @@ -348,3 +373,91 @@ unsafe extern "C" { reduce_type: u8, ) -> GrB_Info; } +unsafe extern "C" { + pub fn LAGraph_RPQMatrix_sample_submatrix( + result: *mut GrB_Matrix, + source: GrB_Matrix, + vertices: *const GrB_Index, + count: GrB_Index, + ) -> GrB_Info; +} +unsafe extern "C" { + pub fn LAGraph_RPQMatrix_sample_identity(result: *mut GrB_Matrix, n: GrB_Index) -> GrB_Info; +} +unsafe extern "C" { + pub fn LAGraph_RPQMatrix_sample_apply( + result: *mut GrB_Matrix, + lhs: GrB_Matrix, + rhs: GrB_Matrix, + ) -> GrB_Info; +} +unsafe extern "C" { + pub fn LAGraph_RPQMatrix_sample_union( + result: *mut GrB_Matrix, + lhs: GrB_Matrix, + rhs: GrB_Matrix, + ) -> GrB_Info; +} +unsafe extern "C" { + pub fn LAGraph_RPQMatrix_sample_stats( + nvals: *mut GrB_Index, + active_rows: *mut GrB_Index, + active_cols: *mut GrB_Index, + diagonal_nvals: *mut GrB_Index, + sample: GrB_Matrix, + ) -> GrB_Info; +} +unsafe extern "C" { + pub fn LAGraph_RPQMatrix_reduce_count_vector( + res: *mut GrB_Vector, + mat: GrB_Matrix, + reduce_type: u8, + ) -> GrB_Info; +} +unsafe extern "C" { + pub fn LAGraph_RPQMatrix_extended_count_vectors( + row_extended: *mut GrB_Vector, + col_extended: *mut GrB_Vector, + mat: GrB_Matrix, + row_counts: GrB_Vector, + col_counts: GrB_Vector, + ) -> GrB_Info; +} +unsafe extern "C" { + pub fn LAGraph_RPQMatrix_count_vector_dot( + res: *mut f64, + lhs: GrB_Vector, + rhs: GrB_Vector, + ) -> GrB_Info; +} +unsafe extern "C" { + pub fn LAGraph_RPQMatrix_count_vector_mnc_matmul_nnz( + res: *mut f64, + lhs_rows: GrB_Vector, + lhs_cols: GrB_Vector, + rhs_rows: GrB_Vector, + rhs_cols: GrB_Vector, + lhs_col_extended: GrB_Vector, + rhs_row_extended: GrB_Vector, + ) -> GrB_Info; +} +unsafe extern "C" { + pub fn LAGraph_RPQMatrix_count_vector_sum(res: *mut f64, vector: GrB_Vector) -> GrB_Info; +} +unsafe extern "C" { + pub fn LAGraph_RPQMatrix_count_vector_scale( + res: *mut GrB_Vector, + vector: GrB_Vector, + scale: f64, + cap: f64, + ) -> GrB_Info; +} +unsafe extern "C" { + pub fn LAGraph_RPQMatrix_count_vector_mnc_add( + res: *mut GrB_Vector, + lhs: GrB_Vector, + rhs: GrB_Vector, + lambda: f64, + cap: f64, + ) -> GrB_Info; +} diff --git a/pathrex/Cargo.toml b/pathrex/Cargo.toml index 6401f1e..500cda7 100644 --- a/pathrex/Cargo.toml +++ b/pathrex/Cargo.toml @@ -28,6 +28,7 @@ memmap2 = "0.9" oxrdf = "0.3.3" oxttl = "0.2.3" rayon = "1" +rand = "0.9.5" rustfst = "1.2" spargebra = "0.4.6" thiserror = "1.0" @@ -45,6 +46,7 @@ bench = ["clap", "serde", "serde_json", "chrono", "criterion", "tempfile"] [dev-dependencies] tempfile = "3" +expect-test = "1.5.1" [[bin]] name = "pathrex" diff --git a/pathrex/src/bin/pathrex.rs b/pathrex/src/bin/pathrex.rs index 69b508a..0dac3a3 100644 --- a/pathrex/src/bin/pathrex.rs +++ b/pathrex/src/bin/pathrex.rs @@ -16,7 +16,9 @@ //! cargo run --release --bin pathrex --features bench -- bench \ //! --graph tests/testdata/mm_graph \ //! --queries tests/testdata/cases/any-any/queries.txt \ -//! --algo nfa rpqmatrix \ +//! --algo nfarpq rpqmatrix \ +//! --bench-mode criterion \ +//! --rpqmatrix-optimizer join \ //! --output results.json //! ``` @@ -27,11 +29,14 @@ use chrono::Utc; use clap::Parser; use thiserror::Error; -use pathrex::cli::args::{BenchArgs, Cli, Commands, QueryArgs}; +use pathrex::cli::args::{ + Algo, BenchArgs, BenchMode, Cli, Commands, CommonArgs, GraphFormat, QueryArgs, + RpqMatrixOptimizer, +}; use pathrex::cli::bench::BenchError; -use pathrex::cli::checkpoint::{Checkpoint, CheckpointError, Checkpointer}; +use pathrex::cli::checkpoint::{BenchRunConfig, Checkpoint, CheckpointError, Checkpointer}; use pathrex::cli::dispatch::{dispatch_bench, dispatch_query}; -use pathrex::cli::loader::{GraphLoadError, LoadedQuery, load_graph, load_queries}; +use pathrex::cli::loader::{GraphLoadError, LoadedQuery, load_graph_with_optimizer, load_queries}; use pathrex::cli::output::{BenchMetadata, BenchOutput, QueryMetadata, QueryOutput}; use pathrex::graph::{GraphDecomposition, InMemoryGraph}; @@ -55,6 +60,8 @@ enum MainError { #[source] source: std::io::Error, }, + #[error("invalid arguments: {0}")] + InvalidArgs(String), } fn main() { @@ -78,6 +85,75 @@ fn run() -> Result<(), MainError> { } } +fn validate_common_args(common: &CommonArgs) -> Result<(), MainError> { + if common.rpqmatrix_optimizer == RpqMatrixOptimizer::None { + return Ok(()); + } + + if !common.algo.contains(&Algo::Rpqmatrix) { + return Err(MainError::InvalidArgs( + "--rpqmatrix-optimizer can only be used when --algo includes rpqmatrix".to_string(), + )); + } + + if common.format != GraphFormat::Mm { + return Err(MainError::InvalidArgs( + "--rpqmatrix-optimizer can only be used with --format mm".to_string(), + )); + } + + Ok(()) +} + +fn validate_bench_args(args: &BenchArgs) -> Result<(), MainError> { + validate_common_args(&args.common)?; + + if args.resume && args.checkpoint.is_none() { + return Err(MainError::InvalidArgs( + "--resume requires --checkpoint".to_string(), + )); + } + + match args.bench_mode { + BenchMode::Fixed => { + if args.criterion_dir.is_some() + || args.plots + || args.sample_size.is_some() + || args.warm_up.is_some() + || args.measurement.is_some() + { + return Err(MainError::InvalidArgs( + "criterion options can only be used with --bench-mode criterion".to_string(), + )); + } + if args.fixed_runs() == 0 { + return Err(MainError::InvalidArgs( + "--runs must be greater than 0".to_string(), + )); + } + } + BenchMode::Criterion => { + if args.runs.is_some() || args.warm_up_runs.is_some() { + return Err(MainError::InvalidArgs( + "fixed-run options can only be used with --bench-mode fixed".to_string(), + )); + } + if args.plots && args.criterion_dir.is_none() { + return Err(MainError::InvalidArgs( + "--plots requires --criterion-dir".to_string(), + )); + } + if args.criterion_sample_size() < 10 { + return Err(MainError::InvalidArgs( + "--sample-size must be at least 10 for criterion".to_string(), + )); + } + } + } + + Ok(()) +} + fn load_query_file(path: &str, base_iri: Option<&str>) -> Result, MainError> { load_queries(Path::new(path), base_iri).map_err(|e| MainError::Queries { path: path.to_string(), @@ -87,17 +163,23 @@ fn load_query_file(path: &str, base_iri: Option<&str>) -> Result Result<(), MainError> { let common = &args.common; + validate_common_args(common)?; eprintln!("=== pathrex query ==="); eprintln!("Graph: {}", common.graph); eprintln!("Format: {}", common.format); eprintln!("Queries: {}", common.queries); eprintln!("Algos: {:?}", common.algo); + eprintln!("RPQMatrix optimizer: {}", common.rpqmatrix_optimizer); eprintln!(); eprintln!("[1/2] Loading graph..."); - let graph: InMemoryGraph = - load_graph(&common.graph, common.format, common.base_iri.as_deref())?; + let graph: InMemoryGraph = load_graph_with_optimizer( + &common.graph, + common.format, + common.base_iri.as_deref(), + common.rpqmatrix_optimizer, + )?; eprintln!(" nodes: {}", graph.num_nodes()); eprintln!(" labels: {}", graph.num_labels()); eprintln!(); @@ -127,6 +209,7 @@ fn run_query_cmd(args: QueryArgs) -> Result<(), MainError> { graph_path: common.graph.clone(), graph_format: common.format.to_string(), queries_file: common.queries.clone(), + rpqmatrix_optimizer: Some(common.rpqmatrix_optimizer.to_string()), base_iri: common.base_iri.clone(), num_nodes: graph.num_nodes(), num_labels: graph.num_labels(), @@ -147,54 +230,83 @@ fn run_query_cmd(args: QueryArgs) -> Result<(), MainError> { fn build_checkpointer(args: &BenchArgs, queries_len: usize) -> Result { let common = &args.common; - let path = PathBuf::from(&args.checkpoint); - - if args.resume { - match Checkpoint::load(&path)? { - Some(cp) => { - cp.validate(&common.graph, &common.queries, &common.algo)?; - let cper = Checkpointer::with_inner(cp, path); - eprintln!( - " resuming: {}/{} queries fully done", - cper.fully_done_count(&common.algo), - queries_len - ); - Ok(cper) - } - None => { - eprintln!(" no checkpoint file found, starting fresh"); - Ok(Checkpointer::fresh( - &common.graph, - &common.queries, - &common.algo, - path, - )) + let bench_config = BenchRunConfig::from_args(args); + + if let Some(checkpoint) = &args.checkpoint { + let path = PathBuf::from(checkpoint); + if args.resume { + match Checkpoint::load(&path)? { + Some(cp) => { + cp.validate( + &common.graph, + &common.queries, + &common.algo, + common.rpqmatrix_optimizer, + &bench_config, + )?; + let cper = Checkpointer::with_inner(cp, path); + eprintln!( + " resuming: {}/{} queries fully done", + cper.fully_done_count(&common.algo), + queries_len + ); + Ok(cper) + } + None => { + eprintln!(" no checkpoint file found, starting fresh"); + Ok(Checkpointer::fresh( + &common.graph, + &common.queries, + &common.algo, + common.rpqmatrix_optimizer, + bench_config, + Some(path), + )) + } } + } else { + Ok(Checkpointer::fresh( + &common.graph, + &common.queries, + &common.algo, + common.rpqmatrix_optimizer, + bench_config, + Some(path), + )) } } else { Ok(Checkpointer::fresh( &common.graph, &common.queries, &common.algo, - path, + common.rpqmatrix_optimizer, + bench_config, + None, )) } } fn run_bench_cmd(args: BenchArgs) -> Result<(), MainError> { let common = &args.common; + validate_bench_args(&args)?; eprintln!("=== pathrex bench ==="); eprintln!("Graph: {}", common.graph); eprintln!("Format: {}", common.format); eprintln!("Queries: {}", common.queries); eprintln!("Algos: {:?}", common.algo); + eprintln!("Bench mode: {}", args.bench_mode); + eprintln!("RPQMatrix optimizer: {}", common.rpqmatrix_optimizer); eprintln!("Output: {}", args.output); eprintln!(); eprintln!("[1/4] Loading graph..."); - let graph: InMemoryGraph = - load_graph(&common.graph, common.format, common.base_iri.as_deref())?; + let graph: InMemoryGraph = load_graph_with_optimizer( + &common.graph, + common.format, + common.base_iri.as_deref(), + common.rpqmatrix_optimizer, + )?; eprintln!(" nodes: {}", graph.num_nodes()); eprintln!(" labels: {}", graph.num_labels()); eprintln!(); @@ -223,11 +335,18 @@ fn run_bench_cmd(args: BenchArgs) -> Result<(), MainError> { graph_format: common.format.to_string(), queries_file: common.queries.clone(), base_iri: common.base_iri.clone(), + rpqmatrix_optimizer: Some(common.rpqmatrix_optimizer.to_string()), num_nodes: graph.num_nodes(), num_labels: graph.num_labels(), - sample_size: args.sample_size, - warm_up_secs: args.warm_up, - measurement_secs: args.measurement, + bench_mode: args.bench_mode.to_string(), + runs: (args.bench_mode == BenchMode::Fixed).then(|| args.fixed_runs()), + warm_up_runs: (args.bench_mode == BenchMode::Fixed).then(|| args.fixed_warm_up_runs()), + sample_size: (args.bench_mode == BenchMode::Criterion) + .then(|| args.criterion_sample_size()), + warm_up_secs: (args.bench_mode == BenchMode::Criterion) + .then(|| args.criterion_warm_up_secs()), + measurement_secs: (args.bench_mode == BenchMode::Criterion) + .then(|| args.criterion_measurement_secs()), }, results, }; @@ -238,10 +357,19 @@ fn run_bench_cmd(args: BenchArgs) -> Result<(), MainError> { path: args.output.clone(), source: e, })?; + let samples_path = output + .write_samples_to_file(Path::new(&args.output)) + .map_err(|e| MainError::Output { + path: args.output.clone(), + source: e, + })?; eprintln!(); eprintln!("=== Done ==="); eprintln!("Results written to: {}", args.output); + if let Some(path) = samples_path { + eprintln!("Run samples written to: {}", path.display()); + } if let Some(dir) = &args.criterion_dir { eprintln!("Criterion data in: {dir}") } diff --git a/pathrex/src/cli/args.rs b/pathrex/src/cli/args.rs index d95f478..cd99536 100644 --- a/pathrex/src/cli/args.rs +++ b/pathrex/src/cli/args.rs @@ -11,6 +11,8 @@ use clap::{Args, Parser, Subcommand, ValueEnum}; +use crate::rpq::rpqmatrix::OptimizationStrategy; + /// Top-level CLI for pathrex. #[derive(Parser, Debug)] #[command( @@ -27,7 +29,7 @@ pub struct Cli { pub enum Commands { /// Run queries once and report result counts Query(QueryArgs), - /// Benchmark RPQ evaluators with criterion + /// Benchmark RPQ evaluators Bench(BenchArgs), } @@ -62,6 +64,15 @@ pub struct CommonArgs { /// Algorithms to use. #[arg(short = 'a', long, value_enum, num_args = 1.., required = true)] pub algo: Vec, + + /// Optimizer type (only for the RPQMatrix algorithm). + #[arg( + short = 'p', + long = "rpqmatrix-optimizer", + value_enum, + default_value_t = RpqMatrixOptimizer::None + )] + pub rpqmatrix_optimizer: RpqMatrixOptimizer, } /// Arguments for the `query` subcommand. @@ -85,14 +96,18 @@ pub struct BenchArgs { #[arg(short = 'o', long, default_value = "bench_results.json")] pub output: String, - /// Checkpoint file path. - #[arg(short = 'c', long, default_value = "bench_checkpoint.json")] - pub checkpoint: String, + /// Optional checkpoint file path. + #[arg(short = 'c', long)] + pub checkpoint: Option, /// Resume from checkpoint, skipping completed queries. #[arg(long)] pub resume: bool, + /// Benchmarking mode. + #[arg(long, value_enum, default_value_t = BenchMode::Fixed)] + pub bench_mode: BenchMode, + /// Directory for criterion output. When omitted, criterion writes into a /// per-group temporary directory that is wiped immediately after each /// benchmark group is parsed (default behavior). @@ -106,16 +121,77 @@ pub struct BenchArgs { pub plots: bool, /// Criterion sample size per benchmark group. - #[arg(long, default_value_t = 10)] - pub sample_size: usize, + #[arg(long)] + pub sample_size: Option, /// Criterion warm-up time in seconds. - #[arg(long, default_value_t = 1)] - pub warm_up: u64, + #[arg(long)] + pub warm_up: Option, /// Criterion measurement time in seconds. - #[arg(long, default_value_t = 5)] - pub measurement: u64, + #[arg(long)] + pub measurement: Option, + + /// Number of warm-up runs. + #[arg(long = "warm-up-runs")] + pub warm_up_runs: Option, + + /// Number of measured runs. + #[arg(long)] + pub runs: Option, +} + +impl BenchArgs { + pub const DEFAULT_FIXED_RUNS: u64 = 10; + pub const DEFAULT_FIXED_WARM_UP_RUNS: u64 = 0; + pub const DEFAULT_CRITERION_SAMPLE_SIZE: usize = 10; + pub const DEFAULT_CRITERION_WARM_UP_SECS: u64 = 1; + pub const DEFAULT_CRITERION_MEASUREMENT_SECS: u64 = 5; + + pub fn fixed_runs(&self) -> u64 { + self.runs.unwrap_or(Self::DEFAULT_FIXED_RUNS) + } + + pub fn fixed_warm_up_runs(&self) -> u64 { + self.warm_up_runs + .unwrap_or(Self::DEFAULT_FIXED_WARM_UP_RUNS) + } + + pub fn criterion_sample_size(&self) -> usize { + self.sample_size + .unwrap_or(Self::DEFAULT_CRITERION_SAMPLE_SIZE) + } + + pub fn criterion_warm_up_secs(&self) -> u64 { + self.warm_up.unwrap_or(Self::DEFAULT_CRITERION_WARM_UP_SECS) + } + + pub fn criterion_measurement_secs(&self) -> u64 { + self.measurement + .unwrap_or(Self::DEFAULT_CRITERION_MEASUREMENT_SECS) + } +} + +#[derive( + Debug, Default, Clone, Copy, PartialEq, Eq, ValueEnum, serde::Serialize, serde::Deserialize, +)] +#[serde(rename_all = "lowercase")] +#[value(rename_all = "lowercase")] +pub enum BenchMode { + /// Fixed number of runs per query. + #[default] + Fixed, + /// Criterion time-based benchmark. + Criterion, +} + +impl std::fmt::Display for BenchMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BenchMode::Fixed => write!(f, "fixed"), + BenchMode::Criterion => write!(f, "criterion"), + } + } } #[derive(Debug, Clone, PartialEq, Eq, Hash, ValueEnum, serde::Serialize, serde::Deserialize)] @@ -156,6 +232,63 @@ impl std::fmt::Display for GraphFormat { } } +/// Optimizer types. +/// Only for the RPQMatrix algorithm. +#[derive( + Debug, Default, Clone, Copy, PartialEq, Eq, ValueEnum, serde::Serialize, serde::Deserialize, +)] +#[value(rename_all = "lowercase")] +#[serde(rename_all = "lowercase")] +pub enum RpqMatrixOptimizer { + /// Optimizer based on join of source matrices. + Join, + /// MetaAC independence estimator. + #[value(name = "metaac")] + MetaAc, + /// Matrix nonzero count estimator. + Mnc, + /// Join work model with MetaAC result estimates. + Hybrid, + /// Size-based Join estimates with Pang-inspired closures. + #[value(name = "pang-hybrid")] + #[serde(rename = "pang-hybrid")] + PangHybrid, + /// GraphBLAS induced-subgraph sampling estimator for composed expressions. + #[value(name = "sampling")] + Sampling, + /// Without any optimizations. + #[default] + None, +} + +impl std::fmt::Display for RpqMatrixOptimizer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RpqMatrixOptimizer::Join => write!(f, "join"), + RpqMatrixOptimizer::MetaAc => write!(f, "metaac"), + RpqMatrixOptimizer::Mnc => write!(f, "mnc"), + RpqMatrixOptimizer::Hybrid => write!(f, "hybrid"), + RpqMatrixOptimizer::PangHybrid => write!(f, "pang-hybrid"), + RpqMatrixOptimizer::Sampling => write!(f, "sampling"), + RpqMatrixOptimizer::None => write!(f, "none"), + } + } +} + +impl From for OptimizationStrategy { + fn from(value: RpqMatrixOptimizer) -> Self { + match value { + RpqMatrixOptimizer::None => OptimizationStrategy::NoOpt, + RpqMatrixOptimizer::Join => OptimizationStrategy::Join, + RpqMatrixOptimizer::MetaAc => OptimizationStrategy::MetaAc, + RpqMatrixOptimizer::Mnc => OptimizationStrategy::Mnc, + RpqMatrixOptimizer::Hybrid => OptimizationStrategy::Hybrid, + RpqMatrixOptimizer::PangHybrid => OptimizationStrategy::PangHybrid, + RpqMatrixOptimizer::Sampling => OptimizationStrategy::Sampling, + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -174,4 +307,113 @@ mod tests { assert!(result.is_err()); } + + #[test] + fn bench_defaults_to_fixed_runs_without_checkpoint_or_criterion() { + let cli = Cli::parse_from([ + "pathrex", + "bench", + "--graph", + "graph", + "--queries", + "queries", + "--algo", + "rpqmatrix", + ]); + + let Commands::Bench(args) = cli.command else { + panic!("expected bench command"); + }; + + assert_eq!(args.bench_mode, BenchMode::Fixed); + assert_eq!(args.fixed_runs(), BenchArgs::DEFAULT_FIXED_RUNS); + assert_eq!( + args.fixed_warm_up_runs(), + BenchArgs::DEFAULT_FIXED_WARM_UP_RUNS + ); + assert!(args.checkpoint.is_none()); + assert!(args.criterion_dir.is_none()); + assert!(args.sample_size.is_none()); + assert!(args.warm_up.is_none()); + assert!(args.measurement.is_none()); + } + + #[test] + fn criterion_mode_accepts_optional_criterion_settings() { + let cli = Cli::parse_from([ + "pathrex", + "bench", + "--graph", + "graph", + "--queries", + "queries", + "--algo", + "rpqmatrix", + "--bench-mode", + "criterion", + "--sample-size", + "20", + "--warm-up", + "2", + "--measurement", + "7", + ]); + + let Commands::Bench(args) = cli.command else { + panic!("expected bench command"); + }; + + assert_eq!(args.bench_mode, BenchMode::Criterion); + assert_eq!(args.criterion_sample_size(), 20); + assert_eq!(args.criterion_warm_up_secs(), 2); + assert_eq!(args.criterion_measurement_secs(), 7); + } + + #[test] + fn new_optimizer_names_are_the_only_supported_names() { + for (name, expected) in [ + ("pang-hybrid", RpqMatrixOptimizer::PangHybrid), + ("sampling", RpqMatrixOptimizer::Sampling), + ] { + let cli = Cli::parse_from([ + "pathrex", + "bench", + "--graph", + "graph", + "--queries", + "queries", + "--algo", + "rpqmatrix", + "--rpqmatrix-optimizer", + name, + ]); + let Commands::Bench(args) = cli.command else { + panic!("expected bench command") + }; + assert_eq!(args.common.rpqmatrix_optimizer, expected); + } + for name in [ + "rpq-static", + "row-sampling", + "hybrid-static-star", + "hybrid-sampled-star", + "sampling-hybrid", + ] { + assert!( + Cli::try_parse_from([ + "pathrex", + "bench", + "--graph", + "graph", + "--queries", + "queries", + "--algo", + "rpqmatrix", + "--rpqmatrix-optimizer", + name, + ]) + .is_err() + ); + } + } } diff --git a/pathrex/src/cli/bench/estimates.rs b/pathrex/src/cli/bench/estimates.rs index 1cf72cb..c6459b7 100644 --- a/pathrex/src/cli/bench/estimates.rs +++ b/pathrex/src/cli/bench/estimates.rs @@ -87,7 +87,11 @@ pub fn read_timing_stats( pub fn read_algo_timing(criterion_dir: &Path, group: &str) -> Result { let total = read_timing_stats(criterion_dir, group, "eval_total")?; let ffi_only = read_timing_stats(criterion_dir, group, "eval_ffi_only")?; - Ok(AlgoTiming { total, ffi_only }) + Ok(AlgoTiming { + total, + ffi_only, + samples: None, + }) } #[cfg(test)] diff --git a/pathrex/src/cli/bench/runner.rs b/pathrex/src/cli/bench/runner.rs index 4b5a54a..817423e 100644 --- a/pathrex/src/cli/bench/runner.rs +++ b/pathrex/src/cli/bench/runner.rs @@ -1,15 +1,15 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::time::Duration; +use std::time::{Duration, Instant}; -use criterion::{Criterion, black_box}; +use criterion::{BatchSize, Criterion, black_box}; -use crate::cli::args::{Algo, BenchArgs}; +use crate::cli::args::{Algo, BenchArgs, BenchMode}; use crate::cli::bench::error::BenchError; use crate::cli::bench::estimates::read_algo_timing; use crate::cli::checkpoint::Checkpointer; use crate::cli::loader::LoadedQuery; -use crate::cli::output::{AlgoResult, QueryResult}; +use crate::cli::output::{AlgoResult, AlgoTiming, AlgoTimingSamples, QueryResult, TimingStats}; use crate::eval::{Evaluator, PreparedEvaluator, ResultCount}; use crate::graph::InMemoryGraph; use crate::rpq::{RpqError, RpqQuery}; @@ -41,9 +41,9 @@ impl GroupOutput { pub(crate) fn build_criterion(args: &BenchArgs, output_dir: &Path) -> Criterion { let c = Criterion::default() - .sample_size(args.sample_size) - .warm_up_time(Duration::from_secs(args.warm_up)) - .measurement_time(Duration::from_secs(args.measurement)) + .sample_size(args.criterion_sample_size()) + .warm_up_time(Duration::from_secs(args.criterion_warm_up_secs())) + .measurement_time(Duration::from_secs(args.criterion_measurement_secs())) .output_directory(output_dir); if args.plots { c.with_plots() @@ -65,10 +65,13 @@ fn run_benchmark_group( query_index: usize, ) -> Result, RpqError> where - E: Evaluator + Copy, + E: Evaluator, E::Result: ResultCount, { - let mut prepared = evaluator.prepare(query, graph)?; + // Validate preparation once so query/graph errors are reported through the + // normal benchmark error path. The `eval_ffi_only` benchmark below creates a + // fresh prepared state per measured iteration. + let _prepared = evaluator.prepare(query, graph)?; let group = group_name(query_index, algo_name); let output = match GroupOutput::for_group(args) { @@ -89,9 +92,17 @@ where }); g.bench_function("eval_ffi_only", |b| { - b.iter(|| { - let _ = black_box(prepared.execute()); - }); + b.iter_batched( + || { + evaluator + .prepare(query, graph) + .expect("prepare should keep succeeding during benchmark") + }, + |mut prepared| { + let _ = black_box(prepared.execute()); + }, + BatchSize::PerIteration, + ); }); g.finish(); @@ -100,6 +111,88 @@ where Ok(read_algo_timing(&output_path, &group)) } +fn timing_stats(samples_ns: &[f64]) -> TimingStats { + let mut sorted = samples_ns.to_vec(); + sorted.sort_by(f64::total_cmp); + + let len = sorted.len(); + let mean = sorted.iter().sum::() / len as f64; + let median = if len.is_multiple_of(2) { + (sorted[len / 2 - 1] + sorted[len / 2]) / 2.0 + } else { + sorted[len / 2] + }; + let variance = sorted + .iter() + .map(|sample| { + let diff = sample - mean; + diff * diff + }) + .sum::() + / len as f64; + + TimingStats { + mean_ns: mean, + median_ns: median, + stddev_ns: variance.sqrt(), + iterations: len, + } +} + +fn elapsed_ns(start: Instant) -> f64 { + start.elapsed().as_nanos() as f64 +} + +fn run_fixed_group( + args: &BenchArgs, + evaluator: E, + query: &RpqQuery, + graph: &InMemoryGraph, +) -> Result<(usize, AlgoTiming), RpqError> +where + E: Evaluator, + E::Result: ResultCount, +{ + for _ in 0..args.fixed_warm_up_runs() { + let _ = black_box(evaluator.evaluate(query, graph)?); + } + + let mut total_samples = Vec::with_capacity(args.fixed_runs() as usize); + let mut result_count = None; + for _ in 0..args.fixed_runs() { + let start = Instant::now(); + let result = black_box(evaluator.evaluate(query, graph)?); + total_samples.push(elapsed_ns(start)); + result_count = Some(result.result_count().map_err(RpqError::Graph)?); + } + + for _ in 0..args.fixed_warm_up_runs() { + let mut prepared = evaluator.prepare(query, graph)?; + let _ = black_box(prepared.execute()?); + } + + let mut ffi_samples = Vec::with_capacity(args.fixed_runs() as usize); + for _ in 0..args.fixed_runs() { + let mut prepared = evaluator.prepare(query, graph)?; + let start = Instant::now(); + let result = black_box(prepared.execute()?); + ffi_samples.push(elapsed_ns(start)); + drop(result); + } + + Ok(( + result_count.unwrap_or(0), + AlgoTiming { + total: timing_stats(&total_samples), + ffi_only: timing_stats(&ffi_samples), + samples: Some(AlgoTimingSamples { + total_ns: total_samples, + ffi_only_ns: ffi_samples, + }), + }, + )) +} + /// Run the bench loop for every query in `queries` for one evaluator. pub fn run_bench_for_evaluator( args: &BenchArgs, @@ -111,7 +204,7 @@ pub fn run_bench_for_evaluator( checkpointer: &mut Checkpointer, ) -> Result, BenchError> where - E: Evaluator + Copy, + E: Evaluator + Clone, E::Result: ResultCount, { let mut results = Vec::with_capacity(queries.len()); @@ -150,9 +243,18 @@ where eprintln!("[query #{}] id={}", idx, loaded.id); eprintln!(" [bench] algo={algo_name}"); - match run_benchmark_group(args, algo_name, evaluator, query, graph, idx) { - Ok(Ok(timing)) => { - algorithms.insert(algo_name.to_string(), AlgoResult::ok(None, Some(timing))); + let bench_result = match args.bench_mode { + BenchMode::Fixed => run_fixed_group(args, evaluator.clone(), query, graph) + .map(|(count, timing)| Ok((Some(count), timing))), + BenchMode::Criterion => { + run_benchmark_group(args, algo_name, evaluator.clone(), query, graph, idx) + .map(|result| result.map(|timing| (None, timing))) + } + }; + + match bench_result { + Ok(Ok((count, timing))) => { + algorithms.insert(algo_name.to_string(), AlgoResult::ok(count, Some(timing))); } Ok(Err(e)) => return Err(e), Err(e) => { diff --git a/pathrex/src/cli/checkpoint.rs b/pathrex/src/cli/checkpoint.rs index f59f3bb..7de7fc8 100644 --- a/pathrex/src/cli/checkpoint.rs +++ b/pathrex/src/cli/checkpoint.rs @@ -16,7 +16,7 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use thiserror::Error; -use super::args::Algo; +use super::args::{Algo, BenchArgs, BenchMode, RpqMatrixOptimizer}; /// Persistent checkpoint state written to disk as JSON. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -25,9 +25,59 @@ pub struct Checkpoint { pub graph_path: String, pub queries_file: String, pub algorithms: Vec, + #[serde(default)] + pub rpqmatrix_optimizer: RpqMatrixOptimizer, + #[serde(default)] + pub bench_config: BenchRunConfig, pub completed: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BenchRunConfig { + pub bench_mode: BenchMode, + pub runs: Option, + pub warm_up_runs: Option, + pub sample_size: Option, + pub warm_up_secs: Option, + pub measurement_secs: Option, +} + +impl BenchRunConfig { + pub fn from_args(args: &BenchArgs) -> Self { + match args.bench_mode { + BenchMode::Fixed => Self { + bench_mode: args.bench_mode, + runs: Some(args.fixed_runs()), + warm_up_runs: Some(args.fixed_warm_up_runs()), + sample_size: None, + warm_up_secs: None, + measurement_secs: None, + }, + BenchMode::Criterion => Self { + bench_mode: args.bench_mode, + runs: None, + warm_up_runs: None, + sample_size: Some(args.criterion_sample_size()), + warm_up_secs: Some(args.criterion_warm_up_secs()), + measurement_secs: Some(args.criterion_measurement_secs()), + }, + } + } +} + +impl Default for BenchRunConfig { + fn default() -> Self { + Self { + bench_mode: BenchMode::Fixed, + runs: Some(BenchArgs::DEFAULT_FIXED_RUNS), + warm_up_runs: Some(BenchArgs::DEFAULT_FIXED_WARM_UP_RUNS), + sample_size: None, + warm_up_secs: None, + measurement_secs: None, + } + } +} + /// Tracks which algorithms have been completed for a single query. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct QueryCompletion { @@ -37,12 +87,20 @@ pub struct QueryCompletion { impl Checkpoint { /// Create a fresh checkpoint for a new benchmark run. - pub fn new(graph_path: &str, queries_file: &str, algorithms: &[Algo]) -> Self { + pub fn new( + graph_path: &str, + queries_file: &str, + algorithms: &[Algo], + rpqmatrix_optimizer: RpqMatrixOptimizer, + bench_config: BenchRunConfig, + ) -> Self { Self { version: 1, graph_path: graph_path.to_string(), queries_file: queries_file.to_string(), algorithms: algorithms.to_vec(), + rpqmatrix_optimizer, + bench_config, completed: Vec::new(), } } @@ -65,6 +123,8 @@ impl Checkpoint { graph_path: &str, queries_file: &str, algorithms: &[Algo], + rpqmatrix_optimizer: RpqMatrixOptimizer, + bench_config: &BenchRunConfig, ) -> Result<(), CheckpointError> { if self.graph_path != graph_path { return Err(CheckpointError::Mismatch(format!( @@ -86,6 +146,18 @@ impl Checkpoint { self.algorithms, algorithms ))); } + if self.rpqmatrix_optimizer != rpqmatrix_optimizer { + return Err(CheckpointError::Mismatch(format!( + "rpqmatrix_optimizer: checkpoint has '{}', current is '{}'", + self.rpqmatrix_optimizer, rpqmatrix_optimizer + ))); + } + if &self.bench_config != bench_config { + return Err(CheckpointError::Mismatch(format!( + "bench_config: checkpoint has {:?}, current is {:?}", + self.bench_config, bench_config + ))); + } Ok(()) } @@ -93,6 +165,14 @@ impl Checkpoint { pub fn save(&self, path: &Path) -> Result<(), CheckpointError> { let json = serde_json::to_string_pretty(self).map_err(CheckpointError::Serialize)?; + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent) + .map_err(|e| CheckpointError::Io(parent.display().to_string(), e))?; + } + // Write to a temp file first, then rename for atomicity. let tmp_path = path.with_extension("json.tmp"); fs::write(&tmp_path, &json) @@ -140,21 +220,37 @@ impl Checkpoint { /// Runtime owner for a [`Checkpoint`] paired with its on-disk path. pub struct Checkpointer { inner: Checkpoint, - path: PathBuf, + path: Option, } impl Checkpointer { /// Create a new checkpointer with no completions. - pub fn fresh(graph_path: &str, queries_file: &str, algorithms: &[Algo], path: PathBuf) -> Self { + pub fn fresh( + graph_path: &str, + queries_file: &str, + algorithms: &[Algo], + rpqmatrix_optimizer: RpqMatrixOptimizer, + bench_config: BenchRunConfig, + path: Option, + ) -> Self { Self { - inner: Checkpoint::new(graph_path, queries_file, algorithms), + inner: Checkpoint::new( + graph_path, + queries_file, + algorithms, + rpqmatrix_optimizer, + bench_config, + ), path, } } /// Wrap an existing [`Checkpoint`] (e.g. one loaded from disk). pub fn with_inner(inner: Checkpoint, path: PathBuf) -> Self { - Self { inner, path } + Self { + inner, + path: Some(path), + } } /// Number of queries that have *all* requested algorithms done. @@ -184,7 +280,10 @@ impl Checkpointer { algo: &Algo, ) -> Result<(), CheckpointError> { self.inner.mark_algo_done(query_index, algo); - self.inner.save(&self.path) + if let Some(path) = &self.path { + self.inner.save(path)?; + } + Ok(()) } } diff --git a/pathrex/src/cli/dispatch.rs b/pathrex/src/cli/dispatch.rs index 2701460..07f57a5 100644 --- a/pathrex/src/cli/dispatch.rs +++ b/pathrex/src/cli/dispatch.rs @@ -1,5 +1,4 @@ //! Typed dispatch from CLI algorithm choices to concrete evaluators. - use crate::cli::args::{Algo, BenchArgs, QueryArgs}; use crate::cli::bench::error::BenchError; use crate::cli::bench::runner::run_bench_for_evaluator; @@ -8,6 +7,7 @@ use crate::cli::loader::LoadedQuery; use crate::cli::output::QueryResult; use crate::cli::query::run_query_for_evaluator; use crate::graph::InMemoryGraph; + use crate::rpq::nfarpq::NfaRpqEvaluator; use crate::rpq::rpqmatrix::RpqMatrixEvaluator; @@ -30,12 +30,16 @@ pub fn dispatch_query( queries: &[LoadedQuery], ) -> Vec { let mut all = Vec::new(); - for algo in &args.common.algo { let name = algo.to_string(); let per_algo = match algo { Algo::NfaRpq => run_query_for_evaluator(&name, NfaRpqEvaluator, graph, queries), - Algo::Rpqmatrix => run_query_for_evaluator(&name, RpqMatrixEvaluator, graph, queries), + Algo::Rpqmatrix => run_query_for_evaluator( + &name, + RpqMatrixEvaluator::optimized(args.common.rpqmatrix_optimizer.into()), + graph, + queries, + ), }; merge_results(&mut all, per_algo); } @@ -67,7 +71,7 @@ pub fn dispatch_bench( args, algo, &name, - RpqMatrixEvaluator, + RpqMatrixEvaluator::optimized(args.common.rpqmatrix_optimizer.into()), graph, queries, checkpointer, diff --git a/pathrex/src/cli/loader.rs b/pathrex/src/cli/loader.rs index a652043..59c1d96 100644 --- a/pathrex/src/cli/loader.rs +++ b/pathrex/src/cli/loader.rs @@ -12,11 +12,11 @@ use thiserror::Error; use crate::formats::Csv; use crate::formats::MatrixMarket; use crate::formats::Rdf; -use crate::graph::{Graph, GraphError, InMemory, InMemoryGraph}; +use crate::graph::{Graph, GraphError, InMemory, InMemoryGraph, MatrixStatsMode}; use crate::rpq::{RpqError, RpqQuery}; use crate::sparql::parse_rpq; -use super::args::GraphFormat; +use super::args::{GraphFormat, RpqMatrixOptimizer}; #[derive(Debug, Error)] pub enum GraphLoadError { @@ -34,19 +34,34 @@ pub enum GraphLoadError { }, } -/// Load an [`InMemoryGraph`] from `graph_path` in the given `format`. +/// Load a graph without optimizer-specific statistics. pub fn load_graph( graph_path: &str, format: GraphFormat, base_iri: Option<&str>, +) -> Result { + load_graph_with_optimizer(graph_path, format, base_iri, RpqMatrixOptimizer::None) +} + +/// Load a graph with statistics required by the selected optimizer. +pub fn load_graph_with_optimizer( + graph_path: &str, + format: GraphFormat, + base_iri: Option<&str>, + optimizer: RpqMatrixOptimizer, ) -> Result { match format { GraphFormat::Mm => { + let stats_mode = match optimizer { + RpqMatrixOptimizer::Mnc => MatrixStatsMode::Extended, + _ => MatrixStatsMode::None, + }; let mm_base = MatrixMarket::from_dir(graph_path); let mm = match base_iri { Some(iri) => mm_base.with_base_iri(iri), None => mm_base, - }; + } + .with_matrix_stats(stats_mode); Graph::::try_from(mm).map_err(|e| GraphLoadError::Build { path: graph_path.to_string(), source: e, @@ -67,7 +82,7 @@ pub fn load_graph( }) } GraphFormat::Rdf => { - let rdf = Rdf::from_path(graph_path).unwrap(); + let rdf = Rdf::from_path(graph_path).unwrap(); // TODO: handle panic Graph::::try_from(rdf).map_err(|e| GraphLoadError::Build { path: graph_path.to_string(), source: e, @@ -76,6 +91,51 @@ pub fn load_graph( } } +#[cfg(test)] +mod graph_stats_tests { + use super::*; + use crate::graph::GraphDecomposition; + + #[test] + fn matrix_statistics_depend_on_optimizer() { + for (optimizer, basic, extended) in [ + (RpqMatrixOptimizer::Join, false, false), + (RpqMatrixOptimizer::MetaAc, false, false), + (RpqMatrixOptimizer::Hybrid, false, false), + (RpqMatrixOptimizer::None, false, false), + (RpqMatrixOptimizer::PangHybrid, false, false), + (RpqMatrixOptimizer::Sampling, false, false), + (RpqMatrixOptimizer::Mnc, true, true), + ] { + let graph = load_graph_with_optimizer( + "tests/testdata/mm_small", + GraphFormat::Mm, + None, + optimizer, + ) + .unwrap(); + let counts = graph + .get_metadata() + .unwrap() + .matrix("knows") + .unwrap() + .counts + .as_ref(); + assert_eq!(counts.is_some(), basic, "{optimizer}"); + assert_eq!( + counts.and_then(|c| c.row_extended.as_ref()).is_some(), + extended, + "{optimizer}" + ); + assert_eq!( + counts.and_then(|c| c.col_extended.as_ref()).is_some(), + extended, + "{optimizer}" + ); + } + } +} + #[derive(Debug)] pub struct LoadedQuery { pub id: String, diff --git a/pathrex/src/cli/output.rs b/pathrex/src/cli/output.rs index 4dbe0a2..de30e86 100644 --- a/pathrex/src/cli/output.rs +++ b/pathrex/src/cli/output.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use serde::Serialize; @@ -60,6 +60,14 @@ impl AlgoResult { pub struct AlgoTiming { pub total: TimingStats, pub ffi_only: TimingStats, + #[serde(skip)] + pub samples: Option, +} + +#[derive(Debug, Serialize)] +pub struct AlgoTimingSamples { + pub total_ns: Vec, + pub ffi_only_ns: Vec, } /// Timing statistics extracted from criterion estimates. @@ -91,6 +99,7 @@ pub struct QueryMetadata { pub graph_path: String, pub graph_format: String, pub queries_file: String, + pub rpqmatrix_optimizer: Option, #[serde(skip_serializing_if = "Option::is_none")] pub base_iri: Option, pub num_nodes: usize, @@ -100,7 +109,7 @@ pub struct QueryMetadata { impl QueryOutput { pub fn write_to_file(&self, path: &Path) -> Result<(), std::io::Error> { let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?; - fs::write(path, json) + write_json_to_file(path, json) } } @@ -116,22 +125,126 @@ pub struct BenchMetadata { pub graph_path: String, pub graph_format: String, pub queries_file: String, + pub rpqmatrix_optimizer: Option, #[serde(skip_serializing_if = "Option::is_none")] pub base_iri: Option, pub num_nodes: usize, pub num_labels: usize, - pub sample_size: usize, - pub warm_up_secs: u64, - pub measurement_secs: u64, + pub bench_mode: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub runs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub warm_up_runs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sample_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub warm_up_secs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub measurement_secs: Option, } impl BenchOutput { pub fn write_to_file(&self, path: &Path) -> Result<(), std::io::Error> { let json = serde_json::to_string_pretty(self).map_err(std::io::Error::other)?; - fs::write(path, json) + write_json_to_file(path, json) + } + + pub fn write_samples_to_file(&self, path: &Path) -> Result, std::io::Error> { + let Some(samples) = BenchSamplesOutput::from_bench_output(self) else { + return Ok(None); + }; + let samples_path = samples_path_for(path); + let json = serde_json::to_string_pretty(&samples).map_err(std::io::Error::other)?; + write_json_to_file(&samples_path, json)?; + Ok(Some(samples_path)) } } +#[derive(Debug, Serialize)] +pub struct BenchSamplesOutput<'a> { + pub metadata: &'a BenchMetadata, + pub results: Vec>, +} + +#[derive(Debug, Serialize)] +pub struct QuerySamples<'a> { + pub query_index: usize, + pub query_id: &'a str, + pub query_text: &'a str, + pub algorithms: HashMap<&'a str, AlgoSamples<'a>>, +} + +#[derive(Debug, Serialize)] +pub struct AlgoSamples<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + pub result_count: Option, + pub total_ns: &'a [f64], + pub ffi_only_ns: &'a [f64], +} + +impl<'a> BenchSamplesOutput<'a> { + pub fn from_bench_output(output: &'a BenchOutput) -> Option { + let mut results = Vec::new(); + + for query in &output.results { + let mut algorithms = HashMap::new(); + for (algo, result) in &query.algorithms { + let Some(timing) = &result.timing else { + continue; + }; + let Some(samples) = &timing.samples else { + continue; + }; + algorithms.insert( + algo.as_str(), + AlgoSamples { + result_count: result.result_count, + total_ns: &samples.total_ns, + ffi_only_ns: &samples.ffi_only_ns, + }, + ); + } + + if !algorithms.is_empty() { + results.push(QuerySamples { + query_index: query.query_index, + query_id: &query.query_id, + query_text: &query.query_text, + algorithms, + }); + } + } + + (!results.is_empty()).then_some(Self { + metadata: &output.metadata, + results, + }) + } +} + +fn samples_path_for(path: &Path) -> PathBuf { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("bench_results.json"); + let samples_name = file_name + .strip_suffix(".json") + .map(|stem| format!("{stem}.runs.json")) + .unwrap_or_else(|| format!("{file_name}.runs.json")); + + path.with_file_name(samples_name) +} + +fn write_json_to_file(path: &Path, json: String) -> Result<(), std::io::Error> { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent)?; + } + fs::write(path, json) +} + #[cfg(test)] mod tests { use super::*; @@ -153,6 +266,7 @@ mod tests { stddev_ns: 0.0, iterations: 10, }, + samples: None, }), ); @@ -176,4 +290,96 @@ mod tests { let v = serde_json::to_value(&r).expect("serialize"); assert_eq!(v["status"], "panic"); } + + #[test] + fn query_output_creates_parent_directory() { + let dir = tempfile::tempdir().expect("tempdir"); + let output_path = dir.path().join("nested").join("query.json"); + let output = QueryOutput { + metadata: QueryMetadata { + timestamp: "now".into(), + graph_path: "graph".into(), + graph_format: "mm".into(), + queries_file: "queries".into(), + rpqmatrix_optimizer: Some("none".into()), + base_iri: None, + num_nodes: 0, + num_labels: 0, + }, + results: Vec::new(), + }; + + output.write_to_file(&output_path).expect("write output"); + + assert!(output_path.exists()); + } + + #[test] + fn bench_output_writes_samples_next_to_output() { + let dir = tempfile::tempdir().expect("tempdir"); + let output_path = dir.path().join("bench.json"); + let output = BenchOutput { + metadata: BenchMetadata { + timestamp: "now".into(), + graph_path: "graph".into(), + graph_format: "mm".into(), + queries_file: "queries".into(), + rpqmatrix_optimizer: Some("none".into()), + base_iri: None, + num_nodes: 0, + num_labels: 0, + bench_mode: "fixed".into(), + runs: Some(2), + warm_up_runs: Some(0), + sample_size: None, + warm_up_secs: None, + measurement_secs: None, + }, + results: vec![QueryResult { + query_index: 0, + query_id: "q0".into(), + query_text: "query".into(), + algorithms: HashMap::from([( + "rpqmatrix".into(), + AlgoResult::ok( + Some(1), + Some(AlgoTiming { + total: TimingStats { + mean_ns: 15.0, + median_ns: 15.0, + stddev_ns: 5.0, + iterations: 2, + }, + ffi_only: TimingStats { + mean_ns: 4.0, + median_ns: 4.0, + stddev_ns: 1.0, + iterations: 2, + }, + samples: Some(AlgoTimingSamples { + total_ns: vec![10.0, 20.0], + ffi_only_ns: vec![3.0, 5.0], + }), + }), + ), + )]), + }], + }; + + let samples_path = output + .write_samples_to_file(&output_path) + .expect("write samples") + .expect("samples path"); + let samples_json = fs::read_to_string(samples_path).expect("read samples"); + let value: serde_json::Value = serde_json::from_str(&samples_json).expect("json"); + + assert_eq!( + value["results"][0]["algorithms"]["rpqmatrix"]["total_ns"][0], + 10.0 + ); + assert_eq!( + value["results"][0]["algorithms"]["rpqmatrix"]["ffi_only_ns"][1], + 5.0 + ); + } } diff --git a/pathrex/src/cli/query.rs b/pathrex/src/cli/query.rs index 152883c..5bbad9a 100644 --- a/pathrex/src/cli/query.rs +++ b/pathrex/src/cli/query.rs @@ -20,7 +20,7 @@ pub fn run_query_for_evaluator( queries: &[LoadedQuery], ) -> Vec where - E: Evaluator + Copy, + E: Evaluator, E::Result: ResultCount, { let mut results = Vec::with_capacity(queries.len()); diff --git a/pathrex/src/formats/mm.rs b/pathrex/src/formats/mm.rs index 5bf44b9..e24d205 100644 --- a/pathrex/src/formats/mm.rs +++ b/pathrex/src/formats/mm.rs @@ -29,6 +29,7 @@ use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use crate::formats::FormatError; +use crate::graph::MatrixStatsMode; pub use crate::graph::load_mm_file; // Trims first "<" and last ">". @@ -129,6 +130,7 @@ pub(crate) fn parse_index_map(path: &Path) -> Result { pub struct MatrixMarket { pub(crate) dir: PathBuf, pub(crate) base_iri: Option, + pub(crate) stats_mode: MatrixStatsMode, } impl MatrixMarket { @@ -137,6 +139,7 @@ impl MatrixMarket { Self { dir: dir.into(), base_iri: None, + stats_mode: MatrixStatsMode::None, } } @@ -145,6 +148,11 @@ impl MatrixMarket { self } + pub fn with_matrix_stats(mut self, mode: MatrixStatsMode) -> Self { + self.stats_mode = mode; + self + } + pub fn mm_path(&self, idx: usize) -> PathBuf { self.dir.join(format!("{}.txt", idx)) } diff --git a/pathrex/src/graph/inmemory.rs b/pathrex/src/graph/inmemory.rs index 9121101..7ff5a22 100644 --- a/pathrex/src/graph/inmemory.rs +++ b/pathrex/src/graph/inmemory.rs @@ -5,14 +5,15 @@ use rayon::prelude::*; use crate::formats::mm::{apply_base_iri, parse_index_map}; use crate::formats::{Csv, MatrixMarket, Rdf}; +use crate::rpq::rpqmatrix::stats::LabelCountVectors; use crate::{ graph::GraphSource, lagraph_sys::{GrB_Index, LAGraph_Kind}, }; use super::{ - Backend, Edge, GraphBuilder, GraphDecomposition, GraphError, LagraphGraph, ThreadScope, - compute_outer_inner, load_mm_file, + Backend, Edge, GraphBuilder, GraphDecomposition, GraphError, LagraphGraph, MatrixStatsMode, + MatrixStorage, ThreadScope, compute_outer_inner, load_mm_file, }; /// Marker type for the in-memory GraphBLAS-backed backend. @@ -42,6 +43,8 @@ pub struct InMemoryBuilder { next_id: usize, label_buffers: HashMap>, prebuilt: HashMap, + prebuilt_csc: HashMap, + metadata: HashMap, } impl InMemoryBuilder { @@ -52,6 +55,8 @@ impl InMemoryBuilder { next_id: 0, label_buffers: HashMap::new(), prebuilt: HashMap::new(), + prebuilt_csc: HashMap::new(), + metadata: HashMap::new(), } } @@ -111,6 +116,22 @@ impl InMemoryBuilder { ) { self.prebuilt.extend(iter); } + + /// Bulk-install pre-wrapped CSC `(label, LagraphGraph)` pairs. + pub(crate) fn extend_prebuilt_csc>( + &mut self, + iter: I, + ) { + self.prebuilt_csc.extend(iter); + } + + /// Bulk-install pre-wrapped `(label, MatrixMetadata)` pairs into `metadata`. + pub(crate) fn extend_metadata>( + &mut self, + iter: I, + ) { + self.metadata.extend(iter); + } } impl GraphBuilder for InMemoryBuilder { @@ -128,10 +149,15 @@ impl GraphBuilder for InMemoryBuilder { let mut graphs: HashMap> = HashMap::with_capacity(self.label_buffers.len() + self.prebuilt.len()); + let mut graphs_csc: HashMap> = + HashMap::with_capacity(self.prebuilt_csc.len()); for (label, lg) in self.prebuilt { graphs.insert(label, Arc::new(lg)); } + for (label, lg) in self.prebuilt_csc { + graphs_csc.insert(label, Arc::new(lg)); + } let label_buffers: Vec<(String, Vec<(usize, usize)>)> = self.label_buffers.into_iter().collect(); @@ -162,11 +188,14 @@ impl GraphBuilder for InMemoryBuilder { for (label, lg) in built { graphs.insert(label, Arc::new(lg)); } - Ok(InMemoryGraph { node_to_id: self.node_to_id, id_to_node: self.id_to_node, graphs, + graphs_csc, + metadata: GraphMetadata { + label_to_data: self.metadata, + }, }) } } @@ -176,6 +205,25 @@ pub struct InMemoryGraph { node_to_id: HashMap, id_to_node: HashMap, graphs: HashMap>, + graphs_csc: HashMap>, + metadata: GraphMetadata, +} +pub struct GraphMetadata { + label_to_data: HashMap, +} + +impl GraphMetadata { + pub fn matrix(&self, label: &str) -> Option<&MatrixMetadata> { + self.label_to_data.get(label) + } +} + +pub struct MatrixMetadata { + pub dimension: usize, + pub nonzero_rows: usize, + pub nonzero_cols: usize, + pub nvals: usize, + pub(crate) counts: Option, } impl GraphDecomposition for InMemoryGraph { @@ -186,6 +234,19 @@ impl GraphDecomposition for InMemoryGraph { .ok_or_else(|| GraphError::LabelNotFound(label.to_owned())) } + fn get_graph_with_storage( + &self, + label: &str, + storage: MatrixStorage, + ) -> Result, GraphError> { + if storage == MatrixStorage::Csc + && let Some(graph) = self.graphs_csc.get(label) + { + return Ok(Arc::clone(graph)); + } + self.get_graph(label) + } + fn get_node_id(&self, string_id: &str) -> Option { self.node_to_id.get(string_id).copied() } @@ -197,6 +258,10 @@ impl GraphDecomposition for InMemoryGraph { fn num_nodes(&self) -> usize { self.id_to_node.len() } + + fn get_metadata(&self) -> Option<&GraphMetadata> { + Some(&self.metadata) + } } impl InMemoryGraph { @@ -204,6 +269,9 @@ impl InMemoryGraph { pub fn num_labels(&self) -> usize { self.graphs.len() } + pub fn metadata(&self, label: &str) -> Option<&MatrixMetadata> { + self.metadata.label_to_data.get(label) + } } impl GraphSource for Csv { @@ -244,22 +312,64 @@ impl GraphSource for MatrixMarket { let _scope = ThreadScope::enter(outer, inner)?; let mm_dir = self.dir.clone(); - let loaded: Vec<(String, LagraphGraph)> = edge_by_idx - .into_par_iter() - .map( - |(idx, label)| -> Result<(String, LagraphGraph), GraphError> { - let path = mm_dir.join(format!("{}.txt", idx)); - let matrix = load_mm_file(&path)?; - let lg = LagraphGraph::from_matrix( - matrix, - LAGraph_Kind::LAGraph_ADJACENCY_DIRECTED, - )?; - Ok((label, lg)) - }, - ) - .collect::, GraphError>>()?; - - builder.extend_prebuilt(loaded); + let loaded: Vec<(String, LagraphGraph, LagraphGraph, MatrixMetadata)> = + edge_by_idx + .into_par_iter() + .map( + |(idx, label)| -> Result< + (String, LagraphGraph, LagraphGraph, MatrixMetadata), + GraphError, + > { + let path = mm_dir.join(format!("{}.txt", idx)); + let matrix = load_mm_file(&path)?; + matrix.set_storage_orientation(MatrixStorage::Csr)?; + let csc_matrix = matrix.dup_with_storage_orientation(MatrixStorage::Csc)?; + let lg = LagraphGraph::from_matrix( + matrix, + LAGraph_Kind::LAGraph_ADJACENCY_DIRECTED, + )?; + let lg_csc = LagraphGraph::from_matrix( + csc_matrix, + LAGraph_Kind::LAGraph_ADJACENCY_DIRECTED, + )?; + let dimension = lg.dimension()?; + let nonzero_rows = lg.nonzero_rows()?; + let nonzero_cols = lg.nonzero_cols()?; + let nvals = lg.nvals()?; + let metadata = MatrixMetadata { + dimension: dimension as usize, + nonzero_rows, + nonzero_cols, + nvals: nvals as usize, + counts: None, + }; + Ok((label, lg, lg_csc, metadata)) + }, + ) + .collect::, GraphError>>()?; + + let mut loaded_graphs = vec![]; + let mut loaded_graphs_csc = vec![]; + let mut loaded_metadata = vec![]; + for (name, graph, graph_csc, mut meta) in loaded { + meta.counts = match self.stats_mode { + MatrixStatsMode::None => None, + MatrixStatsMode::Basic => Some( + LabelCountVectors::from_matrix_basic(graph.matrix()) + .ok_or_else(|| GraphError::Statistics(name.clone()))?, + ), + MatrixStatsMode::Extended => Some( + LabelCountVectors::from_matrix(graph.matrix()) + .ok_or_else(|| GraphError::Statistics(name.clone()))?, + ), + }; + loaded_graphs.push((name.clone(), graph)); + loaded_graphs_csc.push((name.clone(), graph_csc)); + loaded_metadata.push((name, meta)); + } + builder.extend_prebuilt(loaded_graphs); + builder.extend_prebuilt_csc(loaded_graphs_csc); + builder.extend_metadata(loaded_metadata); Ok(builder) } diff --git a/pathrex/src/graph/mod.rs b/pathrex/src/graph/mod.rs index 0922459..aeacef0 100644 --- a/pathrex/src/graph/mod.rs +++ b/pathrex/src/graph/mod.rs @@ -4,12 +4,23 @@ pub mod inmemory; pub mod wrappers; pub use inmemory::{InMemory, InMemoryBuilder, InMemoryGraph}; -pub use wrappers::{GraphblasMatrix, GraphblasVector, LagraphGraph, load_mm_file}; -pub(crate) use wrappers::{ThreadScope, compute_outer_inner, ensure_grb_init}; +pub use wrappers::{GraphblasMatrix, GraphblasVector, LagraphGraph, MatrixStorage, load_mm_file}; +pub(crate) use wrappers::{ + ThreadScope, compute_outer_inner, ensure_grb_init, set_global_matrix_storage_hint, +}; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum MatrixStatsMode { + #[default] + None, + Basic, + Extended, +} use std::marker::PhantomData; use std::sync::Arc; +use crate::graph::inmemory::GraphMetadata; use crate::lagraph_sys::GrB_Info; use thiserror::Error; @@ -32,6 +43,9 @@ pub enum GraphError { #[error("Label not found: '{0}'")] LabelNotFound(String), + #[error("unable to build optimizer statistics for label '{0}'")] + Statistics(String), + /// A format-layer error propagated through [`GraphBuilder::load`]. #[error("Format error: {0}")] Format(#[from] crate::formats::FormatError), @@ -77,12 +91,25 @@ pub trait GraphDecomposition { /// Returns the [`LagraphGraph`] for `label`. fn get_graph(&self, label: &str) -> Result, GraphError>; + /// Returns the [`LagraphGraph`] for `label` in a preferred storage orientation. + /// Backends that do not maintain multiple orientations may return their default graph. + fn get_graph_with_storage( + &self, + label: &str, + _storage: MatrixStorage, + ) -> Result, GraphError> { + self.get_graph(label) + } + /// Translates a string ID to a contiguous matrix index. fn get_node_id(&self, string_id: &str) -> Option; /// Translates a matrix index back to a string ID. fn get_node_name(&self, mapped_id: usize) -> Option; fn num_nodes(&self) -> usize; + fn get_metadata(&self) -> Option<&GraphMetadata> { + None + } } /// Associates a backend marker type with a concrete [`GraphBuilder`] and diff --git a/pathrex/src/graph/wrappers.rs b/pathrex/src/graph/wrappers.rs index e97cfc5..2695cbf 100644 --- a/pathrex/src/graph/wrappers.rs +++ b/pathrex/src/graph/wrappers.rs @@ -9,13 +9,43 @@ use std::ffi::CString; use std::fs::File; use std::os::fd::IntoRawFd; use std::path::Path; -use std::sync::Once; +use std::sync::{ + Once, + atomic::{AtomicU8, Ordering}, +}; -use crate::{grb_ok, la_ok, lagraph_sys::*}; +use crate::{ + graph::wrappers::ReduceType::{ByCols, ByRows}, + grb_ok, la_ok, + lagraph_sys::*, +}; use super::GraphError; static GRB_INIT: Once = Once::new(); +static GLOBAL_MATRIX_STORAGE_HINT: AtomicU8 = AtomicU8::new(0); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MatrixStorage { + Csc, + Csr, +} + +impl MatrixStorage { + fn as_hint_code(self) -> u8 { + match self { + MatrixStorage::Csc => 1, + MatrixStorage::Csr => 2, + } + } + + fn as_rpq_storage(self) -> RPQMatrixStorage { + match self { + MatrixStorage::Csc => RPQMatrixStorage::RPQ_MATRIX_STORAGE_CSC, + MatrixStorage::Csr => RPQMatrixStorage::RPQ_MATRIX_STORAGE_CSR, + } + } +} pub(crate) fn ensure_grb_init() -> Result<(), GraphError> { let mut result = Ok(()); @@ -25,6 +55,22 @@ pub(crate) fn ensure_grb_init() -> Result<(), GraphError> { result } +pub(crate) fn set_global_matrix_storage_hint(storage: MatrixStorage) -> Result<(), GraphError> { + let hint_code = storage.as_hint_code(); + if GLOBAL_MATRIX_STORAGE_HINT.load(Ordering::Acquire) == hint_code { + return Ok(()); + } + + ensure_grb_init()?; + unsafe { + grb_ok!(LAGraph_RPQMatrix_SetGlobalStorageOrientation( + storage.as_rpq_storage(), + ))? + }; + GLOBAL_MATRIX_STORAGE_HINT.store(hint_code, Ordering::Release); + Ok(()) +} + /// Compute a balanced `(outer, inner)` split for LAGraph's two-level threading. /// /// `outer` is the number of user-level concurrent tasks (rayon workers); @@ -69,12 +115,20 @@ impl Drop for ThreadScope { } } +pub enum ReduceType { + ByRows, + ByCols, +} #[derive(Debug)] pub struct LagraphGraph { pub(crate) inner: LAGraph_Graph, } impl LagraphGraph { + pub(crate) fn matrix(&self) -> GrB_Matrix { + unsafe { (*self.inner).A } + } + /// Build a `LagraphGraph` from an RAII-wrapped [`GraphblasMatrix`]. /// /// On success, ownership of the underlying `GrB_Matrix` is transferred @@ -147,6 +201,17 @@ impl LagraphGraph { unsafe { la_ok!(LAGraph_CheckGraph(self.inner)) } } + /// Number of rows and cols in the underlying adjacency matrix. + pub fn dimension(&self) -> Result { + if self.inner.is_null() { + return Ok(0); + } + let matrix: GrB_Matrix = unsafe { (*self.inner).A }; + let mut dimension: GrB_Index = 0; + unsafe { grb_ok!(GrB_Matrix_nrows(&mut dimension, matrix))? }; + Ok(dimension) + } + /// Number of stored (non-zero) values in the underlying adjacency matrix. pub fn nvals(&self) -> Result { if self.inner.is_null() { @@ -157,6 +222,20 @@ impl LagraphGraph { unsafe { grb_ok!(GrB_Matrix_nvals(&mut nvals, matrix))? }; Ok(nvals) } + + pub fn nonzero_cols(&self) -> Result { + let matrix: GrB_Matrix = unsafe { (*self.inner).A }; + let mut res: GrB_Index = 0; + unsafe { grb_ok!(LAGraph_RPQMatrix_reduce(&mut res, matrix, ByCols as u8))? }; + Ok(res as usize) + } + + pub fn nonzero_rows(&self) -> Result { + let matrix: GrB_Matrix = unsafe { (*self.inner).A }; + let mut res: GrB_Index = 0; + unsafe { grb_ok!(LAGraph_RPQMatrix_reduce(&mut res, matrix, ByRows as u8))? }; + Ok(res as usize) + } } impl Drop for LagraphGraph { @@ -239,6 +318,28 @@ impl GraphblasMatrix { pub fn from_raw(raw: GrB_Matrix) -> Self { Self { inner: raw } } + + pub fn set_storage_orientation(&self, storage: MatrixStorage) -> Result<(), GraphError> { + unsafe { + grb_ok!(LAGraph_RPQMatrix_SetStorageOrientation( + self.inner, + storage.as_rpq_storage(), + ))? + }; + Ok(()) + } + + pub fn dup_with_storage_orientation(&self, storage: MatrixStorage) -> Result { + let mut raw: GrB_Matrix = std::ptr::null_mut(); + unsafe { + grb_ok!(LAGraph_RPQMatrix_DupWithStorageOrientation( + &mut raw, + self.inner, + storage.as_rpq_storage(), + ))? + }; + Ok(Self { inner: raw }) + } } impl Drop for GraphblasMatrix { diff --git a/pathrex/src/rpq/rpqmatrix/cost.rs b/pathrex/src/rpq/rpqmatrix/cost.rs new file mode 100644 index 0000000..6a50b94 --- /dev/null +++ b/pathrex/src/rpq/rpqmatrix/cost.rs @@ -0,0 +1,1706 @@ +use std::{cmp::Ordering, collections::HashMap, sync::Arc}; + +use egg::{CostFunction, Id}; + +use super::{ + plan::{LabelMeta, RpqPlan}, + sampling::{MatrixSampler, SampledRelation, SamplingConfig}, + stats::{CountVector, LabelCountVectors}, +}; +use crate::graph::LagraphGraph; + +#[derive(Clone, Debug, PartialEq)] +pub struct JoinCost { + pub score: f64, + pub nnz: f64, + pub nnz_r: f64, + pub nnz_c: f64, +} + +impl Eq for JoinCost {} + +impl PartialOrd for JoinCost { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for JoinCost { + fn cmp(&self, other: &Self) -> Ordering { + match self.score.total_cmp(&other.score) { + Ordering::Equal => {} + ord => return ord, + } + match self.nnz.total_cmp(&other.nnz) { + Ordering::Equal => {} + ord => return ord, + } + match self.nnz_r.total_cmp(&other.nnz_r) { + Ordering::Equal => {} + ord => return ord, + } + self.nnz_c.total_cmp(&other.nnz_c) + } +} + +// Approach based on formula for SQL JOIN operation +// Got from https://github.com/chernishev/Database-Engines-Course/tree/master/Lecture%203 +pub struct JoinCostFn { + pub n: f64, + pub star_penalty: f64, + pub lr_multiplier: f64, +} + +// TODO: enforce or encode `n > 0`; several estimates divide by `n` or `n^2`. +// TODO: decide whether all estimated cardinalities should be clamped to `[0, n^2]`. +impl CostFunction for JoinCostFn { + type Cost = JoinCost; + + fn cost(&mut self, enode: &RpqPlan, mut costs: C) -> Self::Cost + where + C: FnMut(Id) -> Self::Cost, + { + match enode { + RpqPlan::NamedVertex(_name) => JoinCost { + score: 0.0, + nnz: 1.0, + nnz_r: 1.0, + nnz_c: 1.0, + }, + + RpqPlan::Label(meta) => JoinCost { + score: 0.0, + nnz: meta.nvals as f64, + nnz_r: meta.nonzero_rows as f64, + nnz_c: meta.nonzero_cols as f64, + }, + + RpqPlan::Seq([a, b]) => { + let ca = costs(*a); + let cb = costs(*b); + + let denom = ca.nnz_r.max(cb.nnz_c).max(1.0); + let op_cost = (ca.nnz * cb.nnz) / denom; + let score = ca.score + cb.score + op_cost; + let nnz_est = ca.nnz * cb.nnz / self.n; + + JoinCost { + score, + nnz: nnz_est, + nnz_r: ca.nnz_r.min(self.n), // TODO: better reduce estimators + nnz_c: cb.nnz_c.min(self.n), // TODO: better reduce estimators + } + } + + RpqPlan::Alt([a, b]) => { + let ca = costs(*a); + let cb = costs(*b); + + // TODO: score uses the raw union estimate; decide if it should be clamped too. + let overlap = (ca.nnz * cb.nnz) / (self.n * self.n); + let op_cost = ca.nnz + cb.nnz - overlap; + let score = ca.score + cb.score + op_cost; + + let nnz_est = (ca.nnz + cb.nnz - overlap).min(self.n * self.n).max(0.0); + + let nnz_r_est = (ca.nnz_r + cb.nnz_r - (ca.nnz_r * cb.nnz_r) / self.n) + .min(self.n) + .max(0.0); + + let nnz_c_est = (ca.nnz_c + cb.nnz_c - (ca.nnz_c * cb.nnz_c) / self.n) + .min(self.n) + .max(0.0); + + JoinCost { + score, + nnz: nnz_est, + nnz_r: nnz_r_est, + nnz_c: nnz_c_est, + } + } + + RpqPlan::Star([a]) => { + let ca = costs(*a); + + // TODO: full dense closure is a conservative upper bound, not a tight estimate. + let penalty = self.star_penalty * ca.nnz.max(1.0); + let score = ca.score + penalty; + + JoinCost { + score, + nnz: self.n * self.n, + nnz_r: self.n, + nnz_c: self.n, + } + } + + RpqPlan::LStar([a, b]) => { + let ca = costs(*a); + let cb = costs(*b); + + // TODO: LStar/RStar currently reuse Seq-like row/column estimates and do not + // model the closure side directly. + let denom = ca.nnz_r.max(cb.nnz_c).max(1.0); + let base = (ca.nnz * cb.nnz) / denom; + let op_cost = self.lr_multiplier * base; + let score = ca.score + cb.score + op_cost; + + let nnz_est = self.lr_multiplier * ca.nnz * cb.nnz / (self.n * self.n); + + JoinCost { + score, + nnz: nnz_est, + nnz_r: ca.nnz_r.min(self.n), // TODO: better reduce estimators + nnz_c: cb.nnz_c.min(self.n), // TODO: better reduce estimators + } + } + + RpqPlan::RStar([a, b]) => { + let ca = costs(*a); + let cb = costs(*b); + + // TODO: LStar/RStar currently reuse Seq-like row/column estimates and do not + // model the closure side directly. + let denom = ca.nnz_r.max(cb.nnz_c).max(1.0); + let base = (ca.nnz * cb.nnz) / denom; + + let op_cost = self.lr_multiplier * base; + let score = ca.score + cb.score + op_cost; + + let nnz_est = self.lr_multiplier * ca.nnz * cb.nnz / (self.n * self.n); + + JoinCost { + score, + nnz: nnz_est, + nnz_r: ca.nnz_r.min(self.n), // TODO: better reduce estimators + nnz_c: cb.nnz_c.min(self.n), // TODO: better reduce estimators + } + } + } + } +} + +#[derive(Clone, Debug)] +pub(super) struct MetaAcCost { + score: f64, + nnz: f64, + nnz_r: f64, + nnz_c: f64, +} + +impl PartialEq for MetaAcCost { + fn eq(&self, other: &Self) -> bool { + self.cmp(other).is_eq() + } +} +impl Eq for MetaAcCost {} +impl PartialOrd for MetaAcCost { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for MetaAcCost { + fn cmp(&self, other: &Self) -> Ordering { + self.score + .total_cmp(&other.score) + .then(self.nnz.total_cmp(&other.nnz)) + .then(self.nnz_r.total_cmp(&other.nnz_r)) + .then(self.nnz_c.total_cmp(&other.nnz_c)) + } +} + +// Naive metadata estimator +// got from 2.1 of paper https://mboehm7.github.io/resources/sigmod2019.pdf +pub(super) struct MetaAcCostFn { + pub n: f64, + pub star_penalty: f64, + pub lr_multiplier: f64, +} + +fn metaac_matmul_nnz(lhs_nnz: f64, rhs_nnz: f64, n: f64) -> f64 { + let output_cells = (n * n).max(1.0); + let p = ((lhs_nnz / output_cells) * (rhs_nnz / output_cells)).clamp(0.0, 1.0); + (-output_cells * (n * (-p).ln_1p()).exp_m1()).clamp(0.0, output_cells) +} + +impl CostFunction for MetaAcCostFn { + type Cost = MetaAcCost; + + fn cost(&mut self, enode: &RpqPlan, mut costs: C) -> Self::Cost + where + C: FnMut(Id) -> Self::Cost, + { + match enode { + RpqPlan::NamedVertex(_name) => MetaAcCost { + score: 0.0, + nnz: 1.0, + nnz_r: 1.0, + nnz_c: 1.0, + }, + RpqPlan::Label(meta) => MetaAcCost { + score: 0.0, + nnz: meta.nvals as f64, + nnz_r: meta.nonzero_rows as f64, + nnz_c: meta.nonzero_cols as f64, + }, + RpqPlan::Seq([a, b]) => { + let ca = costs(*a); + let cb = costs(*b); + let op_cost = ca.nnz * cb.nnz / self.n.max(1.0); + let score = ca.score + cb.score + op_cost; + let nnz_est = metaac_matmul_nnz(ca.nnz, cb.nnz, self.n); + MetaAcCost { + score, + nnz: nnz_est, + nnz_r: ca.nnz_r.min(self.n), + nnz_c: cb.nnz_c.min(self.n), + } + } + RpqPlan::Alt([a, b]) => { + let ca = costs(*a); + let cb = costs(*b); + let overlap = ca.nnz * cb.nnz / (self.n * self.n).max(1.0); + let nnz_est = (ca.nnz + cb.nnz - overlap).clamp(0.0, self.n * self.n); + let score = ca.score + cb.score + nnz_est; + let nnz_r_est = (ca.nnz_r + cb.nnz_r - ca.nnz_r * cb.nnz_r / self.n.max(1.0)) + .clamp(0.0, self.n); + let nnz_c_est = (ca.nnz_c + cb.nnz_c - ca.nnz_c * cb.nnz_c / self.n.max(1.0)) + .clamp(0.0, self.n); + MetaAcCost { + score, + nnz: nnz_est, + nnz_r: nnz_r_est, + nnz_c: nnz_c_est, + } + } + RpqPlan::Star([a]) => { + let ca = costs(*a); + let penalty = self.star_penalty * ca.nnz.max(1.0); + MetaAcCost { + score: ca.score + penalty, + nnz: self.n * self.n, + nnz_r: self.n, + nnz_c: self.n, + } + } + RpqPlan::LStar([a, b]) | RpqPlan::RStar([a, b]) => { + let ca = costs(*a); + let cb = costs(*b); + let denom = ca.nnz_r.max(cb.nnz_c).max(1.0); + let op_cost = self.lr_multiplier * ca.nnz * cb.nnz / denom; + let score = ca.score + cb.score + op_cost; + let nnz_est = self.lr_multiplier * ca.nnz * cb.nnz / (self.n * self.n).max(1.0); + MetaAcCost { + score, + nnz: nnz_est, + nnz_r: ca.nnz_r.min(self.n), + nnz_c: cb.nnz_c.min(self.n), + } + } + } + } +} + +#[derive(Clone, Debug)] +pub(super) struct HybridCost { + score: f64, + nnz: f64, + nnz_r: f64, + nnz_c: f64, +} + +impl PartialEq for HybridCost { + fn eq(&self, other: &Self) -> bool { + self.cmp(other).is_eq() + } +} +impl Eq for HybridCost {} +impl PartialOrd for HybridCost { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for HybridCost { + fn cmp(&self, other: &Self) -> Ordering { + self.score + .total_cmp(&other.score) + .then(self.nnz.total_cmp(&other.nnz)) + .then(self.nnz_r.total_cmp(&other.nnz_r)) + .then(self.nnz_c.total_cmp(&other.nnz_c)) + } +} + +// Join operation work with MetaAC result cardinality. +pub(super) struct HybridCostFn { + pub n: f64, + pub star_penalty: f64, + pub lr_multiplier: f64, +} + +impl CostFunction for HybridCostFn { + type Cost = HybridCost; + + fn cost(&mut self, enode: &RpqPlan, mut costs: C) -> Self::Cost + where + C: FnMut(Id) -> Self::Cost, + { + match enode { + RpqPlan::NamedVertex(_name) => HybridCost { + score: 0.0, + nnz: 1.0, + nnz_r: 1.0, + nnz_c: 1.0, + }, + RpqPlan::Label(meta) => HybridCost { + score: 0.0, + nnz: meta.nvals as f64, + nnz_r: meta.nonzero_rows as f64, + nnz_c: meta.nonzero_cols as f64, + }, + RpqPlan::Seq([a, b]) => { + let ca = costs(*a); + let cb = costs(*b); + let denom = ca.nnz_r.max(cb.nnz_c).max(1.0); + let op_cost = ca.nnz * cb.nnz / denom; + let score = ca.score + cb.score + op_cost; + let nnz_est = metaac_matmul_nnz(ca.nnz, cb.nnz, self.n); + HybridCost { + score, + nnz: nnz_est, + nnz_r: ca.nnz_r.min(self.n), + nnz_c: cb.nnz_c.min(self.n), + } + } + RpqPlan::Alt([a, b]) => { + let ca = costs(*a); + let cb = costs(*b); + let overlap = ca.nnz * cb.nnz / (self.n * self.n).max(1.0); + let nnz_est = (ca.nnz + cb.nnz - overlap).clamp(0.0, self.n * self.n); + let score = ca.score + cb.score + nnz_est; + let nnz_r_est = (ca.nnz_r + cb.nnz_r - ca.nnz_r * cb.nnz_r / self.n.max(1.0)) + .clamp(0.0, self.n); + let nnz_c_est = (ca.nnz_c + cb.nnz_c - ca.nnz_c * cb.nnz_c / self.n.max(1.0)) + .clamp(0.0, self.n); + HybridCost { + score, + nnz: nnz_est, + nnz_r: nnz_r_est, + nnz_c: nnz_c_est, + } + } + RpqPlan::Star([a]) => { + let ca = costs(*a); + let penalty = self.star_penalty * ca.nnz.max(1.0); + HybridCost { + score: ca.score + penalty, + nnz: self.n * self.n, + nnz_r: self.n, + nnz_c: self.n, + } + } + RpqPlan::LStar([a, b]) | RpqPlan::RStar([a, b]) => { + let ca = costs(*a); + let cb = costs(*b); + let denom = ca.nnz_r.max(cb.nnz_c).max(1.0); + let op_cost = self.lr_multiplier * ca.nnz * cb.nnz / denom; + let score = ca.score + cb.score + op_cost; + let nnz_est = self.lr_multiplier * ca.nnz * cb.nnz / (self.n * self.n).max(1.0); + HybridCost { + score, + nnz: nnz_est, + nnz_r: ca.nnz_r.min(self.n), + nnz_c: cb.nnz_c.min(self.n), + } + } + } + } +} + +#[derive(Clone, Debug)] +pub(super) struct MncCost { + score: f64, + nnz: f64, + nnz_r: f64, + nnz_c: f64, + row_counts: Option, + col_counts: Option, + row_extended: Option, + col_extended: Option, +} + +impl PartialEq for MncCost { + fn eq(&self, other: &Self) -> bool { + self.cmp(other).is_eq() + } +} +impl Eq for MncCost {} +impl PartialOrd for MncCost { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for MncCost { + fn cmp(&self, other: &Self) -> Ordering { + self.score + .total_cmp(&other.score) + .then(self.nnz.total_cmp(&other.nnz)) + .then(self.nnz_r.total_cmp(&other.nnz_r)) + .then(self.nnz_c.total_cmp(&other.nnz_c)) + } +} + +type MncMatmulKey = (usize, usize, usize, usize, Option, Option); + +pub(super) struct MncCostFn { + n: f64, + star_penalty: f64, + lr_multiplier: f64, + labels: HashMap, + vertices: HashMap, + dot_cache: HashMap<(usize, usize), f64>, + matmul_cache: HashMap, + scale_cache: HashMap<(usize, u64, u64), CountVector>, + add_cache: HashMap<(usize, usize, u64, u64), CountVector>, +} + +impl MncCostFn { + pub(super) fn new( + n: f64, + labels: HashMap, + vertices: HashMap, + ) -> Self { + Self { + n, + star_penalty: 50.0, + lr_multiplier: 5.0, + labels, + vertices, + dot_cache: HashMap::new(), + matmul_cache: HashMap::new(), + scale_cache: HashMap::new(), + add_cache: HashMap::new(), + } + } + + fn dot(&mut self, a: &CountVector, b: &CountVector) -> Option { + let key = (a.cache_key(), b.cache_key()); + if let Some(value) = self.dot_cache.get(&key) { + return Some(*value); + } + let value = a.dot(b)?; + self.dot_cache.insert(key, value); + Some(value) + } + + fn matmul_nnz(&mut self, a: &MncCost, b: &MncCost) -> Option { + let (ar, ac, br, bc) = ( + a.row_counts.as_ref()?, + a.col_counts.as_ref()?, + b.row_counts.as_ref()?, + b.col_counts.as_ref()?, + ); + let key = ( + ar.cache_key(), + ac.cache_key(), + br.cache_key(), + bc.cache_key(), + a.col_extended.as_ref().map(CountVector::cache_key), + b.row_extended.as_ref().map(CountVector::cache_key), + ); + if let Some(value) = self.matmul_cache.get(&key) { + return Some(*value); + } + let value = CountVector::mnc_matmul_nnz( + ar, + ac, + br, + bc, + a.col_extended.as_ref(), + b.row_extended.as_ref(), + )?; + self.matmul_cache.insert(key, value); + Some(value) + } + + fn scale(&mut self, vector: &CountVector, factor: f64) -> Option { + let key = (vector.cache_key(), factor.to_bits(), self.n.to_bits()); + if let Some(value) = self.scale_cache.get(&key) { + return Some(value.clone()); + } + let value = vector.scale(factor, self.n)?; + self.scale_cache.insert(key, value.clone()); + Some(value) + } + + fn add(&mut self, a: &CountVector, b: &CountVector, lambda: f64) -> Option { + let (x, y) = ( + a.cache_key().min(b.cache_key()), + a.cache_key().max(b.cache_key()), + ); + let key = (x, y, lambda.to_bits(), self.n.to_bits()); + if let Some(value) = self.add_cache.get(&key) { + return Some(value.clone()); + } + let value = a.mnc_add(b, lambda, self.n)?; + self.add_cache.insert(key, value.clone()); + Some(value) + } + + fn seq(&mut self, a: MncCost, b: MncCost) -> MncCost { + let op_cost = match (&a.col_counts, &b.row_counts) { + (Some(ac), Some(br)) => self.dot(ac, br), + _ => None, + } + .unwrap_or_else(|| a.nnz * b.nnz / self.n.max(1.0)); + let nnz = self + .matmul_nnz(&a, &b) + .unwrap_or_else(|| metaac_matmul_nnz(a.nnz, b.nnz, self.n)); + let row_counts = a + .row_counts + .as_ref() + .and_then(|v| self.scale(v, nnz / a.nnz.max(1.0))); + let col_counts = b + .col_counts + .as_ref() + .and_then(|v| self.scale(v, nnz / b.nnz.max(1.0))); + MncCost { + score: a.score + b.score + op_cost, + nnz, + nnz_r: row_counts + .as_ref() + .map_or(a.nnz_r.min(self.n), CountVector::nonzero_count), + nnz_c: col_counts + .as_ref() + .map_or(b.nnz_c.min(self.n), CountVector::nonzero_count), + row_counts, + col_counts, + row_extended: None, + col_extended: None, + } + } + + fn alt(&mut self, a: MncCost, b: MncCost) -> MncCost { + let overlap = a.nnz * b.nnz / (self.n * self.n).max(1.0); + let fallback_nnz = (a.nnz + b.nnz - overlap).clamp(0.0, self.n * self.n); + let fallback_nnz_r = + (a.nnz_r + b.nnz_r - a.nnz_r * b.nnz_r / self.n.max(1.0)).clamp(0.0, self.n); + let fallback_nnz_c = + (a.nnz_c + b.nnz_c - a.nnz_c * b.nnz_c / self.n.max(1.0)).clamp(0.0, self.n); + let denominator = (a.nnz * b.nnz).max(1.0); + let lambda_cols = match (&a.col_counts, &b.col_counts) { + (Some(ac), Some(bc)) => self.dot(ac, bc).unwrap_or(0.0) / denominator, + _ => 0.0, + } + .clamp(0.0, 1.0); + let lambda_rows = match (&a.row_counts, &b.row_counts) { + (Some(ar), Some(br)) => self.dot(ar, br).unwrap_or(0.0) / denominator, + _ => 0.0, + } + .clamp(0.0, 1.0); + let row_counts = match (&a.row_counts, &b.row_counts) { + (Some(ar), Some(br)) => self.add(ar, br, lambda_cols), + _ => None, + }; + let col_counts = match (&a.col_counts, &b.col_counts) { + (Some(ac), Some(bc)) => self.add(ac, bc, lambda_rows), + _ => None, + }; + let nnz = match (&row_counts, &col_counts) { + (Some(rows), Some(cols)) => (rows.sum() + cols.sum()) / 2.0, + (Some(rows), None) => rows.sum(), + (None, Some(cols)) => cols.sum(), + (None, None) => fallback_nnz, + } + .clamp(0.0, self.n * self.n); + MncCost { + score: a.score + b.score + nnz, + nnz, + nnz_r: row_counts + .as_ref() + .map_or(fallback_nnz_r, CountVector::nonzero_count), + nnz_c: col_counts + .as_ref() + .map_or(fallback_nnz_c, CountVector::nonzero_count), + row_counts, + col_counts, + row_extended: None, + col_extended: None, + } + } +} + +impl CostFunction for MncCostFn { + type Cost = MncCost; + + fn cost(&mut self, enode: &RpqPlan, mut costs: C) -> Self::Cost + where + C: FnMut(Id) -> Self::Cost, + { + match enode { + RpqPlan::NamedVertex(name) => { + let counts = self.vertices.get(name); + let row_counts = counts.map(|v| v.row_counts.clone()); + let col_counts = counts.map(|v| v.col_counts.clone()); + let row_extended = counts.and_then(|v| v.row_extended.clone()); + let col_extended = counts.and_then(|v| v.col_extended.clone()); + MncCost { + score: 0.0, + nnz: 1.0, + nnz_r: row_counts.as_ref().map_or(1.0, CountVector::nonzero_count), + nnz_c: col_counts.as_ref().map_or(1.0, CountVector::nonzero_count), + row_counts, + col_counts, + row_extended, + col_extended, + } + } + RpqPlan::Label(meta) => { + let counts = self.labels.get(&meta.name); + let row_counts = counts.map(|v| v.row_counts.clone()); + let col_counts = counts.map(|v| v.col_counts.clone()); + let row_extended = counts.and_then(|v| v.row_extended.clone()); + let col_extended = counts.and_then(|v| v.col_extended.clone()); + MncCost { + score: 0.0, + nnz: meta.nvals as f64, + nnz_r: row_counts + .as_ref() + .map_or(meta.nonzero_rows as f64, CountVector::nonzero_count), + nnz_c: col_counts + .as_ref() + .map_or(meta.nonzero_cols as f64, CountVector::nonzero_count), + row_counts, + col_counts, + row_extended, + col_extended, + } + } + RpqPlan::Seq([a, b]) => { + let ca = costs(*a); + let cb = costs(*b); + self.seq(ca, cb) + } + RpqPlan::Alt([a, b]) => { + let ca = costs(*a); + let cb = costs(*b); + self.alt(ca, cb) + } + RpqPlan::Star([a]) => { + let ca = costs(*a); + let penalty = self.star_penalty * ca.nnz.max(1.0); + MncCost { + score: ca.score + penalty, + nnz: self.n * self.n, + nnz_r: self.n, + nnz_c: self.n, + row_counts: None, + col_counts: None, + row_extended: None, + col_extended: None, + } + } + RpqPlan::LStar([a, b]) | RpqPlan::RStar([a, b]) => { + let ca = costs(*a); + let cb = costs(*b); + let denom = ca.nnz_r.max(cb.nnz_c).max(1.0); + let op_cost = self.lr_multiplier * ca.nnz * cb.nnz / denom; + let nnz_est = self.lr_multiplier * ca.nnz * cb.nnz / (self.n * self.n).max(1.0); + MncCost { + score: ca.score + cb.score + op_cost, + nnz: nnz_est, + nnz_r: ca.nnz_r.min(self.n), + nnz_c: cb.nnz_c.min(self.n), + row_counts: None, + col_counts: None, + row_extended: None, + col_extended: None, + } + } + } + } +} + +#[derive(Clone, Debug)] +struct Estimate { + score: f64, + nnz: f64, + rows: f64, + cols: f64, +} + +impl PartialEq for Estimate { + fn eq(&self, other: &Self) -> bool { + self.cmp(other).is_eq() + } +} + +impl Eq for Estimate {} + +impl PartialOrd for Estimate { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Estimate { + fn cmp(&self, other: &Self) -> Ordering { + self.score + .total_cmp(&other.score) + .then(self.nnz.total_cmp(&other.nnz)) + .then(self.rows.total_cmp(&other.rows)) + .then(self.cols.total_cmp(&other.cols)) + } +} + +#[derive(Clone, Debug)] +pub(super) struct PangHybridEstimate { + estimate: Estimate, + identity: bool, +} +impl PartialEq for PangHybridEstimate { + fn eq(&self, other: &Self) -> bool { + self.cmp(other).is_eq() + } +} +impl Eq for PangHybridEstimate {} +impl PartialOrd for PangHybridEstimate { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for PangHybridEstimate { + fn cmp(&self, other: &Self) -> Ordering { + self.estimate.cmp(&other.estimate) + } +} + +pub(super) struct PangHybridCostFn { + n: f64, +} + +impl PangHybridCostFn { + pub fn new(n: f64) -> Self { + Self { n } + } + fn label(&self, meta: &LabelMeta) -> PangHybridEstimate { + PangHybridEstimate { + estimate: Estimate { + score: 0.0, + nnz: meta.nvals as f64, + rows: meta.nonzero_rows as f64, + cols: meta.nonzero_cols as f64, + }, + identity: false, + } + } + fn vertex(&self) -> PangHybridEstimate { + PangHybridEstimate { + estimate: Estimate { + score: 0.0, + nnz: 1.0, + rows: 1.0, + cols: 1.0, + }, + identity: false, + } + } + fn product( + &self, + a: &PangHybridEstimate, + b: &PangHybridEstimate, + join_denominator: f64, + ) -> (PangHybridEstimate, f64) { + let work = a.estimate.nnz * b.estimate.nnz / a.estimate.rows.max(b.estimate.cols).max(1.0); + // Eq. 10 with an effective J. Seq passes Join's denominator; + // closure passes n to approximate the support intersection. + let join = a.estimate.cols * b.estimate.rows / join_denominator; + let (nnz, rows, cols) = if join > 0.0 { + let pairs = + join * (a.estimate.nnz / a.estimate.cols) * (b.estimate.nnz / b.estimate.rows); + let nnz = pairs + .min(a.estimate.rows * b.estimate.cols) + .min(self.n * self.n); + ( + nnz, + (a.estimate.rows * join / a.estimate.cols) + .min(a.estimate.rows) + .min(nnz), + (b.estimate.cols * join / b.estimate.rows) + .min(b.estimate.cols) + .min(nnz), + ) + } else { + (0.0, 0.0, 0.0) + }; + let mut out = PangHybridEstimate { + estimate: Estimate { + score: 0.0, + nnz, + rows, + cols, + }, + identity: false, + }; + if a.identity { + out = b.clone() + } else if b.identity { + out = a.clone() + } + out.estimate.score = 0.0; + (out, work) + } + fn alternate(&self, a: &PangHybridEstimate, b: &PangHybridEstimate) -> PangHybridEstimate { + if a.estimate.nnz == 0.0 { + return b.clone(); + } + if b.estimate.nnz == 0.0 { + return a.clone(); + } + if a.identity && b.identity { + let mut out = a.clone(); + out.estimate.score = 0.0; + return out; + } + let n = self.n; + let overlap = a.estimate.nnz * b.estimate.nnz / (n * n).max(1.0); + let estimate = Estimate { + score: 0.0, + nnz: (a.estimate.nnz + b.estimate.nnz - overlap).clamp(0.0, n * n), + rows: (a.estimate.rows + b.estimate.rows + - a.estimate.rows * b.estimate.rows / n.max(1.0)) + .clamp(0.0, n), + cols: (a.estimate.cols + b.estimate.cols + - a.estimate.cols * b.estimate.cols / n.max(1.0)) + .clamp(0.0, n), + }; + PangHybridEstimate { + estimate, + identity: false, + } + } + fn closure_steps(&self, body: &PangHybridEstimate, initial: f64) -> usize { + if body.estimate.nnz == 0.0 || initial == 0.0 { + return 1; + } + // Eq. 24 uses a separate support-intersection estimate. Using the + // Join-work proxy here would make growth >= 1 for every nonempty R. + let growth = body.estimate.nnz / self.n.max(1.0); + if growth == 0.0 { + return 1; + } + if growth >= 1.0 { + 6 + } else { + (-initial.max(1.0).ln() / growth.ln()) + .ceil() + .clamp(1.0, 64.0) as usize + } + } + fn closure( + &mut self, + body: PangHybridEstimate, + seed: PangHybridEstimate, + left: bool, + single: bool, + ) -> PangHybridEstimate { + if body.identity { + if single { + let mut out = body; + out.estimate.score += self.n; + return out; + } + let mut out = seed; + out.estimate.score += body.estimate.score + body.estimate.nnz + out.estimate.nnz; + return out; + } + let initial = if single { + seed.estimate.nnz + } else if left { + self.product(&body, &seed, self.n.max(1.0)).0.estimate.nnz + } else { + self.product(&seed, &body, self.n.max(1.0)).0.estimate.nnz + }; + let steps = self.closure_steps(&body, initial); + let mut result = seed.clone(); + let mut power = seed.clone(); + let mut score = body.estimate.score + if single { 0.0 } else { seed.estimate.score }; + if single || (body.estimate.nnz > 0.0 && seed.estimate.nnz > 0.0) { + let products = steps - usize::from(single); + for _ in 0..products { + let (product, work) = if left { + self.product(&body, &result, self.n.max(1.0)) + } else { + self.product(&result, &body, self.n.max(1.0)) + }; + let (next_power, _) = if left { + self.product(&body, &power, self.n.max(1.0)) + } else { + self.product(&power, &body, self.n.max(1.0)) + }; + let mut next = if next_power.estimate.nnz < 1.0 { + result.clone() + } else { + self.alternate(&result, &next_power) + }; + if single || !left { + next.estimate.rows = seed.estimate.rows; + } + if single || left { + next.estimate.cols = seed.estimate.cols; + } + next.estimate.nnz = next + .estimate + .nnz + .min(next.estimate.rows * next.estimate.cols); + score += work + result.estimate.nnz + product.estimate.nnz + next.estimate.nnz; + result = next; + power = next_power; + if power.estimate.nnz < 1.0 + || result.estimate.nnz >= result.estimate.rows * result.estimate.cols + { + break; + } + } + } + if single { + let plus = result.estimate.nnz; + result.estimate.nnz = (plus + self.n).min(self.n * self.n); + score += self.n + plus + result.estimate.nnz; + result.estimate.rows = self.n; + result.estimate.cols = self.n; + result.identity = body.estimate.nnz == 0.0 + } + result.estimate.score = score; + result + } +} + +impl CostFunction for PangHybridCostFn { + type Cost = PangHybridEstimate; + fn cost PangHybridEstimate>( + &mut self, + node: &RpqPlan, + mut costs: C, + ) -> PangHybridEstimate { + match node { + RpqPlan::Label(meta) => self.label(meta), + RpqPlan::NamedVertex(_) => self.vertex(), + RpqPlan::Seq([a, b]) => { + let (a, b) = (costs(*a), costs(*b)); + let denominator = a.estimate.rows.max(b.estimate.cols).max(1.0); + let (mut out, work) = self.product(&a, &b, denominator); + out.estimate.score = a.estimate.score + b.estimate.score + work; + out + } + RpqPlan::Alt([a, b]) => { + let (a, b) = (costs(*a), costs(*b)); + let mut out = self.alternate(&a, &b); + out.estimate.score = a.estimate.score + b.estimate.score + out.estimate.nnz; + out + } + RpqPlan::Star([a]) => { + let a = costs(*a); + self.closure(a.clone(), a, false, true) + } + RpqPlan::LStar([a, b]) => self.closure(costs(*a), costs(*b), true, false), + RpqPlan::RStar([a, b]) => self.closure(costs(*b), costs(*a), false, false), + } + } +} + +#[derive(Clone, Debug)] +pub(super) struct SamplingEstimate { + base: HybridCost, + sample: Option, +} +impl PartialEq for SamplingEstimate { + fn eq(&self, other: &Self) -> bool { + self.cmp(other).is_eq() + } +} +impl Eq for SamplingEstimate {} +impl PartialOrd for SamplingEstimate { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for SamplingEstimate { + fn cmp(&self, other: &Self) -> Ordering { + self.base.cmp(&other.base) + } +} + +pub(super) struct SamplingCostFn { + // Provides operation costs and a fallback when the sampled relation is unusable. + baseline: HybridCostFn, + sampler: MatrixSampler, + label_ids: HashMap, + vertices: HashMap, +} +impl SamplingCostFn { + pub fn new( + n: f64, + mut graphs: Vec<(String, Arc)>, + vertices: HashMap, + config: SamplingConfig, + ) -> Self { + graphs.sort_unstable_by(|a, b| a.0.cmp(&b.0)); + let label_ids = graphs + .iter() + .enumerate() + .map(|(id, (name, _))| (name.clone(), id)) + .collect(); + let matrices = graphs.iter().map(|(_, graph)| graph.matrix()).collect(); + let fixed_vertices = vertices.values().copied().collect::>(); + Self { + baseline: HybridCostFn { + n, + star_penalty: 50.0, + lr_multiplier: 5.0, + }, + sampler: MatrixSampler::new(n as usize, matrices, &fixed_vertices, config), + label_ids, + vertices, + } + } + + fn sampled( + &self, + mut base: HybridCost, + sample: Option, + output_charged: bool, + ) -> SamplingEstimate { + let old = base.nnz; + let estimate = self.sampler.estimate(sample.as_ref()); + if estimate.converged && (estimate.exact || estimate.nnz > 0.0) { + base.nnz = estimate.nnz.min(estimate.rows * estimate.cols); + base.nnz_r = estimate.rows.min(base.nnz); + base.nnz_c = estimate.cols.min(base.nnz); + if output_charged { + base.score = (base.score + base.nnz - old).max(0.0); + } + } + SamplingEstimate { base, sample } + } +} +impl CostFunction for SamplingCostFn { + type Cost = SamplingEstimate; + fn cost SamplingEstimate>( + &mut self, + node: &RpqPlan, + mut costs: C, + ) -> SamplingEstimate { + match node { + RpqPlan::Label(meta) => SamplingEstimate { + base: self.baseline.cost(node, |_| unreachable!()), + sample: self.sampler.label(self.label_ids[&meta.name]), + }, + RpqPlan::NamedVertex(name) => SamplingEstimate { + base: self.baseline.cost(node, |_| unreachable!()), + sample: self.sampler.vertex(self.vertices[name]), + }, + RpqPlan::Seq([a, b]) => { + let (left, right) = (costs(*a), costs(*b)); + let base = self.baseline.cost(node, |id| { + if id == *a { + left.base.clone() + } else { + right.base.clone() + } + }); + let sample = self + .sampler + .seq(left.sample.as_ref(), right.sample.as_ref()); + self.sampled(base, sample, false) + } + RpqPlan::Alt([a, b]) => { + let (left, right) = (costs(*a), costs(*b)); + let base = self.baseline.cost(node, |id| { + if id == *a { + left.base.clone() + } else { + right.base.clone() + } + }); + let sample = self + .sampler + .alt(left.sample.as_ref(), right.sample.as_ref()); + self.sampled(base, sample, true) + } + RpqPlan::LStar([a, b]) => { + let (left, right) = (costs(*a), costs(*b)); + let base = self.baseline.cost(node, |id| { + if id == *a { + left.base.clone() + } else { + right.base.clone() + } + }); + let sample = + self.sampler + .closure(left.sample.as_ref(), right.sample.as_ref(), true); + self.sampled(base, sample, false) + } + RpqPlan::RStar([a, b]) => { + let (left, right) = (costs(*a), costs(*b)); + let base = self.baseline.cost(node, |id| { + if id == *a { + left.base.clone() + } else { + right.base.clone() + } + }); + let sample = + self.sampler + .closure(right.sample.as_ref(), left.sample.as_ref(), false); + self.sampled(base, sample, false) + } + RpqPlan::Star([a]) => { + let child = costs(*a); + let base = self.baseline.cost(node, |_| child.base.clone()); + let sample = self.sampler.star(child.sample.as_ref()); + self.sampled(base, sample, false) + } + } + } +} + +#[cfg(test)] +mod tests { + use crate::rpq::rpqmatrix::plan::{LabelMeta, RpqPlan}; + use crate::{graph::GraphDecomposition, utils::build_graph}; + + use super::*; + + #[test] + fn sampling_estimates_composed_relations() { + let graph = build_graph(&[("a", "b", "p"), ("b", "c", "p"), ("c", "d", "q")]); + let p = RpqPlan::Label(LabelMeta { + name: "p".to_string(), + nvals: 2, + nonzero_rows: 2, + nonzero_cols: 2, + }); + let seq = RpqPlan::Seq([Id::from(0), Id::from(0)]); + let star = RpqPlan::Star([Id::from(0)]); + let q = RpqPlan::Label(LabelMeta { + name: "q".to_string(), + nvals: 1, + nonzero_rows: 1, + nonzero_cols: 1, + }); + let left_star = RpqPlan::LStar([Id::from(0), Id::from(1)]); + let right_star = RpqPlan::RStar([Id::from(1), Id::from(0)]); + let matrices = ["p", "q"] + .into_iter() + .map(|name| (name.to_string(), graph.get_graph(name).unwrap())) + .collect::>(); + let config = SamplingConfig { + percent: 100, + seed: 0, + max_star_iterations: 4, + }; + let mut sampled = SamplingCostFn::new(4.0, matrices, HashMap::new(), config); + let leaf = sampled.cost(&p, |_| unreachable!()); + assert_eq!(sampled.cost(&seq, |_| leaf.clone()).base.nnz, 1.0); + assert_eq!(sampled.cost(&star, |_| leaf.clone()).base.nnz, 7.0); + let q_leaf = sampled.cost(&q, |_| unreachable!()); + let alt = RpqPlan::Alt([Id::from(0), Id::from(1)]); + let union = sampled.cost(&alt, |id| { + if id == Id::from(0) { + leaf.clone() + } else { + q_leaf.clone() + } + }); + assert_eq!(union.base.nnz, 3.0); + assert_eq!(union.base.score, 3.0); + let child = |id| { + if id == Id::from(0) { + leaf.clone() + } else { + q_leaf.clone() + } + }; + assert_eq!(sampled.cost(&left_star, child).base.nnz, 3.0); + let child = |id| { + if id == Id::from(0) { + leaf.clone() + } else { + q_leaf.clone() + } + }; + assert_eq!(sampled.cost(&right_star, child).base.nnz, 1.0); + } + + #[test] + fn metaac_product_retains_small_estimates_on_large_graphs() { + let estimate = metaac_matmul_nnz(1_000.0, 1_000.0, 100_000_000.0); + assert!((estimate - 0.01).abs() < 1e-10); + } + + #[test] + fn pang_hybrid_star_steps_follow_growth() { + let model = PangHybridCostFn::new(4.0); + let sparse = PangHybridEstimate { + estimate: Estimate { + score: 0.0, + nnz: 2.0, + rows: 2.0, + cols: 2.0, + }, + identity: false, + }; + let dense = PangHybridEstimate { + estimate: Estimate { + score: 0.0, + nnz: 4.0, + rows: 2.0, + cols: 2.0, + }, + identity: false, + }; + assert_eq!(model.closure_steps(&sparse, sparse.estimate.nnz), 1); + assert_eq!(model.closure_steps(&dense, dense.estimate.nnz), 6); + } + + #[test] + fn pang_hybrid_identity_remains_consistent_through_union_and_star() { + let mut model = PangHybridCostFn::new(4.0); + let empty = PangHybridEstimate { + estimate: Estimate { + score: 0.0, + nnz: 0.0, + rows: 0.0, + cols: 0.0, + }, + identity: false, + }; + let identity = model.closure(empty.clone(), empty, false, true); + let union = model.alternate(&identity, &identity); + let nested_star = model.closure(identity.clone(), identity, false, true); + for result in [union, nested_star.clone()] { + assert!(result.identity); + assert_eq!(result.estimate.nnz, 4.0); + assert_eq!(result.estimate.rows, 4.0); + assert_eq!(result.estimate.cols, 4.0); + } + + let operand = PangHybridEstimate { + estimate: Estimate { + score: 1.0, + nnz: 2.0, + rows: 1.0, + cols: 2.0, + }, + identity: false, + }; + let left = model.closure(nested_star.clone(), operand.clone(), true, false); + let right = model.closure(nested_star, operand.clone(), false, false); + for result in [left, right] { + assert!(!result.identity); + assert_eq!(result.estimate.nnz, operand.estimate.nnz); + assert_eq!(result.estimate.rows, operand.estimate.rows); + assert_eq!(result.estimate.cols, operand.estimate.cols); + } + } + + #[test] + fn independent_costs_preserve_seq_estimates() { + let a = Id::from(0); + let b = Id::from(1); + let left = RpqPlan::Label(LabelMeta { + name: "left".to_string(), + nvals: 10, + nonzero_rows: 2, + nonzero_cols: 4, + }); + let right = RpqPlan::Label(LabelMeta { + name: "right".to_string(), + nvals: 20, + nonzero_rows: 5, + nonzero_cols: 6, + }); + let seq = RpqPlan::Seq([a, b]); + + let mut metaac = MetaAcCostFn { + n: 10.0, + star_penalty: 50.0, + lr_multiplier: 5.0, + }; + let ma = metaac.cost(&left, |_| unreachable!()); + let mb = metaac.cost(&right, |_| unreachable!()); + let meta_cost = metaac.cost(&seq, |id| if id == a { ma.clone() } else { mb.clone() }); + let expected_nnz = 100.0 * (1.0 - (1.0_f64 - 0.02).powf(10.0)); + assert!((meta_cost.nnz - expected_nnz).abs() < 1e-10); + assert_eq!(meta_cost.score, 20.0); + assert_eq!((meta_cost.nnz_r, meta_cost.nnz_c), (2.0, 6.0)); + + let mut hybrid = HybridCostFn { + n: 10.0, + star_penalty: 50.0, + lr_multiplier: 5.0, + }; + let ha = hybrid.cost(&left, |_| unreachable!()); + let hb = hybrid.cost(&right, |_| unreachable!()); + let hybrid_cost = hybrid.cost(&seq, |id| if id == a { ha.clone() } else { hb.clone() }); + assert!((hybrid_cost.nnz - expected_nnz).abs() < 1e-10); + assert!((hybrid_cost.score - 200.0 / 6.0).abs() < 1e-10); + + let mut mnc = MncCostFn::new(10.0, HashMap::new(), HashMap::new()); + let ca = mnc.cost(&left, |_| unreachable!()); + let cb = mnc.cost(&right, |_| unreachable!()); + let cost = mnc.cost(&seq, |id| if id == a { ca.clone() } else { cb.clone() }); + assert_eq!(cost.score, meta_cost.score); + assert!((cost.nnz - meta_cost.nnz).abs() < 1e-10); + assert_eq!((cost.nnz_r, cost.nnz_c), (2.0, 6.0)); + } + + #[test] + fn mnc_seq_uses_leaf_extensions_without_propagating_them() { + let graph = build_graph(&[ + ("u", "a", "A"), + ("v", "b", "A"), + ("v", "c", "A"), + ("a", "x", "B"), + ("b", "x", "B"), + ("c", "y", "B"), + ]); + let labels = ["A", "B"] + .into_iter() + .map(|name| { + ( + name.to_string(), + LabelCountVectors::from_matrix(graph.get_graph(name).unwrap().matrix()) + .unwrap(), + ) + }) + .collect(); + let mut mnc = MncCostFn::new(7.0, labels, HashMap::new()); + let a = mnc.cost( + &RpqPlan::Label(LabelMeta { + name: "A".to_string(), + nvals: 3, + nonzero_rows: 2, + nonzero_cols: 3, + }), + |_| unreachable!(), + ); + let b = mnc.cost( + &RpqPlan::Label(LabelMeta { + name: "B".to_string(), + nvals: 3, + nonzero_rows: 3, + nonzero_cols: 2, + }), + |_| unreachable!(), + ); + let result = mnc.seq(a, b); + assert_eq!(result.nnz, 3.0); + assert!(result.row_extended.is_none()); + assert!(result.col_extended.is_none()); + } + + fn assert_finite_nonnegative(cost: &JoinCost) { + assert!(cost.score.is_finite(), "score must be finite: {cost:?}"); + assert!(cost.nnz.is_finite(), "nnz must be finite: {cost:?}"); + assert!(cost.nnz_r.is_finite(), "nnz_r must be finite: {cost:?}"); + assert!(cost.nnz_c.is_finite(), "nnz_c must be finite: {cost:?}"); + + assert!(cost.score >= 0.0, "score must be non-negative: {cost:?}"); + assert!(cost.nnz >= 0.0, "nnz must be non-negative: {cost:?}"); + assert!(cost.nnz_r >= 0.0, "nnz_r must be non-negative: {cost:?}"); + assert!(cost.nnz_c >= 0.0, "nnz_c must be non-negative: {cost:?}"); + } + + fn child_cost(id: Id, a: Id, ca: &JoinCost, b: Id, cb: &JoinCost) -> JoinCost { + if id == a { + ca.clone() + } else if id == b { + cb.clone() + } else { + panic!("unexpected child id: {id:?}") + } + } + + fn unary_child_cost(id: Id, child: Id, cost: &JoinCost) -> JoinCost { + if id == child { + cost.clone() + } else { + panic!("unexpected child id: {id:?}") + } + } + + #[test] + fn card_cost_order_uses_score_then_nnz_then_rows_then_cols() { + let base = JoinCost { + score: 10.0, + nnz: 20.0, + nnz_r: 30.0, + nnz_c: 40.0, + }; + + assert!( + JoinCost { + score: 9.0, + nnz: 100.0, + nnz_r: 100.0, + nnz_c: 100.0, + } < base + ); + assert!( + JoinCost { + score: 10.0, + nnz: 19.0, + nnz_r: 100.0, + nnz_c: 100.0, + } < base + ); + assert!( + JoinCost { + score: 10.0, + nnz: 20.0, + nnz_r: 29.0, + nnz_c: 100.0, + } < base + ); + assert!( + JoinCost { + score: 10.0, + nnz: 20.0, + nnz_r: 30.0, + nnz_c: 39.0, + } < base + ); + } + + #[test] + fn join_cost_base_nodes_use_vertex_and_label_metadata() { + let mut cost_fn = JoinCostFn { + n: 100.0, + star_penalty: 50.0, + lr_multiplier: 5.0, + }; + + let named = cost_fn.cost(&RpqPlan::NamedVertex("A".to_string()), |_| { + panic!("NamedVertex must not request child costs") + }); + assert_eq!( + named, + JoinCost { + score: 0.0, + nnz: 1.0, + nnz_r: 1.0, + nnz_c: 1.0, + } + ); + + let label = cost_fn.cost( + &RpqPlan::Label(LabelMeta { + name: "knows".to_string(), + nvals: 17, + nonzero_rows: 5, + nonzero_cols: 9, + }), + |_| panic!("Label must not request child costs"), + ); + assert_eq!( + label, + JoinCost { + score: 0.0, + nnz: 17.0, + nnz_r: 5.0, + nnz_c: 9.0, + } + ); + } + #[test] + fn join_cost_seq_correctness() { + let mut cost_fn = JoinCostFn { + n: 100.0, + star_penalty: 50.0, + lr_multiplier: 5.0, + }; + let a = Id::from(0); + let b = Id::from(1); + + let ca = JoinCost { + score: 2.0, + nnz: 20.0, + nnz_r: 4.0, + nnz_c: 8.0, + }; + + let cb = JoinCost { + score: 3.0, + nnz: 30.0, + nnz_r: 6.0, + nnz_c: 10.0, + }; + + let seq = cost_fn.cost(&RpqPlan::Seq([a, b]), |id| { + if id == a { + ca.clone() + } else if id == b { + cb.clone() + } else { + panic!("unexpected child id: {id:?}") + } + }); + assert_eq!( + seq, + JoinCost { + score: 65.0, + nnz: 6.0, + nnz_r: 4.0, + nnz_c: 10.0, + } + ); + } + #[test] + fn join_cost_seq_correctness_zero_denom() { + let mut cost_fn = JoinCostFn { + n: 100.0, + star_penalty: 50.0, + lr_multiplier: 5.0, + }; + let a = Id::from(0); + let b = Id::from(1); + + let ca = JoinCost { + score: 2.0, + nnz: 20.0, + nnz_r: 0.0, + nnz_c: 4.0, + }; + + let cb = JoinCost { + score: 3.0, + nnz: 30.0, + nnz_r: 4.0, + nnz_c: 0.0, + }; + + let seq = cost_fn.cost(&RpqPlan::Seq([a, b]), |id| { + if id == a { + ca.clone() + } else if id == b { + cb.clone() + } else { + panic!("unexpected child id: {id:?}") + } + }); + assert_eq!( + seq, + JoinCost { + score: 605.0, + nnz: 6.0, + nnz_r: 0.0, + nnz_c: 0.0, + } + ); + } + #[test] + fn join_cost_alt_with_zero_children_stays_finite_nonnegative() { + let mut cost_fn = JoinCostFn { + n: 100.0, + star_penalty: 50.0, + lr_multiplier: 5.0, + }; + let a = Id::from(0); + let b = Id::from(1); + let ca = JoinCost { + score: 0.0, + nnz: 0.0, + nnz_r: 0.0, + nnz_c: 0.0, + }; + let cb = JoinCost { + score: 3.0, + nnz: 30.0, + nnz_r: 0.0, + nnz_c: 10.0, + }; + + let alt = cost_fn.cost(&RpqPlan::Alt([a, b]), |id| child_cost(id, a, &ca, b, &cb)); + + assert_finite_nonnegative(&alt); + assert_eq!( + alt, + JoinCost { + score: 33.0, + nnz: 30.0, + nnz_r: 0.0, + nnz_c: 10.0, + } + ); + } + + #[test] + fn join_cost_star_with_zero_nnz_uses_min_penalty() { + let mut cost_fn = JoinCostFn { + n: 100.0, + star_penalty: 50.0, + lr_multiplier: 5.0, + }; + let a = Id::from(0); + let ca = JoinCost { + score: 7.0, + nnz: 0.0, + nnz_r: 0.0, + nnz_c: 0.0, + }; + + let star = cost_fn.cost(&RpqPlan::Star([a]), |id| unary_child_cost(id, a, &ca)); + + assert_finite_nonnegative(&star); + assert_eq!( + star, + JoinCost { + score: 57.0, + nnz: 10_000.0, + nnz_r: 100.0, + nnz_c: 100.0, + } + ); + } + + #[test] + fn join_cost_lstar_and_rstar_zero_denom_stay_finite_nonnegative() { + let mut cost_fn = JoinCostFn { + n: 100.0, + star_penalty: 50.0, + lr_multiplier: 5.0, + }; + let a = Id::from(0); + let b = Id::from(1); + let ca = JoinCost { + score: 2.0, + nnz: 20.0, + nnz_r: 0.0, + nnz_c: 4.0, + }; + let cb = JoinCost { + score: 3.0, + nnz: 30.0, + nnz_r: 4.0, + nnz_c: 0.0, + }; + + let lstar = cost_fn.cost(&RpqPlan::LStar([a, b]), |id| child_cost(id, a, &ca, b, &cb)); + let rstar = cost_fn.cost(&RpqPlan::RStar([a, b]), |id| child_cost(id, a, &ca, b, &cb)); + + assert_finite_nonnegative(&lstar); + assert_finite_nonnegative(&rstar); + let expected = JoinCost { + score: 3005.0, + nnz: 0.3, + nnz_r: 0.0, + nnz_c: 0.0, + }; + + assert_eq!(lstar, expected); + assert_eq!(rstar, expected); + } + //TODO: maybe cover other rules +} diff --git a/pathrex/src/rpq/rpqmatrix/eval.rs b/pathrex/src/rpq/rpqmatrix/eval.rs new file mode 100644 index 0000000..4928650 --- /dev/null +++ b/pathrex/src/rpq/rpqmatrix/eval.rs @@ -0,0 +1,191 @@ +use super::expr::{materialize_with_storage, query_to_expr}; +use super::optimize::{EGraphOptimizer, OptimizationStrategy, RpqOptimizer}; +use super::result::{PreparedRpqMatrix, RpqMatrixResult}; +/// RPQ evaluator backed by `LAGraph_RPQMatrix`. +use crate::eval::Evaluator; +use crate::graph::{GraphDecomposition, MatrixStorage, set_global_matrix_storage_hint}; +use crate::rpq::{Endpoint, RpqError, RpqQuery}; +use std::sync::Arc; + +#[derive(Clone)] +pub struct RpqMatrixEvaluator { + optimizer: Arc, +} + +impl RpqMatrixEvaluator { + pub fn unoptimized() -> Self { + Self::optimized(OptimizationStrategy::NoOpt) + } + pub fn optimized(opt: OptimizationStrategy) -> Self { + RpqMatrixEvaluator { + optimizer: Arc::new(EGraphOptimizer::new(opt)), + } + } +} + +impl Default for RpqMatrixEvaluator { + fn default() -> Self { + RpqMatrixEvaluator::unoptimized() + } +} + +fn storage_for_query(query: &RpqQuery) -> MatrixStorage { + match (&query.subject, &query.object) { + (Endpoint::Variable(_), Endpoint::Named(_)) => MatrixStorage::Csc, + _ => MatrixStorage::Csr, + } +} + +impl Evaluator for RpqMatrixEvaluator { + type Query = RpqQuery; + type Result = RpqMatrixResult; + type Error = RpqError; + type Prepared = PreparedRpqMatrix; + + fn prepare( + &self, + query: &RpqQuery, + graph: &G, + ) -> Result { + let storage = storage_for_query(query); + set_global_matrix_storage_hint(storage)?; + + let expr = query_to_expr(query, graph)?; + let expr = self.optimizer.optimize(expr, graph)?; + + let (plans, owned_matrices) = materialize_with_storage(&expr, graph, storage)?; + + Ok(PreparedRpqMatrix { + plans, + owned_matrices, + storage, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::formats::MatrixMarket; + use crate::graph::{Graph, InMemory, MatrixStatsMode}; + use crate::rpq::{Endpoint, PathExpr, RpqQuery}; + use crate::utils::build_graph; + + #[test] + fn optimizer_specific_matrix_statistics_preserve_results() { + let query = RpqQuery { + subject: Endpoint::Variable("x".into()), + path: PathExpr::ZeroOrMore(Box::new(PathExpr::Label("knows".into()))), + object: Endpoint::Variable("y".into()), + }; + for (mode, optimizer) in [ + (MatrixStatsMode::None, OptimizationStrategy::PangHybrid), + (MatrixStatsMode::None, OptimizationStrategy::Sampling), + (MatrixStatsMode::Extended, OptimizationStrategy::Mnc), + ] { + let graph = Graph::::try_from( + MatrixMarket::from_dir("tests/testdata/mm_small").with_matrix_stats(mode), + ) + .unwrap(); + let expected = RpqMatrixEvaluator::unoptimized() + .evaluate(&query, &graph) + .unwrap() + .nnz; + let actual = RpqMatrixEvaluator::optimized(optimizer) + .evaluate(&query, &graph) + .unwrap() + .nnz; + assert_eq!(actual, expected, "{optimizer:?}"); + } + } + + #[test] + fn evaluate_single_edge_nnz() { + let graph = build_graph(&[("A", "B", "p")]); + let q = RpqQuery { + subject: Endpoint::Variable("x".into()), + path: PathExpr::Label("p".into()), + object: Endpoint::Variable("y".into()), + }; + let result = RpqMatrixEvaluator::default() + .evaluate(&q, &graph) + .expect("evaluate"); + assert_eq!(result.nnz, 1); + } + + #[test] + fn evaluate_named_subject_no_match_nnz() { + // Graph: A --p--> B + // Query: p ?y -> C has no outgoing p edges, nnz=0 + let graph = build_graph(&[("A", "B", "p"), ("C", "D", "q")]); + let q = RpqQuery { + subject: Endpoint::Named("C".into()), + path: PathExpr::Label("p".into()), + object: Endpoint::Variable("y".into()), + }; + let result = RpqMatrixEvaluator::default() + .evaluate(&q, &graph) + .expect("evaluate"); + assert_eq!(result.nnz, 0, "C has no outgoing p edges"); + } + + #[test] + fn optimizers_preserve_results() { + let graph = build_graph(&[ + ("A", "B", "p"), + ("B", "C", "p"), + ("B", "C", "q"), + ("A", "D", "q"), + ]); + let queries = [ + ( + RpqQuery { + subject: Endpoint::Named("A".into()), + path: PathExpr::Sequence( + Box::new(PathExpr::Label("p".into())), + Box::new(PathExpr::Label("q".into())), + ), + object: Endpoint::Variable("y".into()), + }, + 1, + ), + ( + RpqQuery { + subject: Endpoint::Named("A".into()), + path: PathExpr::ZeroOrMore(Box::new(PathExpr::Label("p".into()))), + object: Endpoint::Variable("y".into()), + }, + 3, + ), + ( + RpqQuery { + subject: Endpoint::Variable("x".into()), + path: PathExpr::ZeroOrMore(Box::new(PathExpr::Label("p".into()))), + object: Endpoint::Named("C".into()), + }, + 3, + ), + ]; + + for optimizer in [ + OptimizationStrategy::MetaAc, + OptimizationStrategy::Mnc, + OptimizationStrategy::Hybrid, + OptimizationStrategy::PangHybrid, + OptimizationStrategy::Sampling, + ] { + let evaluator = RpqMatrixEvaluator::optimized(optimizer); + for (query, expected) in &queries { + for _ in 0..2 { + let result = evaluator + .evaluate(query, &graph) + .expect("optimized evaluation"); + assert_eq!( + result.nnz, *expected, + "optimizer={optimizer:?}, query={query:?}" + ); + } + } + } + } +} diff --git a/pathrex/src/rpq/rpqmatrix.rs b/pathrex/src/rpq/rpqmatrix/expr.rs similarity index 52% rename from pathrex/src/rpq/rpqmatrix.rs rename to pathrex/src/rpq/rpqmatrix/expr.rs index 1462abc..ff4ee02 100644 --- a/pathrex/src/rpq/rpqmatrix.rs +++ b/pathrex/src/rpq/rpqmatrix/expr.rs @@ -1,50 +1,60 @@ -//! Plan-based RPQ evaluation using `LAGraph_RPQMatrix`. - use std::ptr::null_mut; -use egg::{Id, RecExpr, define_language}; +use egg::{Id, RecExpr}; -use crate::eval::{Evaluator, PreparedEvaluator, ResultCount}; -use crate::graph::{GraphDecomposition, GraphError, GraphblasMatrix}; +use super::plan::{LabelMeta, RpqPlan}; +use crate::graph::{GraphDecomposition, MatrixStorage}; +use crate::grb_ok; use crate::lagraph_sys::*; use crate::rpq::{Endpoint, PathExpr, RpqError, RpqQuery}; -use crate::{grb_ok, la_ok}; - -const RPQMATRIX_REDUCE_BY_COL: u8 = 1; -define_language! { - pub enum RpqPlan { - Label(String), - NamedVertex(String), - "/" = Seq([Id; 2]), - "|" = Alt([Id; 2]), - "*" = Star([Id; 1]), +fn label_meta(label: &str, graph: &G) -> Result { + if let Some(metadata) = graph.get_metadata().and_then(|m| m.matrix(label)) { + return Ok(LabelMeta { + name: label.to_owned(), + nvals: metadata.nvals, + nonzero_rows: metadata.nonzero_rows, + nonzero_cols: metadata.nonzero_cols, + }); } + + let lg = graph.get_graph(label)?; + let nvals = lg.nvals()? as usize; + Ok(LabelMeta { + name: label.to_owned(), + nvals, + nonzero_rows: lg.nonzero_rows()?, + nonzero_cols: lg.nonzero_cols()?, + }) } -fn to_expr_aux(path: &PathExpr, expr: &mut RecExpr) -> Result { +fn to_expr_aux( + path: &PathExpr, + expr: &mut RecExpr, + graph: &G, +) -> Result { match path { - PathExpr::Label(label) => Ok(expr.add(RpqPlan::Label(label.clone()))), + PathExpr::Label(label) => Ok(expr.add(RpqPlan::Label(label_meta(label, graph)?))), PathExpr::Sequence(lhs, rhs) => { - let l = to_expr_aux(lhs, expr)?; - let r = to_expr_aux(rhs, expr)?; + let l = to_expr_aux(lhs, expr, graph)?; + let r = to_expr_aux(rhs, expr, graph)?; Ok(expr.add(RpqPlan::Seq([l, r]))) } PathExpr::Alternative(lhs, rhs) => { - let l = to_expr_aux(lhs, expr)?; - let r = to_expr_aux(rhs, expr)?; + let l = to_expr_aux(lhs, expr, graph)?; + let r = to_expr_aux(rhs, expr, graph)?; Ok(expr.add(RpqPlan::Alt([l, r]))) } PathExpr::ZeroOrMore(inner) => { - let i = to_expr_aux(inner, expr)?; + let i = to_expr_aux(inner, expr, graph)?; Ok(expr.add(RpqPlan::Star([i]))) } PathExpr::OneOrMore(inner) => { - let e = to_expr_aux(inner, expr)?; + let e = to_expr_aux(inner, expr, graph)?; let s = expr.add(RpqPlan::Star([e])); Ok(expr.add(RpqPlan::Seq([e, s]))) } @@ -57,9 +67,12 @@ fn to_expr_aux(path: &PathExpr, expr: &mut RecExpr) -> Result`]. -pub fn query_to_expr(query: &RpqQuery) -> Result, RpqError> { +pub fn query_to_expr( + query: &RpqQuery, + graph: &G, +) -> Result, RpqError> { let mut expr = RecExpr::default(); - let path_root = to_expr_aux(&query.path, &mut expr)?; + let path_root = to_expr_aux(&query.path, &mut expr, graph)?; let _root = match (&query.subject, &query.object) { (Endpoint::Variable(_), Endpoint::Variable(_)) => path_root, @@ -87,9 +100,10 @@ pub fn query_to_expr(query: &RpqQuery) -> Result, RpqError> { /// /// Returns the plan array and a list of owned diagonal matrices that must be /// freed after evaluation. -pub fn materialize( +pub fn materialize_with_storage( expr: &RecExpr, graph: &G, + storage: MatrixStorage, ) -> Result<(Vec, Vec), RpqError> { let null_plan = RPQMatrixPlan { op: RPQMatrixOp::RPQ_MATRIX_OP_LABEL, @@ -105,7 +119,7 @@ pub fn materialize( for (id, node) in expr.as_ref().iter().enumerate() { plans[id] = match node { RpqPlan::Label(label) => { - let lg = graph.get_graph(label)?; + let lg = graph.get_graph_with_storage(&label.name, storage)?; let mat = unsafe { (*lg.inner).A }; RPQMatrixPlan { op: RPQMatrixOp::RPQ_MATRIX_OP_LABEL, @@ -164,135 +178,39 @@ pub fn materialize( mat: null_mut(), res_mat: null_mut(), }, - }; - } - - Ok((plans, owned_matrices)) -} - -/// Output of [`RpqMatrixEvaluator`]: full path relation matrix and its nnz. -#[derive(Debug)] -pub struct RpqMatrixResult { - pub nnz: u64, - pub matrix: GraphblasMatrix, -} - -impl RpqMatrixResult { - /// Count distinct reachable target vertices by reducing the path relation - /// matrix to its non-empty columns. - pub fn reachable_target_count(&self) -> Result { - let mut count: GrB_Index = 0; - unsafe { - grb_ok!(LAGraph_RPQMatrix_reduce( - &mut count, - self.matrix.inner, - RPQMATRIX_REDUCE_BY_COL, - ))? - }; - Ok(count as u64) - } -} -impl ResultCount for RpqMatrixResult { - fn result_count(&self) -> Result { - Ok(self.reachable_target_count()? as usize) - } -} - -pub struct PreparedRpqMatrix { - plans: Vec, - owned_matrices: Vec, -} - -impl PreparedEvaluator for PreparedRpqMatrix { - type Result = RpqMatrixResult; - type Error = RpqError; - - fn execute(&mut self) -> Result { - let root_ptr = unsafe { self.plans.as_mut_ptr().add(self.plans.len() - 1) }; - - let mut nnz: GrB_Index = 0; - unsafe { la_ok!(LAGraph_RPQMatrix(&mut nnz, root_ptr))? }; + RpqPlan::LStar([l, r]) => RPQMatrixPlan { + op: RPQMatrixOp::RPQ_MATRIX_OP_KLEENE_L, + lhs: unsafe { plans.as_mut_ptr().add(usize::from(*l)) }, + rhs: unsafe { plans.as_mut_ptr().add(usize::from(*r)) }, + mat: null_mut(), + res_mat: null_mut(), + }, - let mut matrix_inner: GrB_Matrix = null_mut(); - unsafe { grb_ok!(GrB_Matrix_dup(&mut matrix_inner, (*root_ptr).res_mat))? }; - let matrix = GraphblasMatrix { - inner: matrix_inner, + RpqPlan::RStar([l, r]) => RPQMatrixPlan { + op: RPQMatrixOp::RPQ_MATRIX_OP_KLEENE_R, + lhs: unsafe { plans.as_mut_ptr().add(usize::from(*l)) }, + rhs: unsafe { plans.as_mut_ptr().add(usize::from(*r)) }, + mat: null_mut(), + res_mat: null_mut(), + }, }; - - unsafe { grb_ok!(LAGraph_DestroyRpqMatrixPlan(root_ptr))? }; - - Ok(RpqMatrixResult { - nnz: nnz as u64, - matrix, - }) } -} -impl Drop for PreparedRpqMatrix { - fn drop(&mut self) { - for mat in &mut self.owned_matrices { - unsafe { - LAGraph_RPQMatrix_Free(mat); - } - } - } -} - -/// RPQ evaluator backed by `LAGraph_RPQMatrix`. -#[derive(Clone, Copy)] -pub struct RpqMatrixEvaluator; - -impl Evaluator for RpqMatrixEvaluator { - type Query = RpqQuery; - type Result = RpqMatrixResult; - type Error = RpqError; - type Prepared = PreparedRpqMatrix; - - fn prepare( - &self, - query: &RpqQuery, - graph: &G, - ) -> Result { - let expr = query_to_expr(query)?; - let (plans, owned_matrices) = materialize(&expr, graph)?; - - Ok(PreparedRpqMatrix { - plans, - owned_matrices, - }) - } + Ok((plans, owned_matrices)) } #[cfg(test)] mod tests { use super::*; - use crate::rpq::{Endpoint, PathExpr, RpqQuery}; use crate::utils::build_graph; #[test] - fn evaluate_single_edge_nnz() { - let graph = build_graph(&[("A", "B", "p")]); - let q = RpqQuery { - subject: Endpoint::Variable("x".into()), - path: PathExpr::Label("p".into()), - object: Endpoint::Variable("y".into()), - }; - let result = RpqMatrixEvaluator.evaluate(&q, &graph).expect("evaluate"); - assert_eq!(result.nnz, 1); - } - - #[test] - fn evaluate_named_subject_no_match_nnz() { - // Graph: A --p--> B - // Query: p ?y -> C has no outgoing p edges, nnz=0 - let graph = build_graph(&[("A", "B", "p"), ("C", "D", "q")]); - let q = RpqQuery { - subject: Endpoint::Named("C".into()), - path: PathExpr::Label("p".into()), - object: Endpoint::Variable("y".into()), - }; - let result = RpqMatrixEvaluator.evaluate(&q, &graph).expect("evaluate"); - assert_eq!(result.nnz, 0, "C has no outgoing p edges"); + fn labels_without_metadata_use_actual_nonempty_rows_and_columns() { + let graph = build_graph(&[("a", "b", "p"), ("a", "c", "p"), ("d", "e", "p")]); + let meta = label_meta("p", &graph).unwrap(); + assert_eq!(meta.nvals, 3); + assert_eq!(meta.nonzero_rows, 2); + assert_eq!(meta.nonzero_cols, 3); } } diff --git a/pathrex/src/rpq/rpqmatrix/mod.rs b/pathrex/src/rpq/rpqmatrix/mod.rs new file mode 100644 index 0000000..93c363d --- /dev/null +++ b/pathrex/src/rpq/rpqmatrix/mod.rs @@ -0,0 +1,14 @@ +//! Plan-based RPQ evaluation using `LAGraph_RPQMatrix`. + +mod cost; +pub mod eval; +mod expr; +mod optimize; +mod plan; +pub mod result; +mod sampling; +pub(crate) mod stats; + +pub use eval::RpqMatrixEvaluator; +pub use optimize::OptimizationStrategy; +pub use result::RpqMatrixResult; diff --git a/pathrex/src/rpq/rpqmatrix/optimize.rs b/pathrex/src/rpq/rpqmatrix/optimize.rs new file mode 100644 index 0000000..51d9996 --- /dev/null +++ b/pathrex/src/rpq/rpqmatrix/optimize.rs @@ -0,0 +1,453 @@ +use egg::{Extractor, RecExpr, Runner}; +use std::{ + collections::{HashMap, HashSet}, + sync::{Arc, LazyLock, Mutex, Weak}, +}; + +use super::cost::{ + HybridCostFn, JoinCostFn, MetaAcCostFn, MncCostFn, PangHybridCostFn, SamplingCostFn, +}; +use super::plan::{RpqPlan, make_rules}; +use super::sampling::SamplingConfig; +use super::stats::LabelCountVectors; +use crate::{ + graph::{GraphDecomposition, LagraphGraph}, + rpq::RpqError, +}; + +static RULES: LazyLock>> = LazyLock::new(make_rules); + +#[derive(Clone, Copy, Debug)] +pub enum OptimizationStrategy { + NoOpt, + Join, + MetaAc, + Mnc, + Hybrid, + PangHybrid, + Sampling, + RandomOpt, // TODO: should be same as random from la-n-egg-rpq: https://github.com/SparseLinearAlgebra/la-n-egg-rpq/blob/main/src/main.rs#L75 + Simple, // TODO + Wander, // TODO +} + +/// Common interface for selecting an expression before plan materialization. +/// +/// Graph access stays generic; no dynamic dispatch is required. +pub(super) trait RpqOptimizer { + fn optimize( + &self, + expr: RecExpr, + graph: &G, + ) -> Result, RpqError>; +} + +/// E-graph optimization strategies and their reusable graph statistics. +pub(super) struct EGraphOptimizer { + strategy: OptimizationStrategy, + cache: Mutex, +} + +impl EGraphOptimizer { + pub(super) fn new(strategy: OptimizationStrategy) -> Self { + Self { + strategy, + cache: Mutex::default(), + } + } + + fn runner(expr: &RecExpr) -> Runner { + Runner::default() + .with_explanations_disabled() + .with_expr(expr) + .run(&*RULES) + } + + fn extract_with>( + expr: &RecExpr, + cost: C, + ) -> RecExpr { + let runner = Self::runner(expr); + Extractor::new(&runner.egraph, cost) + .find_best(runner.roots[0]) + .1 + } + + fn optimize_expr_join(&self, expr: RecExpr, graph_size: usize) -> RecExpr { + Self::extract_with( + &expr, + JoinCostFn { + n: graph_size as f64, + star_penalty: 50.0, + lr_multiplier: 5.0, + }, + ) + } + + fn optimize_expr_metaac(&self, expr: RecExpr, n: usize) -> RecExpr { + Self::extract_with( + &expr, + MetaAcCostFn { + n: n as f64, + star_penalty: 50.0, + lr_multiplier: 5.0, + }, + ) + } + + fn optimize_expr_mnc( + &self, + expr: RecExpr, + graph: &G, + ) -> Result, RpqError> { + let labels = cached_label_data(&labels(&expr), graph, &self.cache)?; + let vertices = vertices(&expr, graph)?; + let vertex_counts = vertex_counts(&vertices, graph.num_nodes(), &self.cache)?; + Ok(Self::extract_with( + &expr, + MncCostFn::new(graph.num_nodes() as f64, labels, vertex_counts), + )) + } + + fn optimize_expr_hybrid(&self, expr: RecExpr, n: usize) -> RecExpr { + Self::extract_with( + &expr, + HybridCostFn { + n: n as f64, + star_penalty: 50.0, + lr_multiplier: 5.0, + }, + ) + } + + fn optimize_expr_pang_hybrid(&self, expr: RecExpr, n: usize) -> RecExpr { + Self::extract_with(&expr, PangHybridCostFn::new(n as f64)) + } + + fn optimize_expr_sampling( + &self, + expr: RecExpr, + graph: &G, + ) -> Result, RpqError> { + let names = labels(&expr); + let matrices = names + .iter() + .map(|name| Ok((name.clone(), graph.get_graph(name)?))) + .collect::, RpqError>>()?; + let vertices = vertices(&expr, graph)?; + let parse = |name: &str, default: usize| { + std::env::var(name) + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(default) + }; + let config = SamplingConfig { + percent: parse("RPQ_SAMPLE_PERCENT", 1).clamp(1, 100), + seed: std::env::var("RPQ_SAMPLE_SEED") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(0), + max_star_iterations: parse("RPQ_SAMPLE_MAX_STAR_ITERS", 64), + }; + Ok(Self::extract_with( + &expr, + SamplingCostFn::new(graph.num_nodes() as f64, matrices, vertices, config), + )) + } +} + +impl RpqOptimizer for EGraphOptimizer { + fn optimize( + &self, + expr: RecExpr, + graph: &G, + ) -> Result, RpqError> { + match self.strategy { + OptimizationStrategy::NoOpt => Ok(expr), + OptimizationStrategy::Join => Ok(self.optimize_expr_join(expr, graph.num_nodes())), + OptimizationStrategy::MetaAc => Ok(self.optimize_expr_metaac(expr, graph.num_nodes())), + OptimizationStrategy::Mnc => self.optimize_expr_mnc(expr, graph), + OptimizationStrategy::Hybrid => Ok(self.optimize_expr_hybrid(expr, graph.num_nodes())), + OptimizationStrategy::PangHybrid => { + Ok(self.optimize_expr_pang_hybrid(expr, graph.num_nodes())) + } + OptimizationStrategy::Sampling => self.optimize_expr_sampling(expr, graph), + OptimizationStrategy::RandomOpt + | OptimizationStrategy::Simple + | OptimizationStrategy::Wander => Err(RpqError::UnsupportedPath(format!( + "optimizer {:?} is not implemented", + self.strategy + ))), + } + } +} + +struct CachedLabel { + graph: Weak, + counts: LabelCountVectors, +} + +struct CachedVertex { + vertex: usize, + n: usize, + counts: LabelCountVectors, +} + +#[derive(Default)] +struct OptimizerCache { + labels: HashMap, + vertices: HashMap, +} + +fn cached_label_data( + names: &HashSet, + graph: &G, + cache: &Mutex, +) -> Result, RpqError> { + let mut cache = cache.lock().expect("RPQ optimizer cache poisoned"); + let mut counts = HashMap::with_capacity(names.len()); + for name in names { + let label_graph = graph.get_graph(name)?; + let matrix = label_graph.matrix(); + let stale = cache + .labels + .get(name) + .and_then(|entry| entry.graph.upgrade()) + .is_none_or(|cached| !Arc::ptr_eq(&cached, &label_graph)); + if stale { + let precomputed = graph + .get_metadata() + .and_then(|metadata| metadata.matrix(name)) + .and_then(|metadata| metadata.counts.as_ref()); + let mut vectors = match precomputed { + Some(vectors) => vectors.clone(), + None => LabelCountVectors::from_matrix(matrix).ok_or_else(|| { + RpqError::UnsupportedPath("unable to build count vectors".into()) + })?, + }; + vectors.ensure_extended(matrix).ok_or_else(|| { + RpqError::UnsupportedPath("unable to build extended count vectors".into()) + })?; + cache.labels.insert( + name.clone(), + CachedLabel { + graph: Arc::downgrade(&label_graph), + counts: vectors, + }, + ); + } + let entry = cache.labels.get(name).expect("cached label was inserted"); + counts.insert(name.clone(), entry.counts.clone()); + } + Ok(counts) +} + +fn vertex_counts( + vertices: &HashMap, + n: usize, + cache: &Mutex, +) -> Result, RpqError> { + let mut cache = cache.lock().expect("RPQ optimizer cache poisoned"); + vertices + .iter() + .map(|(name, &vertex)| { + let stale = cache + .vertices + .get(name) + .is_none_or(|entry| entry.vertex != vertex || entry.n != n); + if stale { + let counts = LabelCountVectors::from_vertex(vertex, n).ok_or_else(|| { + RpqError::UnsupportedPath("unable to build endpoint statistics".into()) + })?; + cache + .vertices + .insert(name.clone(), CachedVertex { vertex, n, counts }); + } + Ok(( + name.clone(), + cache + .vertices + .get(name) + .expect("vertex was cached") + .counts + .clone(), + )) + }) + .collect() +} + +fn labels(expr: &RecExpr) -> HashSet { + expr.as_ref() + .iter() + .filter_map(|node| match node { + RpqPlan::Label(meta) => Some(meta.name.clone()), + _ => None, + }) + .collect() +} + +fn vertices( + expr: &RecExpr, + graph: &G, +) -> Result, RpqError> { + expr.as_ref() + .iter() + .filter_map(|node| match node { + RpqPlan::NamedVertex(name) => Some(name), + _ => None, + }) + .map(|name| { + graph + .get_node_id(name) + .map(|id| (name.clone(), id)) + .ok_or_else(|| RpqError::VertexNotFound(name.clone())) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::super::plan::LabelMeta; + use super::*; + use crate::formats::MatrixMarket; + use crate::graph::{Graph, InMemory, MatrixStatsMode}; + + #[test] + fn no_opt_preserves_the_input_expression() { + let graph = crate::utils::build_graph(&[("a", "b", "p")]); + let expr: RecExpr = "(/ 1 2)".parse().unwrap(); + let optimizer = EGraphOptimizer::new(OptimizationStrategy::NoOpt); + assert_eq!(optimizer.optimize(expr.clone(), &graph).unwrap(), expr); + } + + #[test] + fn unimplemented_strategies_return_the_existing_error() { + let graph = crate::utils::build_graph(&[("a", "b", "p")]); + let expr: RecExpr = "1".parse().unwrap(); + for strategy in [ + OptimizationStrategy::RandomOpt, + OptimizationStrategy::Simple, + OptimizationStrategy::Wander, + ] { + let optimizer = EGraphOptimizer::new(strategy); + let error = optimizer.optimize(expr.clone(), &graph).unwrap_err(); + assert!(matches!(error, RpqError::UnsupportedPath(message) + if message == format!("optimizer {strategy:?} is not implemented"))); + } + } + + #[test] + fn cached_labels_reuse_statistics_built_during_graph_load() { + let graph = Graph::::try_from( + MatrixMarket::from_dir("tests/testdata/mm_small") + .with_matrix_stats(MatrixStatsMode::Extended), + ) + .unwrap(); + let names = HashSet::from(["knows".to_string()]); + let cache = Mutex::new(OptimizerCache::default()); + let precomputed = graph + .get_metadata() + .unwrap() + .matrix("knows") + .unwrap() + .counts + .as_ref() + .unwrap(); + let cached = cached_label_data(&names, &graph, &cache).unwrap(); + + assert_eq!( + cached["knows"].row_counts.cache_key(), + precomputed.row_counts.cache_key() + ); + assert_eq!( + cached["knows"].col_counts.cache_key(), + precomputed.col_counts.cache_key() + ); + assert_eq!( + cached["knows"].row_extended.as_ref().unwrap().cache_key(), + precomputed.row_extended.as_ref().unwrap().cache_key() + ); + assert_eq!( + cached["knows"].col_extended.as_ref().unwrap().cache_key(), + precomputed.col_extended.as_ref().unwrap().cache_key() + ); + } + + #[test] + fn cached_basic_stats_are_upgraded_for_mnc() { + let graph = Graph::::try_from( + MatrixMarket::from_dir("tests/testdata/mm_small") + .with_matrix_stats(MatrixStatsMode::Basic), + ) + .unwrap(); + let labels = HashSet::from(["knows".to_string()]); + let cache = Mutex::new(OptimizerCache::default()); + + let extended = cached_label_data(&labels, &graph, &cache).unwrap(); + assert!(extended["knows"].row_extended.is_some()); + assert!(extended["knows"].col_extended.is_some()); + } + + #[test] + fn cached_labels_are_rebuilt_for_another_graph() { + let names = HashSet::from(["p".to_string()]); + let cache = Mutex::new(OptimizerCache::default()); + let first = crate::utils::build_graph(&[("a", "b", "p")]); + let old = cached_label_data(&names, &first, &cache).unwrap(); + let owner = cache.lock().unwrap().labels["p"].graph.clone(); + drop(first); + assert!(owner.upgrade().is_none()); + + let second = crate::utils::build_graph(&[("a", "b", "p"), ("b", "c", "p")]); + let new = cached_label_data(&names, &second, &cache).unwrap(); + assert_ne!( + old["p"].row_counts.cache_key(), + new["p"].row_counts.cache_key() + ); + assert_eq!(new["p"].row_counts.sum(), 2.0); + } + #[test] + fn join_cost_build_lstar() { + let mut expr = RecExpr::default(); + let a = expr.add(RpqPlan::Label(LabelMeta { + name: "knows".to_string(), + nvals: 17, + nonzero_rows: 5, + nonzero_cols: 9, + })); + let b = expr.add(RpqPlan::Label(LabelMeta { + name: "knows".to_string(), + nvals: 17, + nonzero_rows: 5, + nonzero_cols: 9, + })); + let star = expr.add(RpqPlan::Star([a])); + let _seq = expr.add(RpqPlan::Seq([star, b])); + let opt = EGraphOptimizer::new(OptimizationStrategy::Join).optimize_expr_join(expr, 100); + let root = opt.as_ref().last().expect("optimized expr is non-empty"); + + assert!(matches!(root, RpqPlan::LStar(_))); + } + #[test] + fn join_cost_build_rstar() { + let mut expr = RecExpr::default(); + let a = expr.add(RpqPlan::Label(LabelMeta { + name: "knows".to_string(), + nvals: 17, + nonzero_rows: 5, + nonzero_cols: 9, + })); + let b = expr.add(RpqPlan::Label(LabelMeta { + name: "knows".to_string(), + nvals: 17, + nonzero_rows: 5, + nonzero_cols: 9, + })); + let star = expr.add(RpqPlan::Star([b])); + let _seq = expr.add(RpqPlan::Seq([a, star])); + let opt = EGraphOptimizer::new(OptimizationStrategy::Join).optimize_expr_join(expr, 100); + let root = opt.as_ref().last().expect("optimized expr is non-empty"); + + assert!(matches!(root, RpqPlan::RStar(_))); + } +} diff --git a/pathrex/src/rpq/rpqmatrix/plan.rs b/pathrex/src/rpq/rpqmatrix/plan.rs new file mode 100644 index 0000000..7a39527 --- /dev/null +++ b/pathrex/src/rpq/rpqmatrix/plan.rs @@ -0,0 +1,56 @@ +use std::{fmt::Display, str::FromStr}; + +use egg::{Id, define_language, rewrite}; + +#[derive(Clone, Hash, Ord, Eq, PartialEq, PartialOrd, Debug)] +pub(super) struct LabelMeta { + pub name: String, + pub nvals: usize, + pub nonzero_rows: usize, + pub nonzero_cols: usize, +} + +impl FromStr for LabelMeta { + type Err = ::Err; + // This is needed for the builtin egg parser. Only used in tests. + fn from_str(s: &str) -> Result { + Ok(LabelMeta { + name: "-".to_string(), + nvals: s.parse()?, + nonzero_rows: s.parse()?, + nonzero_cols: s.parse()?, + }) + } +} + +impl Display for LabelMeta { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "({}, {})", self.name, self.nvals) + } +} + +define_language! { + pub enum RpqPlan { + Label(LabelMeta), + NamedVertex(String), + "/" = Seq([Id; 2]), + "|" = Alt([Id; 2]), + "*" = Star([Id; 1]), + "l*" = LStar([Id; 2]), + "*r" = RStar([Id; 2]), + } +} + +pub(super) fn make_rules() -> Vec> { + vec![ + rewrite!("assoc-sec-1"; "(/ ?a (/ ?b ?c))" => "(/ (/ ?a ?b) ?c)"), + rewrite!("assoc-sec-2"; "(/ (/ ?a ?b) ?c)" => "(/ ?a (/ ?b ?c))"), + rewrite!("assoc-alt"; "(| ?a (| ?b ?c))" => "(| (| ?a ?b) ?c)"), + rewrite!("distribute-1"; "(/ ?a (| ?b ?c))" => "(| (/ ?a ?b) (/ ?a ?c))"), + rewrite!("distribute-2"; "(/ (| ?a ?b) ?c)" => "(| (/ ?a ?c) (/ ?b ?c))"), + rewrite!("distribute-3"; "(| (/ ?a ?b) (/ ?a ?c))" => "(/ ?a (| ?b ?c))"), + rewrite!("distribute-4"; "(| (/ ?a ?c) (/ ?b ?c))" => "(/ (| ?a ?b) ?c)"), + rewrite!("build-lstar"; "(/ (* ?a) ?b)" => "(l* ?a ?b)"), + rewrite!("build-rstar"; "(/ ?a (* ?b))" => "(*r ?a ?b)"), + ] +} diff --git a/pathrex/src/rpq/rpqmatrix/result.rs b/pathrex/src/rpq/rpqmatrix/result.rs new file mode 100644 index 0000000..72d9215 --- /dev/null +++ b/pathrex/src/rpq/rpqmatrix/result.rs @@ -0,0 +1,81 @@ +use std::ptr::null_mut; + +use crate::eval::{PreparedEvaluator, ResultCount}; +use crate::graph::wrappers::ReduceType::ByCols; +use crate::graph::{GraphError, GraphblasMatrix, MatrixStorage, set_global_matrix_storage_hint}; +use crate::lagraph_sys::*; + +use crate::rpq::RpqError; +use crate::{grb_ok, la_ok}; + +/// Output of [`RpqMatrixEvaluator`]: full path relation matrix and its nnz. +#[derive(Debug)] +pub struct RpqMatrixResult { + pub nnz: u64, + pub matrix: GraphblasMatrix, +} + +impl RpqMatrixResult { + /// Count distinct reachable target vertices by reducing the path relation + /// matrix to its non-empty columns. + pub fn reachable_target_count(&self) -> Result { + let mut count: GrB_Index = 0; + unsafe { + grb_ok!(LAGraph_RPQMatrix_reduce( + &mut count, + self.matrix.inner, + ByCols as u8, + ))? + }; + Ok(count as u64) + } +} + +impl ResultCount for RpqMatrixResult { + fn result_count(&self) -> Result { + Ok(self.reachable_target_count()? as usize) + } +} + +pub struct PreparedRpqMatrix { + pub(super) plans: Vec, + pub(super) owned_matrices: Vec, + pub(super) storage: MatrixStorage, +} + +impl PreparedEvaluator for PreparedRpqMatrix { + type Result = RpqMatrixResult; + type Error = RpqError; + + fn execute(&mut self) -> Result { + set_global_matrix_storage_hint(self.storage)?; + + let root_ptr = unsafe { self.plans.as_mut_ptr().add(self.plans.len() - 1) }; + + let mut nnz: GrB_Index = 0; + unsafe { la_ok!(LAGraph_RPQMatrix(&mut nnz, root_ptr))? }; + + let mut matrix_inner: GrB_Matrix = null_mut(); + unsafe { grb_ok!(GrB_Matrix_dup(&mut matrix_inner, (*root_ptr).res_mat))? }; + let matrix = GraphblasMatrix { + inner: matrix_inner, + }; + + unsafe { grb_ok!(LAGraph_DestroyRpqMatrixPlan(root_ptr))? }; + + Ok(RpqMatrixResult { + nnz: nnz as u64, + matrix, + }) + } +} + +impl Drop for PreparedRpqMatrix { + fn drop(&mut self) { + for mat in &mut self.owned_matrices { + unsafe { + LAGraph_RPQMatrix_Free(mat); + } + } + } +} diff --git a/pathrex/src/rpq/rpqmatrix/sampling.rs b/pathrex/src/rpq/rpqmatrix/sampling.rs new file mode 100644 index 0000000..420d73a --- /dev/null +++ b/pathrex/src/rpq/rpqmatrix/sampling.rs @@ -0,0 +1,393 @@ +use std::sync::Arc; + +use rand::{SeedableRng, rngs::StdRng, seq::index}; + +use crate::graph::GraphblasMatrix; + +use crate::lagraph_sys::{ + GrB_Info, GrB_Matrix, GrB_Matrix_nvals, LAGraph_RPQMatrix_label, + LAGraph_RPQMatrix_sample_apply, LAGraph_RPQMatrix_sample_identity, + LAGraph_RPQMatrix_sample_stats, LAGraph_RPQMatrix_sample_submatrix, + LAGraph_RPQMatrix_sample_union, +}; + +#[derive(Clone, Copy)] +pub(super) struct SamplingConfig { + pub percent: usize, + pub seed: u64, + pub max_star_iterations: usize, +} + +#[derive(Clone, Copy, Default)] +pub(super) struct SampleEstimate { + pub nnz: f64, + pub rows: f64, + pub cols: f64, + pub exact: bool, + pub converged: bool, +} + +fn sample_matrix(info: GrB_Info, raw: GrB_Matrix) -> Option> { + let matrix = GraphblasMatrix::from_raw(raw); + if info == GrB_Info::GrB_SUCCESS && !matrix.inner.is_null() { + Some(Arc::new(matrix)) + } else { + None + } +} + +#[derive(Clone, Debug)] +pub(super) struct SampledRelation { + matrix: Arc, + converged: bool, + source: Option, + target: Option, +} + +impl SampledRelation { + fn nvals(&self) -> Option { + let mut n = 0; + (unsafe { GrB_Matrix_nvals(&mut n, self.matrix.inner) } == GrB_Info::GrB_SUCCESS) + .then_some(n as usize) + } +} + +pub(super) struct MatrixSampler { + n: usize, + vertices: Vec, + labels: Vec>, + max_star_iterations: usize, +} + +impl MatrixSampler { + pub fn new( + n: usize, + label_matrices: Vec, + fixed_vertices: &[usize], + config: SamplingConfig, + ) -> Self { + let percent = config.percent.clamp(1, 100); + let count = ((n as u128 * percent as u128).div_ceil(100)) as usize; + let mut rng = StdRng::seed_from_u64(config.seed); + let mut vertices = index::sample(&mut rng, n, count).into_vec(); + // Every label uses the same vertex order; named endpoints must be present. + vertices.extend_from_slice(fixed_vertices); + vertices.sort_unstable(); + vertices.dedup(); + let indices: Vec = vertices.iter().map(|&v| v as u64).collect(); + let mut labels = Vec::with_capacity(label_matrices.len()); + for matrix in label_matrices { + let mut result = std::ptr::null_mut(); + let info = unsafe { + LAGraph_RPQMatrix_sample_submatrix( + &mut result, + matrix, + indices.as_ptr(), + indices.len() as u64, + ) + }; + labels.push(sample_matrix(info, result).map(|matrix| SampledRelation { + matrix, + converged: true, + source: None, + target: None, + })); + } + Self { + n, + vertices, + labels, + max_star_iterations: config.max_star_iterations, + } + } + + pub fn label(&self, id: usize) -> Option { + self.labels.get(id)?.clone() + } + + pub fn vertex(&self, vertex: usize) -> Option { + let local = self.vertices.binary_search(&vertex).ok()?; + let mut matrix = std::ptr::null_mut(); + let info = unsafe { + LAGraph_RPQMatrix_label( + &mut matrix, + local as u64, + self.vertices.len() as u64, + self.vertices.len() as u64, + ) + }; + Some(SampledRelation { + matrix: sample_matrix(info, matrix)?, + converged: true, + source: Some(vertex), + target: Some(vertex), + }) + } + + pub fn seq( + &self, + lhs: Option<&SampledRelation>, + rhs: Option<&SampledRelation>, + ) -> Option { + let (lhs, rhs) = (lhs?, rhs?); + let mut matrix = std::ptr::null_mut(); + let info = unsafe { + LAGraph_RPQMatrix_sample_apply(&mut matrix, lhs.matrix.inner, rhs.matrix.inner) + }; + Some(SampledRelation { + matrix: sample_matrix(info, matrix)?, + converged: lhs.converged && rhs.converged, + source: lhs.source, + target: rhs.target, + }) + } + + pub fn alt( + &self, + lhs: Option<&SampledRelation>, + rhs: Option<&SampledRelation>, + ) -> Option { + let (lhs, rhs) = (lhs?, rhs?); + let mut matrix = std::ptr::null_mut(); + let info = unsafe { + LAGraph_RPQMatrix_sample_union(&mut matrix, lhs.matrix.inner, rhs.matrix.inner) + }; + Some(SampledRelation { + matrix: sample_matrix(info, matrix)?, + converged: lhs.converged && rhs.converged, + source: if lhs.source == rhs.source { + lhs.source + } else { + None + }, + target: if lhs.target == rhs.target { + lhs.target + } else { + None + }, + }) + } + + pub fn star(&self, body: Option<&SampledRelation>) -> Option { + let body = body?; + let mut matrix = std::ptr::null_mut(); + let info = + unsafe { LAGraph_RPQMatrix_sample_identity(&mut matrix, self.vertices.len() as u64) }; + let identity = SampledRelation { + matrix: sample_matrix(info, matrix)?, + converged: true, + source: None, + target: None, + }; + self.closure(Some(body), Some(&identity), false) + } + + pub fn closure( + &self, + body: Option<&SampledRelation>, + seed: Option<&SampledRelation>, + left: bool, + ) -> Option { + let (body, seed) = (body?, seed?); + // Operations allocate new matrices, so the immutable seed can be shared. + let mut result = seed.clone(); + result.converged &= body.converged; + let mut previous = result.nvals()?; + for _ in 0..self.max_star_iterations { + let expanded = if left { + self.seq(Some(body), Some(&result))? + } else { + self.seq(Some(&result), Some(body))? + }; + let merged = self.alt(Some(&result), Some(&expanded))?; + let nvals = merged.nvals()?; + result = merged; + if nvals == previous { + return Some(result); + } + previous = nvals; + } + result.converged = false; + Some(result) + } + + pub fn estimate(&self, relation: Option<&SampledRelation>) -> SampleEstimate { + let Some(relation) = relation else { + return SampleEstimate::default(); + }; + let (mut nnz, mut rows, mut cols, mut diagonal) = (0, 0, 0, 0); + if unsafe { + LAGraph_RPQMatrix_sample_stats( + &mut nnz, + &mut rows, + &mut cols, + &mut diagonal, + relation.matrix.inner, + ) + } != GrB_Info::GrB_SUCCESS + { + return SampleEstimate::default(); + } + if self.vertices.is_empty() { + return SampleEstimate { + exact: self.n == 0, + converged: relation.converged, + ..SampleEstimate::default() + }; + } + let scale = self.n as f64 / self.vertices.len() as f64; + let n = self.n as f64; + // A diagonal pair contains one sampled vertex, an off-diagonal pair contains two. + let diagonal_scale = if relation.source.is_some() || relation.target.is_some() { + 1.0 + } else { + scale + }; + let row_scale = if relation.source.is_some() { + 1.0 + } else { + scale + }; + let col_scale = if relation.target.is_some() { + 1.0 + } else { + scale + }; + SampleEstimate { + nnz: ((nnz - diagonal) as f64 * row_scale * col_scale + + diagonal as f64 * diagonal_scale) + .min(n * n), + rows: (rows as f64 * row_scale).min(n), + cols: (cols as f64 * col_scale).min(n), + exact: self.vertices.len() == self.n && relation.converged, + converged: relation.converged, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{graph::GraphDecomposition, utils::build_graph}; + + #[test] + fn induced_subgraph_only_contains_selected_vertices() { + let graph = build_graph(&[("a", "b", "p"), ("b", "c", "p")]); + let matrix = graph.get_graph("p").unwrap().matrix(); + let a = graph.get_node_id("a").unwrap(); + let c = graph.get_node_id("c").unwrap(); + let sampler = MatrixSampler::new( + graph.num_nodes(), + vec![matrix], + &[a, c], + SamplingConfig { + percent: 1, + seed: 0, + max_star_iterations: 4, + }, + ); + assert!(sampler.vertices.contains(&a)); + assert!(sampler.vertices.contains(&c)); + let b = graph.get_node_id("b").unwrap(); + let expected_edges = if sampler.vertices.contains(&b) { 2 } else { 0 }; + assert_eq!(sampler.label(0).unwrap().nvals(), Some(expected_edges)); + assert_eq!( + sampler + .seq(sampler.label(0).as_ref(), sampler.label(0).as_ref()) + .unwrap() + .nvals(), + Some(usize::from(expected_edges == 2)) + ); + } + + #[test] + fn fixed_source_is_not_scaled_as_a_random_row() { + let graph = build_graph(&[("a", "b", "p"), ("c", "d", "q")]); + let p = graph.get_graph("p").unwrap().matrix(); + let a = graph.get_node_id("a").unwrap(); + let b = graph.get_node_id("b").unwrap(); + let sampler = MatrixSampler::new( + graph.num_nodes(), + vec![p], + &[a, b], + SamplingConfig { + percent: 25, + seed: 0, + max_star_iterations: 4, + }, + ); + let sampled = sampler.seq(sampler.vertex(a).as_ref(), sampler.label(0).as_ref()); + assert_eq!(sampler.estimate(sampled.as_ref()).rows, 1.0); + } + + #[test] + fn fixed_endpoints_do_not_scale_the_empty_path() { + let graph = build_graph(&[("a", "b", "q"), ("c", "d", "p")]); + let p = graph.get_graph("p").unwrap().matrix(); + let a = graph.get_node_id("a").unwrap(); + let sampler = MatrixSampler::new( + graph.num_nodes(), + vec![p], + &[a], + SamplingConfig { + percent: 25, + seed: 0, + max_star_iterations: 4, + }, + ); + let star = sampler.star(sampler.label(0).as_ref()); + let fixed = sampler.seq(sampler.vertex(a).as_ref(), star.as_ref()); + assert_eq!(sampler.estimate(fixed.as_ref()).nnz, 1.0); + let fixed_target = sampler.seq(star.as_ref(), sampler.vertex(a).as_ref()); + assert_eq!(sampler.estimate(fixed_target.as_ref()).nnz, 1.0); + let both_fixed = sampler.seq(fixed.as_ref(), sampler.vertex(a).as_ref()); + assert_eq!(sampler.estimate(both_fixed.as_ref()).nnz, 1.0); + } + + #[test] + fn self_loops_are_scaled_once_per_vertex() { + let names: Vec = (0..100).map(|v| v.to_string()).collect(); + let edges: Vec<_> = names + .iter() + .map(|v| (v.as_str(), v.as_str(), "p")) + .collect(); + let graph = build_graph(&edges); + let p = graph.get_graph("p").unwrap().matrix(); + let sampler = MatrixSampler::new( + graph.num_nodes(), + vec![p], + &[], + SamplingConfig { + percent: 1, + seed: 0, + max_star_iterations: 4, + }, + ); + let relation = sampler.seq(sampler.label(0).as_ref(), sampler.label(0).as_ref()); + assert_eq!(sampler.estimate(relation.as_ref()).nnz, 100.0); + let star = sampler.star(sampler.label(0).as_ref()); + assert_eq!(sampler.estimate(star.as_ref()).nnz, 100.0); + } + + #[test] + fn iteration_limit_does_not_mark_partial_closure_as_exact() { + let graph = build_graph(&[("a", "b", "p"), ("b", "c", "p")]); + let p = graph.get_graph("p").unwrap().matrix(); + let sampler = MatrixSampler::new( + graph.num_nodes(), + vec![p], + &[], + SamplingConfig { + percent: 100, + seed: 0, + max_star_iterations: 1, + }, + ); + let star = sampler.star(sampler.label(0).as_ref()); + let estimate = sampler.estimate(star.as_ref()); + assert!(!estimate.converged); + assert!(!estimate.exact); + let seq = sampler.seq(star.as_ref(), sampler.label(0).as_ref()); + assert!(!sampler.estimate(seq.as_ref()).converged); + } +} diff --git a/pathrex/src/rpq/rpqmatrix/stats.rs b/pathrex/src/rpq/rpqmatrix/stats.rs new file mode 100644 index 0000000..4876da4 --- /dev/null +++ b/pathrex/src/rpq/rpqmatrix/stats.rs @@ -0,0 +1,311 @@ +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use crate::graph::{GraphblasMatrix, GraphblasVector}; +use crate::lagraph_sys::{ + GrB_Info, GrB_Matrix, GrB_Vector, GrB_Vector_free, GrB_Vector_nvals, + LAGraph_RPQMatrix_count_vector_dot, LAGraph_RPQMatrix_count_vector_mnc_add, + LAGraph_RPQMatrix_count_vector_mnc_matmul_nnz, LAGraph_RPQMatrix_count_vector_scale, + LAGraph_RPQMatrix_count_vector_sum, LAGraph_RPQMatrix_extended_count_vectors, + LAGraph_RPQMatrix_label, LAGraph_RPQMatrix_reduce_count_vector, +}; + +static NEXT_VECTOR_ID: AtomicUsize = AtomicUsize::new(1); + +#[derive(Clone, Debug)] +pub(crate) struct CountVector { + handle: Arc, + nvals: usize, + sum: f64, + id: usize, +} + +impl CountVector { + fn from_matrix(matrix: GrB_Matrix, by_columns: bool) -> Option { + let mut vector = std::ptr::null_mut(); + let code = unsafe { + LAGraph_RPQMatrix_reduce_count_vector(&mut vector, matrix, u8::from(by_columns)) + }; + (code == GrB_Info::GrB_SUCCESS) + .then(|| Self::from_owned(vector)) + .flatten() + } + + fn from_owned(mut vector: GrB_Vector) -> Option { + let mut nvals = 0; + let mut sum = 0.0; + let ok = unsafe { + GrB_Vector_nvals(&mut nvals, vector) == GrB_Info::GrB_SUCCESS + && LAGraph_RPQMatrix_count_vector_sum(&mut sum, vector) == GrB_Info::GrB_SUCCESS + }; + if ok { + Some(Self { + handle: Arc::new(GraphblasVector { inner: vector }), + nvals: nvals as usize, + sum, + id: NEXT_VECTOR_ID.fetch_add(1, Ordering::Relaxed), + }) + } else { + unsafe { GrB_Vector_free(&mut vector) }; + None + } + } + + fn raw(&self) -> GrB_Vector { + self.handle.inner + } + + pub(super) fn cache_key(&self) -> usize { + self.id + } + + pub(super) fn sum(&self) -> f64 { + self.sum + } + + pub(super) fn dot(&self, other: &Self) -> Option { + let mut result = 0.0; + let code = + unsafe { LAGraph_RPQMatrix_count_vector_dot(&mut result, self.raw(), other.raw()) }; + (code == GrB_Info::GrB_SUCCESS).then_some(result) + } + + pub(super) fn mnc_matmul_nnz( + lhs_rows: &Self, + lhs_cols: &Self, + rhs_rows: &Self, + rhs_cols: &Self, + lhs_col_extended: Option<&Self>, + rhs_row_extended: Option<&Self>, + ) -> Option { + let mut result = 0.0; + let code = unsafe { + LAGraph_RPQMatrix_count_vector_mnc_matmul_nnz( + &mut result, + lhs_rows.raw(), + lhs_cols.raw(), + rhs_rows.raw(), + rhs_cols.raw(), + lhs_col_extended.map_or(std::ptr::null_mut(), Self::raw), + rhs_row_extended.map_or(std::ptr::null_mut(), Self::raw), + ) + }; + (code == GrB_Info::GrB_SUCCESS).then_some(result) + } + + pub(super) fn mnc_add(&self, other: &Self, lambda: f64, cap: f64) -> Option { + let mut result = std::ptr::null_mut(); + let code = unsafe { + LAGraph_RPQMatrix_count_vector_mnc_add( + &mut result, + self.raw(), + other.raw(), + lambda, + cap, + ) + }; + (code == GrB_Info::GrB_SUCCESS) + .then(|| Self::from_owned(result)) + .flatten() + } + + pub(super) fn scale(&self, scale: f64, cap: f64) -> Option { + let mut result = std::ptr::null_mut(); + let code = + unsafe { LAGraph_RPQMatrix_count_vector_scale(&mut result, self.raw(), scale, cap) }; + (code == GrB_Info::GrB_SUCCESS) + .then(|| Self::from_owned(result)) + .flatten() + } + + pub(super) fn nonzero_count(&self) -> f64 { + self.nvals as f64 + } +} + +#[derive(Clone, Debug)] +pub(crate) struct LabelCountVectors { + pub row_counts: CountVector, + pub col_counts: CountVector, + pub row_extended: Option, + pub col_extended: Option, +} + +impl LabelCountVectors { + pub(crate) fn from_matrix(matrix: GrB_Matrix) -> Option { + let mut counts = Self::from_matrix_basic(matrix)?; + counts.ensure_extended(matrix)?; + Some(counts) + } + + pub(crate) fn from_matrix_basic(matrix: GrB_Matrix) -> Option { + let row_counts = CountVector::from_matrix(matrix, false)?; + let col_counts = CountVector::from_matrix(matrix, true)?; + Some(Self { + row_counts, + col_counts, + row_extended: None, + col_extended: None, + }) + } + + pub(super) fn ensure_extended(&mut self, matrix: GrB_Matrix) -> Option<()> { + if self.row_extended.is_some() && self.col_extended.is_some() { + return Some(()); + } + let mut row_extended = std::ptr::null_mut(); + let mut col_extended = std::ptr::null_mut(); + let code = unsafe { + LAGraph_RPQMatrix_extended_count_vectors( + &mut row_extended, + &mut col_extended, + matrix, + self.row_counts.raw(), + self.col_counts.raw(), + ) + }; + if code != GrB_Info::GrB_SUCCESS { + return None; + } + let row = CountVector::from_owned(row_extended); + let col = CountVector::from_owned(col_extended); + let (Some(row), Some(col)) = (row, col) else { + return None; + }; + self.row_extended = Some(row); + self.col_extended = Some(col); + Some(()) + } + + pub(super) fn from_vertex(vertex: usize, n: usize) -> Option { + let mut matrix = std::ptr::null_mut(); + let create = unsafe { LAGraph_RPQMatrix_label(&mut matrix, vertex as _, n as _, n as _) }; + let matrix = GraphblasMatrix::from_raw(matrix); + if create != GrB_Info::GrB_SUCCESS { + return None; + } + Self::from_matrix(matrix.inner) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{graph::GraphDecomposition, utils::build_graph}; + + #[test] + fn mnc_vector_operations_use_graphblas_counts() { + let graph = build_graph(&[("A", "B", "p"), ("B", "C", "p")]); + let matrix = graph.get_graph("p").unwrap().matrix(); + let counts = LabelCountVectors::from_matrix(matrix).unwrap(); + assert_eq!(counts.row_counts.sum(), 2.0); + assert_eq!(counts.col_counts.sum(), 2.0); + assert_eq!(counts.col_counts.dot(&counts.row_counts), Some(1.0)); + assert_eq!( + CountVector::mnc_matmul_nnz( + &counts.row_counts, + &counts.col_counts, + &counts.row_counts, + &counts.col_counts, + counts.col_extended.as_ref(), + counts.row_extended.as_ref(), + ), + Some(1.0), + ); + let union = counts + .row_counts + .mnc_add(&counts.row_counts, 0.5, 3.0) + .unwrap(); + assert_eq!(union.sum(), 3.0); + assert_eq!(union.nonzero_count(), 2.0); + let empty = counts.row_counts.scale(0.0, 3.0).unwrap(); + assert_eq!(empty.sum(), 0.0); + assert_eq!(empty.nonzero_count(), 0.0); + let fractional = counts.row_counts.scale(0.4, 3.0).unwrap(); + assert_eq!(fractional.sum(), 0.8); + assert_eq!(fractional.nonzero_count(), 2.0); + } + + #[test] + fn basic_counts_do_not_build_mnc_extensions_until_requested() { + let graph = build_graph(&[("A", "B", "p")]); + let matrix = graph.get_graph("p").unwrap().matrix(); + let mut counts = LabelCountVectors::from_matrix_basic(matrix).unwrap(); + assert!(counts.row_extended.is_none()); + assert!(counts.col_extended.is_none()); + counts.ensure_extended(matrix).unwrap(); + assert!(counts.row_extended.is_some()); + assert!(counts.col_extended.is_some()); + } + + #[test] + fn mnc_extension_vectors_account_for_singleton_rows_and_columns() { + let graph = build_graph(&[ + ("u", "a", "A"), + ("v", "b", "A"), + ("v", "c", "A"), + ("a", "x", "B"), + ("b", "x", "B"), + ("c", "y", "B"), + ]); + let a = LabelCountVectors::from_matrix(graph.get_graph("A").unwrap().matrix()).unwrap(); + let b = LabelCountVectors::from_matrix(graph.get_graph("B").unwrap().matrix()).unwrap(); + assert_eq!(a.row_counts.sum(), 3.0); + assert_eq!(a.col_counts.sum(), 3.0); + assert_eq!(b.row_counts.sum(), 3.0); + assert_eq!(b.col_counts.sum(), 3.0); + assert_eq!(a.row_counts.nonzero_count(), 2.0); + assert_eq!(a.col_counts.nonzero_count(), 3.0); + assert_eq!(b.row_counts.nonzero_count(), 3.0); + assert_eq!(b.col_counts.nonzero_count(), 2.0); + assert_eq!(a.row_extended.as_ref().unwrap().nonzero_count(), 2.0); + assert_eq!(a.col_extended.as_ref().unwrap().nonzero_count(), 1.0); + assert_eq!(b.row_extended.as_ref().unwrap().nonzero_count(), 1.0); + assert_eq!(b.col_extended.as_ref().unwrap().nonzero_count(), 2.0); + assert_eq!(a.row_extended.as_ref().unwrap().sum(), 3.0); + assert_eq!(a.col_extended.as_ref().unwrap().sum(), 1.0); + assert_eq!(b.row_extended.as_ref().unwrap().sum(), 1.0); + assert_eq!(b.col_extended.as_ref().unwrap().sum(), 3.0); + + let estimate = |lhs_ext, rhs_ext| { + CountVector::mnc_matmul_nnz( + &a.row_counts, + &a.col_counts, + &b.row_counts, + &b.col_counts, + lhs_ext, + rhs_ext, + ) + .unwrap() + }; + assert!((estimate(None, None) - 2.3125).abs() < 1e-10); + let lhs_only = estimate(a.col_extended.as_ref(), None); + let rhs_only = estimate(None, b.row_extended.as_ref()); + assert!((lhs_only - 2.5).abs() < 1e-10, "lhs-only: {lhs_only}"); + assert!((rhs_only - 2.5).abs() < 1e-10, "rhs-only: {rhs_only}"); + assert_eq!( + estimate(a.col_extended.as_ref(), b.row_extended.as_ref()), + 3.0 + ); + } + + #[test] + fn fractional_counts_do_not_trigger_the_exact_singleton_case() { + let graph = build_graph(&[("A", "A", "p"), ("B", "B", "p")]); + let counts = + LabelCountVectors::from_matrix(graph.get_graph("p").unwrap().matrix()).unwrap(); + let fractional = counts.row_counts.scale(0.4, 2.0).unwrap(); + let estimate = CountVector::mnc_matmul_nnz( + &fractional, + &fractional, + &fractional, + &fractional, + None, + None, + ) + .unwrap(); + assert!((estimate - 0.3136).abs() < 1e-10, "estimate: {estimate}"); + } +} diff --git a/pathrex/src/utils.rs b/pathrex/src/utils.rs index 30477fb..7b9ea4b 100644 --- a/pathrex/src/utils.rs +++ b/pathrex/src/utils.rs @@ -26,6 +26,9 @@ impl GraphDecomposition for CountOutput { fn num_nodes(&self) -> usize { self.0 } + fn get_metadata(&self) -> Option<&inmemory::GraphMetadata> { + None + } } /// A minimal [`GraphBuilder`] that counts pushed edges and produces a [`CountOutput`]. diff --git a/pathrex/tests/mm_tests.rs b/pathrex/tests/mm_tests.rs index 0998e1b..f914ccd 100644 --- a/pathrex/tests/mm_tests.rs +++ b/pathrex/tests/mm_tests.rs @@ -215,3 +215,31 @@ fn test_mm_graph_empty_label_handling() { let result = graph.get_graph(""); assert!(result.is_err(), "Empty label should not exist in the graph"); } + +#[test] +fn test_mm_graph_correct_metadata() { + let mm = MatrixMarket::from_dir("tests/testdata/mm_small"); + let graph = Graph::::try_from(mm).expect("Failed to load graph"); + + let result = graph + .get_metadata() + .expect("metadata should exist") + .matrix("knows") + .expect("matrix with metadata should exist"); + assert!( + result.dimension == 4, + "dimension of matrix should be calculated correctly" + ); + assert!( + result.nvals == 3, + "nonzero vals of matrix should be calculated correctly" + ); + assert!( + result.nonzero_cols == 2, + "nonzero columns of matrix should be calculated correctly" + ); + assert!( + result.nonzero_rows == 2, + "nonzero rows of matrix should be calculated correctly" + ); +} diff --git a/pathrex/tests/rpqmatrix_tests.rs b/pathrex/tests/rpqmatrix_tests.rs index 3f84d80..45eb1ad 100644 --- a/pathrex/tests/rpqmatrix_tests.rs +++ b/pathrex/tests/rpqmatrix_tests.rs @@ -3,10 +3,15 @@ use std::io::{BufRead, BufReader}; use std::path::Path; use std::sync::LazyLock; +use pathrex::eval::ResultCount; use pathrex::formats::mm::MatrixMarket; use pathrex::graph::{Graph, GraphDecomposition, GraphError, InMemory, InMemoryGraph}; use pathrex::lagraph_sys::{GrB_Index, GrB_Info, GrB_Matrix_extractElement_BOOL}; -use pathrex::rpq::rpqmatrix::{RpqMatrixEvaluator, RpqMatrixResult}; +use pathrex::rpq::rpqmatrix::OptimizationStrategy::{ + Hybrid, Join, MetaAc, Mnc, PangHybrid, Sampling, +}; +use pathrex::rpq::rpqmatrix::eval::RpqMatrixEvaluator; +use pathrex::rpq::rpqmatrix::result::RpqMatrixResult; use pathrex::rpq::{Endpoint, PathExpr, PreparedRpq, RpqError, RpqEvaluator, RpqQuery}; use pathrex::sparql::parse_rpq; use pathrex::utils::build_graph; @@ -67,7 +72,7 @@ fn load_expected_nnz(case_dir: &Path) -> Vec { .collect() } -fn run_la_n_egg_case(case_name: &str) { +fn run_la_n_egg_case_with_evaluator(case_name: &str, evaluator: RpqMatrixEvaluator) { let case_dir = Path::new(CASES_DIR).join(case_name); let queries = load_queries(&case_dir); let expected = load_expected_nnz(&case_dir); @@ -79,7 +84,6 @@ fn run_la_n_egg_case(case_name: &str) { ); let graph = &*LA_N_EGG_GRAPH; - let evaluator = RpqMatrixEvaluator; for (i, (query, expected_nnz)) in queries.iter().zip(expected.iter()).enumerate() { let result = evaluator.evaluate(query, graph).unwrap_or_else(|e| { @@ -95,6 +99,14 @@ fn run_la_n_egg_case(case_name: &str) { } } +fn run_la_n_egg_case(case_name: &str) { + run_la_n_egg_case_with_evaluator(case_name, RpqMatrixEvaluator::default()); +} + +fn run_la_n_egg_case_join(case_name: &str) { + run_la_n_egg_case_with_evaluator(case_name, RpqMatrixEvaluator::optimized(Join)); +} + fn label(s: &str) -> PathExpr { PathExpr::Label(s.to_string()) } @@ -123,12 +135,37 @@ fn matrix_entry_set(result: &RpqMatrixResult, row: GrB_Index, col: GrB_Index) -> } } +// TODO: made it reusable for different optimizers +fn evaluate_default_and_join( + graph: &InMemoryGraph, + query: &RpqQuery, +) -> (RpqMatrixResult, RpqMatrixResult) { + let default_result = RpqMatrixEvaluator::default() + .evaluate(query, graph) + .expect("default evaluator should succeed"); + let optimized_result = RpqMatrixEvaluator::optimized(Join) + .evaluate(query, graph) + .expect("join optimizer should succeed"); + + assert_eq!( + default_result.nnz, optimized_result.nnz, + "optimized evaluator should preserve result nnz" + ); + assert_eq!( + default_result.result_count().expect("default count"), + optimized_result.result_count().expect("optimized count"), + "optimized evaluator should preserve result count" + ); + + (default_result, optimized_result) +} + /// Graph: A --knows--> B --knows--> C /// Query: ?x ?y #[test] fn test_single_label_variable_variable() { let graph = build_graph(&[("A", "B", "knows"), ("B", "C", "knows")]); - let evaluator = RpqMatrixEvaluator; + let evaluator = RpqMatrixEvaluator::default(); let result = evaluator .evaluate(&rq(var("x"), label("knows"), var("y")), &graph) @@ -142,7 +179,7 @@ fn test_single_label_variable_variable() { #[test] fn test_single_label_named_source() { let graph = build_graph(&[("A", "B", "knows"), ("B", "C", "knows")]); - let evaluator = RpqMatrixEvaluator; + let evaluator = RpqMatrixEvaluator::default(); let result = evaluator .evaluate(&rq(named_ep("A"), label("knows"), var("y")), &graph) @@ -162,7 +199,7 @@ fn test_single_label_named_source() { #[test] fn test_sequence_path() { let graph = build_graph(&[("A", "B", "knows"), ("B", "C", "likes")]); - let evaluator = RpqMatrixEvaluator; + let evaluator = RpqMatrixEvaluator::default(); let path = PathExpr::Sequence(Box::new(label("knows")), Box::new(label("likes"))); @@ -182,8 +219,12 @@ fn prepared_rpqmatrix_execution_matches_evaluate() { var("y"), ); - let direct = RpqMatrixEvaluator.evaluate(&query, &graph).expect("direct"); - let mut prepared = RpqMatrixEvaluator.prepare(&query, &graph).expect("prepare"); + let direct = RpqMatrixEvaluator::default() + .evaluate(&query, &graph) + .expect("direct"); + let mut prepared = RpqMatrixEvaluator::default() + .prepare(&query, &graph) + .expect("prepare"); let prepared_result = prepared.execute().expect("execute"); assert_eq!(prepared_result.nnz, direct.nnz); @@ -198,7 +239,9 @@ fn prepared_rpqmatrix_execution_can_run_twice() { var("y"), ); - let mut prepared = RpqMatrixEvaluator.prepare(&query, &graph).expect("prepare"); + let mut prepared = RpqMatrixEvaluator::default() + .prepare(&query, &graph) + .expect("prepare"); let first = prepared.execute().expect("first"); let second = prepared.execute().expect("second"); @@ -210,7 +253,7 @@ fn prepared_rpqmatrix_execution_can_run_twice() { #[test] fn test_sequence_path_named_source() { let graph = build_graph(&[("A", "B", "knows"), ("B", "C", "likes")]); - let evaluator = RpqMatrixEvaluator; + let evaluator = RpqMatrixEvaluator::default(); let path = PathExpr::Sequence(Box::new(label("knows")), Box::new(label("likes"))); @@ -232,7 +275,7 @@ fn test_sequence_path_named_source() { #[test] fn test_alternative_path() { let graph = build_graph(&[("A", "B", "knows"), ("A", "C", "likes")]); - let evaluator = RpqMatrixEvaluator; + let evaluator = RpqMatrixEvaluator::default(); let path = PathExpr::Alternative(Box::new(label("knows")), Box::new(label("likes"))); @@ -259,7 +302,7 @@ fn test_alternative_path() { #[test] fn test_zero_or_more_path() { let graph = build_graph(&[("A", "B", "knows"), ("B", "C", "knows")]); - let evaluator = RpqMatrixEvaluator; + let evaluator = RpqMatrixEvaluator::default(); let path = PathExpr::ZeroOrMore(Box::new(label("knows"))); @@ -291,7 +334,7 @@ fn test_zero_or_more_path() { #[test] fn test_one_or_more_path() { let graph = build_graph(&[("A", "B", "knows"), ("B", "C", "knows")]); - let evaluator = RpqMatrixEvaluator; + let evaluator = RpqMatrixEvaluator::default(); let path = PathExpr::OneOrMore(Box::new(label("knows"))); @@ -321,7 +364,7 @@ fn test_one_or_more_path() { #[test] fn test_zero_or_one_unsupported() { let graph = build_graph(&[("A", "B", "knows")]); - let evaluator = RpqMatrixEvaluator; + let evaluator = RpqMatrixEvaluator::default(); let path = PathExpr::ZeroOrOne(Box::new(label("knows"))); let result = evaluator.evaluate(&rq(var("x"), path, var("y")), &graph); @@ -335,7 +378,7 @@ fn test_zero_or_one_unsupported() { #[test] fn test_label_not_found() { let graph = build_graph(&[("A", "B", "knows")]); - let evaluator = RpqMatrixEvaluator; + let evaluator = RpqMatrixEvaluator::default(); let result = evaluator.evaluate(&rq(var("x"), label("nonexistent"), var("y")), &graph); @@ -348,7 +391,7 @@ fn test_label_not_found() { #[test] fn test_vertex_not_found() { let graph = build_graph(&[("A", "B", "knows")]); - let evaluator = RpqMatrixEvaluator; + let evaluator = RpqMatrixEvaluator::default(); let result = evaluator.evaluate(&rq(named_ep("Z"), label("knows"), var("y")), &graph); @@ -363,7 +406,7 @@ fn test_vertex_not_found() { #[test] fn test_bound_object() { let graph = build_graph(&[("A", "B", "knows"), ("C", "D", "knows")]); - let evaluator = RpqMatrixEvaluator; + let evaluator = RpqMatrixEvaluator::default(); let result = evaluator .evaluate(&rq(var("x"), label("knows"), named_ep("B")), &graph) @@ -377,7 +420,7 @@ fn test_bound_object() { #[test] fn test_bound_subject_and_object() { let graph = build_graph(&[("A", "B", "knows"), ("C", "D", "knows")]); - let evaluator = RpqMatrixEvaluator; + let evaluator = RpqMatrixEvaluator::default(); let result = evaluator .evaluate(&rq(named_ep("A"), label("knows"), named_ep("B")), &graph) @@ -402,7 +445,7 @@ fn test_cycle_graph_star() { ("B", "C", "knows"), ("C", "A", "knows"), ]); - let evaluator = RpqMatrixEvaluator; + let evaluator = RpqMatrixEvaluator::default(); let path = PathExpr::ZeroOrMore(Box::new(label("knows"))); @@ -441,7 +484,7 @@ fn test_complex_path() { ("B", "C", "likes"), ("C", "D", "knows"), ]); - let evaluator = RpqMatrixEvaluator; + let evaluator = RpqMatrixEvaluator::default(); // knows / likes* / knows let path = PathExpr::Sequence( @@ -468,7 +511,7 @@ fn test_complex_path() { #[test] fn test_no_matching_path() { let graph = build_graph(&[("A", "B", "knows")]); - let evaluator = RpqMatrixEvaluator; + let evaluator = RpqMatrixEvaluator::default(); let path = PathExpr::Sequence(Box::new(label("knows")), Box::new(label("likes"))); @@ -494,3 +537,138 @@ fn test_la_n_egg_any_con() { fn test_la_n_egg_con_any() { run_la_n_egg_case("con-any"); } + +#[test] +fn test_la_n_egg_any_any_join_optimizer() { + run_la_n_egg_case_join("any-any"); +} + +#[test] +fn test_la_n_egg_any_con_join_optimizer() { + run_la_n_egg_case_join("con-any"); +} + +#[test] +fn test_la_n_egg_cases_with_core_optimizers() { + for optimizer in [MetaAc, Mnc, Hybrid] { + let evaluator = RpqMatrixEvaluator::optimized(optimizer); + for case in ["any-any", "any-con", "con-any"] { + run_la_n_egg_case_with_evaluator(case, evaluator.clone()); + } + } +} + +#[test] +fn test_la_n_egg_cases_with_restored_optimizers() { + for optimizer in [PangHybrid, Sampling] { + let evaluator = RpqMatrixEvaluator::optimized(optimizer); + for case in ["any-any", "any-con", "con-any"] { + run_la_n_egg_case_with_evaluator(case, evaluator.clone()); + } + } +} + +#[test] +fn test_join_optimizer_give_same_result_unoptimized_way_1() { + let graph = build_graph(&[ + ("A", "B", "knows"), + ("B", "C", "knows"), + ("C", "D", "likes"), + ("A", "E", "likes"), + ]); + + // knows* / likes can be rewritten to LStar. + let path = PathExpr::Sequence( + Box::new(PathExpr::ZeroOrMore(Box::new(label("knows")))), + Box::new(label("likes")), + ); + let query = rq(named_ep("A"), path, var("y")); + + let (default_result, optimized_result) = evaluate_default_and_join(&graph, &query); + assert_eq!(default_result.nnz, 2); + + let a_id = graph.get_node_id("A").expect("A should exist") as GrB_Index; + let d_id = graph.get_node_id("D").expect("D should exist") as GrB_Index; + let e_id = graph.get_node_id("E").expect("E should exist") as GrB_Index; + + for result in [&default_result, &optimized_result] { + assert!( + matrix_entry_set(result, a_id, d_id), + "D should be reachable via knows*/likes" + ); + assert!( + matrix_entry_set(result, a_id, e_id), + "E should be reachable via zero knows hops then likes" + ); + } +} + +#[test] +fn test_join_optimizer_give_same_result_unoptimized_way_2() { + let graph = build_graph(&[ + ("A", "B", "knows"), + ("B", "C", "likes"), + ("C", "D", "knows"), + ]); + + // knows / likes* / knows + let path = PathExpr::Sequence( + Box::new(PathExpr::Sequence( + Box::new(label("knows")), + Box::new(PathExpr::ZeroOrMore(Box::new(label("likes")))), + )), + Box::new(label("knows")), + ); + + let query = rq(named_ep("A"), path, var("y")); + let (default_result, optimized_result) = evaluate_default_and_join(&graph, &query); + + assert_eq!(default_result.nnz, 1); + let a_id = graph.get_node_id("A").expect("A should exist") as GrB_Index; + let d_id = graph.get_node_id("D").expect("D should exist") as GrB_Index; + assert!( + matrix_entry_set(&default_result, a_id, d_id), + "D should be reachable via knows/likes*/knows" + ); + assert!( + matrix_entry_set(&optimized_result, a_id, d_id), + "D should be reachable via knows/likes*/knows" + ); +} + +#[test] +fn test_join_optimizer_give_same_result_unoptimized_way_3() { + let graph = build_graph(&[ + ("A", "B", "knows"), + ("B", "C", "likes"), + ("B", "D", "hates"), + ]); + + // knows / (likes | hates) can be rewritten by distributivity rules. + let path = PathExpr::Sequence( + Box::new(label("knows")), + Box::new(PathExpr::Alternative( + Box::new(label("likes")), + Box::new(label("hates")), + )), + ); + let query = rq(named_ep("A"), path, var("y")); + + let (default_result, optimized_result) = evaluate_default_and_join(&graph, &query); + assert_eq!(default_result.nnz, 2); + + let a_id = graph.get_node_id("A").expect("A should exist") as GrB_Index; + let c_id = graph.get_node_id("C").expect("C should exist") as GrB_Index; + let d_id = graph.get_node_id("D").expect("D should exist") as GrB_Index; + + for result in [&default_result, &optimized_result] { + assert!( + matrix_entry_set(result, a_id, c_id), + "C should be reachable via knows/likes" + ); + assert!( + matrix_entry_set(result, a_id, d_id), + "D should be reachable via knows/hates" + ); + } +} diff --git a/pathrex/tests/testdata/mm_small/1.txt b/pathrex/tests/testdata/mm_small/1.txt new file mode 100644 index 0000000..cd8561a --- /dev/null +++ b/pathrex/tests/testdata/mm_small/1.txt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cde9a9330825c73e01fe5fe1c0be5d226cef1b63f314ed410c7a7620c717a623 +size 66 diff --git a/pathrex/tests/testdata/mm_small/edges.txt b/pathrex/tests/testdata/mm_small/edges.txt new file mode 100644 index 0000000..bda8e20 --- /dev/null +++ b/pathrex/tests/testdata/mm_small/edges.txt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b107c484753ed13cc4e1e10c97ebda2e327e3be24d7fc2e84d62075c8f0909f5 +size 9 diff --git a/pathrex/tests/testdata/mm_small/vertices.txt b/pathrex/tests/testdata/mm_small/vertices.txt new file mode 100644 index 0000000..79ef958 --- /dev/null +++ b/pathrex/tests/testdata/mm_small/vertices.txt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d7e475829a22013136702d92adef2253a9fae510fff62dba7975c7062e27c425 +size 23