@lotargo/memory_plugin 1.1.5 → 1.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,170 @@
1
+ import { updateConfig, getConfig } from "../config/config_manager.js";
2
+ import { getExtractor, embedBatch, resetExtractor } from "../ml/model_manager.js";
3
+ import { GpuMonitor, ExecutionTracer } from "../ml/gpu_monitor.js";
4
+
5
+ async function runSinglePass(device, modelName, batchSize, totalItems, onProgress) {
6
+ updateConfig({ executionDevice: device, embeddingModel: modelName, batchSize });
7
+ resetExtractor();
8
+
9
+ await getExtractor(modelName);
10
+
11
+ const allTexts = Array.from({ length: totalItems }, (_, i) =>
12
+ `High throughput parallel matrix multiplication on GPU using DirectML execution provider. Micro-chunk block #${i + 1} for dense vector embedding computation.`
13
+ );
14
+
15
+ const isGpu = device !== "cpu";
16
+ const monitor = isGpu ? new GpuMonitor(50) : null;
17
+ if (monitor) monitor.start();
18
+
19
+ const start = Date.now();
20
+ let totalComputed = 0;
21
+
22
+ for (let i = 0; i < allTexts.length; i += batchSize) {
23
+ const batch = allTexts.slice(i, i + batchSize);
24
+ const batchVecs = await embedBatch(batch, false, modelName, null, null, {
25
+ enableTrace: false,
26
+ enableMonitor: false,
27
+ });
28
+ totalComputed += batchVecs.length;
29
+ if (onProgress) onProgress({ current: totalComputed, total: totalItems, device });
30
+ }
31
+
32
+ const duration = Date.now() - start;
33
+ const gpuStats = monitor ? monitor.stop() : null;
34
+
35
+ global.gc?.({ type: "major" });
36
+
37
+ return {
38
+ device,
39
+ totalComputed,
40
+ durationMs: duration,
41
+ throughput: totalComputed / (duration / 1000),
42
+ avgMsPerItem: duration / totalComputed,
43
+ gpuStats,
44
+ };
45
+ }
46
+
47
+ export async function runCpuVsGpuComparison(options = {}) {
48
+ const modelName = options.modelName || getConfig().embeddingModel || "Xenova/bge-m3";
49
+ const batchSize = options.batchSize || 32;
50
+ const totalItems = options.totalItems || 512;
51
+
52
+ console.log(`\n===============================================================`);
53
+ console.log(` CPU vs GPU INFERENCE COMPARISON BENCHMARK`);
54
+ console.log(`===============================================================`);
55
+ console.log(` Model: ${modelName}`);
56
+ console.log(` Batch Size: ${batchSize}`);
57
+ console.log(` Items: ${totalItems}`);
58
+ console.log(` GC Exposed: ${typeof global.gc === "function" ? "YES" : "NO"}`);
59
+ console.log(`===============================================================\n`);
60
+
61
+ console.log(` [1/2] Running CPU pass...`);
62
+ const cpuResult = await runSinglePass("cpu", modelName, batchSize, totalItems, options.onProgress);
63
+ console.log(` CPU: ${cpuResult.throughput.toFixed(1)} emb/s, ${cpuResult.avgMsPerItem.toFixed(2)} ms/item, ${cpuResult.durationMs}ms total\n`);
64
+
65
+ console.log(` [2/2] Running GPU pass (DirectML)...`);
66
+ const gpuResult = await runSinglePass("webgpu", modelName, batchSize, totalItems, options.onProgress);
67
+ console.log(` GPU: ${gpuResult.throughput.toFixed(1)} emb/s, ${gpuResult.avgMsPerItem.toFixed(2)} ms/item, ${gpuResult.durationMs}ms total\n`);
68
+
69
+ const speedup = cpuResult.durationMs / gpuResult.durationMs;
70
+ const gpuPeak = gpuResult.gpuStats ? gpuResult.gpuStats.peak : null;
71
+ const gpuAvg = gpuResult.gpuStats ? gpuResult.gpuStats.avg : null;
72
+
73
+ console.log(`===============================================================`);
74
+ console.log(` COMPARISON RESULTS`);
75
+ console.log(`===============================================================`);
76
+ console.log(` Metric CPU GPU`);
77
+ console.log(` ─────────────────────────────────────────────────────────`);
78
+ console.log(` Duration ${String(cpuResult.durationMs + "ms").padEnd(17)}${gpuResult.durationMs}ms`);
79
+ console.log(` Throughput ${String(cpuResult.throughput.toFixed(1) + " emb/s").padEnd(17)}${gpuResult.throughput.toFixed(1)} emb/s`);
80
+ console.log(` Avg per item ${String(cpuResult.avgMsPerItem.toFixed(2) + " ms").padEnd(17)}${gpuResult.avgMsPerItem.toFixed(2)} ms`);
81
+ if (gpuPeak !== null) {
82
+ console.log(` GPU Peak - ${gpuPeak}%`);
83
+ console.log(` GPU Average - ${gpuAvg}%`);
84
+ }
85
+ console.log(` ─────────────────────────────────────────────────────────`);
86
+ console.log(` Speedup: ${speedup.toFixed(2)}x ${speedup > 1 ? "(GPU faster)" : speedup < 1 ? "(CPU faster)" : "(equal)"}`);
87
+ console.log(`===============================================================\n`);
88
+
89
+ return { cpu: cpuResult, gpu: gpuResult, speedup };
90
+ }
91
+
92
+ export async function runGpuProfileBenchmark(options = {}) {
93
+ const modelName = options.modelName || "Xenova/bge-small-en-v1.5";
94
+ const batchSize = options.batchSize || 256;
95
+ const totalItems = options.totalItems || 1024;
96
+ const minGpuThreshold = options.minGpuThreshold || 25;
97
+
98
+ console.log(`\n===============================================================`);
99
+ console.log(` ⚡ GPU HARDWARE INFERENCE & BOTTLENECK PROFILER BENCHMARK`);
100
+ console.log(`===============================================================`);
101
+ console.log(` Target Model: ${modelName}`);
102
+ console.log(` Batch Size: ${batchSize} items/batch`);
103
+ console.log(` Total Items: ${totalItems}`);
104
+ console.log(`===============================================================\n`);
105
+
106
+ updateConfig({ executionDevice: "webgpu", embeddingModel: modelName, batchSize });
107
+ resetExtractor();
108
+
109
+ console.log("1. Initializing GPU DirectML Engine...");
110
+ await getExtractor(modelName);
111
+ console.log(" [OK] Engine Loaded & Initialized on GPU (DirectML)\n");
112
+
113
+ console.log("2. Sampling GPU Utilization & Profiling Operations...");
114
+ const allTexts = Array.from({ length: totalItems }, (_, i) =>
115
+ `High throughput parallel matrix multiplication on GPU using DirectML execution provider. Micro-chunk block #${i + 1} for dense vector embedding computation.`
116
+ );
117
+
118
+ const monitor = new GpuMonitor(30);
119
+ monitor.start();
120
+
121
+ const tracer = new ExecutionTracer(`GPU Execution (${totalItems} items, batch ${batchSize})`);
122
+ const start = Date.now();
123
+ let totalComputed = 0;
124
+
125
+ for (let i = 0; i < allTexts.length; i += batchSize) {
126
+ const batch = allTexts.slice(i, i + batchSize);
127
+
128
+ tracer.startStage(`Batch ${i / batchSize + 1} Preprocessing (CPU)`, "CPU");
129
+ const formatted = batch.map((t) => t.trim());
130
+
131
+ tracer.startStage(`Batch ${i / batchSize + 1} Tensor Inference (GPU)`, "GPU");
132
+ const batchVecs = await embedBatch(formatted, false, modelName, null, null, {
133
+ enableTrace: false,
134
+ enableMonitor: false,
135
+ });
136
+ totalComputed += batchVecs.length;
137
+ }
138
+ tracer.endStage();
139
+
140
+ const duration = Date.now() - start;
141
+ const gpuStats = monitor.stop();
142
+
143
+ const summary = tracer.printTraceReport(gpuStats, minGpuThreshold);
144
+
145
+ console.log(`=== BENCHMARK SUMMARY & METRICS ===`);
146
+ console.log(` - Total Throughput: ${(totalComputed / (duration / 1000)).toFixed(1)} embeddings / second`);
147
+ console.log(` - Average Per-Item: ${(duration / totalComputed).toFixed(2)} ms / item`);
148
+ console.log(` - Peak GPU Load: ${gpuStats.peak}%`);
149
+ console.log(` - Average GPU Load: ${gpuStats.avg}%`);
150
+ console.log(` - GPU Time Share: ${summary.gpuMs.toFixed(1)}ms (${summary.gpuPct}% of total time)`);
151
+ console.log(` - CPU Time Share: ${summary.cpuMs.toFixed(1)}ms (${summary.cpuPct}% of total time)`);
152
+ console.log(`===============================================================\n`);
153
+
154
+ return {
155
+ totalComputed,
156
+ durationMs: duration,
157
+ throughput: totalComputed / (duration / 1000),
158
+ gpuStats,
159
+ summary,
160
+ };
161
+ }
162
+
163
+ if (process.argv[1]?.includes("gpu_profile_benchmark.js")) {
164
+ const args = process.argv.slice(2);
165
+ const fn = args.includes("--compare") ? runCpuVsGpuComparison : runGpuProfileBenchmark;
166
+ fn().catch((err) => {
167
+ console.error("\n❌ BENCHMARK ABORTED:", err.message);
168
+ process.exit(1);
169
+ });
170
+ }
@@ -1,193 +1,195 @@
1
- import { readdir, readFile, stat } from "node:fs/promises";
2
- import { join, basename } from "node:path";
3
- import { tmpdir } from "node:os";
4
- import { rmSync, existsSync } from "node:fs";
5
- import { getDatabase } from "../db/database.js";
6
- import { ingestDocument } from "../ingest/pipeline.js";
7
- import { embedText } from "../ml/model_manager.js";
8
- import { CORPUS_DIR, fetchRealCorpus } from "./fetch_real_corpus.js";
9
-
10
- // RSS is too volatile to measure incremental ingestion memory because V8's RSS
11
- // retains free-list pages long after the underlying heap shrinks. We track
12
- // `heapUsed + external` instead: heapUsed covers JS allocations (vector buffers,
13
- // sqlite cache), external covers WASM/ONNX buffers held off-heap.
14
- function memUsedBytes() {
15
- const m = process.memoryUsage();
16
- return m.heapUsed + m.external;
17
- }
18
-
19
- const PANEL_WIDTH = 58;
20
-
21
- function printRichPanel(title, subtitle = "") {
22
- const line = "─".repeat(PANEL_WIDTH - 2);
23
- console.log(`\x1b[36m╭${line}╮\x1b[0m`);
24
- console.log(`\x1b[36m│\x1b[0m \x1b[1m\x1b[37m${title.padEnd(PANEL_WIDTH - 6)}\x1b[0m \x1b[36m│\x1b[0m`);
25
- if (subtitle) {
26
- console.log(`\x1b[36m│\x1b[0m \x1b[90m${subtitle.padEnd(PANEL_WIDTH - 6)}\x1b[0m \x1b[36m│\x1b[0m`);
27
- }
28
- console.log(`\x1b[36m╰${line}╯\x1b[0m`);
29
- }
30
-
31
- export async function runIngestionBenchmark(options = {}) {
32
- const silent = options.silent || false;
33
- const onProgress = options.onProgress || null;
34
- const subsetDocIds = options.subsetDocIds || null;
35
- const corpus = await fetchRealCorpus({ silent, onProgress, subsetDocIds });
36
- const TEST_DIR = join(tmpdir(), `memory_bench_ingest_${Date.now()}`);
37
- const TEST_DB_PATH = join(TEST_DIR, "bench_memory.sqlite");
38
- const TEST_BLOB_DIR = join(TEST_DIR, "blobs");
39
-
40
- const db = getDatabase(TEST_DB_PATH);
41
-
42
- if (!silent) {
43
- printRichPanel("DOCUMENT INGESTION BENCHMARK", `Embeddings ONNX Enabled: ${options.generateEmbeddings}`);
44
- console.log(`\n [INGEST] Processing ${corpus.length} technical documents...\n`);
45
- }
46
-
47
- // Pre-warm ONNX model so its weight loading does NOT inflate ingestion RSS delta.
48
- // Without this the first embedText() inside the loop would pay the one-time model
49
- // load cost (~100MB), biasing the "Ingestion RAM" metric.
50
- let baselineMemLabel = "pre-loop";
51
- if (options.generateEmbeddings) {
52
- await embedText("warmup", false);
53
- baselineMemLabel = "post-model-warmup";
54
- if (!silent) console.log(` [Warmup] ONNX model loaded; baseline taken ${baselineMemLabel}.`);
55
- // Force GC if exposed to drop transient allocation noise from init.
56
- if (global.gc) {
57
- global.gc();
58
- baselineMemLabel = "post-model-warmup(post-gc)";
59
- }
60
- }
61
-
62
- const startMem = memUsedBytes();
63
- const startTime = performance.now();
64
- // Track peak heapUsed+external during the loop to capture transient allocations.
65
- let peakMem = startMem;
66
- // Heap shrinkage is much faster than RSS free-list reclaim, so polling works; but
67
- // also sample synchronously after each doc ingest in between timer ticks, since
68
- // ONNX inference for a single doc may span multiple 25ms windows.
69
- const memPollInterval = setInterval(() => {
70
- const cur = memUsedBytes();
71
- if (cur > peakMem) peakMem = cur;
72
- }, 25);
73
-
74
- let totalSections = 0;
75
- let totalMicroChunks = 0;
76
- let totalBytes = 0;
77
- let deduplicatedCount = 0;
78
-
79
- for (let i = 0; i < corpus.length; i++) {
80
- const file = corpus[i];
81
- const content = await readFile(file.path, "utf-8");
82
- totalBytes += content.length;
83
-
84
- const ingestRes = await ingestDocument({
85
- content,
86
- type: "file",
87
- title: file.title,
88
- path: file.path,
89
- generateEmbeddings: options.generateEmbeddings,
90
- customDb: db,
91
- customBlobDir: TEST_BLOB_DIR,
92
- });
93
-
94
- totalSections += ingestRes.sectionsCount;
95
- totalMicroChunks += ingestRes.microChunksCount;
96
- if (ingestRes.deduplicated) deduplicatedCount++;
97
-
98
- // Synchronous peak sample: captures peak after each doc ingestion completes,
99
- // complementing the 25ms interval poll (which can miss transient peaks).
100
- const cur = memUsedBytes();
101
- if (cur > peakMem) peakMem = cur;
102
-
103
- if (!silent && ((i + 1) % 5 === 0 || i === corpus.length - 1)) {
104
- console.log(` [Progress] Ingested ${i + 1}/${corpus.length} docs (${totalMicroChunks} micro-chunks)`);
105
- }
106
- if (onProgress) onProgress({ phase: "ingest", current: i + 1, total: corpus.length });
107
- }
108
-
109
- clearInterval(memPollInterval);
110
- const endTime = performance.now();
111
- const endMemPreGc = memUsedBytes();
112
-
113
- // Force GC to separate transient garbage from the persistent ingestion footprint.
114
- // Requires --expose-gc (run_benchmarks.js auto-respawns with it).
115
- let endMemPostGc = endMemPreGc;
116
- if (global.gc) {
117
- global.gc();
118
- endMemPostGc = memUsedBytes();
119
- }
120
-
121
- const durationMs = endTime - startTime;
122
- const durationSec = durationMs / 1000;
123
- const docsPerSec = corpus.length / durationSec;
124
- const chunksPerSec = totalMicroChunks / durationSec;
125
- const peakDeltaMB = Math.max(peakMem - startMem, endMemPreGc - startMem) / (1024 * 1024);
126
-
127
- const dbStat = await stat(TEST_DB_PATH);
128
- const dbSizeBytes = dbStat.size;
129
-
130
- let blobSizeBytes = 0;
131
- if (existsSync(TEST_BLOB_DIR)) {
132
- const blobFiles = await readdir(TEST_BLOB_DIR, { recursive: true });
133
- for (const bf of blobFiles) {
134
- const p = join(TEST_BLOB_DIR, bf);
135
- const st = await stat(p);
136
- if (st.isFile()) blobSizeBytes += st.size;
137
- }
138
- }
139
-
140
- const metrics = {
141
- docCount: corpus.length,
142
- networkDocCount: corpus.filter((d) => d.source === "network").length,
143
- localDocCount: corpus.filter((d) => d.source !== "network").length,
144
- totalSections,
145
- totalMicroChunks,
146
- totalBytes,
147
- deduplicatedCount,
148
- durationSec: Number(durationSec.toFixed(2)),
149
- docsPerSec: Number(docsPerSec.toFixed(2)),
150
- chunksPerSec: Number(chunksPerSec.toFixed(2)),
151
- dbSizeMB: Number((dbSizeBytes / (1024 * 1024)).toFixed(2)),
152
- blobSizeMB: Number((blobSizeBytes / (1024 * 1024)).toFixed(2)),
153
- // Ingestion-only memory delta (heapUsed + external), measured AFTER model
154
- // pre-warm so ONNX weight load is excluded. `ramUsageMB` is pre-GC (upper
155
- // bound incl. transient garbage); `settledRamUsageMB` is post-GC.
156
- ramUsageMB: Number(((endMemPreGc - startMem) / (1024 * 1024)).toFixed(2)),
157
- settledRamUsageMB: Number(((endMemPostGc - startMem) / (1024 * 1024)).toFixed(2)),
158
- peakRamUsageMB: Number(peakDeltaMB.toFixed(2)),
159
- ramBaseline: baselineMemLabel,
160
- metric: "heapUsed + external",
161
- dbPath: TEST_DB_PATH,
162
- blobDir: TEST_BLOB_DIR,
163
- dbInstance: db,
164
- };
165
-
166
- if (!silent) {
167
- console.log("\n [Ingestion Performance Summary]");
168
- console.log(` - Total Documents: ${metrics.docCount}`);
169
- console.log(` - Total Sections: ${metrics.totalSections}`);
170
- console.log(` - Total Micro-Chunks: ${metrics.totalMicroChunks}`);
171
- console.log(` - Duration: ${metrics.durationSec}s`);
172
- console.log(` - Throughput: ${metrics.docsPerSec} docs/sec | ${metrics.chunksPerSec} chunks/sec`);
173
- console.log(` - Database Size: ${metrics.dbSizeMB} MB`);
174
- console.log(` - Blob Storage Size: ${metrics.blobSizeMB} MB`);
175
- console.log(` - Memory Footprint pre-GC (heapUsed+ext Δ): ${metrics.ramUsageMB} MB (${metrics.ramBaseline})`);
176
- console.log(` - Memory Footprint post-GC (settled Δ): ${metrics.settledRamUsageMB} MB`);
177
- console.log(` - Memory Peak (max Δ during loop): ${metrics.peakRamUsageMB} MB`);
178
- console.log(` (Negative ingestion Δ = loop reclaimed more warmup garbage than it allocated.`);
179
- console.log(` ONNX native weights are owned by onnxruntime-node and not visible here.)\n`);
180
- }
181
-
182
- return metrics;
183
- }
184
-
185
- if (process.argv[1] && process.argv[1].includes("stress_ingestion.js")) {
186
- const metrics = await runIngestionBenchmark({ generateEmbeddings: false });
187
- if (existsSync(dirname(metrics.dbPath))) {
188
- try {
189
- metrics.dbInstance.close();
190
- rmSync(dirname(metrics.dbPath), { recursive: true, force: true });
191
- } catch {}
192
- }
193
- }
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import { join, basename } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { rmSync, existsSync } from "node:fs";
5
+ import { getDatabase } from "../db/database.js";
6
+ import { ingestDocument } from "../ingest/pipeline.js";
7
+ import { embedText, resetExtractor } from "../ml/model_manager.js";
8
+ import { CORPUS_DIR, fetchRealCorpus } from "./fetch_real_corpus.js";
9
+
10
+ // RSS is too volatile to measure incremental ingestion memory because V8's RSS
11
+ // retains free-list pages long after the underlying heap shrinks. We track
12
+ // `heapUsed + external` instead: heapUsed covers JS allocations (vector buffers,
13
+ // sqlite cache), external covers WASM/ONNX buffers held off-heap.
14
+ function memUsedBytes() {
15
+ const m = process.memoryUsage();
16
+ return m.heapUsed + m.external;
17
+ }
18
+
19
+ const PANEL_WIDTH = 58;
20
+
21
+ function printRichPanel(title, subtitle = "") {
22
+ const line = "─".repeat(PANEL_WIDTH - 2);
23
+ console.log(`\x1b[36m╭${line}╮\x1b[0m`);
24
+ console.log(`\x1b[36m│\x1b[0m \x1b[1m\x1b[37m${title.padEnd(PANEL_WIDTH - 6)}\x1b[0m \x1b[36m│\x1b[0m`);
25
+ if (subtitle) {
26
+ console.log(`\x1b[36m│\x1b[0m \x1b[90m${subtitle.padEnd(PANEL_WIDTH - 6)}\x1b[0m \x1b[36m│\x1b[0m`);
27
+ }
28
+ console.log(`\x1b[36m╰${line}╯\x1b[0m`);
29
+ }
30
+
31
+ export async function runIngestionBenchmark(options = {}) {
32
+ const silent = options.silent || false;
33
+ const onProgress = options.onProgress || null;
34
+ const subsetDocIds = options.subsetDocIds || null;
35
+ const corpus = await fetchRealCorpus({ silent, onProgress, subsetDocIds });
36
+ const TEST_DIR = join(tmpdir(), `memory_bench_ingest_${Date.now()}`);
37
+ const TEST_DB_PATH = join(TEST_DIR, "bench_memory.sqlite");
38
+ const TEST_BLOB_DIR = join(TEST_DIR, "blobs");
39
+
40
+ const db = getDatabase(TEST_DB_PATH);
41
+
42
+ if (!silent) {
43
+ printRichPanel("DOCUMENT INGESTION BENCHMARK", `Embeddings ONNX Enabled: ${options.generateEmbeddings}`);
44
+ console.log(`\n [INGEST] Processing ${corpus.length} technical documents...\n`);
45
+ }
46
+
47
+ // Pre-warm ONNX model so its weight loading does NOT inflate ingestion RSS delta.
48
+ // Without this the first embedText() inside the loop would pay the one-time model
49
+ // load cost (~100MB), biasing the "Ingestion RAM" metric.
50
+ let baselineMemLabel = "pre-loop";
51
+ if (options.generateEmbeddings) {
52
+ await embedText("warmup", false);
53
+ baselineMemLabel = "post-model-warmup";
54
+ if (!silent) console.log(` [Warmup] ONNX model loaded; baseline taken ${baselineMemLabel}.`);
55
+ // Force GC if exposed to drop transient allocation noise from init.
56
+ if (global.gc) {
57
+ global.gc();
58
+ baselineMemLabel = "post-model-warmup(post-gc)";
59
+ }
60
+ }
61
+
62
+ const startMem = memUsedBytes();
63
+ const startTime = performance.now();
64
+ // Track peak heapUsed+external during the loop to capture transient allocations.
65
+ let peakMem = startMem;
66
+ // Heap shrinkage is much faster than RSS free-list reclaim, so polling works; but
67
+ // also sample synchronously after each doc ingest in between timer ticks, since
68
+ // ONNX inference for a single doc may span multiple 25ms windows.
69
+ const memPollInterval = setInterval(() => {
70
+ const cur = memUsedBytes();
71
+ if (cur > peakMem) peakMem = cur;
72
+ }, 25);
73
+
74
+ let totalSections = 0;
75
+ let totalMicroChunks = 0;
76
+ let totalBytes = 0;
77
+ let deduplicatedCount = 0;
78
+
79
+ for (let i = 0; i < corpus.length; i++) {
80
+ const file = corpus[i];
81
+ const content = await readFile(file.path, "utf-8");
82
+ totalBytes += content.length;
83
+
84
+ const ingestRes = await ingestDocument({
85
+ content,
86
+ type: "file",
87
+ title: file.title,
88
+ path: file.path,
89
+ generateEmbeddings: options.generateEmbeddings,
90
+ customDb: db,
91
+ customBlobDir: TEST_BLOB_DIR,
92
+ });
93
+
94
+ totalSections += ingestRes.sectionsCount;
95
+ totalMicroChunks += ingestRes.microChunksCount;
96
+ if (ingestRes.deduplicated) deduplicatedCount++;
97
+
98
+ if (global.gc) global.gc();
99
+
100
+ // Synchronous peak sample: captures peak after each doc ingestion completes,
101
+ // complementing the 25ms interval poll (which can miss transient peaks).
102
+ const cur = memUsedBytes();
103
+ if (cur > peakMem) peakMem = cur;
104
+
105
+ if (!silent && ((i + 1) % 5 === 0 || i === corpus.length - 1)) {
106
+ console.log(` [Progress] Ingested ${i + 1}/${corpus.length} docs (${totalMicroChunks} micro-chunks)`);
107
+ }
108
+ if (onProgress) onProgress({ phase: "ingest", current: i + 1, total: corpus.length });
109
+ }
110
+
111
+ clearInterval(memPollInterval);
112
+ const endTime = performance.now();
113
+ const endMemPreGc = memUsedBytes();
114
+
115
+ // Force GC to separate transient garbage from the persistent ingestion footprint.
116
+ // Requires --expose-gc (run_benchmarks.js auto-respawns with it).
117
+ let endMemPostGc = endMemPreGc;
118
+ if (global.gc) {
119
+ global.gc();
120
+ endMemPostGc = memUsedBytes();
121
+ }
122
+
123
+ const durationMs = endTime - startTime;
124
+ const durationSec = durationMs / 1000;
125
+ const docsPerSec = corpus.length / durationSec;
126
+ const chunksPerSec = totalMicroChunks / durationSec;
127
+ const peakDeltaMB = Math.max(peakMem - startMem, endMemPreGc - startMem) / (1024 * 1024);
128
+
129
+ const dbStat = await stat(TEST_DB_PATH);
130
+ const dbSizeBytes = dbStat.size;
131
+
132
+ let blobSizeBytes = 0;
133
+ if (existsSync(TEST_BLOB_DIR)) {
134
+ const blobFiles = await readdir(TEST_BLOB_DIR, { recursive: true });
135
+ for (const bf of blobFiles) {
136
+ const p = join(TEST_BLOB_DIR, bf);
137
+ const st = await stat(p);
138
+ if (st.isFile()) blobSizeBytes += st.size;
139
+ }
140
+ }
141
+
142
+ const metrics = {
143
+ docCount: corpus.length,
144
+ networkDocCount: corpus.filter((d) => d.source === "network").length,
145
+ localDocCount: corpus.filter((d) => d.source !== "network").length,
146
+ totalSections,
147
+ totalMicroChunks,
148
+ totalBytes,
149
+ deduplicatedCount,
150
+ durationSec: Number(durationSec.toFixed(2)),
151
+ docsPerSec: Number(docsPerSec.toFixed(2)),
152
+ chunksPerSec: Number(chunksPerSec.toFixed(2)),
153
+ dbSizeMB: Number((dbSizeBytes / (1024 * 1024)).toFixed(2)),
154
+ blobSizeMB: Number((blobSizeBytes / (1024 * 1024)).toFixed(2)),
155
+ // Ingestion-only memory delta (heapUsed + external), measured AFTER model
156
+ // pre-warm so ONNX weight load is excluded. `ramUsageMB` is pre-GC (upper
157
+ // bound incl. transient garbage); `settledRamUsageMB` is post-GC.
158
+ ramUsageMB: Number(((endMemPreGc - startMem) / (1024 * 1024)).toFixed(2)),
159
+ settledRamUsageMB: Number(((endMemPostGc - startMem) / (1024 * 1024)).toFixed(2)),
160
+ peakRamUsageMB: Number(peakDeltaMB.toFixed(2)),
161
+ ramBaseline: baselineMemLabel,
162
+ metric: "heapUsed + external",
163
+ dbPath: TEST_DB_PATH,
164
+ blobDir: TEST_BLOB_DIR,
165
+ dbInstance: db,
166
+ };
167
+
168
+ if (!silent) {
169
+ console.log("\n [Ingestion Performance Summary]");
170
+ console.log(` - Total Documents: ${metrics.docCount}`);
171
+ console.log(` - Total Sections: ${metrics.totalSections}`);
172
+ console.log(` - Total Micro-Chunks: ${metrics.totalMicroChunks}`);
173
+ console.log(` - Duration: ${metrics.durationSec}s`);
174
+ console.log(` - Throughput: ${metrics.docsPerSec} docs/sec | ${metrics.chunksPerSec} chunks/sec`);
175
+ console.log(` - Database Size: ${metrics.dbSizeMB} MB`);
176
+ console.log(` - Blob Storage Size: ${metrics.blobSizeMB} MB`);
177
+ console.log(` - Memory Footprint pre-GC (heapUsed+ext Δ): ${metrics.ramUsageMB} MB (${metrics.ramBaseline})`);
178
+ console.log(` - Memory Footprint post-GC (settled Δ): ${metrics.settledRamUsageMB} MB`);
179
+ console.log(` - Memory Peak (max Δ during loop): ${metrics.peakRamUsageMB} MB`);
180
+ console.log(` (Negative ingestion Δ = loop reclaimed more warmup garbage than it allocated.`);
181
+ console.log(` ONNX native weights are owned by onnxruntime-node and not visible here.)\n`);
182
+ }
183
+
184
+ return metrics;
185
+ }
186
+
187
+ if (process.argv[1] && process.argv[1].includes("stress_ingestion.js")) {
188
+ const metrics = await runIngestionBenchmark({ generateEmbeddings: false });
189
+ if (existsSync(dirname(metrics.dbPath))) {
190
+ try {
191
+ metrics.dbInstance.close();
192
+ rmSync(dirname(metrics.dbPath), { recursive: true, force: true });
193
+ } catch {}
194
+ }
195
+ }