Skip to content

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.

  1. The host creates a pool and splits the total run count into chunks.
  2. 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
  3. Each chunk returns compact summary stats: escaped count, total runs, sum of escape steps.
  4. 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)?
deno.sh
deno add --npm jsr:@vixeny/knitting
bun.sh
bun src/walks_runs.ts --threads 6 --runs 15000000 --batch 5000 --steps 15000 --radius 100

Expected output:

threads: 6 runs: 15,000,000 batch: 5,000 steps: 15,000 radius: 100
escape probability: 0.9847
mean escape steps: 7,312
elapsed: 3.41s
walks_runs.ts
import { 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);
}

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.

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.

p^=escapedtotal runsμ^=stepsescaped\hat{p} = \frac{\text{escaped}}{\text{total runs}} \qquad \hat{\mu} = \frac{\sum \text{steps}}{\text{escaped}}

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.

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.

  1. Increase --radius and see how mean escape steps changes.
  2. Compare different --batch chunk sizes and measure throughput.
  3. Add variance reporting and compute a 95% confidence interval.
  4. Replace the random walk with a drift term (constant force) and compare escape behavior.