@torrent-tv/proxy 2.101.2 → 2.101.4
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/bin/cli.js +0 -2
- package/package.json +1 -1
- package/server.js +12 -9
- package/services/encode/EncodeInputs.js +2 -16
- package/services/encode/EncodeOrchestrator.js +143 -18
- package/services/encode/EncodePlan.js +15 -4
- package/services/encode/EncodeRun.js +5 -0
- package/services/encode/PauseRule.js +334 -0
- package/services/encode/ProcessorBudget.js +22 -1
- package/services/encode/RunClock.js +10 -0
- package/services/encode/available-share.js +39 -4
- package/services/encode/calibration.js +4 -1
- package/services/encode/fingerprint.js +12 -4
- package/services/encode/host-load.js +34 -6
- package/services/encode/hwaccel.js +15 -9
- package/services/encode/quality/HostLoad.js +20 -3
- package/services/server/wire-outputs.js +3 -7
- package/services/storage/processor-report.js +13 -1
package/bin/cli.js
CHANGED
|
@@ -90,7 +90,6 @@ program
|
|
|
90
90
|
.option("--no-port-mapping", "Disable automatic UPnP/NAT-PMP port mapping")
|
|
91
91
|
.option("--delivery-sink", "Serve /api/delivery-sink, a torrent-free byte stream for delivery testing")
|
|
92
92
|
.option("--pin-preset <name>", "Encode every software picture at this libx264 preset only, for runs that must be compared at one quality (off by default)")
|
|
93
|
-
.option("--input-stretch-bytes <bytes>", "Size every original-input stretch to this many bytes instead of copy rate times open, for runs that compare input sizes; memory, downloaded bytes and the plan still bound it (off by default)")
|
|
94
93
|
.option("--heap-snapshots", "Write a heap snapshot of a thread whose memory reaches a new high-water; each one stops that thread while it is written (off by default)")
|
|
95
94
|
.option("--profile-start <seconds>", "Profile the CPU of the main thread and the torrent thread for this many seconds from the first source opened, written beside the state (off by default)")
|
|
96
95
|
.option(
|
|
@@ -400,7 +399,6 @@ try {
|
|
|
400
399
|
profileStartSeconds: Number(options.profileStart) || 0,
|
|
401
400
|
heapSnapshots: options.heapSnapshots === true,
|
|
402
401
|
pinnedPreset: typeof options.pinPreset === "string" && options.pinPreset.trim() ? options.pinPreset.trim() : null,
|
|
403
|
-
inputStretchBytes: Number(options.inputStretchBytes) > 0 ? Math.floor(Number(options.inputStretchBytes)) : null,
|
|
404
402
|
// Late-bound the same way `webRtcManager` is below: the torrent pool is
|
|
405
403
|
// built inside `startProxyServer`, before `dataChannelHandler` — the
|
|
406
404
|
// thing that actually owns a channel to push down — exists.
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -88,6 +88,7 @@ import { takeTurns } from "./utils/take-turns.js";
|
|
|
88
88
|
import { completedFilesRoot } from "./services/storage/files/CompletedFiles.js";
|
|
89
89
|
import { startupCpuProfile } from "./services/storage/cpu-profile.js";
|
|
90
90
|
import { startProcessorReport } from "./services/storage/processor-report.js";
|
|
91
|
+
import { processorBound } from "./services/storage/machine-processor.js";
|
|
91
92
|
|
|
92
93
|
const require = createRequire(import.meta.url);
|
|
93
94
|
const { version } = require("./package.json");
|
|
@@ -134,9 +135,6 @@ export async function startProxyServer({
|
|
|
134
135
|
// The one software preset every picture is encoded at, for runs compared at
|
|
135
136
|
// one quality; null leaves the choice to the machine.
|
|
136
137
|
pinnedPreset = null,
|
|
137
|
-
// The size every original-input stretch is given in place of copy rate
|
|
138
|
-
// times open, for runs that compare input sizes; null keeps the rule.
|
|
139
|
-
inputStretchBytes = null,
|
|
140
138
|
// What viewers' connections carry beyond the film, per byte of film; the
|
|
141
139
|
// transport counts it and is built after this server, so it is late-bound.
|
|
142
140
|
serviceShare = () => null,
|
|
@@ -234,6 +232,10 @@ export async function startProxyServer({
|
|
|
234
232
|
let videoEncoder = transcodeAudio
|
|
235
233
|
? await detectVideoEncoder({ ffmpegBin, logger })
|
|
236
234
|
: null;
|
|
235
|
+
// The cores this process may use (a container's quota or CPU set): the
|
|
236
|
+
// readings below divide out a share of those, the unit every later share of
|
|
237
|
+
// the machine is in (torrent-tv/meta#180, phase 3).
|
|
238
|
+
const processorCores = processorBound().cores;
|
|
237
239
|
// WHAT THIS HOST DOES, measured before any viewer exists. Every one of these
|
|
238
240
|
// was gated on the chosen encoder being SOFTWARE, and two of the three are
|
|
239
241
|
// not about the encoder at all: a host with a GPU decodes in software just
|
|
@@ -246,7 +248,7 @@ export async function startProxyServer({
|
|
|
246
248
|
// (the rungs are timed on raw frames), but the order keeps the two figures
|
|
247
249
|
// side by side in the log.
|
|
248
250
|
const decodeCostModel = transcodeAudio
|
|
249
|
-
? await benchmarkDecodeCost({ ffmpegBin, logger })
|
|
251
|
+
? await benchmarkDecodeCost({ ffmpegBin, logger, processorCores })
|
|
250
252
|
: null;
|
|
251
253
|
// What a second job costs on this host. Measured because the budget adds
|
|
252
254
|
// independent prices and this host says two jobs that each fit alone do not
|
|
@@ -267,16 +269,16 @@ export async function startProxyServer({
|
|
|
267
269
|
// the processor and the threads, the device and its driver — which is what
|
|
268
270
|
// decides whether anything kept from an earlier run still applies.
|
|
269
271
|
const fingerprint = videoEncoder
|
|
270
|
-
? await readHostFingerprint({ ffmpegBin, encoder: videoEncoder })
|
|
272
|
+
? await readHostFingerprint({ ffmpegBin, encoder: videoEncoder, processorCores })
|
|
271
273
|
: null;
|
|
272
274
|
/** @type {Record<string, object[]>} */
|
|
273
275
|
const calibratedModes = {};
|
|
274
276
|
if (videoEncoder) {
|
|
275
277
|
calibratedModes[videoEncoder.kind] =
|
|
276
|
-
(await calibrateEncoder({ ffmpegBin, encoder: videoEncoder, logger })).modes;
|
|
278
|
+
(await calibrateEncoder({ ffmpegBin, encoder: videoEncoder, logger, processorCores })).modes;
|
|
277
279
|
if (videoEncoder.kind !== "software") {
|
|
278
280
|
calibratedModes.software =
|
|
279
|
-
(await calibrateEncoder({ ffmpegBin, encoder: softwareDescriptor(), logger })).modes;
|
|
281
|
+
(await calibrateEncoder({ ffmpegBin, encoder: softwareDescriptor(), logger, processorCores })).modes;
|
|
280
282
|
// A device that passed detection's own test and then none of whose modes
|
|
281
283
|
// passed the product's is not used: detection encodes a synthetic pattern
|
|
282
284
|
// with arguments of its own, and it is the product's arguments a viewer
|
|
@@ -857,7 +859,6 @@ export async function startProxyServer({
|
|
|
857
859
|
let sourcePreparation;
|
|
858
860
|
const outputParts = wireOutputs({
|
|
859
861
|
pinnedPreset,
|
|
860
|
-
inputStretchBytes,
|
|
861
862
|
indexMemory,
|
|
862
863
|
serviceShare,
|
|
863
864
|
readMetadataActivity: () => containerOrchestrator.activity(),
|
|
@@ -1158,7 +1159,9 @@ export async function startProxyServer({
|
|
|
1158
1159
|
film: Number(run.progress?.processedSeconds) - Number(run.progress?.startPositionSeconds ?? 0)
|
|
1159
1160
|
})))
|
|
1160
1161
|
.filter((run) => Number.isInteger(run.pid)),
|
|
1161
|
-
detailed: () => startupProfile.active()
|
|
1162
|
+
detailed: () => startupProfile.active(),
|
|
1163
|
+
// What the pause rule judges from (torrent-tv/meta#180, phase 4).
|
|
1164
|
+
onRunSeconds: (seconds) => outputParts.encodeOrchestrator?.noteRunSeconds(seconds)
|
|
1162
1165
|
});
|
|
1163
1166
|
// What the last life of this process left on the disk. The kernel kills this
|
|
1164
1167
|
// one often enough for that to be an ordinary state rather than an odd one —
|
|
@@ -62,20 +62,10 @@ export class EncodeInputs {
|
|
|
62
62
|
* @type {(output: object, from: number, to: number, input: object) => void}
|
|
63
63
|
*/
|
|
64
64
|
#stretchCopied;
|
|
65
|
-
/**
|
|
66
|
-
* A stretch size the operator named for comparison runs
|
|
67
|
-
* (`--input-stretch-bytes`), in place of copy rate times open; null keeps
|
|
68
|
-
* the measured rule. Only the target changes: memory, downloaded bytes and
|
|
69
|
-
* the plan's bound still end a stretch (torrent-tv/meta#182).
|
|
70
|
-
*
|
|
71
|
-
* @type {number | null}
|
|
72
|
-
*/
|
|
73
|
-
#fixedStretchBytes;
|
|
74
65
|
|
|
75
66
|
constructor({ resolve, readRanges, heldRanges = null, reviseBudget, changed, failed, capacity = () => null, urgent = () => true,
|
|
76
|
-
rankAt = () => null, openOf = () => null, log = () => {}, now = Date.now, stretchCopied = () => {}
|
|
67
|
+
rankAt = () => null, openOf = () => null, log = () => {}, now = Date.now, stretchCopied = () => {} }) {
|
|
77
68
|
this.#stretchCopied = stretchCopied;
|
|
78
|
-
this.#fixedStretchBytes = Number.isSafeInteger(stretchBytes) && stretchBytes > 0 ? stretchBytes : null;
|
|
79
69
|
this.#openOf = openOf;
|
|
80
70
|
this.#resolve = resolve;
|
|
81
71
|
this.#read = readRanges;
|
|
@@ -354,10 +344,6 @@ export class EncodeInputs {
|
|
|
354
344
|
#sizingFor(output) {
|
|
355
345
|
const rate = middleOf(this.#copyRates);
|
|
356
346
|
const open = this.#openOf(output);
|
|
357
|
-
if (this.#fixedStretchBytes !== null) {
|
|
358
|
-
return { bytes: this.#fixedStretchBytes, openMeasured: true, openSeconds: open?.seconds ?? null,
|
|
359
|
-
copyBytesPerMs: rate, fixed: true };
|
|
360
|
-
}
|
|
361
347
|
return rate === null || !open ? null : { bytes: Math.floor(rate * open.seconds * 1000),
|
|
362
348
|
openMeasured: open.measured, openSeconds: open.seconds, copyBytesPerMs: rate };
|
|
363
349
|
}
|
|
@@ -621,7 +607,7 @@ export class EncodeInputs {
|
|
|
621
607
|
if (result.kind === "result") {
|
|
622
608
|
this.#log(`encode input selection of ${addressOf(output)}: ${JSON.stringify({
|
|
623
609
|
from, requestedTo: request.to, serves, floor, targetEnd, chosenEnd: end,
|
|
624
|
-
targetBytes: target,
|
|
610
|
+
targetBytes: target, minimumBytes: minimum, inputBytes: result.bytes,
|
|
625
611
|
copyBytesPerMs: sizing?.copyBytesPerMs ?? null, openSeconds: sizing?.openSeconds ?? null,
|
|
626
612
|
openMeasured: sizing?.openMeasured ?? null, reservations, probes
|
|
627
613
|
})}`);
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { CoverageMap } from "./CoverageMap.js";
|
|
27
|
-
import { firstUnmetWant, planEncoders } from "./EncodePlan.js";
|
|
27
|
+
import { deadlineReaderFor, firstUnmetWant, planEncoders } from "./EncodePlan.js";
|
|
28
|
+
import { PauseRule } from "./PauseRule.js";
|
|
28
29
|
import { endOfRun } from "./EncodeRun.js";
|
|
29
30
|
import { ENCODE_EXIT } from "./encode-exit.js";
|
|
30
31
|
import { affordableRuns } from "./run-budget.js";
|
|
@@ -138,13 +139,19 @@ export class EncodeOrchestrator {
|
|
|
138
139
|
* viewer was stopped for waited 59 s behind a picture that had long left
|
|
139
140
|
* the urgent band (Home Assistant 2026-10-12, torrent-tv/meta#178).
|
|
140
141
|
*
|
|
141
|
-
* @type {Map<string, { urgent: boolean, priority: number }>}
|
|
142
|
+
* @type {Map<string, { urgent: boolean, priority: number, from: number }>}
|
|
142
143
|
*/
|
|
143
144
|
#heldBack = new Map();
|
|
144
145
|
|
|
145
146
|
/** What a stop and a start have cost on this host. */
|
|
146
147
|
#costs = new RunCosts();
|
|
147
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Whether a less urgent encoder runs beside the leading one, from what
|
|
151
|
+
* running it was measured to do (torrent-tv/meta#180, phase 4).
|
|
152
|
+
*/
|
|
153
|
+
#pauseRule = new PauseRule();
|
|
154
|
+
|
|
148
155
|
/**
|
|
149
156
|
* What starting and stopping an encoder was measured to cost on this host,
|
|
150
157
|
* before any viewer existed. Told once; real runs replace it as they end.
|
|
@@ -228,6 +235,9 @@ export class EncodeOrchestrator {
|
|
|
228
235
|
* this one may be asked about encoders with no ledger, no clock and no
|
|
229
236
|
* viewer anywhere. Absent, the line simply does not carry it.
|
|
230
237
|
* @param {SegmentCoverage | null} [params.segmentCoverage]
|
|
238
|
+
* @param {{ freeCores: () => number | null, otherCores: () => number | null } | null} [params.processor] -
|
|
239
|
+
* The processor left free and the processor other work takes, in cores
|
|
240
|
+
* (`ProcessorBudget`); absent, both are unknown.
|
|
231
241
|
*/
|
|
232
242
|
constructor({
|
|
233
243
|
maxRunsFor,
|
|
@@ -242,6 +252,7 @@ export class EncodeOrchestrator {
|
|
|
242
252
|
inputsUnplaced = null,
|
|
243
253
|
describeWaits = null,
|
|
244
254
|
admission = null,
|
|
255
|
+
processor = null,
|
|
245
256
|
logger,
|
|
246
257
|
now
|
|
247
258
|
}) {
|
|
@@ -250,6 +261,7 @@ export class EncodeOrchestrator {
|
|
|
250
261
|
// encoder that would slow everybody below realtime. Absent in a check that
|
|
251
262
|
// is about one output, and then only the per-output limit binds.
|
|
252
263
|
this.admission = admission && typeof admission.placesFor === "function" ? admission : null;
|
|
264
|
+
this.processor = processor && typeof processor.freeCores === "function" ? processor : null;
|
|
253
265
|
this.#describeWaits = typeof describeWaits === "function" ? describeWaits : () => "";
|
|
254
266
|
// Only the segment facts and operations this planner needs cross the
|
|
255
267
|
// storage boundary; the concrete storage remains in the server assembly.
|
|
@@ -665,7 +677,7 @@ export class EncodeOrchestrator {
|
|
|
665
677
|
/**
|
|
666
678
|
* The most urgent band a live encoder stands in, over every output.
|
|
667
679
|
*
|
|
668
|
-
* @returns {{ urgent: boolean, priority: number, address: string, index: number } | null}
|
|
680
|
+
* @returns {{ urgent: boolean, priority: number, address: string, index: number, run: object } | null}
|
|
669
681
|
*/
|
|
670
682
|
#leadingBand() {
|
|
671
683
|
let leading = null;
|
|
@@ -673,7 +685,7 @@ export class EncodeOrchestrator {
|
|
|
673
685
|
for (const run of runs) {
|
|
674
686
|
if (!run.isAlive || run.isStopping) continue;
|
|
675
687
|
const band = this.#bandAt(address, run.head);
|
|
676
|
-
if (!leading || compareUrgency(band, leading) < 0) leading = { ...band, address, index: run.head };
|
|
688
|
+
if (!leading || compareUrgency(band, leading) < 0) leading = { ...band, address, index: run.head, run };
|
|
677
689
|
}
|
|
678
690
|
}
|
|
679
691
|
return leading;
|
|
@@ -696,6 +708,13 @@ export class EncodeOrchestrator {
|
|
|
696
708
|
* Paused rather than lowered in priority: a lowered priority cannot be raised
|
|
697
709
|
* again without `CAP_SYS_NICE`, which the add-on's container does not have
|
|
698
710
|
* (Home Assistant 2026-10-10: `renice 0` after `renice 19` left 19).
|
|
711
|
+
*
|
|
712
|
+
* AND PAUSED ONLY WHERE RUNNING IT WAS MEASURED TO COST THE LEADER
|
|
713
|
+
* (torrent-tv/meta#180, phase 4). Behind the leader is the condition, not
|
|
714
|
+
* the verdict: `PauseRule` lets a less urgent encoder run while the leader
|
|
715
|
+
* still makes what its deadlines need, and pauses it once the leader's own
|
|
716
|
+
* readings say it does not. Without readings to judge from, behind is paused
|
|
717
|
+
* as before.
|
|
699
718
|
*/
|
|
700
719
|
shareTheMachine() {
|
|
701
720
|
const leading = this.#leadingBand();
|
|
@@ -704,21 +723,125 @@ export class EncodeOrchestrator {
|
|
|
704
723
|
this.#placeHeldBack(null);
|
|
705
724
|
return;
|
|
706
725
|
}
|
|
726
|
+
const now = this.now();
|
|
727
|
+
const running = this.#runningNow();
|
|
728
|
+
const need = this.#needOf(leading);
|
|
729
|
+
const free = this.#processorReading("freeCores");
|
|
730
|
+
const others = this.#processorReading("otherCores");
|
|
707
731
|
for (const [address, runs] of this.#runs) {
|
|
708
732
|
for (const run of runs) {
|
|
709
733
|
if (!run.isAlive || run.isStopping || typeof run.pause !== "function") continue;
|
|
710
734
|
const behind = compareUrgency(this.#bandAt(address, run.head), leading) > 0;
|
|
711
|
-
if (behind
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
}
|
|
715
|
-
|
|
735
|
+
if (!behind) {
|
|
736
|
+
if (run.isSuspended === true) run.resume(`nothing more urgent than #${run.head} of ${address} is being made`);
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
const urgency = `#${run.head} of ${address} is wanted less urgently than #${leading.index} of ${leading.address}`;
|
|
740
|
+
const verdict = this.#pauseRule.judge({
|
|
741
|
+
leader: leading.run, band: leading, run, address, need, remainingFilm: this.#remainingFilm(run), free, others, running, now
|
|
742
|
+
});
|
|
743
|
+
if (verdict === null) {
|
|
744
|
+
if (run.isSuspended !== true) run.pause(`${urgency}, which is being made`);
|
|
745
|
+
} else if (verdict.runs && run.isSuspended === true) {
|
|
746
|
+
run.resume(`${urgency}, and runs beside it: ${verdict.because}`);
|
|
747
|
+
} else if (!verdict.runs && run.isSuspended !== true) {
|
|
748
|
+
run.pause(`${urgency}, which is being made: ${verdict.because}`);
|
|
716
749
|
}
|
|
717
750
|
}
|
|
718
751
|
}
|
|
719
752
|
this.#placeHeldBack(leading);
|
|
720
753
|
}
|
|
721
754
|
|
|
755
|
+
/**
|
|
756
|
+
* One second of each encoder's processor and speed, by process
|
|
757
|
+
* (`storage/processor-report.js`): what `PauseRule` judges from. Who has the
|
|
758
|
+
* processor is decided again after it.
|
|
759
|
+
*
|
|
760
|
+
* @param {Map<number, { cores: number, speed: number | null }>} seconds
|
|
761
|
+
* @param {number} [at]
|
|
762
|
+
*/
|
|
763
|
+
noteRunSeconds(seconds, at = this.now()) {
|
|
764
|
+
if (!(seconds instanceof Map) || seconds.size === 0) return;
|
|
765
|
+
const running = this.#runningNow();
|
|
766
|
+
let noted = false;
|
|
767
|
+
for (const runs of this.#runs.values()) {
|
|
768
|
+
for (const run of runs) {
|
|
769
|
+
const reading = seconds.get(run.process?.pid);
|
|
770
|
+
// A paused process draws nothing and makes nothing: its second says
|
|
771
|
+
// nothing about what it costs when it runs.
|
|
772
|
+
if (!reading || !run.isAlive || run.isSuspended === true) continue;
|
|
773
|
+
this.#pauseRule.noteSecond(run, { at, cores: reading.cores, speed: reading.speed }, running, run.isWaitingForInput === true);
|
|
774
|
+
noted = true;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
if (noted) this.shareTheMachine();
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** @returns {Set<object>} Every encoder running now, not paused. */
|
|
781
|
+
#runningNow() {
|
|
782
|
+
const running = new Set();
|
|
783
|
+
for (const runs of this.#runs.values()) {
|
|
784
|
+
for (const run of runs) if (run.isAlive && !run.isStopping && run.isSuspended !== true) running.add(run);
|
|
785
|
+
}
|
|
786
|
+
return running;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* The speed the leading run's deadlines ask for: over every number it has
|
|
791
|
+
* left, the film up to it over the time until it is needed. Infinite where a
|
|
792
|
+
* viewer already waits for one of them; 0 where none is due.
|
|
793
|
+
*
|
|
794
|
+
* @param {{ address: string, run: object }} leading
|
|
795
|
+
* @returns {number}
|
|
796
|
+
*/
|
|
797
|
+
#needOf({ address, run }) {
|
|
798
|
+
const seconds = this.segmentSeconds;
|
|
799
|
+
if (!(seconds > 0)) return Number.POSITIVE_INFINITY;
|
|
800
|
+
const dueIn = deadlineReaderFor(this.#demand.mapOn(address), seconds);
|
|
801
|
+
let need = 0;
|
|
802
|
+
for (let index = run.head; index <= run.to; index += 1) {
|
|
803
|
+
const due = dueIn(index);
|
|
804
|
+
if (!Number.isFinite(due)) break;
|
|
805
|
+
if (due <= 0) return Number.POSITIVE_INFINITY;
|
|
806
|
+
need = Math.max(need, ((index - run.head + 1) * seconds) / due);
|
|
807
|
+
}
|
|
808
|
+
return need;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* @param {object} run
|
|
813
|
+
* @returns {number} Film seconds it has left to make; infinite with no end.
|
|
814
|
+
*/
|
|
815
|
+
#remainingFilm(run) {
|
|
816
|
+
return Number.isFinite(run.to) ? Math.max(0, run.to - run.head + 1) * this.segmentSeconds : Number.POSITIVE_INFINITY;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* @param {"freeCores" | "otherCores"} name
|
|
821
|
+
* @returns {number | null}
|
|
822
|
+
*/
|
|
823
|
+
#processorReading(name) {
|
|
824
|
+
const value = this.processor?.[name]?.();
|
|
825
|
+
return Number.isFinite(value) ? value : null;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Why a start at `from` on `address` would be paused the moment it exists,
|
|
830
|
+
* or null: behind the leader, and not let go by `PauseRule`.
|
|
831
|
+
*
|
|
832
|
+
* @param {string} address
|
|
833
|
+
* @param {number} from
|
|
834
|
+
* @returns {string | null}
|
|
835
|
+
*/
|
|
836
|
+
#holdReason(address, from) {
|
|
837
|
+
const leading = this.#leading;
|
|
838
|
+
if (!leading?.run || compareUrgency(this.#bandAt(address, from), leading) <= 0) return null;
|
|
839
|
+
const verdict = this.#pauseRule.wouldStart({
|
|
840
|
+
leader: leading.run, address, need: this.#needOf(leading), free: this.#processorReading("freeCores")
|
|
841
|
+
});
|
|
842
|
+
return verdict.runs ? null : verdict.because;
|
|
843
|
+
}
|
|
844
|
+
|
|
722
845
|
/**
|
|
723
846
|
* Plan again every output whose start was held back behind what is no
|
|
724
847
|
* longer more urgent than it: the start is placed now, by the same plan,
|
|
@@ -728,7 +851,7 @@ export class EncodeOrchestrator {
|
|
|
728
851
|
*/
|
|
729
852
|
#placeHeldBack(leading) {
|
|
730
853
|
for (const [address, band] of [...this.#heldBack]) {
|
|
731
|
-
if (leading && compareUrgency(band, leading) > 0) continue;
|
|
854
|
+
if (leading && compareUrgency(band, leading) > 0 && this.#holdReason(address, band.from) !== null) continue;
|
|
732
855
|
this.#heldBack.delete(address);
|
|
733
856
|
this.#reconcileOne(address);
|
|
734
857
|
}
|
|
@@ -824,7 +947,11 @@ export class EncodeOrchestrator {
|
|
|
824
947
|
// was measured it holds the largest reading rather than continuing a curve
|
|
825
948
|
// nothing observed.
|
|
826
949
|
contentionPenaltyFor: (others) => contentionPenalty(others, this.contentionPenalties).penalty,
|
|
827
|
-
speedX
|
|
950
|
+
speedX,
|
|
951
|
+
// A START THE PAUSE RULE WOULD PAUSE AT ONCE IS NOT MADE: the encoder
|
|
952
|
+
// would hold a process and its input for nothing. The plan returns it as
|
|
953
|
+
// `hold`, and it is placed when the rule lets it go (`#placeHeldBack`).
|
|
954
|
+
holdStart: (from) => this.#holdReason(address, from)
|
|
828
955
|
});
|
|
829
956
|
|
|
830
957
|
// A move is the plan taking a running encoder away from where it already
|
|
@@ -869,13 +996,10 @@ export class EncodeOrchestrator {
|
|
|
869
996
|
`live=${JSON.stringify(live.map((run) => ({ from: run.from, to: run.to, head: run.head, speedX: run.speedX })))}`
|
|
870
997
|
);
|
|
871
998
|
}
|
|
872
|
-
// A
|
|
873
|
-
// be paused the moment it exists (`shareTheMachine`), holding a process and
|
|
874
|
-
// its input for nothing. It is placed when nothing more urgent is in hand.
|
|
999
|
+
// A start held by the plan is placed when the pause rule lets it go.
|
|
875
1000
|
let heldBack = null;
|
|
876
1001
|
for (const action of actions) {
|
|
877
|
-
if (action.type === "
|
|
878
|
-
compareUrgency(this.#bandAt(address, action.from), this.#leading) > 0) {
|
|
1002
|
+
if (action.type === "hold") {
|
|
879
1003
|
heldBack = action;
|
|
880
1004
|
continue;
|
|
881
1005
|
}
|
|
@@ -946,7 +1070,7 @@ export class EncodeOrchestrator {
|
|
|
946
1070
|
// a 159 090-line file, 31 % of the log, all of one wedge.
|
|
947
1071
|
const wanting = firstUnmetWant(coverage, windows);
|
|
948
1072
|
const stillRunning = this.runsOn(address).filter((run) => run.isAlive);
|
|
949
|
-
if (heldBack) this.#heldBack.set(address, this.#bandAt(address, heldBack.from));
|
|
1073
|
+
if (heldBack) this.#heldBack.set(address, { ...this.#bandAt(address, heldBack.from), from: heldBack.from });
|
|
950
1074
|
else this.#heldBack.delete(address);
|
|
951
1075
|
if (wanting === null || stillRunning.length > 0) {
|
|
952
1076
|
this.#lastUnmet.delete(address);
|
|
@@ -956,7 +1080,7 @@ export class EncodeOrchestrator {
|
|
|
956
1080
|
this.#lastUnmet.set(address, said);
|
|
957
1081
|
this.logger.info(
|
|
958
1082
|
`encode: #${heldBack.from} of ${address} waits — #${this.#leading?.index} of ` +
|
|
959
|
-
`${this.#leading?.address} is wanted more urgently and is being made`
|
|
1083
|
+
`${this.#leading?.address} is wanted more urgently and is being made (${heldBack.because})`
|
|
960
1084
|
);
|
|
961
1085
|
}
|
|
962
1086
|
} else if (this.#lastUnmet.get(address) !== wanting) {
|
|
@@ -1184,6 +1308,7 @@ export class EncodeOrchestrator {
|
|
|
1184
1308
|
this.#lastProgress.set(ended.address, progress);
|
|
1185
1309
|
}
|
|
1186
1310
|
this.#costs.note(ended);
|
|
1311
|
+
if (ended.run) this.#pauseRule.forget(ended.run, ended.address);
|
|
1187
1312
|
// Exactly one ending is normal — the run reached the end of the stretch it
|
|
1188
1313
|
// was given and closed its last file. Every other leaves a piece open, and
|
|
1189
1314
|
// that piece is under this run's OWN working name, so clearing up after it
|
|
@@ -84,7 +84,8 @@
|
|
|
84
84
|
* @typedef {{ type: "start", from: number, to: number, because: string }
|
|
85
85
|
* | { type: "move", run: object, from: number, to: number, because: string }
|
|
86
86
|
* | { type: "stop", run: object, because: string }
|
|
87
|
-
* | { type: "keep", run: object, from: number, to: number }
|
|
87
|
+
* | { type: "keep", run: object, from: number, to: number }
|
|
88
|
+
* | { type: "hold", from: number, to: number, because: string }} PlanAction
|
|
88
89
|
*/
|
|
89
90
|
|
|
90
91
|
/**
|
|
@@ -116,6 +117,11 @@
|
|
|
116
117
|
* encoder per piece: at exactly realtime each next piece is marginally late
|
|
117
118
|
* however many are running, so another one always seemed to help a little.
|
|
118
119
|
* Unmeasured is 1, and then the budget is the only thing bounding the count.
|
|
120
|
+
* @param {(from: number) => string | null} [params.holdStart] - Why a start at
|
|
121
|
+
* `from` would be paused the moment it exists (`PauseRule`), or null. Such a
|
|
122
|
+
* start comes back as `hold` with that reason: the encoder would hold a
|
|
123
|
+
* process and its input for nothing, and one place decides a start
|
|
124
|
+
* (torrent-tv/meta#180).
|
|
119
125
|
* @returns {PlanAction[]} Stops first, then moves, then starts, so that a plan
|
|
120
126
|
* carried out in order never holds two encoders where it means to hold one.
|
|
121
127
|
*/
|
|
@@ -130,7 +136,8 @@ export function planEncoders({
|
|
|
130
136
|
now = Date.now(),
|
|
131
137
|
refetchSecPerFilmSecond = 0,
|
|
132
138
|
contentionPenaltyFor = () => 1,
|
|
133
|
-
speedX = 0
|
|
139
|
+
speedX = 0,
|
|
140
|
+
holdStart = () => null
|
|
134
141
|
}) {
|
|
135
142
|
/** @type {PlanAction[]} */
|
|
136
143
|
const stops = [];
|
|
@@ -686,7 +693,11 @@ export function planEncoders({
|
|
|
686
693
|
});
|
|
687
694
|
}
|
|
688
695
|
|
|
689
|
-
|
|
696
|
+
const startsOrHeld = starts.map((start) => {
|
|
697
|
+
const because = holdStart(start.from);
|
|
698
|
+
return because ? { type: "hold", from: start.from, to: start.to, because } : start;
|
|
699
|
+
});
|
|
700
|
+
return [...stops, ...moves, ...startsOrHeld, ...keeps];
|
|
690
701
|
}
|
|
691
702
|
|
|
692
703
|
|
|
@@ -1010,7 +1021,7 @@ function lastOf(windows) {
|
|
|
1010
1021
|
return Math.max(...windows.map((span) => span.to));
|
|
1011
1022
|
}
|
|
1012
1023
|
|
|
1013
|
-
function deadlineReaderFor(windows, segmentSeconds) {
|
|
1024
|
+
export function deadlineReaderFor(windows, segmentSeconds) {
|
|
1014
1025
|
const perSegment = segmentSeconds > 0 ? segmentSeconds : 0;
|
|
1015
1026
|
// A TABLE BY SEGMENT, filled once per plan. Read for every segment of every
|
|
1016
1027
|
// arrangement the plan weighs, a search of every window for each index was
|
|
@@ -454,6 +454,11 @@ export class EncodeRun {
|
|
|
454
454
|
this.clock.inputWaitEnds();
|
|
455
455
|
}
|
|
456
456
|
|
|
457
|
+
/** @returns {boolean} Whether an input read of this run is waiting for bytes now. */
|
|
458
|
+
get isWaitingForInput() {
|
|
459
|
+
return this.clock.waitingForInput;
|
|
460
|
+
}
|
|
461
|
+
|
|
457
462
|
/** @returns {boolean} Whether it is stopped where it stands, producing nothing. */
|
|
458
463
|
get isSuspended() {
|
|
459
464
|
return this.#state === ENCODE_RUN_STATE.SUSPENDED;
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Whether an encoder making something less urgent runs beside the most
|
|
3
|
+
* urgent one, from what running it was measured to do (torrent-tv/meta#180,
|
|
4
|
+
* phase 4).
|
|
5
|
+
*
|
|
6
|
+
* Until this, everything less urgent than the leading band was stopped,
|
|
7
|
+
* whatever the leader did with the processor and whatever it needed by its
|
|
8
|
+
* deadline (`EncodeOrchestrator.shareTheMachine`). On a machine where the
|
|
9
|
+
* leader does not take all the processor — libx264 thread scaling, a hardware
|
|
10
|
+
* encoder whose work is on the device, a leader ahead of its deadline — the
|
|
11
|
+
* paused work waited for nothing; and a one-piece soundtrack copy at 150x was
|
|
12
|
+
* paused behind a picture it could not have slowed (Home Assistant r73).
|
|
13
|
+
*
|
|
14
|
+
* THE RULE. `L` is the leading run, `r` a less urgent one.
|
|
15
|
+
*
|
|
16
|
+
* 1. `L`'s need `s*` is the speed its deadlines ask for (film seconds per
|
|
17
|
+
* second); infinite where a viewer already waits for what `L` is making.
|
|
18
|
+
* 2. `r` keeps running when, over the seconds it ran beside `L`, `L` still made
|
|
19
|
+
* at least `s*` (finite need), or neither `L`'s speed nor its cores fell
|
|
20
|
+
* below what they were without `r` by more than their own scatter (infinite
|
|
21
|
+
* need). Otherwise `r` is paused. Both are read: memory-bandwidth contention
|
|
22
|
+
* lowers the speed and not the cores, and on Home Assistant (r78, r79) a
|
|
23
|
+
* short run's speed scattered by as much as its median from second to second
|
|
24
|
+
* while its cores scattered by 0.02-0.2, so a slowdown is often visible in
|
|
25
|
+
* the cores first. No number of cores enters: a fraction of a core can be
|
|
26
|
+
* enough, and a free core does not cap what a new ffmpeg takes.
|
|
27
|
+
* 3. A paused `r` is tried when the processor left free (`F`, cores) is
|
|
28
|
+
* unknown or exceeds what `r` drew when it last ran, or when `L` makes more
|
|
29
|
+
* than `s*` by more than its scatter. With an infinite need it is tried only
|
|
30
|
+
* once `L` has seconds without it to be compared against.
|
|
31
|
+
* 4. A run whose whole remaining stretch costs less processor than `L`'s
|
|
32
|
+
* scatter over one second cannot be measured against `L` and is never
|
|
33
|
+
* paused.
|
|
34
|
+
*
|
|
35
|
+
* NO ALTERNATION UNDER UNCHANGED LOAD. A pause for a measured slowdown is
|
|
36
|
+
* remembered for the pair with what it was measured under: `L`'s output and
|
|
37
|
+
* band, the other runs running, and the processor other work took. `r` is not
|
|
38
|
+
* tried beside `L` again until one of these changes — the others' draw by more
|
|
39
|
+
* than `r`'s own — so under constant load there is at most one try and one
|
|
40
|
+
* pause.
|
|
41
|
+
*
|
|
42
|
+
* WITHOUT READINGS NOTHING CHANGES. Where `L` has no per-second readings (no
|
|
43
|
+
* `/proc` to read an ffmpeg's processor from), or `r` has run beside `L` since
|
|
44
|
+
* before anything could be compared, the answer is null and the caller keeps
|
|
45
|
+
* the urgency rule it had.
|
|
46
|
+
*
|
|
47
|
+
* Readings are the per-second ones of `storage/processor-report.js`: each
|
|
48
|
+
* run's cores and the film it made per second of wall clock. Seconds in which
|
|
49
|
+
* `L` waited for its input say nothing about `r` and are left out.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* How many seconds of readings a run keeps. Enough for a baseline before a try
|
|
54
|
+
* and the seconds beside after it; `L`'s cost changes from piece to piece, so
|
|
55
|
+
* older seconds describe other pieces.
|
|
56
|
+
*/
|
|
57
|
+
const KEPT_SECONDS = 30;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The fewest seconds a median and its scatter are taken from: with fewer,
|
|
61
|
+
* the scatter of one or two values says nothing.
|
|
62
|
+
*/
|
|
63
|
+
const LEAST_SECONDS = 3;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @typedef {object} RunSecond
|
|
67
|
+
* @property {number} at - When the second ended, in milliseconds.
|
|
68
|
+
* @property {number} cores - What the run's ffmpeg used over it.
|
|
69
|
+
* @property {number | null} speed - Film made per second of wall clock.
|
|
70
|
+
* @property {Set<object>} running - The runs running (not paused) beside it.
|
|
71
|
+
* @property {boolean} waiting - Whether its input waited at the end of it.
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @typedef {object} PairConditions
|
|
76
|
+
* @property {string} address - `L`'s output.
|
|
77
|
+
* @property {boolean} urgent - `L`'s band.
|
|
78
|
+
* @property {number} priority
|
|
79
|
+
* @property {Set<object>} running - The other runs running, `L` and `r` aside.
|
|
80
|
+
* @property {number | null} others - Cores other work took.
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
export class PauseRule {
|
|
84
|
+
/** @type {Map<object, RunSecond[]>} */
|
|
85
|
+
#seconds = new Map();
|
|
86
|
+
|
|
87
|
+
/** When the rule last let each run go. @type {Map<object, number>} */
|
|
88
|
+
#triedAt = new Map();
|
|
89
|
+
|
|
90
|
+
/** What the last run on each output drew, for the next run there. @type {Map<string, number>} */
|
|
91
|
+
#lastDrawOn = new Map();
|
|
92
|
+
|
|
93
|
+
/** Pauses for a measured slowdown, by leader, then by run. @type {Map<object, Map<object, PairConditions>>} */
|
|
94
|
+
#pairs = new Map();
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* One second of one run.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} run
|
|
100
|
+
* @param {{ at: number, cores: number, speed: number | null }} reading
|
|
101
|
+
* @param {Iterable<object>} running - Every run running at the time.
|
|
102
|
+
* @param {boolean} [waiting]
|
|
103
|
+
*/
|
|
104
|
+
noteSecond(run, { at, cores, speed }, running, waiting = false) {
|
|
105
|
+
if (!Number.isFinite(cores)) return;
|
|
106
|
+
const kept = this.#seconds.get(run) ?? [];
|
|
107
|
+
kept.push({ at, cores, speed: Number.isFinite(speed) ? speed : null, running: new Set(running), waiting: waiting === true });
|
|
108
|
+
while (kept.length > 0 && kept[0].at <= at - KEPT_SECONDS * 1000) kept.shift();
|
|
109
|
+
this.#seconds.set(run, kept);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A run that has ended: what it drew is kept for the next run on its output.
|
|
114
|
+
*
|
|
115
|
+
* @param {object} run
|
|
116
|
+
* @param {string} address
|
|
117
|
+
*/
|
|
118
|
+
forget(run, address) {
|
|
119
|
+
const draw = this.#drawOf(run);
|
|
120
|
+
if (draw !== null && typeof address === "string") this.#lastDrawOn.set(address, draw);
|
|
121
|
+
this.#seconds.delete(run);
|
|
122
|
+
this.#triedAt.delete(run);
|
|
123
|
+
this.#pairs.delete(run);
|
|
124
|
+
for (const pairs of this.#pairs.values()) pairs.delete(run);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Whether `run` runs beside `leader` now.
|
|
129
|
+
*
|
|
130
|
+
* @param {object} params
|
|
131
|
+
* @param {object} params.leader - The leading run.
|
|
132
|
+
* @param {{ address: string, urgent: boolean, priority: number }} params.band - Where it leads.
|
|
133
|
+
* @param {object} params.run - A run less urgent than it.
|
|
134
|
+
* @param {string} params.address - `run`'s output.
|
|
135
|
+
* @param {number} params.need - `L`'s `s*`: film seconds per second, `Infinity`
|
|
136
|
+
* where a viewer waits for what it is making, 0 where nothing is due.
|
|
137
|
+
* @param {number} params.remainingFilm - Film seconds `run` has left to make.
|
|
138
|
+
* @param {number | null} params.free - `F`: cores left free, or null.
|
|
139
|
+
* @param {number | null} params.others - Cores other work took, or null.
|
|
140
|
+
* @param {Iterable<object>} params.running - Every run running now.
|
|
141
|
+
* @param {number} params.now
|
|
142
|
+
* @returns {{ runs: boolean, because: string } | null} Null where nothing
|
|
143
|
+
* was measured to decide from.
|
|
144
|
+
*/
|
|
145
|
+
judge({ leader, band, run, address, need, remainingFilm, free, others, running, now }) {
|
|
146
|
+
const lead = (this.#seconds.get(leader) ?? []).filter((second) => !second.waiting && second.speed !== null);
|
|
147
|
+
if (lead.length === 0) return null;
|
|
148
|
+
const leadCores = middleAndScatter(lead.map((second) => second.cores));
|
|
149
|
+
const draw = this.#drawOf(run) ?? this.#lastDrawOn.get(address) ?? null;
|
|
150
|
+
const perFilm = this.#costPerFilm(run);
|
|
151
|
+
if (perFilm !== null && Number.isFinite(remainingFilm) && remainingFilm * perFilm < leadCores.scatter) {
|
|
152
|
+
return { runs: true, because: `its remaining ${remainingFilm.toFixed(1)}s of film cost ${(remainingFilm * perFilm).toFixed(3)} ` +
|
|
153
|
+
`CPU-s, less than the leader's scatter of ${leadCores.scatter.toFixed(3)} cores over one second` };
|
|
154
|
+
}
|
|
155
|
+
const without = lead.filter((second) => !second.running.has(run));
|
|
156
|
+
const otherRunning = new Set([...running].filter((other) => other !== leader && other !== run));
|
|
157
|
+
if (run.isSuspended === true) {
|
|
158
|
+
return this.#whetherToTry({ leader, band, run, need, lead, without, draw, free, others, otherRunning, now });
|
|
159
|
+
}
|
|
160
|
+
const since = this.#triedAt.get(run) ?? Number.NEGATIVE_INFINITY;
|
|
161
|
+
const beside = lead.filter((second) => second.running.has(run) && second.at > since);
|
|
162
|
+
if (beside.length < LEAST_SECONDS) {
|
|
163
|
+
// Let go by this rule and not measured yet: it runs until it is.
|
|
164
|
+
// Running since before anything could be compared: the urgency rule.
|
|
165
|
+
return this.#triedAt.has(run) ? { runs: true, because: `being measured beside the leader (${beside.length}s so far)` } : null;
|
|
166
|
+
}
|
|
167
|
+
const besideSpeed = middleAndScatter(beside.map((second) => second.speed));
|
|
168
|
+
if (Number.isFinite(need)) {
|
|
169
|
+
if (besideSpeed.middle >= need) {
|
|
170
|
+
return { runs: true, because: `the leader made ${besideSpeed.middle.toFixed(2)}x beside it over ${beside.length}s, ` +
|
|
171
|
+
`needing ${need.toFixed(2)}x` };
|
|
172
|
+
}
|
|
173
|
+
this.#remember(leader, run, { band, otherRunning, others });
|
|
174
|
+
return { runs: false, because: `the leader made ${besideSpeed.middle.toFixed(2)}x beside it over ${beside.length}s, ` +
|
|
175
|
+
`below the ${need.toFixed(2)}x its deadlines need` };
|
|
176
|
+
}
|
|
177
|
+
if (without.length < LEAST_SECONDS) {
|
|
178
|
+
return this.#triedAt.has(run) ? { runs: true, because: "nothing to compare the leader's speed with" } : null;
|
|
179
|
+
}
|
|
180
|
+
const alone = middleAndScatter(without.map((second) => second.speed));
|
|
181
|
+
const besideCores = middleAndScatter(beside.map((second) => second.cores));
|
|
182
|
+
const aloneCores = middleAndScatter(without.map((second) => second.cores));
|
|
183
|
+
const measured = `the leader made ${besideSpeed.middle.toFixed(2)}x on ${besideCores.middle.toFixed(2)} cores beside it ` +
|
|
184
|
+
`over ${beside.length}s, ${alone.middle.toFixed(2)}x (scatter ${alone.scatter.toFixed(2)}) on ` +
|
|
185
|
+
`${aloneCores.middle.toFixed(2)} cores (scatter ${aloneCores.scatter.toFixed(2)}) without it over ${without.length}s, ` +
|
|
186
|
+
"and a viewer waits for it";
|
|
187
|
+
if (alone.middle - besideSpeed.middle > alone.scatter || aloneCores.middle - besideCores.middle > aloneCores.scatter) {
|
|
188
|
+
this.#remember(leader, run, { band, otherRunning, others });
|
|
189
|
+
return { runs: false, because: measured };
|
|
190
|
+
}
|
|
191
|
+
return { runs: true, because: measured };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Whether a run not started yet, on `address` and less urgent than
|
|
196
|
+
* `leader`, would be let go at once: a start the rule would pause is not
|
|
197
|
+
* made (`EncodePlan` holds it). Without readings of the leader, it would.
|
|
198
|
+
*
|
|
199
|
+
* @param {object} params
|
|
200
|
+
* @param {object} params.leader
|
|
201
|
+
* @param {string} params.address
|
|
202
|
+
* @param {number} params.need
|
|
203
|
+
* @param {number | null} params.free
|
|
204
|
+
* @returns {{ runs: boolean, because: string }}
|
|
205
|
+
*/
|
|
206
|
+
wouldStart({ leader, address, need, free }) {
|
|
207
|
+
const lead = (this.#seconds.get(leader) ?? []).filter((second) => !second.waiting && second.speed !== null);
|
|
208
|
+
if (lead.length === 0) {
|
|
209
|
+
return { runs: false, because: "nothing has measured what the leader does" };
|
|
210
|
+
}
|
|
211
|
+
const draw = this.#lastDrawOn.get(address) ?? null;
|
|
212
|
+
return this.#tryCondition({ need, lead, without: lead, draw, free });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Whether a paused run is let go to be measured beside the leader.
|
|
217
|
+
*
|
|
218
|
+
* @param {object} params
|
|
219
|
+
* @returns {{ runs: boolean, because: string }}
|
|
220
|
+
*/
|
|
221
|
+
#whetherToTry({ leader, band, run, need, lead, without, draw, free, others, otherRunning, now }) {
|
|
222
|
+
const remembered = this.#pairs.get(leader)?.get(run);
|
|
223
|
+
const changed = remembered ? changeOf(remembered, { band, otherRunning, others, draw }) : null;
|
|
224
|
+
if (remembered) {
|
|
225
|
+
if (changed === null) {
|
|
226
|
+
return { runs: false, because: "it slowed the leader when last tried, and nothing it was tried under has changed" };
|
|
227
|
+
}
|
|
228
|
+
this.#pairs.get(leader)?.delete(run);
|
|
229
|
+
}
|
|
230
|
+
const verdict = this.#tryCondition({ need, lead, without, draw, free });
|
|
231
|
+
if (!verdict.runs) return verdict;
|
|
232
|
+
this.#triedAt.set(run, now);
|
|
233
|
+
return { runs: true, because: `tried beside the leader: ${verdict.because}` +
|
|
234
|
+
(remembered ? ` (${changed} changed since it was paused)` : "") };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Rule 3: the conditions under which a run not running is let go.
|
|
239
|
+
*
|
|
240
|
+
* @param {{ need: number, lead: RunSecond[], without: RunSecond[], draw: number | null, free: number | null }} params
|
|
241
|
+
* @returns {{ runs: boolean, because: string }}
|
|
242
|
+
*/
|
|
243
|
+
#tryCondition({ need, lead, without, draw, free }) {
|
|
244
|
+
if (!Number.isFinite(need) && without.length < LEAST_SECONDS) {
|
|
245
|
+
return { runs: false, because: `a viewer waits for the leader, and it has run ${without.length}s without this one ` +
|
|
246
|
+
"to be compared with" };
|
|
247
|
+
}
|
|
248
|
+
const leadSpeed = middleAndScatter(lead.map((second) => second.speed));
|
|
249
|
+
if (free === null || !Number.isFinite(free)) {
|
|
250
|
+
return { runs: true, because: "the processor left free is unknown" };
|
|
251
|
+
}
|
|
252
|
+
if (free > (draw ?? 0)) {
|
|
253
|
+
return { runs: true, because: `${free.toFixed(2)} cores are free, more than the ` +
|
|
254
|
+
`${draw === null ? "unmeasured draw" : `${draw.toFixed(2)} cores`} it draws` };
|
|
255
|
+
}
|
|
256
|
+
if (Number.isFinite(need) && leadSpeed.middle - need > leadSpeed.scatter) {
|
|
257
|
+
return { runs: true, because: `the leader makes ${leadSpeed.middle.toFixed(2)}x, needing ${need.toFixed(2)}x` };
|
|
258
|
+
}
|
|
259
|
+
return { runs: false, because: `${free.toFixed(2)} cores are free, not more than the ` +
|
|
260
|
+
`${draw === null ? "?" : draw.toFixed(2)} cores it draws, and the leader makes ${leadSpeed.middle.toFixed(2)}x` +
|
|
261
|
+
(Number.isFinite(need) ? `, needing ${need.toFixed(2)}x` : " with a viewer waiting") };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* @param {object} leader
|
|
266
|
+
* @param {object} run
|
|
267
|
+
* @param {{ band: { address: string, urgent: boolean, priority: number }, otherRunning: Set<object>, others: number | null }} under
|
|
268
|
+
*/
|
|
269
|
+
#remember(leader, run, { band, otherRunning, others }) {
|
|
270
|
+
const pairs = this.#pairs.get(leader) ?? new Map();
|
|
271
|
+
pairs.set(run, { address: band.address, urgent: band.urgent === true, priority: band.priority, running: otherRunning,
|
|
272
|
+
others: Number.isFinite(others) ? others : null });
|
|
273
|
+
this.#pairs.set(leader, pairs);
|
|
274
|
+
this.#triedAt.delete(run);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The cores a run drew while it ran: the median of its seconds.
|
|
279
|
+
*
|
|
280
|
+
* @param {object} run
|
|
281
|
+
* @returns {number | null}
|
|
282
|
+
*/
|
|
283
|
+
#drawOf(run) {
|
|
284
|
+
const seconds = this.#seconds.get(run) ?? [];
|
|
285
|
+
return seconds.length === 0 ? null : middleAndScatter(seconds.map((second) => second.cores)).middle;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* CPU seconds per second of film, over the seconds in which it made film.
|
|
290
|
+
*
|
|
291
|
+
* @param {object} run
|
|
292
|
+
* @returns {number | null}
|
|
293
|
+
*/
|
|
294
|
+
#costPerFilm(run) {
|
|
295
|
+
const costs = (this.#seconds.get(run) ?? []).filter((second) => second.speed > 0).map((second) => second.cores / second.speed);
|
|
296
|
+
return costs.length === 0 ? null : middleAndScatter(costs).middle;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* What has changed since a pause for a slowdown, or null.
|
|
302
|
+
*
|
|
303
|
+
* @param {PairConditions} remembered
|
|
304
|
+
* @param {{ band: { address: string, urgent: boolean, priority: number }, otherRunning: Set<object>, others: number | null, draw: number | null }} now
|
|
305
|
+
* @returns {string | null}
|
|
306
|
+
*/
|
|
307
|
+
function changeOf(remembered, { band, otherRunning, others, draw }) {
|
|
308
|
+
if (remembered.address !== band.address || remembered.urgent !== (band.urgent === true) || remembered.priority !== band.priority) {
|
|
309
|
+
return "the leader's band";
|
|
310
|
+
}
|
|
311
|
+
if (remembered.running.size !== otherRunning.size || [...otherRunning].some((other) => !remembered.running.has(other))) {
|
|
312
|
+
return "the runs running";
|
|
313
|
+
}
|
|
314
|
+
if (remembered.others !== null && Number.isFinite(others) && draw !== null && remembered.others - others > draw) {
|
|
315
|
+
return "the processor other work takes";
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The median and the median distance to it.
|
|
322
|
+
*
|
|
323
|
+
* @param {number[]} values
|
|
324
|
+
* @returns {{ middle: number, scatter: number }}
|
|
325
|
+
*/
|
|
326
|
+
function middleAndScatter(values) {
|
|
327
|
+
const median = (list) => {
|
|
328
|
+
const sorted = [...list].sort((left, right) => left - right);
|
|
329
|
+
const half = Math.floor(sorted.length / 2);
|
|
330
|
+
return sorted.length % 2 === 0 ? (sorted[half - 1] + sorted[half]) / 2 : sorted[half];
|
|
331
|
+
};
|
|
332
|
+
const middle = median(values);
|
|
333
|
+
return { middle, scatter: median(values.map((value) => Math.abs(value - middle))) };
|
|
334
|
+
}
|
|
@@ -23,7 +23,7 @@ import { correctForAvailability, leftForEncoders } from "./available-share.js";
|
|
|
23
23
|
import { contentionPenalty } from "./contention.js";
|
|
24
24
|
|
|
25
25
|
export class ProcessorBudget {
|
|
26
|
-
/** @type {() => ({ share: number, forEncoders?: number, known: boolean } | null | undefined)} */
|
|
26
|
+
/** @type {() => ({ share: number, forEncoders?: number, known: boolean, freeCores?: number | null, otherCores?: number | null } | null | undefined)} */
|
|
27
27
|
#reading;
|
|
28
28
|
|
|
29
29
|
/** @type {import("./contention.js").ContentionPenalties | null} */
|
|
@@ -74,6 +74,27 @@ export class ProcessorBudget {
|
|
|
74
74
|
return correctForAvailability(aloneSpeed, this.availability());
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The processor left free over the latest reading, in cores, or null where
|
|
79
|
+
* it was not read (torrent-tv/meta#180, phase 4).
|
|
80
|
+
*
|
|
81
|
+
* @returns {number | null}
|
|
82
|
+
*/
|
|
83
|
+
freeCores() {
|
|
84
|
+
const free = this.availability()?.freeCores;
|
|
85
|
+
return Number.isFinite(free) ? free : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The processor other work took over the latest reading, in cores, or null.
|
|
90
|
+
*
|
|
91
|
+
* @returns {number | null}
|
|
92
|
+
*/
|
|
93
|
+
otherCores() {
|
|
94
|
+
const others = this.availability()?.otherCores;
|
|
95
|
+
return Number.isFinite(others) ? others : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
77
98
|
/**
|
|
78
99
|
* The measured penalties, as the plan and the runs read them.
|
|
79
100
|
*
|
|
@@ -91,6 +91,16 @@ export class RunClock {
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Whether an input read of this run is waiting for bytes now, whether or
|
|
96
|
+
* not the process is also stopped.
|
|
97
|
+
*
|
|
98
|
+
* @returns {boolean}
|
|
99
|
+
*/
|
|
100
|
+
get waitingForInput() {
|
|
101
|
+
return this.#idleCauses - (this.#isStopped ? 1 : 0) > 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
94
104
|
/**
|
|
95
105
|
* Milliseconds the process has been stopped so far.
|
|
96
106
|
*
|
|
@@ -41,6 +41,19 @@
|
|
|
41
41
|
* `share`, what other processes leave, stays for the download's share of the
|
|
42
42
|
* proxy's processor (`processor-share.js`), which divides the proxy's room
|
|
43
43
|
* between its own download and its encoders and must not lose that room twice.
|
|
44
|
+
*
|
|
45
|
+
* THE WHOLE MACHINE IS WHAT THIS PROCESS MAY USE (torrent-tv/meta#180, phase
|
|
46
|
+
* 3). `/proc/stat` and `os.cpus()` describe the host; a container's CPU quota
|
|
47
|
+
* (`cpu.max`, Docker `--cpus`) or a CPU set (`--cpuset-cpus`) gives the proxy
|
|
48
|
+
* fewer cores than that. Where such a bound is smaller than the host, both
|
|
49
|
+
* shares are fractions of the bound instead: the encoders get what the host's
|
|
50
|
+
* other work leaves, but never more than the bound less the proxy's own draw.
|
|
51
|
+
* Measured as fractions of the host, a 1.5-core quota on an 8-core host read
|
|
52
|
+
* a busy neighbour as lost room the quota never had, and the proxy's own draw
|
|
53
|
+
* as an eighth of what it takes from the quota. The startup measurements are
|
|
54
|
+
* divided by the same fraction (`host-load.js` `freeShareDuring`), so a cost
|
|
55
|
+
* filed from either stays in one unit. Without a bound smaller than the host,
|
|
56
|
+
* nothing changes.
|
|
44
57
|
*/
|
|
45
58
|
|
|
46
59
|
/**
|
|
@@ -52,10 +65,17 @@
|
|
|
52
65
|
* @param {number | null} reading.encoderShare - Our own ffmpeg processes.
|
|
53
66
|
* @param {number | null} reading.proxyShare - The proxy process itself, every
|
|
54
67
|
* thread of it.
|
|
55
|
-
* @
|
|
68
|
+
* @param {number | null} [reading.hostCores] - The cores the three shares
|
|
69
|
+
* above are fractions of.
|
|
70
|
+
* @param {number | null} [reading.boundCores] - The cores this process may use
|
|
71
|
+
* (`storage/machine-processor.js` `processorBound`); counts only when
|
|
72
|
+
* smaller than `hostCores`.
|
|
73
|
+
* @returns {{ share: number, forEncoders: number, unattributed: number, known: boolean, boundCores?: number }}
|
|
56
74
|
* `share` is what other processes leave; `forEncoders` what they and the
|
|
57
|
-
* proxy leave
|
|
58
|
-
*
|
|
75
|
+
* proxy leave; both of `boundCores` where it is given, else of the whole
|
|
76
|
+
* host. `unattributed` stays a fraction of the host. `known` is false when
|
|
77
|
+
* the host does not report its own load — then both are 1 and the caller
|
|
78
|
+
* must say it is uncorrected rather than pretend.
|
|
59
79
|
*/
|
|
60
80
|
export function availableShareFrom(reading = {}) {
|
|
61
81
|
const systemBusy = finite(reading.systemBusy);
|
|
@@ -65,11 +85,26 @@ export function availableShareFrom(reading = {}) {
|
|
|
65
85
|
return { share: 1, forEncoders: 1, unattributed: 0, known: false };
|
|
66
86
|
}
|
|
67
87
|
const encoders = finite(reading.encoderShare) ?? 0;
|
|
68
|
-
const
|
|
88
|
+
const proxy = finite(reading.proxyShare) ?? 0;
|
|
89
|
+
const ours = encoders + proxy;
|
|
69
90
|
// Rounding, and the two readings being taken microseconds apart, can put our
|
|
70
91
|
// own share fractionally above the system total. Below zero is not a
|
|
71
92
|
// measurement of anything.
|
|
72
93
|
const unattributed = clamp(systemBusy - ours, 0, 1);
|
|
94
|
+
const hostCores = finite(reading.hostCores);
|
|
95
|
+
const boundCores = finite(reading.boundCores);
|
|
96
|
+
if (hostCores !== null && boundCores !== null && boundCores > 0 && boundCores < hostCores) {
|
|
97
|
+
// In cores: the host's other work takes from the host, the proxy's own
|
|
98
|
+
// draw from the bound as well.
|
|
99
|
+
const leftOnHost = hostCores - clamp(systemBusy - encoders, 0, 1) * hostCores;
|
|
100
|
+
return {
|
|
101
|
+
share: clamp(Math.min(boundCores, hostCores - unattributed * hostCores) / boundCores, 0, 1),
|
|
102
|
+
forEncoders: clamp(Math.min(boundCores - proxy * hostCores, leftOnHost) / boundCores, 0, 1),
|
|
103
|
+
unattributed,
|
|
104
|
+
known: true,
|
|
105
|
+
boundCores
|
|
106
|
+
};
|
|
107
|
+
}
|
|
73
108
|
return { share: clamp(1 - unattributed, 0, 1), forEncoders: clamp(1 - (systemBusy - encoders), 0, 1),
|
|
74
109
|
unattributed, known: true };
|
|
75
110
|
}
|
|
@@ -185,6 +185,8 @@ async function checkModeProducesCorrectSegments({ ffmpegBin, encoder, rung, segm
|
|
|
185
185
|
* @param {{ info: (m: string) => void, warn: (m: string) => void }} [params.logger]
|
|
186
186
|
* @param {number} [params.segmentDurationSec]
|
|
187
187
|
* @param {Array<{ width: number, height: number }>} [params.frames]
|
|
188
|
+
* @param {number | null} [params.processorCores] - The cores this process may
|
|
189
|
+
* use, which the readings' share of the machine is of (`measureEncodeSlope`).
|
|
188
190
|
* @param {object} [params.measure] - Replaces the three readings, for a check
|
|
189
191
|
* that must not run ffmpeg: `check`, `rawFrames`, `speed`. `speed` answers a
|
|
190
192
|
* number, or `{ speed, freeShare }` as `measureEncodeSlope` does.
|
|
@@ -196,12 +198,13 @@ export async function calibrateEncoder({
|
|
|
196
198
|
logger,
|
|
197
199
|
segmentDurationSec = 4,
|
|
198
200
|
frames = CALIBRATION_FRAMES,
|
|
201
|
+
processorCores = null,
|
|
199
202
|
measure = null
|
|
200
203
|
}) {
|
|
201
204
|
const log = logger ?? { info: () => {}, warn: () => {} };
|
|
202
205
|
const check = measure?.check ?? checkModeProducesCorrectSegments;
|
|
203
206
|
const rawFrames = measure?.rawFrames ?? ((frame) => decodeToRawFrames(ffmpegBin, log, frame));
|
|
204
|
-
const speed = measure?.speed ?? ((rung, rawPath, frame) => measureEncodeSlope(ffmpegBin, encoder, rung, rawPath, frame));
|
|
207
|
+
const speed = measure?.speed ?? ((rung, rawPath, frame) => measureEncodeSlope(ffmpegBin, encoder, rung, rawPath, frame, processorCores));
|
|
205
208
|
const release = measure?.release ?? ((rawPath) => {
|
|
206
209
|
try {
|
|
207
210
|
rmSync(path.dirname(rawPath), { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
* 2. the libx264 build — the version string x264 writes into every stream it
|
|
23
23
|
* produces (`x264 - core N rNNNN hash`), read from one encoded frame;
|
|
24
24
|
* 3. the processor model and the thread count the encoder is given
|
|
25
|
-
* (`CPU_THREADS`, which is the core count)
|
|
25
|
+
* (`CPU_THREADS`, which is the core count), and the cores this process may
|
|
26
|
+
* use where a container's CPU quota or CPU set makes them fewer: a speed
|
|
27
|
+
* filed as a share of those cores (`available-share.js`) does not hold
|
|
28
|
+
* under another quota (torrent-tv/meta#180);
|
|
26
29
|
* 4. for a device-backed encoder, the device model and its driver: NVIDIA's
|
|
27
30
|
* own tool where it exists, and otherwise what the kernel publishes under
|
|
28
31
|
* `/sys` for the render node or the video device.
|
|
@@ -116,7 +119,9 @@ export function configurationKeyOf(fingerprint, encoder) {
|
|
|
116
119
|
...common,
|
|
117
120
|
`x264=${fingerprint?.x264 ?? UNKNOWN}`,
|
|
118
121
|
`cpu=${fingerprint?.cpu ?? UNKNOWN}`,
|
|
119
|
-
`threads=${fingerprint?.threads ?? UNKNOWN}
|
|
122
|
+
`threads=${fingerprint?.threads ?? UNKNOWN}`,
|
|
123
|
+
// Only where it binds, so a host without a quota keeps its key.
|
|
124
|
+
...(Number.isFinite(fingerprint?.boundCores) ? [`bound=${fingerprint.boundCores}`] : [])
|
|
120
125
|
].join(";");
|
|
121
126
|
}
|
|
122
127
|
const device = fingerprint?.devices?.[encoder?.kind] ?? null;
|
|
@@ -137,6 +142,8 @@ export function configurationKeyOf(fingerprint, encoder) {
|
|
|
137
142
|
* @property {string} x264
|
|
138
143
|
* @property {string} cpu
|
|
139
144
|
* @property {number} threads
|
|
145
|
+
* @property {number | null} [boundCores] - The cores this process may use,
|
|
146
|
+
* where fewer than the host's; absent or null otherwise.
|
|
140
147
|
* @property {Record<string, DeviceFingerprint>} devices - By encoder kind.
|
|
141
148
|
*/
|
|
142
149
|
|
|
@@ -288,10 +295,10 @@ async function nvidiaDevice() {
|
|
|
288
295
|
* Read this host's fingerprint. Always resolves: an unreadable part is
|
|
289
296
|
* `unknown`, never a refusal to start.
|
|
290
297
|
*
|
|
291
|
-
* @param {{ ffmpegBin: string, encoder?: { kind: string, device: string | null } | null }} params
|
|
298
|
+
* @param {{ ffmpegBin: string, encoder?: { kind: string, device: string | null } | null, processorCores?: number | null }} params
|
|
292
299
|
* @returns {Promise<HostFingerprint>}
|
|
293
300
|
*/
|
|
294
|
-
export async function readHostFingerprint({ ffmpegBin, encoder = null }) {
|
|
301
|
+
export async function readHostFingerprint({ ffmpegBin, encoder = null, processorCores = null }) {
|
|
295
302
|
const versionText = await capture(ffmpegBin, ["-hide_banner", "-version"], 5000);
|
|
296
303
|
// `-hide_banner` does not hide the version line itself: it is the answer.
|
|
297
304
|
const ffmpeg = parseFfmpegVersion(versionText ? versionText.toString("utf8") : "");
|
|
@@ -317,6 +324,7 @@ export async function readHostFingerprint({ ffmpegBin, encoder = null }) {
|
|
|
317
324
|
x264,
|
|
318
325
|
cpu: os.cpus()[0]?.model?.trim() || UNKNOWN,
|
|
319
326
|
threads: CPU_THREADS,
|
|
327
|
+
boundCores: Number.isFinite(processorCores) && processorCores < os.cpus().length ? processorCores : null,
|
|
320
328
|
devices
|
|
321
329
|
};
|
|
322
330
|
}
|
|
@@ -189,14 +189,14 @@ export function shareOfMachine(before, after, cores = os.cpus().length) {
|
|
|
189
189
|
* One reading of everything above, to be compared against the next.
|
|
190
190
|
*
|
|
191
191
|
* @param {number | null} pid - The encoder's process, when one is running.
|
|
192
|
-
* @returns {Promise<{ takenAt: number, processCpuSeconds: number | null, system: CpuTotals | null }>}
|
|
192
|
+
* @returns {Promise<{ takenAt: number, processCpuSeconds: number | null, system: CpuTotals | null, proxyCpuSeconds: number }>}
|
|
193
193
|
*/
|
|
194
194
|
export async function sampleHost(pid) {
|
|
195
195
|
const [processCpuSeconds, system] = await Promise.all([
|
|
196
196
|
pid === null ? Promise.resolve(null) : readProcessCpuSeconds(pid),
|
|
197
197
|
readSystemCpu()
|
|
198
198
|
]);
|
|
199
|
-
return { takenAt: Date.now(), processCpuSeconds, system };
|
|
199
|
+
return { takenAt: Date.now(), processCpuSeconds, system, proxyCpuSeconds: readProxyCpuSeconds() };
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
/**
|
|
@@ -215,12 +215,22 @@ export async function sampleHost(pid) {
|
|
|
215
215
|
* Null when the host does not report its load (no `/proc`), or a reading was
|
|
216
216
|
* refused; the measurement is then used as it was read, and says so.
|
|
217
217
|
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
218
|
+
* Where this process may use fewer cores than the host has (a container's CPU
|
|
219
|
+
* quota or CPU set, `boundCores`), the share is of those cores, as the share a
|
|
220
|
+
* running encoder is filed with (`available-share.js`): what the host's other
|
|
221
|
+
* work leaves, but never more than the bound less this process's own draw.
|
|
222
|
+
* Otherwise the measurement, which ran inside the bound, was divided by a
|
|
223
|
+
* share of the whole host, and a busy neighbour made it read faster than the
|
|
224
|
+
* bound allows (torrent-tv/meta#180, phase 3).
|
|
225
|
+
*
|
|
226
|
+
* @param {{ takenAt: number, processCpuSeconds: number | null, system: object | null, proxyCpuSeconds?: number | null } | null} before
|
|
227
|
+
* @param {{ takenAt: number, processCpuSeconds: number | null, system: object | null, proxyCpuSeconds?: number | null } | null} after
|
|
220
228
|
* @param {number} [cores]
|
|
229
|
+
* @param {number | null} [boundCores] - The cores this process may use; counts
|
|
230
|
+
* only when fewer than `cores`.
|
|
221
231
|
* @returns {number | null} Between 0 and 1.
|
|
222
232
|
*/
|
|
223
|
-
export function freeShareDuring(before, after, cores = os.cpus().length) {
|
|
233
|
+
export function freeShareDuring(before, after, cores = os.cpus().length, boundCores = null) {
|
|
224
234
|
if (!before || !after) {
|
|
225
235
|
return null;
|
|
226
236
|
}
|
|
@@ -229,10 +239,28 @@ export function freeShareDuring(before, after, cores = os.cpus().length) {
|
|
|
229
239
|
return null;
|
|
230
240
|
}
|
|
231
241
|
const others = Math.min(1, Math.max(0, shares.systemShare - shares.processShare));
|
|
232
|
-
const free =
|
|
242
|
+
const free = Number.isFinite(boundCores) && boundCores > 0 && boundCores < cores
|
|
243
|
+
? Math.min(1, (cores - others * cores) / boundCores, (boundCores - proxyCoresBetween(before, after)) / boundCores)
|
|
244
|
+
: 1 - others;
|
|
233
245
|
return free > 0 ? free : null;
|
|
234
246
|
}
|
|
235
247
|
|
|
248
|
+
/**
|
|
249
|
+
* The cores this process drew between two readings; 0 where either has no
|
|
250
|
+
* reading of it.
|
|
251
|
+
*
|
|
252
|
+
* @param {{ takenAt: number, proxyCpuSeconds?: number | null }} before
|
|
253
|
+
* @param {{ takenAt: number, proxyCpuSeconds?: number | null }} after
|
|
254
|
+
* @returns {number}
|
|
255
|
+
*/
|
|
256
|
+
function proxyCoresBetween(before, after) {
|
|
257
|
+
const elapsedSec = (after.takenAt - before.takenAt) / 1000;
|
|
258
|
+
if (!(elapsedSec > 0) || !Number.isFinite(before.proxyCpuSeconds) || !Number.isFinite(after.proxyCpuSeconds)) {
|
|
259
|
+
return 0;
|
|
260
|
+
}
|
|
261
|
+
return Math.max(0, (after.proxyCpuSeconds - before.proxyCpuSeconds) / elapsedSec);
|
|
262
|
+
}
|
|
263
|
+
|
|
236
264
|
/**
|
|
237
265
|
* How much CPU THIS process has used, across every thread it owns.
|
|
238
266
|
*
|
|
@@ -679,13 +679,13 @@ export async function benchmarkContention({ ffmpegBin, logger, clipsDir = CALIBR
|
|
|
679
679
|
return penalties;
|
|
680
680
|
}
|
|
681
681
|
|
|
682
|
-
export async function benchmarkDecodeCost({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR }) {
|
|
682
|
+
export async function benchmarkDecodeCost({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR, processorCores = null }) {
|
|
683
683
|
const log = logger ?? { info: () => {}, warn: () => {} };
|
|
684
684
|
const startedAllAt = Date.now();
|
|
685
685
|
/** @type {Record<string, { pixelTerm: number, bitrateTerm: number, constantTerm: number }>} */
|
|
686
686
|
const families = {};
|
|
687
687
|
for (const [family, clips] of Object.entries(CALIBRATION_SETS)) {
|
|
688
|
-
const fitted = await fitOneFamily({ ffmpegBin, log, clipsDir, family, clips });
|
|
688
|
+
const fitted = await fitOneFamily({ ffmpegBin, log, clipsDir, family, clips, processorCores });
|
|
689
689
|
if (fitted) {
|
|
690
690
|
families[family] = fitted;
|
|
691
691
|
}
|
|
@@ -721,10 +721,10 @@ export async function benchmarkDecodeCost({ ffmpegBin, logger, clipsDir = CALIBR
|
|
|
721
721
|
/**
|
|
722
722
|
* Fit one codec family's decode cost from its own clips.
|
|
723
723
|
*
|
|
724
|
-
* @param {{ ffmpegBin: string, log: { info: Function, warn: Function }, clipsDir: string, family: string, clips: string[] }} params
|
|
724
|
+
* @param {{ ffmpegBin: string, log: { info: Function, warn: Function }, clipsDir: string, family: string, clips: string[], processorCores?: number | null }} params
|
|
725
725
|
* @returns {Promise<{ pixelTerm: number, bitrateTerm: number, constantTerm: number } | null>}
|
|
726
726
|
*/
|
|
727
|
-
async function fitOneFamily({ ffmpegBin, log, clipsDir, family, clips }) {
|
|
727
|
+
async function fitOneFamily({ ffmpegBin, log, clipsDir, family, clips, processorCores = null }) {
|
|
728
728
|
const startedAllAt = Date.now();
|
|
729
729
|
/** @type {Array<{ megapixelsPerSecond: number, megabitsPerSecond: number, costSecondsPerSecond: number }>} */
|
|
730
730
|
const samples = [];
|
|
@@ -745,7 +745,7 @@ async function fitOneFamily({ ffmpegBin, log, clipsDir, family, clips }) {
|
|
|
745
745
|
}
|
|
746
746
|
for (const [index, clip] of clips.entries()) {
|
|
747
747
|
const stream = streams[index];
|
|
748
|
-
const measured = stream ? await decodePipedStream(ffmpegBin, stream, log) : null;
|
|
748
|
+
const measured = stream ? await decodePipedStream(ffmpegBin, stream, log, processorCores) : null;
|
|
749
749
|
if (!measured?.speed) {
|
|
750
750
|
log.warn(
|
|
751
751
|
`hwaccel: decode benchmark "${clip}" said nothing; ${family} not measured` +
|
|
@@ -998,9 +998,11 @@ function runCapturingStderr(ffmpegBin, args, timeoutMs) {
|
|
|
998
998
|
*
|
|
999
999
|
* @param {string} ffmpegBin
|
|
1000
1000
|
* @param {{ bytes: Buffer, demuxer: string, megapixelsPerSecond: number, megabitsPerSecond: number, fps: number }} stream
|
|
1001
|
+
* @param {{ info: Function, warn: Function }} [log]
|
|
1002
|
+
* @param {number | null} [processorCores] - The cores this process may use, for `freeShareDuring`.
|
|
1001
1003
|
* @returns {Promise<{ speed: number, windowSec: number, megapixelsPerSecond: number, megabitsPerSecond: number } | null>}
|
|
1002
1004
|
*/
|
|
1003
|
-
function decodePipedStream(ffmpegBin, stream, log = { info: () => {}, warn: () => {} }) {
|
|
1005
|
+
function decodePipedStream(ffmpegBin, stream, log = { info: () => {}, warn: () => {} }, processorCores = null) {
|
|
1004
1006
|
return new Promise((resolve) => {
|
|
1005
1007
|
const args = [
|
|
1006
1008
|
"-hide_banner", "-loglevel", "error", "-nostats",
|
|
@@ -1047,7 +1049,7 @@ function decodePipedStream(ffmpegBin, stream, log = { info: () => {}, warn: () =
|
|
|
1047
1049
|
const hostAfter = hostBefore !== null && child?.pid && child.exitCode === null
|
|
1048
1050
|
? await sampleHost(child.pid).catch(() => null)
|
|
1049
1051
|
: null;
|
|
1050
|
-
const freeShare = freeShareDuring(hostBefore === null ? null : await hostBefore, hostAfter);
|
|
1052
|
+
const freeShare = freeShareDuring(hostBefore === null ? null : await hostBefore, hostAfter, undefined, processorCores);
|
|
1051
1053
|
try {
|
|
1052
1054
|
child?.stdin?.destroy();
|
|
1053
1055
|
} catch {
|
|
@@ -1418,9 +1420,13 @@ export function slopeOf(samples, minimumWindowSec = ENCODE_BENCHMARK_WINDOW_SEC)
|
|
|
1418
1420
|
* A hardware encoder is limited by its device and not by the cores, so its
|
|
1419
1421
|
* reading is kept as read.
|
|
1420
1422
|
*
|
|
1423
|
+
* `processorCores` is the cores this process may use (a container's quota or
|
|
1424
|
+
* CPU set); the share divided out is of those where they are fewer than the
|
|
1425
|
+
* host's (`freeShareDuring`, torrent-tv/meta#180).
|
|
1426
|
+
*
|
|
1421
1427
|
* @returns {Promise<{ speed: number, measuredSpeed: number, freeShare: number | null } | null>}
|
|
1422
1428
|
*/
|
|
1423
|
-
export function measureEncodeSlope(ffmpegBin, encoder, rung, rawFramesPath, frame = { width: BENCHMARK_REF_W, height: BENCHMARK_REF_H }) {
|
|
1429
|
+
export function measureEncodeSlope(ffmpegBin, encoder, rung, rawFramesPath, frame = { width: BENCHMARK_REF_W, height: BENCHMARK_REF_H }, processorCores = null) {
|
|
1424
1430
|
const cpuBound = encoder?.kind === "software";
|
|
1425
1431
|
return new Promise((resolve) => {
|
|
1426
1432
|
const args = [
|
|
@@ -1470,7 +1476,7 @@ export function measureEncodeSlope(ffmpegBin, encoder, rung, rawFramesPath, fram
|
|
|
1470
1476
|
resolve(null);
|
|
1471
1477
|
return;
|
|
1472
1478
|
}
|
|
1473
|
-
const freeShare = freeShareDuring(before, after);
|
|
1479
|
+
const freeShare = freeShareDuring(before, after, undefined, processorCores);
|
|
1474
1480
|
resolve({ speed: freeShare === null ? value : value / freeShare, measuredSpeed: value, freeShare });
|
|
1475
1481
|
});
|
|
1476
1482
|
};
|
|
@@ -593,6 +593,7 @@ export class HostLoad {
|
|
|
593
593
|
// What the offer will multiply a prediction by, in the same line as the
|
|
594
594
|
// readings it comes from.
|
|
595
595
|
` available=${asPercent(this.hostAvailability.share)} for-encoders=${asPercent(this.hostAvailability.forEncoders)}` +
|
|
596
|
+
(Number.isFinite(this.hostAvailability.boundCores) ? ` of ${this.hostAvailability.boundCores.toFixed(2)} cores` : "") +
|
|
596
597
|
` free=${asPercent(free)}` + processor + late
|
|
597
598
|
);
|
|
598
599
|
}
|
|
@@ -664,7 +665,7 @@ export class HostLoad {
|
|
|
664
665
|
/**
|
|
665
666
|
* @param {{ elapsedSec: number, processShare: number | null, systemShare: number | null, rejected?: string[] } | null} share
|
|
666
667
|
* @param {{ proxyCpuSeconds: number | null }} previous
|
|
667
|
-
* @param {{ proxyCpuSeconds: number | null }} sample
|
|
668
|
+
* @param {{ proxyCpuSeconds: number | null, processor?: { bound?: { cores: number } | null } | null }} sample
|
|
668
669
|
* @returns {number | null} The proxy's own share of the machine, for the log.
|
|
669
670
|
*/
|
|
670
671
|
#setAvailability(share, previous, sample) {
|
|
@@ -682,11 +683,27 @@ export class HostLoad {
|
|
|
682
683
|
const proxyShare = Number.isFinite(previous.proxyCpuSeconds)
|
|
683
684
|
? (sample.proxyCpuSeconds - previous.proxyCpuSeconds) / (share.elapsedSec * cores)
|
|
684
685
|
: null;
|
|
685
|
-
this
|
|
686
|
+
// Of the cores this process may use where that is fewer than the host's
|
|
687
|
+
// (torrent-tv/meta#180, phase 3).
|
|
688
|
+
const availability = availableShareFrom({
|
|
686
689
|
systemBusy: share.systemShare,
|
|
687
690
|
encoderShare: share.processShare,
|
|
688
|
-
proxyShare
|
|
691
|
+
proxyShare,
|
|
692
|
+
hostCores: cores,
|
|
693
|
+
boundCores: sample.processor?.bound?.cores ?? null
|
|
689
694
|
});
|
|
695
|
+
// The processor left free and what other work takes, in cores, for the
|
|
696
|
+
// pause rule (torrent-tv/meta#180, phase 4). Unknown stays null.
|
|
697
|
+
const ourShare = Number.isFinite(proxyShare) && Number.isFinite(share.processShare) ? proxyShare + share.processShare : null;
|
|
698
|
+
const reading = typeof this.#host.processorBetween === "function"
|
|
699
|
+
? this.#host.processorBetween(previous.processor ?? null, sample.processor ?? null,
|
|
700
|
+
{ systemShare: share.systemShare, ourShare, hostCores: cores })
|
|
701
|
+
: null;
|
|
702
|
+
this.hostAvailability = {
|
|
703
|
+
...availability,
|
|
704
|
+
freeCores: Number.isFinite(reading?.freeCores) ? reading.freeCores : null,
|
|
705
|
+
otherCores: availability.known ? availability.unattributed * cores : null
|
|
706
|
+
};
|
|
690
707
|
return proxyShare;
|
|
691
708
|
}
|
|
692
709
|
|
|
@@ -197,9 +197,6 @@ export function wireOutputs({
|
|
|
197
197
|
// A software preset every picture is encoded at, for runs compared at one
|
|
198
198
|
// quality (`--pin-preset`); null leaves the choice to the machine.
|
|
199
199
|
pinnedPreset = null,
|
|
200
|
-
// A size every original-input stretch is given in place of copy rate
|
|
201
|
-
// times open (`--input-stretch-bytes`); null keeps the rule.
|
|
202
|
-
inputStretchBytes = null,
|
|
203
200
|
// What every encoder this host may use was qualified and measured to do at
|
|
204
201
|
// startup, by kind (`encode/calibration.js`). Takes the place of the one
|
|
205
202
|
// array above, which a test may still hand in on its own.
|
|
@@ -1079,6 +1076,9 @@ export function wireOutputs({
|
|
|
1079
1076
|
makeRun: ({ address, from, to, because }) => parts.encodeRuns.makeRunAt(address, from, to, because),
|
|
1080
1077
|
segmentSeconds: parts.segmentDurationSec,
|
|
1081
1078
|
contentionPenalties: parts.processor.penalties,
|
|
1079
|
+
// The processor left free and what other work takes, for the pause rule
|
|
1080
|
+
// (torrent-tv/meta#180, phase 4).
|
|
1081
|
+
processor: parts.processor,
|
|
1082
1082
|
startingSpeedFor: (address) => parts.encodeCost.speedForOutput(address),
|
|
1083
1083
|
segmentCoverage,
|
|
1084
1084
|
// HOW IT ASKS TO DECIDE AGAIN. A plan that refuses to place anything
|
|
@@ -1117,7 +1117,6 @@ export function wireOutputs({
|
|
|
1117
1117
|
readRanges: readEncodeRanges,
|
|
1118
1118
|
heldRanges: readEncodeHeldRanges,
|
|
1119
1119
|
stretchCopied,
|
|
1120
|
-
stretchBytes: inputStretchBytes,
|
|
1121
1120
|
reviseBudget: () => parts.machineBudget.revise("memory", "admitted encode inputs"),
|
|
1122
1121
|
capacity: () => parts.machineBudget.capacityOf("memory"),
|
|
1123
1122
|
// How long this output's input takes to open, from the one owner of start
|
|
@@ -1141,9 +1140,6 @@ export function wireOutputs({
|
|
|
1141
1140
|
parts.serving.invalidateWaits(output);
|
|
1142
1141
|
}
|
|
1143
1142
|
}) : null);
|
|
1144
|
-
if (parts.encodeInputs && inputStretchBytes) {
|
|
1145
|
-
logger.info(`encode input: every original-input stretch sized to ${inputStretchBytes} bytes (--input-stretch-bytes)`);
|
|
1146
|
-
}
|
|
1147
1143
|
// One owner of the disk, and the list of what takes it lives with the owner.
|
|
1148
1144
|
parts.machineBudget = wireMachineBudget({
|
|
1149
1145
|
segmentStore: parts.segmentStore,
|
|
@@ -286,11 +286,14 @@ function middleAndScatter(values) {
|
|
|
286
286
|
* @param {(line: string) => void} params.log
|
|
287
287
|
* @param {() => Array<{ pid: number, label: string, film: number | null }>} params.ffmpegRuns - The encoders running now, with the film each has made.
|
|
288
288
|
* @param {() => boolean} [params.detailed]
|
|
289
|
+
* @param {(seconds: Map<number, { label: string, cores: number, speed: number | null }>) => void} [params.onRunSeconds] -
|
|
290
|
+
* Each second's readings of every run, by process, for whoever decides
|
|
291
|
+
* from them (`EncodeOrchestrator.noteRunSeconds`, torrent-tv/meta#180).
|
|
289
292
|
* @param {number} [params.everyMs]
|
|
290
293
|
* @param {number} [params.sayEveryMs]
|
|
291
294
|
* @returns {{ stop: () => void }}
|
|
292
295
|
*/
|
|
293
|
-
export function startProcessorReport({ log, ffmpegRuns, detailed = () => false, everyMs = 1000, sayEveryMs = 60_000 }) {
|
|
296
|
+
export function startProcessorReport({ log, ffmpegRuns, detailed = () => false, onRunSeconds = () => {}, everyMs = 1000, sayEveryMs = 60_000 }) {
|
|
294
297
|
let previous = null;
|
|
295
298
|
let series = [];
|
|
296
299
|
/** @type {Map<number, { label: string, cores: number[], speed: number[] }>} */
|
|
@@ -305,6 +308,15 @@ export function startProcessorReport({ log, ffmpegRuns, detailed = () => false,
|
|
|
305
308
|
const shares = previous ? sharesBetween(previous, now) : null;
|
|
306
309
|
const perRun = previous ? runSecondsBetween(previous, now) : new Map();
|
|
307
310
|
previous = now;
|
|
311
|
+
if (perRun.size > 0) {
|
|
312
|
+
// A fault in whoever decides from the readings must not end the
|
|
313
|
+
// readings, nor reach the timer as an uncaught exception.
|
|
314
|
+
try {
|
|
315
|
+
onRunSeconds(perRun);
|
|
316
|
+
} catch (error) {
|
|
317
|
+
log(`processor: run seconds not handled: ${error instanceof Error ? error.message : String(error)}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
308
320
|
if (!shares) return;
|
|
309
321
|
series.push(shares);
|
|
310
322
|
for (const [pid, second] of perRun) {
|