Public: anyone can read this; the crew can change it.
forked from trevato/cs
What a room costs, measured before anything is built on it: the world stepped headless in this process, at a ceiling a test holds #346
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/302-what-a-room-costs-measured-before-anythi → main 1 commit fork
5 files
changed +831
−18
README.md +54
| @@ -27,6 +27,8 @@ bun run scripts/surface.ts # is the door still the | ||
| 27 | 27 | bun run scripts/surface.ts --write 0.2.0 # record today's door as that version |
| 28 | 28 | bun run scripts/cost.ts # what each group behind the door adds to a bundle |
| 29 | 29 | bun run scripts/cost.ts --guide # the same, as the lines GUIDE.md carries |
| 30 | bun run scripts/room.ts # what one room costs: CPU a world-second, snapshot bytes, rooms a core | |
| 31 | bun run scripts/room.ts --seconds 30 --margin 0.25 # a longer window, and a quarter of a core for rooms | |
| 30 | 32 | bun test # the check every pull runs |
| 31 | 33 | bun run scripts/mutate-result.ts # break each rule in src/result.ts and src/gate.ts, see a test go red |
| 32 | 34 | FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface |
| @@ -1592,3 +1594,55 @@ What is real and what is not, today: | ||
| 1592 | 1594 | effects recorded for one screen and a snapshot goes to everybody); and a room's |
| 1593 | 1595 | result is not posted to the leaderboard. Rooms live in this process only — a |
| 1594 | 1596 | deploy ends them. |
| 1597 | ||
| 1598 | ### What a room costs, measured | |
| 1599 | ||
| 1600 | The caps above are arithmetic on two numbers, and `bun run scripts/room.ts` | |
| 1601 | measures both rather than asserting them: what a **world-second** of the | |
| 1602 | simulation costs this process, and what a **snapshot** of that world weighs on | |
| 1603 | a wire. It steps a real `createRoom` with ten bodies on de_forge, with the | |
| 1604 | shipped policy thinking, and encodes the round that leaves. It excludes the | |
| 1605 | socket: no connection is opened, so the kernel's share of the bytes is in no | |
| 1606 | number here. | |
| 1607 | ||
| 1608 | Measured on the forge's own box — an Intel Xeon Gold 6230 at 2.1 GHz, 80 shared | |
| 1609 | cores at a load average of about 20, bun 1.3.14 — over an eight-second window of | |
| 1610 | a live round, median of seven batches, across four runs. The spread between runs | |
| 1611 | is the box and not the code, and the dearest second is one 64-tick window, so it | |
| 1612 | carries most of that noise: | |
| 1613 | ||
| 1614 | | per simulated world-second | average | dearest second | | |
| 1615 | | --- | --- | --- | | |
| 1616 | | ten bots thinking, the shipped policy | 5.4–8.1 ms | 10–24 ms | | |
| 1617 | | ten seats driven by members, nothing thinking | 2.5–3.0 ms | 2.8–5.5 ms | | |
| 1618 | | of which the input frames off the wire | 0.35–0.54 ms | | | |
| 1619 | ||
| 1620 | So the ten policies are about **60–70% of a thinking room**: a room with ten | |
| 1621 | people in every seat is less than half the CPU of a room full of bots. There is | |
| 1622 | no third case — a bot without a policy does not think, it throws — so "without | |
| 1623 | the trained policy" is measured as the room whose seats are all held. | |
| 1624 | ||
| 1625 | The snapshot, over 128 beats of that round at 16 Hz: | |
| 1626 | ||
| 1627 | | a snapshot of ten bodies | median | widest | 8 Hz | 16 Hz | 32 Hz | | |
| 1628 | | --- | --- | --- | --- | --- | --- | | |
| 1629 | | packed binary (what the wire sends) | 514 B | 764 B | 4.1 kB/s | 8.2 kB/s | 16.4 kB/s | | |
| 1630 | | JSON | 3.8 kB | 5.1 kB | 30.7 kB/s | 61.4 kB/s | 122.7 kB/s | | |
| 1631 | | JSON gzipped (level 6) | 684 B | 1.1 kB | 5.5 kB/s | 10.9 kB/s | 21.9 kB/s | | |
| 1632 | ||
| 1633 | and what each costs this process to encode: 0.006 ms a beat packed, 0.019 ms | |
| 1634 | JSON, 0.066 ms JSON gzipped — which for ten members at 16 Hz is 1.0 ms, 3.1 ms | |
| 1635 | and 10.7 ms of CPU per world-second. Gzip buys 170 bytes a beat over the packed | |
| 1636 | frame and costs ten times its CPU, which is the whole comparison in a sentence. | |
| 1637 | ||
| 1638 | **The answer:** a whole room — the step plus ten packed snapshots at 16 Hz — | |
| 1639 | costs **6.4–9.0 ms of CPU a second**, so one core holds **55–78 rooms** with | |
| 1640 | half of it left over, and 19–45 if every room has its dearest second at the same | |
| 1641 | moment. `ROOM_CAP` is 20, which is a sixth of a core: the cap is a choice about | |
| 1642 | a box that also serves pages, not a ceiling the simulation put there. Raising it | |
| 1643 | is a decision these numbers can now be read before. | |
| 1644 | ||
| 1645 | `src/budget.test.ts` holds the ceiling that keeps it true: a room-second must | |
| 1646 | cost under 40 ms in this process, and the same file proves the assertion can | |
| 1647 | fail. The bench prints the machine it ran on because a number from a laptop is | |
| 1648 | not a number from production. |
scripts/room.test.ts added +135
| @@ -0,0 +1,135 @@ | ||
| 1 | /** | |
| 2 | * The room bench, held to the two things a bench can be wrong about that a | |
| 3 | * reader would never see: its **arithmetic** and its **subject**. | |
| 4 | * | |
| 5 | * The timings themselves are not asserted here and must not be — a number from | |
| 6 | * a shared box is not a number from production, which is why `scripts/room.ts` | |
| 7 | * prints the machine it ran on and why the suite's only assertion about speed | |
| 8 | * is the ceiling in `src/budget.test.ts`. What is asserted is everything | |
| 9 | * around them: | |
| 10 | * | |
| 11 | * - The flags are read or refused with a sentence, so nobody measures a window | |
| 12 | * of `NaN` seconds and writes the answer in a pull. | |
| 13 | * - Rooms per core is the division it claims to be, floored, and a margin | |
| 14 | * narrows it. | |
| 15 | * - The JSON candidate carries the **same round** the packed one does — ten | |
| 16 | * bodies with their health and their guns, the grenades, the smoke, the bomb | |
| 17 | * and the clock — because a comparison between a packed snapshot and a | |
| 18 | * smaller idea of one would flatter the winner and settle #304 on a lie. | |
| 19 | * - And the three encodings are measured over a real stepped room, packed | |
| 20 | * smallest, JSON largest. | |
| 21 | */ | |
| 22 | ||
| 23 | import { expect, test } from "bun:test"; | |
| 24 | import { GZIP_LEVEL } from "../src/compress.ts"; | |
| 25 | import { useShipped } from "../src/client/shipped.ts"; | |
| 26 | import { createRoom, stepRoom, SNAPSHOT_HZ } from "../src/rooms/room.ts"; | |
| 27 | import { SNAPSHOT_MAX } from "../src/rooms/wire.ts"; | |
| 28 | import { | |
| 29 | DEFAULTS, RATES, encodings, jsonSnapshot, machine, parseOptions, perMemberPerSecond, roomsPerCore, | |
| 30 | } from "./room.ts"; | |
| 31 | import type { Player } from "../src/client/world.ts"; | |
| 32 | ||
| 33 | // --- the flags ------------------------------------------------------------ | |
| 34 | ||
| 35 | test("the flags are read, and anything that is not a run is a sentence", () => { | |
| 36 | expect(parseOptions([])).toEqual(DEFAULTS); | |
| 37 | expect(parseOptions(["--seconds", "2.5"]).seconds).toBe(2.5); | |
| 38 | expect(parseOptions(["--members", "4"]).members).toBe(4); | |
| 39 | expect(parseOptions(["--margin", "0.25"]).margin).toBe(0.25); | |
| 40 | ||
| 41 | expect(() => parseOptions(["--fast"])).toThrow(/not a flag this script knows/); | |
| 42 | expect(() => parseOptions(["seconds", "2"])).toThrow(/is not a flag/); | |
| 43 | expect(() => parseOptions(["--seconds"])).toThrow(/needs a value/); | |
| 44 | expect(() => parseOptions(["--seconds", "--members"])).toThrow(/needs a value/); | |
| 45 | expect(() => parseOptions(["--seconds", "soon"])).toThrow(/takes a number/); | |
| 46 | expect(() => parseOptions(["--seconds", "0"])).toThrow(/above zero/); | |
| 47 | expect(() => parseOptions(["--members", "11"])).toThrow(/1…10/); | |
| 48 | expect(() => parseOptions(["--members", "2.5"])).toThrow(/1…10/); | |
| 49 | expect(() => parseOptions(["--margin", "2"])).toThrow(/share of one core/); | |
| 50 | expect(() => parseOptions(["--margin", "0"])).toThrow(/share of one core/); | |
| 51 | }); | |
| 52 | ||
| 53 | // --- the arithmetic ------------------------------------------------------- | |
| 54 | ||
| 55 | test("rooms per core is the division it says it is, floored, and the margin narrows it", () => { | |
| 56 | // A second of one core is 1000 ms of CPU: a room costing 10 ms of it a second | |
| 57 | // is a hundredth of a core, so a hundred of them is the core and fifty is | |
| 58 | // half of it. | |
| 59 | expect(roomsPerCore(10, 1)).toBe(100); | |
| 60 | expect(roomsPerCore(10, 0.5)).toBe(50); | |
| 61 | // Half a room is no room. | |
| 62 | expect(roomsPerCore(30, 1)).toBe(33); | |
| 63 | // And a cost nobody measured is not an infinite number of rooms. | |
| 64 | expect(roomsPerCore(0, 1)).toBe(0); | |
| 65 | expect(roomsPerCore(-1, 1)).toBe(0); | |
| 66 | }); | |
| 67 | ||
| 68 | test("bytes a member a second is the frame at the rate, and the rates include the shipped one", () => { | |
| 69 | expect(perMemberPerSecond(544, 16)).toBe(8704); | |
| 70 | expect(perMemberPerSecond(544, 32)).toBe(17408); | |
| 71 | expect(RATES).toEqual([8, SNAPSHOT_HZ, 32]); | |
| 72 | expect(RATES).toContain(SNAPSHOT_HZ); | |
| 73 | }); | |
| 74 | ||
| 75 | test("the machine is named, because a number from a laptop is not a number from production", () => { | |
| 76 | expect(machine()).toContain(`bun ${Bun.version}`); | |
| 77 | expect(machine()).toContain(process.platform); | |
| 78 | }); | |
| 79 | ||
| 80 | // --- the subject ---------------------------------------------------------- | |
| 81 | ||
| 82 | /** A room with a few seconds of a live round played into it. */ | |
| 83 | function played(seconds: number) { | |
| 84 | useShipped(); | |
| 85 | const room = createRoom("TESTRM", { map: "de_forge", seed: 9, length: "short", now: 0 }); | |
| 86 | room.world.match.phase = "live"; | |
| 87 | room.world.match.timer = room.rules.roundTime; | |
| 88 | for (let i = 0; i < 64 * seconds; i++) stepRoom(room); | |
| 89 | return room; | |
| 90 | } | |
| 91 | ||
| 92 | test("the JSON candidate carries the same round the packed one does", () => { | |
| 93 | const room = played(3); | |
| 94 | room.world.bomb.state = "planted"; | |
| 95 | room.world.bomb.site = "A"; | |
| 96 | room.world.clouds.push({ pos: [500, 500, 40], born: room.world.time - 1, owner: 3 }); | |
| 97 | const snapshot = jsonSnapshot(room.world, room.tick, 0) as Record<string, unknown>; | |
| 98 | ||
| 99 | expect(snapshot.t).toBe(room.tick); | |
| 100 | expect(snapshot.phase).toBe("live"); | |
| 101 | const bomb = snapshot.bomb as Record<string, unknown>; | |
| 102 | expect(bomb.state).toBe("planted"); | |
| 103 | expect(bomb.site).toBe("A"); | |
| 104 | expect((snapshot.clouds as unknown[]).length).toBe(1); | |
| 105 | ||
| 106 | const bodies = snapshot.players as Record<string, unknown>[]; | |
| 107 | expect(bodies.length).toBe(room.world.players.length); | |
| 108 | bodies.forEach((body, i) => { | |
| 109 | const p = room.world.players[i] as Player; | |
| 110 | // Every body, to the hundredth of a unit a JSON wire would round to. | |
| 111 | expect((body.pos as number[])[0]).toBeCloseTo(p.pos[0], 1); | |
| 112 | expect(body.health).toBe(Math.round(p.health)); | |
| 113 | expect(body.weapon).toBe(p.weapon); | |
| 114 | expect(body.alive).toBe(p.alive); | |
| 115 | expect(body.money).toBe(Math.round(p.money)); | |
| 116 | }); | |
| 117 | }); | |
| 118 | ||
| 119 | test("the three encodings are measured over a real round, and the packed one is the smallest", () => { | |
| 120 | const room = played(3); | |
| 121 | const frame = encodings(room.world, room.tick, 0, new Uint8Array(SNAPSHOT_MAX)); | |
| 122 | ||
| 123 | expect(frame.packed).toBeGreaterThan(0); | |
| 124 | expect(frame.packed).toBeLessThan(frame.gzip); | |
| 125 | expect(frame.gzip).toBeLessThan(frame.json); | |
| 126 | // The packed frame is the one the wire sends, so it is the one pinned by | |
| 127 | // `src/rooms/wire.test.ts`; this only holds the ordering the pull reports. | |
| 128 | expect(frame.json).toBeGreaterThan(2000); | |
| 129 | ||
| 130 | // And the gzip is really gzip at the level this server compresses at: a | |
| 131 | // different level would be a different number in the table. | |
| 132 | const text = JSON.stringify(jsonSnapshot(room.world, room.tick, 0)); | |
| 133 | expect(frame.gzip).toBe(Bun.gzipSync(Buffer.from(text, "utf8"), { level: GZIP_LEVEL }).byteLength); | |
| 134 | expect(frame.json).toBe(Buffer.byteLength(text, "utf8")); | |
| 135 | }); |
scripts/room.ts added +506
| @@ -0,0 +1,506 @@ | ||
| 1 | /** | |
| 2 | * What one room costs, measured: | |
| 3 | * | |
| 4 | * bun run scripts/room.ts # the table | |
| 5 | * bun run scripts/room.ts --seconds 30 # a longer window of play | |
| 6 | * bun run scripts/room.ts --members 4 # snapshots for four people, not ten | |
| 7 | * bun run scripts/room.ts --margin 0.25 # a quarter of a core for rooms | |
| 8 | * | |
| 9 | * `src/rooms/hub.ts` holds `ROOM_CAP` rooms in this process and the argument for | |
| 10 | * where that cap is is arithmetic on two numbers: what a world-second of the | |
| 11 | * simulation costs this process, and what a snapshot of that world weighs on a | |
| 12 | * wire. This script measures both rather than asserting them, because every | |
| 13 | * question multiplayer asks after it — how many rooms a deployment holds, how | |
| 14 | * often a snapshot can go out, whether this process can be the truth at all — | |
| 15 | * is those two numbers and nothing else. | |
| 16 | * | |
| 17 | * It measures a **real room**: `createRoom` from `src/rooms/room.ts`, ten | |
| 18 | * bodies on de_forge, `stepRoom` at the game's own 64 Hz with the shipped | |
| 19 | * policy thinking, and `writeSnapshot` on the round that leaves. Nothing here | |
| 20 | * is a stub and nothing is a model of the game; the only thing it does that a | |
| 21 | * deployment does not is skip the freeze at the top of the round, because eight | |
| 22 | * seconds of ten bodies standing still is eight seconds of a tick that is not | |
| 23 | * the tick anybody is asking about. | |
| 24 | * | |
| 25 | * **What it excludes**, said plainly because a bench that hides its edges is | |
| 26 | * worse than none: the socket. No WebSocket is opened, nothing is written to a | |
| 27 | * file descriptor, and the kernel's share of sending 8.7 kB a second to ten | |
| 28 | * members is not in any number below. What is in them is everything this | |
| 29 | * process computes — the simulation, the input frames off the wire, and the | |
| 30 | * encoding of every snapshot. | |
| 31 | * | |
| 32 | * **It is not a ceiling.** `src/budget.test.ts` holds the ceiling and fails | |
| 33 | * when a room-second gets dearer; this prints numbers for a person reading | |
| 34 | * them, and the two are kept apart for the reason that file's header gives. | |
| 35 | * | |
| 36 | * It ships in no bundle and the server never imports it: this is a script a | |
| 37 | * person runs and `room.test.ts` runs the pure parts of. It writes nothing, | |
| 38 | * anywhere. | |
| 39 | */ | |
| 40 | ||
| 41 | import { cpus } from "node:os"; | |
| 42 | import { GZIP_LEVEL } from "../src/compress.ts"; | |
| 43 | import { useShipped } from "../src/client/shipped.ts"; | |
| 44 | import { createRoom, drive, join, stepRoom, TICK, SNAPSHOT_HZ, type Room } from "../src/rooms/room.ts"; | |
| 45 | import { writeInput, writeSnapshot, SNAPSHOT_MAX } from "../src/rooms/wire.ts"; | |
| 46 | import { NO_INPUT, type Effect, type Player, type World } from "../src/client/world.ts"; | |
| 47 | ||
| 48 | // --- the options ---------------------------------------------------------- | |
| 49 | ||
| 50 | export type Options = { | |
| 51 | /** How many simulated seconds one batch of the step is. */ | |
| 52 | seconds: number; | |
| 53 | /** How many members' snapshots a full room is costed for. */ | |
| 54 | members: number; | |
| 55 | /** What share of one core rooms are allowed, for the "how many" arithmetic. */ | |
| 56 | margin: number; | |
| 57 | }; | |
| 58 | ||
| 59 | export const DEFAULTS: Options = { seconds: 10, members: 10, margin: 0.5 }; | |
| 60 | ||
| 61 | const KNOWN = ["--seconds", "--members", "--margin"]; | |
| 62 | ||
| 63 | export const USAGE = `usage: bun run scripts/room.ts [${KNOWN.join("] [")}]`; | |
| 64 | ||
| 65 | /** | |
| 66 | * argv (what comes after the script name) in, options out. Anything that is not | |
| 67 | * a run — an unknown flag, a value that is not a number, a window of zero | |
| 68 | * seconds, a margin outside 0…1 — throws a sentence naming the flag and what it | |
| 69 | * got, because a table of numbers measured over `NaN` seconds is worse than an | |
| 70 | * error. | |
| 71 | */ | |
| 72 | export function parseOptions(argv: readonly string[]): Options { | |
| 73 | const options: Options = { ...DEFAULTS }; | |
| 74 | for (let i = 0; i < argv.length; i++) { | |
| 75 | const flag = argv[i] as string; | |
| 76 | if (!flag.startsWith("--")) throw new Error(`${JSON.stringify(flag)} is not a flag; ${USAGE}`); | |
| 77 | if (!KNOWN.includes(flag)) throw new Error(`${flag} is not a flag this script knows; ${USAGE}`); | |
| 78 | const text = argv[++i]; | |
| 79 | if (text === undefined || text.startsWith("--")) throw new Error(`${flag} needs a value`); | |
| 80 | const n = Number(text); | |
| 81 | if (!Number.isFinite(n)) throw new Error(`${flag} takes a number, and got ${JSON.stringify(text)}`); | |
| 82 | if (flag === "--seconds") { | |
| 83 | if (n <= 0) throw new Error(`--seconds is a window of play, so it is above zero, and got ${n}`); | |
| 84 | options.seconds = n; | |
| 85 | } else if (flag === "--members") { | |
| 86 | if (!Number.isInteger(n) || n < 1 || n > 10) throw new Error(`--members is 1…10, and got ${JSON.stringify(text)}`); | |
| 87 | options.members = n; | |
| 88 | } else { | |
| 89 | if (n <= 0 || n > 1) throw new Error(`--margin is a share of one core, above 0 and at most 1, and got ${n}`); | |
| 90 | options.margin = n; | |
| 91 | } | |
| 92 | } | |
| 93 | return options; | |
| 94 | } | |
| 95 | ||
| 96 | // --- the arithmetic the numbers are read through -------------------------- | |
| 97 | ||
| 98 | /** | |
| 99 | * How many rooms one core holds, given what a room-second of CPU costs and the | |
| 100 | * share of that core rooms may have. A thousand milliseconds of CPU is a second | |
| 101 | * of one core; a room that costs `each` milliseconds a simulated second takes | |
| 102 | * that much of it, and the floor is taken because half a room is no room. | |
| 103 | * | |
| 104 | * The margin is not conservatism for its own sake: this process also serves | |
| 105 | * pages, encodes snapshots for everybody, and runs a garbage collector, and a | |
| 106 | * room that is late does not drop a frame — it judders for ten people. | |
| 107 | */ | |
| 108 | export const roomsPerCore = (each: number, margin: number): number => | |
| 109 | each <= 0 ? 0 : Math.floor((1000 * margin) / each); | |
| 110 | ||
| 111 | /** What one member is sent a second, in bytes, at a rate of `hz` snapshots. */ | |
| 112 | export const perMemberPerSecond = (bytes: number, hz: number): number => bytes * hz; | |
| 113 | ||
| 114 | /** The rates this script costs every encoding at. */ | |
| 115 | export const RATES = [8, SNAPSHOT_HZ, 32] as const; | |
| 116 | ||
| 117 | // --- the two clocks ------------------------------------------------------- | |
| 118 | ||
| 119 | /** | |
| 120 | * Milliseconds of CPU this process has burned, user and system together. | |
| 121 | * | |
| 122 | * Every batch below is timed by this **and** by the wall clock, and the | |
| 123 | * measurement is the smaller of the two: interference only ever pushes one of | |
| 124 | * them up — a descheduled process reads a long wall clock, a GC thread on | |
| 125 | * another core reads a long CPU clock — so the smaller is the tightest bound | |
| 126 | * the pair honestly supports. It is the same argument `src/budget.test.ts` | |
| 127 | * makes at length, and the fifteen lines are here rather than imported from | |
| 128 | * there because a script that imports a test file to run is a worse trade than | |
| 129 | * fifteen lines that say the same thing. | |
| 130 | */ | |
| 131 | const cpuMs = (): number => { | |
| 132 | const used = process.cpuUsage(); | |
| 133 | return (used.user + used.system) / 1000; | |
| 134 | }; | |
| 135 | ||
| 136 | const median = (xs: number[]): number => | |
| 137 | [...xs].sort((a, b) => a - b)[(xs.length - 1) >> 1] as number; | |
| 138 | ||
| 139 | /** Batches thrown away before any are kept, so nothing measured is cold. */ | |
| 140 | const WARM = 2; | |
| 141 | /** Batches kept, of which the median is the answer. Odd, so there is one. */ | |
| 142 | const BATCHES = 7; | |
| 143 | ||
| 144 | export type Measured = { label: string; each: number; wall: number; cpu: number }; | |
| 145 | ||
| 146 | /** One batch's worth of world-seconds, timed one at a time. */ | |
| 147 | const secondsOf = (ticks: number, step: () => void): number[] => { | |
| 148 | const out: number[] = []; | |
| 149 | for (let i = 0; i < ticks; i += 64) { | |
| 150 | const wall = performance.now(); | |
| 151 | const cpu = cpuMs(); | |
| 152 | for (let j = 0; j < Math.min(64, ticks - i); j++) step(); | |
| 153 | out.push(Math.min(performance.now() - wall, cpuMs() - cpu)); | |
| 154 | } | |
| 155 | return out; | |
| 156 | }; | |
| 157 | ||
| 158 | /** | |
| 159 | * The cost of a world-second across a round rather than averaged over one: a | |
| 160 | * room's first second is ten bots walking out of spawn and its fifth is a | |
| 161 | * firefight, and a deployment is sized by the dear second and not the cheap | |
| 162 | * one. `make` hands back a fresh room per batch; `step` takes one tick of it. | |
| 163 | */ | |
| 164 | export function profile(ticks: number, make: () => () => void): { mean: number; peak: number; at: number } { | |
| 165 | const means: number[] = []; | |
| 166 | const peaks: number[] = []; | |
| 167 | const ats: number[] = []; | |
| 168 | for (let b = 0; b < WARM + BATCHES; b++) { | |
| 169 | const each = secondsOf(ticks, make()); | |
| 170 | if (b < WARM) continue; | |
| 171 | means.push(each.reduce((a, c) => a + c, 0) / each.length); | |
| 172 | const peak = Math.max(...each); | |
| 173 | peaks.push(peak); | |
| 174 | ats.push(each.indexOf(peak) + 1); | |
| 175 | } | |
| 176 | return { mean: median(means), peak: median(peaks), at: median(ats) }; | |
| 177 | } | |
| 178 | ||
| 179 | /** | |
| 180 | * `runs` of something, warmed and repeated, in milliseconds each. `batch` does | |
| 181 | * `runs` operations and is called once per batch; the answer is the median of | |
| 182 | * `BATCHES` kept batches on the tighter of the two clocks. | |
| 183 | */ | |
| 184 | function measure(label: string, runs: number, batch: () => void): Measured { | |
| 185 | const walls: number[] = []; | |
| 186 | const cpus_: number[] = []; | |
| 187 | for (let i = 0; i < WARM + BATCHES; i++) { | |
| 188 | const wall = performance.now(); | |
| 189 | const cpu = cpuMs(); | |
| 190 | batch(); | |
| 191 | if (i < WARM) continue; | |
| 192 | walls.push((performance.now() - wall) / runs); | |
| 193 | cpus_.push((cpuMs() - cpu) / runs); | |
| 194 | } | |
| 195 | const wall = median(walls); | |
| 196 | const cpu = median(cpus_); | |
| 197 | return { label, each: Math.min(wall, cpu), wall, cpu }; | |
| 198 | } | |
| 199 | ||
| 200 | // --- the room being measured ---------------------------------------------- | |
| 201 | ||
| 202 | /** How many bodies a room has. Ten, like the game. */ | |
| 203 | const BODIES = 10; | |
| 204 | ||
| 205 | /** | |
| 206 | * A room with its round already under way: the same fixed map and seed every | |
| 207 | * time, and the freeze set aside for the reason in the header. Fresh per batch, | |
| 208 | * because a world played for thousands of ticks is a world where people have | |
| 209 | * died and a dead body does not think — the measurement would drift downwards | |
| 210 | * into a number about nothing. | |
| 211 | */ | |
| 212 | function liveRoom(seed = 9): Room { | |
| 213 | const room = createRoom("BENCH1", { map: "de_forge", seed, length: "short", now: 0 }); | |
| 214 | room.world.match.phase = "live"; | |
| 215 | room.world.match.timer = room.rules.roundTime; | |
| 216 | return room; | |
| 217 | } | |
| 218 | ||
| 219 | /** The same room with every seat held by a member, so no body is left to think. */ | |
| 220 | function heldRoom(seed = 9): { room: Room; ids: string[] } { | |
| 221 | const room = liveRoom(seed); | |
| 222 | const ids: string[] = []; | |
| 223 | for (let i = 0; i < BODIES; i++) { | |
| 224 | const id = `member-${i}`; | |
| 225 | if (join(room, id, id, 0) !== null) ids.push(id); | |
| 226 | } | |
| 227 | return { room, ids }; | |
| 228 | } | |
| 229 | ||
| 230 | /** One input frame per member per tick, the way a page sends them: keys down. */ | |
| 231 | function driveAll(room: Room, ids: readonly string[], frames: Uint8Array<ArrayBuffer>[]): void { | |
| 232 | ids.forEach((id, i) => { | |
| 233 | writeInput( | |
| 234 | { tick: room.tick, input: { ...NO_INPUT, forward: 1 }, yaw: 90, pitch: 0, switchCode: 0, buy: null }, | |
| 235 | frames[i] as Uint8Array<ArrayBuffer>, | |
| 236 | ); | |
| 237 | drive(room, id, frames[i] as Uint8Array); | |
| 238 | }); | |
| 239 | } | |
| 240 | ||
| 241 | // --- the three encodings a wire could use --------------------------------- | |
| 242 | ||
| 243 | /** | |
| 244 | * The same snapshot as plain data: every field `writeSnapshot` packs, in the | |
| 245 | * shape a JSON wire would send it. It exists so the comparison below is between | |
| 246 | * two encodings of *one* frame rather than between a packed snapshot and a | |
| 247 | * smaller idea of one — the ten bodies, the grenades in the air, the smoke | |
| 248 | * standing, the bomb, the clock, and the events since the member's last beat. | |
| 249 | * | |
| 250 | * Nothing chooses an encoding here. This measures three candidates; the choice | |
| 251 | * and its reasons are `src/rooms/wire.ts`'s, and a reader comparing the numbers | |
| 252 | * should read that file's header for what the packed one buys. | |
| 253 | * | |
| 254 | * Numbers are rounded the way an honest JSON wire would round them — positions | |
| 255 | * to a hundredth of a unit, angles to a tenth of a degree — because | |
| 256 | * `JSON.stringify` of an f32 writes seventeen significant digits and charging | |
| 257 | * JSON for those would be measuring a strawman. | |
| 258 | */ | |
| 259 | export function jsonSnapshot(world: World, tick: number, since: number): unknown { | |
| 260 | const r2 = (v: number): number => Math.round(v * 100) / 100; | |
| 261 | const r1 = (v: number): number => Math.round(v * 10) / 10; | |
| 262 | const m = world.match; | |
| 263 | return { | |
| 264 | t: tick, | |
| 265 | time: r2(world.time), | |
| 266 | phase: m.phase, | |
| 267 | round: m.round, | |
| 268 | score: [m.score.t, m.score.ct], | |
| 269 | timer: r2(m.timer), | |
| 270 | winner: m.matchWinner, | |
| 271 | bomb: { | |
| 272 | state: world.bomb.state, | |
| 273 | pos: world.bomb.pos.map(r2), | |
| 274 | plant: r2(world.bomb.plantProgress), | |
| 275 | defuse: r2(world.bomb.defuseProgress), | |
| 276 | site: world.bomb.site, | |
| 277 | }, | |
| 278 | players: world.players.map((p: Player) => ({ | |
| 279 | pos: p.pos.map(r2), | |
| 280 | vel: p.vel.map(r2), | |
| 281 | yaw: r1(p.yaw), | |
| 282 | pitch: r1(p.pitch), | |
| 283 | health: Math.round(p.health), | |
| 284 | armor: Math.round(p.armor), | |
| 285 | alive: p.alive, | |
| 286 | team: p.team, | |
| 287 | onGround: p.onGround, | |
| 288 | ducking: p.ducking, | |
| 289 | duck: r2(p.duckAmount), | |
| 290 | bomb: p.hasBomb, | |
| 291 | helmet: p.helmet, | |
| 292 | kit: p.defuseKit, | |
| 293 | scoped: p.scoped, | |
| 294 | weapon: p.weapon, | |
| 295 | primary: p.primary, | |
| 296 | secondary: p.secondary, | |
| 297 | mag: p.ammo[p.weapon]?.mag ?? 0, | |
| 298 | reserve: p.ammo[p.weapon]?.reserve ?? 0, | |
| 299 | money: Math.round(p.money), | |
| 300 | blind: r2(p.blind), | |
| 301 | pocket: [p.pocket.he, p.pocket.flash, p.pocket.smoke], | |
| 302 | kills: p.kills, | |
| 303 | deaths: p.deaths, | |
| 304 | damage: Math.round(p.damage), | |
| 305 | })), | |
| 306 | grenades: world.grenades.map((g) => ({ | |
| 307 | pos: g.pos.map(r2), weapon: g.weapon, owner: g.owner, team: g.team, fuse: r2(g.fuse), | |
| 308 | })), | |
| 309 | clouds: world.clouds.map((s) => ({ pos: s.pos.map(r2), age: r2(world.time - s.born), owner: s.owner })), | |
| 310 | events: eventsSince(world, since).map((e) => ({ ...e, at: r2(e.at) })), | |
| 311 | }; | |
| 312 | } | |
| 313 | ||
| 314 | /** Everything in the world's effect ring younger than `since`, oldest first. */ | |
| 315 | function eventsSince(world: World, since: number): Effect[] { | |
| 316 | const out: Effect[] = []; | |
| 317 | const ring = world.effects; | |
| 318 | for (let i = 0; i < ring.count; i++) { | |
| 319 | const e = ring.events[i]; | |
| 320 | if (e !== undefined && e.at > since) out.push(e); | |
| 321 | } | |
| 322 | return out; | |
| 323 | } | |
| 324 | ||
| 325 | export type Frame = { packed: number; json: number; gzip: number }; | |
| 326 | ||
| 327 | /** What one beat weighs in each of the three encodings, in bytes. */ | |
| 328 | export function encodings(world: World, tick: number, since: number, into: Uint8Array): Frame { | |
| 329 | const packed = writeSnapshot(world, tick, since, into).byteLength; | |
| 330 | const text = JSON.stringify(jsonSnapshot(world, tick, since)); | |
| 331 | const bytes = Buffer.byteLength(text, "utf8"); | |
| 332 | return { packed, json: bytes, gzip: Bun.gzipSync(Buffer.from(text, "utf8"), { level: GZIP_LEVEL }).byteLength }; | |
| 333 | } | |
| 334 | ||
| 335 | // --- printing ------------------------------------------------------------- | |
| 336 | ||
| 337 | const ms = (v: number): string => `${v.toFixed(v < 1 ? 3 : 2)} ms`; | |
| 338 | const kb = (v: number): string => (v < 1000 ? `${Math.round(v)} B` : `${(v / 1000).toFixed(1)} kB`); | |
| 339 | const pad = (text: string, width: number): string => text.padEnd(width); | |
| 340 | const rpad = (text: string, width: number): string => text.padStart(width); | |
| 341 | ||
| 342 | /** Which machine this ran on, because a number from a laptop is not production. */ | |
| 343 | export const machine = (): string => | |
| 344 | `${cpus()[0]?.model.trim() ?? "unknown CPU"} × ${cpus().length}` + | |
| 345 | `, bun ${Bun.version}, ${process.platform}/${process.arch}`; | |
| 346 | ||
| 347 | async function main(options: Options): Promise<void> { | |
| 348 | useShipped(); // a bot with no policy does not think, and would not be measured | |
| 349 | const ticks = Math.round(options.seconds * 64); | |
| 350 | const seconds = ticks * TICK; | |
| 351 | const buffer = new Uint8Array(SNAPSHOT_MAX); | |
| 352 | ||
| 353 | console.log(`what a room costs — ten bodies on de_forge at 64 Hz, ${seconds.toFixed(2)} simulated seconds a batch`); | |
| 354 | console.log(` ${machine()}`); | |
| 355 | console.log(` median of ${BATCHES} batches after ${WARM}, on the tighter of the wall and CPU clocks\n`); | |
| 356 | ||
| 357 | // --- the step --- | |
| 358 | // The rooms of every batch are kept so the round each measurement really | |
| 359 | // played can be asked afterwards how many bodies were still standing in it. | |
| 360 | const played: Room[] = []; | |
| 361 | const thinking = profile(ticks, () => { | |
| 362 | const room = liveRoom(); | |
| 363 | played.push(room); | |
| 364 | return () => stepRoom(room); | |
| 365 | }); | |
| 366 | const aliveAfter = (played[played.length - 1] as Room).world.players.filter((p) => p.alive).length; | |
| 367 | ||
| 368 | // The other half of the pair, and the only way this game has of stepping a | |
| 369 | // world with no policy in the loop: a bot without one does not think, it | |
| 370 | // throws (`policy()` in `src/client/bots.ts`), so "the policy unloaded" is a | |
| 371 | // room whose every seat is held by a member sending keys. | |
| 372 | const frames = Array.from({ length: BODIES }, () => new Uint8Array(14)); | |
| 373 | const idle = profile(ticks, () => { | |
| 374 | const { room, ids } = heldRoom(); | |
| 375 | return () => { | |
| 376 | driveAll(room, ids, frames); | |
| 377 | stepRoom(room); | |
| 378 | }; | |
| 379 | }); | |
| 380 | ||
| 381 | // What the wire itself costs inside that number: ten input frames written and | |
| 382 | // read every tick, which the thinking room above does not pay at all. | |
| 383 | const wire = measure(" of which the input frames off the wire", seconds, () => { | |
| 384 | const { room, ids } = heldRoom(); | |
| 385 | for (let i = 0; i < ticks; i++) driveAll(room, ids, frames); | |
| 386 | }); | |
| 387 | ||
| 388 | console.log("CPU per simulated world-second"); | |
| 389 | console.log(` ${pad("", 40)}${rpad("average", 10)}${rpad("dearest second", 18)}`); | |
| 390 | const step = (label: string, m: { mean: number; peak: number; at: number }): void => | |
| 391 | console.log(` ${pad(label, 40)}${rpad(ms(m.mean), 10)}${rpad(`${ms(m.peak)} (no. ${m.at})`, 18)}`); | |
| 392 | step("ten bots thinking, the shipped policy", thinking); | |
| 393 | step("ten seats driven, nothing thinking", idle); | |
| 394 | console.log(` ${pad(wire.label, 40)}${rpad(ms(wire.each), 10)}`); | |
| 395 | // Ten bodies are stepped either way; what the first row has and the second | |
| 396 | // does not is ten policies thinking, and what the second has and the first | |
| 397 | // does not is the wire. So the policy's share is the difference, with the | |
| 398 | // wire handed back — which is an estimate and is called one: bots that think | |
| 399 | // also shoot, throw and die, and a body walking forward into a wall does not. | |
| 400 | const share = thinking.mean - (idle.mean - wire.each); | |
| 401 | console.log( | |
| 402 | ` ${pad("so the ten policies cost about", 40)}${rpad(ms(share), 10)}` + | |
| 403 | ` (${((share / thinking.mean) * 100).toFixed(0)}% of a thinking room)`, | |
| 404 | ); | |
| 405 | console.log( | |
| 406 | ` ${aliveAfter} of ${BODIES} bodies were still alive after ${seconds.toFixed(1)}s` + | |
| 407 | ` of the thinking round, which is what the numbers above are seconds of\n`, | |
| 408 | ); | |
| 409 | ||
| 410 | // --- the snapshot, over a real round --- | |
| 411 | const beats: Frame[] = []; | |
| 412 | { | |
| 413 | const room = liveRoom(); | |
| 414 | const every = Math.max(1, Math.round(64 / SNAPSHOT_HZ)); | |
| 415 | let since = 0; | |
| 416 | for (let i = 0; i < ticks; i++) { | |
| 417 | stepRoom(room); | |
| 418 | if (i % every !== 0) continue; | |
| 419 | beats.push(encodings(room.world, room.tick, since, buffer)); | |
| 420 | since = room.world.time; | |
| 421 | } | |
| 422 | } | |
| 423 | const pick = (of: (f: Frame) => number): { med: number; max: number } => ({ | |
| 424 | med: median(beats.map(of)), | |
| 425 | max: Math.max(...beats.map(of)), | |
| 426 | }); | |
| 427 | const sizes = { | |
| 428 | "packed binary": pick((f) => f.packed), | |
| 429 | "JSON": pick((f) => f.json), | |
| 430 | [`JSON gzipped (level ${GZIP_LEVEL})`]: pick((f) => f.gzip), | |
| 431 | }; | |
| 432 | ||
| 433 | console.log(`a snapshot of this round, over ${beats.length} beats at ${SNAPSHOT_HZ} Hz`); | |
| 434 | for (const [name, size] of Object.entries(sizes)) | |
| 435 | console.log(` ${pad(name, 30)} median ${rpad(kb(size.med), 8)} widest ${rpad(kb(size.max), 8)}`); | |
| 436 | console.log(); | |
| 437 | ||
| 438 | console.log(`bytes a member a second, at the median beat`); | |
| 439 | console.log(` ${pad("", 30)}${RATES.map((hz) => rpad(`${hz} Hz`, 12)).join("")}`); | |
| 440 | for (const [name, size] of Object.entries(sizes)) | |
| 441 | console.log( | |
| 442 | ` ${pad(name, 30)}` + | |
| 443 | RATES.map((hz) => rpad(`${kb(perMemberPerSecond(size.med, hz))}/s`, 12)).join(""), | |
| 444 | ); | |
| 445 | console.log(); | |
| 446 | ||
| 447 | // --- what encoding a beat costs this process --- | |
| 448 | const busy = liveRoom(); | |
| 449 | for (let i = 0; i < ticks; i++) stepRoom(busy); | |
| 450 | // One beat's worth of "since", not the whole ring: what a member is sent | |
| 451 | // sixteen times a second is the round plus what happened in the last | |
| 452 | // sixteenth of it, and costing them the whole ring would be costing a frame | |
| 453 | // nobody is ever sent. | |
| 454 | const since = busy.world.time - 1 / SNAPSHOT_HZ; | |
| 455 | const runs = 200; | |
| 456 | const encode = [ | |
| 457 | measure("packed binary", runs, () => { | |
| 458 | for (let i = 0; i < runs; i++) writeSnapshot(busy.world, busy.tick, since, buffer); | |
| 459 | }), | |
| 460 | measure("JSON", runs, () => { | |
| 461 | for (let i = 0; i < runs; i++) JSON.stringify(jsonSnapshot(busy.world, busy.tick, since)); | |
| 462 | }), | |
| 463 | measure(`JSON gzipped (level ${GZIP_LEVEL})`, runs, () => { | |
| 464 | for (let i = 0; i < runs; i++) | |
| 465 | Bun.gzipSync(Buffer.from(JSON.stringify(jsonSnapshot(busy.world, busy.tick, since)), "utf8"), { level: GZIP_LEVEL }); | |
| 466 | }), | |
| 467 | ]; | |
| 468 | console.log("CPU to encode one beat, and what that is a world-second for a full room"); | |
| 469 | for (const m of encode) | |
| 470 | console.log( | |
| 471 | ` ${pad(m.label, 30)} ${rpad(ms(m.each), 9)} a beat` + | |
| 472 | ` ${rpad(ms(m.each * SNAPSHOT_HZ * options.members), 9)} a world-second` + | |
| 473 | ` (${options.members} members at ${SNAPSHOT_HZ} Hz)`, | |
| 474 | ); | |
| 475 | console.log(); | |
| 476 | ||
| 477 | // --- the answer --- | |
| 478 | const packedCost = (encode[0] as Measured).each * SNAPSHOT_HZ * options.members; | |
| 479 | const whole = thinking.mean + packedCost; | |
| 480 | const dearest = thinking.peak + packedCost; | |
| 481 | const row = (label: string, each: number): void => | |
| 482 | console.log( | |
| 483 | ` ${pad(label, 46)} ${rpad(String(roomsPerCore(each, options.margin)), 4)} rooms` + | |
| 484 | ` (${roomsPerCore(each, 1)} flat out, at ${ms(each)} a room-second)`, | |
| 485 | ); | |
| 486 | console.log(`rooms one core holds at 64 Hz, with ${((1 - options.margin) * 100).toFixed(0)}% of it left for everything else`); | |
| 487 | row("the step alone, bots thinking", thinking.mean); | |
| 488 | row("the step alone, nobody thinking", idle.mean); | |
| 489 | row(`a whole room: step and ${options.members} packed snapshots`, whole); | |
| 490 | row("the same, priced at the round's dearest second", dearest); | |
| 491 | console.log( | |
| 492 | `\none room costs ${ms(whole)} of CPU a second — the step ${ms(thinking.mean)}` + | |
| 493 | ` and ${options.members} snapshots ${ms(packedCost)} — so one core holds ${roomsPerCore(whole, options.margin)}` + | |
| 494 | ` of them at ${SNAPSHOT_HZ} Hz with ${((1 - options.margin) * 100).toFixed(0)}% of the core to spare,` + | |
| 495 | ` or ${roomsPerCore(dearest, options.margin)} if every one of them is having its dearest second at once.`, | |
| 496 | ); | |
| 497 | } | |
| 498 | ||
| 499 | if (import.meta.main) { | |
| 500 | try { | |
| 501 | await main(parseOptions(process.argv.slice(2))); | |
| 502 | } catch (error) { | |
| 503 | console.error(error instanceof Error ? error.message : String(error)); | |
| 504 | process.exit(1); | |
| 505 | } | |
| 506 | } |
src/budget.test.ts +119 −6
| @@ -8,16 +8,23 @@ | ||
| 8 | 8 | * for the same reason: both claims are about the two games at once, and neither |
| 9 | 9 | * game may import the other. |
| 10 | 10 | * |
| 11 | * **This is not a bench.** #20 is a script a person runs to read numbers. This | |
| 12 | * is a ceiling the suite enforces on every push, and nothing here is meant to | |
| 13 | * be read as "how fast is it": for that, run the bench. Merging the two would | |
| 14 | * lose the only thing this file is for — failing. | |
| 11 | * **This is not a bench.** #20 is a script a person runs to read numbers, and | |
| 12 | * `bun run scripts/room.ts` is the one for the room below. This is a ceiling | |
| 13 | * the suite enforces on every push, and nothing here is meant to be read as | |
| 14 | * "how fast is it": for that, run the bench. Merging the two would lose the | |
| 15 | * only thing this file is for — failing. | |
| 15 | 16 | * |
| 16 | 17 | * ## What is measured |
| 17 | 18 | * |
| 18 | 19 | * - One tick of the shooter at 64 Hz on a live round: ten bodies on de_forge, |
| 19 | 20 | * nine of them bots running the shipped policy, plus the grenades, the bomb |
| 20 | 21 | * and the rules clock. This is the whole simulation a playing frame steps. |
| 22 | * - **A world-second of a room**, which is the same simulation stepped by this | |
| 23 | * process instead of by a browser: `createRoom` and sixty-four `stepRoom`s, | |
| 24 | * ten bots thinking, which is what a deployment pays per room per second of | |
| 25 | * play. A frame's budget is a person's patience; a room-second's budget is a | |
| 26 | * core, and `ROOM_CAP` in `src/rooms/hub.ts` is one divided by the other, so | |
| 27 | * this is the number that decides how many rooms one box holds. | |
| 21 | 28 | * - `buildScene` on the world that tick left, with a firefight's worth of |
| 22 | 29 | * effects in the ring — bodies, the bomb, tracers, sparks and blood — which |
| 23 | 30 | * is a hungry frame rather than a quiet one. |
| @@ -42,6 +49,20 @@ | ||
| 42 | 49 | * 0.022 ms and 0.0010 ms — still under their ceilings by four, seven and twenty |
| 43 | 50 | * times over. |
| 44 | 51 | * |
| 52 | * The room-second is the same argument at a different scale, and it is worth | |
| 53 | * saying exactly which second it is: the **first** of a live round, which is | |
| 54 | * ten bots walking out of spawn, and which reads about **4.6 ms** here. A round | |
| 55 | * gets dearer as it meets itself — `bun run scripts/room.ts` measures an | |
| 56 | * eight-second window on the forge's box, eighty cores at a load average of | |
| 57 | * twenty, and reads 5.4–8.1 ms an average second with 10–24 ms in the dearest | |
| 58 | * one — so the ceiling is set against the bench's numbers and not against this | |
| 59 | * test's own. It is **40 ms**: roughly five times an average second of play, | |
| 60 | * four per cent of the second a room has to be stepped inside, and still above | |
| 61 | * the dearest second this box has produced. What it catches is a change that | |
| 62 | * makes the simulation *several* times dearer, not one that makes it a fifth | |
| 63 | * dearer — a tighter ceiling on a shared box is a red build about somebody | |
| 64 | * else's job, which nobody would read twice. | |
| 65 | * | |
| 45 | 66 | * The multiple is deliberately larger where the number is smaller. Clock |
| 46 | 67 | * resolution and scheduler jitter are absolute, not proportional: ten times |
| 47 | 68 | * 0.3 µs is inside the noise of any machine, so the range's ceiling is sixty |
| @@ -83,6 +104,7 @@ import { useShipped } from "./client/shipped.ts"; | ||
| 83 | 104 | import { NO_INPUT, createWorld, update, type Effect, type World } from "./client/world.ts"; |
| 84 | 105 | import { buildRangeScene, createRangeScene } from "./range/scene.ts"; |
| 85 | 106 | import { EYE, createRun, start, step } from "./range/targets.ts"; |
| 107 | import { createRoom, stepRoom, type Room } from "./rooms/room.ts"; | |
| 86 | 108 | |
| 87 | 109 | /** The fixed simulation step, and the wall clock a frame of it has to fit in. */ |
| 88 | 110 | const TICK = 1 / 64; |
| @@ -94,6 +116,13 @@ const TICK_CEILING = 1.0; | ||
| 94 | 116 | const SCENE_CEILING = 0.15; |
| 95 | 117 | /** The range's scene. Today: 0.0003 ms — see the note on small numbers above. */ |
| 96 | 118 | const RANGE_SCENE_CEILING = 0.02; |
| 119 | /** | |
| 120 | * One second of play in one room, stepped here. Today: 4.6 ms for the second | |
| 121 | * this test takes, 5.4–8.1 ms for an average one on the forge's box under load | |
| 122 | * — see the note above, and `bun run scripts/room.ts` for where the numbers | |
| 123 | * come from and what a deployment does with them. | |
| 124 | */ | |
| 125 | const ROOM_SECOND_CEILING = 40; | |
| 97 | 126 | |
| 98 | 127 | /** Batches thrown away before any are kept, so nothing measured is cold. */ |
| 99 | 128 | const WARM = 2; |
| @@ -131,13 +160,21 @@ function timed(runs: number, work: () => void): Batch { | ||
| 131 | 160 | */ |
| 132 | 161 | const PATIENCE = 60_000; |
| 133 | 162 | |
| 163 | /** | |
| 164 | * What a ceiling here is a slice of. A frame has 15.625 ms of a person's | |
| 165 | * patience; a room-second has a second of a core, which is the same sentence | |
| 166 | * about a different clock, and the printed line says which one it meant. | |
| 167 | */ | |
| 168 | const FRAME = { ms: FRAME_BUDGET, name: "frame" }; | |
| 169 | const ROOM_SECOND = { ms: 1000, name: "second a room has" }; | |
| 170 | ||
| 134 | 171 | /** |
| 135 | 172 | * Run `batch` — which times some number of operations and hands back the |
| 136 | 173 | * milliseconds one of them cost — until it is warm, then seven more times, and |
| 137 | 174 | * return the median. The number is printed, because a green run should still |
| 138 | 175 | * tell a person what a frame costs today. |
| 139 | 176 | */ |
| 140 | function measure(label: string, ceiling: number, batch: () => Batch): number { | |
| 177 | function measure(label: string, ceiling: number, batch: () => Batch, within = FRAME): number { | |
| 141 | 178 | for (let i = 0; i < WARM; i++) batch(); |
| 142 | 179 | const kept: Batch[] = []; |
| 143 | 180 | for (let i = 0; i < BATCHES; i++) kept.push(batch()); |
| @@ -147,7 +184,7 @@ function measure(label: string, ceiling: number, batch: () => Batch): number { | ||
| 147 | 184 | console.log( |
| 148 | 185 | ` ${label}: ${cost.toFixed(4)} ms` + |
| 149 | 186 | ` (median of ${BATCHES}: wall ${wall.toFixed(4)}, cpu ${cpu.toFixed(4)};` + |
| 150 | ` ceiling ${ceiling} ms of a ${FRAME_BUDGET.toFixed(3)} ms frame)`, | |
| 187 | ` ceiling ${ceiling} ms of a ${within.ms.toFixed(3)} ms ${within.name})`, | |
| 151 | 188 | ); |
| 152 | 189 | return cost; |
| 153 | 190 | } |
| @@ -225,6 +262,82 @@ test("a tick with ten clouds of smoke standing is still well inside the budget", | ||
| 225 | 262 | expect(clouds).toBe(10); |
| 226 | 263 | }, PATIENCE); |
| 227 | 264 | |
| 265 | /** | |
| 266 | * A room's own round, whose freeze is set aside the way `liveRound` sets it | |
| 267 | * aside and for the same reason. Every one of the ten bodies is a bot here — | |
| 268 | * `createRoom` makes `world.me` one too, because a room has seats and not a | |
| 269 | * person — so this is ten policies thinking and not nine. | |
| 270 | */ | |
| 271 | function liveRoom(seed: number): Room { | |
| 272 | const room = createRoom("BUDGET", { map: "de_forge", seed, length: "short", now: 0 }); | |
| 273 | room.world.match.phase = "live"; | |
| 274 | room.world.match.timer = room.rules.roundTime; | |
| 275 | return room; | |
| 276 | } | |
| 277 | ||
| 278 | /** | |
| 279 | * What this process pays to *be* one room for one second of play. It is the | |
| 280 | * number `ROOM_CAP` is divided out of, so the day the simulation gets several | |
| 281 | * times dearer this is where it is read — before twenty rooms full of people | |
| 282 | * find out for themselves. | |
| 283 | * | |
| 284 | * The snapshots are not in here: what they weigh is pinned in | |
| 285 | * `src/rooms/wire.test.ts` and what they cost is printed by | |
| 286 | * `bun run scripts/room.ts`, and this holds the one number that is the room | |
| 287 | * whether anybody is watching it or not. | |
| 288 | */ | |
| 289 | test("a second of play in one room costs a small fraction of the second it had", () => { | |
| 290 | useShipped(); // a bot with no policy does not think, and would not be measured | |
| 291 | let alive = 0; | |
| 292 | const each = measure( | |
| 293 | "a room-second: 64 steps of ten bodies, all ten thinking", | |
| 294 | ROOM_SECOND_CEILING, | |
| 295 | () => { | |
| 296 | // A fresh round per batch, for the reason the tick above takes one. | |
| 297 | const room = liveRoom(7); | |
| 298 | const batch = timed(1, () => { | |
| 299 | for (let i = 0; i < TICKS; i++) stepRoom(room); | |
| 300 | }); | |
| 301 | alive = room.world.players.filter((p) => p.alive).length; | |
| 302 | return batch; | |
| 303 | }, | |
| 304 | ROOM_SECOND, | |
| 305 | ); | |
| 306 | ||
| 307 | expect(each).toBeLessThan(ROOM_SECOND_CEILING); | |
| 308 | // And the second that was measured really was ten bodies thinking: a room | |
| 309 | // that had emptied out would be a cheap number about nothing. | |
| 310 | expect(alive).toBe(10); | |
| 311 | }, PATIENCE); | |
| 312 | ||
| 313 | /** | |
| 314 | * And the room's ceiling bites too, for the reason the frame's does. Sixteen | |
| 315 | * seconds of play charged as one is a simulation sixteen times dearer — nothing | |
| 316 | * about the room changed, it is simply asked for sixteen times the work inside | |
| 317 | * the second it had — and the measurement notices and the ceiling refuses it. | |
| 318 | * Sixteen and not two, for the same reason the frame's proof is written at | |
| 319 | * sixty-four substeps and not at ten: the margin between the ceiling and | |
| 320 | * today's cost is what keeps a busy box from turning this file red, and a proof | |
| 321 | * has to clear that margin on a machine slower than this one as well. | |
| 322 | */ | |
| 323 | test("a room-second that got several times dearer is past the ceiling", () => { | |
| 324 | useShipped(); | |
| 325 | const dearer = 16; | |
| 326 | const each = measure( | |
| 327 | `a room-second asked to step ${dearer} of them, to prove the ceiling bites`, | |
| 328 | ROOM_SECOND_CEILING, | |
| 329 | () => { | |
| 330 | const room = liveRoom(7); | |
| 331 | return timed(1, () => { | |
| 332 | for (let i = 0; i < TICKS * dearer; i++) stepRoom(room); | |
| 333 | }); | |
| 334 | }, | |
| 335 | ROOM_SECOND, | |
| 336 | ); | |
| 337 | ||
| 338 | expect(each).toBeGreaterThan(ROOM_SECOND_CEILING); | |
| 339 | }, PATIENCE); | |
| 340 | ||
| 228 | 341 | /** |
| 229 | 342 | * The guard's own guard. A ceiling nothing can reach is a green light wired to |
| 230 | 343 | * nothing: a loop the JIT lifted out, a clock read twice in a row, a batch that |
src/rooms/hub.ts +17 −12
| @@ -1,13 +1,18 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Every room this process is holding, and the one clock that steps them. |
| 3 | 3 | * |
| 4 | * A room is CPU on a shared box, so this file is mostly numbers with reasons. | |
| 5 | * One room is one world stepped 64 times a second: measured by | |
| 6 | * `src/budget.test.ts` a tick of ten bodies with nine of them thinking costs | |
| 7 | * about 0.08 ms, so a room costs about **5 ms of CPU a second** — half a per | |
| 8 | * cent of one core — and `ROOM_CAP` rooms at once is a tenth of a core plus the | |
| 9 | * snapshots. That is the whole argument for the cap being where it is, and it is | |
| 10 | * the number to revisit before raising it. | |
| 4 | * A room is CPU on a shared box, so this file is mostly numbers with reasons, | |
| 5 | * and the numbers are measured rather than assumed: `bun run scripts/room.ts` | |
| 6 | * steps a real room and prints them. On the forge's own box a room-second of | |
| 7 | * ten bots thinking costs **5.4–8.1 ms** — 10–24 ms in the round's dearest | |
| 8 | * second — and ten packed snapshots a second on top of it about **1 ms**, so a | |
| 9 | * whole room is about **6.4–9.0 ms of CPU a second**, under one per cent of a | |
| 10 | * core. `ROOM_CAP` rooms at once is therefore about a sixth of a core, and one | |
| 11 | * core would hold fifty to eighty of them. That is the whole argument for the | |
| 12 | * cap being where it is: it is a choice about a box that also serves pages, not | |
| 13 | * a ceiling the simulation put there. `src/budget.test.ts` holds a room-second | |
| 14 | * under 40 ms so the day the simulation gets several times dearer this file's | |
| 15 | * arithmetic goes red rather than silently wrong. | |
| 11 | 16 | * |
| 12 | 17 | * Nothing here is a socket either. The hub is handed a `Wire` — deliver these |
| 13 | 18 | * bytes to that member, close that member — and the server is the only file that |
| @@ -29,11 +34,11 @@ import { | ||
| 29 | 34 | import type { PlayedMap } from "../mapname.ts"; |
| 30 | 35 | |
| 31 | 36 | /** |
| 32 | * How many rooms one deployment holds at once. Twenty rooms is about 0.1 s of | |
| 33 | * CPU a second — a tenth of a core — against a box that also serves pages, and | |
| 34 | * two hundred people in them at ten a room. Past it a create is refused with a | |
| 35 | * sentence, which is the honest answer: a twenty-first room would make the | |
| 36 | * other twenty judder. | |
| 37 | * How many rooms one deployment holds at once. Twenty rooms is a measured | |
| 38 | * 0.13–0.18 s of CPU a second — about a sixth of a core — against a box that | |
| 39 | * also serves pages, and two hundred people in them at ten a room. Past it a | |
| 40 | * create is refused with a sentence, which is the honest answer: a twenty-first | |
| 41 | * room would make the other twenty judder. | |
| 37 | 42 | */ |
| 38 | 43 | export const ROOM_CAP = 20; |
| 39 | 44 |
new above
offline