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 | |
| 45 | import { open, scratch } from './harness.mjs'; |
| 46 | import fs from 'node:fs'; |
| 47 | |
| 48 | const FAT = process.argv.includes('--fat'); |
| 49 | const N = 15; // the phone holds fifteen |
| 50 | |
| 51 | const PROFILE = scratch('pw', 'dheap-' + process.pid); |
| 52 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 53 | |
| 54 | const s = await open({ name: 'dheap', connect: false, profile: PROFILE }); |
| 55 | const p = s.page; |
| 56 | |
| 57 | /// The wasm heap, in whole megabytes, straight from the module. |
| 58 | const 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. |
| 63 | const walk = () => p.evaluate(() => DaimondCore.loadDiamonds()); |
| 64 | |
| 65 | if ((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 | |
| 100 | console.log(`probe: ${N} Diamonds, ${FAT ? 'each with a crystal and 5 versions' : 'empty'}\n`); |
| 101 | console.log(` after boot, before any walk ${await heap()}M`); |
| 102 | await walk(); |
| 103 | console.log(` one walk, empty store ${await heap()}M`); |
| 104 | |
| 105 | for (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. |
| 140 | const 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 | }); |
| 147 | console.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 | |
| 151 | const errs = s.errs.filter((e) => !/favicon|401|402|502/i.test(e)); |
| 152 | if (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 | |
| 191 | await s.close(); |
| 192 | try { fs.rmSync(PROFILE, { recursive: true, force: true }); } catch (e) { /* gone */ } |