Benchmarking WebGPU and WebGL 2.0 Point Rendering

The specific trap this page defuses is measuring the wrong thing. Wrap a draw call in performance.now(), subtract, and you have timed how long the CPU spent recording commands plus whatever queue latency the driver happened to add — not how long the GPU spent rendering. Because WebGPU and WebGL 2.0 buffer work differently, that CPU-side number flatters whichever API defers more, and the benchmark tells you nothing about the rasterizer. A fair point-rendering comparison has to read the clock on the GPU: timestamp-query on WebGPU and the EXT_disjoint_timer_query_webgl2 extension on WebGL 2.0. Both bracket the actual GPU span, and both must be paired with a warm-up and a multi-frame median to survive shader compilation and driver scheduling noise. This harness renders the same N-million-point buffer on both paths and reports GPU milliseconds you can defend.

The API choice this benchmark informs is laid out in WebGPU vs WebGL 2.0 for spatial workloads; this page produces the numbers that decision leans on, using the same packed Float32Array of projected coordinates for both backends so the only variable is the rendering API.

Runnable reference implementation

Making the two backends comparable before timing them Four preparation steps that have to be identical across both backends before any measurement means anything. The dataset must be the same bytes in the same order, not regenerated per backend. The canvas size, device pixel ratio and antialiasing setting must match, because a multisampled framebuffer changes fragment cost. The camera path must be scripted and identical, not driven by hand. And the first thirty frames must be discarded, so shader compilation and buffer allocation do not land inside the measurement window. BEFORE TIMING ANYTHING · FOUR CONTROLS 1 Same bytes, same order one dataset, both backends never regenerate per run 2 Same canvas and sampling size, DPR, antialias MSAA changes fragment cost 3 Scripted camera path deterministic, repeatable hand-driven runs are not comparable 4 Discard the first 30 frames compile + allocate excluded or you are timing start-up Report the median and the 95th percentile — a mean hides exactly the stutter users notice.
Each of these has produced a published comparison that was wrong. The antialias default differs between the two context types, which alone is enough to make a fair benchmark report a two-fold difference that is entirely fragment-shading cost.

The harness below acquires both backends over one canvas, uploads an identical instanced-point buffer, and times a fixed number of frames on each with GPU-side queries. It discards warm-up frames, takes the median to reject scheduler outliers, and reports GPU milliseconds per frame. Copy it, feed it a point count, and read the two medians.

typescript
interface BenchResult { api: "webgpu" | "webgl2"; points: number; gpuMedianMs: number; frames: number[]; }

const median = (xs: number[]): number => {
  const s = [...xs].sort((a, b) => a - b);
  const m = s.length >> 1;
  return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
};

