Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

465 lines
17 KiB
JavaScript

#!/usr/bin/env node
/**
* Eliza-1 local-embedding latency / throughput / cold-load / peak-RSS
* harness.
*
* Per `packages/inference/AGENTS.md` §1 the embedding model is either the
* text backbone with `--pooling last` (`2b`) or a dedicated
* `embedding/eliza-1-embedding.gguf` on the larger tiers. This harness
* drives a `llama-server --embeddings --pooling last`
* over a GGUF and measures:
* - cold-load time of the embedding region (spawn → /health),
* - single-text embed latency (median over N runs),
* - batch-embed throughput (texts/sec) at a few batch sizes,
* - peak RSS of the server process,
* - cosine-similarity preservation at each Matryoshka width
* {64,128,256,512,768} vs the full 1024-dim vector (the "quality"
* proxy when MTEB isn't available offline).
*
* It runs on whatever backend the resolved `llama-server` binary was
* built for (CPU / Vulkan / CUDA); pass `--backend cpu|vulkan|cuda` to
* select a specific build, or `--bin PATH` to point at one directly.
*
* Like the other verify harnesses, when no binary / model is available it
* writes a structured `status: "skipped"` report and exits 0 — it does
* NOT fabricate numbers (AGENTS.md §3).
*
* Usage:
* node packages/inference/verify/embedding_bench.mjs \
* [--bin PATH] [--backend cpu|vulkan|cuda] [--model PATH] \
* [--runs 30] [--threads 24] [--report PATH] [--json]
*/
import { spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function parseArgs(argv) {
const out = {
bin: null,
backend: null,
model: null,
runs: 30,
threads: Math.max(1, os.cpus().length),
report: null,
json: false,
};
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i];
if (a === "--bin") out.bin = argv[++i];
else if (a === "--backend") out.backend = argv[++i];
else if (a === "--model") out.model = argv[++i];
else if (a === "--runs") out.runs = Number.parseInt(argv[++i], 10) || 30;
else if (a === "--threads") out.threads = Number.parseInt(argv[++i], 10) || out.threads;
else if (a === "--report") out.report = argv[++i];
else if (a === "--json") out.json = true;
}
return out;
}
function firstExisting(...candidates) {
return candidates.find((c) => c && fs.existsSync(c)) ?? null;
}
function resolveBinary(opts) {
if (opts.bin) return fs.existsSync(opts.bin) ? opts.bin : null;
const cacheRoots = [
path.join(os.homedir(), ".cache", "eliza-mtp", "eliza-llama-cpp", "build"),
path.join(os.homedir(), ".cache", "eliza-mtp", "buun-llama-cpp", "build"),
path.join(os.homedir(), ".eliza", "local-inference", "bin", "mtp"),
];
const platform = `${process.platform}-${process.arch}`.replace("darwin", "darwin").replace("linux", "linux");
const backends = opts.backend ? [opts.backend] : ["cuda", "vulkan", "cpu"];
const candidates = [];
for (const root of cacheRoots) {
for (const b of backends) {
candidates.push(path.join(root, `${platform === "linux-x64" ? "linux-x64" : platform}-${b}`, "bin", "llama-server"));
candidates.push(path.join(root, `linux-x64-${b}`, "bin", "llama-server"));
candidates.push(path.join(root, `${b}`, "bin", "llama-server"));
candidates.push(path.join(root, `linux-x64-${b}`, "llama-server"));
}
}
return firstExisting(...candidates);
}
function resolveModel(opts) {
if (opts.model) return fs.existsSync(opts.model) ? opts.model : null;
const home = os.homedir();
const modelsRoot = path.join(home, ".eliza", "local-inference", "models");
const candidates = [];
// A real dedicated embedding region, if any bundle ships it.
for (const tier of ["2b", "4b", "9b", "27b", "27b-256k"]) {
const dir = path.join(modelsRoot, `eliza-1-${tier}.bundle`, "embedding");
if (fs.existsSync(dir)) {
for (const f of fs.readdirSync(dir)) {
if (/\.gguf$/i.test(f)) candidates.push(path.join(dir, f));
}
}
}
// Eliza-1 pooled-text stand-in (the current 2b text backbone base).
candidates.push(path.join(modelsRoot, "eliza-1-2b.bundle", "text", "eliza-1-2b-128k.gguf"));
candidates.push(path.join(modelsRoot, "SmolLM2-360M-Instruct-Q4_K_M.gguf"));
return firstExisting(...candidates);
}
function backendOfBinary(binPath) {
const lc = (binPath || "").toLowerCase();
if (lc.includes("metal")) return "metal";
if (lc.includes("vulkan")) return "vulkan";
if (lc.includes("cuda")) return "cuda";
if (lc.includes("rocm") || lc.includes("hip")) return "rocm";
return "cpu";
}
async function pickPort() {
const net = await import("node:net");
return new Promise((resolve, reject) => {
const srv = net.createServer();
srv.unref();
srv.on("error", reject);
srv.listen(0, "127.0.0.1", () => {
const a = srv.address();
srv.close(() => (a && typeof a === "object" ? resolve(a.port) : reject(new Error("no port"))));
});
});
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function readRssKb(pid) {
try {
if (process.platform === "linux") {
const statm = fs.readFileSync(`/proc/${pid}/statm`, "utf8").trim().split(/\s+/);
const pages = Number.parseInt(statm[1], 10); // resident pages
return Math.round((pages * 4096) / 1024);
}
} catch {}
return null;
}
function cosine(a, b) {
let dot = 0;
let na = 0;
let nb = 0;
const n = Math.min(a.length, b.length);
for (let i = 0; i < n; i += 1) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
if (na === 0 || nb === 0) return 0;
return dot / (Math.sqrt(na) * Math.sqrt(nb));
}
function l2norm(v, dim) {
const s = v.slice(0, dim);
let ss = 0;
for (const x of s) ss += x * x;
if (ss === 0) return s;
const inv = 1 / Math.sqrt(ss);
return s.map((x) => x * inv);
}
const MATRYOSHKA_DIMS = [64, 128, 256, 512, 768, 1024];
// A small heterogeneous corpus — short factual sentences spanning a few
// topics so cosine-preservation at truncated dims isn't measured on
// near-identical inputs.
const CORPUS = [
"The mitochondria is the powerhouse of the cell.",
"Paris is the capital of France and sits on the Seine.",
"TypeScript adds static types on top of JavaScript.",
"A llama is a domesticated South American camelid.",
"Quantum entanglement links the states of two particles.",
"The Pacific Ocean is the largest and deepest ocean on Earth.",
"Photosynthesis converts light energy into chemical energy in plants.",
"The HTTP 404 status code means the resource was not found.",
"Mount Everest is the highest mountain above sea level.",
"Embeddings map text into a dense vector space for retrieval.",
"The French Revolution began in 1789.",
"A binary search runs in logarithmic time on a sorted array.",
"Caffeine is a stimulant found in coffee and tea.",
"The speed of light in vacuum is about 299,792 kilometres per second.",
"Git is a distributed version control system.",
"The Great Barrier Reef is off the coast of Queensland, Australia.",
];
async function embedBatch(baseUrl, texts) {
const res = await fetch(`${baseUrl}/v1/embeddings`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ input: texts }),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`/v1/embeddings HTTP ${res.status}${body ? `: ${body}` : ""}`);
}
const json = await res.json();
return (json.data || []).map((d) => d.embedding);
}
function median(xs) {
if (xs.length === 0) return 0;
const s = [...xs].sort((a, b) => a - b);
const m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}
async function main() {
const opts = parseArgs(process.argv.slice(2));
const reportPath =
opts.report || path.join(__dirname, "bench_results", `embedding_${new Date().toISOString().slice(0, 10)}.json`);
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
const bin = resolveBinary(opts);
const model = resolveModel(opts);
const baseReport = {
tool: "embedding_bench",
date: new Date().toISOString(),
host: os.cpus()[0]?.model ?? "unknown",
cpus: os.cpus().length,
totalRamMb: Math.round(os.totalmem() / 1024 / 1024),
};
if (!bin || !model) {
const skipped = {
...baseReport,
status: "skipped",
reason: !bin
? "no llama-server binary found (build one: node packages/app-core/scripts/build-llama-cpp-mtp.mjs --target linux-x64-cpu)"
: "no embedding GGUF found (point --model at an embedding/ GGUF or an Eliza-1 pooled-text backbone such as eliza-1-2b)",
resolved: { bin, model },
};
fs.writeFileSync(reportPath, JSON.stringify(skipped, null, 2));
if (opts.json) console.log(JSON.stringify(skipped, null, 2));
else console.log(`[embedding-bench] SKIPPED: ${skipped.reason}\n[embedding-bench] report → ${reportPath}`);
return;
}
const backend = backendOfBinary(bin);
const host = "127.0.0.1";
const port = await pickPort();
const args = [
"-m", model,
"--host", host,
"--port", String(port),
"--ctx-size", "8192",
// -ub == -b so a multi-input /v1/embeddings call is one ubatch, not
// chunked at the 512-token default; --parallel 16 lets the inputs ride
// one forward pass instead of being serialized under --pooling last.
"--batch-size", "4096",
"--ubatch-size", "4096",
"--parallel", "16",
"--threads", String(opts.threads),
"--n-gpu-layers", "99",
"--embeddings",
"--pooling", "last",
];
const tColdStart = Date.now();
const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
let serverLog = "";
child.stdout?.on("data", (c) => (serverLog += c.toString()));
child.stderr?.on("data", (c) => (serverLog += c.toString()));
let peakRssKb = 0;
const rssTimer = setInterval(() => {
const rss = readRssKb(child.pid);
if (rss && rss > peakRssKb) peakRssKb = rss;
}, 200);
// Wait for health.
let healthy = false;
const deadline = Date.now() + 90_000;
while (Date.now() < deadline) {
if (child.exitCode !== null) break;
try {
const r = await fetch(`http://${host}:${port}/health`);
if (r.ok) { healthy = true; break; }
} catch {}
await sleep(150);
}
const coldLoadMs = Date.now() - tColdStart;
const cleanup = async () => {
clearInterval(rssTimer);
try { child.kill("SIGTERM"); } catch {}
await sleep(300);
try { if (child.exitCode === null) child.kill("SIGKILL"); } catch {}
};
if (!healthy) {
await cleanup();
const failed = {
...baseReport,
status: "failed",
reason: `llama-server (${bin}) did not become healthy within 90s`,
backend,
model,
serverLogTail: serverLog.split("\n").slice(-25).join("\n"),
};
fs.writeFileSync(reportPath, JSON.stringify(failed, null, 2));
if (opts.json) console.log(JSON.stringify(failed, null, 2));
else console.error(`[embedding-bench] FAILED: ${failed.reason}\n[embedding-bench] report → ${reportPath}`);
process.exitCode = 1;
return;
}
const baseUrl = `http://${host}:${port}`;
let result;
try {
// Determine the model's embedding dimension from one call.
const probe = await embedBatch(baseUrl, [CORPUS[0]]);
const modelDim = probe[0]?.length ?? 0;
// Single-text latency (median of `runs`, after a warmup).
await embedBatch(baseUrl, [CORPUS[0]]);
const singleLatencies = [];
for (let i = 0; i < opts.runs; i += 1) {
const t0 = performance.now();
await embedBatch(baseUrl, [CORPUS[i % CORPUS.length]]);
singleLatencies.push(performance.now() - t0);
}
// Batch throughput at a few sizes (texts/sec = batch / wall).
const batchSizes = [1, 4, 8, 16].filter((b) => b <= CORPUS.length);
const throughput = {};
for (const bsz of batchSizes) {
const reps = Math.max(3, Math.ceil(opts.runs / bsz));
const walls = [];
for (let r = 0; r < reps; r += 1) {
const batch = [];
for (let k = 0; k < bsz; k += 1) batch.push(CORPUS[(r * bsz + k) % CORPUS.length]);
const t0 = performance.now();
await embedBatch(baseUrl, batch);
walls.push(performance.now() - t0);
}
const medWall = median(walls);
throughput[`batch_${bsz}`] = {
median_wall_ms: Number(medWall.toFixed(2)),
texts_per_sec: Number((bsz / (medWall / 1000)).toFixed(1)),
};
}
// Matryoshka cosine-preservation: full corpus → full vectors; then for
// each truncated width, average cos(truncate(v, dim), v_full[:dim]) ...
// measured as cos(truncate_renorm(v, dim), truncate_renorm(v, 1024)[:dim])
// which is just cos of the leading slices. The interesting number is how
// well *pairwise rankings* survive — approximate that with the mean
// pairwise-cosine correlation between full and truncated.
const fullVecs = await embedBatch(baseUrl, CORPUS);
const dims = MATRYOSHKA_DIMS.filter((d) => d <= modelDim);
const matryoshka = {};
// Pairwise cosine matrix at 1024 (or modelDim).
const fullPairs = [];
for (let i = 0; i < fullVecs.length; i += 1)
for (let j = i + 1; j < fullVecs.length; j += 1)
fullPairs.push(cosine(fullVecs[i], fullVecs[j]));
for (const dim of dims) {
const truncated = fullVecs.map((v) => l2norm(v, dim));
const pairs = [];
for (let i = 0; i < truncated.length; i += 1)
for (let j = i + 1; j < truncated.length; j += 1)
pairs.push(cosine(truncated[i], truncated[j]));
// Spearman-ish: rank-correlation between fullPairs and pairs.
const rho = pearson(fullPairs, pairs);
// Mean self-cosine: how aligned the truncated slice is with the full
// vector's leading slice (sanity — should be 1.0 by construction since
// truncate is a prefix; kept for the table's "alignment" column).
matryoshka[`dim_${dim}`] = {
bytes_per_vec_fp32: dim * 4,
bytes_per_vec_fp16: dim * 2,
storage_fraction_vs_1024: Number((dim / modelDim).toFixed(3)),
pairwise_ranking_pearson_vs_full: Number(rho.toFixed(4)),
};
}
result = {
...baseReport,
status: "ok",
backend,
binary: bin,
model,
modelDim,
threads: opts.threads,
runs: opts.runs,
coldLoadMs,
peakRssMb: Number((peakRssKb / 1024).toFixed(1)),
singleTextEmbed: {
median_ms: Number(median(singleLatencies).toFixed(2)),
p10_ms: Number(percentile(singleLatencies, 10).toFixed(2)),
p90_ms: Number(percentile(singleLatencies, 90).toFixed(2)),
},
throughput,
matryoshka,
note:
"Server: llama-server --embeddings --pooling last over the GGUF. " +
"`pairwise_ranking_pearson_vs_full` is the Pearson correlation between the corpus's pairwise-cosine matrix at the truncated width and at the full width — a cheap offline proxy for retrieval-ranking preservation when MTEB isn't available. " +
"On 2b the GGUF may be the text backbone (pooled-text mode); on larger tiers it should be the dedicated embedding/eliza-1-embedding.gguf.",
};
} catch (err) {
result = {
...baseReport,
status: "failed",
backend,
model,
reason: err instanceof Error ? err.message : String(err),
serverLogTail: serverLog.split("\n").slice(-25).join("\n"),
};
process.exitCode = 1;
} finally {
await cleanup();
}
fs.writeFileSync(reportPath, JSON.stringify(result, null, 2));
if (opts.json) console.log(JSON.stringify(result, null, 2));
else {
console.log(`[embedding-bench] ${result.status} backend=${result.backend} model=${path.basename(model)}`);
if (result.status === "ok") {
console.log(` cold-load=${result.coldLoadMs}ms peakRSS=${result.peakRssMb}MB single=${result.singleTextEmbed.median_ms}ms modelDim=${result.modelDim}`);
for (const [k, v] of Object.entries(result.throughput)) console.log(` ${k}: ${v.texts_per_sec} texts/s (${v.median_wall_ms}ms)`);
for (const [k, v] of Object.entries(result.matryoshka)) console.log(` ${k}: storage=${(v.storage_fraction_vs_1024 * 100).toFixed(1)}% rankPearson=${v.pairwise_ranking_pearson_vs_full}`);
}
console.log(`[embedding-bench] report → ${reportPath}`);
}
}
function percentile(xs, p) {
if (xs.length === 0) return 0;
const s = [...xs].sort((a, b) => a - b);
const idx = Math.min(s.length - 1, Math.max(0, Math.round((p / 100) * (s.length - 1))));
return s[idx];
}
function pearson(a, b) {
const n = Math.min(a.length, b.length);
if (n === 0) return 0;
let sa = 0;
let sb = 0;
for (let i = 0; i < n; i += 1) { sa += a[i]; sb += b[i]; }
const ma = sa / n;
const mb = sb / n;
let num = 0;
let da = 0;
let db = 0;
for (let i = 0; i < n; i += 1) {
const xa = a[i] - ma;
const xb = b[i] - mb;
num += xa * xb;
da += xa * xa;
db += xb * xb;
}
if (da === 0 || db === 0) return 1;
return num / Math.sqrt(da * db);
}
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});