diff --git a/lib/core.ts b/lib/core.ts index d334dc5..ab5dd1e 100644 --- a/lib/core.ts +++ b/lib/core.ts @@ -9,6 +9,7 @@ import { import { countNewIntersectionsWithValues } from "./countNewIntersections" import { MinHeap } from "./MinHeap" import { shuffle } from "./shuffle" +import { SparseCandidateBestCostTable } from "./sparse-candidate-best-cost-table" import type { StaticallyUnroutableRouteSummary } from "./static-reachability" import { createStaticallyUnroutableRouteSummary, @@ -345,6 +346,7 @@ interface SegmentGeometryScratch { export class TinyHyperGraphSolver extends BaseSolver { state: TinyHyperGraphWorkingState private _problemSetup?: TinyHyperGraphProblemSetup + private readonly sparseCandidateBestCostTable?: SparseCandidateBestCostTable protected routeAttemptCountByRouteId: Uint32Array protected routeSuccessCountByRouteId: Uint32Array protected bestSolvedStateSnapshot?: SolvedStateSnapshot @@ -383,6 +385,18 @@ export class TinyHyperGraphSolver extends BaseSolver { ) { super() applyTinyHyperGraphSolverOptions(this, options) + if (this.USE_SPARSE_CANDIDATE_STORAGE) { + let incidentHopCount = 0 + for (const incidentRegions of topology.incidentPortRegion) { + incidentHopCount += incidentRegions.length + } + this.sparseCandidateBestCostTable = new SparseCandidateBestCostTable( + incidentHopCount, + (hopId) => { + this.assertValidIncidentHopId(hopId) + }, + ) + } this.state = { portAssignment: new Int32Array(topology.portCount).fill(-1), regionSegments: Array.from({ length: topology.regionCount }, () => []), @@ -395,10 +409,10 @@ export class TinyHyperGraphSolver extends BaseSolver { unroutedRoutes: range(problem.routeCount), candidateQueue: new MinHeap([], compareCandidatesByF), candidateBestCostByHopId: this.USE_SPARSE_CANDIDATE_STORAGE - ? new Map() + ? new Float64Array(0) : new Float64Array(topology.portCount * topology.regionCount), candidateBestCostGenerationByHopId: this.USE_SPARSE_CANDIDATE_STORAGE - ? new Map() + ? new Uint32Array(0) : new Uint32Array(topology.portCount * topology.regionCount), candidateBestCostGeneration: 1, goalPortId: -1, @@ -608,6 +622,10 @@ export class TinyHyperGraphSolver extends BaseSolver { resetCandidateBestCosts() { const { state } = this + if (this.sparseCandidateBestCostTable) { + this.sparseCandidateBestCostTable.reset() + return + } if (state.candidateBestCostGeneration === 0xffffffff) { if (state.candidateBestCostByHopId instanceof Map) { @@ -626,6 +644,10 @@ export class TinyHyperGraphSolver extends BaseSolver { } getCandidateBestCost(hopId: HopId) { + if (this.sparseCandidateBestCostTable) { + return this.sparseCandidateBestCostTable.get(hopId) + } + const { state } = this const bestCostGeneration = state.candidateBestCostGenerationByHopId @@ -639,8 +661,12 @@ export class TinyHyperGraphSolver extends BaseSolver { } setCandidateBestCost(hopId: HopId, bestCost: number) { - const { state } = this + if (this.sparseCandidateBestCostTable) { + this.sparseCandidateBestCostTable.set(hopId, bestCost) + return + } + const { state } = this if (state.candidateBestCostGenerationByHopId instanceof Map) { state.candidateBestCostGenerationByHopId.set( hopId, @@ -662,6 +688,26 @@ export class TinyHyperGraphSolver extends BaseSolver { return portId * this.topology.regionCount + nextRegionId } + private assertValidIncidentHopId(hopId: HopId): void { + const portId = Math.floor(hopId / this.topology.regionCount) + const regionId = hopId - portId * this.topology.regionCount + if ( + portId >= this.topology.portCount || + regionId >= this.topology.regionCount || + !this.topology.incidentPortRegion[portId]?.includes(regionId) + ) { + throw new Error( + "Sparse candidate best-cost table received non-incident hop " + + hopId + + " (port " + + portId + + ", region " + + regionId + + ")", + ) + } + } + getStartingNextRegionId( routeId: RouteId, startingPortId: PortId, diff --git a/lib/sparse-candidate-best-cost-table.ts b/lib/sparse-candidate-best-cost-table.ts new file mode 100644 index 0000000..40fd5e4 --- /dev/null +++ b/lib/sparse-candidate-best-cost-table.ts @@ -0,0 +1,111 @@ +const MAX_GENERATION = 0xffffffff +const MIN_CAPACITY = 2 + +type ValidateHopId = (hopId: number) => void + +export class SparseCandidateBestCostTable { + private readonly keys: Float64Array + private readonly occupied: Uint8Array + private readonly generations: Uint32Array + private readonly costs: Float64Array + private readonly mask: number + private generation = 1 + private entryCount = 0 + + constructor( + private readonly maxEntryCount: number, + private readonly validateHopId: ValidateHopId, + ) { + if (!Number.isSafeInteger(maxEntryCount) || maxEntryCount < 0) { + throw new Error( + "Sparse candidate best-cost table requires a non-negative safe entry count, received " + + maxEntryCount, + ) + } + + let capacity = MIN_CAPACITY + const minimumCapacity = Math.max(MIN_CAPACITY, maxEntryCount * 2) + while (capacity < minimumCapacity) capacity *= 2 + + this.keys = new Float64Array(capacity) + this.occupied = new Uint8Array(capacity) + this.generations = new Uint32Array(capacity) + this.costs = new Float64Array(capacity) + this.mask = capacity - 1 + } + + get size(): number { + return this.entryCount + } + + reset(): void { + if (this.generation === MAX_GENERATION) { + this.generations.fill(0) + this.generation = 1 + return + } + + this.generation += 1 + } + + get(hopId: number): number { + const slot = this.findSlot(hopId) + if (slot === undefined || this.generations[slot] !== this.generation) { + return Number.POSITIVE_INFINITY + } + + return this.costs[slot]! + } + + set(hopId: number, cost: number): void { + if (!Number.isSafeInteger(hopId) || hopId < 0) { + throw new Error( + "Sparse candidate best-cost table received invalid hop id " + hopId, + ) + } + + const existingSlot = this.findSlot(hopId) + if (existingSlot !== undefined) { + this.generations[existingSlot] = this.generation + this.costs[existingSlot] = cost + return + } + + if (this.entryCount >= this.maxEntryCount) { + throw new Error( + "Sparse candidate best-cost table exhausted its " + + this.maxEntryCount + + " legal hop entries while inserting hop " + + hopId, + ) + } + + this.validateHopId(hopId) + let slot = this.hash(hopId) + while (this.occupied[slot] !== 0) { + slot = (slot + 1) & this.mask + } + + this.keys[slot] = hopId + this.occupied[slot] = 1 + this.generations[slot] = this.generation + this.costs[slot] = cost + this.entryCount += 1 + } + + private findSlot(hopId: number): number | undefined { + let slot = this.hash(hopId) + while (this.occupied[slot] !== 0) { + if (this.keys[slot] === hopId) return slot + slot = (slot + 1) & this.mask + } + + return undefined + } + + private hash(hopId: number): number { + const lowBits = hopId >>> 0 + const highBits = Math.floor(hopId / 0x100000000) >>> 0 + return Math.imul(lowBits ^ highBits, 0x9e3779b1) & this.mask + } +} diff --git a/tests/solver/sparse-candidate-storage-equivalence.test.ts b/tests/solver/sparse-candidate-storage-equivalence.test.ts new file mode 100644 index 0000000..d2508f4 --- /dev/null +++ b/tests/solver/sparse-candidate-storage-equivalence.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from "bun:test" +import { + createEmptyRegionIntersectionCache, + type TinyHyperGraphProblem, + TinyHyperGraphSolver, + type TinyHyperGraphTopology, +} from "lib/index" + +const createSolver = (useSparseCandidateStorage: boolean) => { + const topology: TinyHyperGraphTopology = { + portCount: 2, + regionCount: 3, + regionIncidentPorts: [[0], [0, 1], [1]], + incidentPortRegion: [ + [1, 0], + [1, 2], + ], + regionWidth: new Float64Array(3).fill(10), + regionHeight: new Float64Array(3).fill(10), + regionCenterX: new Float64Array([0, 1, 2]), + regionCenterY: new Float64Array(3), + portAngleForRegion1: new Int32Array(2), + portAngleForRegion2: new Int32Array(2), + portX: new Float64Array([0, 1]), + portY: new Float64Array(2), + portZ: new Int32Array(2), + } + const problem: TinyHyperGraphProblem = { + routeCount: 1, + portSectionMask: new Int8Array(2).fill(1), + routeStartPort: Int32Array.from([0]), + routeEndPort: Int32Array.from([1]), + routeNet: Int32Array.from([0]), + regionNetId: new Int32Array(3).fill(-1), + } + const solver = new TinyHyperGraphSolver(topology, problem, { + USE_SPARSE_CANDIDATE_STORAGE: useSparseCandidateStorage, + }) + + solver.state.unroutedRoutes = [] + solver.state.portAssignment.set([0, 0]) + solver.state.regionSegments[1] = [[0, 0, 1]] + const congestedCache = createEmptyRegionIntersectionCache() + congestedCache.existingRegionCost = 0.5 + solver.state.regionIntersectionCaches[1] = congestedCache + solver.solve() + + return { + solved: solver.solved, + failed: solver.failed, + error: solver.error, + iterations: solver.iterations, + ripCount: solver.state.ripCount, + portAssignment: Array.from(solver.state.portAssignment), + regionSegments: solver.state.regionSegments, + unroutedRoutes: solver.state.unroutedRoutes, + stats: solver.stats, + } +} + +test("sparse and dense candidate storage stay equivalent across a rerip", () => { + const denseResult = createSolver(false) + const sparseResult = createSolver(true) + + expect(sparseResult).toEqual(denseResult) + expect(sparseResult.solved).toBe(true) + expect(sparseResult.failed).toBe(false) + expect(sparseResult.ripCount).toBe(1) + expect(sparseResult.regionSegments[1]).toEqual([[0, 0, 1]]) +}) diff --git a/tests/sparse-candidate-best-cost-table.test.ts b/tests/sparse-candidate-best-cost-table.test.ts new file mode 100644 index 0000000..ba64b1d --- /dev/null +++ b/tests/sparse-candidate-best-cost-table.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from "bun:test" +import { SparseCandidateBestCostTable } from "lib/sparse-candidate-best-cost-table" + +test("stores bounded generation-scoped costs across colliding hop ids", () => { + const validatedHopIds: number[] = [] + const table = new SparseCandidateBestCostTable(4, (hopId) => { + validatedHopIds.push(hopId) + }) + + table.set(0, 8) + table.set(8, 4) + table.set(16, 2) + + expect(table.size).toBe(3) + expect(table.get(0)).toBe(8) + expect(table.get(8)).toBe(4) + expect(table.get(16)).toBe(2) + + table.set(8, 3) + expect(table.get(8)).toBe(3) + expect(validatedHopIds).toEqual([0, 8, 16]) + + table.reset() + expect(table.get(0)).toBe(Number.POSITIVE_INFINITY) + expect(table.get(8)).toBe(Number.POSITIVE_INFINITY) + + table.set(8, 1) + table.set(24, 6) + expect(table.size).toBe(4) + expect(table.get(8)).toBe(1) + expect(table.get(24)).toBe(6) + expect(validatedHopIds).toEqual([0, 8, 16, 24]) + + expect(() => table.set(32, 5)).toThrow( + "Sparse candidate best-cost table exhausted its 4 legal hop entries", + ) + + const invalidTable = new SparseCandidateBestCostTable(1, (hopId) => { + throw new Error("invalid incident hop " + hopId) + }) + expect(() => invalidTable.set(1, 1)).toThrow("invalid incident hop 1") + expect(() => invalidTable.set(-1, 1)).toThrow("invalid hop id -1") +})