Public: anyone can read this; the crew can change it.

The host stops a 1-CPU forge at 2.1 cores after 31 seconds: the new CPU rule is not the one that stops a hosted forge, and its builds die #11661

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/11657-the-host-stops-a-1-cpu-forge-at-2-1-core → main 8 commits fork 7 threads · 7 unresolved

  • Merged. torque/11657-the-host-stops-a-1-cpu-forge-at-2-1-core was merged into main as 73d9b66.

Review

Sign in to review this change.
5 files changed +262 −43
scripts/unreached-allow.json −1
@@ -1,5 +1,4 @@
11 [
2 "src/apps/cpu_rule.ts",
32 "src/core/one_core.ts",
43 "src/perf/page_budget.ts",
54 "src/perf/page_budget_read.ts",
src/apps/contain.ts +93 −31
@@ -50,6 +50,7 @@ import {
5050 } from "./cgroup.ts";
5151 import { during } from "../perf/jobs.ts";
5252 import { isSize, SIZES, type Size } from "../spawn/sizes.ts";
53 import { cpuWatch } from "./cpu_rule.ts";
5354
5455 /** `ceiling`: a declaration was cut to the machine's maximum; `declared`: forge.json's own; `default`: neither. */
5556 export type CeilingFrom = "env" | "pod" | "default" | "size";
@@ -632,6 +633,54 @@ export function cellCgroup(
632633 );
633634 }
634635
636 /** Where the next pinned cell's cores begin; `affinityArgv` advances it. */
637 export interface CoreRotor {
638 next: number;
639 }
640
641 /** The process's one rotor: every cell opened here draws its cores from it. */
642 const cellCores: CoreRotor = { next: 0 };
643
644 /**
645 * Where the kernel's `cpu.max` is not ours to write, a cell that declares a
646 * quota is held to its cores by CPU affinity instead (`taskset -c`, util-linux;
647 * sched_setaffinity(2)): a 1-CPU forge builds slowly on one core instead of
648 * being stopped. The cores rotate so forges do not all pile on core 0.
649 * Nothing is added when the kernel holds the cores, `taskset` is absent, or
650 * the cell may have every core the machine has (#11657).
651 */
652 export function affinityArgv(
653 spec: Pick<CellSpec, "cpuQuota" | "limits">,
654 kernelHolds: boolean,
655 rotor: CoreRotor = cellCores,
656 machine = availableParallelism(),
657 have: boolean = Bun.which("taskset") !== null,
658 ): string[] {
659 if (spec.cpuQuota !== true || kernelHolds || !have) return [];
660 const n = Math.max(1, Math.ceil(spec.limits.cpus));
661 if (!Number.isFinite(n) || n >= machine) return [];
662 const first = rotor.next % machine;
663 rotor.next = (first + n) % machine;
664 const cores = Array.from({ length: n }, (_, i) => (first + i) % machine);
665 return ["taskset", "-c", cores.join(",")];
666 }
667
668 /**
669 * The argv a cell's command runs as: into its cgroup when it has one, then
670 * pinned to its cores when the kernel does not hold them, then `inner` (bwrap).
671 */
672 export function cellArgv(
673 inner: readonly string[],
674 spec: Pick<CellSpec, "cpuQuota" | "limits">,
675 cgroup: { readonly dir: string; readonly cpuCapped: boolean } | undefined,
676 rotor: CoreRotor = cellCores,
677 machine = availableParallelism(),
678 have: boolean = Bun.which("taskset") !== null,
679 ): string[] {
680 const pinned = [...affinityArgv(spec, cgroup?.cpuCapped === true, rotor, machine, have), ...inner];
681 return cgroup === undefined ? pinned : enterArgv(join(cgroup.dir, "cgroup.procs"), pinned);
682 }
683
635684 /** Make a cell: its sockets, its proxy, its relays. `close()` undoes it. */
636685 export function openCell(
637686 spec: CellSpec,
@@ -681,10 +730,7 @@ export function openCell(
681730 const SMALL = String(1 << 20);
682731
683732 const cgroup = cellCgroup(spec);
684 const argv = (command: string): string[] => {
685 const inner = bwrapArgv(command);
686 return cgroup === undefined ? inner : enterArgv(join(cgroup.dir, "cgroup.procs"), inner);
687 };
733 const argv = (command: string): string[] => cellArgv(bwrapArgv(command), spec, cgroup);
688734 const rlimits = cellRlimits(spec.limits);
689735 const bwrapArgv = (command: string): string[] => [
690736 "bwrap",
@@ -894,15 +940,6 @@ function readMountinfo(): string {
894940
895941 /** How often the process table is read, and over how long CPU is averaged. */
896942 export const WATCH = { tickMs: 1_000, cpuWindowMs: 30_000, diskEveryTicks: 15 };
897 /**
898 * How far past its cores a cell may run before the watcher stops it. A
899 * cell is already held to its cores by affinity, so a busy one measures
900 * right at its limit (16.0 to 18.2 of 16 over a window): stopping at the
901 * limit itself failed three checks in four and stopped a 1-core forge at
902 * 1.0. The watcher is the last resort for a runaway, so it acts well past
903 * the limit.
904 */
905 export const CPU_STOP_SLACK = 1.5;
906943 /** A cell's process limit: the watch's every tick, and the kernel's `pids.max` where pids is delegated. */
907944 export const PROCESSES_MAX = 512;
908945 const CLK_TCK = 100;
@@ -915,6 +952,9 @@ interface Watched {
915952 readonly breach: (why: string) => void;
916953 readonly samples: { at: number; ticks: number }[];
917954 ticks: number;
955 /** When the cell began to be watched, and since when its cores have held over the stop line without a break (#11657). */
956 readonly startedAt: number;
957 overSince: number | null;
918958 readonly key: number;
919959 readonly label: string | undefined;
920960 /** The memory cap, and who holds it: the kernel (a cgroup) or this watch alone. */
@@ -1097,6 +1137,8 @@ export function watchTree(
10971137 breach,
10981138 samples: [],
10991139 ticks: 0,
1140 startedAt: Date.now(),
1141 overSince: null,
11001142 key: nextKey++,
11011143 label,
11021144 cap,
@@ -1249,6 +1291,36 @@ export function diskBytes(dirs: readonly string[]): number {
12491291
12501292 const mib = (bytes: number): string => `${Math.round(bytes / 1024 / 1024)} MiB`;
12511293
1294 /** The part of a watched cell its CPU decision reads and keeps. */
1295 export interface CpuState {
1296 readonly samples: { at: number; ticks: number }[];
1297 readonly startedAt: number;
1298 overSince: number | null;
1299 }
1300
1301 /**
1302 * One tick's CPU decision for a watched cell: record the tree's CPU `ticks`
1303 * at `now`, average the cores over `WATCH.cpuWindowMs`, keep the hold on
1304 * `state`, and say whether `cpuWatch` (`apps/cpu_rule.ts`) stops it — with the
1305 * cores and the window it measured — or null to let it run (#11657).
1306 */
1307 export function cpuSample(
1308 state: CpuState,
1309 ticks: number,
1310 cpus: number,
1311 now: number,
1312 ): { cores: number; seconds: number } | null {
1313 state.samples.push({ at: now, ticks });
1314 while (state.samples.length > 1 && now - (state.samples[1]?.at ?? now) >= WATCH.cpuWindowMs) state.samples.shift();
1315 const oldest = state.samples[0];
1316 if (oldest === undefined || now - oldest.at < WATCH.cpuWindowMs) return null;
1317 const seconds = (now - oldest.at) / 1000;
1318 const cores = (ticks - oldest.ticks) / CLK_TCK / seconds;
1319 const verdict = cpuWatch(state.overSince, cores, cpus, now, state.startedAt);
1320 state.overSince = verdict.overSinceMs;
1321 return verdict.stop ? { cores, seconds } : null;
1322 }
1323
12521324 function tick(): void {
12531325 if (watched.size === 0) return;
12541326 const { rows, children } = processTable();
@@ -1282,24 +1354,14 @@ function tick(): void {
12821354 );
12831355 continue;
12841356 }
1285 entry.samples.push({ at: now, ticks });
1286 while (
1287 entry.samples.length > 1 &&
1288 now - (entry.samples[1]?.at ?? now) >= WATCH.cpuWindowMs
1289 )
1290 entry.samples.shift();
1291 const oldest = entry.samples[0];
1292 if (oldest !== undefined && now - oldest.at >= WATCH.cpuWindowMs) {
1293 const cores =
1294 (ticks - oldest.ticks) / CLK_TCK / ((now - oldest.at) / 1000);
1295 if (cores > limits.cpus * CPU_STOP_SLACK) {
1296 // Read only now, at the stop: no tick pays for naming a process.
1297 const top = busiest(tree, (pid) => rows.get(pid)?.ticks ?? 0);
1298 entry.breach(
1299 cpuBreach(cores, (now - oldest.at) / 1000, limits.cpus, top === undefined ? undefined : { ...top, name: commandName(top.pid) }),
1300 );
1301 continue;
1302 }
1357 const over = cpuSample(entry, ticks, limits.cpus, now);
1358 if (over !== null) {
1359 // Read only now, at the stop: no tick pays for naming a process.
1360 const top = busiest(tree, (pid) => rows.get(pid)?.ticks ?? 0);
1361 entry.breach(
1362 cpuBreach(over.cores, over.seconds, limits.cpus, top === undefined ? undefined : { ...top, name: commandName(top.pid) }),
1363 );
1364 continue;
13031365 }
13041366 entry.ticks++;
13051367 if (entry.ticks % WATCH.diskEveryTicks === 0) {
src/apps/cpu_rule.ts +37 −1
@@ -1,9 +1,18 @@
11 /**
22 * When a cell's CPU use is a stop. Pure: plain data in, a boolean out; no clock,
3 * no I/O. No caller yet (#11612, part of #11539).
3 * no I/O. Called by the cell watch (`apps/contain.ts` tick, #11657).
44 *
55 * Rule: cores used > CPU_STOP_FACTOR × the cell's cpus, held continuously for at
66 * least CPU_STOP_HOLD_MS, and the cell at least CPU_STOP_GRACE_MS old.
7 *
8 * The cost of the grace, chosen (#11657): the watch is the only bound on a cell
9 * the kernel does not hold (no delegated `cpu.max`) and affinity does not pin
10 * (`taskset` absent). Such a cell — an untrusted check included — may use every
11 * core of the host for up to CPU_STOP_GRACE_MS (10 minutes) before it can be
12 * stopped, and must keep it up for CPU_STOP_HOLD_MS. A cell whose cores are the
13 * whole machine is within its limit at any load. The other choice, a quick stop,
14 * is what killed a sized forge's builds at 2.1 of 1 core after 31 s; where the
15 * kernel holds the quota or `taskset` pins the cores this cost does not arise.
716 */
817
918 /** A cell is over its limit only past this multiple of its cores. */
@@ -33,3 +42,30 @@ export function cpuStop(i: CpuStopInput): boolean {
3342 if (nowMs - overSinceMs < CPU_STOP_HOLD_MS) return false;
3443 return nowMs - startedAtMs >= CPU_STOP_GRACE_MS;
3544 }
45
46 /** The next `overSinceMs`: kept while cores stay over the line, set to now when they cross it, null when they drop under. */
47 function overSince(prev: number | null, cores: number, cpus: number, nowMs: number): number | null {
48 if (!(cores > CPU_STOP_FACTOR * cpus)) return null;
49 return prev ?? nowMs;
50 }
51
52 /** What one tick of the watch decides: the hold to keep, and whether to stop. */
53 export interface CpuVerdict {
54 readonly overSinceMs: number | null;
55 readonly stop: boolean;
56 }
57
58 /**
59 * One tick of the watch's decision, pure: the new `overSinceMs` and whether the cell is
60 * stopped. The watch keeps the first and acts on the second (`apps/contain.ts`, #11657).
61 */
62 export function cpuWatch(
63 prevOverSinceMs: number | null,
64 cores: number,
65 cpus: number,
66 nowMs: number,
67 startedAtMs: number,
68 ): CpuVerdict {
69 const overSinceMs = overSince(prevOverSinceMs, cores, cpus, nowMs);
70 return { overSinceMs, stop: cpuStop({ cores, cpus, overSinceMs, nowMs, startedAtMs }) };
71 }
test/contain.test.ts +8 −8
@@ -834,14 +834,14 @@ describe.skipIf(noCells)("cells: limits stop a step, and say which", () => {
834834 );
835835 }, 30_000);
836836
837 test("over its CPU limit", async () => {
838 // A small share, so a busy machine still gives the loop more than it.
839 const result = await step(`bun -e "for (;;) {}"`, cell({ cpus: 0.02 }));
840 expect(result.ok).toBe(false);
841 if (!result.ok)
842 expect(result.error).toMatch(
843 /^check stopped: it used [\d.]+ cores for \d+s, over its limit of 0\.02; the busiest process is \S+ \(pid \d+, \d+s of CPU\)$/,
844 );
837 test("over its CPU limit inside its grace: it runs on, and finishes (#11657)", async () => {
838 // Twice the (shrunk) CPU window busy at far over 0.02 cores: the old 1.5× line
839 // stopped this at the first full window; the rule waits out the grace first.
840 const result = await step(
841 `bun -e "const end = Date.now() + 6000; while (Date.now() < end) {}"`,
842 cell({ cpus: 0.02 }),
843 );
844 expect(result.ok).toBe(true);
845845 }, 30_000);
846846
847847 test("over its disk limit", async () => {
test/cpu-rule.test.ts +124 −2
@@ -1,5 +1,7 @@
1 import { expect, test } from "bun:test";
2 import { CPU_STOP_GRACE_MS, CPU_STOP_HOLD_MS, cpuStop } from "../src/apps/cpu_rule.ts";
1 import { describe, expect, test } from "bun:test";
2 import { CPU_STOP_GRACE_MS, CPU_STOP_HOLD_MS, cpuStop, cpuWatch } from "../src/apps/cpu_rule.ts";
3 import { affinityArgv, cellArgv, cpuSample, type CpuState, WATCH } from "../src/apps/contain.ts";
4 import { enterArgv } from "../src/apps/cgroup.ts";
35
46 const M = 60_000;
57 const now = 100 * M;
@@ -27,3 +29,123 @@ test("3.0 of 1 at minute 9 of the cell's life is kept; at minute 10 it is stoppe
2729 test("an infinite cpus (kernel-capped) is kept", () => {
2830 expect(cpuStop({ cores: 50, cpus: Infinity, overSinceMs: now - 10 * M, nowMs: now, startedAtMs: old })).toBe(false);
2931 });
outdated

Move imports to the top of the file; add a test for the Settings page and one for the watch wiring that runs without cells.

32
33 /** Drive the watch's per-tick decision with a clock, as the cell watch does. */
34 function run(cores: number, cpus: number, forMs: number, startedAt = 0): { stoppedAt: number | null } {
35 let over: number | null = null;
36 for (let t = 0; t <= forMs; t += 1_000) {
37 const v = cpuWatch(over, cores, cpus, startedAt + t, startedAt);
38 over = v.overSinceMs;
39 if (v.stop) return { stoppedAt: t };
40 }
41 return { stoppedAt: null };
42 }
43
44 test("the watch: a 1-CPU cell at 2.1 cores is not stopped after 31 s, or after 5 minutes (#11657)", () => {
45 expect(run(2.1, 1, 31_000).stoppedAt).toBeNull();
46 expect(run(2.1, 1, CPU_STOP_HOLD_MS - 1_000).stoppedAt).toBeNull();
47 });
48
49 test("the watch: 3.0 of 1 core held without a break is stopped at the hold, never before", () => {
50 const { stoppedAt } = run(3, 1, 20 * M);
51 expect(stoppedAt).toBe(Math.max(CPU_STOP_HOLD_MS, CPU_STOP_GRACE_MS));
52 });
53
54 test("the watch: a dip under the line resets the hold", () => {
55 const a = cpuWatch(null, 3, 1, 1_000, 0);
56 expect(a.overSinceMs).toBe(1_000);
57 expect(cpuWatch(a.overSinceMs, 3, 1, 5_000, 0).overSinceMs).toBe(1_000);
58 expect(cpuWatch(a.overSinceMs, 1.5, 1, 6_000, 0)).toEqual({ overSinceMs: null, stop: false });
59 });
60
61 const limits = (cpus: number) => ({ memoryMiB: 1, cpus, diskMiB: 1 });
62
63 test("a cell the kernel does not hold is pinned to its cores; one it holds, or the whole machine, is not (#11657)", () => {
64 const spec = { cpuQuota: true, limits: limits(1) };
65 const rotor = () => ({ next: 0 });
66 expect(affinityArgv(spec, false, rotor(), 4, true)).toEqual(["taskset", "-c", "0"]);
67 expect(affinityArgv(spec, true, rotor(), 4, true)).toEqual([]);
68 expect(affinityArgv(spec, false, rotor(), 4, false)).toEqual([]);
69 expect(affinityArgv(spec, false, rotor(), 1, true)).toEqual([]);
70 expect(affinityArgv({ cpuQuota: false, limits: limits(1) }, false, rotor(), 4, true)).toEqual([]);
71 });
72
73 test("pinned cells rotate over the cores, wrapping past the last", () => {
74 const spec = { cpuQuota: true, limits: limits(2) };
75 const rotor = { next: 0 };
76 const cores = Array.from({ length: 3 }, () => affinityArgv(spec, false, rotor, 4, true)[2]);
77 expect(cores).toEqual(["0,1", "2,3", "0,1"]);
78 // A 1.5-cpu cell takes two whole cores; one from 3 of 4 wraps to core 0.
79 expect(affinityArgv({ cpuQuota: true, limits: limits(1.5) }, false, { next: 3 }, 4, true)[2]).toBe("3,0");
80 });
81
82 describe("the argv openCell runs a command as (cellArgv)", () => {
83 const bwrap = ["bwrap", "--", "true"];
84 const spec = { cpuQuota: true, limits: limits(1) };
85
86 test("no cgroup the kernel caps: the command is pinned inside the cgroup's enter", () => {
87 const argv = cellArgv(bwrap, spec, { dir: "/sys/fs/cgroup/cell-a", cpuCapped: false }, { next: 2 }, 4, true);
88 expect(argv).toEqual(enterArgv("/sys/fs/cgroup/cell-a/cgroup.procs", ["taskset", "-c", "2", ...bwrap]));
89 });
90
91 test("a cgroup that caps the cores: entered, not pinned", () => {
92 const argv = cellArgv(bwrap, spec, { dir: "/sys/fs/cgroup/cell-b", cpuCapped: true }, { next: 0 }, 4, true);
93 expect(argv).toEqual(enterArgv("/sys/fs/cgroup/cell-b/cgroup.procs", bwrap));
94 });
95
96 test("no cgroup at all: pinned, then bwrap", () => {
97 expect(cellArgv(bwrap, spec, undefined, { next: 0 }, 4, true)).toEqual(["taskset", "-c", "0", ...bwrap]);
98 expect(cellArgv(bwrap, spec, undefined, { next: 0 }, 4, false)).toEqual(bwrap);
99 });
100 });
101
102 describe("the watch's tick feeds the rule (cpuSample)", () => {
103 const CLK_TCK = 100;
104
105 /** A tree burning `cores` from t = 0, sampled each second; the second at which it is stopped, or null. */
106 function watch(cores: number, cpus: number, forMs: number): { stoppedAt: number | null; state: CpuState } {
107 const state: CpuState = { samples: [], startedAt: 0, overSince: null };
108 for (let t = 0; t <= forMs; t += 1_000) {
109 const over = cpuSample(state, Math.round((cores * CLK_TCK * t) / 1000), cpus, t);
110 if (over !== null) {
111 expect(over.cores).toBeCloseTo(cores, 5);
112 expect(over.seconds).toBe(WATCH.cpuWindowMs / 1000);
113 return { stoppedAt: t, state };
114 }
115 }
116 return { stoppedAt: null, state };
117 }
118
119 test("a 1-CPU forge at 2.1 cores is not stopped at 31 s — the old 1.5× line would have (#11657)", () => {
120 const { stoppedAt, state } = watch(2.1, 1, 31_000);
121 expect(stoppedAt).toBeNull();
122 // The window filled at 30 s, and from then the hold is kept on the watched cell.
123 expect(state.overSince).toBe(WATCH.cpuWindowMs);
124 });
125
126 test("2.1 of 1 core held is stopped once the grace is over, not before", () => {
127 expect(watch(2.1, 1, CPU_STOP_GRACE_MS - 1_000).stoppedAt).toBeNull();
128 expect(watch(2.1, 1, 20 * M).stoppedAt).toBe(CPU_STOP_GRACE_MS);
129 });
130
131 test("at twice its cores, not over: never stopped, and no hold kept", () => {
132 const { stoppedAt, state } = watch(2, 1, 20 * M);
133 expect(stoppedAt).toBeNull();
134 expect(state.overSince).toBeNull();
135 });
136
137 test("a dip under the line inside the window restarts the hold", () => {
138 const state: CpuState = { samples: [], startedAt: -CPU_STOP_GRACE_MS, overSince: null };
139 let ticks = 0;
140 let stoppedAt: number | null = null;
141 for (let t = 0; t <= 20 * M && stoppedAt === null; t += 1_000) {
142 // Busy at 3 cores, idle for one window at 4 minutes.
143 const idle = t > 4 * M && t <= 4 * M + WATCH.cpuWindowMs;
144 if (t > 0) ticks += idle ? 0 : 3 * CLK_TCK;
145 if (cpuSample(state, ticks, 1, t) !== null) stoppedAt = t;
146 }
147 // Over again from the first full busy window after the dip; stopped a hold later.
148 expect(stoppedAt).toBeGreaterThan(4 * M + CPU_STOP_HOLD_MS);
149 expect(stoppedAt).toBeLessThanOrEqual(4 * M + 2 * WATCH.cpuWindowMs + CPU_STOP_HOLD_MS);
150 });
151 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs