From b049208e1397d43a73455a50ef3488adc49715f6 Mon Sep 17 00:00:00 2001
From: Ib Green
Date: Tue, 4 Aug 2026 22:25:53 -0400
Subject: [PATCH 1/2] feat(experimental): add live CPU and WebGPU graph
benchmarks
---
modules/experimental/package.json | 5 +
.../experimental/src/lugraph/benchmarks.ts | 15 +
.../src/lugraph/lu-graph-benchmark-data.ts | 697 ++++++++++++++++++
.../src/lugraph/lu-graph-benchmark.ts | 697 ++++++++++++++++++
.../lugraph/lu-graph-benchmark.node.spec.ts | 347 +++++++++
.../test/lugraph/lu-graph-benchmark.spec.ts | 234 ++++++
.../src/components/docs/lugraph-benchmark.tsx | 236 ++++++
7 files changed, 2231 insertions(+)
create mode 100644 modules/experimental/src/lugraph/benchmarks.ts
create mode 100644 modules/experimental/src/lugraph/lu-graph-benchmark-data.ts
create mode 100644 modules/experimental/src/lugraph/lu-graph-benchmark.ts
create mode 100644 modules/experimental/test/lugraph/lu-graph-benchmark.node.spec.ts
create mode 100644 modules/experimental/test/lugraph/lu-graph-benchmark.spec.ts
create mode 100644 website/src/components/docs/lugraph-benchmark.tsx
diff --git a/modules/experimental/package.json b/modules/experimental/package.json
index 75c072fa1a..1d00234996 100644
--- a/modules/experimental/package.json
+++ b/modules/experimental/package.json
@@ -42,6 +42,11 @@
"require": "./dist/lugraph/index.cjs",
"types": "./dist/lugraph/index.d.ts"
},
+ "./lugraph/benchmarks": {
+ "import": "./dist/lugraph/benchmarks.js",
+ "require": "./dist/lugraph/benchmarks.cjs",
+ "types": "./dist/lugraph/benchmarks.d.ts"
+ },
"./luraster": {
"import": "./dist/luraster/index.js",
"require": "./dist/luraster/index.cjs",
diff --git a/modules/experimental/src/lugraph/benchmarks.ts b/modules/experimental/src/lugraph/benchmarks.ts
new file mode 100644
index 0000000000..02941ba76c
--- /dev/null
+++ b/modules/experimental/src/lugraph/benchmarks.ts
@@ -0,0 +1,15 @@
+// luma.gl
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+export {runLuGraphBenchmark} from './lu-graph-benchmark';
+export {makeLuGraphBenchmarkDataset} from './lu-graph-benchmark-data';
+export type {
+ LuGraphBenchmarkAlgorithm,
+ LuGraphBenchmarkDataset,
+ LuGraphBenchmarkDatasetKind,
+ LuGraphBenchmarkDistribution,
+ LuGraphBenchmarkOptions,
+ LuGraphBenchmarkPathReport,
+ LuGraphBenchmarkReport
+} from './lu-graph-benchmark-data';
diff --git a/modules/experimental/src/lugraph/lu-graph-benchmark-data.ts b/modules/experimental/src/lugraph/lu-graph-benchmark-data.ts
new file mode 100644
index 0000000000..698243e1d6
--- /dev/null
+++ b/modules/experimental/src/lugraph/lu-graph-benchmark-data.ts
@@ -0,0 +1,697 @@
+// luma.gl
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+/** Deterministic graph families compared against their actual browser GPU execution. */
+export type LuGraphBenchmarkDatasetKind =
+ | 'sparse'
+ | 'dense'
+ | 'scale-free'
+ | 'disconnected'
+ | 'high-degree';
+
+/** Explicit workload and repetition controls for an opt-in graph benchmark. */
+export type LuGraphBenchmarkOptions = {
+ kind: LuGraphBenchmarkDatasetKind;
+ vertexCount: number;
+ seed?: number;
+ warmupIterations?: number;
+ measuredIterations?: number;
+ pageRankIterations?: number;
+ forceIterations?: number;
+ maxDepth?: number;
+ theta?: number;
+ gridSize?: readonly [number, number];
+};
+
+/** Source edge batches, including the deliberately preserved empty middle partition. */
+export type LuGraphBenchmarkDataset = {
+ kind: LuGraphBenchmarkDatasetKind;
+ vertexCount: number;
+ edgeCount: number;
+ sourceChunks: Uint32Array[];
+ targetChunks: Uint32Array[];
+ positions: Float32Array;
+};
+
+/** Actual CPU and WebGPU algorithm implementations compared by the live benchmark. */
+export type LuGraphBenchmarkAlgorithm =
+ | 'topology'
+ | 'breadth-first-search'
+ | 'connected-components'
+ | 'page-rank'
+ | 'exact-layout'
+ | 'spatial-layout';
+
+/** Non-interpolated nearest-rank summary of real observed durations. */
+export type LuGraphBenchmarkDistribution = {
+ minimum: number;
+ median: number;
+ percentile95: number;
+ maximum: number;
+};
+
+/** Correctness-gated timings and physical allocations for one real GPU algorithm. */
+export type LuGraphBenchmarkPathReport = {
+ algorithm: LuGraphBenchmarkAlgorithm;
+ cpuTimeMilliseconds: LuGraphBenchmarkDistribution;
+ cpuEncodeTimeMilliseconds: LuGraphBenchmarkDistribution;
+ synchronizedTimeMilliseconds: LuGraphBenchmarkDistribution;
+ gpuTimeMilliseconds?: LuGraphBenchmarkDistribution;
+ maxAbsoluteError: number;
+ importedBufferBytes: number;
+ transientBufferBytes: number;
+};
+
+/** Browser/device-specific results; no result is populated without executing its workload. */
+export type LuGraphBenchmarkReport = {
+ datasetKind: LuGraphBenchmarkDatasetKind;
+ vertexCount: number;
+ edgeCount: number;
+ warmupIterations: number;
+ measuredIterations: number;
+ timestampQueries: boolean;
+ uploadTimeMilliseconds: number;
+ compilationTimeMilliseconds: number;
+ readbackTimeMilliseconds: number;
+ spatialIndexBuildTimeMilliseconds: LuGraphBenchmarkDistribution;
+ indexMemoryBytes: number;
+ approximationMaxAbsoluteError: number;
+ paths: LuGraphBenchmarkPathReport[];
+};
+
+/** @internal Exact CPU outputs used to reject incomplete or incorrect GPU measurements. */
+export type LuGraphBenchmarkReference = {
+ forwardOffsets: Uint32Array;
+ forwardNeighbors: Uint32Array;
+ reverseOffsets: Uint32Array;
+ reverseNeighbors: Uint32Array;
+ distances: Uint32Array;
+ predecessors: Uint32Array;
+ components: Uint32Array;
+ pageRank: Float32Array;
+ exactPositions: Float32Array;
+ exactVelocities: Float32Array;
+ spatialPositions: Float32Array;
+ spatialVelocities: Float32Array;
+};
+
+/** @internal Shared validated source rows, independently evaluated references, and CPU timings. */
+export type LuGraphBenchmarkContext = {
+ options: Required;
+ dataset: LuGraphBenchmarkDataset;
+ reference: LuGraphBenchmarkReference;
+ cpuTimeMilliseconds: Record;
+};
+
+type LuGraphBenchmarkAdjacency = Pick<
+ LuGraphBenchmarkReference,
+ 'forwardOffsets' | 'forwardNeighbors' | 'reverseOffsets' | 'reverseNeighbors'
+>;
+
+type LuGraphBenchmarkSearch = Pick;
+
+type LuGraphBenchmarkLayout = {positions: Float32Array; velocities: Float32Array};
+
+type MeasuredLuGraphBenchmarkValue = {
+ value: Value;
+ timeMilliseconds: LuGraphBenchmarkDistribution;
+};
+
+const UINT32_MAXIMUM = 0xffffffff;
+const MAXIMUM_VERTEX_COUNT = 0xfffffffe;
+const MINIMUM_REPULSION_DISTANCE_SQUARED = 0.0001;
+const PAGE_RANK_DAMPING = 0.85;
+const DATASET_KINDS: readonly LuGraphBenchmarkDatasetKind[] = [
+ 'sparse',
+ 'dense',
+ 'scale-free',
+ 'disconnected',
+ 'high-degree'
+];
+
+/** @internal Shared exact/approximate physics avoid comparing unrelated implementations. */
+export const LU_GRAPH_BENCHMARK_FORCE_PROPS = {
+ repulsion: 0.01,
+ attraction: 0.05,
+ gravity: 0.01,
+ damping: 0.9,
+ maxVelocity: 0.05,
+ timeStep: 1
+} as const;
+
+/** @internal Explicit indexing domain shared by CPU cell assignment and real GPUGridIndex. */
+export const LU_GRAPH_BENCHMARK_BOUNDS = [-2, -2, 2, 2] as const;
+
+/**
+ * Creates reproducible directed workloads without flattening the explicit source partitions.
+ *
+ * The returned arrays belong to the caller; repeated invocations never share mutable storage.
+ * The middle source and target chunks stay empty so measured topology construction exercises
+ * the same ordered batch contract as streamed graph ingestion.
+ */
+export function makeLuGraphBenchmarkDataset(
+ options: Pick
+): LuGraphBenchmarkDataset {
+ const {kind, vertexCount} = options;
+ const seed = options.seed ?? 0;
+ if (!DATASET_KINDS.includes(kind)) {
+ throw new Error('luGraph benchmark dataset kind is unsupported');
+ }
+ validateInteger('vertexCount', vertexCount, 1, MAXIMUM_VERTEX_COUNT);
+ validateInteger('seed', seed, 0, UINT32_MAXIMUM);
+
+ const sources: number[] = [];
+ const targets: number[] = [];
+ let randomState = seed;
+ const nextRandom = (): number => {
+ randomState = (Math.imul(randomState, 1664525) + 1013904223) >>> 0;
+ return randomState / 0x100000000;
+ };
+ const addEdge = (source: number, target: number): void => {
+ sources.push(source);
+ targets.push(target);
+ };
+
+ switch (kind) {
+ case 'sparse': {
+ if (vertexCount > 1) {
+ for (let vertex = 0; vertex < vertexCount; vertex++) {
+ addEdge(vertex, (vertex + 1) % vertexCount);
+ if (vertex % 7 === 0 && vertexCount > 3) {
+ addEdge(vertex, (vertex + 3) % vertexCount);
+ }
+ }
+ }
+ break;
+ }
+
+ case 'dense': {
+ if (vertexCount * (vertexCount - 1) > UINT32_MAXIMUM) {
+ throw new Error('luGraph benchmark dense edgeCount exceeds uint32 capacity');
+ }
+ for (let source = 0; source < vertexCount; source++) {
+ for (let target = 0; target < vertexCount; target++) {
+ if (source !== target) addEdge(source, target);
+ }
+ }
+ break;
+ }
+
+ case 'scale-free': {
+ if (vertexCount > 1) {
+ addEdge(0, 1);
+ const preferentialVertices = [0, 1];
+ for (let vertex = 2; vertex < vertexCount; vertex++) {
+ const attachments = Math.min(2, vertex);
+ const selected = new Set();
+ while (selected.size < attachments) {
+ selected.add(
+ preferentialVertices[Math.floor(nextRandom() * preferentialVertices.length)]
+ );
+ }
+ for (const target of selected) {
+ addEdge(target, vertex);
+ preferentialVertices.push(vertex, target);
+ }
+ }
+ }
+ break;
+ }
+
+ case 'disconnected': {
+ // Reserve the final vertex as an observable isolated weak component.
+ const connectedVertices = Math.max(0, vertexCount - 1);
+ const componentCount = Math.min(3, connectedVertices);
+ for (let component = 0; component < componentCount; component++) {
+ const first = Math.floor((component * connectedVertices) / componentCount);
+ const last = Math.floor(((component + 1) * connectedVertices) / componentCount);
+ for (let vertex = first; vertex + 1 < last; vertex++) {
+ addEdge(vertex, vertex + 1);
+ if ((vertex - first) % 4 === 0 && vertex + 2 < last) {
+ addEdge(vertex, vertex + 2);
+ }
+ }
+ }
+ break;
+ }
+
+ case 'high-degree': {
+ for (let vertex = 1; vertex < vertexCount; vertex++) {
+ addEdge(0, vertex);
+ if (vertex % 3 === 0) addEdge(vertex, 0);
+ }
+ break;
+ }
+ }
+
+ const positions = new Float32Array(vertexCount * 2);
+ const goldenAngle = Math.PI * (3 - Math.sqrt(5));
+ for (let vertex = 0; vertex < vertexCount; vertex++) {
+ const radius = 0.15 + 0.8 * Math.sqrt((vertex + 0.5) / vertexCount);
+ const angle = vertex * goldenAngle + nextRandom() * 0.2;
+ positions[vertex * 2] = radius * Math.cos(angle);
+ positions[vertex * 2 + 1] = radius * Math.sin(angle);
+ }
+
+ const split = Math.ceil(sources.length / 2);
+ return {
+ kind,
+ vertexCount,
+ edgeCount: sources.length,
+ sourceChunks: [
+ Uint32Array.from(sources.slice(0, split)),
+ new Uint32Array(0),
+ Uint32Array.from(sources.slice(split))
+ ],
+ targetChunks: [
+ Uint32Array.from(targets.slice(0, split)),
+ new Uint32Array(0),
+ Uint32Array.from(targets.slice(split))
+ ],
+ positions
+ };
+}
+
+/** @internal Generates every reference independently before any GPU timing is reported. */
+export function prepareLuGraphBenchmark(options: LuGraphBenchmarkOptions): LuGraphBenchmarkContext {
+ const normalizedOptions = normalizeLuGraphBenchmarkOptions(options);
+ const dataset = makeLuGraphBenchmarkDataset(normalizedOptions);
+ const adjacency = measureLuGraphBenchmarkValue(normalizedOptions, () =>
+ buildLuGraphBenchmarkAdjacency(dataset)
+ );
+ const search = measureLuGraphBenchmarkValue(normalizedOptions, () =>
+ evaluateLuGraphBenchmarkBreadthFirstSearch(adjacency.value, normalizedOptions.maxDepth)
+ );
+ const components = measureLuGraphBenchmarkValue(normalizedOptions, () =>
+ evaluateLuGraphBenchmarkConnectedComponents(dataset)
+ );
+ const pageRank = measureLuGraphBenchmarkValue(normalizedOptions, () =>
+ evaluateLuGraphBenchmarkPageRank(adjacency.value, normalizedOptions.pageRankIterations)
+ );
+ const exactLayout = measureLuGraphBenchmarkValue(normalizedOptions, () =>
+ evaluateLuGraphBenchmarkForceLayout(dataset, adjacency.value, normalizedOptions, false)
+ );
+ const spatialLayout = measureLuGraphBenchmarkValue(normalizedOptions, () =>
+ evaluateLuGraphBenchmarkForceLayout(dataset, adjacency.value, normalizedOptions, true)
+ );
+
+ return {
+ options: normalizedOptions,
+ dataset,
+ reference: {
+ ...adjacency.value,
+ ...search.value,
+ components: components.value,
+ pageRank: pageRank.value,
+ exactPositions: exactLayout.value.positions,
+ exactVelocities: exactLayout.value.velocities,
+ spatialPositions: spatialLayout.value.positions,
+ spatialVelocities: spatialLayout.value.velocities
+ },
+ cpuTimeMilliseconds: {
+ topology: adjacency.timeMilliseconds,
+ 'breadth-first-search': search.timeMilliseconds,
+ 'connected-components': components.timeMilliseconds,
+ 'page-rank': pageRank.timeMilliseconds,
+ 'exact-layout': exactLayout.timeMilliseconds,
+ 'spatial-layout': spatialLayout.timeMilliseconds
+ }
+ };
+}
+
+/** @internal Uses the browser's monotonic timer without preventing portable Node execution. */
+export function getLuGraphBenchmarkTime(): number {
+ return globalThis.performance?.now() ?? Date.now();
+}
+
+/** @internal Preserves observed nearest-rank durations without interpolation or synthetic data. */
+export function summarizeLuGraphBenchmarkSamples(
+ samples: readonly number[]
+): LuGraphBenchmarkDistribution {
+ if (samples.length === 0 || samples.some(sample => !Number.isFinite(sample) || sample < 0)) {
+ throw new Error('luGraph benchmark samples must contain finite non-negative durations');
+ }
+ const sortedSamples = [...samples].sort((left, right) => left - right);
+ return {
+ minimum: sortedSamples[0],
+ median: sortedSamples[Math.ceil(sortedSamples.length * 0.5) - 1],
+ percentile95: sortedSamples[Math.ceil(sortedSamples.length * 0.95) - 1],
+ maximum: sortedSamples[sortedSamples.length - 1]
+ };
+}
+
+function normalizeLuGraphBenchmarkOptions(
+ options: LuGraphBenchmarkOptions
+): Required {
+ const normalizedOptions = {
+ kind: options.kind,
+ vertexCount: options.vertexCount,
+ seed: options.seed ?? 0,
+ warmupIterations: options.warmupIterations ?? 1,
+ measuredIterations: options.measuredIterations ?? 3,
+ pageRankIterations: options.pageRankIterations ?? 20,
+ forceIterations: options.forceIterations ?? 1,
+ maxDepth: options.maxDepth ?? 8,
+ theta: options.theta ?? 0.6,
+ gridSize: options.gridSize ?? [8, 8]
+ } satisfies Required;
+
+ validateInteger('warmupIterations', normalizedOptions.warmupIterations, 0, UINT32_MAXIMUM);
+ validateInteger('measuredIterations', normalizedOptions.measuredIterations, 1, UINT32_MAXIMUM);
+ validateInteger('pageRankIterations', normalizedOptions.pageRankIterations, 1, 1024);
+ validateInteger('forceIterations', normalizedOptions.forceIterations, 1, 1024);
+ validateInteger('maxDepth', normalizedOptions.maxDepth, 0, 1024);
+ if (!Number.isFinite(normalizedOptions.theta) || normalizedOptions.theta < 0) {
+ throw new Error('luGraph benchmark theta must be finite and non-negative');
+ }
+ if (!Array.isArray(normalizedOptions.gridSize) || normalizedOptions.gridSize.length !== 2) {
+ throw new Error('luGraph benchmark gridSize must contain two positive dimensions');
+ }
+ validateInteger('gridSize width', normalizedOptions.gridSize[0], 1, MAXIMUM_VERTEX_COUNT);
+ validateInteger('gridSize height', normalizedOptions.gridSize[1], 1, MAXIMUM_VERTEX_COUNT);
+ if (normalizedOptions.gridSize[0] * normalizedOptions.gridSize[1] > MAXIMUM_VERTEX_COUNT) {
+ throw new Error('luGraph benchmark gridSize cell count exceeds uint32 capacity');
+ }
+ return normalizedOptions;
+}
+
+function validateInteger(name: string, value: number, minimum: number, maximum: number): void {
+ if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
+ throw new Error(`luGraph benchmark ${name} must be an integer in the supported range`);
+ }
+}
+
+function measureLuGraphBenchmarkValue(
+ options: Required,
+ evaluate: () => Value
+): MeasuredLuGraphBenchmarkValue {
+ for (let iteration = 0; iteration < options.warmupIterations; iteration++) {
+ evaluate();
+ }
+ let value: Value | undefined;
+ const samples: number[] = [];
+ for (let iteration = 0; iteration < options.measuredIterations; iteration++) {
+ const startTime = getLuGraphBenchmarkTime();
+ value = evaluate();
+ samples.push(getLuGraphBenchmarkTime() - startTime);
+ }
+ return {value: value!, timeMilliseconds: summarizeLuGraphBenchmarkSamples(samples)};
+}
+
+function buildLuGraphBenchmarkAdjacency(
+ dataset: LuGraphBenchmarkDataset
+): LuGraphBenchmarkAdjacency {
+ const forwardOffsets = new Uint32Array(dataset.vertexCount + 1);
+ const reverseOffsets = new Uint32Array(dataset.vertexCount + 1);
+ forEachBenchmarkEdge(dataset, (source, target) => {
+ forwardOffsets[source + 1]++;
+ reverseOffsets[target + 1]++;
+ });
+ for (let vertex = 0; vertex < dataset.vertexCount; vertex++) {
+ forwardOffsets[vertex + 1] += forwardOffsets[vertex];
+ reverseOffsets[vertex + 1] += reverseOffsets[vertex];
+ }
+ const forwardNeighbors = new Uint32Array(dataset.edgeCount);
+ const reverseNeighbors = new Uint32Array(dataset.edgeCount);
+ const forwardCursors = forwardOffsets.slice(0, -1);
+ const reverseCursors = reverseOffsets.slice(0, -1);
+ forEachBenchmarkEdge(dataset, (source, target) => {
+ forwardNeighbors[forwardCursors[source]++] = target;
+ reverseNeighbors[reverseCursors[target]++] = source;
+ });
+ return {forwardOffsets, forwardNeighbors, reverseOffsets, reverseNeighbors};
+}
+
+function evaluateLuGraphBenchmarkBreadthFirstSearch(
+ adjacency: LuGraphBenchmarkAdjacency,
+ maxDepth: number
+): LuGraphBenchmarkSearch {
+ const vertexCount = adjacency.forwardOffsets.length - 1;
+ const distances = new Uint32Array(vertexCount).fill(UINT32_MAXIMUM);
+ const predecessors = new Uint32Array(vertexCount).fill(UINT32_MAXIMUM);
+ distances[0] = 0;
+ const frontier = new Uint32Array(vertexCount);
+ let first = 0;
+ let last = 1;
+ while (first < last) {
+ const source = frontier[first++];
+ const distance = distances[source];
+ if (distance >= maxDepth) continue;
+ for (
+ let slot = adjacency.forwardOffsets[source];
+ slot < adjacency.forwardOffsets[source + 1];
+ slot++
+ ) {
+ const target = adjacency.forwardNeighbors[slot];
+ if (distances[target] === UINT32_MAXIMUM) {
+ distances[target] = distance + 1;
+ predecessors[target] = source;
+ frontier[last++] = target;
+ } else if (distances[target] === distance + 1) {
+ predecessors[target] = Math.min(predecessors[target], source);
+ }
+ }
+ }
+ return {distances, predecessors};
+}
+
+function evaluateLuGraphBenchmarkConnectedComponents(
+ dataset: LuGraphBenchmarkDataset
+): Uint32Array {
+ const parents = Uint32Array.from({length: dataset.vertexCount}, (_, vertex) => vertex);
+ const findRoot = (vertex: number): number => {
+ let root = vertex;
+ while (parents[root] !== root) root = parents[root];
+ while (parents[vertex] !== vertex) {
+ const next = parents[vertex];
+ parents[vertex] = root;
+ vertex = next;
+ }
+ return root;
+ };
+ forEachBenchmarkEdge(dataset, (source, target) => {
+ const firstRoot = findRoot(source);
+ const secondRoot = findRoot(target);
+ if (firstRoot < secondRoot) parents[secondRoot] = firstRoot;
+ else if (secondRoot < firstRoot) parents[firstRoot] = secondRoot;
+ });
+ return Uint32Array.from(parents, (_, vertex) => findRoot(vertex));
+}
+
+function evaluateLuGraphBenchmarkPageRank(
+ adjacency: LuGraphBenchmarkAdjacency,
+ iterations: number
+): Float32Array {
+ const vertexCount = adjacency.forwardOffsets.length - 1;
+ let scores = new Float32Array(vertexCount).fill(1 / vertexCount);
+ for (let iteration = 0; iteration < iterations; iteration++) {
+ let danglingMass = 0;
+ for (let vertex = 0; vertex < vertexCount; vertex++) {
+ if (adjacency.forwardOffsets[vertex + 1] === adjacency.forwardOffsets[vertex]) {
+ danglingMass += scores[vertex];
+ }
+ }
+ const next = new Float32Array(vertexCount);
+ let total = 0;
+ for (let vertex = 0; vertex < vertexCount; vertex++) {
+ let incomingMass = 0;
+ for (
+ let slot = adjacency.reverseOffsets[vertex];
+ slot < adjacency.reverseOffsets[vertex + 1];
+ slot++
+ ) {
+ const neighbor = adjacency.reverseNeighbors[slot];
+ const degree = adjacency.forwardOffsets[neighbor + 1] - adjacency.forwardOffsets[neighbor];
+ if (degree > 0) incomingMass += scores[neighbor] / degree;
+ }
+ next[vertex] =
+ (1 - PAGE_RANK_DAMPING) / vertexCount +
+ PAGE_RANK_DAMPING * (incomingMass + danglingMass / vertexCount);
+ total += next[vertex];
+ }
+ for (let vertex = 0; vertex < vertexCount; vertex++) next[vertex] /= total;
+ scores = next;
+ }
+ return scores;
+}
+
+function evaluateLuGraphBenchmarkForceLayout(
+ dataset: LuGraphBenchmarkDataset,
+ adjacency: LuGraphBenchmarkAdjacency,
+ options: Required,
+ approximate: boolean
+): LuGraphBenchmarkLayout {
+ const positions = dataset.positions.slice();
+ const velocities = new Float32Array(positions.length);
+ const {repulsion, attraction, gravity, damping, maxVelocity, timeStep} =
+ LU_GRAPH_BENCHMARK_FORCE_PROPS;
+
+ for (let iteration = 0; iteration < options.forceIterations; iteration++) {
+ const cells = approximate ? buildLuGraphBenchmarkCells(positions, options.gridSize) : undefined;
+ if (approximate && !cells) {
+ velocities.fill(0);
+ continue;
+ }
+ const cellCenters = cells?.map(occupants => {
+ let centerX = 0;
+ let centerY = 0;
+ for (const occupant of occupants) {
+ centerX += positions[occupant * 2];
+ centerY += positions[occupant * 2 + 1];
+ }
+ return occupants.length === 0
+ ? [0, 0]
+ : [Math.fround(centerX / occupants.length), Math.fround(centerY / occupants.length)];
+ });
+ const intermediate = approximate ? new Float32Array(positions.length) : undefined;
+
+ for (let vertex = 0; vertex < dataset.vertexCount; vertex++) {
+ const positionX = positions[vertex * 2];
+ const positionY = positions[vertex * 2 + 1];
+ let forceX = -gravity * positionX;
+ let forceY = -gravity * positionY;
+
+ if (cells) {
+ const [gridWidth, gridHeight] = options.gridSize;
+ const sourceColumn = getLuGraphBenchmarkCellCoordinate(positionX, gridWidth);
+ const sourceRow = getLuGraphBenchmarkCellCoordinate(positionY, gridHeight);
+ const cellWidth = 4 / gridWidth;
+ const cellHeight = 4 / gridHeight;
+ const diameterSquared = cellWidth * cellWidth + cellHeight * cellHeight;
+
+ for (let cell = 0; cell < cells.length; cell++) {
+ const occupants = cells[cell];
+ if (occupants.length === 0) continue;
+ const column = cell % gridWidth;
+ const row = Math.floor(cell / gridWidth);
+ const isNear = Math.abs(column - sourceColumn) <= 1 && Math.abs(row - sourceRow) <= 1;
+ const [centerX, centerY] = cellCenters![cell];
+ const differenceX = positionX - centerX;
+ const differenceY = positionY - centerY;
+ const distanceSquared = differenceX * differenceX + differenceY * differenceY;
+ const useMonopole =
+ !isNear &&
+ options.theta > 0 &&
+ diameterSquared < options.theta * options.theta * distanceSquared;
+ if (useMonopole) {
+ const scale =
+ (repulsion * occupants.length) /
+ Math.max(distanceSquared, MINIMUM_REPULSION_DISTANCE_SQUARED);
+ forceX += differenceX * scale;
+ forceY += differenceY * scale;
+ } else {
+ for (const otherVertex of occupants) {
+ if (otherVertex === vertex) continue;
+ const differenceToVertexX = positionX - positions[otherVertex * 2];
+ const differenceToVertexY = positionY - positions[otherVertex * 2 + 1];
+ const scale =
+ repulsion /
+ Math.max(
+ differenceToVertexX * differenceToVertexX +
+ differenceToVertexY * differenceToVertexY,
+ MINIMUM_REPULSION_DISTANCE_SQUARED
+ );
+ forceX += differenceToVertexX * scale;
+ forceY += differenceToVertexY * scale;
+ }
+ }
+ }
+ intermediate![vertex * 2] = velocities[vertex * 2] + forceX * timeStep;
+ intermediate![vertex * 2 + 1] = velocities[vertex * 2 + 1] + forceY * timeStep;
+ forceX = 0;
+ forceY = 0;
+ } else {
+ for (let otherVertex = 0; otherVertex < dataset.vertexCount; otherVertex++) {
+ if (otherVertex === vertex) continue;
+ const differenceX = positionX - positions[otherVertex * 2];
+ const differenceY = positionY - positions[otherVertex * 2 + 1];
+ const scale =
+ repulsion /
+ Math.max(
+ differenceX * differenceX + differenceY * differenceY,
+ MINIMUM_REPULSION_DISTANCE_SQUARED
+ );
+ forceX += differenceX * scale;
+ forceY += differenceY * scale;
+ }
+ }
+
+ for (const offsetsAndNeighbors of [
+ [adjacency.forwardOffsets, adjacency.forwardNeighbors],
+ [adjacency.reverseOffsets, adjacency.reverseNeighbors]
+ ]) {
+ const [offsets, neighbors] = offsetsAndNeighbors;
+ for (let slot = offsets[vertex]; slot < offsets[vertex + 1]; slot++) {
+ const neighbor = neighbors[slot];
+ forceX += attraction * (positions[neighbor * 2] - positionX);
+ forceY += attraction * (positions[neighbor * 2 + 1] - positionY);
+ }
+ }
+ let velocityX =
+ ((intermediate ? intermediate[vertex * 2] : velocities[vertex * 2]) + forceX * timeStep) *
+ damping;
+ let velocityY =
+ ((intermediate ? intermediate[vertex * 2 + 1] : velocities[vertex * 2 + 1]) +
+ forceY * timeStep) *
+ damping;
+ const speed = Math.hypot(velocityX, velocityY);
+ if (speed > maxVelocity) {
+ velocityX *= maxVelocity / speed;
+ velocityY *= maxVelocity / speed;
+ }
+ velocities[vertex * 2] = velocityX;
+ velocities[vertex * 2 + 1] = velocityY;
+ }
+
+ for (let vertex = 0; vertex < dataset.vertexCount; vertex++) {
+ positions[vertex * 2] += velocities[vertex * 2] * timeStep;
+ positions[vertex * 2 + 1] += velocities[vertex * 2 + 1] * timeStep;
+ }
+ }
+ return {positions, velocities};
+}
+
+function buildLuGraphBenchmarkCells(
+ positions: Float32Array,
+ gridSize: readonly [number, number]
+): number[][] | undefined {
+ const [gridWidth, gridHeight] = gridSize;
+ const cells = Array.from({length: gridWidth * gridHeight}, () => [] as number[]);
+ for (let vertex = 0; vertex < positions.length / 2; vertex++) {
+ const positionX = positions[vertex * 2];
+ const positionY = positions[vertex * 2 + 1];
+ if (
+ !Number.isFinite(positionX) ||
+ !Number.isFinite(positionY) ||
+ positionX < -2 ||
+ positionX > 2 ||
+ positionY < -2 ||
+ positionY > 2
+ ) {
+ return undefined;
+ }
+ const column = getLuGraphBenchmarkCellCoordinate(positionX, gridWidth);
+ const row = getLuGraphBenchmarkCellCoordinate(positionY, gridHeight);
+ cells[row * gridWidth + column].push(vertex);
+ }
+ return cells;
+}
+
+function getLuGraphBenchmarkCellCoordinate(position: number, size: number): number {
+ if (position === -2) return 0;
+ if (position === 2) return size - 1;
+ // Matches GPUGridIndex's signed-bounds normalization path without f32 atomics.
+ return Math.min(Math.floor(((position / 2 + 1) / 2) * size), size - 1);
+}
+
+function forEachBenchmarkEdge(
+ dataset: LuGraphBenchmarkDataset,
+ visit: (source: number, target: number) => void
+): void {
+ for (let chunkIndex = 0; chunkIndex < dataset.sourceChunks.length; chunkIndex++) {
+ const sources = dataset.sourceChunks[chunkIndex];
+ const targets = dataset.targetChunks[chunkIndex];
+ for (let row = 0; row < sources.length; row++) visit(sources[row], targets[row]);
+ }
+}
diff --git a/modules/experimental/src/lugraph/lu-graph-benchmark.ts b/modules/experimental/src/lugraph/lu-graph-benchmark.ts
new file mode 100644
index 0000000000..fbab717a12
--- /dev/null
+++ b/modules/experimental/src/lugraph/lu-graph-benchmark.ts
@@ -0,0 +1,697 @@
+// luma.gl
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+import {Buffer, type Device, type QuerySet} from '@luma.gl/core';
+import {GPUData, GPUVector} from '@luma.gl/tables';
+import {
+ GPUCommandGraph,
+ type CompiledGPUCommandGraph,
+ type GPUCommandGraphContributor
+} from '../gpu-primitives/gpu-command-graph';
+import {GPUGridIndex} from '../gpu-primitives/gpu-grid-index';
+import {LuGraph} from './lu-graph';
+import {
+ LU_GRAPH_BENCHMARK_BOUNDS,
+ LU_GRAPH_BENCHMARK_FORCE_PROPS,
+ getLuGraphBenchmarkTime,
+ prepareLuGraphBenchmark,
+ summarizeLuGraphBenchmarkSamples,
+ type LuGraphBenchmarkAlgorithm,
+ type LuGraphBenchmarkContext,
+ type LuGraphBenchmarkDataset,
+ type LuGraphBenchmarkDistribution,
+ type LuGraphBenchmarkOptions,
+ type LuGraphBenchmarkPathReport,
+ type LuGraphBenchmarkReport
+} from './lu-graph-benchmark-data';
+import {LuGraphBreadthFirstSearch} from './lu-graph-breadth-first-search';
+import {LuGraphConnectedComponents} from './lu-graph-connected-components';
+import {LuGraphForceLayout} from './lu-graph-force-layout';
+import {LuGraphPageRank} from './lu-graph-page-rank';
+import {LuGraphSpatialForceLayout} from './lu-graph-spatial-force-layout';
+import {LuGraphTopology, type LuGraphAdjacency} from './lu-graph-topology';
+
+const SCALAR_BYTE_LENGTH = 4;
+const PAGE_RANK_TOLERANCE = 0.0001;
+const FORCE_LAYOUT_TOLERANCE = 0.0005;
+
+type BenchmarkScalarFormat = 'uint32' | 'float32';
+
+type CompiledBenchmarkPath = {
+ algorithm: LuGraphBenchmarkAlgorithm;
+ compiled: CompiledGPUCommandGraph;
+};
+
+type BenchmarkExecution = {
+ cpuEncodeTimeMilliseconds: number;
+ synchronizedTimeMilliseconds: number;
+ gpuTimeMilliseconds?: number;
+};
+
+type BenchmarkValidation = {
+ maxAbsoluteError: number;
+ approximationMaxAbsoluteError?: number;
+ readbackTimeMilliseconds: number;
+};
+
+/**
+ * Runs actual CPU and fence-synchronized WebGPU graph algorithms on identical source rows.
+ *
+ * Source upload, command-graph compilation, result validation/readback, and spatial-index
+ * construction are reported separately. An independently evaluated CPU near/far implementation
+ * validates the accelerated path before its approximation error against exact forces is exposed.
+ * No work starts until an application explicitly calls this optional benchmark entry point.
+ */
+export async function runLuGraphBenchmark(
+ device: Device,
+ options: LuGraphBenchmarkOptions
+): Promise {
+ if (device.type !== 'webgpu') {
+ throw new Error('luGraph benchmarks require a WebGPU device');
+ }
+
+ const context = prepareLuGraphBenchmark(options);
+ let resources: LuGraphBenchmarkResources | undefined;
+ const compiledPaths: CompiledBenchmarkPath[] = [];
+ let compiledIndex: CompiledGPUCommandGraph | undefined;
+
+ try {
+ const uploadStartTime = getLuGraphBenchmarkTime();
+ resources = new LuGraphBenchmarkResources(device, context);
+ await waitForBenchmarkFence(device);
+ const uploadTimeMilliseconds = getLuGraphBenchmarkTime() - uploadStartTime;
+
+ let compilationTimeMilliseconds = 0;
+ const contributors: [LuGraphBenchmarkAlgorithm, GPUCommandGraphContributor][] = [
+ ['topology', resources.topology],
+ ['breadth-first-search', resources.search],
+ ['connected-components', resources.components],
+ ['page-rank', resources.pageRank],
+ ['exact-layout', resources.exactLayout],
+ ['spatial-layout', resources.spatialLayout]
+ ];
+ for (const [algorithm, contributor] of contributors) {
+ const startTime = getLuGraphBenchmarkTime();
+ const graph = new GPUCommandGraph(device, {id: `lugraph-benchmark-${algorithm}`});
+ contributor.addToGraph(graph);
+ const compiled = graph.compile();
+ compilationTimeMilliseconds += getLuGraphBenchmarkTime() - startTime;
+ compiledPaths.push({algorithm, compiled});
+ }
+
+ const indexCompilationStartTime = getLuGraphBenchmarkTime();
+ compiledIndex = resources.compileSpatialIndex();
+ compilationTimeMilliseconds += getLuGraphBenchmarkTime() - indexCompilationStartTime;
+
+ let readbackTimeMilliseconds = 0;
+ let approximationMaxAbsoluteError = 0;
+ const paths: LuGraphBenchmarkPathReport[] = [];
+
+ for (const path of compiledPaths) {
+ const initialValidation = await validateBenchmarkPath(device, resources, context, path);
+ readbackTimeMilliseconds += initialValidation.readbackTimeMilliseconds;
+
+ for (let iteration = 0; iteration < context.options.warmupIterations; iteration++) {
+ await resetBenchmarkPath(device, resources, path.algorithm);
+ await executeBenchmarkPath(device, path.compiled, `${path.algorithm}-warmup-${iteration}`);
+ }
+
+ const executions: BenchmarkExecution[] = [];
+ for (let iteration = 0; iteration < context.options.measuredIterations; iteration++) {
+ await resetBenchmarkPath(device, resources, path.algorithm);
+ executions.push(
+ await executeBenchmarkPath(
+ device,
+ path.compiled,
+ `${path.algorithm}-measured-${iteration}`,
+ device.features.has('timestamp-query')
+ )
+ );
+ }
+
+ const finalValidation = await readBenchmarkPath(resources, context, path.algorithm);
+ readbackTimeMilliseconds += finalValidation.readbackTimeMilliseconds;
+ approximationMaxAbsoluteError = Math.max(
+ approximationMaxAbsoluteError,
+ initialValidation.approximationMaxAbsoluteError ?? 0,
+ finalValidation.approximationMaxAbsoluteError ?? 0
+ );
+
+ const gpuSamples = executions.flatMap(execution =>
+ execution.gpuTimeMilliseconds === undefined ? [] : [execution.gpuTimeMilliseconds]
+ );
+ paths.push({
+ algorithm: path.algorithm,
+ cpuTimeMilliseconds: context.cpuTimeMilliseconds[path.algorithm],
+ cpuEncodeTimeMilliseconds: summarizeLuGraphBenchmarkSamples(
+ executions.map(execution => execution.cpuEncodeTimeMilliseconds)
+ ),
+ synchronizedTimeMilliseconds: summarizeLuGraphBenchmarkSamples(
+ executions.map(execution => execution.synchronizedTimeMilliseconds)
+ ),
+ ...(gpuSamples.length > 0
+ ? {gpuTimeMilliseconds: summarizeLuGraphBenchmarkSamples(gpuSamples)}
+ : {}),
+ maxAbsoluteError: Math.max(
+ initialValidation.maxAbsoluteError,
+ finalValidation.maxAbsoluteError
+ ),
+ importedBufferBytes: path.compiled.stats.importedBufferBytes,
+ transientBufferBytes: path.compiled.stats.physicalTransientBytes
+ });
+ }
+
+ const spatialIndexBuildTimeMilliseconds = await measureSpatialIndexBuild(
+ device,
+ resources,
+ context,
+ compiledIndex
+ );
+
+ return {
+ datasetKind: context.dataset.kind,
+ vertexCount: context.dataset.vertexCount,
+ edgeCount: context.dataset.edgeCount,
+ warmupIterations: context.options.warmupIterations,
+ measuredIterations: context.options.measuredIterations,
+ timestampQueries: device.features.has('timestamp-query'),
+ uploadTimeMilliseconds,
+ compilationTimeMilliseconds,
+ readbackTimeMilliseconds,
+ spatialIndexBuildTimeMilliseconds,
+ indexMemoryBytes: resources.indexMemoryBytes,
+ approximationMaxAbsoluteError,
+ paths
+ };
+ } finally {
+ compiledIndex?.destroy();
+ for (const path of compiledPaths.reverse()) path.compiled.destroy();
+ resources?.destroy();
+ }
+}
+
+/** Caller-owned benchmark allocations, vectors, topology, and reusable algorithm contributors. */
+class LuGraphBenchmarkResources {
+ readonly device: Device;
+ readonly dataset: LuGraphBenchmarkDataset;
+ readonly buffers: Buffer[] = [];
+ readonly vectors: GPUVector[] = [];
+ readonly graph: LuGraph;
+ readonly topology: LuGraphTopology;
+ readonly search: LuGraphBreadthFirstSearch;
+ readonly components: LuGraphConnectedComponents;
+ readonly pageRank: LuGraphPageRank;
+ readonly exactLayout: LuGraphForceLayout;
+ readonly spatialLayout: LuGraphSpatialForceLayout;
+ readonly indexMemoryBytes: number;
+
+ constructor(device: Device, context: LuGraphBenchmarkContext) {
+ this.device = device;
+ this.dataset = context.dataset;
+ const vertexCount = context.dataset.vertexCount;
+ const edgeCount = context.dataset.edgeCount;
+
+ this.graph = new LuGraph({
+ vertexCount,
+ directed: true,
+ sourceVertices: this.createChunkedVector('sources', context.dataset.sourceChunks),
+ targetVertices: this.createChunkedVector('targets', context.dataset.targetChunks)
+ });
+ const forward = this.createAdjacency('forward', vertexCount, edgeCount);
+ const reverse = this.createAdjacency('reverse', vertexCount, edgeCount);
+ this.topology = new LuGraphTopology({
+ id: 'lugraph-benchmark-topology',
+ graph: this.graph,
+ forward,
+ reverse,
+ invalidEdgeCount: this.createScalarVector('invalid-edges', 'uint32', 1)
+ });
+ this.search = new LuGraphBreadthFirstSearch({
+ id: 'lugraph-benchmark-search',
+ topology: this.topology,
+ seeds: this.createScalarVector('seeds', 'uint32', 1, new Uint32Array([0])),
+ distances: this.createScalarVector('distances', 'uint32', vertexCount),
+ predecessors: this.createScalarVector('predecessors', 'uint32', vertexCount),
+ maxDepth: context.options.maxDepth,
+ direction: 'outgoing'
+ });
+ this.components = new LuGraphConnectedComponents({
+ id: 'lugraph-benchmark-components',
+ topology: this.topology,
+ output: this.createScalarVector('component-labels', 'uint32', vertexCount),
+ converged: this.createScalarVector('component-convergence', 'uint32', 1)
+ });
+ this.pageRank = new LuGraphPageRank({
+ id: 'lugraph-benchmark-page-rank',
+ topology: this.topology,
+ output: this.createScalarVector('page-rank', 'float32', vertexCount),
+ residual: this.createScalarVector('page-rank-residual', 'float32', 1),
+ iterations: context.options.pageRankIterations
+ });
+
+ this.exactLayout = this.createForceLayout('exact', context);
+ const approximateLayout = this.createForceLayout('spatial', context);
+ const cellCount = context.options.gridSize[0] * context.options.gridSize[1];
+ const cellOffsets = this.createScalarVector('cell-offsets', 'uint32', cellCount + 1);
+ const vertexIds = this.createScalarVector('spatial-vertex-ids', 'uint32', vertexCount);
+ const cellCenters = this.createCoordinateVector(
+ 'cell-centers',
+ new Float32Array(cellCount * 2)
+ );
+ const count = this.createScalarVector('spatial-count', 'uint32', 1);
+ const overflow = this.createScalarVector('spatial-overflow', 'uint32', 1);
+ this.spatialLayout = new LuGraphSpatialForceLayout({
+ id: 'lugraph-benchmark-spatial-layout',
+ layout: approximateLayout,
+ gridSize: context.options.gridSize,
+ bounds: LU_GRAPH_BENCHMARK_BOUNDS,
+ theta: context.options.theta,
+ cellOffsets,
+ vertexIds,
+ cellCenters,
+ count,
+ overflow
+ });
+ this.indexMemoryBytes = [cellOffsets, vertexIds, cellCenters, count, overflow].reduce(
+ (byteLength, vector) => byteLength + vector.data[0].buffer.byteLength,
+ 0
+ );
+ }
+
+ /** Independently rebuilds the exact same explicit spatial grid for honest construction timing. */
+ compileSpatialIndex(): CompiledGPUCommandGraph {
+ const graph = new GPUCommandGraph(this.device, {id: 'lugraph-benchmark-spatial-index'});
+ const importVector = (
+ identifier: string,
+ vector: GPUVector
+ ) => graph.importGPUVector(identifier, vector).data[0];
+ const index = new GPUGridIndex({
+ id: 'lugraph-benchmark-independent-grid',
+ positions: importVector('spatial-positions', this.spatialLayout.layout.positions),
+ gridSize: this.spatialLayout.gridSize,
+ bounds: this.spatialLayout.bounds,
+ cellOffsets: importVector('spatial-cell-offsets', this.spatialLayout.cellOffsets),
+ objectIds: importVector('spatial-vertex-ids', this.spatialLayout.vertexIds),
+ count: importVector('spatial-count', this.spatialLayout.count),
+ overflow: importVector('spatial-overflow', this.spatialLayout.overflow)
+ });
+ index.addToGraph(graph);
+ return graph.compile();
+ }
+
+ /** Restores identical source positions and zero velocity without polluting measured execution. */
+ resetLayout(layout: LuGraphForceLayout): void {
+ getVectorBuffer(layout.positions).write(this.dataset.positions);
+ getVectorBuffer(layout.velocities).write(new Float32Array(this.dataset.vertexCount * 2));
+ }
+
+ /** Destroys only explicit benchmark-owned resources; aggregate vectors merely borrow buffers. */
+ destroy(): void {
+ for (const vector of this.vectors.reverse()) vector.destroy();
+ for (const buffer of this.buffers.reverse()) buffer.destroy();
+ }
+
+ /** Preserves aligned original source batches, including the deterministic empty middle chunk. */
+ private createChunkedVector(name: string, chunks: Uint32Array[]): GPUVector<'uint32'> {
+ const data = chunks.map((values, chunkIndex) => {
+ const buffer = this.device.createBuffer({
+ id: `lugraph-benchmark-${name}-${chunkIndex}`,
+ data: values.length === 0 ? new Uint32Array(1) : values,
+ usage: Buffer.STORAGE | Buffer.COPY_DST
+ });
+ this.buffers.push(buffer);
+ return new GPUData<'uint32'>({
+ buffer,
+ format: 'uint32',
+ length: values.length,
+ ownsBuffer: false
+ });
+ });
+ const vector = new GPUVector<'uint32'>({
+ type: 'data',
+ name,
+ format: 'uint32',
+ data,
+ ownsData: false
+ });
+ this.vectors.push(vector);
+ return vector;
+ }
+
+ /** Creates independently allocated packed topology, output, seed, or status vectors. */
+ private createScalarVector(
+ name: string,
+ format: Format,
+ length: number,
+ values?: Uint32Array | Float32Array
+ ): GPUVector {
+ const buffer = this.device.createBuffer({
+ id: `lugraph-benchmark-${name}`,
+ byteLength: Math.max(length, 1) * SCALAR_BYTE_LENGTH,
+ usage: Buffer.STORAGE | Buffer.COPY_SRC | Buffer.COPY_DST
+ });
+ if (values && values.length > 0) buffer.write(values);
+ this.buffers.push(buffer);
+ const vector = new GPUVector({
+ type: 'buffer',
+ name,
+ format,
+ buffer,
+ length,
+ ownsBuffer: false
+ });
+ this.vectors.push(vector);
+ return vector;
+ }
+
+ /** Creates render-ready packed coordinate state while preserving explicit validation readback. */
+ private createCoordinateVector(name: string, values: Float32Array): GPUVector<'float32x2'> {
+ const buffer = this.device.createBuffer({
+ id: `lugraph-benchmark-${name}`,
+ data: values.length === 0 ? new Float32Array(2) : values,
+ usage: Buffer.STORAGE | Buffer.VERTEX | Buffer.COPY_SRC | Buffer.COPY_DST
+ });
+ this.buffers.push(buffer);
+ const vector = new GPUVector<'float32x2'>({
+ type: 'buffer',
+ name,
+ format: 'float32x2',
+ buffer,
+ length: values.length / 2,
+ ownsBuffer: false
+ });
+ this.vectors.push(vector);
+ return vector;
+ }
+
+ /** Allocates complete caller-owned, non-truncated directed CSR storage. */
+ private createAdjacency(name: string, vertexCount: number, edgeCount: number): LuGraphAdjacency {
+ return {
+ offsets: this.createScalarVector(`${name}-offsets`, 'uint32', vertexCount + 1),
+ neighbors: this.createScalarVector(`${name}-neighbors`, 'uint32', edgeCount),
+ edgeIds: this.createScalarVector(`${name}-edge-ids`, 'uint32', edgeCount),
+ count: this.createScalarVector(`${name}-count`, 'uint32', 1),
+ overflow: this.createScalarVector(`${name}-overflow`, 'uint32', 1)
+ };
+ }
+
+ /** Gives exact and accelerated paths distinct render-ready positions and progressive velocity. */
+ private createForceLayout(name: string, context: LuGraphBenchmarkContext): LuGraphForceLayout {
+ return new LuGraphForceLayout({
+ id: `lugraph-benchmark-${name}-force`,
+ topology: this.topology,
+ positions: this.createCoordinateVector(`${name}-positions`, context.dataset.positions),
+ velocities: this.createCoordinateVector(
+ `${name}-velocities`,
+ new Float32Array(context.dataset.vertexCount * 2)
+ ),
+ iterationsPerFrame: context.options.forceIterations,
+ ...LU_GRAPH_BENCHMARK_FORCE_PROPS
+ });
+ }
+}
+
+/** Validates before timings so an incorrect or incomplete implementation never claims a speedup. */
+async function validateBenchmarkPath(
+ device: Device,
+ resources: LuGraphBenchmarkResources,
+ context: LuGraphBenchmarkContext,
+ path: CompiledBenchmarkPath
+): Promise {
+ await resetBenchmarkPath(device, resources, path.algorithm);
+ await executeBenchmarkPath(device, path.compiled, `${path.algorithm}-correctness`);
+ return readBenchmarkPath(resources, context, path.algorithm);
+}
+
+/** Keeps source restores and their queue completion outside every timed submission interval. */
+async function resetBenchmarkPath(
+ device: Device,
+ resources: LuGraphBenchmarkResources,
+ algorithm: LuGraphBenchmarkAlgorithm | 'spatial-index'
+): Promise {
+ if (algorithm === 'exact-layout') {
+ resources.resetLayout(resources.exactLayout);
+ } else if (algorithm === 'spatial-layout' || algorithm === 'spatial-index') {
+ resources.resetLayout(resources.spatialLayout.layout);
+ } else {
+ return;
+ }
+ await waitForBenchmarkFence(device);
+}
+
+/** Times queue submission through a real completion fence; GPU query readback stays outside it. */
+async function executeBenchmarkPath(
+ device: Device,
+ compiled: CompiledGPUCommandGraph,
+ identifier: string,
+ requestTimestamps = false
+): Promise {
+ const querySet: QuerySet | undefined = requestTimestamps
+ ? device.createQuerySet({
+ id: `lugraph-benchmark-${identifier}-timestamps`,
+ type: 'timestamp',
+ count: compiled.stats.nodeOrder.length * 2
+ })
+ : undefined;
+ const commandEncoder = device.createCommandEncoder({
+ id: `lugraph-benchmark-${identifier}`,
+ ...(querySet ? {timeProfilingQuerySet: querySet} : {})
+ });
+ let submitted = false;
+
+ try {
+ const encoding = compiled.encode(commandEncoder, {parameters: undefined});
+ const startTime = getLuGraphBenchmarkTime();
+ device.submit(commandEncoder.finish());
+ submitted = true;
+ await waitForBenchmarkFence(device);
+ const synchronizedTimeMilliseconds = getLuGraphBenchmarkTime() - startTime;
+ const timing = await encoding.readTimings();
+ return {
+ cpuEncodeTimeMilliseconds: timing.cpuEncodeTimeMilliseconds,
+ synchronizedTimeMilliseconds,
+ ...(timing.gpuTimeMilliseconds === undefined
+ ? {}
+ : {gpuTimeMilliseconds: timing.gpuTimeMilliseconds})
+ };
+ } catch (error) {
+ if (!submitted) commandEncoder.destroy();
+ throw error;
+ } finally {
+ querySet?.destroy();
+ }
+}
+
+/** Uses the portable device fence rather than assuming command submission is GPU completion. */
+async function waitForBenchmarkFence(device: Device): Promise {
+ const fence = device.createFence();
+ try {
+ await fence.signaled;
+ } finally {
+ fence.destroy();
+ }
+}
+
+/** Rebuilds the same explicit uniform-grid buffers in a separate, fully synchronized graph. */
+async function measureSpatialIndexBuild(
+ device: Device,
+ resources: LuGraphBenchmarkResources,
+ context: LuGraphBenchmarkContext,
+ compiled: CompiledGPUCommandGraph
+): Promise {
+ for (let iteration = 0; iteration < context.options.warmupIterations; iteration++) {
+ await resetBenchmarkPath(device, resources, 'spatial-index');
+ await executeBenchmarkPath(device, compiled, `spatial-index-warmup-${iteration}`);
+ }
+ const samples: number[] = [];
+ for (let iteration = 0; iteration < context.options.measuredIterations; iteration++) {
+ await resetBenchmarkPath(device, resources, 'spatial-index');
+ const execution = await executeBenchmarkPath(
+ device,
+ compiled,
+ `spatial-index-measured-${iteration}`
+ );
+ samples.push(execution.synchronizedTimeMilliseconds);
+ }
+ return summarizeLuGraphBenchmarkSamples(samples);
+}
+
+/** Reads and compares observable outputs only after the synchronized execution window closes. */
+async function readBenchmarkPath(
+ resources: LuGraphBenchmarkResources,
+ context: LuGraphBenchmarkContext,
+ algorithm: LuGraphBenchmarkAlgorithm
+): Promise {
+ let readbackTimeMilliseconds = 0;
+ let maxAbsoluteError = 0;
+ let approximationMaxAbsoluteError: number | undefined;
+ const readOutputs = async (
+ ...vectors: (GPUVector<'uint32'> | GPUVector<'float32'> | GPUVector<'float32x2'>)[]
+ ): Promise => {
+ const startTime = getLuGraphBenchmarkTime();
+ const values = await Promise.all(vectors.map(vector => readBenchmarkVector(vector)));
+ readbackTimeMilliseconds += getLuGraphBenchmarkTime() - startTime;
+ return values;
+ };
+
+ switch (algorithm) {
+ case 'topology': {
+ const [forwardOffsets, forwardNeighbors, reverseOffsets, reverseNeighbors, invalid] =
+ await readOutputs(
+ resources.topology.forward.offsets,
+ resources.topology.forward.neighbors,
+ resources.topology.reverse!.offsets,
+ resources.topology.reverse!.neighbors,
+ resources.topology.invalidEdgeCount
+ );
+ maxAbsoluteError = Math.max(
+ getMaximumAbsoluteError(forwardOffsets, context.reference.forwardOffsets),
+ getMaximumAbsoluteError(reverseOffsets, context.reference.reverseOffsets),
+ getMaximumAdjacencyError(
+ forwardOffsets,
+ forwardNeighbors,
+ context.reference.forwardOffsets,
+ context.reference.forwardNeighbors
+ ),
+ getMaximumAdjacencyError(
+ reverseOffsets,
+ reverseNeighbors,
+ context.reference.reverseOffsets,
+ context.reference.reverseNeighbors
+ ),
+ invalid[0]
+ );
+ break;
+ }
+ case 'breadth-first-search': {
+ const [distances, predecessors] = await readOutputs(
+ resources.search.distances,
+ resources.search.predecessors
+ );
+ maxAbsoluteError = Math.max(
+ getMaximumAbsoluteError(distances, context.reference.distances),
+ getMaximumAbsoluteError(predecessors, context.reference.predecessors)
+ );
+ break;
+ }
+ case 'connected-components': {
+ const [components, converged] = await readOutputs(
+ resources.components.output,
+ resources.components.converged!
+ );
+ maxAbsoluteError = Math.max(
+ getMaximumAbsoluteError(components, context.reference.components),
+ Math.abs(converged[0] - 1)
+ );
+ break;
+ }
+ case 'page-rank': {
+ const [values] = await readOutputs(resources.pageRank.output);
+ maxAbsoluteError = Math.max(
+ getMaximumAbsoluteError(values, context.reference.pageRank),
+ Math.abs(values.reduce((sum, value) => sum + value, 0) - 1)
+ );
+ break;
+ }
+ case 'exact-layout': {
+ const [positions, velocities] = await readOutputs(
+ resources.exactLayout.positions,
+ resources.exactLayout.velocities
+ );
+ maxAbsoluteError = Math.max(
+ getMaximumAbsoluteError(positions, context.reference.exactPositions),
+ getMaximumAbsoluteError(velocities, context.reference.exactVelocities)
+ );
+ break;
+ }
+ case 'spatial-layout': {
+ const [positions, velocities, count, overflow] = await readOutputs(
+ resources.spatialLayout.layout.positions,
+ resources.spatialLayout.layout.velocities,
+ resources.spatialLayout.count,
+ resources.spatialLayout.overflow
+ );
+ maxAbsoluteError = Math.max(
+ getMaximumAbsoluteError(positions, context.reference.spatialPositions),
+ getMaximumAbsoluteError(velocities, context.reference.spatialVelocities),
+ Math.abs(count[0] - context.dataset.vertexCount),
+ overflow[0]
+ );
+ approximationMaxAbsoluteError = getMaximumAbsoluteError(
+ positions,
+ context.reference.exactPositions
+ );
+ break;
+ }
+ }
+
+ const tolerance =
+ algorithm === 'page-rank'
+ ? PAGE_RANK_TOLERANCE
+ : algorithm === 'exact-layout' || algorithm === 'spatial-layout'
+ ? FORCE_LAYOUT_TOLERANCE
+ : 0;
+ if (!Number.isFinite(maxAbsoluteError) || maxAbsoluteError > tolerance) {
+ throw new Error(
+ `luGraph benchmark ${algorithm} disagrees with its CPU oracle: ${maxAbsoluteError}`
+ );
+ }
+
+ return {
+ maxAbsoluteError,
+ ...(approximationMaxAbsoluteError === undefined ? {} : {approximationMaxAbsoluteError}),
+ readbackTimeMilliseconds
+ };
+}
+
+/** Reads exactly one packed caller-owned GPU column after an explicit correctness request. */
+async function readBenchmarkVector(
+ vector: GPUVector<'uint32'> | GPUVector<'float32'> | GPUVector<'float32x2'>
+): Promise {
+ if (vector.length === 0) return [];
+ const chunk = vector.data[0];
+ const componentCount = vector.format === 'float32x2' ? 2 : 1;
+ const byteLength = vector.length * componentCount * SCALAR_BYTE_LENGTH;
+ const bytes = await getVectorBuffer(vector).readAsync(chunk.byteOffset, byteLength);
+ return vector.format === 'uint32'
+ ? Array.from(new Uint32Array(bytes.buffer, bytes.byteOffset, byteLength / SCALAR_BYTE_LENGTH))
+ : Array.from(new Float32Array(bytes.buffer, bytes.byteOffset, byteLength / SCALAR_BYTE_LENGTH));
+}
+
+/** Atomic CSR placement is intentionally unordered within rows; compare complete row multisets. */
+function getMaximumAdjacencyError(
+ actualOffsets: readonly number[],
+ actualNeighbors: readonly number[],
+ expectedOffsets: ArrayLike,
+ expectedNeighbors: ArrayLike
+): number {
+ let maximumError = 0;
+ const expectedValues = Array.from(expectedNeighbors);
+ for (let vertex = 0; vertex < expectedOffsets.length - 1; vertex++) {
+ const actual = actualNeighbors
+ .slice(actualOffsets[vertex], actualOffsets[vertex + 1])
+ .sort((left, right) => left - right);
+ const expected = expectedValues
+ .slice(expectedOffsets[vertex], expectedOffsets[vertex + 1])
+ .sort((left, right) => left - right);
+ maximumError = Math.max(maximumError, getMaximumAbsoluteError(actual, expected));
+ }
+ return maximumError;
+}
+
+/** Rejects missing, extra, non-finite, or numerically divergent observable output rows. */
+function getMaximumAbsoluteError(actual: ArrayLike, expected: ArrayLike): number {
+ if (actual.length !== expected.length) return Number.POSITIVE_INFINITY;
+ let maximumError = 0;
+ for (let index = 0; index < actual.length; index++) {
+ const difference = Math.abs(actual[index] - expected[index]);
+ if (!Number.isFinite(difference)) return Number.POSITIVE_INFINITY;
+ maximumError = Math.max(maximumError, difference);
+ }
+ return maximumError;
+}
+
+function getVectorBuffer(vector: GPUVector): Buffer {
+ return vector.data[0].buffer as Buffer;
+}
diff --git a/modules/experimental/test/lugraph/lu-graph-benchmark.node.spec.ts b/modules/experimental/test/lugraph/lu-graph-benchmark.node.spec.ts
new file mode 100644
index 0000000000..4fae83fe6c
--- /dev/null
+++ b/modules/experimental/test/lugraph/lu-graph-benchmark.node.spec.ts
@@ -0,0 +1,347 @@
+// luma.gl
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+import {readFileSync} from 'node:fs';
+
+import * as experimentalModule from '@luma.gl/experimental';
+import * as luGraphModule from '@luma.gl/experimental/lugraph';
+import * as benchmarkModule from '@luma.gl/experimental/lugraph/benchmarks';
+import {
+ makeLuGraphBenchmarkDataset,
+ type LuGraphBenchmarkDatasetKind,
+ type LuGraphBenchmarkOptions
+} from '@luma.gl/experimental/lugraph/benchmarks';
+import {describe, expect, test} from 'vitest';
+
+import {
+ prepareLuGraphBenchmark,
+ summarizeLuGraphBenchmarkSamples
+} from '../../src/lugraph/lu-graph-benchmark-data';
+
+const DATASET_KINDS: LuGraphBenchmarkDatasetKind[] = [
+ 'sparse',
+ 'dense',
+ 'scale-free',
+ 'disconnected',
+ 'high-degree'
+];
+
+describe('luGraph benchmark optional package boundary', () => {
+ test('isolates deterministic datasets and GPU benchmark runners from production entry points', () => {
+ expect(Object.keys(benchmarkModule).sort()).toEqual([
+ 'makeLuGraphBenchmarkDataset',
+ 'runLuGraphBenchmark'
+ ]);
+ expect(typeof benchmarkModule.makeLuGraphBenchmarkDataset).toBe('function');
+ expect(typeof benchmarkModule.runLuGraphBenchmark).toBe('function');
+ expect('runLuGraphBenchmark' in experimentalModule).toBe(false);
+ expect('makeLuGraphBenchmarkDataset' in experimentalModule).toBe(false);
+ expect('runLuGraphBenchmark' in luGraphModule).toBe(false);
+ expect('makeLuGraphBenchmarkDataset' in luGraphModule).toBe(false);
+ });
+
+ test('declares a conditional benchmark subpath without adding Apache Arrow dependencies', () => {
+ const packageJson = JSON.parse(
+ readFileSync(new URL('../../package.json', import.meta.url), 'utf8')
+ ) as {
+ exports: Record>;
+ dependencies?: Record;
+ peerDependencies?: Record;
+ optionalDependencies?: Record;
+ };
+
+ expect(packageJson.exports['./lugraph/benchmarks']).toEqual({
+ import: './dist/lugraph/benchmarks.js',
+ require: './dist/lugraph/benchmarks.cjs',
+ types: './dist/lugraph/benchmarks.d.ts'
+ });
+ for (const dependencies of [
+ packageJson.dependencies,
+ packageJson.peerDependencies,
+ packageJson.optionalDependencies
+ ]) {
+ expect(dependencies?.['apache-arrow']).toBeUndefined();
+ }
+ });
+});
+
+describe('luGraph benchmark deterministic source datasets', () => {
+ test.each(
+ DATASET_KINDS
+ )('creates repeatable, valid, independently owned %s graph batches and coordinates', kind => {
+ const first = makeLuGraphBenchmarkDataset({kind, vertexCount: 32, seed: 123});
+ const repeated = makeLuGraphBenchmarkDataset({kind, vertexCount: 32, seed: 123});
+
+ expect(first.kind).toBe(kind);
+ expect(first.vertexCount).toBe(32);
+ expect(first.sourceChunks).toHaveLength(3);
+ expect(first.targetChunks).toHaveLength(3);
+ expect(first.sourceChunks[1]).toHaveLength(0);
+ expect(first.targetChunks[1]).toHaveLength(0);
+ expect(first.positions).toBeInstanceOf(Float32Array);
+ expect(first.positions).toHaveLength(64);
+ expect(first.positions).not.toBe(repeated.positions);
+ expect(Array.from(first.positions)).toEqual(Array.from(repeated.positions));
+ expect(Array.from(first.positions).every(position => Number.isFinite(position))).toBe(true);
+ expect(Array.from(first.positions).every(position => position > -2 && position < 2)).toBe(true);
+
+ let edgeCount = 0;
+ for (const [chunkIndex, sources] of first.sourceChunks.entries()) {
+ const targets = first.targetChunks[chunkIndex];
+ expect(sources).toBeInstanceOf(Uint32Array);
+ expect(targets).toBeInstanceOf(Uint32Array);
+ expect(targets.length).toBe(sources.length);
+ expect(Array.from(sources)).toEqual(Array.from(repeated.sourceChunks[chunkIndex]));
+ expect(Array.from(targets)).toEqual(Array.from(repeated.targetChunks[chunkIndex]));
+ expect(Array.from(sources).every(vertex => vertex < first.vertexCount)).toBe(true);
+ expect(Array.from(targets).every(vertex => vertex < first.vertexCount)).toBe(true);
+ edgeCount += sources.length;
+ }
+ expect(first.edgeCount).toBe(edgeCount);
+ expect(first.edgeCount).toBeGreaterThan(0);
+ });
+
+ test('dense inputs contain every distinct ordered pair rather than synthetic edge counts', () => {
+ const dataset = makeLuGraphBenchmarkDataset({kind: 'dense', vertexCount: 12, seed: 7});
+ const uniqueEdges = new Set();
+
+ for (const [chunkIndex, sources] of dataset.sourceChunks.entries()) {
+ for (const [rowIndex, source] of sources.entries()) {
+ const target = dataset.targetChunks[chunkIndex][rowIndex];
+ expect(source).not.toBe(target);
+ uniqueEdges.add(`${source}-${target}`);
+ }
+ }
+
+ expect(dataset.edgeCount).toBe(12 * 11);
+ expect(uniqueEdges.size).toBe(dataset.edgeCount);
+ });
+
+ test('sparse and dense datasets have materially different actual edge workloads', () => {
+ const sparse = makeLuGraphBenchmarkDataset({kind: 'sparse', vertexCount: 32, seed: 9});
+ const dense = makeLuGraphBenchmarkDataset({kind: 'dense', vertexCount: 32, seed: 9});
+
+ expect(sparse.edgeCount).toBeLessThan(32 * 5);
+ expect(dense.edgeCount).toBe(32 * 31);
+ expect(dense.edgeCount).toBeGreaterThan(sparse.edgeCount * 3);
+ });
+
+ test('high-degree and scale-free generators contain genuine hub vertices', () => {
+ for (const kind of ['high-degree', 'scale-free'] as const) {
+ const dataset = makeLuGraphBenchmarkDataset({kind, vertexCount: 32, seed: 5});
+ const degrees = new Uint32Array(dataset.vertexCount);
+ for (const [chunkIndex, sources] of dataset.sourceChunks.entries()) {
+ for (const [rowIndex, source] of sources.entries()) {
+ degrees[source]++;
+ degrees[dataset.targetChunks[chunkIndex][rowIndex]]++;
+ }
+ }
+ expect(Math.max(...degrees)).toBeGreaterThanOrEqual(8);
+ }
+ });
+
+ test('disconnected workloads contain multiple independently discoverable weak components', () => {
+ const dataset = makeLuGraphBenchmarkDataset({kind: 'disconnected', vertexCount: 24, seed: 9});
+ const neighbors = Array.from({length: dataset.vertexCount}, () => [] as number[]);
+ for (const [chunkIndex, sources] of dataset.sourceChunks.entries()) {
+ for (const [rowIndex, source] of sources.entries()) {
+ const target = dataset.targetChunks[chunkIndex][rowIndex];
+ neighbors[source].push(target);
+ neighbors[target].push(source);
+ }
+ }
+
+ const reached = new Set();
+ let componentCount = 0;
+ for (let vertex = 0; vertex < dataset.vertexCount; vertex++) {
+ if (reached.has(vertex)) continue;
+ componentCount++;
+ const frontier = [vertex];
+ while (frontier.length > 0) {
+ const current = frontier.pop()!;
+ if (reached.has(current)) continue;
+ reached.add(current);
+ frontier.push(...neighbors[current]);
+ }
+ }
+ expect(componentCount).toBeGreaterThan(1);
+ });
+
+ test('different deterministic seeds actually change graph coordinates', () => {
+ const first = makeLuGraphBenchmarkDataset({kind: 'scale-free', vertexCount: 24, seed: 1});
+ const second = makeLuGraphBenchmarkDataset({kind: 'scale-free', vertexCount: 24, seed: 2});
+ expect(Array.from(first.positions)).not.toEqual(Array.from(second.positions));
+ });
+
+ test.each([
+ 0,
+ -1,
+ 1.5,
+ Number.NaN,
+ Number.POSITIVE_INFINITY
+ ])('rejects invalid deterministic dataset vertex counts: %s', vertexCount => {
+ expect(() => makeLuGraphBenchmarkDataset({kind: 'sparse', vertexCount})).toThrow(
+ /vertexCount|vertex|uint32|positive/
+ );
+ });
+
+ test.each([
+ -1,
+ 1.5,
+ 0x100000000,
+ Number.NaN
+ ])('rejects invalid uint32 dataset seeds: %s', seed => {
+ expect(() => makeLuGraphBenchmarkDataset({kind: 'sparse', vertexCount: 8, seed})).toThrow(
+ /seed|uint32|unsigned/
+ );
+ });
+
+ test('rejects unsupported graph families instead of reporting fabricated workload data', () => {
+ expect(() =>
+ makeLuGraphBenchmarkDataset({
+ kind: 'fabricated' as LuGraphBenchmarkDatasetKind,
+ vertexCount: 8
+ })
+ ).toThrow(/kind|dataset|graph/);
+ });
+
+ test('supports a one-vertex graph while preserving all three explicit source batches', () => {
+ const dataset = makeLuGraphBenchmarkDataset({kind: 'sparse', vertexCount: 1, seed: 0});
+ expect(dataset.positions).toHaveLength(2);
+ expect(dataset.sourceChunks).toHaveLength(3);
+ expect(dataset.targetChunks).toHaveLength(3);
+ expect(dataset.edgeCount).toBe(0);
+ });
+});
+
+describe('luGraph benchmark independent CPU oracles', () => {
+ test.each(DATASET_KINDS)('evaluates all six real CPU references for %s workloads', kind => {
+ const context = prepareLuGraphBenchmark({
+ kind,
+ vertexCount: 12,
+ seed: 42,
+ warmupIterations: 0,
+ measuredIterations: 1,
+ pageRankIterations: 4,
+ forceIterations: 1,
+ maxDepth: 4,
+ theta: 0,
+ gridSize: [4, 4]
+ });
+ const {reference} = context;
+
+ expect(reference.forwardOffsets).toHaveLength(13);
+ expect(reference.reverseOffsets).toHaveLength(13);
+ expect(reference.forwardOffsets[12]).toBe(context.dataset.edgeCount);
+ expect(reference.reverseOffsets[12]).toBe(context.dataset.edgeCount);
+ expect(reference.forwardNeighbors).toHaveLength(context.dataset.edgeCount);
+ expect(reference.reverseNeighbors).toHaveLength(context.dataset.edgeCount);
+ expect(reference.distances).toHaveLength(12);
+ expect(reference.predecessors).toHaveLength(12);
+ expect(reference.distances[0]).toBe(0);
+ expect(reference.predecessors[0]).toBe(0xffffffff);
+ expect(reference.components).toHaveLength(12);
+ expect(reference.pageRank).toHaveLength(12);
+ expect(Array.from(reference.pageRank).reduce((sum, score) => sum + score, 0)).toBeCloseTo(1, 5);
+ expect(reference.exactPositions).toHaveLength(24);
+ expect(reference.exactVelocities).toHaveLength(24);
+ expect(reference.spatialPositions).toHaveLength(24);
+ expect(reference.spatialVelocities).toHaveLength(24);
+
+ for (let index = 0; index < reference.exactPositions.length; index++) {
+ expect(reference.spatialPositions[index]).toBeCloseTo(reference.exactPositions[index], 5);
+ expect(reference.spatialVelocities[index]).toBeCloseTo(reference.exactVelocities[index], 5);
+ }
+
+ expect(Object.keys(context.cpuTimeMilliseconds)).toEqual([
+ 'topology',
+ 'breadth-first-search',
+ 'connected-components',
+ 'page-rank',
+ 'exact-layout',
+ 'spatial-layout'
+ ]);
+ for (const distribution of Object.values(context.cpuTimeMilliseconds)) {
+ expect(distribution.minimum).toBeGreaterThanOrEqual(0);
+ expect(distribution.minimum).toBeLessThanOrEqual(distribution.median);
+ expect(distribution.median).toBeLessThanOrEqual(distribution.percentile95);
+ expect(distribution.percentile95).toBeLessThanOrEqual(distribution.maximum);
+ }
+ });
+
+ test('preserves default controls and isolated unreachable-component semantics', () => {
+ const context = prepareLuGraphBenchmark({kind: 'disconnected', vertexCount: 8});
+
+ expect(context.options).toMatchObject({
+ seed: 0,
+ warmupIterations: 1,
+ measuredIterations: 3,
+ pageRankIterations: 20,
+ forceIterations: 1,
+ maxDepth: 8,
+ theta: 0.6,
+ gridSize: [8, 8]
+ });
+ expect(context.reference.components[7]).toBe(7);
+ expect(context.reference.distances[7]).toBe(0xffffffff);
+ expect(context.reference.predecessors[7]).toBe(0xffffffff);
+ });
+
+ test.each([
+ [{warmupIterations: -1}, /warmupIterations/],
+ [{measuredIterations: 0}, /measuredIterations/],
+ [{pageRankIterations: 0}, /pageRankIterations/],
+ [{pageRankIterations: 1025}, /pageRankIterations/],
+ [{forceIterations: 0}, /forceIterations/],
+ [{forceIterations: 1025}, /forceIterations/],
+ [{maxDepth: -1}, /maxDepth/],
+ [{maxDepth: 1025}, /maxDepth/],
+ [{theta: -1}, /theta/],
+ [{theta: Number.NaN}, /theta/],
+ [{gridSize: [0, 4]}, /gridSize/],
+ [{gridSize: [4, 1.5]}, /gridSize/]
+ ] as [
+ Partial,
+ RegExp
+ ][])('rejects unsupported benchmark controls %j before producing measurements', (invalidOptions, error) => {
+ expect(() =>
+ prepareLuGraphBenchmark({kind: 'sparse', vertexCount: 8, ...invalidOptions})
+ ).toThrow(error);
+ });
+
+ test('reports observed nearest-rank timings without fabricating or interpolating values', () => {
+ expect(summarizeLuGraphBenchmarkSamples([8, 1, 5, 3])).toEqual({
+ minimum: 1,
+ median: 3,
+ percentile95: 8,
+ maximum: 8
+ });
+ for (const invalidSamples of [[], [-1], [Number.NaN], [Number.POSITIVE_INFINITY]]) {
+ expect(() => summarizeLuGraphBenchmarkSamples(invalidSamples)).toThrow(/sample|duration/);
+ }
+ });
+});
+
+describe('luGraph live documentation benchmark isolation', () => {
+ test('uses explicit-start SSR-safe UI and independently reports index and approximation costs', () => {
+ const component = readFileSync(
+ new URL('../../../../website/src/components/docs/lugraph-benchmark.tsx', import.meta.url),
+ 'utf8'
+ );
+ const documentation = readFileSync(
+ new URL('../../../../docs/api-reference/experimental/lugraph.md', import.meta.url),
+ 'utf8'
+ );
+
+ expect(component).toContain("from '@luma.gl/experimental/lugraph/benchmarks'");
+ expect(component).toContain('');
+ expect(component).toContain("typeof navigator === 'undefined'");
+ expect(component).toContain('spatialIndexBuildTimeMilliseconds');
+ expect(component).toContain('approximationMaxAbsoluteError');
+ expect(component.match(/await runLuGraphBenchmark\(/g)).toHaveLength(1);
+ expect(documentation).toContain(' ');
+ expect(documentation).toContain('explicit completion fence');
+ });
+});
diff --git a/modules/experimental/test/lugraph/lu-graph-benchmark.spec.ts b/modules/experimental/test/lugraph/lu-graph-benchmark.spec.ts
new file mode 100644
index 0000000000..a2950581e7
--- /dev/null
+++ b/modules/experimental/test/lugraph/lu-graph-benchmark.spec.ts
@@ -0,0 +1,234 @@
+// luma.gl
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+import {type Device} from '@luma.gl/core';
+import {
+ makeLuGraphBenchmarkDataset,
+ runLuGraphBenchmark,
+ type LuGraphBenchmarkAlgorithm,
+ type LuGraphBenchmarkDatasetKind,
+ type LuGraphBenchmarkDistribution,
+ type LuGraphBenchmarkReport
+} from '@luma.gl/experimental/lugraph/benchmarks';
+import {getWebGPUTestDevice} from '@luma.gl/test-utils';
+import test, {type Test} from 'test/utils/vitest-tape';
+import {vi} from 'vitest';
+
+const BENCHMARK_ALGORITHMS: LuGraphBenchmarkAlgorithm[] = [
+ 'topology',
+ 'breadth-first-search',
+ 'connected-components',
+ 'page-rank',
+ 'exact-layout',
+ 'spatial-layout'
+];
+
+const BENCHMARK_DATASETS: LuGraphBenchmarkDatasetKind[] = [
+ 'sparse',
+ 'dense',
+ 'disconnected',
+ 'scale-free',
+ 'high-degree'
+];
+
+for (const kind of BENCHMARK_DATASETS) {
+ test(`luGraph real WebGPU benchmark independently validates the ${kind} graph workload`, async tapeTest => {
+ const device = await getWebGPUTestDevice();
+ if (!device) {
+ tapeTest.comment('WebGPU is not available');
+ tapeTest.end();
+ return;
+ }
+
+ const fenceSpy = vi.spyOn(device, 'createFence');
+ const submitSpy = vi.spyOn(device, 'submit');
+ try {
+ const theta = kind === 'sparse' ? 0 : kind === 'scale-free' ? 1 : 0.6;
+ const expectedDataset = makeLuGraphBenchmarkDataset({kind, vertexCount: 12, seed: 42});
+ const report = await runLuGraphBenchmark(device, {
+ kind,
+ vertexCount: 12,
+ seed: 42,
+ warmupIterations: 0,
+ measuredIterations: 1,
+ pageRankIterations: 4,
+ forceIterations: 1,
+ maxDepth: 4,
+ theta,
+ gridSize: [4, 4]
+ });
+
+ assertBenchmarkReport(tapeTest, report, device, {
+ kind,
+ vertexCount: 12,
+ edgeCount: expectedDataset.edgeCount,
+ cellCount: 16,
+ theta
+ });
+ tapeTest.ok(
+ submitSpy.mock.calls.length >= 7,
+ 'each real graph workload and independent spatial-index phase submits actual GPU work'
+ );
+ tapeTest.ok(
+ fenceSpy.mock.calls.length >= 7,
+ 'GPU operation and standalone spatial-index timers wait on real completion fences'
+ );
+ if (theta === 0) {
+ tapeTest.ok(
+ report.approximationMaxAbsoluteError < 2e-4,
+ 'theta zero retains exact long-range repulsion instead of hiding approximation error'
+ );
+ }
+ } finally {
+ fenceSpy.mockRestore();
+ submitSpy.mockRestore();
+ }
+
+ tapeTest.end();
+ });
+}
+
+function assertBenchmarkReport(
+ tapeTest: Test,
+ report: LuGraphBenchmarkReport,
+ device: Device,
+ expected: {
+ kind: LuGraphBenchmarkDatasetKind;
+ vertexCount: number;
+ edgeCount: number;
+ cellCount: number;
+ theta: number;
+ }
+): void {
+ tapeTest.equal(
+ report.datasetKind,
+ expected.kind,
+ 'CPU and GPU execute the requested graph family'
+ );
+ tapeTest.equal(report.vertexCount, expected.vertexCount, 'both paths share the same vertex IDs');
+ tapeTest.equal(
+ report.edgeCount,
+ expected.edgeCount,
+ 'both paths share every deterministic source edge'
+ );
+ tapeTest.equal(report.warmupIterations, 0, 'warmup cost is excluded from the requested sample');
+ tapeTest.equal(
+ report.measuredIterations,
+ 1,
+ 'one real measured execution contributes each sample'
+ );
+ tapeTest.ok(
+ Number.isFinite(report.uploadTimeMilliseconds) && report.uploadTimeMilliseconds >= 0,
+ 'source upload is measured and reported independently'
+ );
+ tapeTest.ok(
+ Number.isFinite(report.compilationTimeMilliseconds) && report.compilationTimeMilliseconds >= 0,
+ 'graph compilation is measured separately from GPU execution'
+ );
+ tapeTest.ok(
+ Number.isFinite(report.readbackTimeMilliseconds) && report.readbackTimeMilliseconds >= 0,
+ 'explicit oracle-validation readback is separated from fenced benchmark timing'
+ );
+ assertDistribution(
+ tapeTest,
+ report.spatialIndexBuildTimeMilliseconds,
+ 'independent GPUGridIndex construction'
+ );
+ tapeTest.equal(
+ report.indexMemoryBytes,
+ 4 * (expected.cellCount + 1) + 4 * expected.vertexCount + 8 * expected.cellCount + 8,
+ 'index memory reports exclusive offsets, vertex IDs, float32x2 centers, and both statuses'
+ );
+ tapeTest.ok(
+ Number.isFinite(report.approximationMaxAbsoluteError) &&
+ report.approximationMaxAbsoluteError >= 0,
+ 'spatial accuracy is honestly compared against the independently evaluated exact force result'
+ );
+
+ tapeTest.deepEqual(
+ report.paths.map(path => path.algorithm),
+ BENCHMARK_ALGORITHMS,
+ 'six independently compiled CPU and WebGPU algorithms are actually benchmarked'
+ );
+ for (const path of report.paths) {
+ assertDistribution(tapeTest, path.cpuTimeMilliseconds, `${path.algorithm} CPU reference`);
+ assertDistribution(tapeTest, path.cpuEncodeTimeMilliseconds, `${path.algorithm} CPU encoding`);
+ assertDistribution(
+ tapeTest,
+ path.synchronizedTimeMilliseconds,
+ `${path.algorithm} fence-synchronized GPU execution`
+ );
+ tapeTest.ok(
+ Number.isSafeInteger(path.importedBufferBytes) && path.importedBufferBytes > 0,
+ `${path.algorithm} reports actual caller-owned imported GPU bytes`
+ );
+ tapeTest.ok(
+ Number.isSafeInteger(path.transientBufferBytes) && path.transientBufferBytes >= 0,
+ `${path.algorithm} reports actual graph-owned transient GPU bytes`
+ );
+ tapeTest.ok(
+ Number.isFinite(path.maxAbsoluteError) && path.maxAbsoluteError >= 0,
+ `${path.algorithm} validates GPU results against its independent CPU oracle`
+ );
+ if (
+ path.algorithm === 'topology' ||
+ path.algorithm === 'breadth-first-search' ||
+ path.algorithm === 'connected-components'
+ ) {
+ tapeTest.equal(
+ path.maxAbsoluteError,
+ 0,
+ `${path.algorithm} matches exact integer CPU results`
+ );
+ } else {
+ tapeTest.ok(
+ path.maxAbsoluteError < 5e-4,
+ `${path.algorithm} stays within real float32 accuracy`
+ );
+ }
+ if (!report.timestampQueries) {
+ tapeTest.equal(
+ path.gpuTimeMilliseconds,
+ undefined,
+ `${path.algorithm} never fabricates unavailable GPU timestamp-query timings`
+ );
+ } else if (path.gpuTimeMilliseconds) {
+ assertDistribution(tapeTest, path.gpuTimeMilliseconds, `${path.algorithm} GPU timestamps`);
+ }
+ }
+ tapeTest.equal(
+ typeof report.timestampQueries,
+ 'boolean',
+ 'optional timestamp support is explicit'
+ );
+ tapeTest.equal(
+ device.type,
+ 'webgpu',
+ 'reported timings were produced by an actual WebGPU device'
+ );
+}
+
+function assertDistribution(
+ tapeTest: Test,
+ distribution: LuGraphBenchmarkDistribution,
+ label: string
+): void {
+ for (const value of [
+ distribution.minimum,
+ distribution.median,
+ distribution.percentile95,
+ distribution.maximum
+ ]) {
+ tapeTest.ok(Number.isFinite(value) && value >= 0, `${label} publishes finite measured timings`);
+ }
+ tapeTest.ok(distribution.minimum <= distribution.median, `${label} median follows its minimum`);
+ tapeTest.ok(
+ distribution.median <= distribution.percentile95,
+ `${label} 95th percentile follows its median`
+ );
+ tapeTest.ok(
+ distribution.percentile95 <= distribution.maximum,
+ `${label} maximum bounds its percentile`
+ );
+}
diff --git a/website/src/components/docs/lugraph-benchmark.tsx b/website/src/components/docs/lugraph-benchmark.tsx
new file mode 100644
index 0000000000..3c2b475fe6
--- /dev/null
+++ b/website/src/components/docs/lugraph-benchmark.tsx
@@ -0,0 +1,236 @@
+import React, {useEffect, useState, type ReactNode} from 'react';
+
+import {
+ runLuGraphBenchmark,
+ type LuGraphBenchmarkAlgorithm,
+ type LuGraphBenchmarkDatasetKind,
+ type LuGraphBenchmarkPathReport,
+ type LuGraphBenchmarkReport
+} from '@luma.gl/experimental/lugraph/benchmarks';
+
+import {createDevice, useStore} from '../../react-luma/store/device-store';
+import {LiveBenchmarkPanel} from './live-benchmark-panel';
+
+const GRAPH_BENCHMARK_VERTEX_COUNTS = [32, 64, 128, 256] as const;
+const GRAPH_BENCHMARK_DATASETS: {
+ kind: LuGraphBenchmarkDatasetKind;
+ label: string;
+}[] = [
+ {kind: 'sparse', label: 'Sparse'},
+ {kind: 'dense', label: 'Dense'},
+ {kind: 'scale-free', label: 'Scale-free'},
+ {kind: 'disconnected', label: 'Disconnected'},
+ {kind: 'high-degree', label: 'High-degree hub'}
+];
+
+/** Runs honest CPU and fence-synchronized WebGPU graph workloads only after an explicit click. */
+export function LuGraphBenchmark(): ReactNode {
+ const selectedDevice = useStore(store => store.presentationDevice || store.device);
+ const [datasetKind, setDatasetKind] = useState('scale-free');
+ const [vertexCount, setVertexCount] = useState(128);
+ const [webGPUUnavailable, setWebGPUUnavailable] = useState(false);
+
+ useEffect(() => {
+ setWebGPUUnavailable(typeof navigator === 'undefined' || !('gpu' in navigator));
+ }, []);
+
+ return (
+
+
+
+ Graph dataset
+
+ setDatasetKind(event.target.value as LuGraphBenchmarkDatasetKind)
+ }
+ value={datasetKind}
+ >
+ {GRAPH_BENCHMARK_DATASETS.map(dataset => (
+
+ {dataset.label}
+
+ ))}
+
+
+
+
+ Vertices
+ setVertexCount(Number(event.target.value))}
+ value={vertexCount}
+ >
+ {GRAPH_BENCHMARK_VERTEX_COUNTS.map(count => (
+
+ {count.toLocaleString()}
+
+ ))}
+
+
+
+
+
{
+ const device =
+ selectedDevice?.type === 'webgpu' ? selectedDevice : await createDevice('webgpu-core');
+ const report = await runLuGraphBenchmark(device, {
+ kind: datasetKind,
+ vertexCount,
+ seed: 42,
+ warmupIterations: 1,
+ measuredIterations: 3,
+ pageRankIterations: 20,
+ forceIterations: 1,
+ maxDepth: 6,
+ theta: 0.6,
+ gridSize: [8, 8]
+ });
+
+ return (
+
+ );
+ }}
+ />
+
+ );
+}
+
+function LuGraphBenchmarkResults({
+ report,
+ deviceLabel
+}: {
+ report: LuGraphBenchmarkReport;
+ deviceLabel: string;
+}): ReactNode {
+ return (
+
+
+ {report.datasetKind} · {report.vertexCount.toLocaleString()} {' '}
+ vertices · {report.edgeCount.toLocaleString()} edges ·{' '}
+ {report.measuredIterations} measured iterations · {deviceLabel}
+
+
+
+
+
+
+ Graph operation
+ CPU median
+ CPU encode
+ Fenced GPU median
+ {report.timestampQueries ? GPU timestamp : null}
+ GPU versus CPU
+ Oracle error
+ GPU working memory
+
+
+
+ {report.paths.map(path => (
+
+ ))}
+
+
+
+
+
+ Source upload: {formatMilliseconds(report.uploadTimeMilliseconds)} · graph compilation:{' '}
+ {formatMilliseconds(report.compilationTimeMilliseconds)} · explicit validation readback:{' '}
+ {formatMilliseconds(report.readbackTimeMilliseconds)}. These phases are reported separately
+ and are excluded from fenced operation measurements.
+
+
+
+ Standalone spatial-grid rebuild: {formatMilliseconds(report.spatialIndexBuildTimeMilliseconds.median)} median ·
+ caller-owned grid storage: {formatBytes(report.indexMemoryBytes)} · accelerated-versus-exact
+ maximum coordinate error: {formatError(report.approximationMaxAbsoluteError)}.
+
+
+
+ Every GPU submission completes through an explicit fence before its timer stops. Each result
+ is checked against an independent CPU oracle; spatial approximation error is additionally
+ compared with the exact force reference. Measurements describe this browser and adapter,
+ not cross-device performance guarantees.
+
+
+ );
+}
+
+function LuGraphBenchmarkRow({
+ path,
+ showTimestamp
+}: {
+ path: LuGraphBenchmarkPathReport;
+ showTimestamp: boolean;
+}): ReactNode {
+ const cpuMedian = path.cpuTimeMilliseconds.median;
+ const gpuMedian = path.synchronizedTimeMilliseconds.median;
+ const speedup = gpuMedian > 0 ? cpuMedian / gpuMedian : 0;
+
+ return (
+
+ {formatAlgorithm(path.algorithm)}
+ {formatMilliseconds(cpuMedian)}
+ {formatMilliseconds(path.cpuEncodeTimeMilliseconds.median)}
+ {formatMilliseconds(gpuMedian)}
+ {showTimestamp ? (
+
+ {path.gpuTimeMilliseconds
+ ? formatMilliseconds(path.gpuTimeMilliseconds.median)
+ : 'Unavailable'}
+
+ ) : null}
+ {speedup.toFixed(2)}×
+ {formatError(path.maxAbsoluteError)}
+
+ {formatBytes(path.importedBufferBytes)} source · {formatBytes(path.transientBufferBytes)}{' '}
+ scratch
+
+
+ );
+}
+
+function formatAlgorithm(algorithm: LuGraphBenchmarkAlgorithm): string {
+ const labels: Record = {
+ topology: 'CSR adjacency',
+ 'breadth-first-search': 'Neighborhood search',
+ 'connected-components': 'Weak components',
+ 'page-rank': 'PageRank',
+ 'exact-layout': 'Exact force layout',
+ 'spatial-layout': 'Approximate spatial layout'
+ };
+ return labels[algorithm];
+}
+
+function formatMilliseconds(milliseconds: number): string {
+ return `${milliseconds.toFixed(3)} ms`;
+}
+
+function formatBytes(bytes: number): string {
+ return bytes >= 1024 ? `${(bytes / 1024).toFixed(1)} KiB` : `${bytes} B`;
+}
+
+function formatError(error: number): string {
+ return error === 0 ? 'Exact' : error.toExponential(2);
+}
From b66cea1018ed8b9463ee53b12936621e954b1304 Mon Sep 17 00:00:00 2001
From: Ib Green
Date: Tue, 4 Aug 2026 23:06:52 -0400
Subject: [PATCH 2/2] feat(experimental): report real graph convergence metrics
---
docs/api-reference/experimental/README.md | 2 +
docs/api-reference/experimental/lugraph.md | 100 ++++++++++++++++++
.../experimental/src/lugraph/benchmarks.ts | 3 +-
.../src/lugraph/lu-graph-benchmark-data.ts | 9 +-
.../src/lugraph/lu-graph-benchmark.ts | 31 +++++-
.../lugraph/lu-graph-benchmark.node.spec.ts | 34 +++++-
.../test/lugraph/lu-graph-benchmark.spec.ts | 38 ++++++-
test/examples/lugraph-docs.node.spec.ts | 55 ++++++++++
.../src/components/docs/lugraph-benchmark.tsx | 26 ++++-
9 files changed, 286 insertions(+), 12 deletions(-)
diff --git a/docs/api-reference/experimental/README.md b/docs/api-reference/experimental/README.md
index 985b6dec31..a651e9334c 100644
--- a/docs/api-reference/experimental/README.md
+++ b/docs/api-reference/experimental/README.md
@@ -139,6 +139,8 @@ transaction investigations, and infrastructure maps can compose those operations
command graph without copying source batches or reading complete results back to JavaScript.
The [interactive graph explorer](/examples/experimental/lugraph-explorer) adds directly renderable
exact force-layout coordinates, neighborhood highlighting, stable GPU picking, dragging, and pinning.
+An opt-in live benchmark compares six actual CPU and WebGPU graph workloads across five graph
+families while reporting command encoding, completion fences, setup costs, and layout accuracy.
## GPU-resident Linked Crossfiltering
diff --git a/docs/api-reference/experimental/lugraph.md b/docs/api-reference/experimental/lugraph.md
index a772268558..26d3958955 100644
--- a/docs/api-reference/experimental/lugraph.md
+++ b/docs/api-reference/experimental/lugraph.md
@@ -1,4 +1,5 @@
import {ExperimentalDocsTabs} from '@site/src/components/docs/experimental-docs-tabs';
+import {LuGraphBenchmark} from '@site/src/components/docs/lugraph-benchmark';
import {LuGraphExplorerExample} from '@site/src/examples';
# luGraph: GPU-Resident Graph Analytics
@@ -69,6 +70,105 @@ network, dependency map, fraud investigation, or other relationship visualizatio
WebGPU-only educational example, not a large-graph performance benchmark: its exact layout costs
`O(V² + E)` per force iteration and intentionally uses only 128 vertices.
+## Measure real CPU and WebGPU graph workloads
+
+**Question: Does this graph workflow benefit from GPU execution on my actual browser, and what do
+setup, command submission, and approximate layout really cost?**
+
+A network diagram can demonstrate an algorithm without explaining its cost. This opt-in benchmark
+runs independent CPU implementations and the actual WebGPU graph operations against identical,
+deterministic source edges, stable vertex identifiers, and initial coordinates. Use it to compare
+different graph structures, understand why a small CPU-resident task may be faster on the CPU, and
+decide whether reusing GPU-resident adjacency or approximating distant layout forces suits a real
+application.
+
+
+
+Select a graph family and 32, 64, 128, or 256 vertices, then explicitly start the benchmark.
+No benchmark GPU work runs during page rendering or hydration. A WebGPU-capable browser, supported
+adapter, and secure origin are required; unavailable hardware never produces simulated measurements.
+
+### Choose a graph that resembles your application
+
+- **Sparse:** a mostly connected ring with occasional shortcuts, similar to infrastructure routes
+ or simple dependency chains.
+- **Dense:** every distinct pair has a directed edge, producing `V × (V - 1)` edges and exposing
+ workloads dominated by adjacency and relationship count.
+- **Scale-free:** preferential attachment creates a few influential hubs, resembling citation,
+ social, and package-dependency networks.
+- **Disconnected:** multiple independent groups plus an isolated vertex exercise unreachable
+ searches and weak-component labeling.
+- **High-degree hub:** one central vertex connects to many neighbors, testing uneven relationship
+ distributions such as a heavily depended-on service.
+
+The original source and target edges retain three ordered batches, including an intentionally empty
+middle batch. Each family runs six genuine GPU algorithms and independent CPU references:
+compressed adjacency, breadth-first neighborhood search, weak components, PageRank, exact force
+layout, and explicitly approximate uniform-grid force layout. These bounded demonstrations are not
+million-vertex benchmarks; dense graphs and exact force layout can require quadratic work.
+
+### Read each timing without hiding its costs
+
+The **CPU median** measures the independent CPU algorithm. **CPU encode** measures the separate CPU
+work of recording the GPU command graph. **Fenced GPU median** begins at command submission and
+stops only after an explicit completion fence confirms that the real GPU workload completed; it
+does not include the separately reported CPU encoding. The displayed GPU-versus-CPU ratio compares
+only those two algorithm medians and therefore excludes encoding, initial upload, graph
+compilation, and correctness readback. Include those phases when judging one-off or end-to-end
+workflows.
+
+The panel performs one warmup and three measured iterations. Its median is an observed sample, not
+a statistically robust cross-device result; the programmatic API additionally reports observed
+minimum, median, 95th-percentile, and maximum values. Hardware GPU timestamps appear only when the
+active adapter genuinely exposes timestamp queries. Queue synchronization, browser overhead, and
+hardware execution describe different costs, so a timestamp is not a substitute for fenced
+end-to-end submission time.
+
+Source upload, initial command-graph compilation, explicit correctness readback, and an
+independently fenced spatial-grid rebuild are reported as separate phases. The accelerated layout
+measurement still includes the grid rebuild required by each actual iteration; the standalone
+grid result merely makes that cost visible. Working-memory columns distinguish imported buffers
+from transient graph storage, while caller-owned spatial-index bytes are reported independently.
+
+Every GPU result must match its independently computed CPU reference before timings are published.
+The spatial path is checked against a CPU implementation of the same approximation; its additional
+coordinate error is measured against the exact force reference. Weak components report their actual
+GPU convergence flag, and PageRank reports its final GPU L1 residual. A fixed iteration budget does
+not imply convergence or early termination. Results apply only to this graph, browser, and
+adapter; they never promise a speedup, generalize across devices, or describe the approximation
+as Barnes–Hut or ForceAtlas2.
+
+### Run the same benchmark programmatically
+
+Benchmark helpers live behind an optional nested entry point so ordinary graph applications do not
+import benchmark-only datasets, CPU references, or measurement code:
+
+```ts
+import {
+ makeLuGraphBenchmarkDataset,
+ runLuGraphBenchmark
+} from '@luma.gl/experimental/lugraph/benchmarks';
+
+const dataset = makeLuGraphBenchmarkDataset({kind: 'scale-free', vertexCount: 128, seed: 42});
+const report = await runLuGraphBenchmark(device, {
+ kind: dataset.kind,
+ vertexCount: dataset.vertexCount,
+ seed: 42,
+ warmupIterations: 1,
+ measuredIterations: 3,
+ pageRankIterations: 20,
+ forceIterations: 1,
+ maxDepth: 6,
+ theta: 0.6,
+ gridSize: [8, 8]
+});
+```
+
+The dataset helper returns fresh, caller-owned CPU arrays; the benchmark independently generates
+its identical seeded input, explicitly uploads and validates real GPU results, and releases its
+own temporary allocations after reporting. Neither helper changes production graph ownership or
+adds an automatic CPU execution fallback to the graph API.
+
## Why keep a graph on the GPU?
A CPU application can certainly traverse a graph. The problem appears when its relationship data
diff --git a/modules/experimental/src/lugraph/benchmarks.ts b/modules/experimental/src/lugraph/benchmarks.ts
index 02941ba76c..6c26bb7675 100644
--- a/modules/experimental/src/lugraph/benchmarks.ts
+++ b/modules/experimental/src/lugraph/benchmarks.ts
@@ -1,6 +1,7 @@
// luma.gl
// SPDX-License-Identifier: MIT
-// Copyright (c) vis.gl contributors
+// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors
+// SPDX-FileComment: Independently implemented for WebGPU; inspired by NVIDIA RAPIDS cuGraph.
export {runLuGraphBenchmark} from './lu-graph-benchmark';
export {makeLuGraphBenchmarkDataset} from './lu-graph-benchmark-data';
diff --git a/modules/experimental/src/lugraph/lu-graph-benchmark-data.ts b/modules/experimental/src/lugraph/lu-graph-benchmark-data.ts
index 698243e1d6..19beac21f4 100644
--- a/modules/experimental/src/lugraph/lu-graph-benchmark-data.ts
+++ b/modules/experimental/src/lugraph/lu-graph-benchmark-data.ts
@@ -1,6 +1,7 @@
// luma.gl
// SPDX-License-Identifier: MIT
-// Copyright (c) vis.gl contributors
+// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors
+// SPDX-FileComment: Independently implemented for WebGPU; inspired by NVIDIA RAPIDS cuGraph.
/** Deterministic graph families compared against their actual browser GPU execution. */
export type LuGraphBenchmarkDatasetKind =
@@ -54,6 +55,12 @@ export type LuGraphBenchmarkDistribution = {
/** Correctness-gated timings and physical allocations for one real GPU algorithm. */
export type LuGraphBenchmarkPathReport = {
algorithm: LuGraphBenchmarkAlgorithm;
+ /** Compiled algorithm-iteration budget; this does not imply early stopping or convergence. */
+ iterations?: number;
+ /** Actual final GPU fixed-point status when the implementation publishes one. */
+ converged?: boolean;
+ /** Actual final GPU L1 residual when the implementation publishes that metric. */
+ residual?: number;
cpuTimeMilliseconds: LuGraphBenchmarkDistribution;
cpuEncodeTimeMilliseconds: LuGraphBenchmarkDistribution;
synchronizedTimeMilliseconds: LuGraphBenchmarkDistribution;
diff --git a/modules/experimental/src/lugraph/lu-graph-benchmark.ts b/modules/experimental/src/lugraph/lu-graph-benchmark.ts
index fbab717a12..36c7b7f019 100644
--- a/modules/experimental/src/lugraph/lu-graph-benchmark.ts
+++ b/modules/experimental/src/lugraph/lu-graph-benchmark.ts
@@ -1,6 +1,7 @@
// luma.gl
// SPDX-License-Identifier: MIT
-// Copyright (c) vis.gl contributors
+// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors
+// SPDX-FileComment: Independently implemented for WebGPU; inspired by NVIDIA RAPIDS cuGraph.
import {Buffer, type Device, type QuerySet} from '@luma.gl/core';
import {GPUData, GPUVector} from '@luma.gl/tables';
@@ -52,6 +53,8 @@ type BenchmarkExecution = {
type BenchmarkValidation = {
maxAbsoluteError: number;
approximationMaxAbsoluteError?: number;
+ converged?: boolean;
+ residual?: number;
readbackTimeMilliseconds: number;
};
@@ -143,6 +146,12 @@ export async function runLuGraphBenchmark(
);
paths.push({
algorithm: path.algorithm,
+ ...(finalValidation.converged === undefined
+ ? {}
+ : {iterations: resources.components.iterations, converged: finalValidation.converged}),
+ ...(finalValidation.residual === undefined
+ ? {}
+ : {iterations: resources.pageRank.iterations, residual: finalValidation.residual}),
cpuTimeMilliseconds: context.cpuTimeMilliseconds[path.algorithm],
cpuEncodeTimeMilliseconds: summarizeLuGraphBenchmarkSamples(
executions.map(execution => execution.cpuEncodeTimeMilliseconds)
@@ -526,6 +535,8 @@ async function readBenchmarkPath(
let readbackTimeMilliseconds = 0;
let maxAbsoluteError = 0;
let approximationMaxAbsoluteError: number | undefined;
+ let converged: boolean | undefined;
+ let residual: number | undefined;
const readOutputs = async (
...vectors: (GPUVector<'uint32'> | GPUVector<'float32'> | GPUVector<'float32x2'>)[]
): Promise => {
@@ -576,18 +587,28 @@ async function readBenchmarkPath(
break;
}
case 'connected-components': {
- const [components, converged] = await readOutputs(
+ const [components, convergenceValues] = await readOutputs(
resources.components.output,
resources.components.converged!
);
+ converged = convergenceValues[0] === 1;
maxAbsoluteError = Math.max(
getMaximumAbsoluteError(components, context.reference.components),
- Math.abs(converged[0] - 1)
+ Math.abs(convergenceValues[0] - 1)
);
break;
}
case 'page-rank': {
- const [values] = await readOutputs(resources.pageRank.output);
+ const [values, residualValues] = await readOutputs(
+ resources.pageRank.output,
+ resources.pageRank.residual!
+ );
+ residual = residualValues[0];
+ if (!Number.isFinite(residual) || residual < 0) {
+ throw new Error(
+ `luGraph benchmark page-rank produced an invalid GPU residual: ${residual}`
+ );
+ }
maxAbsoluteError = Math.max(
getMaximumAbsoluteError(values, context.reference.pageRank),
Math.abs(values.reduce((sum, value) => sum + value, 0) - 1)
@@ -641,6 +662,8 @@ async function readBenchmarkPath(
return {
maxAbsoluteError,
...(approximationMaxAbsoluteError === undefined ? {} : {approximationMaxAbsoluteError}),
+ ...(converged === undefined ? {} : {converged}),
+ ...(residual === undefined ? {} : {residual}),
readbackTimeMilliseconds
};
}
diff --git a/modules/experimental/test/lugraph/lu-graph-benchmark.node.spec.ts b/modules/experimental/test/lugraph/lu-graph-benchmark.node.spec.ts
index 4fae83fe6c..34c7cb89ec 100644
--- a/modules/experimental/test/lugraph/lu-graph-benchmark.node.spec.ts
+++ b/modules/experimental/test/lugraph/lu-graph-benchmark.node.spec.ts
@@ -1,6 +1,6 @@
// luma.gl
// SPDX-License-Identifier: MIT
-// Copyright (c) vis.gl contributors
+// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors
import {readFileSync} from 'node:fs';
@@ -10,9 +10,10 @@ import * as benchmarkModule from '@luma.gl/experimental/lugraph/benchmarks';
import {
makeLuGraphBenchmarkDataset,
type LuGraphBenchmarkDatasetKind,
- type LuGraphBenchmarkOptions
+ type LuGraphBenchmarkOptions,
+ type LuGraphBenchmarkPathReport
} from '@luma.gl/experimental/lugraph/benchmarks';
-import {describe, expect, test} from 'vitest';
+import {describe, expect, expectTypeOf, test} from 'vitest';
import {
prepareLuGraphBenchmark,
@@ -64,6 +65,33 @@ describe('luGraph benchmark optional package boundary', () => {
expect(dependencies?.['apache-arrow']).toBeUndefined();
}
});
+
+ test('keeps bounded convergence and final residual metadata optional and backward compatible', () => {
+ expectTypeOf()
+ .toHaveProperty('iterations')
+ .toEqualTypeOf();
+ expectTypeOf()
+ .toHaveProperty('converged')
+ .toEqualTypeOf();
+ expectTypeOf()
+ .toHaveProperty('residual')
+ .toEqualTypeOf();
+
+ const distribution = {minimum: 1, median: 1, percentile95: 1, maximum: 1};
+ const legacyPath: LuGraphBenchmarkPathReport = {
+ algorithm: 'topology',
+ cpuTimeMilliseconds: distribution,
+ cpuEncodeTimeMilliseconds: distribution,
+ synchronizedTimeMilliseconds: distribution,
+ maxAbsoluteError: 0,
+ importedBufferBytes: 4,
+ transientBufferBytes: 0
+ };
+
+ expect('iterations' in legacyPath).toBe(false);
+ expect('converged' in legacyPath).toBe(false);
+ expect('residual' in legacyPath).toBe(false);
+ });
});
describe('luGraph benchmark deterministic source datasets', () => {
diff --git a/modules/experimental/test/lugraph/lu-graph-benchmark.spec.ts b/modules/experimental/test/lugraph/lu-graph-benchmark.spec.ts
index a2950581e7..5d26ceeb45 100644
--- a/modules/experimental/test/lugraph/lu-graph-benchmark.spec.ts
+++ b/modules/experimental/test/lugraph/lu-graph-benchmark.spec.ts
@@ -1,6 +1,6 @@
// luma.gl
// SPDX-License-Identifier: MIT
-// Copyright (c) vis.gl contributors
+// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors
import {type Device} from '@luma.gl/core';
import {
@@ -64,7 +64,8 @@ for (const kind of BENCHMARK_DATASETS) {
vertexCount: 12,
edgeCount: expectedDataset.edgeCount,
cellCount: 16,
- theta
+ theta,
+ pageRankIterations: 4
});
tapeTest.ok(
submitSpy.mock.calls.length >= 7,
@@ -99,6 +100,7 @@ function assertBenchmarkReport(
edgeCount: number;
cellCount: number;
theta: number;
+ pageRankIterations: number;
}
): void {
tapeTest.equal(
@@ -196,6 +198,38 @@ function assertBenchmarkReport(
} else if (path.gpuTimeMilliseconds) {
assertDistribution(tapeTest, path.gpuTimeMilliseconds, `${path.algorithm} GPU timestamps`);
}
+
+ if (path.algorithm === 'connected-components') {
+ tapeTest.equal(path.iterations, 32, 'weak components report their actual bounded GPU passes');
+ tapeTest.equal(
+ path.converged,
+ true,
+ 'weak components report the real final GPU fixed-point convergence status'
+ );
+ tapeTest.equal(
+ path.residual,
+ undefined,
+ 'integer weak components never fabricate a residual'
+ );
+ } else if (path.algorithm === 'page-rank') {
+ tapeTest.equal(
+ path.iterations,
+ expected.pageRankIterations,
+ 'PageRank reports its actual independently configured GPU pass count'
+ );
+ tapeTest.ok(
+ typeof path.residual === 'number' &&
+ Number.isFinite(path.residual) &&
+ path.residual >= 0 &&
+ path.residual <= 2,
+ 'PageRank exposes the real finite, normalized final GPU L1 residual'
+ );
+ tapeTest.equal(path.converged, undefined, 'PageRank never invents a binary convergence flag');
+ } else {
+ tapeTest.equal(path.iterations, undefined, `${path.algorithm} omits unrelated pass counts`);
+ tapeTest.equal(path.converged, undefined, `${path.algorithm} omits unrelated convergence`);
+ tapeTest.equal(path.residual, undefined, `${path.algorithm} omits unrelated GPU residuals`);
+ }
}
tapeTest.equal(
typeof report.timestampQueries,
diff --git a/test/examples/lugraph-docs.node.spec.ts b/test/examples/lugraph-docs.node.spec.ts
index bc2b36b7c2..2da5b96138 100644
--- a/test/examples/lugraph-docs.node.spec.ts
+++ b/test/examples/lugraph-docs.node.spec.ts
@@ -85,6 +85,61 @@ describe('luGraph GPU-resident graph analytics documentation', () => {
}
});
+ test('documents opt-in CPU and actual WebGPU benchmarks with reproducible graph workloads', () => {
+ expect(graphDocumentation).toContain(
+ "import {LuGraphBenchmark} from '@site/src/components/docs/lugraph-benchmark';"
+ );
+ expect(graphDocumentation).toContain('## Measure real CPU and WebGPU graph workloads');
+ expect(graphDocumentation).toContain(' ');
+ expect(graphDocumentation).toContain('32, 64, 128, or 256 vertices');
+ expect(graphDocumentation).toContain(
+ 'No benchmark GPU work runs during page rendering or hydration'
+ );
+ expect(graphDocumentation).toContain('### Choose a graph that resembles your application');
+
+ for (const graphFamily of [
+ '**Sparse:**',
+ '**Dense:**',
+ '**Scale-free:**',
+ '**Disconnected:**',
+ '**High-degree hub:**'
+ ]) {
+ expect(graphDocumentation, graphFamily).toContain(graphFamily);
+ }
+
+ expect(graphDocumentation).toContain('`V × (V - 1)` edges');
+ expect(graphDocumentation).toContain('including an intentionally empty');
+ expect(graphDocumentation).toContain('six genuine GPU algorithms');
+ expect(graphDocumentation).toContain('not\nmillion-vertex benchmarks');
+ expect(graphDocumentation).toContain("from '@luma.gl/experimental/lugraph/benchmarks';");
+ expect(graphDocumentation).toContain('makeLuGraphBenchmarkDataset');
+ expect(graphDocumentation).toContain('await runLuGraphBenchmark(device, {');
+ expect(experimentalOverview).toContain('opt-in live benchmark');
+ });
+
+ test('distinguishes GPU submission, encoding, validation, approximation, and real convergence', () => {
+ expect(graphDocumentation).toContain('### Read each timing without hiding its costs');
+ expect(graphDocumentation).toContain('**CPU median**');
+ expect(graphDocumentation).toContain('**CPU encode**');
+ expect(graphDocumentation).toContain('**Fenced GPU median**');
+ expect(graphDocumentation).toContain('explicit completion fence');
+ expect(graphDocumentation).toContain('does not include the separately reported CPU encoding');
+ expect(graphDocumentation).toContain('one warmup and three measured iterations');
+ expect(graphDocumentation).toContain('95th-percentile');
+ expect(graphDocumentation).toContain('genuinely exposes timestamp queries');
+ expect(graphDocumentation).toContain('Source upload, initial command-graph compilation');
+ expect(graphDocumentation).toContain('explicit correctness readback');
+ expect(graphDocumentation).toContain('independently fenced spatial-grid rebuild');
+ expect(graphDocumentation).toContain('measurement still includes the grid rebuild');
+ expect(graphDocumentation).toContain('independently computed CPU reference');
+ expect(graphDocumentation).toContain('same approximation');
+ expect(graphDocumentation).toContain('exact force reference');
+ expect(graphDocumentation).toContain('actual\nGPU convergence flag');
+ expect(graphDocumentation).toContain('final GPU L1 residual');
+ expect(graphDocumentation).toContain('does\nnot imply convergence or early termination');
+ expect(graphDocumentation).toContain('they never promise a speedup');
+ });
+
test('explains graph motivation, appropriate workloads, and concrete application use cases', () => {
expect(graphDocumentation).toContain('## Overview');
expect(graphDocumentation).toContain('## Why keep a graph on the GPU?');
diff --git a/website/src/components/docs/lugraph-benchmark.tsx b/website/src/components/docs/lugraph-benchmark.tsx
index 3c2b475fe6..1b79ba58f3 100644
--- a/website/src/components/docs/lugraph-benchmark.tsx
+++ b/website/src/components/docs/lugraph-benchmark.tsx
@@ -129,7 +129,7 @@ function LuGraphBenchmarkResults({
-
+
Graph operation
@@ -138,6 +138,7 @@ function LuGraphBenchmarkResults({
Fenced GPU median
{report.timestampQueries ? GPU timestamp : null}
GPU versus CPU
+ Bounded convergence
Oracle error
GPU working memory
@@ -173,6 +174,12 @@ function LuGraphBenchmarkResults({
compared with the exact force reference. Measurements describe this browser and adapter,
not cross-device performance guarantees.
+
+
+ Weak-component convergence is read from its final GPU status after the stated bounded pass
+ count. PageRank reports its actual final GPU L₁ residual after its separately stated pass
+ count; neither metric changes the measured timing or implies early termination.
+
);
}
@@ -202,6 +209,7 @@ function LuGraphBenchmarkRow({
) : null}
{speedup.toFixed(2)}×
+ {formatConvergence(path)}
{formatError(path.maxAbsoluteError)}
{formatBytes(path.importedBufferBytes)} source · {formatBytes(path.transientBufferBytes)}{' '}
@@ -234,3 +242,19 @@ function formatBytes(bytes: number): string {
function formatError(error: number): string {
return error === 0 ? 'Exact' : error.toExponential(2);
}
+
+function formatConvergence(path: LuGraphBenchmarkPathReport): string {
+ if (path.algorithm === 'connected-components' && path.iterations !== undefined) {
+ const status =
+ path.converged === undefined
+ ? 'status unavailable'
+ : path.converged
+ ? 'converged'
+ : 'not converged';
+ return `${path.iterations} passes · ${status}`;
+ }
+ if (path.algorithm === 'page-rank' && path.iterations !== undefined) {
+ return `${path.iterations} passes · L₁ ${path.residual?.toExponential(2) ?? 'unavailable'}`;
+ }
+ return '—';
+}