// ---------- WebGPU path: timestamp-query brackets the render pass ----------
async function benchWebGPU(canvas: HTMLCanvasElement, xy: Float32Array,
                           frames: number, warmup: number): Promise<BenchResult> {
  const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });
  if (!adapter?.features.has("timestamp-query")) throw new Error("timestamp-query unsupported");
  const device = await adapter.requestDevice({ requiredFeatures: ["timestamp-query"] });
  const ctx = canvas.getContext("webgpu") as GPUCanvasContext;
  const format = navigator.gpu.getPreferredCanvasFormat();
  ctx.configure({ device, format, alphaMode: "opaque" });

  const n = xy.length / 2;
  const inst = device.createBuffer({ size: xy.byteLength, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
  device.queue.writeBuffer(inst, 0, xy);
  const quad = device.createBuffer({ size: 32, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
  device.queue.writeBuffer(quad, 0, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]));

  const shader = device.createShaderModule({ code: /* wgsl */ `
    struct VOut { @builtin(position) pos: vec4<f32> };
    @vertex fn vs(@location(0) corner: vec2<f32>, @location(1) center: vec2<f32>) -> VOut {
      var o: VOut;
      o.pos = vec4<f32>(center + corner * 0.001, 0.0, 1.0); // 2px sprite in clip space
      return o;
    }
    @fragment fn fs() -> @location(0) vec4<f32> { return vec4<f32>(0.15, 0.42, 0.45, 1.0); }
  ` });
  const pipeline = device.createRenderPipeline({
    layout: "auto",
    vertex: { module: shader, entryPoint: "vs", buffers: [
      { arrayStride: 8, stepMode: "vertex",   attributes: [{ shaderLocation: 0, offset: 0, format: "float32x2" }] },
      { arrayStride: 8, stepMode: "instance", attributes: [{ shaderLocation: 1, offset: 0, format: "float32x2" }] },
    ] },
    fragment: { module: shader, entryPoint: "fs", targets: [{ format }] },
    primitive: { topology: "triangle-strip" },
  });

  const querySet = device.createQuerySet({ type: "timestamp", count: 2 });
  const resolve = device.createBuffer({ size: 16, usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC });
  const readback = device.createBuffer({ size: 16, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });

  const samples: number[] = [];
  for (let f = 0; f < frames + warmup; f++) {
    const enc = device.createCommandEncoder();
    const pass = enc.beginRenderPass({
      colorAttachments: [{ view: ctx.getCurrentTexture().createView(), loadOp: "clear", storeOp: "store", clearValue: { r: 0, g: 0, b: 0, a: 1 } }],
      timestampWrites: { querySet, beginningOfPassWriteIndex: 0, endOfPassWriteIndex: 1 },
    });
    pass.setPipeline(pipeline);
    pass.setVertexBuffer(0, quad);
    pass.setVertexBuffer(1, inst);
    pass.draw(4, n);
    pass.end();
    enc.resolveQuerySet(querySet, 0, 2, resolve, 0);
    enc.copyBufferToBuffer(resolve, 0, readback, 0, 16);
    device.queue.submit([enc.finish()]);

    await readback.mapAsync(GPUMapMode.READ);
    const t = new BigUint64Array(readback.getMappedRange());
    const ms = Number(t[1] - t[0]) / 1e6;      // timestamps are nanoseconds
    readback.unmap();
    if (f >= warmup) samples.push(ms);          // discard warm-up frames
  }
  return { api: "webgpu", points: n, gpuMedianMs: median(samples), frames: samples };
}

// ---------- WebGL 2.0 path: EXT_disjoint_timer_query_webgl2 brackets the draw ----------
async function benchWebGL2(canvas: HTMLCanvasElement, xy: Float32Array,
                           frames: number, warmup: number): Promise<BenchResult> {
  const gl = canvas.getContext("webgl2", { antialias: false })!;
  const ext = gl.getExtension("EXT_disjoint_timer_query_webgl2");
  if (!ext) throw new Error("EXT_disjoint_timer_query_webgl2 unsupported");

  const prog = linkProgram(gl,
    `#version 300 es
     layout(location=0) in vec2 corner; layout(location=1) in vec2 center;
     void main() { gl_Position = vec4(center + corner * 0.001, 0.0, 1.0); }`,
    `#version 300 es
     precision highp float; out vec4 frag;
     void main() { frag = vec4(0.88, 0.39, 0.30, 1.0); }`);

  const n = xy.length / 2;
  const quad = gl.createBuffer()!; gl.bindBuffer(gl.ARRAY_BUFFER, quad);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
  gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0);
  const inst = gl.createBuffer()!; gl.bindBuffer(gl.ARRAY_BUFFER, inst);
  gl.bufferData(gl.ARRAY_BUFFER, xy, gl.STATIC_DRAW);
  gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(1);
  gl.vertexAttribDivisor(1, 1);
  gl.useProgram(prog);

  const await1 = (q: WebGLQuery): Promise<number> => new Promise((res) => {
    const poll = () => {
      const avail = gl.getQueryParameter(q, gl.QUERY_RESULT_AVAILABLE);
      const disjoint = gl.getParameter(ext.GPU_DISJOINT_EXT);
      if (avail && !disjoint) res(gl.getQueryParameter(q, gl.QUERY_RESULT) / 1e6); // ns -> ms
      else if (disjoint) res(NaN);                // discard: clock was reset mid-query
      else requestAnimationFrame(poll);
    };
    poll();
  });

  const samples: number[] = [];
  for (let f = 0; f < frames + warmup; f++) {
    const q = gl.createQuery()!;
    gl.beginQuery(ext.TIME_ELAPSED_EXT, q);
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, n);
    gl.endQuery(ext.TIME_ELAPSED_EXT);
    const ms = await await1(q);
    gl.deleteQuery(q);
    if (f >= warmup && !Number.isNaN(ms)) samples.push(ms);
  }
  return { api: "webgl2", points: n, gpuMedianMs: median(samples), frames: samples };
}

function linkProgram(gl: WebGL2RenderingContext, vs: string, fs: string): WebGLProgram {
  const c = (t: number, src: string) => { const s = gl.createShader(t)!; gl.shaderSource(s, src); gl.compileShader(s); return s; };
  const p = gl.createProgram()!;
  gl.attachShader(p, c(gl.VERTEX_SHADER, vs));
  gl.attachShader(p, c(gl.FRAGMENT_SHADER, fs));
  gl.linkProgram(p);
  if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p) ?? "link failed");
  return p;
}

Run each backend on its own canvas (a WebGPU context and a WebGL 2.0 context cannot share one canvas), feed both the identical xy array, and compare gpuMedianMs. Sweep points across 1e6, 2e6, 5e6, and 10e6 to trace the crossover rather than reading a single figure.

Parameter reference

