Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/probe_diamondheap.mjs

8.8 KiB, 1 run

created by r2519314175:43, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// probe_diamondheap.mjs — where does `list_diamonds` put 1.6 GB?
2//
3// THE MEASUREMENT THIS IS CHASING. An iPhone's trail, seq 82:
4//
5// ok panels.reflow heap 1M
6// list_diamonds start
7// list 15 entries
8// list entry … ×3
9// ok connectGateway heap 235M
10// …
11// heap high 1639M
12// boot <- the tab is gone, ~1.5s later
13//
14// Wasm linear memory only grows, so 1.6 GB is 1.6 GB for the rest of the tab's
15// life, and iOS kills it. Safe mode ruled SYNC out: with the engine off and no
16// pull at all, the heap still reached 1639 MB.
17//
18// AND THIS FILE RULED THE WALK OUT TOO, which is why it exists. The trail named
19// `list_diamonds` only because its `list entry` lines were the only clock ticks
20// visible while the heap climbed — correlation read as location. Measured here,
21// fifteen Diamonds carrying seven hundred and fifty version files between them
22// cost NOTHING. What remains is `connectGateway`, across which the phone reaches
23// 235 MB; and since `heap_bytes()` reports WASM linear memory and nothing else,
24// whatever does it is on the Rust side, where no amount of JavaScript can reach.
25//
26// A PROBE, NOT A VERIFIER. It asserts nothing about a correct app; it prints a
27// curve. The desktop will not kill the tab at 1.6 GB, but `heap_bytes()` reports
28// the same number here as there — so if the growth reproduces at all, the
29// culprit can be found tonight instead of after another round trip through a
30// phone the author cannot inspect.
31//
32// It walks up in steps, because the shape of the curve is the diagnosis:
33//
34// * flat with 0 Diamonds -> nothing in the walk's fixed cost
35// * a step per Diamond -> a per-entry allocation, and the trail's
36// `HEAP GREW <phase>` lines name which call
37// * flat until content is added -> it is proportional to what a Diamond HOLDS,
38// not to how many there are
39//
40// node dev/probe_diamondheap.mjs # 15 empty Diamonds
41// node dev/probe_diamondheap.mjs --fat # …each with a crystal and versions
42//
43// Needs dev/serve.mjs. No gateway; nothing spends.
44
45import { open, scratch } from './harness.mjs';
46import fs from 'node:fs';
47
48const FAT = process.argv.includes('--fat');
49const N = 15; // the phone holds fifteen
50
51const PROFILE = scratch('pw', 'dheap-' + process.pid);
52fs.rmSync(PROFILE, { recursive: true, force: true });
53
54const s = await open({ name: 'dheap', connect: false, profile: PROFILE });
55const p = s.page;
56
57/// The wasm heap, in whole megabytes, straight from the module.
58const heap = () => p.evaluate(() => {
59 try { return Math.round(DaimondCore.heapBytes() / 1048576); } catch (e) { return -1; }
60});
61
62/// One walk of the store, through the app's own door.
63const walk = () => p.evaluate(() => DaimondCore.loadDiamonds());
64
65if ((await heap()) < 0) {
66 console.log('the engine cannot report its heap — is this build older than seq 81?');
67 await s.close();
68 process.exit(1);
69}
70
71// ── FIRST, PROVE THE GAUGE ──────────────────────────────────────────
72//
73// Everything below is read off `heap_bytes()`, and it has reported exactly 1 MB
74// through every local experiment so far. A number that never moves cannot be
75// told from a number that CANNOT move, and this project has already lost four
76// sessions to instruments nobody had watched working — the panic hook meant
77// nothing until `panic_on_purpose` made it fire.
78//
79// So: allocate 64 MB inside the wasm on purpose and require the gauge to say so.
80// If it does not, nothing else this file prints is evidence of anything, and the
81// 235M/1639M on the phone would need re-reading too.
82{
83 const g = await p.evaluate(() => {
84 const mb = () => Math.round(DaimondCore.heapBytes() / 1048576);
85 const before = mb();
86 try { DaimondCore.growOnPurpose(64); } catch (e) { return { err: String(e) }; }
87 return { before, after: mb() };
88 });
89 if (g.err) {
90 console.log('GAUGE UNPROVED: ' + g.err + '\n');
91 } else {
92 const moved = g.after - g.before;
93 console.log(`gauge self-test: asked wasm for 64M, gauge went ${g.before}M -> ${g.after}M (+${moved}M)`);
94 console.log(moved >= 60
95 ? ' the gauge tracks a real allocation. Everything below is evidence.\n'
96 : ' IT DID NOT MOVE. Nothing below is evidence, and the phone trail needs re-reading.\n');
97 }
98}
99
100console.log(`probe: ${N} Diamonds, ${FAT ? 'each with a crystal and 5 versions' : 'empty'}\n`);
101console.log(` after boot, before any walk ${await heap()}M`);
102await walk();
103console.log(` one walk, empty store ${await heap()}M`);
104
105for (let i = 1; i <= N; i++) {
106 await p.evaluate(async (fat) => {
107 const app = DaimondCore.diamondApp();
108 const id = await app.create_diamond('Probe ' + Math.random().toString(36).slice(2, 8));
109 if (!fat) return;
110 // A crystal at very nearly the cap, snapshotted many times over. The
111 // crystal itself CANNOT be the source — `crystal_write_refused` holds it
112 // to 16 KiB, so fifteen of them are a quarter of a megabyte — but every
113 // write snapshots, and NOTHING PRUNES THE SNAPSHOTS. Version history is
114 // the one thing in a Diamond that grows without bound, which makes it the
115 // only candidate on the phone that could be measured in hundreds of
116 // megabytes. Fifty each here is ~11 MB across the store: if the walk
117 // allocates in proportion to what is on disk, that is enough to show a
118 // slope, and if the curve stays flat the history is exonerated too.
119 const body = 'lorem ipsum dolor sit amet '.repeat(555); // ~15 KB
120 // A fact per pass, so no two versions are the same bytes and the store cannot
121 // quietly collapse fifty writes into one.
122 for (let v = 0; v < 50; v++) {
123 await app.write_crystal_data(id, JSON.stringify(
124 { title: 'Probe', summary: body, facts: [{ k: 'pass', v: String(v) }] }));
125 }
126 }, FAT);
127
128 // Every third, so the output is a curve rather than a wall.
129 if (i % 3 === 0 || i === N) {
130 const before = await heap();
131 await walk();
132 const after = await heap();
133 console.log(` ${String(i).padStart(2)} Diamonds: before ${String(before).padStart(5)}M`
134 + ` after walk ${String(after).padStart(5)}M (+${after - before}M)`);
135 }
136}
137
138// The trail carries the per-call breakdown that seq 83 added; print whatever of
139// it names a phase, because that is the line this whole exercise is for.
140const grew = await p.evaluate(() => {
141 try {
142 return (DaimondTrail.rows() || [])
143 .filter((r) => r.w === 'HEAP GREW')
144 .map((r) => r.w + ' ' + r.d);
145 } catch (e) { return []; }
146});
147console.log(grew.length
148 ? '\nper-call growth, from the app\'s own trail:\n ' + grew.join('\n ')
149 : '\nno HEAP GREW rows: nothing in the per-entry loop grew the heap by a megabyte.');
150
151const errs = s.errs.filter((e) => !/favicon|401|402|502/i.test(e));
152if (errs.length) console.log('\nconsole errors:\n ' + errs.slice(0, 5).join('\n '));
153
154// ── What does ONE wasm object cost? ─────────────────────────────────
155//
156// `heap_bytes()` reports WASM LINEAR MEMORY and nothing else, which narrows the
157// hunt sharply: whatever takes the phone to 235 MB and then 1639 MB is on the
158// Rust side, because no amount of JavaScript can move this number. The trail
159// puts the first jump across `connectGateway`, and the wasm-side thing that
160// function can multiply is a `DaimondApp` — one per model a balance buys, and
161// the comment above `syncCredits` says "several hundred models".
162//
163// So: what is one worth? If a DaimondApp is hundreds of kilobytes, several
164// hundred of them is the 235 MB, and the arithmetic says so without anyone
165// having to believe a story about it.
166{
167 const step = await p.evaluate(async () => {
168 const mb = () => Math.round(DaimondCore.heapBytes() / 1048576);
169 const before = mb();
170 const apps = [];
171 // Twenty, not several hundred: enough to divide, cheap enough to be sure
172 // the cost is per-object rather than a one-off first-touch.
173 for (let i = 0; i < 20; i++) {
174 apps.push(DaimondCore.newApp('http://127.0.0.1/v1/chat/completions', '', 'probe-' + i));
175 }
176 const after = mb();
177 window.__probeApps = apps; // held, so nothing is collected before it is read
178 return { before, after, n: apps.length };
179 }).catch((e) => ({ err: String(e).split('\n')[0] }));
180 if (step.err) {
181 console.log('\nDaimondApp probe unavailable: ' + step.err);
182 } else {
183 const each = step.n ? (step.after - step.before) / step.n : 0;
184 console.log(`\n${step.n} DaimondApps: ${step.before}M -> ${step.after}M`
185 + ` (~${each.toFixed(1)}M each)`);
186 console.log(` at that rate, 300 of them would be ~${Math.round(each * 300)}M`
187 + ' — the phone reaches 235M across connectGateway.');
188 }
189}
190
191await s.close();
192try { fs.rmSync(PROFILE, { recursive: true, force: true }); } catch (e) { /* gone */ }