Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json --pretty false",
"test": "CONCURRENCY=$(node -p \"parseInt(process.versions.node)>=19?'--test-concurrency=1':''\") && node --test $CONCURRENCY $(find dist -type f -path '*/__tests__/*.test.js' | LC_ALL=C sort)"
"test": "CONCURRENCY=$(node -p \"parseInt(process.versions.node)>=19?'--test-concurrency=1':''\") && node --test $CONCURRENCY $(find dist -type f -path '*/__tests__/*.test.js' | LC_ALL=C sort)",
"bench:text-arena": "npm run build && node scripts/bench-text-arena.mjs"
},
"types": "./dist/index.d.ts",
"exports": {
Expand Down
118 changes: 118 additions & 0 deletions packages/core/scripts/bench-text-arena.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
#!/usr/bin/env node
import { performance } from "node:perf_hooks";
import { FrameTextArena } from "../dist/drawlist/textArena.js";

function parsePositiveInt(name, fallback) {
const raw = process.env[name];
if (raw === undefined) return fallback;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed <= 0) return fallback;
return parsed;
}

const SEGMENTS = parsePositiveInt("SEGMENTS", 20_000);
const ITERATIONS = parsePositiveInt("ITERATIONS", 40);
const WARMUP = parsePositiveInt("WARMUP", 5);

function makeSegments(count) {
const out = [];
for (let i = 0; i < count; i++) {
const level = i % 3 === 0 ? "INFO" : i % 3 === 1 ? "WARN" : "ERR";
out.push(
`${String(i).padStart(6, "0")} ${level} worker-${i % 17}: event-${1000 + (i % 200)} / caf\u00e9 / \u6f22\u5b57 / \ud83d\ude00`,
);
}
return out;
}

function legacyEncodeFrame(strings) {
const encoder = new TextEncoder();
const chunks = new Array(strings.length);
let totalBytes = 0;
for (let i = 0; i < strings.length; i++) {
const chunk = encoder.encode(strings[i] ?? "");
chunks[i] = chunk;
totalBytes += chunk.byteLength;
}

const out = new Uint8Array(totalBytes);
let off = 0;
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
if (!chunk) continue;
out.set(chunk, off);
off += chunk.byteLength;
}

return {
bytes: out.byteLength,
encoderCalls: strings.length,
textSegments: strings.length,
estimatedAllocations: strings.length + 1,
};
}

function arenaEncodeFrame(strings) {
const arena = new FrameTextArena(new TextEncoder(), 1024);
for (let i = 0; i < strings.length; i++) {
arena.allocUtf8(strings[i] ?? "");
}
const counters = arena.counters();
return {
bytes: arena.bytes().byteLength,
encoderCalls: counters.textEncoderCalls,
textSegments: counters.textSegments,
estimatedAllocations: 1,
};
}

function bench(label, iterations, warmup, runOnce) {
if (!Number.isInteger(iterations) || iterations <= 0) {
throw new Error(`bench(${label}): iterations must be > 0 (got ${String(iterations)})`);
}

for (let i = 0; i < warmup; i++) runOnce();

const t0 = performance.now();
let last = null;
for (let i = 0; i < iterations; i++) {
last = runOnce();
}
const t1 = performance.now();

const totalMs = t1 - t0;
const avgMs = totalMs / iterations;
return {
label,
iterations,
totalMs,
avgMs,
last,
};
}

const strings = makeSegments(SEGMENTS);

const legacy = bench("legacy_per_string_encode", ITERATIONS, WARMUP, () =>
legacyEncodeFrame(strings),
);
const arena = bench("arena_encode_into", ITERATIONS, WARMUP, () => arenaEncodeFrame(strings));

const legacyAlloc = legacy.last?.estimatedAllocations ?? 0;
const arenaAlloc = arena.last?.estimatedAllocations ?? 0;
const allocReductionPct = legacyAlloc === 0 ? 0 : ((legacyAlloc - arenaAlloc) / legacyAlloc) * 100;
const speedup = arena.avgMs === 0 ? 0 : legacy.avgMs / arena.avgMs;

