@torrent-tv/proxy 2.101.0 → 2.101.1
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/quality/HostLoad.js +101 -9
- package/services/server/wire-outputs.js +6 -0
- 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/package.json
CHANGED
package/server.js
CHANGED
|
@@ -1151,10 +1151,13 @@ export async function startProxyServer({
|
|
|
1151
1151
|
});
|
|
1152
1152
|
startProcessorReport({
|
|
1153
1153
|
log: (line) => logger.info(line),
|
|
1154
|
-
|
|
1155
|
-
.flatMap((output) => outputParts.encodeRuns.liveRunsOf(output))
|
|
1156
|
-
|
|
1157
|
-
|
|
1154
|
+
ffmpegRuns: () => [...outputParts.outputs.values()]
|
|
1155
|
+
.flatMap((output) => outputParts.encodeRuns.liveRunsOf(output).map((run) => ({
|
|
1156
|
+
pid: run.process?.pid,
|
|
1157
|
+
label: `${output.outputKey} #${run.from}..#${run.to}`,
|
|
1158
|
+
film: Number(run.progress?.processedSeconds) - Number(run.progress?.startPositionSeconds ?? 0)
|
|
1159
|
+
})))
|
|
1160
|
+
.filter((run) => Number.isInteger(run.pid)),
|
|
1158
1161
|
detailed: () => startupProfile.active()
|
|
1159
1162
|
});
|
|
1160
1163
|
// What the last life of this process left on the disk. The kernel kills this
|
|
@@ -16,6 +16,7 @@ import { baseDrawFrom, costPerMegabyteFrom } from "../torrent-cost.js";
|
|
|
16
16
|
import { medianOf, movedBeyondScatter, READINGS_KEPT, scatterOf } from "../learned-median.js";
|
|
17
17
|
import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode-run-state.js";
|
|
18
18
|
import { SourceFiles } from "../../media/SourceFile.js";
|
|
19
|
+
import { costKindForSession } from "./EncodeCost.js";
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* What the torrent thread says of itself over one interval: how late its event
|
|
@@ -537,7 +538,8 @@ export class HostLoad {
|
|
|
537
538
|
this.#host.readSystemCpu?.() ?? null,
|
|
538
539
|
this.#host.readEndedChildrenCpuSeconds?.() ?? null
|
|
539
540
|
]);
|
|
540
|
-
const sample = { takenAt: Date.now(), system, endedChildren, byPid: new Map(), proxyCpuSeconds: this.#host.readProxyCpuSeconds()
|
|
541
|
+
const sample = { takenAt: Date.now(), system, endedChildren, byPid: new Map(), proxyCpuSeconds: this.#host.readProxyCpuSeconds(),
|
|
542
|
+
processor: this.#host.sampleProcessor?.() ?? null };
|
|
541
543
|
const previous = this.#idleAvailabilitySample;
|
|
542
544
|
this.#idleAvailabilitySample = sample;
|
|
543
545
|
if (previous === null) {
|
|
@@ -556,7 +558,7 @@ export class HostLoad {
|
|
|
556
558
|
// stands is seen beside what it is free for (torrent-tv/meta#178); a proxy
|
|
557
559
|
// nobody watches stays quiet.
|
|
558
560
|
if (share !== null && watched) {
|
|
559
|
-
await this.#sayMachine(share, proxyShare, 0, suspended);
|
|
561
|
+
await this.#sayMachine(share, proxyShare, 0, suspended, false, this.#processorLine(previous, sample, share, proxyShare));
|
|
560
562
|
}
|
|
561
563
|
}
|
|
562
564
|
|
|
@@ -566,7 +568,7 @@ export class HostLoad {
|
|
|
566
568
|
* and the processor share divide; `free` is what nothing used over the
|
|
567
569
|
* interval. Unknown stays `n/a`, never zero.
|
|
568
570
|
*/
|
|
569
|
-
async #sayMachine(share, proxyShare, running, suspended, incomplete = false) {
|
|
571
|
+
async #sayMachine(share, proxyShare, running, suspended, incomplete = false, processor = "") {
|
|
570
572
|
const machine = await this.#host.readMachineState();
|
|
571
573
|
// HOW LATE THIS THREAD'S LOOP RAN over the interval: every answer to FFmpeg
|
|
572
574
|
// and every hand-over of a copy waits for it (torrent-tv/meta#178). Begun at
|
|
@@ -591,10 +593,74 @@ export class HostLoad {
|
|
|
591
593
|
// What the offer will multiply a prediction by, in the same line as the
|
|
592
594
|
// readings it comes from.
|
|
593
595
|
` available=${asPercent(this.hostAvailability.share)} for-encoders=${asPercent(this.hostAvailability.forEncoders)}` +
|
|
594
|
-
` free=${asPercent(free)}` + late
|
|
596
|
+
` free=${asPercent(free)}` + processor + late
|
|
595
597
|
);
|
|
596
598
|
}
|
|
597
599
|
|
|
600
|
+
/**
|
|
601
|
+
* The processor's upper bound and its measured free amount over the same
|
|
602
|
+
* interval, for the machine's line (torrent-tv/meta#180, phase 1). Read
|
|
603
|
+
* beside today's figures and used by no decision; empty where the host does
|
|
604
|
+
* not take these readings.
|
|
605
|
+
*
|
|
606
|
+
* @param {{ processor?: object | null }} previous
|
|
607
|
+
* @param {{ processor?: object | null }} sample
|
|
608
|
+
* @param {{ processShare: number | null, systemShare: number | null }} share
|
|
609
|
+
* @param {number | null} proxyShare
|
|
610
|
+
* @returns {string}
|
|
611
|
+
*/
|
|
612
|
+
#processorLine(previous, sample, share, proxyShare) {
|
|
613
|
+
if (typeof this.#host.processorBetween !== "function" || typeof this.#host.describeProcessor !== "function") {
|
|
614
|
+
return "";
|
|
615
|
+
}
|
|
616
|
+
const ourShare = Number.isFinite(proxyShare) && Number.isFinite(share.processShare) ? proxyShare + share.processShare : null;
|
|
617
|
+
return this.#host.describeProcessor(this.#host.processorBetween(previous.processor, sample.processor, {
|
|
618
|
+
systemShare: share.systemShare,
|
|
619
|
+
ourShare,
|
|
620
|
+
hostCores: Math.max(1, os.cpus().length)
|
|
621
|
+
}));
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* What each run's FFmpeg spent per second of film over the interval between
|
|
626
|
+
* two readings, with the runs beside it (torrent-tv/meta#180, phase 1).
|
|
627
|
+
*
|
|
628
|
+
* In CPU seconds per second of film (`k`), which is not the unit of
|
|
629
|
+
* `EncodeCost` (seconds of the whole machine per second of film, from
|
|
630
|
+
* speed): a run that uses all it is given has `cost = k / cores`. A `k` read
|
|
631
|
+
* beside other runs already carries their contention, so it is said with
|
|
632
|
+
* them; a run is alone only when no other run was alive at either reading.
|
|
633
|
+
* Only runs read at both ends that made film in between are said. For the
|
|
634
|
+
* log only.
|
|
635
|
+
*
|
|
636
|
+
* @param {{ takenAt: number, byPid: Map<number, number>, runs?: Map<number, { film: number | null, label: string, kind: string }> }} previous
|
|
637
|
+
* @param {{ takenAt: number, byPid: Map<number, number>, runs?: Map<number, { film: number | null, label: string, kind: string }> }} sample
|
|
638
|
+
*/
|
|
639
|
+
#sayRunCosts(previous, sample) {
|
|
640
|
+
if (!previous.runs || !sample.runs) return;
|
|
641
|
+
const elapsedSec = (sample.takenAt - previous.takenAt) / 1000;
|
|
642
|
+
if (!(elapsedSec > 0)) return;
|
|
643
|
+
const alive = new Map([...previous.runs, ...sample.runs]);
|
|
644
|
+
const said = [];
|
|
645
|
+
for (const [pid, run] of sample.runs) {
|
|
646
|
+
const before = previous.runs.get(pid);
|
|
647
|
+
const cpuNow = sample.byPid.get(pid);
|
|
648
|
+
const cpuBefore = previous.byPid.get(pid);
|
|
649
|
+
if (!before || !Number.isFinite(cpuNow) || !Number.isFinite(cpuBefore) || cpuNow < cpuBefore) continue;
|
|
650
|
+
if (!Number.isFinite(run.film) || !Number.isFinite(before.film)) continue;
|
|
651
|
+
const film = run.film - before.film;
|
|
652
|
+
if (!(film > 0)) continue;
|
|
653
|
+
const cpu = cpuNow - cpuBefore;
|
|
654
|
+
const beside = [...alive].filter(([other]) => other !== pid).map(([, other]) => other.kind);
|
|
655
|
+
said.push(`${run.label} ${run.kind} k=${(cpu / film).toFixed(3)} CPU-s per film-s ` +
|
|
656
|
+
`(${cpu.toFixed(2)} CPU-s, ${film.toFixed(2)} film-s, speed ${(film / elapsedSec).toFixed(2)}x, ` +
|
|
657
|
+
`${(cpu / elapsedSec).toFixed(2)} cores), beside ${beside.length}` + (beside.length > 0 ? ` [${beside.join(", ")}]` : ""));
|
|
658
|
+
}
|
|
659
|
+
if (said.length > 0) {
|
|
660
|
+
logger.info(`processor: run cost over ${elapsedSec.toFixed(1)}s: ${said.join("; ")}`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
598
664
|
/**
|
|
599
665
|
* @param {{ elapsedSec: number, processShare: number | null, systemShare: number | null, rejected?: string[] } | null} share
|
|
600
666
|
* @param {{ proxyCpuSeconds: number | null }} previous
|
|
@@ -658,9 +724,17 @@ export class HostLoad {
|
|
|
658
724
|
// that ends meanwhile drops its process, and read after the await it threw
|
|
659
725
|
// `Cannot read properties of null (reading 'pid')`, seven times in one
|
|
660
726
|
// field session, each losing that reading (r64, torrent-tv/meta#178).
|
|
661
|
-
|
|
727
|
+
// With the film each has made and what it is, for its cost over the
|
|
728
|
+
// interval (torrent-tv/meta#180).
|
|
729
|
+
const runs = encoding.flatMap((session) => this.#host.liveRunsOf(session)
|
|
662
730
|
.filter((run) => Number.isInteger(run.process?.pid))
|
|
663
|
-
.map((run) => ({
|
|
731
|
+
.map((run) => ({
|
|
732
|
+
pid: run.process.pid,
|
|
733
|
+
startedAt: run.startedAt,
|
|
734
|
+
film: filmMadeBy(run),
|
|
735
|
+
label: `${session.outputKey ?? session.id} #${run.from}..#${run.to}`,
|
|
736
|
+
kind: costKindForSession(session)
|
|
737
|
+
})));
|
|
664
738
|
const [system, endedChildren, ...cpuReadings] = await Promise.all([
|
|
665
739
|
this.#host.readSystemCpu(),
|
|
666
740
|
this.#host.readEndedChildrenCpuSeconds?.() ?? null,
|
|
@@ -670,12 +744,15 @@ export class HostLoad {
|
|
|
670
744
|
const byPid = new Map();
|
|
671
745
|
/** @type {Map<number, number>} */
|
|
672
746
|
const startedAt = new Map();
|
|
747
|
+
/** @type {Map<number, { film: number | null, label: string, kind: string }>} */
|
|
748
|
+
const runsByPid = new Map();
|
|
673
749
|
runs.forEach((run, index) => {
|
|
674
750
|
const seconds = cpuReadings[index];
|
|
675
751
|
if (seconds !== null) {
|
|
676
752
|
byPid.set(run.pid, seconds);
|
|
677
753
|
startedAt.set(run.pid, run.startedAt);
|
|
678
754
|
}
|
|
755
|
+
runsByPid.set(run.pid, { film: run.film, label: run.label, kind: run.kind });
|
|
679
756
|
});
|
|
680
757
|
const sample = {
|
|
681
758
|
takenAt: Date.now(),
|
|
@@ -687,7 +764,9 @@ export class HostLoad {
|
|
|
687
764
|
// piece store and the delivery. None of it is in the encode budget, and
|
|
688
765
|
// on the addon host it is most of what the machine does while encoders
|
|
689
766
|
// are suspended.
|
|
690
|
-
proxyCpuSeconds: this.#host.readProxyCpuSeconds()
|
|
767
|
+
proxyCpuSeconds: this.#host.readProxyCpuSeconds(),
|
|
768
|
+
runs: runsByPid,
|
|
769
|
+
processor: this.#host.sampleProcessor?.() ?? null
|
|
691
770
|
};
|
|
692
771
|
const previous = this.#hostLoadSample;
|
|
693
772
|
this.#hostLoadSample = sample;
|
|
@@ -699,6 +778,7 @@ export class HostLoad {
|
|
|
699
778
|
// `/proc` there are no readings at all, and the share is null rather than a
|
|
700
779
|
// confident zero.
|
|
701
780
|
const encoders = encoderCpuBetween(previous, sample);
|
|
781
|
+
this.#sayRunCosts(previous, sample);
|
|
702
782
|
const share = this.#host.shareOfMachine(
|
|
703
783
|
{ takenAt: previous.takenAt, processCpuSeconds: encoders === null ? null : 0, system: previous.system },
|
|
704
784
|
{ takenAt: sample.takenAt, processCpuSeconds: encoders?.seconds ?? null, system: sample.system }
|
|
@@ -718,7 +798,7 @@ export class HostLoad {
|
|
|
718
798
|
if (encoders && !encoders.complete) {
|
|
719
799
|
// A lower bound of our own load is not a reading of what other work left
|
|
720
800
|
// us: the last availability stands, and the line says so.
|
|
721
|
-
await this.#sayMachine(share, null, running, suspended, true);
|
|
801
|
+
await this.#sayMachine(share, null, running, suspended, true, this.#processorLine(previous, sample, share, null));
|
|
722
802
|
return false;
|
|
723
803
|
}
|
|
724
804
|
// Kept for the quality offer, which predicts from a benchmark taken on a
|
|
@@ -726,7 +806,7 @@ export class HostLoad {
|
|
|
726
806
|
// machine a new encoder could actually have. Only what nobody has been
|
|
727
807
|
// charged for is subtracted — see `encode/available-share.js`.
|
|
728
808
|
const proxyShare = this.#setAvailability(share, previous, sample);
|
|
729
|
-
await this.#sayMachine(share, proxyShare, running, suspended);
|
|
809
|
+
await this.#sayMachine(share, proxyShare, running, suspended, false, this.#processorLine(previous, sample, share, proxyShare));
|
|
730
810
|
return false;
|
|
731
811
|
}
|
|
732
812
|
|
|
@@ -756,3 +836,15 @@ export class HostLoad {
|
|
|
756
836
|
: 0;
|
|
757
837
|
}
|
|
758
838
|
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* The film a run has made since it started, from its progress, or null before
|
|
842
|
+
* it has said any.
|
|
843
|
+
*
|
|
844
|
+
* @param {{ progress?: { processedSeconds?: number, startPositionSeconds?: number } }} run
|
|
845
|
+
* @returns {number | null}
|
|
846
|
+
*/
|
|
847
|
+
function filmMadeBy(run) {
|
|
848
|
+
const made = Number(run.progress?.processedSeconds) - Number(run.progress?.startPositionSeconds ?? 0);
|
|
849
|
+
return Number.isFinite(made) && made >= 0 ? made : null;
|
|
850
|
+
}
|
|
@@ -101,6 +101,7 @@ import { contentOf, LocalObservations, PROXY_ROOT } from "../encode/LocalObserva
|
|
|
101
101
|
import { configurationKeyOf } from "../encode/fingerprint.js";
|
|
102
102
|
import { qualityStateOf } from "../encode/quality/OutputQualityState.js";
|
|
103
103
|
import { downloadShare } from "../storage/processor-share.js";
|
|
104
|
+
import { describeProcessor, processorBetween, sampleProcessor } from "../storage/machine-processor.js";
|
|
104
105
|
import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode/encode-run-state.js";
|
|
105
106
|
import { secondsUntilClosed } from "../encode/piece-close-time.js";
|
|
106
107
|
|
|
@@ -303,6 +304,11 @@ export function wireOutputs({
|
|
|
303
304
|
readProxyCpuSeconds,
|
|
304
305
|
readSystemCpu,
|
|
305
306
|
shareOfMachine,
|
|
307
|
+
// The processor's upper bound and measured free amount, said beside the
|
|
308
|
+
// machine's line and used by no decision yet (torrent-tv/meta#180).
|
|
309
|
+
sampleProcessor: () => sampleProcessor(),
|
|
310
|
+
processorBetween,
|
|
311
|
+
describeProcessor,
|
|
306
312
|
liveRunsOf: (...args) => parts.encodeRuns.liveRunsOf(...args),
|
|
307
313
|
runStateOf: (...args) => parts.encodeRuns.runStateOf(...args),
|
|
308
314
|
get getSourceStats() { return parts.getSourceStats; },
|
|
@@ -163,7 +163,7 @@ function hostAvailable(readFile) {
|
|
|
163
163
|
function containerRoom(readFile, hostTotal) {
|
|
164
164
|
let membership;
|
|
165
165
|
try {
|
|
166
|
-
membership =
|
|
166
|
+
membership = cgroupOf(readFile("/proc/self/cgroup"), "memory");
|
|
167
167
|
} catch {
|
|
168
168
|
return null; // silent-ok: not Linux, or /proc is not readable.
|
|
169
169
|
}
|
|
@@ -187,18 +187,19 @@ function containerRoom(readFile, hostTotal) {
|
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
/**
|
|
190
|
-
* Which cgroup this process is in, for
|
|
190
|
+
* Which cgroup this process is in, for one controller (`memory`, `cpu`).
|
|
191
191
|
*
|
|
192
|
-
* A host in hybrid mode lists both versions;
|
|
193
|
-
*
|
|
192
|
+
* A host in hybrid mode lists both versions; a controller mounted on version 1
|
|
193
|
+
* is named in the line that lists it, and only then is version 2 the answer.
|
|
194
194
|
*
|
|
195
195
|
* @param {string} text - `/proc/self/cgroup`.
|
|
196
|
+
* @param {string} controller
|
|
196
197
|
* @returns {{ version: 1 | 2, path: string } | null}
|
|
197
198
|
*/
|
|
198
|
-
function
|
|
199
|
+
export function cgroupOf(text, controller) {
|
|
199
200
|
for (const line of text.split("\n")) {
|
|
200
201
|
const match = /^\d+:([^:]*):(.*)$/.exec(line);
|
|
201
|
-
if (match && match[1].split(",").includes(
|
|
202
|
+
if (match && match[1].split(",").includes(controller)) {
|
|
202
203
|
return { version: 1, path: match[2] };
|
|
203
204
|
}
|
|
204
205
|
}
|
|
@@ -219,7 +220,7 @@ function ownCgroup(text) {
|
|
|
219
220
|
* @param {string} path
|
|
220
221
|
* @returns {string[]}
|
|
221
222
|
*/
|
|
222
|
-
function levelsOf(mount, path) {
|
|
223
|
+
export function levelsOf(mount, path) {
|
|
223
224
|
const parts = path.split("/").filter(Boolean);
|
|
224
225
|
if (parts.includes("..")) {
|
|
225
226
|
return [];
|
|
@@ -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();
|