A display-p3 Canvas Names the CPU Chrome Runs On
Chrome rasterises an sRGB canvas to the same pixels on every machine we measured. Draw the same arc and line on a display-p3 canvas and Chrome on arm64 returns different pixels from Chrome on x86-64, because Skia's raster pipeline rounds antialiased edges with an exact divide on ARM NEON and an approximate one on x86. Measured in Chrome 155 on macOS, Android, Windows and Linux, and confirmed with the operating system held fixed by running the same Chromium 155 package as amd64 and as arm64 on one Linux host.
Draw a blue arc and an orange line on a 120 by 60 canvas, read the pixels back and hash them. Chrome 155 returns 1ebddf23 on an Apple-silicon Mac, on an Android phone, on a Windows PC and on a Linux PC. Create the same canvas in the display-p31 colour space instead of the default sRGB and draw the same two shapes, and the hash splits along the CPU architecture: every arm64 build we measured returns a01f0ec4, and every x86-64 build returns 44f9862a.
The split comes from one rounding function in Skia2, the 2D graphics library under Chrome’s canvas. On an sRGB canvas, opaque fills go through blending code that performs the same integer arithmetic on every CPU. On a display-p3 canvas they go through Skia’s raster pipeline3, which divides by 255 with an exact formula on ARM and with a faster approximation on x86. The approximation lands one level off on a quarter of its inputs, and the antialiased edges of the arc and the line are where those inputs occur.
The probe
// FNV-1a over the RGBA bytes
const fnv = a => {
let h = 0x811c9dc5 >>> 0;
for (let i = 0; i < a.length; i++) { h ^= a[i]; h = Math.imul(h, 0x01000193) >>> 0; }
return h.toString(16).padStart(8, '0');
};
function geometryHash(colorSpace) {
const c = document.createElement('canvas');
c.width = 120; c.height = 60;
const ctx = c.getContext('2d', { colorSpace, willReadFrequently: true });
ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, 120, 60);
ctx.fillStyle = '#0066cc';
ctx.beginPath(); ctx.arc(60, 30, 25, 0, Math.PI * 1.7); ctx.fill();
ctx.strokeStyle = '#cc3300'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(3.5, 3.5); ctx.lineTo(116.5, 56.5); ctx.stroke();
return fnv(ctx.getImageData(0, 0, 120, 60, { colorSpace: 'srgb' }).data);
}
geometryHash('srgb'); // '1ebddf23' on every machine in the table below
geometryHash('display-p3'); // 'a01f0ec4' on arm64, '44f9862a' on x86-64
The willReadFrequently4 option keeps the canvas on Chrome’s CPU raster path. Without it Chrome rasterises the canvas on the GPU when it can, and in our runs that default context returned a different sRGB hash on each machine, because the GPU then decides the pixels. The readback passes colorSpace: 'srgb' to getImageData5, so both canvases are hashed in the same encoding. The hash is FNV-1a6 over the raw RGBA bytes, so a one-level change in any channel of any pixel moves it.
The measurements
All values below were measured on 2026-10-18 in Chrome 155. The two Chromium rows are Debian’s Chromium 155 package in two containers on one x86-64 Linux server.
| Platform | CPU architecture | Browser | sRGB | display-p3 |
|---|---|---|---|---|
| macOS, Apple silicon | arm64 | Chrome 155 | 1ebddf23 | a01f0ec4 |
| Android | arm64 | Chrome 155 | 1ebddf23 | a01f0ec4 |
| Linux container under qemu-user7 | arm64 | Chromium 155 | 1ebddf23 | a01f0ec4 |
| Linux container, same host | x86-64 | Chromium 155 | 1ebddf23 | 44f9862a |
| Windows 11 | x86-64 | Chrome 155 | 1ebddf23 | 44f9862a |
| Linux | x86-64 | Chrome 155 | 1ebddf23 | 44f9862a |
Machines on the same Chrome 155 build still split by architecture. The x86-64 value has not moved since Chrome 150, and it held on Intel and AMD processors, with AVX-512 and without it.
As a check on which instruction set actually executed, every run we made for this post also read the bits of a runtime 0 / 0 through a Float64Array, the NaN tell from our WebAssembly post. The arm64 machines and the arm64 container returned the ARM default NaN, upper word 0x7ff80000, and the x86-64 machines and the amd64 container returned the x86 one, 0xfff80000.
Holding the operating system fixed
The first arm64 row is macOS and the x86-64 rows are Windows and Linux, so on their own they cannot separate the CPU from the operating system. The Android row removes macOS from the explanation: Android on arm64 lands on the Mac value.
The direct test changes the instruction set and keeps everything else. We installed Debian’s chromium package, version 155.0.8059.79-1, into two Debian sid containers on one x86-64 Linux server. The amd64 container runs natively. The arm64 container runs under qemu-user, which translates every arm64 instruction, NEON8 SIMD included, into host code and passes system calls to the host kernel. The two browsers shared the host, the kernel, the package version, the command line and the probe script. The amd64 browser returned 44f9862a and the arm64 browser returned a01f0ec4.
Every CPU-rasterised canvas in this post agrees with that split. The arm64 container matched the Mac and Android results byte for byte, and the amd64 container matched the Windows and Linux results. Chromium needed --no-zygote and --in-process-gpu to start under qemu-user here, and the amd64 container ran with the same two flags.
What stays the same on every machine
The colour-managed part of the canvas returned identical bytes on every machine we drove for this post. The probe checks it in several ways:
getContext('2d', { colorSpace })acceptsdisplay-p3and throws aTypeErrorforrec2020andsrgb-linear.getContextAttributes()does not reflect a requestedpixelFormat.- Solid colours convert identically.
#ff0000read back in display-p3 isea3323ff,rgb(0 102 204)is2a64c5ff, andcolor(display-p3 1 0 0)drawn on an sRGB canvas reads backff0000ff, clamped to the sRGB gamut. - A bulk conversion gives the same bytes in both directions. We wrote 5,120 colours with
putImageData, a 16-level RGB lattice plus 256-step grey, red, green and blue ramps, and read them back in the other colour space. toDataURL('image/png')on a display-p3 canvas emits aniCCP9 chunk carrying the P3 profile, and an sRGB canvas emits none.- Glyph coverage10 ignores the colour space. The alpha channel of
fillTextis identical between an sRGB and a display-p3 canvas for six text colours. matchMedia('(color-gamut: p3)')11 returned false on every machine we drove for this post, and the display-p3 canvas split all the same. Canvas colour management takes its colour space from the page; the media query describes the monitor.
Skia’s colour conversion, whose transfer functions and gamut matrices come from its skcms12 library, turns sRGB #0066cc into P3 (42, 100, 197) with the same result on both architectures. The split enters one step later, where Skia blends an antialiased edge pixel into what is already on the canvas.
Where the two values come from
edge blend with SkAlphaMulQ, integer C++"] S["same bytes on every CPU
sRGB hash 1ebddf23"] P["SkRasterPipeline in lowp
opaque source-over reduced to src"] E["edge pixel: lerp(d, s, c)
div255(d * (255 - c) + s * c)"] A["arm64 build, NEON: formula [2]
display-p3 hash a01f0ec4"] X["x86-64 build: formula [3]
display-p3 hash 44f9862a"] D --> Q Q -- yes --> L L --> S Q -- no --> P P --> E E --> A E --> X
Blink creates the canvas surface in the colour space the page asked for: srgb maps to Skia’s sRGB colour space and display-p3 to Display P3 with the D65 white point. When Skia picks a blitter for a fill, it keeps an older fast path for draws that need no colour management. The code below is from Skia revision 29ed1e87a0a5, the one Chromium 155.0.8059.39 pins:
// src/core/SkBlitter.cpp, SkBlitter::UseLegacyBlitter
auto cs = device.colorSpace();
// We check (indirectly via makeContext()) later on if the shader can handle the colorspace
// in legacy mode, so here we just focus on if a single color needs raster-pipeline.
if (cs && !paint.getShader()) {
if (!paint.getColor4f().fitsInBytes() || !cs->isSRGB()) {
return false;
}
}
An sRGB canvas filled with an in-gamut colour under the default source-over takes the legacy 32-bit blitter. It blends each edge pixel with SkAlphaMulQ, packed-integer C++ that scales the colour by a coverage between 0 and 256 and shifts right by 8, and the compiler turns it into the same arithmetic on ARM and on x86. A display-p3 canvas fails cs->isSRGB(), so the same fill goes to SkRasterPipelineBlitter and the raster pipeline.
Two details of that blitter decide the result. An opaque colour under source-over is strength-reduced to src, so a fully covered pixel is a plain store of the converted colour and comes out identical everywhere. A partially covered pixel mixes the colour into the destination with the coverage c as the weight. An 8-bit canvas runs this pipeline in lowp13, where that mix is lerp, and lerp ends in div255:
// src/opts/SkRasterPipeline_opts.h, lowp
// From the comment above div255:
// [2] (v + 128 + ((v + 128) >> 8)) >> 8 correct for all inputs
// [3] (v + 255) >> 8 wrong (+-1) ~25% of the time
SI U16 div255(U16 v) {
#if defined(SKRP_CPU_NEON)
// With NEON we can compute [2] just as fast as [3], so let's be correct.
// First we compute v + ((v+128)>>8), then one more round of (...+128)>>8 to finish up:
return vrshrq_n_u16(vrsraq_n_u16(v, v, 8), 8);
#else
// Otherwise, use [3], which is never wrong by more than 1:
return (v+255)/256;
#endif
}
SI U16 lerp(U16 from, U16 to, U16 t) { return div255( from*inv(t) + to*t ); }
SKRP_CPU_NEON is defined for every arm64 Chrome build, where NEON is part of the baseline instruction set. Every x86 tier Skia compiles, from SSE2 to AVX-512, takes the #else branch. The preprocessor makes the choice at compile time, so it follows the instruction set of the build and the operating system plays no part in it.
Formula [2] is v / 255 rounded to the nearest integer. Over the range a lowp blend passes to div255, every value from 0 to 255 × 255, [2] matches that rounding for all 65,026 inputs. Formula [3] differs from [2] on 16,256 of them, 25.00 percent, and always by exactly one.
Pixel (4, 3) of the probe shows the arithmetic. It sits on the edge of the stroke, whose #cc3300 converts to (188, 64, 30) in P3, over the white background, with a coverage of 232 out of 255. For the blue channel the pipeline computes v = 255 * (255 - 232) + 30 * 232 = 12825, and 12825 / 255 = 50.294. Formula [2] rounds that to 50, and formula [3] computes (12825 + 255) >> 8 = 51. The red and green channels land on 194 and 81 under both formulas, so the arm64 machines store (194, 81, 50) at that pixel and the x86-64 machines store (194, 81, 51).
Read back in its own colour space, the 120 by 60 display-p3 plane has 269 of its 7,200 pixels differing between arm64 and x86-64. All of them are antialiased edge pixels, and no channel differs by more than one level. A single lerp with one shared coverage value, formula [2] on arm64 and formula [3] on x86-64, reproduces 262 of them exactly. The other 7 sit where the stroke crosses the edge of the arc, and two lerps in drawing order reproduce those. Converted to sRGB for the readback, the same 269 pixels differ by up to three levels, and that is what the probe’s hash sees.
Four predictions the code makes
Reading the code gives predictions a measurement can break. We drew four more canvases on the same machines and both containers:
| Canvas and drawing | Path in Skia | Predicted | Measured |
|---|---|---|---|
| display-p3, opaque colour, partial coverage | raster pipeline, lerp with div255 | splits | splits on 221 of 255 coverage levels; [2] and [3] with one shared coverage per level reproduce all 255 |
| display-p3, translucent colour, full coverage | raster pipeline, srcover with div255_accurate | identical | identical |
| sRGB, opaque colour, partial coverage | legacy blitter | identical | identical |
sRGB, globalCompositeOperation = 'multiply' | raster pipeline, lowp multiply with div255 | splits | splits; div255(d * m) under [2] and [3] reproduces all six rows |
The second row stays identical because lowp srcover calls div255_accurate, which computes formula [2] on every architecture, so a translucent fill at full coverage blends the same way everywhere. A split needs the raster pipeline and a call to the plain div255.
The fourth row isolates the blitter choice: it is an sRGB canvas, the same colour space as the invariant probe, and it splits as soon as a blend mode sends the fill around the legacy blitter. The probe’s own arc and line drawn with multiply on an sRGB canvas split the same way, on 303 of 7,200 pixels.
The threat model
Any page can run this check. Creating a display-p3 context needs no permission or user gesture, and it worked on every display we drove, including ones that report no wide gamut. The probe costs one small canvas and one readback. With willReadFrequently set, the result followed the instruction set alone in every measurement above.
The hash is most useful read next to the platform the browser claims. Apple’s upgrade guide states that macOS 27 “is compatible with any Mac with Apple silicon”, and its compatibility list names no Intel model. Chrome reports the macOS version through User-Agent Client Hints14, and on macOS 27 platformVersion reads 27.0.0. A browser that reports macOS 27 and returns 44f9862a would therefore be running x86-64 code: the x86-64 half of Chrome’s universal binary under Rosetta15, which Apple keeps as a general-purpose tool through macOS 27, or a machine that is not a Mac. On macOS 26 and earlier an Intel Mac is a genuine x86-64 Mac and, by the same #if, would return the x86-64 value; we had no Intel Mac to measure.
Android reads the same way: Chrome on an arm64 phone returns the arm64 value, so an Android user agent paired with 44f9862a points to an x86-64 build, such as an emulator image or a desktop browser presenting as a phone.
The browser’s own architecture hints are weaker witnesses. The UA-CH architecture hint read arm on the Mac and x86 on Windows, and came back empty on Android. The arm64 Chromium build on Linux reported Linux x86_64 in both navigator.platform and the user agent string, so neither of those shows the architecture there. Each hint is a string the browser decides to send, and a spoofing stack can set it; the canvas bytes come from the arithmetic the build performs.
Few people set out to spoof a CPU architecture. A scraper that presents a Mac or an Android phone from an x86-64 server spoofs one anyway, because its build computes formula [3] whatever the user agent says, and the first display-p3 canvas a page draws reports x86-64.
Closing it
For a browser that runs on x86-64 and presents an ARM device, closing the gap means computing formula [2] in the portable branch whenever the persona is ARM. It has to be a runtime check, because one binary serves ARM and x86 personas alike. With a placeholder for the persona flag:
SI U16 div255(U16 v) {
#if defined(SKRP_CPU_NEON)
return vrshrq_n_u16(vrsraq_n_u16(v, v, 8), 8); // [2]
#else
if (spoofARMArch) { // set once from the persona at startup
v += 128;
return (v + (v / 256)) / 256; // [2], in portable C
}
return (v + 255) / 256; // [3]
#endif
}
The 16-bit lanes cannot overflow on that path. The coverage blends of a fill never pass div255 more than 255 × 255 = 65,025, so the largest intermediate value is 65,407.
On an x86-64 Linux machine, our build with a Mac persona returns a01f0ec4 and matches the Mac on every canvas in the predictions table, and with a Windows persona it returns 44f9862a. For this probe nothing else on the path needed to change, because the colour conversion, the PNG tagging and the glyph coverage were already identical on both architectures. Adding noise to the readback would leave the gap open: a detector compares against the two values real machines return, and a perturbed hash matches neither.
Scrapium is Scrapfly’s scraping browser, built to stay indistinguishable from real traffic. Our engineering blog goes deep on the rest of the stack.
display-p3: an RGB colour space with the DCI-P3 primaries, the D65 white point and the sRGB transfer curve, wider than sRGB in the reds and greens. A 2D canvas opts into it with
getContext('2d', { colorSpace: 'display-p3' }). Reference: MDN. ↩︎Skia: the open-source 2D graphics library Chrome uses to rasterise canvas, text and page content. The code quoted here is from revision
29ed1e87a0a5, the one Chromium 155.0.8059.39 pins. Reference: skia.org. ↩︎SkRasterPipeline: Skia’s general CPU renderer. It turns each draw into a chain of small stages (load the colour, apply coverage, load the destination, blend, store) and runs the chain over spans of pixels with SIMD. Reference: Skia source. ↩︎
willReadFrequently: a 2D context option telling the browser the page will read pixels back often. Chrome answers it by keeping the canvas in CPU memory and rasterising it in software. Reference: MDN. ↩︎getImageDatacolorSpace: an option ofgetImageDatathat converts the pixels into the requested colour space before returning them. Reference: MDN. ↩︎FNV-1a: a small non-cryptographic hash that XORs each byte into its state and multiplies by a fixed prime, so any changed byte changes the result. Reference: Wikipedia. ↩︎
qemu-user: QEMU’s user-mode emulator. It runs a Linux program built for another CPU by translating its instructions on the fly and passing its system calls to the host kernel. Reference: QEMU documentation. ↩︎
NEON: the SIMD instruction set of ARM processors, mandatory on arm64.
vrsraq_n_u16andvrshrq_n_u16are its rounding shift-right instructions over eight 16-bit lanes. Reference: Arm intrinsics. ↩︎iCCP chunk: the PNG chunk that embeds an ICC colour profile, telling a decoder which colour space the pixel values are in. Reference: PNG specification. ↩︎
coverage: the fraction of a pixel a shape covers, computed by the rasteriser for antialiasing. Edge pixels get partial coverage and are mixed with what is underneath in proportion to it. Reference: Wikipedia. ↩︎
color-gamutmedia query: reports the approximate range of colours the output display supports, assrgb,p3orrec2020. Reference: MDN. ↩︎skcms: Skia’s colour-management library. It parses ICC profiles and supplies the transfer functions and gamut matrices Skia uses to convert between colour spaces. Reference: skcms. ↩︎
lowp: SkRasterPipeline’s low-precision mode, which holds each channel as a 16-bit integer from 0 to 255 instead of a float. Skia builds a lowp pipeline whenever every stage in it has a lowp version, as the stages of an 8-bit canvas fill do. Reference: Skia source. ↩︎
User-Agent Client Hints (UA-CH): structured browser and platform details, including
platformVersionandarchitecture, exposed throughnavigator.userAgentData.getHighEntropyValues()and theSec-CH-UA-*request headers. Reference: MDN. ↩︎Rosetta: Apple’s translator that runs x86-64 Mac apps on Apple silicon. Apple keeps it available as a general-purpose tool through macOS 27. Reference: Apple Developer. ↩︎