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/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..6c26bb7675 --- /dev/null +++ b/modules/experimental/src/lugraph/benchmarks.ts @@ -0,0 +1,16 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// 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'; +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..19beac21f4 --- /dev/null +++ b/modules/experimental/src/lugraph/lu-graph-benchmark-data.ts @@ -0,0 +1,704 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// 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 = + | '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; + /** 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; + 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..36c7b7f019 --- /dev/null +++ b/modules/experimental/src/lugraph/lu-graph-benchmark.ts @@ -0,0 +1,720 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// 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'; +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; + converged?: boolean; + residual?: 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, + ...(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) + ), + 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; + let converged: boolean | undefined; + let residual: 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, convergenceValues] = await readOutputs( + resources.components.output, + resources.components.converged! + ); + converged = convergenceValues[0] === 1; + maxAbsoluteError = Math.max( + getMaximumAbsoluteError(components, context.reference.components), + Math.abs(convergenceValues[0] - 1) + ); + break; + } + case 'page-rank': { + 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) + ); + 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}), + ...(converged === undefined ? {} : {converged}), + ...(residual === undefined ? {} : {residual}), + 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..34c7cb89ec --- /dev/null +++ b/modules/experimental/test/lugraph/lu-graph-benchmark.node.spec.ts @@ -0,0 +1,375 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 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, + type LuGraphBenchmarkPathReport +} from '@luma.gl/experimental/lugraph/benchmarks'; +import {describe, expect, expectTypeOf, 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(); + } + }); + + 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', () => { + 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..5d26ceeb45 --- /dev/null +++ b/modules/experimental/test/lugraph/lu-graph-benchmark.spec.ts @@ -0,0 +1,268 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 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, + pageRankIterations: 4 + }); + 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; + pageRankIterations: 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`); + } + + 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, + '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/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 new file mode 100644 index 0000000000..1b79ba58f3 --- /dev/null +++ b/website/src/components/docs/lugraph-benchmark.tsx @@ -0,0 +1,260 @@ +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 ( +
+
+ + + +
+ + { + 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} +

+ +
+ + + + + + + + {report.timestampQueries ? : null} + + + + + + + + {report.paths.map(path => ( + + ))} + +
Graph operationCPU medianCPU encodeFenced GPU medianGPU timestampGPU versus CPUBounded convergenceOracle errorGPU working memory
+
+ +

+ 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. +

+ +

+ 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. +

+
+ ); +} + +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)}× + {formatConvergence(path)} + {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); +} + +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 '—'; +}