Parallel Monte Carlo Physics Loop
A physics-style simulation loop: step-by-step state updates with branching and early exits. Unlike the pi example (pure arithmetic), this one simulates a 2D random walk — start at the origin, move one unit in a random direction each step, stop when the particle crosses a radius boundary. The work per trial varies (some particles escape early, some don’t), which makes chunking important for load balance.
How it works
Section titled “How it works”- The host creates a pool and splits the total run count into chunks.
- Each worker runs many independent particle trials in a tight inner loop:
- Initialize
(x, y)at the origin - Each step: pick a random direction, update position, check escape condition
- Track whether the particle escaped and how many steps it took
- Initialize
- Each chunk returns compact summary stats: escaped count, total runs, sum of escape steps.
- The host aggregates chunk results into final estimates.
We measure two things:
- Escape probability: what fraction of particles reached the boundary within
maxSteps? - Mean escape time: how many steps did it take (for particles that escaped)?
Install
Section titled “Install”deno add --npm jsr:@vixeny/knittingnpx jsr add @vixeny/knitting# pnpm 10.9+pnpm add jsr:@vixeny/knitting
# fallback (older pnpm)pnpm dlx jsr add @vixeny/knitting# yarn 4.9+yarn add jsr:@vixeny/knitting
# fallback (older yarn)yarn dlx jsr add @vixeny/knittingbunx jsr add @vixeny/knittingbun src/walks_runs.ts --threads 6 --runs 15000000 --batch 5000 --steps 15000 --radius 100deno run -A src/walks_runs.ts --threads 6 --runs 15000000 --batch 5000 --steps 15000 --radius 100npx tsx src/walks_runs.ts --threads 6 --runs 15000000 --batch 5000 --steps 15000 --radius 100Expected output:
threads: 6 runs: 15,000,000 batch: 5,000 steps: 15,000 radius: 100
escape probability: 0.9847mean escape steps: 7,312elapsed: 3.41simport { createPool, isMain } from "@vixeny/knitting";import { walkChunk } from "./walk2d.ts";
function intArg(name: string, fallback: number) { const i = process.argv.indexOf(`--${name}`); if (i !== -1 && i + 1 < process.argv.length) { const v = Number(process.argv[i + 1]); if (Number.isFinite(v) && v > 0) return Math.floor(v); } return fallback;}function numArg(name: string, fallback: number) { const i = process.argv.indexOf(`--${name}`); if (i !== -1 && i + 1 < process.argv.length) { const v = Number(process.argv[i + 1]); if (Number.isFinite(v) && v > 0) return v; } return fallback;}
// Tunables (pick any data you like)const THREADS = intArg("threads", 4);const TOTAL_RUNS = intArg("runs", 5_000_000);const RUNS_PER_JOB = intArg("batch", 5_000);const MAX_STEPS = intArg("steps", 15_000);const RADIUS = numArg("radius", 100);
const { call, shutdown } = createPool({ threads: THREADS, balancer: "firstIdle", // Optional: inliner helps if each job is too small. // inliner: { position: "last", batchSize: 1 },})({ walkChunk });
async function main() { const jobsCount = Math.ceil(TOTAL_RUNS / RUNS_PER_JOB); const jobs = new Array< Promise< { escaped: number; totalRuns: number; sumSteps: number; sumSteps2: number; } > >(jobsCount);
const seedBase = ((Date.now() | 0) ^ 0x9e3779b9) | 0;
for (let j = 0; j < jobsCount; j++) { const remaining = TOTAL_RUNS - j * RUNS_PER_JOB; const runs = remaining >= RUNS_PER_JOB ? RUNS_PER_JOB : remaining;
// Spread seeds per job so streams differ const seed = (seedBase + (j * 0x6d2b79f5)) | 0;
jobs[j] = call.walkChunk([seed, runs, MAX_STEPS, RADIUS]); }
const results = await Promise.all(jobs);
let escaped = 0; let total = 0; let sumSteps = 0; let sumSteps2 = 0;
for (const r of results) { escaped += r.escaped; total += r.totalRuns; sumSteps += r.sumSteps; sumSteps2 += r.sumSteps2; }
const pEscape = escaped / total;
let mean = NaN; let stdev = NaN;
if (escaped > 0) { mean = sumSteps / escaped; const mean2 = sumSteps2 / escaped; const variance = Math.max(0, mean2 - mean * mean); stdev = Math.sqrt(variance); }
console.log("Monte Carlo: 2D random-walk first-exit"); console.log("threads :", THREADS); console.log("total runs :", total.toLocaleString()); console.log("radius :", RADIUS); console.log("max steps :", MAX_STEPS.toLocaleString()); console.log("escape prob :", pEscape); console.log("mean steps :", mean); console.log("stdev steps :", stdev);}
if (isMain) { main().finally(shutdown);}import { task } from "@vixeny/knitting";
type Args = readonly [ seed: number, runs: number, maxSteps: number, radius: number, dirPow2?: number, // optional: directions table size = 2^dirPow2 (default 10 => 1024)];
type Result = { escaped: number; totalRuns: number; sumSteps: number; // sum of steps taken until escape (only for escaped runs) sumSteps2: number; // sum of steps^2 (only for escaped runs)};
// Fast deterministic RNG (xorshift32)function xorshift32(state: number): number { state |= 0; state ^= state << 13; state ^= state >>> 17; state ^= state << 5; return state | 0;}
// Precompute direction tables (module-scope = done once per worker)function makeDirs(pow2: number) { const n = 1 << pow2; const xs = new Float64Array(n); const ys = new Float64Array(n); const twoPi = Math.PI * 2; for (let i = 0; i < n; i++) { const a = (i * twoPi) / n; xs[i] = Math.cos(a); ys[i] = Math.sin(a); } return { xs, ys, mask: n - 1 };}
// Default table: 1024 directionsconst DEFAULT_DIR_POW2 = 10;let DIRS = makeDirs(DEFAULT_DIR_POW2);
export const walkChunk = task<Args, Result>({ f: ([seed, runs, maxSteps, radius, dirPow2]) => { if (dirPow2 && dirPow2 !== DEFAULT_DIR_POW2) { // Rare path: allow custom resolution if you want DIRS = makeDirs(dirPow2 | 0); }
const r2Limit = radius * radius;
let s = seed | 0; let escaped = 0; let sumSteps = 0; let sumSteps2 = 0;
const xs = DIRS.xs; const ys = DIRS.ys; const mask = DIRS.mask;
for (let run = 0; run < runs; run++) { let x = 0.0; let y = 0.0;
for (let step = 1; step <= maxSteps; step++) { s = xorshift32(s); const idx = s & mask;
x += xs[idx]; y += ys[idx];
const r2 = x * x + y * y; if (r2 >= r2Limit) { escaped++; sumSteps += step; sumSteps2 += step * step; break; } } }
return { escaped, totalRuns: runs, sumSteps, sumSteps2 }; },});What makes this different from the pi example
Section titled “What makes this different from the pi example”The pi example does the same amount of work per sample (two multiplies and a compare). This simulation has variable work per trial — particles that escape early are cheap, particles that hit the step limit are expensive. That variability means chunking matters more: well-sized chunks smooth out the variance so no single worker gets stuck with all the hard trials.
The inner loop is also branch-heavy (escape checks, direction selection), which is closer to real simulation code than a pure arithmetic kernel.
The science
Section titled “The science”This is a discrete-time approximation of Brownian motion / diffusion. The estimates converge by the Law of Large Numbers, and Monte Carlo error shrinks like 1/sqrtN.
Real applications of this pattern: diffusion/Brownian motion, hitting time problems, Monte Carlo transport (particles through materials), agent-based models, game simulation, and uncertainty propagation.
Practical notes
Section titled “Practical notes”Chunk size: Start with --batch 5000 to 50000 for heavier loops. Increase if each run is short, decrease if each run is long.
Keep the inner loop tight: Avoid allocations per step. Precompute direction tables if possible. Use simple numeric types.
Validate invariants: Check that 0 <= escaped <= totalRuns, totals add up across chunks, and results are stable under fixed seeds.
Things to try
Section titled “Things to try”- Increase
--radiusand see how mean escape steps changes. - Compare different
--batchchunk sizes and measure throughput. - Add variance reporting and compute a 95% confidence interval.
- Replace the random walk with a drift term (constant force) and compare escape behavior.