Benchmark results that are measuring the wrong thing A table of three suspicious benchmark outcomes with the likely cause and the check that confirms it. A WebGPU result that is slower at every size usually means resources are being recreated per frame, and the check is to count allocations across a run. A result where both backends are identical usually means the benchmark is bound by presentation rather than by rendering, and the check is to lower the canvas resolution and see whether anything changes. A result that swings by more than twenty percent between runs usually means the GPU clocks are not at steady state, and the check is to run twice and discard the first. SUSPICIOUS RESULT · CAUSE · CHECK Likely cause How to confirm WebGPU slower everywhere per-frame recreation count allocations both identical presentation-bound shrink the canvas ±20% between runs clocks not settled discard the first run Publish the harness alongside the numbers, or the numbers cannot be argued with — or trusted.
A benchmark that produces a surprising result is usually measuring something other than what it claims. Each of the checks above takes a minute and settles the question before the number goes anywhere.
Parameter Typical value Guidance
points (N) 1M – 10M Sweep it; a single N hides the crossover. Instance count equals N — one sprite per point.
frames 60 – 120 Median over enough frames to reject scheduler spikes. Fewer than ~30 is noisy.
warmup 10 – 20 Discards shader-compile and first-use allocation cost so the median reflects steady state.
Sprite half-size 0.001 clip units Keep fill rate constant across both paths, or you benchmark overdraw, not geometry.
powerPreference high-performance Forces the discrete GPU on dual-GPU laptops; without it the integrated GPU skews results.
WebGPU timestamp period ns (fixed) timestamp-query values are nanoseconds; divide by 1e6 for ms. No per-device scaling needed.
WebGL2 TIME_ELAPSED_EXT ns Same unit; always check GPU_DISJOINT_EXT and discard the sample if the clock was reset.
antialias false MSAA changes fragment counts; disable it on both paths for an apples-to-apples geometry test.
Canvas size fixed, e.g. 1920×1080 Resolution drives fill rate; pin it identically for both backends.

The two most consequential knobs are the sprite size and the warm-up count. A larger sprite turns the test into a fill-rate benchmark where both APIs converge on the same rasterizer; too small a warm-up leaves WebGPU’s one-time pipeline compilation in the median and makes it look slower than it runs.

One thing worth measuring alongside frame time, because it usually explains the result: bytes transferred to the GPU per frame. The WebGL 2.0 path re-uploads transformed vertices every frame, so its number scales with the visible point count, while the WebGPU path transforms in place and its number is close to zero once the tiles are resident. Plotting those two series next to the frame times turns the comparison from a claim about API speed into an observation about data movement, which is both more accurate and more actionable.

Failure modes

What a point-rendering benchmark should report A table of four reported statistics with what each one reveals. The median frame time reveals typical behaviour and is the headline number. The 95th percentile reveals the stutter a user actually notices, which a mean would hide entirely. Bytes uploaded per frame reveals whether the comparison is about rendering or about data movement, which is usually the real difference. And peak resident video memory reveals whether the two backends were given the same working set at all. WHAT TO REPORT, AND WHY What it reveals Median frame time typical behaviour 95th percentile the stutter users notice Bytes uploaded per frame rendering vs data movement Peak resident VRAM whether the setups matched A benchmark that reports only a mean frame time cannot be reasoned about — publish the distribution.
The third row usually explains the result. Most WebGPU-versus-WebGL comparisons on spatial data are really measuring how much data crosses the bus each frame, and reporting that number turns a mysterious ratio into an obvious one.
  • Timing the CPU instead of the GPU. Bracketing the draw with performance.now() measures command recording and queue latency, not GPU work. Detection: numbers swing with unrelated CPU load and disagree with the timestamp median. Fix: use timestamp-query and EXT_disjoint_timer_query_webgl2 as shown; treat wall-clock only as a submit-latency sanity check.
  • Ignoring GPU_DISJOINT_EXT. When the WebGL 2.0 timer clock is reset mid-query — a context switch or power-state change — the elapsed value is garbage. Detection: occasional absurd spikes or near-zero times. Fix: read GPU_DISJOINT_EXT after the query resolves and drop the sample when it is set, exactly as the poll loop does.
  • timestamp-query feature not requested. Reading a QuerySet of type timestamp without listing the feature in requiredFeatures throws at device creation or produces zeros. Detection: adapter.features.has("timestamp-query") is false, or all deltas are zero. Fix: gate the WebGPU path on the feature and request it; fall back to submit-boundary timing only when it is absent.
  • First-frame compilation counted as render time. Without warm-up, shader compilation and lazy buffer allocation land in the first samples and inflate the median for whichever path compiles more eagerly. Detection: the first few frames are multiples slower than the rest. Fix: discard warmup frames before recording, and keep the sprite, canvas size, and point buffer byte-identical across both backends.

Up: WebGPU vs WebGL 2.0 for Spatial Workloads