@torrent-tv/proxy 2.101.0 → 2.101.2
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/package.json +1 -1
- package/server.js +7 -4
- package/services/encode/ProcessorBudget.js +104 -0
- package/services/encode/quality/HostLoad.js +101 -9
- package/services/server/ViewerRequests.js +24 -6
- package/services/server/wire-outputs.js +25 -8
- package/services/storage/machine-memory.js +8 -7
- package/services/storage/machine-processor.js +395 -0
- package/services/storage/processor-report.js +82 -3
- package/services/viewer/playback-readiness.js +229 -17
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file How much processor this process could ever use, and how much of it was
|
|
3
|
+
* free over an interval (torrent-tv/meta#180, phase 1: readings only).
|
|
4
|
+
*
|
|
5
|
+
* Two quantities, never put in each other's place:
|
|
6
|
+
*
|
|
7
|
+
* 1. **The upper bound**, in cores: what the operating system lets this process
|
|
8
|
+
* run on — the CPUs it may be scheduled on (`os.availableParallelism()`,
|
|
9
|
+
* which follows the affinity mask on Linux) and the CPU quota of every cgroup
|
|
10
|
+
* on its path (Docker's `--cpus`, systemd's `CPUQuota=`), the smallest of
|
|
11
|
+
* them. A quota may be fractional: `cpu.max` of `150000 100000` is one and a
|
|
12
|
+
* half cores. The bound says what can never be exceeded; it says nothing of
|
|
13
|
+
* what is free.
|
|
14
|
+
* 2. **The measured free amount**, in cores: the idle time of the machine's
|
|
15
|
+
* CPUs over the interval — from `/proc/stat` where the caller has it, from
|
|
16
|
+
* `os.cpus()` times on every other platform — and, under a bound smaller
|
|
17
|
+
* than the machine, no more than the bound less what this proxy and its
|
|
18
|
+
* encoders used. Inside a container both readings describe the HOST's CPUs,
|
|
19
|
+
* which is what the other containers left. Where neither reading exists, or
|
|
20
|
+
* our own use is unknown under such a bound, it is unknown, and said so.
|
|
21
|
+
*
|
|
22
|
+
* A cgroup's own `cpu.stat` is NOT a reading of other work: its usage is what
|
|
23
|
+
* this cgroup and its descendants used, and its throttling says that the quota
|
|
24
|
+
* held our work back. Both are reported as that, and nothing else.
|
|
25
|
+
*
|
|
26
|
+
* Memory had the same defect this file answers for the processor — the host's
|
|
27
|
+
* figure where a container's limit decided — and was fixed in #153/#155
|
|
28
|
+
* (`machine-memory.js`); the cgroup is found the same way.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { readFileSync } from "node:fs";
|
|
32
|
+
import os from "node:os";
|
|
33
|
+
|
|
34
|
+
import { cgroupOf, levelsOf } from "./machine-memory.js";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Where each cgroup version keeps its CPU controller, and under which names.
|
|
38
|
+
*
|
|
39
|
+
* Version 1 keeps the usage in a controller of its own, `cpuacct`, mounted
|
|
40
|
+
* either beside `cpu` (`cpu,cpuacct`, with `cpu` and `cpuacct` as links to
|
|
41
|
+
* it) or on its own; `/sys/fs/cgroup/cpuacct` names it in both cases. Its
|
|
42
|
+
* counters are in nanoseconds; version 2's in microseconds.
|
|
43
|
+
*/
|
|
44
|
+
const HIERARCHIES = {
|
|
45
|
+
1: { mount: "/sys/fs/cgroup/cpu", source: "cgroup v1 cfs", usageController: "cpuacct", usageMount: "/sys/fs/cgroup/cpuacct",
|
|
46
|
+
usage: "cpuacct.usage", usageUnit: 1e-9, throttledName: "throttled_time", throttledUnit: 1e-9 },
|
|
47
|
+
2: { mount: "/sys/fs/cgroup", source: "cgroup v2 cpu.max", usageController: "cpu", usageMount: "/sys/fs/cgroup",
|
|
48
|
+
usage: "cpu.stat", usageUnit: 1e-6, throttledName: "throttled_usec", throttledUnit: 1e-6 }
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {object} ProcessorBound
|
|
53
|
+
* @property {number} cores - What this process can never exceed, in cores.
|
|
54
|
+
* @property {number} parallelism - The CPUs it may be scheduled on.
|
|
55
|
+
* @property {number | null} quotaCores - The smallest cgroup quota on its path, or null for none.
|
|
56
|
+
* @property {string} source - Which of the two decided `cores`.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @typedef {object} CgroupCpu
|
|
61
|
+
* @property {number | null} usageSeconds - CPU seconds this cgroup and its descendants used.
|
|
62
|
+
* @property {number | null} throttledPeriods - Periods the binding quota held the cgroup back.
|
|
63
|
+
* @property {number | null} throttledSeconds - Time it was held back.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @typedef {object} ProcessorSample
|
|
68
|
+
* @property {number} takenAt - Wall clock, ms.
|
|
69
|
+
* @property {ProcessorBound} bound
|
|
70
|
+
* @property {CgroupCpu | null} cgroup
|
|
71
|
+
* @property {number | null} idleSeconds - Idle time of every CPU since boot, from `os.cpus()`.
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* What this process can never exceed.
|
|
76
|
+
*
|
|
77
|
+
* @param {object} [options]
|
|
78
|
+
* @param {(path: string) => string} [options.readFile] - Throws where a file is absent; injected by the checks.
|
|
79
|
+
* @param {number} [options.parallelism]
|
|
80
|
+
* @returns {ProcessorBound}
|
|
81
|
+
*/
|
|
82
|
+
export function processorBound({ readFile = readText, parallelism = os.availableParallelism() } = {}) {
|
|
83
|
+
const quota = quotaOnPath(readFile);
|
|
84
|
+
const cores = Math.max(1, parallelism);
|
|
85
|
+
if (quota !== null && quota.cores < cores) {
|
|
86
|
+
return { cores: quota.cores, parallelism: cores, quotaCores: quota.cores, source: HIERARCHIES[quota.version].source };
|
|
87
|
+
}
|
|
88
|
+
return { cores, parallelism: cores, quotaCores: quota?.cores ?? null, source: "cores" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* This cgroup's own CPU counters: its usage, and how often its binding quota
|
|
93
|
+
* held it back. Null where there is no cgroup to read (not Linux, no CPU
|
|
94
|
+
* controller); each counter null where its file is not there.
|
|
95
|
+
*
|
|
96
|
+
* @param {object} [options]
|
|
97
|
+
* @param {(path: string) => string} [options.readFile]
|
|
98
|
+
* @returns {CgroupCpu | null}
|
|
99
|
+
*/
|
|
100
|
+
export function cgroupCpu({ readFile = readText } = {}) {
|
|
101
|
+
const levels = cpuLevels(readFile);
|
|
102
|
+
if (levels === null) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const hierarchy = HIERARCHIES[levels.version];
|
|
106
|
+
let usageSeconds = null;
|
|
107
|
+
for (const directory of usageLevels(readFile, levels.version)) {
|
|
108
|
+
const text = tryRead(readFile, `${directory}/${hierarchy.usage}`);
|
|
109
|
+
if (text === null) continue;
|
|
110
|
+
const value = levels.version === 2 ? statField(text, "usage_usec") : wholeNumber(text);
|
|
111
|
+
if (value !== null) {
|
|
112
|
+
usageSeconds = value * hierarchy.usageUnit;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
let throttledPeriods = null;
|
|
117
|
+
let throttledSeconds = null;
|
|
118
|
+
const binding = bindingLevel(readFile, levels);
|
|
119
|
+
const stat = binding === null ? null : tryRead(readFile, `${binding}/cpu.stat`);
|
|
120
|
+
if (stat !== null) {
|
|
121
|
+
throttledPeriods = statField(stat, "nr_throttled");
|
|
122
|
+
const time = statField(stat, hierarchy.throttledName);
|
|
123
|
+
throttledSeconds = time === null ? null : time * hierarchy.throttledUnit;
|
|
124
|
+
}
|
|
125
|
+
return { usageSeconds, throttledPeriods, throttledSeconds };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The idle time of every CPU since boot, from `os.cpus()`, which every
|
|
130
|
+
* platform answers. Null where it states no times.
|
|
131
|
+
*
|
|
132
|
+
* @param {Array<{ times?: { idle?: number } }>} [cpus]
|
|
133
|
+
* @returns {number | null} Seconds.
|
|
134
|
+
*/
|
|
135
|
+
export function cpusIdleSeconds(cpus = os.cpus()) {
|
|
136
|
+
if (!Array.isArray(cpus) || cpus.length === 0) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
let idleMs = 0;
|
|
140
|
+
for (const cpu of cpus) {
|
|
141
|
+
const idle = Number(cpu?.times?.idle);
|
|
142
|
+
if (!Number.isFinite(idle)) return null;
|
|
143
|
+
idleMs += idle;
|
|
144
|
+
}
|
|
145
|
+
return idleMs / 1000;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* One reading of everything above, to be compared against the next.
|
|
150
|
+
*
|
|
151
|
+
* @param {object} [options]
|
|
152
|
+
* @param {(path: string) => string} [options.readFile]
|
|
153
|
+
* @param {() => Array<{ times?: { idle?: number } }>} [options.cpus]
|
|
154
|
+
* @param {number} [options.parallelism]
|
|
155
|
+
* @param {() => number} [options.now]
|
|
156
|
+
* @returns {ProcessorSample}
|
|
157
|
+
*/
|
|
158
|
+
export function sampleProcessor({ readFile = readText, cpus = () => os.cpus(), parallelism, now = Date.now } = {}) {
|
|
159
|
+
return {
|
|
160
|
+
takenAt: now(),
|
|
161
|
+
bound: processorBound({ readFile, ...(parallelism === undefined ? {} : { parallelism }) }),
|
|
162
|
+
cgroup: cgroupCpu({ readFile }),
|
|
163
|
+
idleSeconds: cpusIdleSeconds(cpus())
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @typedef {object} ProcessorReading
|
|
169
|
+
* @property {ProcessorBound} bound
|
|
170
|
+
* @property {number | null} freeCores - Measured free, in cores; null when unknown.
|
|
171
|
+
* @property {string} freeSource - Where it came from, or why it is unknown.
|
|
172
|
+
* @property {number | null} cgroupUsedCores - This cgroup's own use over the interval.
|
|
173
|
+
* @property {number | null} throttledPeriods
|
|
174
|
+
* @property {number | null} throttledSeconds
|
|
175
|
+
*/
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* What two samples say about the interval between them.
|
|
179
|
+
*
|
|
180
|
+
* @param {ProcessorSample | null | undefined} before
|
|
181
|
+
* @param {ProcessorSample | null | undefined} after
|
|
182
|
+
* @param {object} measured - Shares of the WHOLE machine over the same interval.
|
|
183
|
+
* @param {number | null} measured.systemShare - Everything the machine did, from `/proc/stat`; null where unread.
|
|
184
|
+
* @param {number | null} measured.ourShare - The proxy and its encoders; null where either is unread.
|
|
185
|
+
* @param {number} measured.hostCores - The CPUs those shares are fractions of.
|
|
186
|
+
* @returns {ProcessorReading | null} Null without two samples.
|
|
187
|
+
*/
|
|
188
|
+
export function processorBetween(before, after, { systemShare, ourShare, hostCores }) {
|
|
189
|
+
if (!before || !after) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
const elapsedSec = (after.takenAt - before.takenAt) / 1000;
|
|
193
|
+
if (!(elapsedSec > 0)) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const cores = Math.max(1, hostCores);
|
|
197
|
+
let idleCores = null;
|
|
198
|
+
let freeSource = "unknown: no idle reading";
|
|
199
|
+
if (Number.isFinite(systemShare)) {
|
|
200
|
+
idleCores = clamp((1 - systemShare) * cores, 0, cores);
|
|
201
|
+
freeSource = "/proc/stat";
|
|
202
|
+
} else if (Number.isFinite(before.idleSeconds) && Number.isFinite(after.idleSeconds) && after.idleSeconds >= before.idleSeconds) {
|
|
203
|
+
idleCores = clamp((after.idleSeconds - before.idleSeconds) / elapsedSec, 0, cores);
|
|
204
|
+
freeSource = "os.cpus";
|
|
205
|
+
}
|
|
206
|
+
let freeCores = idleCores;
|
|
207
|
+
const bound = after.bound;
|
|
208
|
+
if (idleCores !== null && bound.cores < cores) {
|
|
209
|
+
// Under a bound smaller than the machine, an idle CPU is ours only as far
|
|
210
|
+
// as the bound leaves room beside what we already use.
|
|
211
|
+
if (Number.isFinite(ourShare)) {
|
|
212
|
+
freeCores = Math.min(idleCores, Math.max(0, bound.cores - ourShare * cores));
|
|
213
|
+
} else {
|
|
214
|
+
freeCores = null;
|
|
215
|
+
freeSource = "unknown: our own use is unread under a limit";
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const delta = (key) => {
|
|
219
|
+
const from = before.cgroup?.[key];
|
|
220
|
+
const to = after.cgroup?.[key];
|
|
221
|
+
return Number.isFinite(from) && Number.isFinite(to) && to >= from ? to - from : null;
|
|
222
|
+
};
|
|
223
|
+
const used = delta("usageSeconds");
|
|
224
|
+
return {
|
|
225
|
+
bound,
|
|
226
|
+
freeCores,
|
|
227
|
+
freeSource,
|
|
228
|
+
cgroupUsedCores: used === null ? null : used / elapsedSec,
|
|
229
|
+
throttledPeriods: delta("throttledPeriods"),
|
|
230
|
+
throttledSeconds: delta("throttledSeconds")
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The reading as it is written into the machine's line of the log.
|
|
236
|
+
*
|
|
237
|
+
* @param {ProcessorReading | null} reading
|
|
238
|
+
* @returns {string}
|
|
239
|
+
*/
|
|
240
|
+
export function describeProcessor(reading) {
|
|
241
|
+
if (reading === null) {
|
|
242
|
+
return " bound=n/a free-cores=n/a";
|
|
243
|
+
}
|
|
244
|
+
const { bound } = reading;
|
|
245
|
+
const limit = bound.quotaCores === null ? "no quota" : `quota ${bound.quotaCores.toFixed(2)}`;
|
|
246
|
+
const free = reading.freeCores === null ? `unknown (${reading.freeSource.replace(/^unknown: /, "")})`
|
|
247
|
+
: `${reading.freeCores.toFixed(2)} (${reading.freeSource})`;
|
|
248
|
+
const used = reading.cgroupUsedCores === null ? "n/a" : `${reading.cgroupUsedCores.toFixed(2)} cores`;
|
|
249
|
+
const throttled = reading.throttledPeriods === null && reading.throttledSeconds === null ? "n/a"
|
|
250
|
+
: `${reading.throttledPeriods ?? "n/a"} periods ${reading.throttledSeconds === null ? "n/a" : `${reading.throttledSeconds.toFixed(2)}s`}`;
|
|
251
|
+
return ` bound=${bound.cores.toFixed(2)} cores (${bound.source}; ${bound.parallelism} cpus, ${limit})` +
|
|
252
|
+
` free-cores=${free} cgroup-used=${used} throttled=${throttled}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* This process's cgroup for the CPU controller and the directories to read,
|
|
257
|
+
* its own first, or null where there is none.
|
|
258
|
+
*
|
|
259
|
+
* @param {(path: string) => string} readFile
|
|
260
|
+
* @returns {{ version: 1 | 2, directories: string[] } | null}
|
|
261
|
+
*/
|
|
262
|
+
function cpuLevels(readFile) {
|
|
263
|
+
const text = tryRead(readFile, "/proc/self/cgroup");
|
|
264
|
+
const membership = text === null ? null : cgroupOf(text, "cpu");
|
|
265
|
+
if (membership === null) {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
const directories = levelsOf(HIERARCHIES[membership.version].mount, membership.path);
|
|
269
|
+
return directories.length === 0 ? null : { version: membership.version, directories };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The directories that hold this process's CPU usage, its own first.
|
|
274
|
+
*
|
|
275
|
+
* @param {(path: string) => string} readFile
|
|
276
|
+
* @param {1 | 2} version
|
|
277
|
+
* @returns {string[]}
|
|
278
|
+
*/
|
|
279
|
+
function usageLevels(readFile, version) {
|
|
280
|
+
const hierarchy = HIERARCHIES[version];
|
|
281
|
+
const text = tryRead(readFile, "/proc/self/cgroup");
|
|
282
|
+
const membership = text === null ? null : cgroupOf(text, hierarchy.usageController);
|
|
283
|
+
return membership === null ? [] : levelsOf(hierarchy.usageMount, membership.path);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The quota of one cgroup directory, in cores, or null where it states none.
|
|
288
|
+
*
|
|
289
|
+
* @param {(path: string) => string} readFile
|
|
290
|
+
* @param {1 | 2} version
|
|
291
|
+
* @param {string} directory
|
|
292
|
+
* @returns {number | null}
|
|
293
|
+
*/
|
|
294
|
+
function quotaAt(readFile, version, directory) {
|
|
295
|
+
if (version === 2) {
|
|
296
|
+
const text = tryRead(readFile, `${directory}/cpu.max`);
|
|
297
|
+
const [quota, period] = (text ?? "").trim().split(/\s+/);
|
|
298
|
+
const cores = Number(quota) / Number(period);
|
|
299
|
+
return /^\d+$/.test(quota ?? "") && Number.isFinite(cores) && cores > 0 ? cores : null;
|
|
300
|
+
}
|
|
301
|
+
const quota = wholeNumber(tryRead(readFile, `${directory}/cpu.cfs_quota_us`) ?? "");
|
|
302
|
+
const period = wholeNumber(tryRead(readFile, `${directory}/cpu.cfs_period_us`) ?? "");
|
|
303
|
+
// `-1` is version 1's "no quota", which `wholeNumber` does not accept.
|
|
304
|
+
return quota !== null && period !== null && quota > 0 && period > 0 ? quota / period : null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The smallest quota on this process's cgroup path, and its version.
|
|
309
|
+
*
|
|
310
|
+
* @param {(path: string) => string} readFile
|
|
311
|
+
* @returns {{ version: 1 | 2, cores: number } | null}
|
|
312
|
+
*/
|
|
313
|
+
function quotaOnPath(readFile) {
|
|
314
|
+
const levels = cpuLevels(readFile);
|
|
315
|
+
if (levels === null) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
const binding = bindingLevel(readFile, levels);
|
|
319
|
+
return binding === null ? null : { version: levels.version, cores: quotaAt(readFile, levels.version, binding) };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The directory whose quota is the smallest on the path, or null for none.
|
|
324
|
+
*
|
|
325
|
+
* @param {(path: string) => string} readFile
|
|
326
|
+
* @param {{ version: 1 | 2, directories: string[] }} levels
|
|
327
|
+
* @returns {string | null}
|
|
328
|
+
*/
|
|
329
|
+
function bindingLevel(readFile, levels) {
|
|
330
|
+
let binding = null;
|
|
331
|
+
let smallest = Infinity;
|
|
332
|
+
for (const directory of levels.directories) {
|
|
333
|
+
const cores = quotaAt(readFile, levels.version, directory);
|
|
334
|
+
if (cores !== null && cores < smallest) {
|
|
335
|
+
smallest = cores;
|
|
336
|
+
binding = directory;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return binding;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* @param {string} path
|
|
344
|
+
* @returns {string}
|
|
345
|
+
*/
|
|
346
|
+
function readText(path) {
|
|
347
|
+
return readFileSync(path, "utf8");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* @param {(path: string) => string} readFile
|
|
352
|
+
* @param {string} path
|
|
353
|
+
* @returns {string | null}
|
|
354
|
+
*/
|
|
355
|
+
function tryRead(readFile, path) {
|
|
356
|
+
try {
|
|
357
|
+
return readFile(path);
|
|
358
|
+
} catch {
|
|
359
|
+
return null; // silent-ok: not Linux, no such level, or no CPU controller on it.
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* @param {string} text
|
|
365
|
+
* @returns {number | null}
|
|
366
|
+
*/
|
|
367
|
+
function wholeNumber(text) {
|
|
368
|
+
const trimmed = text.trim();
|
|
369
|
+
return /^\d+$/.test(trimmed) ? Number(trimmed) : null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* One field of a `cpu.stat`.
|
|
374
|
+
*
|
|
375
|
+
* @param {string} text
|
|
376
|
+
* @param {string} name
|
|
377
|
+
* @returns {number | null}
|
|
378
|
+
*/
|
|
379
|
+
function statField(text, name) {
|
|
380
|
+
for (const line of text.split("\n")) {
|
|
381
|
+
const [key, value = ""] = line.trim().split(/\s+/);
|
|
382
|
+
if (key === name) return wholeNumber(value);
|
|
383
|
+
}
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* @param {number} value
|
|
389
|
+
* @param {number} low
|
|
390
|
+
* @param {number} high
|
|
391
|
+
* @returns {number}
|
|
392
|
+
*/
|
|
393
|
+
function clamp(value, low, high) {
|
|
394
|
+
return Math.min(high, Math.max(low, value));
|
|
395
|
+
}
|
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
* threads only says where that time went. Each ffmpeg is its own process and
|
|
15
15
|
* is added beside it. Linux only for the split and for ffmpeg; elsewhere the
|
|
16
16
|
* process figure alone is said.
|
|
17
|
+
*
|
|
18
|
+
* Each run is also said on its own once a minute: the middle of its seconds'
|
|
19
|
+
* processor and speed, and how far those seconds sit from it
|
|
20
|
+
* (torrent-tv/meta#180, phase 1). That scatter is what a slowdown of the most
|
|
21
|
+
* urgent run would have to exceed before it could be put down to another run
|
|
22
|
+
* beside it; nothing decides on it yet.
|
|
17
23
|
*/
|
|
18
24
|
|
|
19
25
|
import { readFileSync, readdirSync } from "node:fs";
|
|
@@ -212,35 +218,108 @@ export function describeShares(seconds) {
|
|
|
212
218
|
return `${said.join(", ")} of a core over ${seconds.length}s`;
|
|
213
219
|
}
|
|
214
220
|
|
|
221
|
+
/**
|
|
222
|
+
* One second of each run read at both ends of it: the cores its ffmpeg used
|
|
223
|
+
* and the film it made per second of wall clock. A run whose film is not
|
|
224
|
+
* known at either end says its processor alone.
|
|
225
|
+
*
|
|
226
|
+
* @param {{ at: number, ffmpeg: Map<number, number>, runs: Map<number, { label: string, film: number | null }> }} before
|
|
227
|
+
* @param {{ at: number, ffmpeg: Map<number, number>, runs: Map<number, { label: string, film: number | null }> }} after
|
|
228
|
+
* @returns {Map<number, { label: string, cores: number, speed: number | null }>}
|
|
229
|
+
*/
|
|
230
|
+
export function runSecondsBetween(before, after) {
|
|
231
|
+
/** @type {Map<number, { label: string, cores: number, speed: number | null }>} */
|
|
232
|
+
const seconds = new Map();
|
|
233
|
+
const elapsed = (after.at - before.at) / 1000;
|
|
234
|
+
if (!(elapsed > 0)) return seconds;
|
|
235
|
+
for (const [pid, run] of after.runs) {
|
|
236
|
+
const from = before.ffmpeg.get(pid);
|
|
237
|
+
const to = after.ffmpeg.get(pid);
|
|
238
|
+
const earlier = before.runs.get(pid);
|
|
239
|
+
if (!earlier || !Number.isFinite(from) || !Number.isFinite(to) || to < from) continue;
|
|
240
|
+
const made = Number.isFinite(run.film) && Number.isFinite(earlier.film) && run.film >= earlier.film ? run.film - earlier.film : null;
|
|
241
|
+
seconds.set(pid, { label: run.label, cores: (to - from) / elapsed, speed: made === null ? null : made / elapsed });
|
|
242
|
+
}
|
|
243
|
+
return seconds;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Per run, over the interval: the median of its seconds and the median of
|
|
248
|
+
* their distances to it, for its processor and its speed.
|
|
249
|
+
*
|
|
250
|
+
* @param {Map<number, { label: string, cores: number[], speed: number[] }>} runs
|
|
251
|
+
* @returns {string} Empty when no run was read for two seconds.
|
|
252
|
+
*/
|
|
253
|
+
export function describeRuns(runs) {
|
|
254
|
+
const said = [];
|
|
255
|
+
for (const run of runs.values()) {
|
|
256
|
+
if (run.cores.length < 2) continue;
|
|
257
|
+
const cores = middleAndScatter(run.cores);
|
|
258
|
+
const speed = middleAndScatter(run.speed);
|
|
259
|
+
said.push(`${run.label} cores ${cores.middle.toFixed(2)} (scatter ${cores.scatter.toFixed(2)})` +
|
|
260
|
+
(speed === null ? ", speed n/a" : `, speed ${speed.middle.toFixed(2)}x (scatter ${speed.scatter.toFixed(2)})`) +
|
|
261
|
+
` over ${run.cores.length}s`);
|
|
262
|
+
}
|
|
263
|
+
return said.join("; ");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* @param {number[]} values
|
|
268
|
+
* @returns {{ middle: number, scatter: number } | null}
|
|
269
|
+
*/
|
|
270
|
+
function middleAndScatter(values) {
|
|
271
|
+
if (values.length === 0) return null;
|
|
272
|
+
const median = (list) => {
|
|
273
|
+
const sorted = [...list].sort((a, b) => a - b);
|
|
274
|
+
const half = Math.floor(sorted.length / 2);
|
|
275
|
+
return sorted.length % 2 === 0 ? (sorted[half - 1] + sorted[half]) / 2 : sorted[half];
|
|
276
|
+
};
|
|
277
|
+
const middle = median(values);
|
|
278
|
+
return { middle, scatter: median(values.map((value) => Math.abs(value - middle))) };
|
|
279
|
+
}
|
|
280
|
+
|
|
215
281
|
/**
|
|
216
282
|
* Read every second, say once a minute, and every second while `detailed()`
|
|
217
283
|
* is true.
|
|
218
284
|
*
|
|
219
285
|
* @param {object} params
|
|
220
286
|
* @param {(line: string) => void} params.log
|
|
221
|
-
* @param {() => number
|
|
287
|
+
* @param {() => Array<{ pid: number, label: string, film: number | null }>} params.ffmpegRuns - The encoders running now, with the film each has made.
|
|
222
288
|
* @param {() => boolean} [params.detailed]
|
|
223
289
|
* @param {number} [params.everyMs]
|
|
224
290
|
* @param {number} [params.sayEveryMs]
|
|
225
291
|
* @returns {{ stop: () => void }}
|
|
226
292
|
*/
|
|
227
|
-
export function startProcessorReport({ log,
|
|
293
|
+
export function startProcessorReport({ log, ffmpegRuns, detailed = () => false, everyMs = 1000, sayEveryMs = 60_000 }) {
|
|
228
294
|
let previous = null;
|
|
229
295
|
let series = [];
|
|
296
|
+
/** @type {Map<number, { label: string, cores: number[], speed: number[] }>} */
|
|
297
|
+
let runSeries = new Map();
|
|
230
298
|
let since = Date.now();
|
|
231
299
|
const threads = threadReader();
|
|
232
300
|
const tick = () => {
|
|
233
301
|
const at = Date.now();
|
|
234
302
|
const usage = process.cpuUsage();
|
|
235
|
-
const
|
|
303
|
+
const runs = new Map(ffmpegRuns().map((run) => [run.pid, { label: run.label, film: run.film }]));
|
|
304
|
+
const now = { at, process: (usage.user + usage.system) / 1e6, threads: threads.read(), ffmpeg: readProcessesSeconds([...runs.keys()]), runs };
|
|
236
305
|
const shares = previous ? sharesBetween(previous, now) : null;
|
|
306
|
+
const perRun = previous ? runSecondsBetween(previous, now) : new Map();
|
|
237
307
|
previous = now;
|
|
238
308
|
if (!shares) return;
|
|
239
309
|
series.push(shares);
|
|
310
|
+
for (const [pid, second] of perRun) {
|
|
311
|
+
const kept = runSeries.get(pid) ?? { label: second.label, cores: [], speed: [] };
|
|
312
|
+
kept.cores.push(second.cores);
|
|
313
|
+
if (second.speed !== null) kept.speed.push(second.speed);
|
|
314
|
+
runSeries.set(pid, kept);
|
|
315
|
+
}
|
|
240
316
|
if (detailed()) log(`processor: ${describeShares([shares])}`);
|
|
241
317
|
if (now.at - since >= sayEveryMs) {
|
|
242
318
|
log(`processor: ${describeShares(series)}`);
|
|
319
|
+
const perRunLine = describeRuns(runSeries);
|
|
320
|
+
if (perRunLine) log(`processor: per run, by second: ${perRunLine}`);
|
|
243
321
|
series = [];
|
|
322
|
+
runSeries = new Map();
|
|
244
323
|
since = now.at;
|
|
245
324
|
// A torrent thread made again since is counted as the rest until then.
|
|
246
325
|
threads.rediscover();
|