Task 2 of 6

Halve the Step, Halve the Error

Euler is wrong. The useful question is how wrong, and what more steps buy you. Explicit Euler is a first-order method: its error at a fixed end time is proportional to dt. Halve the step, halve the error. Pay twice, get twice — which is a terrible exchange rate, and you can only find that out by measuring it.

Measuring needs the same trajectories run at several step sizes, which is what the second output axis is for. output: [256, 4] launches a grid: this.thread.x picks the trajectory, this.thread.y picks the refinement level — 50, 100, 200 or 400 steps, every level covering the same t = 0…5, so dt is 5 / n. One launch, the whole convergence study.

Notice what that does to the loop: the trip count now differs from thread to thread. gpu.js compiles a non-constant bound as a fixed loopMaxIterations loop with an early exit, so the 50-step threads still march through all 400 iterations alongside their neighbours. That is not a gpu.js quirk — lockstep hardware behaves that way everywhere.

Goal: return each thread's absolute error against the closed form, then average each row in JavaScript and log what each halving of dt bought.

Requirements

Hint 1 — which step size is mine?

The row index is the level:

const n = levelSteps[this.thread.y];
const dt = this.constants.tEnd / n;

Every row ends at the same time; only the number of steps it took to get there differs.

Hint 2 — the truth to subtract

Math.cos and Math.sin both work inside kernels:

const exact = startX[this.thread.x] * Math.cos(this.constants.tEnd)
            + startV[this.thread.x] * Math.sin(this.constants.tEnd);
return Math.abs(x - exact);

Math.abs matters: without it half the trajectories report a positive error and half a negative one, and the row average cancels to nearly nothing.

Hint 3 — the JavaScript half

A 2D kernel returns rows, so errors[level] is a whole row of trajectory errors:

const means = [];
for (let level = 0; level < errors.length; level++) {
  let total = 0;
  for (let i = 0; i < errors[level].length; i++) total += errors[level][i];
  means.push(total / errors[level].length);
}
for (let level = 1; level < means.length; level++) {
  console.log((means[level - 1] / means[level]).toFixed(3));
}

Same idea elsewhere

Running an entire convergence study as one dispatch is the GPU-native form of a parameter sweep, and 2D launch grids are how every platform spells it — CUDA's dim3 grid, WebGPU's dispatch dimensions, Metal's threadgroup grid. The divergence lesson travels too: a warp, a wavefront or a subgroup runs its loop until the last lane is finished, so a per-thread trip count is a hint about work, never a promise about time.

All tasks in ODE Integrators

  1. One Thread, One Whole Trajectory
  2. Halve the Step, Halve the Error
  3. Look Before You Leap
  4. Four Slopes, Weighted
  5. Velocity Verlet: the Symplectic Step
  6. Payoff: RK4 Drifts, Verlet Does Not

This page is an interactive exercise — the editor, the GPU runner and your saved progress need JavaScript. The text above is the full brief.