gpu-atlas
v0.2.2
Published
Probe what WebGPU actually does on a device — 53 formats, real limits, 7 benchmarks — then compare devices to find what is safe to depend on.
Maintainers
Readme
gpu-atlas
What WebGPU actually does on a device — measured, not declared.
Run it on your device → Takes a few seconds. Nothing is uploaded; the profile stays in your browser unless you save it yourself. It records the GPU and browser, not your user agent string — a profile is meant to be shareable without handing over more than the device description it is about.
What measuring three devices turned up
| | desktop | tablet | phone | |---|---|---|---| | GPU | NVIDIA Lovelace | Apple | Adreno 7xx | | browser | Chrome 151, Edge 152 | Safari 26.6 | Samsung Internet 30 | | OS | Windows | iPadOS | Android 16 |
The tablet is an iPad, and finding that out took longer than it should have.
Since iPadOS 13 Safari sends a Macintosh user agent containing neither iPad
nor Mobi, so the probe filed it as a desktop and left platform empty — a
profile that could not say whether it came from a Mac or an iPad. Schema 5 uses
maxTouchPoints to tell them apart. Everything below was re-measured after the
fix; profiles at schema 4 and earlier cannot make the distinction at all.
Performance does not scale by a single factor
| benchmark | desktop | iPad | Adreno | spread | |---|---:|---:|---:|---:| | triangle throughput | 4,246 MTri/s | 356 | 65 | 65x | | texture sampling | 254 GSample/s | 17.4 | 11.0 | 23x | | fill rate | 127,117 MPixel/s | 36,263 | 6,522 | 20x | | fragment ALU | 3,122 MPixel/s | 266 | 179 | 18x | | bind group switching | 4,067,797 /s | 888,889 | 279,720 | 14x | | pipeline switching | 1,904,762 /s | 1,200,000 | 181,818 | 11x | | draw call overhead | 6,185,567 /s | 1,828,571 | 764,331 | 8x |
Geometry spreads nearly three times wider than the next-widest axis, and the ordering is not uniform either. Against the iPad the desktop's geometry lead (12x) is ordinary and its texture sampling lead (15x) is the largest; against Adreno, geometry is the worst axis by a distance. Tile-based mobile GPUs pay binning cost on vertices, so on phones, cutting triangles buys far more than cutting pixels — the reverse of the desktop instinct.
Pipeline switching is the flattest axis of all: the iPad is within 1.6x of a desktop discrete GPU there while being 12x behind on geometry. Any single "this device is N times slower" number would be wrong in both directions at once.
Draw call overhead came back flagged unreliable on the desktop — the run
varied more than 15% between samples. The number is printed rather than hidden,
with the flag attached.
Capability differences that break code
maxUniformBufferBindingSize differs by 10,923x. The iPad allows
715,827,880 bytes; Chrome and Samsung Internet cap at 65,536. Code developed
against large uniform buffers does not merely run slower elsewhere — it fails
outright. Treat the upper figure as one device's, not WebKit's: it is large
enough to look memory-derived, and no second Apple device has been measured.
No compressed texture format works on all three. Desktop Chrome has BC only (6 formats). The iPad has ETC2 and ASTC but no BC (5). Adreno has all three (11). Desktop Chrome and the iPad share zero — and across all three devices the intersection is also empty, so there is no single compressed format to ship.
bgra8unorm is storage-writable on desktop and on the iPad, but not on
Adreno. It is also the preferred canvas format on those same two, while the
phone reports rgba8unorm — an easy thing to build on and have fail on phones.
maxStorageBufferBindingSize spans 16x (2.1GB / 716MB / 134MB) and
maxBufferSize 3x.
The browser barely matters; the device does
Chrome and Edge on the same machine, same GPU, same driver:
| benchmark | Chrome 151 | Edge 152 | ratio | |---|---:|---:|---:| | triangle throughput | 4,246 MTri/s | 3,906 | 1.09x | | texture sampling | 254 GSample/s | 241 | 1.06x | | bind group switching | 4,067,797 /s | 4,353,742 | 1.07x | | fill rate | 127,117 MPixel/s | 132,129 | 1.04x | | fragment ALU | 3,122 MPixel/s | 2,994 | 1.04x | | pipeline switching | 1,904,762 /s | 2,170,543 | 1.14x |
Everything lands within 1.14x, against the 65x that separates devices. Draw call
overhead appeared to differ by 1.63x — but the Chrome run of that benchmark came
back flagged unreliable at 21% variation, so the one axis that looked
interesting is the one the tool declines to stand behind. That is the flag
working, not a finding.
Two caveats worth stating: this is Chrome 151 against Edge on Chromium 152, so
it is not a clean same-version comparison, and Edge declares
subgroup-size-control where Chrome 151 does not — a feature that moved between
Chromium releases rather than anything Edge added. Declared limits are identical.
Browsers quantize GPU timestamps, and by different amounts
timestamp-query results are rounded into buckets as a Spectre mitigation.
Measured rather than assumed:
| | GPU timer | performance.now() |
|---|---|---|
| Chrome 151 | 65,536 ns (2^16) | 0.1 ms |
| Edge 152 | 65,536 ns (2^16) | 0.1 ms |
| Samsung Internet 30 | 65,536 ns (2^16) | 0.1 ms |
| Safari 26.6 | no quantization detected | 1 ms |
All three Chromium browsers return exactly 2^16 across two GPU vendors and two operating systems, while WebKit does not quantize the GPU timer at all — this is browser policy, not hardware.
The practical consequence: on Chromium, GPU work shorter than ~65 microseconds cannot be measured. It reports as zero or as a value indistinguishable from unrelated work. Before accounting for this, two unrelated benchmarks here reported byte-identical timings.
A benchmark that measured nothing
Fragment work was originally created by stacking identical opaque fullscreen draws. On the iPad that reported 112,524 MPixel/s — a 36x advantage over an RTX 4060, which is not plausible. A tile-based deferred renderer discards occluded opaque fragments before shading them, so every draw but the last was being thrown away. Adreno, tile-based but not deferred to the same degree, did not do this, so the same benchmark id was measuring different work per architecture.
Additive blending fixes it, since each draw must contribute to the accumulated result. The corrected figure is 266 MPixel/s — 423x lower, and in line with the rest of that device's numbers. Geometry throughput was unaffected, as its triangles occupy distinct screen positions and never occluded one another, which is the signature a real fix should have.
Usage
npm install gpu-atlasOr without installing anything, straight from a CDN:
import { probe } from 'https://esm.sh/gpu-atlas';import { probe, breakingIssues, pickFormat } from 'gpu-atlas';
const profile = await probe();
// Anything that will break on this device
for (const issue of breakingIssues(profile)) {
console.warn(issue.subject, issue.detail);
}
// Pick a format verified to work here, rather than one merely declared
const hdr = pickFormat(profile, ['rgba16float', 'rgb10a2unorm', 'rgba8unorm'], 'render');Comparing devices — this needs no GPU, so it also works in Node:
import { compareProfiles, formatComparison } from 'gpu-atlas';
const comparison = compareProfiles([desktop, tablet, phone]);
console.log(formatComparison(comparison));
// Sorted by spread, so the worst portability risk is first
const worst = comparison.benchmarks[0];
console.log(worst.id, worst.ratio); // "triangle-throughput", 65.2Measurements flagged unreliable — quantized or unstable — are marked rather
than folded silently into a ratio.
What it measures
Texture formats. 53 formats, each checked for six separate capabilities: creation, shader sampling, render target, blending, storage binding, and 4x MSAA. A format that creates fine but fails to bind is a real failure mode, and it is invisible in the feature list.
WGSL compilation. Cases where implementations diverge: function pointers, dynamic uniform indexing, struct alignment, override constants, workgroup atomics, uniformity analysis. Chrome uses Dawn/Tint, Firefox uses wgpu/naga, Safari has its own compiler, and each targets a different backend language. Compile time is recorded too, since it drives first-frame stalls.
Limits. Declared values are requested for real, then bisected to find the actual ceiling when a device refuses.
Benchmarks. Separated by axis rather than collapsed into a score, for the reason the table above demonstrates.
Measurement notes
Getting numbers is easy; getting numbers that mean anything was most of the work.
Quantization is measured, not assumed — and validated before it is believed. Work below one bucket reports as zero, so a trivial workload is grown until readings become non-zero. The smallest positive reading bounds the bucket, and the smallest gap between distinct readings lands on it. That candidate then has to behave like a bucket: under real quantization every reading is a multiple of it. Without that check, a fine-grained timer looks identical to a quantized one, and the same Safari machine reported a different timer on consecutive runs.
Both clocks are measured. performance.now() is quantized too, to a full
millisecond in Safari, and the draw-call benchmarks are wall-clock by necessity
— their cost lives in browser validation and driver calls, which barely register
on GPU timestamps. Each benchmark scales its repetitions until it spans enough
ticks of whichever clock timed it, and every result carries that tick count.
This is the only way to read a variation of zero correctly. Perfect consistency and a timer that cannot resolve the work look identical otherwise. Mobile turned out to be genuinely more reproducible than desktop — geometry throughput repeated at exactly 65.1 MTri/s across runs weeks apart — but that only became a claim worth making once ticks confirmed the measurement was not sitting on the floor.
Feature-aware baselines
WebGPU widens core capabilities through features — texture-formats-tier1 adds
storage binding to a set of formats, float32-blendable adds blending to 32-bit
float targets. Comparing against a fixed core baseline produces a flood of false
"more permissive than spec" reports, so the baseline is raised to match what a
device declares before comparing. What survives is genuine divergence.
The profile
probe() returns a JSON-serializable AtlasProfile keeping declared and
verified strictly separate, plus a discrepancies list where they disagree:
breaking— code relying on the declared value will fail heredegraded— it works, but slower or with reduced capabilitynote— worth recording, not worth acting on
Profile schema
Profiles are the point of this project, so a profile states which schema it was
captured under and SCHEMA_VERSION is bumped whenever a field is added,
removed, or changes meaning. The history is kept in src/types.ts.
Version 2 made measurement trustworthiness explicit — tick counts, quantization flags, measured timer resolutions — and changed the overdraw benchmarks to blend additively. Version 3 made errors structured rather than preformatted strings, and widened the fingerprint from 32 bits to 128, since the narrow version collided at a rate that mattered once profiles were being collected in bulk. Version 4 dropped the raw user agent: browser, version, platform and mobile are already parsed into their own fields, so keeping the original string added identifying detail without adding information.
compareProfiles accepts older profiles and still compares their capability
data, but marks pre-v2 benchmark numbers staleBenchmarks and treats them as
unreliable, because a version 1 profile's silence about quantization means
"not recorded" rather than "fine".
Contributing
npm install
npm test # comparison and quantization detection, no GPU needed
npm run dev # demo at /demo/The probe needs a real GPU, so it is verified by running the demo on actual devices. Everything that does not — profile comparison, quantization detection, discrepancy analysis, fingerprinting — is unit tested and runs in CI.
Automating the probe itself was attempted and does not currently work:
Playwright's bundled Chromium ships without WebGPU, and driving a system Chrome
through it leaves navigator.gpu undefined regardless of --enable-unsafe-swiftshader,
--use-angle=swiftshader, or headed mode. Deno's built-in WebGPU looks like the
more promising route for anyone who wants to try again.
Status
Early, and honest about it. Three devices is not a dataset. The differences above are facts about these three machines; whether they generalize needs many more profiles. Firefox is entirely unmeasured, and so is any iPhone — the one Apple device here is an iPad, which is not the same GPU class and not the same browser build.
Worth stating plainly: the original premise — that browsers misreport their own capabilities — has not held up. All three devices did exactly what they declared, zero discrepancies each. The value turned out to be in the gaps between devices, which is why comparison exists at all.
Contributing a profile
The dataset is the bottleneck, and it takes about a minute to widen it.
- Open the demo on any device with WebGPU — a phone counts, and phones are underrepresented.
- Run the probe, then press Share profile. That copies the JSON and opens a prefilled issue.
- Paste, and submit.
A profile records the GPU vendor and architecture the browser reports, which formats and limits actually worked, timing numbers, and coarse environment facts — browser and version, platform, core count, memory bucket, pixel ratio. The raw user-agent string is deliberately not collected. Press Copy profile first if you would rather read the whole thing before posting it.
Crashes are worth reporting too. If the probe fell over, it met something it was not built for, and that is a finding.
License
MIT
