Parallel TSP heuristic
What is this about?
Section titled “What is this about?”The Traveling Salesman Problem asks for the shortest route that visits every city once and returns to the start. Trying every possible route becomes impractical quickly, so this example uses a heuristic instead of an exact solver.
Each worker runs an independent gravity-inspired search, refines its best route with 2-opt, and sends the result back to the host. The host keeps the best restart.
The result is a good route, not a proof of the optimum. More restarts improve the odds of finding a better route.
How it works
Section titled “How it works”- The host creates one deterministic map of cities.
- It starts independent solver restarts with different seeds.
- Each worker turns continuous GSA keys into a city permutation, then applies 2-opt.
- The host selects the shortest returned tour.
- The host validates the permutation and recomputes its length.
This is an embarrassingly parallel pattern: restarts do not share state, and the result of each task is small.
bun src/run_tsp.ts --threads 4 --restarts 16 --cities 32 --population 8 --iterations 40 --worldSeed 123456deno run -A src/run_tsp.ts --threads 4 --restarts 16 --cities 32 --population 8 --iterations 40 --worldSeed 123456npx tsx src/run_tsp.ts --threads 4 --restarts 16 --cities 32 --population 8 --iterations 40 --worldSeed 123456Expected output:
threads: 4cities: 32restarts: 16population: 8iterations: 40best length: 4.718tour valid: yesrecomputed: 4.718elapsed: runtime-dependentThe seed makes the city map and restart results reproducible. Elapsed time depends on your runtime, CPU, and worker count.
Why this pattern works
Section titled “Why this pattern works”- A heuristic can get stuck in a local minimum.
- Independent seeds explore different parts of the search space.
- Restarts are easy to distribute because workers do not coordinate.
- The host validates the best result instead of trusting a worker blindly.
import { createPool, isMain } from "knitting";import { solveTspGsa } from "./tsp_gsa.ts";
type Options = { threads: number; restarts: number; cities: number; population: number; iterations: number; worldSeed: number;};
type TspResult = { bestLen: number; bestTour: number[];};
function positiveIntArg(name: string, fallback: number): number { const index = process.argv.indexOf(`--${name}`); const value = index === -1 ? undefined : Number(process.argv[index + 1]); return Number.isSafeInteger(value) && value > 0 ? value : fallback;}
function readOptions(): Options { return { threads: positiveIntArg("threads", 4), restarts: positiveIntArg("restarts", 16), cities: positiveIntArg("cities", 32), population: positiveIntArg("population", 8), iterations: positiveIntArg("iterations", 40), worldSeed: positiveIntArg("worldSeed", 123_456), };}
function xorshift32(state: number): number { state |= 0; state ^= state << 13; state ^= state >>> 17; state ^= state << 5; return state | 0;}
function makeCities(worldSeed: number, cities: number): Float64Array { const coords = new Float64Array(cities * 2); let state = worldSeed | 0;
for (let city = 0; city < cities; city++) { state = xorshift32(state); coords[city * 2] = (state >>> 0) / 2 ** 32; state = xorshift32(state); coords[city * 2 + 1] = (state >>> 0) / 2 ** 32; }
return coords;}
function makeDistances(coords: Float64Array, cities: number): Float32Array { const distances = new Float32Array(cities * cities);
for (let left = 0; left < cities; left++) { const leftX = coords[left * 2]; const leftY = coords[left * 2 + 1];
for (let right = left + 1; right < cities; right++) { const dx = leftX - coords[right * 2]; const dy = leftY - coords[right * 2 + 1]; const distance = Math.hypot(dx, dy); distances[left * cities + right] = distance; distances[right * cities + left] = distance; } }
return distances;}
function tourLength( tour: number[], distances: Float32Array, cities: number,): number { let length = 0;
for (let index = 0; index < cities; index++) { const from = tour[index]; const to = tour[(index + 1) % cities]; length += distances[from * cities + to]; }
return length;}
function validateTour(tour: number[], cities: number): void { if (tour.length !== cities) { throw new Error(`expected ${cities} cities, got ${tour.length}`); }
const seen = new Uint8Array(cities); for (const city of tour) { if (!Number.isInteger(city) || city < 0 || city >= cities) { throw new Error(`invalid city index: ${city}`); } if (seen[city]) throw new Error(`city appears twice: ${city}`); seen[city] = 1; }}
async function main() { const options = readOptions(); const worldSeed = options.worldSeed | 0; const runSeed = 0x51f15e5;
using pool = createPool({ threads: options.threads })({ solveTspGsa });
const started = performance.now(); const jobs: Promise<TspResult>[] = [];
for (let restart = 0; restart < options.restarts; restart++) { const seed = (runSeed + restart * 0x6d2b_79f5) | 0; jobs.push( pool.call.solveTspGsa([ worldSeed, seed, options.cities, options.population, options.iterations, ]), ); }
const results = await Promise.all(jobs); const best = results.reduce((current, result) => result.bestLen < current.bestLen ? result : current, );
const distances = makeDistances( makeCities(worldSeed, options.cities), options.cities, ); validateTour(best.bestTour, options.cities);
const recomputed = tourLength(best.bestTour, distances, options.cities); if (Math.abs(recomputed - best.bestLen) > 1e-5) { throw new Error( `worker length mismatch: ${best.bestLen} vs ${recomputed}`, ); }
const elapsed = performance.now() - started;
console.log(`threads: ${options.threads}`); console.log(`cities: ${options.cities}`); console.log(`restarts: ${options.restarts}`); console.log(`population: ${options.population}`); console.log(`iterations: ${options.iterations}`); console.log(`best length: ${best.bestLen.toFixed(3)}`); console.log(`tour valid: yes`); console.log(`recomputed: ${recomputed.toFixed(3)}`); console.log(`elapsed: ${elapsed.toFixed(0)} ms`);}
if (isMain) { await main();}import { task } from "knitting";
type Args = readonly [ worldSeed: number, // generates the same city map for all runs runSeed: number, // controls the optimizer randomness nCities: number, popSize: number, iters: number,];
type Result = { bestLen: number; bestTour: number[];};
function xorshift32(s: number): number { s |= 0; s ^= s << 13; s ^= s >>> 17; s ^= s << 5; return s | 0;}const INV_U32 = 2.3283064365386963e-10; // 1 / 2^32
function rand01(stateRef: { s: number }): number { stateRef.s = xorshift32(stateRef.s); return (stateRef.s >>> 0) * INV_U32;}
function makeCities(worldSeed: number, n: number): Float64Array { // coords: [x0,y0,x1,y1,...] in [0,1) const coords = new Float64Array(n * 2); const st = { s: worldSeed | 0 }; for (let i = 0; i < n; i++) { coords[i * 2 + 0] = rand01(st); coords[i * 2 + 1] = rand01(st); } return coords;}
function makeDistMatrix(coords: Float64Array, n: number): Float32Array { const d = new Float32Array(n * n); for (let i = 0; i < n; i++) { const xi = coords[i * 2 + 0]; const yi = coords[i * 2 + 1]; for (let j = i + 1; j < n; j++) { const dx = xi - coords[j * 2 + 0]; const dy = yi - coords[j * 2 + 1]; const dist = Math.hypot(dx, dy); d[i * n + j] = dist; d[j * n + i] = dist; } } return d;}
function tourLen(dist: Float32Array, n: number, tour: Int32Array): number { let sum = 0; let prev = tour[0]; for (let i = 1; i < n; i++) { const cur = tour[i]; sum += dist[prev * n + cur]; prev = cur; } sum += dist[prev * n + tour[0]]; return sum;}
function decodeKeysToTour( keys: Float64Array, n: number, scratchIdx: number[], outTour: Int32Array,) { // scratchIdx contains 0..n-1 and is reused scratchIdx.sort((a, b) => keys[a] - keys[b]); for (let i = 0; i < n; i++) outTour[i] = scratchIdx[i];}
const eps = 1e-12;
function twoOpt(dist: Float32Array, n: number, tour: Int32Array): number { let best = tourLen(dist, n, tour);
while (true) { let improved = false;
outer: for (let i = 0; i < n - 1; i++) { for (let k = i + 2; k < n; k++) { const a = tour[i]; const b = tour[(i + 1) % n]; const c = tour[k]; const d = tour[(k + 1) % n];
const before = dist[a * n + b] + dist[c * n + d]; const after = dist[a * n + c] + dist[b * n + d];
if (after + eps < before) { // reverse segment (i+1..k) for (let l = i + 1, r = k; l < r; l++, r--) { const tmp = tour[l]; tour[l] = tour[r]; tour[r] = tmp; }
// delta update is valid because we restart scanning immediately best += after - before;
improved = true; break outer; } } }
if (!improved) break; }
// Safety: compute the true length once (guaranteed non-negative if dist is) return tourLen(dist, n, tour);}
export const solveTspGsa = task<Args, Result>({ f: ([worldSeed, runSeed, nCities, popSize, iters]) => { const n = nCities | 0; const pop = popSize | 0; const T = iters | 0;
const coords = makeCities(worldSeed | 0, n); const dist = makeDistMatrix(coords, n);
// Agent states const X = new Float64Array(pop * n); const V = new Float64Array(pop * n); const fit = new Float64Array(pop); const mass = new Float64Array(pop);
const st = { s: runSeed | 0 };
// Init positions and velocities for (let i = 0; i < pop * n; i++) { X[i] = rand01(st); // [0,1) V[i] = (rand01(st) - 0.5) * 0.1; // small initial velocity }
const scratchIdx: number[] = new Array(n); for (let i = 0; i < n; i++) scratchIdx[i] = i;
const tmpTour = new Int32Array(n); const bestTour = new Int32Array(n); let bestLen = Infinity;
// Helpers const idxPop: number[] = new Array(pop); for (let i = 0; i < pop; i++) idxPop[i] = i;
const eps = 1e-9; const G0 = 100.0; const alpha = 20.0;
// Main loop for (let t = 0; t < T; t++) { // Evaluate fitness (tour length) for (let i = 0; i < pop; i++) { const base = i * n; decodeKeysToTour(X.subarray(base, base + n), n, scratchIdx, tmpTour); const L = tourLen(dist, n, tmpTour); fit[i] = L;
if (L < bestLen) { bestLen = L; bestTour.set(tmpTour); } }
// Sort agents by fitness (ascending) idxPop.sort((a, b) => fit[a] - fit[b]);
const bestF = fit[idxPop[0]]; const worstF = fit[idxPop[pop - 1]]; const denom = Math.max(eps, worstF - bestF);
// Mass for minimization: better fitness => larger mass let sumM = 0; for (let r = 0; r < pop; r++) { const i = idxPop[r]; const m = (worstF - fit[i]) / denom; mass[i] = m; sumM += m; } const invSumM = 1 / Math.max(eps, sumM); for (let i = 0; i < pop; i++) mass[i] *= invSumM;
// K-best shrinks over time const K = Math.max(2, (pop * (1 - t / T)) | 0); const G = G0 * Math.exp(-alpha * (t / T));
// Update each agent via gravitational attraction for (let ii = 0; ii < pop; ii++) { const i = idxPop[ii]; const Mi = Math.max(eps, mass[i]); const baseI = i * n;
for (let d = 0; d < n; d++) { let Fi = 0;
// Pull from top-K agents for (let kk = 0; kk < K; kk++) { const j = idxPop[kk]; if (j === i) continue;
// Distance between agent vectors (cheap L2) const baseJ = j * n; let r2 = 0; for (let q = 0; q < n; q++) { const diff = X[baseJ + q] - X[baseI + q]; r2 += diff * diff; } const R = Math.sqrt(r2) + eps;
const Mj = mass[j]; const rij = X[baseJ + d] - X[baseI + d];
// random factor to avoid lockstep collapse Fi += rand01(st) * G * (Mi * Mj) * (rij / R); }
// a = F / Mi const a = Fi / Mi;
// velocity + position update const idx = baseI + d; V[idx] = rand01(st) * V[idx] + a; X[idx] = X[idx] + V[idx];
// keep keys in a reasonable range if (X[idx] < -2) X[idx] = -2; else if (X[idx] > 3) X[idx] = 3; } } }
// Local refinement: 2-opt on best tour const refined = bestTour.slice() as Int32Array; const refinedLen = twoOpt(dist, n, refined); if (refinedLen < bestLen) bestLen = refinedLen;
// Return as plain JS array for safe payload compatibility const out: number[] = new Array(n); for (let i = 0; i < n; i++) out[i] = refined[i];
return { bestLen, bestTour: out }; },});The algorithm
Section titled “The algorithm”GSA stores a real-valued key for each city. Sorting the keys produces a tour. Better tours receive more mass, and the agents move toward one another using a gravity-inspired update. After the global search, 2-opt repeatedly reverses a route segment when that makes the tour shorter.
The continuous keys are only a convenient search representation; the returned value is an ordinary permutation of city indexes.
CLI knobs
Section titled “CLI knobs”--cities— number of cities; route difficulty grows rapidly.--restarts— independent solver runs; increase this to explore more possible routes.--population— agents per restart; more exploration costs more work.--iterations— GSA updates per restart; deeper search costs more work.--threads— worker count.--worldSeed— fixed city layout for reproducible comparisons.
Things to try
Section titled “Things to try”- Increase
--restartsand compare quality per second. - Increase
--citiesto64and watch the problem become harder. - Compare four workers with one worker on the same seeded workload.
- Change the city distance metric or replace 2-opt with another local search.