@magield/collectors 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/registry.js +2 -1
- package/dist/signals/gpu-timing.d.ts +2 -0
- package/dist/signals/gpu-timing.js +157 -0
- package/package.json +1 -1
package/dist/registry.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { gpuTimingCollector } from './signals/gpu-timing.js';
|
|
1
2
|
import { adblockCollector } from './signals/adblock.js';
|
|
2
3
|
import { appsCollector } from './signals/apps.js';
|
|
3
4
|
import { audioCollector } from './signals/audio.js';
|
|
@@ -76,7 +77,7 @@ export const COLLECTOR_IDS = ALL_COLLECTORS.map((c) => c.id);
|
|
|
76
77
|
* The split is the product position, not a technical one. `docs/` says what
|
|
77
78
|
* Magic Shield collects by default, and this is the list that is not in it.
|
|
78
79
|
*/
|
|
79
|
-
export const INVASIVE_COLLECTORS = [webrtcCollector, adblockCollector, appsCollector];
|
|
80
|
+
export const INVASIVE_COLLECTORS = [webrtcCollector, adblockCollector, appsCollector, gpuTimingCollector];
|
|
80
81
|
/**
|
|
81
82
|
* Removed: localPortsCollector.
|
|
82
83
|
*
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long this GPU takes to finish work stalled on each of a few points.
|
|
3
|
+
*
|
|
4
|
+
* A trial, in `INVASIVE_COLLECTORS`, and named `gputiming`: it costs a few
|
|
5
|
+
* hundred milliseconds of GPU and battery on the device, which is a decision
|
|
6
|
+
* for the page, not a default. Nothing scores it. It exists to answer one
|
|
7
|
+
* question on real traffic: can it tell apart phones of the same model, which
|
|
8
|
+
* is what a phone farm is and what every other signal here cannot?
|
|
9
|
+
*
|
|
10
|
+
* ## The idea
|
|
11
|
+
*
|
|
12
|
+
* DrawnApart (NDSS 2022, Laperdrix, Rudametkin, Oren et al.) showed that the
|
|
13
|
+
* execution units of one GPU model run at slightly different speeds, and that
|
|
14
|
+
* timing a draw in which one vertex does heavy work, moved from point to point,
|
|
15
|
+
* yields a vector particular to the physical chip. Canvas noise does not touch
|
|
16
|
+
* it, because it measures time rather than pixels. Reported accuracy on
|
|
17
|
+
* identical hardware ranged from about 36% to over 90% depending on the GPU,
|
|
18
|
+
* and nobody has measured it on low-end Android, which is the reason this is a
|
|
19
|
+
* trial collector and not a signal.
|
|
20
|
+
*
|
|
21
|
+
* ## What is sent
|
|
22
|
+
*
|
|
23
|
+
* gpu.timing the median time for each stall position, divided by their
|
|
24
|
+
* mean, three decimals, comma-joined: the shape, not the
|
|
25
|
+
* speed, so a busy or throttled phone still gives its own shape
|
|
26
|
+
* gpu.timingMs what the measurement cost on this device, to judge the trial
|
|
27
|
+
* gpu.supported false where there is no WebGL
|
|
28
|
+
*
|
|
29
|
+
* Bounded by its own deadline well inside the deep wave's budget: a slow GPU
|
|
30
|
+
* gets a partial measurement reported as null rather than a slow login.
|
|
31
|
+
*/
|
|
32
|
+
/** Stall positions, and samples per position. */
|
|
33
|
+
const POINTS = 8;
|
|
34
|
+
const RUNS = 5;
|
|
35
|
+
/** The whole measurement, calibration included, in milliseconds. */
|
|
36
|
+
const DEADLINE_MS = 300;
|
|
37
|
+
/** What one stalled draw should cost, so the timer's coarseness stays small beside it. */
|
|
38
|
+
const TARGET_DRAW_MS = 4;
|
|
39
|
+
const VERTEX = `
|
|
40
|
+
attribute float a_index;
|
|
41
|
+
uniform float u_stall;
|
|
42
|
+
uniform int u_work;
|
|
43
|
+
void main() {
|
|
44
|
+
float acc = a_index;
|
|
45
|
+
if (abs(a_index - u_stall) < 0.5) {
|
|
46
|
+
for (int i = 0; i < 100000; i++) {
|
|
47
|
+
if (i >= u_work) break;
|
|
48
|
+
acc = sin(acc) * 1.0001 + cos(acc * 0.999);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
gl_PointSize = 1.0;
|
|
52
|
+
gl_Position = vec4(acc * 1e-9, 0.0, 0.0, 1.0);
|
|
53
|
+
}`;
|
|
54
|
+
const FRAGMENT = `
|
|
55
|
+
precision mediump float;
|
|
56
|
+
void main() { gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); }`;
|
|
57
|
+
export const gpuTimingCollector = {
|
|
58
|
+
id: 'gputiming',
|
|
59
|
+
wave: 'deep',
|
|
60
|
+
optional: true,
|
|
61
|
+
collect({ document, performance }) {
|
|
62
|
+
const out = {};
|
|
63
|
+
const started = performance.now();
|
|
64
|
+
let gl = null;
|
|
65
|
+
try {
|
|
66
|
+
const canvas = document.createElement('canvas');
|
|
67
|
+
canvas.width = 1;
|
|
68
|
+
canvas.height = 1;
|
|
69
|
+
gl = canvas.getContext('webgl');
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
gl = null;
|
|
73
|
+
}
|
|
74
|
+
if (gl === null) {
|
|
75
|
+
out['gpu.supported'] = false;
|
|
76
|
+
out['gpu.timing'] = null;
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
out['gpu.supported'] = true;
|
|
80
|
+
const program = link(gl);
|
|
81
|
+
if (program === undefined) {
|
|
82
|
+
out['gpu.timing'] = null;
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
gl.useProgram(program);
|
|
86
|
+
const buffer = gl.createBuffer();
|
|
87
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
|
88
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(Array.from({ length: POINTS }, (_, i) => i)), gl.STATIC_DRAW);
|
|
89
|
+
const index = gl.getAttribLocation(program, 'a_index');
|
|
90
|
+
gl.enableVertexAttribArray(index);
|
|
91
|
+
gl.vertexAttribPointer(index, 1, gl.FLOAT, false, 0, 0);
|
|
92
|
+
const stall = gl.getUniformLocation(program, 'u_stall');
|
|
93
|
+
const work = gl.getUniformLocation(program, 'u_work');
|
|
94
|
+
const context = gl;
|
|
95
|
+
const draw = (position, iterations) => {
|
|
96
|
+
context.uniform1f(stall, position);
|
|
97
|
+
context.uniform1i(work, iterations);
|
|
98
|
+
const t0 = performance.now();
|
|
99
|
+
context.drawArrays(context.POINTS, 0, POINTS);
|
|
100
|
+
context.finish();
|
|
101
|
+
return performance.now() - t0;
|
|
102
|
+
};
|
|
103
|
+
// Calibrate the work so one stalled draw costs about TARGET_DRAW_MS here:
|
|
104
|
+
// a fast GPU needs more of it to rise above the timer's resolution, a slow
|
|
105
|
+
// one less to stay inside the deadline.
|
|
106
|
+
let iterations = 2000;
|
|
107
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
108
|
+
const ms = Math.max(0.05, draw(0, iterations));
|
|
109
|
+
if (ms > TARGET_DRAW_MS * 0.5 && ms < TARGET_DRAW_MS * 2)
|
|
110
|
+
break;
|
|
111
|
+
iterations = Math.max(100, Math.min(100_000, Math.round((iterations * TARGET_DRAW_MS) / ms)));
|
|
112
|
+
if (performance.now() - started > DEADLINE_MS / 3)
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
const samples = Array.from({ length: POINTS }, () => []);
|
|
116
|
+
// Interleaved, so a thermal or scheduling drift during the measurement
|
|
117
|
+
// falls on every position alike instead of on the last ones.
|
|
118
|
+
for (let run = 0; run < RUNS; run++) {
|
|
119
|
+
for (let position = 0; position < POINTS; position++) {
|
|
120
|
+
if (performance.now() - started > DEADLINE_MS) {
|
|
121
|
+
out['gpu.timing'] = null;
|
|
122
|
+
out['gpu.timingMs'] = Math.round(performance.now() - started);
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
samples[position]?.push(draw(position, iterations));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const medians = samples.map(median);
|
|
129
|
+
const mean = medians.reduce((sum, value) => sum + value, 0) / medians.length;
|
|
130
|
+
out['gpu.timing'] = mean > 0 ? medians.map((value) => (value / mean).toFixed(3)).join(',') : null;
|
|
131
|
+
out['gpu.timingMs'] = Math.round(performance.now() - started);
|
|
132
|
+
return out;
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
function link(gl) {
|
|
136
|
+
const compile = (type, source) => {
|
|
137
|
+
const shader = gl.createShader(type);
|
|
138
|
+
if (shader === null)
|
|
139
|
+
return undefined;
|
|
140
|
+
gl.shaderSource(shader, source);
|
|
141
|
+
gl.compileShader(shader);
|
|
142
|
+
return gl.getShaderParameter(shader, gl.COMPILE_STATUS) === true ? shader : undefined;
|
|
143
|
+
};
|
|
144
|
+
const vertex = compile(gl.VERTEX_SHADER, VERTEX);
|
|
145
|
+
const fragment = compile(gl.FRAGMENT_SHADER, FRAGMENT);
|
|
146
|
+
const program = gl.createProgram();
|
|
147
|
+
if (vertex === undefined || fragment === undefined || program === null)
|
|
148
|
+
return undefined;
|
|
149
|
+
gl.attachShader(program, vertex);
|
|
150
|
+
gl.attachShader(program, fragment);
|
|
151
|
+
gl.linkProgram(program);
|
|
152
|
+
return gl.getProgramParameter(program, gl.LINK_STATUS) === true ? program : undefined;
|
|
153
|
+
}
|
|
154
|
+
function median(values) {
|
|
155
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
156
|
+
return sorted[Math.floor(sorted.length / 2)] ?? 0;
|
|
157
|
+
}
|