console.log(`segments=${SEGMENTS} iterations=${ITERATIONS} warmup=${WARMUP}`);
console.log("---");
console.log(
`${legacy.label}: avg_ms=${legacy.avgMs.toFixed(3)} total_ms=${legacy.totalMs.toFixed(1)} encoder_calls=${String(legacy.last?.encoderCalls ?? 0)} bytes=${String(legacy.last?.bytes ?? 0)} est_allocs=${String(legacyAlloc)}`,
);
console.log(
`${arena.label}: avg_ms=${arena.avgMs.toFixed(3)} total_ms=${arena.totalMs.toFixed(1)} encoder_calls=${String(arena.last?.encoderCalls ?? 0)} bytes=${String(arena.last?.bytes ?? 0)} text_segments=${String(arena.last?.textSegments ?? 0)} est_allocs=${String(arenaAlloc)}`,
);
console.log("---");
console.log(
`allocation_reduction_est=${allocReductionPct.toFixed(1)}% speedup=${speedup.toFixed(2)}x (higher is faster)`,
);
console.log("tip: run with node --trace-gc scripts/bench-text-arena.mjs for GC traces");
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,15 @@ function u32(bytes: Uint8Array, off: number): number {
return dv.getUint32(off, true);
}

function u16(bytes: Uint8Array, off: number): number {
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
return dv.getUint16(off, true);
}

