diff --git a/.github/workflows/normalized-native-bfs.yml b/.github/workflows/normalized-native-bfs.yml new file mode 100644 index 0000000..1af38ad --- /dev/null +++ b/.github/workflows/normalized-native-bfs.yml @@ -0,0 +1,139 @@ +name: Normalized Native BFS + +on: + pull_request: + paths: + - '.github/workflows/normalized-native-bfs.yml' + - 'tools/validate_normalized_native_bfs.py' + - 'tools/competitor_benchmark.py' + - 'tools/native_competitors/**' + - 'docs/normalized-native-bfs.md' + workflow_dispatch: + +jobs: + normalized-bfs: + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + GRAPHBLAS_TAG: v10.3.2 + LAGRAPH_TAG: v1.2.2 + GAPBS_TAG: v1.5 + steps: + - uses: actions/checkout@v4 + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y cmake ninja-build libomp-dev + + - name: Clone immutable native competitors + run: | + git clone --depth 1 --branch "$GRAPHBLAS_TAG" https://github.com/DrTimothyAldenDavis/GraphBLAS.git external/GraphBLAS + git clone --depth 1 --branch "$LAGRAPH_TAG" https://github.com/GraphBLAS/LAGraph.git external/LAGraph + git clone --depth 1 --branch "$GAPBS_TAG" https://github.com/sbeamer/gapbs.git external/gapbs + + - name: Build SuiteSparse GraphBLAS + run: | + cmake -S external/GraphBLAS -B external/GraphBLAS/build \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="$PWD/external/install" + cmake --build external/GraphBLAS/build --parallel 2 + cmake --install external/GraphBLAS/build + + - name: Prepare normalized native runner build + run: | + set -euxo pipefail + mkdir -p build/native artifacts/normalized + + - name: Build LAGraph and native BFS runner + run: | + set -euxo pipefail + # Build the thin runner in the exact pinned LAGraph build graph. This + # avoids a second find_package consumer project and links against the + # same LAGraph/GraphBLAS targets that CMake has already resolved. + cat >> external/LAGraph/CMakeLists.txt <<'CMAKE' + + add_executable(velographx_lagraph_bfs_runner + ${CMAKE_CURRENT_SOURCE_DIR}/../../tools/native_competitors/lagraph_bfs_json_runner.c) + target_compile_features(velographx_lagraph_bfs_runner PRIVATE c_std_11) + target_compile_options(velographx_lagraph_bfs_runner PRIVATE -O3) + target_link_libraries(velographx_lagraph_bfs_runner PRIVATE + LAGraph GraphBLAS::GraphBLAS) + set_target_properties(velographx_lagraph_bfs_runner PROPERTIES + OUTPUT_NAME lagraph_bfs_json_runner + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/../../build/native") + CMAKE + cmake -S external/LAGraph -B external/LAGraph/build \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH="$PWD/external/install" \ + -DCMAKE_INSTALL_PREFIX="$PWD/external/install" + cmake --build external/LAGraph/build --parallel 2 + cmake --install external/LAGraph/build + test -x build/native/lagraph_bfs_json_runner + + - name: Build GAP native BFS runner + run: | + set -euxo pipefail + # The thin runner includes the pinned GAPBS v1.5 bfs.cc directly, + # renaming only its CLI main. GAPBS v1.5's own Makefile uses C++11, + # so keep the hosted correctness runner on that exact language mode. + c++ -O3 -std=c++11 -fopenmp \ + -Iexternal/gapbs/src \ + tools/native_competitors/gap_bfs_json_runner.cc \ + -o build/native/gap_bfs_json_runner + test -x build/native/gap_bfs_json_runner + + - name: Generate deterministic normalization graph + run: | + python - <<'PY' + from pathlib import Path + n = 4096 + edges = set() + for u in range(n): + for delta in (1, 7, 31, 127): + v = (u + delta) % n + a, b = sorted((u, v)) + if a != b: + edges.add((a, b)) + Path('artifacts/normalized/hosted-native.el').write_text( + ''.join(f'{u} {v}\n' for u, v in sorted(edges)) + ) + PY + + - name: Validate full-distance normalization + env: + LD_LIBRARY_PATH: ${{ github.workspace }}/external/install/lib:${{ github.workspace }}/external/install/lib64 + VELOGRAPHX_LAGRAPH_BFS_BIN: ${{ github.workspace }}/build/native/lagraph_bfs_json_runner + VELOGRAPHX_GAP_BFS_BIN: ${{ github.workspace }}/build/native/gap_bfs_json_runner + run: | + python tools/validate_normalized_native_bfs.py \ + --dataset artifacts/normalized/hosted-native.el \ + --source 0 \ + --repeat 3 \ + --lagraph-command "python tools/native_competitors/lagraph_wrapper.py" \ + --gap-command "python tools/native_competitors/gap_wrapper.py" \ + --output artifacts/normalized/normalized-native-bfs.json + + - name: Validate claim gate + run: | + python - <<'PY' + import json + from pathlib import Path + r = json.loads(Path('artifacts/normalized/normalized-native-bfs.json').read_text()) + assert r['normalized_cross_engine_claim'] is True + assert r['correctness_gate']['passed'] is True + assert r['correctness_gate']['same_dataset'] is True + assert r['correctness_gate']['same_source'] is True + assert r['correctness_gate']['same_directedness'] is True + assert r['correctness_gate']['same_full_distance_digest'] is True + assert r['research_claim'] is False + assert r['publication_grade'] is False + assert r['claim_gate']['publication_ready'] is False + PY + + - name: Upload normalized native BFS evidence + uses: actions/upload-artifact@v4 + with: + name: velographx-normalized-native-bfs + path: artifacts/normalized + retention-days: 30 diff --git a/docs/normalized-native-bfs.md b/docs/normalized-native-bfs.md new file mode 100644 index 0000000..f6103fe --- /dev/null +++ b/docs/normalized-native-bfs.md @@ -0,0 +1,35 @@ +# Normalized native BFS comparison + +This stage closes the semantic gap between VeloGraphX hosted engineering evidence and like-for-like native competitor evidence. + +The validator `tools/validate_normalized_native_bfs.py` runs the builtin reference plus the existing external LAGraph and GAP adapter contracts and refuses to produce a normalized result unless every engine agrees on: + +- dataset SHA-256; +- source vertex; +- directedness; +- vertex and edge counts; +- reachable-vertex count; and +- the SHA-256 digest of the complete BFS distance vector. + +A successful run may set `normalized_cross_engine_claim: true` only for correctness normalization and same-runner engineering timing. It must continue to emit `research_claim: false`, `publication_grade: false`, and `publication_ready: false` on GitHub-hosted hardware. + +## Native runner contract + +Both native runners must accept: + +```text +--dataset PATH --source N --vertices N [--directed] +``` + +and emit exactly one JSON object containing a `distances` array with one integer per vertex (`-1` for unreachable vertices) and an immutable framework version/commit identifier where available. + +The existing shims are: + +- `tools/native_competitors/lagraph_wrapper.py` +- `tools/native_competitors/gap_wrapper.py` + +## Remaining implementation + +The hosted workflow still needs native runner binaries that expose full BFS distance vectors from the pinned LAGraph/GraphBLAS and GAP implementations. Once those runners are wired in, the normalization validator becomes the mandatory correctness gate before any cross-engine timing summary is emitted. + +Publication-grade competitor claims remain blocked until the same normalized contract is executed on the dedicated hardware campaign required by Issue #10. diff --git a/tests/test_work_stealing.cpp b/tests/test_work_stealing.cpp index 6940d52..048b97e 100644 --- a/tests/test_work_stealing.cpp +++ b/tests/test_work_stealing.cpp @@ -2,7 +2,9 @@ #include #include +#include #include +#include #include int main() { @@ -17,6 +19,26 @@ int main() { assert(pool.queue_group(2) == 0); assert(pool.queue_group(3) == 1); + // Force a real stealing opportunity instead of relying on scheduler timing. + // All tasks start on queue 0 and remain blocked until at least one worker has + // attempted to steal from another queue. + std::atomic release_probe{false}; + for (std::size_t i = 0; i < 32; ++i) { + pool.submit([&release_probe] { + while (!release_probe.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + }, 0); + } + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); + while (pool.stats().steal_attempts == 0 && std::chrono::steady_clock::now() < deadline) { + std::this_thread::yield(); + } + assert(pool.stats().steal_attempts > 0); + release_probe.store(true, std::memory_order_release); + pool.wait_idle(); + std::atomic sum{0}; for (std::size_t i = 0; i < 1000; ++i) { pool.submit([&sum, i] { sum.fetch_add(i, std::memory_order_relaxed); }, 0); diff --git a/tools/native_competitors/gap_bfs_json_runner.cc b/tools/native_competitors/gap_bfs_json_runner.cc new file mode 100644 index 0000000..e3e3c71 --- /dev/null +++ b/tools/native_competitors/gap_bfs_json_runner.cc @@ -0,0 +1,84 @@ +#include +#include +#include +#include +#include + +// Compile the pinned GAP Benchmark Suite BFS implementation in this same +// translation unit. This keeps the runner on GAPBS v1.5's native C++11 build +// model and avoids a fragile cross-translation-unit re-declaration boundary. +// Rename only GAP's CLI entry point; DOBFS and BFSVerifier remain unchanged. +#define main gapbs_original_main +#include "bfs.cc" +#undef main + +static void die(const std::string &msg) { + std::cerr << msg << "\n"; + std::exit(2); +} + +int main(int argc, char **argv) { + std::string dataset; + int source = -1; + int vertices = -1; + bool directed = false; + for (int i = 1; i < argc; ++i) { + std::string a = argv[i]; + if (a == "--dataset" && i + 1 < argc) dataset = argv[++i]; + else if (a == "--source" && i + 1 < argc) source = std::stoi(argv[++i]); + else if (a == "--vertices" && i + 1 < argc) vertices = std::stoi(argv[++i]); + else if (a == "--directed") directed = true; + else die("invalid argument"); + } + if (dataset.empty() || source < 0 || vertices <= 0 || source >= vertices) { + die("invalid runner arguments"); + } + + std::vector args = {"gap-json-runner"}; + if (!directed) args.push_back("-s"); + args.push_back("-f"); + args.push_back(dataset); + args.push_back("-r"); + args.push_back(std::to_string(source)); + std::vector cargs; + cargs.reserve(args.size()); + for (auto &s : args) cargs.push_back(&s[0]); + + CLApp cli(static_cast(cargs.size()), cargs.data(), + "normalized breadth-first search"); + if (!cli.ParseArgs()) die("GAP argument parsing failed"); + Builder b(cli); + Graph g = b.MakeGraph(); + if (g.num_nodes() != vertices) die("GAP graph vertex count mismatch"); + + pvector parent = DOBFS(g, static_cast(source), false, 15, 18); + if (!BFSVerifier(g, static_cast(source), parent)) { + die("GAP BFS verification failed"); + } + + std::vector dist(vertices, -1); + dist[source] = 0; + for (int v = 0; v < vertices; ++v) { + if (v == source || parent[v] < 0) continue; + NodeID cur = static_cast(v); + int depth = 0; + while (cur != source) { + if (cur < 0 || cur >= vertices || parent[cur] < 0) { + depth = -1; + break; + } + cur = parent[cur]; + ++depth; + if (depth > vertices) die("cycle detected in GAP BFS parent tree"); + } + if (depth >= 0) dist[v] = depth; + } + + std::cout << "{\"framework_version\":\"GAPBS-1.5\",\"distances\":["; + for (int i = 0; i < vertices; ++i) { + if (i) std::cout << ','; + std::cout << dist[i]; + } + std::cout << "]}\n"; + return 0; +} diff --git a/tools/native_competitors/gap_wrapper.py b/tools/native_competitors/gap_wrapper.py index ad0412b..2a8d2c5 100644 --- a/tools/native_competitors/gap_wrapper.py +++ b/tools/native_competitors/gap_wrapper.py @@ -25,6 +25,21 @@ def require_env(name: str) -> str: return value +def parse_native_payload(stdout: str) -> dict: + """Extract the runner JSON while tolerating GAPBS timing output.""" + for raw_line in reversed(stdout.splitlines()): + line = raw_line.strip() + if not line.startswith("{"): + continue + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(payload, dict) and "distances" in payload: + return payload + raise RuntimeError("GAP BFS runner did not emit a valid JSON distances payload") + + def main() -> int: if os.environ.get("VELOGRAPHX_ALGORITHM") != "bfs": raise RuntimeError("GAP wrapper currently supports only bfs") @@ -47,12 +62,7 @@ def main() -> int: if proc.returncode != 0: detail = proc.stderr.strip() or proc.stdout.strip() or f"exit code {proc.returncode}" raise RuntimeError(f"GAP BFS runner failed: {detail}") - try: - payload = json.loads(proc.stdout) - except json.JSONDecodeError as exc: - raise RuntimeError("GAP BFS runner must emit one JSON object") from exc - if not isinstance(payload, dict) or "distances" not in payload: - raise RuntimeError("GAP BFS runner JSON must contain distances") + payload = parse_native_payload(proc.stdout) if len(payload["distances"]) != vertices: raise RuntimeError("GAP BFS runner returned the wrong number of distances") payload.setdefault("framework_version", "GAP-local") diff --git a/tools/native_competitors/lagraph_bfs_json_runner.c b/tools/native_competitors/lagraph_bfs_json_runner.c new file mode 100644 index 0000000..b3a6ac5 --- /dev/null +++ b/tools/native_competitors/lagraph_bfs_json_runner.c @@ -0,0 +1,71 @@ +#include +#include +#include +#include +#include +#include + +static void die(const char *msg) { fprintf(stderr, "%s\n", msg); exit(2); } + +int main(int argc, char **argv) { + const char *dataset = NULL; + int64_t source = -1, vertices = -1; + bool directed = false; + for (int i = 1; i < argc; i++) { + if (!strcmp(argv[i], "--dataset") && i + 1 < argc) dataset = argv[++i]; + else if (!strcmp(argv[i], "--source") && i + 1 < argc) source = atoll(argv[++i]); + else if (!strcmp(argv[i], "--vertices") && i + 1 < argc) vertices = atoll(argv[++i]); + else if (!strcmp(argv[i], "--directed")) directed = true; + else die("invalid argument"); + } + if (!dataset || source < 0 || vertices <= 0 || source >= vertices) die("invalid runner arguments"); + + FILE *f = fopen(dataset, "r"); + if (!f) die("cannot open dataset"); + + char msg[LAGRAPH_MSG_LEN]; + if (LAGraph_Init(msg) < 0) die(msg); + GrB_Matrix A = NULL; + if (GrB_Matrix_new(&A, GrB_BOOL, (GrB_Index) vertices, (GrB_Index) vertices) < 0) die("GrB_Matrix_new failed"); + + long long u, v; + while (fscanf(f, "%lld %lld", &u, &v) == 2) { + if (u < 0 || v < 0 || u >= vertices || v >= vertices) die("edge vertex out of range"); + if (GrB_Matrix_setElement_BOOL(A, true, (GrB_Index) u, (GrB_Index) v) < 0) die("set edge failed"); + if (!directed && u != v) { + if (GrB_Matrix_setElement_BOOL(A, true, (GrB_Index) v, (GrB_Index) u) < 0) die("set reverse edge failed"); + } + } + fclose(f); + + LAGraph_Graph G = NULL; + LAGraph_Kind kind = directed ? LAGraph_ADJACENCY_DIRECTED : LAGraph_ADJACENCY_UNDIRECTED; + if (LAGraph_New(&G, &A, kind, msg) < 0) die(msg); + if (LAGraph_Cached_OutDegree(G, msg) < 0) die(msg); + + GrB_Vector level = NULL; + if (LAGr_BreadthFirstSearch( + &level, + NULL, + G, + (GrB_Index) source, + msg) < 0) { + die(msg); + } + + printf("{\"framework_version\":\"LAGraph-1.2.2\",\"distances\":["); + for (int64_t i = 0; i < vertices; i++) { + int64_t d = -1; + GrB_Info info = GrB_Vector_extractElement(&d, level, (GrB_Index) i); + if (info == GrB_NO_VALUE) d = -1; + else if (info != GrB_SUCCESS) die("extract level failed"); + if (i) putchar(','); + printf("%lld", (long long) d); + } + printf("]}\n"); + + GrB_free(&level); + LAGraph_Delete(&G, msg); + LAGraph_Finalize(msg); + return 0; +} diff --git a/tools/validate_normalized_native_bfs.py b/tools/validate_normalized_native_bfs.py new file mode 100644 index 0000000..27b14ee --- /dev/null +++ b/tools/validate_normalized_native_bfs.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +import argparse +import json +import subprocess +import sys +from pathlib import Path + + +def run_report(repo: Path, dataset: Path, source: int, directed: bool, repeat: int, + framework: str, *, external_name=None, external_command=None): + cmd = [ + sys.executable, + str(repo / "tools" / "competitor_benchmark.py"), + "--dataset", str(dataset), + "--framework", framework, + "--source", str(source), + "--repeat", str(repeat), + ] + if directed: + cmd.append("--directed") + if framework == "external": + cmd.extend([ + "--external-name", external_name, + "--external-command", external_command, + ]) + proc = subprocess.run(cmd, text=True, capture_output=True, check=False, cwd=repo) + if proc.returncode != 0: + detail = proc.stderr.strip() or proc.stdout.strip() + raise RuntimeError(f"{external_name or framework} failed: {detail}") + return json.loads(proc.stdout) + + +def require_equal(label, reports, key): + values = {name: report[key] for name, report in reports.items()} + if len(set(values.values())) != 1: + raise RuntimeError(f"normalized BFS mismatch for {label}: {values}") + return next(iter(values.values())) + + +def main(): + p = argparse.ArgumentParser(description="Validate normalized BFS semantics across builtin, LAGraph and GAP native adapters.") + p.add_argument("--dataset", type=Path, required=True) + p.add_argument("--source", type=int, default=0) + p.add_argument("--directed", action="store_true") + p.add_argument("--repeat", type=int, default=3) + p.add_argument("--lagraph-command", required=True) + p.add_argument("--gap-command", required=True) + p.add_argument("--output", type=Path) + args = p.parse_args() + + if args.repeat < 1: + raise ValueError("--repeat must be at least 1") + if not args.dataset.is_file(): + raise ValueError(f"dataset does not exist: {args.dataset}") + + repo = Path(__file__).resolve().parents[1] + reports = { + "builtin": run_report(repo, args.dataset, args.source, args.directed, args.repeat, "builtin"), + "lagraph": run_report( + repo, args.dataset, args.source, args.directed, args.repeat, "external", + external_name="SuiteSparse:GraphBLAS/LAGraph", + external_command=args.lagraph_command, + ), + "gap": run_report( + repo, args.dataset, args.source, args.directed, args.repeat, "external", + external_name="GAP Benchmark Suite", + external_command=args.gap_command, + ), + } + + normalized = { + "dataset_sha256": require_equal("dataset checksum", reports, "dataset_sha256"), + "source": require_equal("source", reports, "source"), + "directed": require_equal("directedness", reports, "directed"), + "vertices": require_equal("vertex count", reports, "vertices"), + "edges": require_equal("edge count", reports, "edges"), + "result_digest": require_equal("full BFS distance digest", reports, "result_digest"), + "reachable_vertices": require_equal("reachable vertex count", reports, "reachable_vertices"), + } + + payload = { + "schema_version": 1, + "artifact_type": "velographx-normalized-native-bfs", + "algorithm": "bfs", + "normalized_cross_engine_claim": True, + "correctness_gate": { + "passed": True, + "same_dataset": True, + "same_source": True, + "same_directedness": True, + "same_full_distance_digest": True, + }, + "normalized": normalized, + "reports": reports, + "research_claim": False, + "publication_grade": False, + "claim_gate": { + "publication_ready": False, + "allowed_claim": "Normalized same-dataset/source/directedness BFS correctness and hosted engineering timing only; no publication-grade superiority claim.", + }, + } + + text = json.dumps(payload, indent=2, sort_keys=True) + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(text + "\n", encoding="utf-8") + else: + print(text) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as exc: + print(f"error: {exc}", file=sys.stderr) + raise SystemExit(2)