function parseInternedStrings(bytes: Uint8Array): readonly string[] {
const cmdOffset = u32(bytes, 16);
const cmdBytes = u32(bytes, 20);
const cmdEnd = cmdOffset + cmdBytes;
const spanOffset = u32(bytes, 28);
const count = u32(bytes, 32);
const bytesOffset = u32(bytes, 36);
Expand All @@ -100,15 +108,89 @@ function parseInternedStrings(bytes: Uint8Array): readonly string[] {

const tableEnd = bytesOffset + bytesLen;
assert.ok(tableEnd <= bytes.byteLength, "string table must be in bounds");
assert.ok(cmdEnd <= bytes.byteLength, "command section must be in bounds");

const out: string[] = [];
const seen = new Set<string>();
const decoder = new TextDecoder();
const pushUnique = (text: string): void => {
if (seen.has(text)) return;
seen.add(text);
out.push(text);
};

for (let i = 0; i < count; i++) {
const span = spanOffset + i * 8;
const start = bytesOffset + u32(bytes, span);
const end = start + u32(bytes, span + 4);
assert.ok(end <= tableEnd, "string span must be in bounds");
out.push(decoder.decode(bytes.subarray(start, end)));
pushUnique(decoder.decode(bytes.subarray(start, end)));
}

let off = cmdOffset;
while (off < cmdEnd) {
const opcode = u16(bytes, off);
const size = u32(bytes, off + 4);
assert.ok(size >= 8, "command size must be >= 8");

if (opcode === 3 && size >= 48) {
const stringIndex = u32(bytes, off + 16);
const byteOff = u32(bytes, off + 20);
const byteLen = u32(bytes, off + 24);
if (stringIndex < count) {
const span = spanOffset + stringIndex * 8;
const strOff = u32(bytes, span);
const strLen = u32(bytes, span + 4);
if (byteOff + byteLen <= strLen) {
const start = bytesOffset + strOff + byteOff;
const end = start + byteLen;
if (end <= tableEnd) {
pushUnique(decoder.decode(bytes.subarray(start, end)));
}
}
}
}

off += size;
}
assert.equal(off, cmdEnd, "commands must parse exactly to cmd end");

const blobsSpanOffset = u32(bytes, 44);
const blobsCount = u32(bytes, 48);
const blobsBytesOffset = u32(bytes, 52);
const blobsBytesLen = u32(bytes, 56);
const blobsEnd = blobsBytesOffset + blobsBytesLen;
assert.ok(blobsEnd <= bytes.byteLength, "blob section must be in bounds");

for (let i = 0; i < blobsCount; i++) {
const span = blobsSpanOffset + i * 8;
const blobStart = blobsBytesOffset + u32(bytes, span);
const blobEnd = blobStart + u32(bytes, span + 4);
if (blobEnd > blobsEnd || blobEnd < blobStart || blobEnd - blobStart < 4) continue;

const segmentCount = u32(bytes, blobStart);
const segmentBase = blobStart + 4;
for (let seg = 0; seg < segmentCount; seg++) {
const segStart = segmentBase + seg * 40;
const segEnd = segStart + 40;
if (segEnd > blobEnd) break;

const stringIndex = u32(bytes, segStart + 28);
const byteOff = u32(bytes, segStart + 32);
const byteLen = u32(bytes, segStart + 36);
if (stringIndex >= count) continue;

const stringSpan = spanOffset + stringIndex * 8;
const strOff = u32(bytes, stringSpan);
const strLen = u32(bytes, stringSpan + 4);
if (byteOff + byteLen > strLen) continue;

const start = bytesOffset + strOff + byteOff;
const end = start + byteLen;
if (end <= tableEnd) {
pushUnique(decoder.decode(bytes.subarray(start, end)));
}
}
}

return Object.freeze(out);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,15 @@ function u32(bytes: Uint8Array, off: number): number {
return dv.getUint32(off, true);
}

function u16(bytes: Uint8Array, off: number): number {
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
return dv.getUint16(off, true);
}

function parseInternedStrings(bytes: Uint8Array): readonly string[] {
const cmdOffset = u32(bytes, 16);
const cmdBytes = u32(bytes, 20);
const cmdEnd = cmdOffset + cmdBytes;
const spanOffset = u32(bytes, 28);
const count = u32(bytes, 32);
const bytesOffset = u32(bytes, 36);
Expand All @@ -345,16 +353,89 @@ function parseInternedStrings(bytes: Uint8Array): readonly string[] {

const tableEnd = bytesOffset + bytesLen;
assert.equal(tableEnd <= bytes.byteLength, true);
assert.equal(cmdEnd <= bytes.byteLength, true);

const out: string[] = [];
const seen = new Set<string>();
const decoder = new TextDecoder();
const pushUnique = (text: string): void => {
if (seen.has(text)) return;
seen.add(text);
out.push(text);
};

for (let i = 0; i < count; i++) {
const span = spanOffset + i * 8;
const start = bytesOffset + u32(bytes, span);
const end = start + u32(bytes, span + 4);
assert.equal(end <= tableEnd, true);
out.push(decoder.decode(bytes.subarray(start, end)));
pushUnique(decoder.decode(bytes.subarray(start, end)));
}

let off = cmdOffset;
while (off < cmdEnd) {
const opcode = u16(bytes, off);
const size = u32(bytes, off + 4);
assert.equal(size >= 8, true);
if (opcode === 3 && size >= 48) {
const stringIndex = u32(bytes, off + 16);
const byteOff = u32(bytes, off + 20);
const byteLen = u32(bytes, off + 24);
if (stringIndex < count) {
const span = spanOffset + stringIndex * 8;
const strOff = u32(bytes, span);
const strLen = u32(bytes, span + 4);
if (byteOff + byteLen <= strLen) {
const start = bytesOffset + strOff + byteOff;
const end = start + byteLen;
if (end <= tableEnd) {
pushUnique(decoder.decode(bytes.subarray(start, end)));
}
}
}
}
off += size;
}
assert.equal(off, cmdEnd, "commands must parse exactly to cmd end");

const blobsSpanOffset = u32(bytes, 44);
const blobsCount = u32(bytes, 48);
const blobsBytesOffset = u32(bytes, 52);
const blobsBytesLen = u32(bytes, 56);
const blobsEnd = blobsBytesOffset + blobsBytesLen;
assert.equal(blobsEnd <= bytes.byteLength, true);

for (let i = 0; i < blobsCount; i++) {
const span = blobsSpanOffset + i * 8;
const blobStart = blobsBytesOffset + u32(bytes, span);
const blobEnd = blobStart + u32(bytes, span + 4);
if (blobEnd > blobsEnd || blobEnd < blobStart || blobEnd - blobStart < 4) continue;

const segmentCount = u32(bytes, blobStart);
const segmentBase = blobStart + 4;
for (let seg = 0; seg < segmentCount; seg++) {
const segStart = segmentBase + seg * 40;
const segEnd = segStart + 40;
if (segEnd > blobEnd) break;

const stringIndex = u32(bytes, segStart + 28);
const byteOff = u32(bytes, segStart + 32);
const byteLen = u32(bytes, segStart + 36);
if (stringIndex >= count) continue;

const stringSpan = spanOffset + stringIndex * 8;
const strOff = u32(bytes, stringSpan);
const strLen = u32(bytes, stringSpan + 4);
if (byteOff + byteLen > strLen) continue;

const start = bytesOffset + strOff + byteOff;
const end = start + byteLen;
if (end <= tableEnd) {
pushUnique(decoder.decode(bytes.subarray(start, end)));
}
}
}

return Object.freeze(out);
}

Expand Down
Loading
Loading