mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
src/next is src (the main export), src/next-node is src/node with short subpaths (/http, /workers, /plugins, /plugins/runtime, /app, /surfaces, /web, /react, /tui, /tui/launcher); the foundations get /json and /watch; Chord v1 moves to src/old (/old, /old/node) until p2 runs on v2; tests and docs move likewise (test/, docs/, test/old, docs/old). Durable and env import the foundations from their subpaths.
507 lines
22 KiB
TypeScript
507 lines
22 KiB
TypeScript
// Light benchmarks of remote objects over one memory link (slices 3a, 3b), per codec side by side: wire bytes per
|
|
// message; compute (encode and decode time per message, commit-to-frame end to end); memory (heap allocated per message
|
|
// while encoding and decoding, retained per hold); drops (3b: reconnects re-holding 1 and 100 references, 1,000 calls
|
|
// parked while the link is down). Payloads are modelled on p2: a notes list, a 500 KB conversation document, a reply
|
|
// streamed token by token, calls with and without bytes.
|
|
// Run by hand from packages/chord (not in CI):
|
|
// node --conditions=source --experimental-strip-types --expose-gc test/bench/links.ts
|
|
import { GCProfiler } from "node:v8";
|
|
import { InMemoryTelemetryContext } from "@earendil-works/pi-telemetry";
|
|
import { BACKGROUND_CONTEXT, type Context, withTelemetry } from "../../src/context/index.ts";
|
|
import { arr, bytes, hostKind, int, local, method, obj, remote, str } from "../../src/contracts/index.ts";
|
|
import { defineState } from "../../src/documents/index.ts";
|
|
import { createHost, defineFacet, type Host, type RefState } from "../../src/host/index.ts";
|
|
import { type Codec, type Link, memoryLinkPair } from "../../src/link/index.ts";
|
|
import { binaryCodec, type Message, textCodec } from "../../src/link/internal.ts";
|
|
import type { NativeWatchable } from "../../src/watch.ts";
|
|
|
|
const Server = hostKind("server");
|
|
|
|
type P = { kind: "owner" } | { kind: "server" };
|
|
|
|
const Entry = obj({ id: int(), text: str(), author: str(), at: int() });
|
|
const Shape = obj({ entries: arr(Entry) });
|
|
|
|
/** The shared store: one document of entries, and the writes the benchmark makes. */
|
|
const Store = local("bench.store", {
|
|
version: 1,
|
|
params: { name: str() },
|
|
members: {
|
|
doc: Shape,
|
|
append: method(Entry),
|
|
edit: method(obj({ index: int(), text: str() })),
|
|
remove: method(int()),
|
|
stream: method(str()),
|
|
},
|
|
});
|
|
|
|
/** The remote view a client holds: the store's document as it is. */
|
|
const View = remote("bench.view", {
|
|
version: 1,
|
|
params: { name: str() },
|
|
hostAddress: () => Server(),
|
|
variants: { owner: { state: { doc: Shape } } },
|
|
});
|
|
|
|
/** A file service: a call with a small argument, and one with bytes. */
|
|
const Files = remote("bench.files", {
|
|
version: 1,
|
|
params: {},
|
|
hostAddress: () => Server(),
|
|
variants: {
|
|
owner: { methods: { ping: method(obj({ n: int() }), int()), put: method(obj({ data: bytes() }), int()) } },
|
|
},
|
|
});
|
|
|
|
const Doc = defineState("doc", { schema: Shape, version: 1, initial: () => ({ entries: [] }) });
|
|
|
|
/** The `n` of every `ping` in the order the provider ran them. */
|
|
const pings: number[] = [];
|
|
|
|
const facet = defineFacet<P>((host) => {
|
|
host.serve(Store, (_, self) => {
|
|
const doc = self.state(Doc);
|
|
const change = (
|
|
fn: (draft: { entries: { id: number; text: string; author: string; at: number }[] }) => void,
|
|
ctx: Context,
|
|
) => doc.change((draft) => void fn(draft), ctx).then(() => null);
|
|
return {
|
|
doc,
|
|
append: (entry, ctx) => change((d) => void d.entries.push(entry), ctx),
|
|
edit: ({ index, text }, ctx) =>
|
|
change((d) => {
|
|
d.entries[index]!.text = text;
|
|
}, ctx),
|
|
remove: (index, ctx) => change((d) => void d.entries.splice(index, 1), ctx),
|
|
stream: (token, ctx) =>
|
|
change((d) => {
|
|
d.entries[d.entries.length - 1]!.text += token;
|
|
}, ctx),
|
|
};
|
|
});
|
|
host.serve(View, ({ name }, _principal, self) => ({
|
|
variant: "owner",
|
|
members: { doc: self.get(Store, { name }).doc },
|
|
}));
|
|
host.serve(Files, () => ({
|
|
variant: "owner",
|
|
members: {
|
|
ping: async ({ n }) => {
|
|
pings.push(n);
|
|
return n + 1;
|
|
},
|
|
put: async ({ data }) => data.byteLength,
|
|
},
|
|
}));
|
|
});
|
|
|
|
/** A link end counting what it sends and keeping the last message. */
|
|
function counted(link: Link): Link & { bytes: number; messages: number; last: Uint8Array | undefined } {
|
|
const counting = {
|
|
bytes: 0,
|
|
messages: 0,
|
|
last: undefined as Uint8Array | undefined,
|
|
send(message: Uint8Array) {
|
|
counting.bytes += message.byteLength;
|
|
counting.messages++;
|
|
counting.last = message;
|
|
link.send(message);
|
|
},
|
|
onMessage: (listener: (message: Uint8Array) => void) => link.onMessage(listener),
|
|
close: () => link.close(),
|
|
onClose: (listener: () => void) => link.onClose(listener),
|
|
};
|
|
return counting;
|
|
}
|
|
|
|
const telemetry = new InMemoryTelemetryContext();
|
|
const span = await telemetry.startSpan({ name: "bench" }, (s) => s);
|
|
const counting = withTelemetry(span, BACKGROUND_CONTEXT);
|
|
|
|
/** The encodes counted so far (the in-memory adapter records each add). */
|
|
const encodes = (): number =>
|
|
telemetry
|
|
.getMetrics()
|
|
.filter(({ name }) => name === "chord.link.encodes")
|
|
.reduce((sum, { value }) => sum + value, 0);
|
|
|
|
type Entry = { id: number; text: string; author: string; at: number };
|
|
|
|
function entry(id: number, length: number): Entry {
|
|
return { id, text: "x".repeat(length), author: "mario", at: 1_700_000_000_000 + id };
|
|
}
|
|
|
|
const notes = (length = 1_000): Entry[] => Array.from({ length }, (_, id) => entry(id, 80));
|
|
|
|
const document = (): Entry[] => Array.from({ length: 500 }, (_, id) => entry(id, 960));
|
|
|
|
/**
|
|
* A server with `entries` in store `name`, a client connected over a counted link in `codec`. `metered: false` leaves
|
|
* telemetry out (the in-memory adapter keeps every record, which a memory measurement would count).
|
|
*/
|
|
async function setup(name: string, entries: Entry[], codec: Codec, metered = true) {
|
|
const context = metered ? counting : BACKGROUND_CONTEXT;
|
|
const server = createHost<P>({
|
|
address: Server(),
|
|
principal: { kind: "server" },
|
|
telemetry: metered ? telemetry : undefined,
|
|
onError: console.error,
|
|
});
|
|
await server.load([{ name: "@bench/app", facet }], context);
|
|
const store = await server.get(Store, { name }, context);
|
|
for (const item of entries) await store.append(item, context);
|
|
const [a, b] = memoryLinkPair();
|
|
const down = counted(a);
|
|
const up = counted(b);
|
|
server.accept(down, { principal: { kind: "owner" }, codec }, context);
|
|
const client = createHost<P>({ onError: console.error });
|
|
client.connect(up, { principal: { kind: "server" }, codec }, context);
|
|
await client.load([], context);
|
|
const close = async () => {
|
|
await client.close(context);
|
|
await server.close(context);
|
|
};
|
|
return { server, client, store, down, up, close, context };
|
|
}
|
|
|
|
const gc = (globalThis as { gc?: () => void }).gc!;
|
|
|
|
/** Heap and `ArrayBuffer` bytes: the message bytes themselves live outside the heap. */
|
|
type Memory = { readonly heap: number; readonly arrayBuffers: number };
|
|
|
|
/** Waits until queued deliveries ran and collects garbage, repeatedly, then returns the memory in use. */
|
|
async function settledMemory(): Promise<Memory> {
|
|
for (let i = 0; i < 5; i++) {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
gc();
|
|
}
|
|
const { heapUsed, arrayBuffers } = process.memoryUsage();
|
|
return { heap: heapUsed, arrayBuffers };
|
|
}
|
|
|
|
/** Heap bytes `work` allocated: the growth plus what the collections it caused freed. */
|
|
function allocated(work: () => void): number {
|
|
gc();
|
|
const before = process.memoryUsage().heapUsed;
|
|
const profiler = new GCProfiler();
|
|
profiler.start();
|
|
work();
|
|
const after = process.memoryUsage().heapUsed;
|
|
let freed = 0;
|
|
for (const { beforeGC, afterGC } of profiler.stop().statistics) {
|
|
freed += beforeGC.heapStatistics.usedHeapSize - afterGC.heapStatistics.usedHeapSize;
|
|
}
|
|
return after - before + freed;
|
|
}
|
|
|
|
/**
|
|
* `ArrayBuffer` bytes one encoded message keeps alive (its backing store, which may be larger than the message), over
|
|
* `runs` messages kept at once. Freed buffers are not reported reliably during a loop, so this measures what is kept.
|
|
*/
|
|
async function retainedBuffers(codec: Codec, message: Message, runs: number): Promise<number> {
|
|
const before = (await settledMemory()).arrayBuffers;
|
|
const kept = Array.from({ length: runs }, () => codec.encode(message));
|
|
const after = (await settledMemory()).arrayBuffers;
|
|
return kept.length === runs ? (after - before) / runs : 0;
|
|
}
|
|
|
|
/**
|
|
* One message through a codec, `runs` times: wire bytes, encode and decode time per message, and heap allocated per
|
|
* message while encoding and decoding.
|
|
*/
|
|
function codecRun(codec: Codec, message: Message, runs: number) {
|
|
const bytes = codec.encode(message);
|
|
for (let i = 0; i < 100; i++) codec.decode(codec.encode(message));
|
|
// The fastest of five loops: one loop of a microsecond-sized message is at the mercy of the collector and the JIT.
|
|
let encode = Number.POSITIVE_INFINITY;
|
|
let decode = Number.POSITIVE_INFINITY;
|
|
for (let repeat = 0; repeat < 5; repeat++) {
|
|
const t0 = performance.now();
|
|
for (let i = 0; i < runs; i++) codec.encode(message);
|
|
const t1 = performance.now();
|
|
for (let i = 0; i < runs; i++) codec.decode(bytes);
|
|
const t2 = performance.now();
|
|
encode = Math.min(encode, (t1 - t0) / runs);
|
|
decode = Math.min(decode, (t2 - t1) / runs);
|
|
}
|
|
const heap = allocated(() => {
|
|
for (let i = 0; i < runs; i++) codec.decode(codec.encode(message));
|
|
});
|
|
return { bytes: bytes.byteLength, encode, decode, heap: heap / runs };
|
|
}
|
|
|
|
const CODECS = [binaryCodec, textCodec];
|
|
|
|
/** Results by row label, one value per codec. */
|
|
const table = new Map<string, string[]>();
|
|
|
|
const record = (label: string, codec: Codec, value: string): void => {
|
|
const row = table.get(label) ?? [];
|
|
row[CODECS.indexOf(codec)] = value;
|
|
table.set(label, row);
|
|
};
|
|
|
|
const ms = (value: number) => `${value.toFixed(3)} ms`;
|
|
const us = (value: number) => `${(value * 1000).toFixed(1)} µs`;
|
|
const kb = (value: number) => `${(value / 1024).toFixed(1)} KiB`;
|
|
const b = (value: number) => (value >= 10_240 ? kb(value) : `${value.toFixed(0)} B`);
|
|
|
|
/** Records a captured message's codec run under `label`. */
|
|
async function codecRows(label: string, codec: Codec, message: Message, runs: number): Promise<void> {
|
|
const run = codecRun(codec, message, runs);
|
|
record(`${label}: wire bytes`, codec, b(run.bytes));
|
|
record(`${label}: encode / decode`, codec, `${us(run.encode)} / ${us(run.decode)}`);
|
|
record(`${label}: heap per encode + decode`, codec, b(run.heap));
|
|
record(`${label}: array buffer kept per encoded message`, codec, b(await retainedBuffers(codec, message, 50)));
|
|
}
|
|
|
|
/** A snapshot over a link: hold to live, then the captured `bound` through the codec alone. */
|
|
async function snapshot(label: string, codec: Codec, entries: Entry[]): Promise<void> {
|
|
const { client, down, close } = await setup(label, entries, codec);
|
|
let start = performance.now();
|
|
// The first hold also activates the view; it stays held, so later holds measure the snapshot path alone.
|
|
await client.get(View, { name: label }, counting);
|
|
record(`${label}: first hold to live (activation, one sample)`, codec, ms(performance.now() - start));
|
|
const runs = 100;
|
|
start = performance.now();
|
|
for (let i = 0; i < runs; i++) (await client.get(View, { name: label }, counting)).release();
|
|
record(`${label}: hold to live (two hops, mean of ${runs})`, codec, ms((performance.now() - start) / runs));
|
|
await codecRows(`${label}: snapshot`, codec, codec.decode(down.last!), 1_000);
|
|
await close();
|
|
}
|
|
|
|
/**
|
|
* Untimed commits before the timed ones. V8 sizes the young generation from the bytes surviving its collections so
|
|
* far; with these, the timed loop runs nearer its steady size (its medians match a run with a pinned young generation),
|
|
* so a scavenge's place and cost depend less on the setup (slice 7b fix round 3, `benchmarks.md`).
|
|
*/
|
|
const WARM_UP_COMMITS = 1_000;
|
|
|
|
/**
|
|
* One commit per run, after `WARM_UP_COMMITS` untimed ones: commit-to-frame as the mean, median and p90 of the runs
|
|
* (the mean carries the scavenges that fall in the loop; the median and p90 do not), bytes and messages per commit,
|
|
* encodes per hold; then its message alone. `extra` notes more at the start, which the warm-up's writes take (a
|
|
* removal).
|
|
*/
|
|
async function commits(
|
|
label: string,
|
|
codec: Codec,
|
|
write: (store: Awaited<ReturnType<typeof setup>>["store"], i: number) => Promise<unknown>,
|
|
extra = 0,
|
|
): Promise<void> {
|
|
const { client, store, down, close } = await setup(label, notes(1_000 + extra), codec);
|
|
const view = await client.get(View, { name: label }, counting);
|
|
const watch = await view.doc.watch(counting);
|
|
let frame: () => void = () => {};
|
|
watch.start(async () => frame());
|
|
const runs = 1_000;
|
|
/** One commit and its frame; its time in milliseconds. */
|
|
const commit = async (i: number): Promise<number> => {
|
|
const arrived = new Promise<void>((resolve) => {
|
|
frame = resolve;
|
|
});
|
|
const start = performance.now();
|
|
await write(store, i);
|
|
await arrived;
|
|
return performance.now() - start;
|
|
};
|
|
for (let i = 0; i < WARM_UP_COMMITS; i++) await commit(runs + i);
|
|
const [messages0, encodes0] = [down.messages, encodes()];
|
|
const times: number[] = [];
|
|
for (let i = 0; i < runs; i++) times.push(await commit(i));
|
|
const mean = times.reduce((sum, time) => sum + time, 0) / runs;
|
|
times.sort((a, b) => a - b);
|
|
const [median, p90] = [times[runs / 2]!, times[Math.floor(runs * 0.9)]!];
|
|
record(`${label}: commit to frame (mean, median, p90)`, codec, `${ms(mean)}, ${ms(median)}, ${ms(p90)}`);
|
|
record(
|
|
`${label}: messages per commit, encodes per hold`,
|
|
codec,
|
|
`${(down.messages - messages0) / runs}, ${(encodes() - encodes0) / runs}`,
|
|
);
|
|
await codecRows(`${label}: state message`, codec, codec.decode(down.last!), 10_000);
|
|
await close();
|
|
}
|
|
|
|
/** A streamed reply: 2,000 small text appends to one entry. */
|
|
async function stream(codec: Codec): Promise<void> {
|
|
const { client, store, down, close } = await setup("stream", [entry(0, 0)], codec);
|
|
const view = await client.get(View, { name: "stream" }, counting);
|
|
const watch = await view.doc.watch(counting);
|
|
let frames = 0;
|
|
const done = Promise.withResolvers<void>();
|
|
watch.start(async () => {
|
|
if (++frames === 2_000) done.resolve();
|
|
});
|
|
const bytes0 = down.bytes;
|
|
const start = performance.now();
|
|
for (let i = 0; i < 2_000; i++) await store.stream(" tok", counting);
|
|
await done.promise;
|
|
record("streamed reply, 2,000 appends: time per append", codec, ms((performance.now() - start) / 2_000));
|
|
record(
|
|
"streamed reply: bytes per append, total",
|
|
codec,
|
|
`${((down.bytes - bytes0) / 2_000).toFixed(0)} B, ${kb(down.bytes - bytes0)}`,
|
|
);
|
|
await codecRows("streamed token", codec, codec.decode(down.last!), 10_000);
|
|
await close();
|
|
}
|
|
|
|
/** One trial: the memory retained per hold and the messages of one commit, with `holds` holds on one link. */
|
|
async function holdTrial(
|
|
codec: Codec,
|
|
holds: number,
|
|
): Promise<{ readonly perHold: Memory; readonly messages: number }> {
|
|
const name = `holds ${holds}`;
|
|
const { client, store, down, close } = await setup(name, notes(), codec, false);
|
|
const before = await settledMemory();
|
|
const views = [];
|
|
for (let i = 0; i < holds; i++) views.push(await client.get(View, { name }, BACKGROUND_CONTEXT));
|
|
await store.edit({ index: 1, text: "warm" }, BACKGROUND_CONTEXT);
|
|
const after = await settledMemory();
|
|
const messages0 = down.messages;
|
|
await store.edit({ index: 2, text: "changed" }, BACKGROUND_CONTEXT);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
const messages = down.messages - messages0;
|
|
await close();
|
|
const perHold = {
|
|
heap: (after.heap - before.heap) / holds,
|
|
arrayBuffers: (after.arrayBuffers - before.arrayBuffers) / holds,
|
|
};
|
|
return { perHold, messages };
|
|
}
|
|
|
|
const BIG_CALL_BURSTS = 20;
|
|
|
|
/**
|
|
* Calls: a small argument, and a 1 MiB `bytes()` argument; round trips, then the call and result messages alone. The
|
|
* 1 MiB round trip is the median and p90 of `BIG_CALL_BURSTS` bursts of 50 calls, each from a collected heap: the
|
|
* arguments fill memory outside the heap, and a full collection (about 0.25 ms per call over a burst) lands in one
|
|
* burst and not the next, so a single burst measured the collector, not the call.
|
|
*/
|
|
async function calls(codec: Codec): Promise<void> {
|
|
const { client, down, up, close } = await setup("calls", [], codec);
|
|
const files = await client.get(Files, {}, counting);
|
|
let start = performance.now();
|
|
for (let i = 0; i < 1_000; i++) await files.ping({ n: i }, counting);
|
|
record("call, small argument: round trip", codec, ms((performance.now() - start) / 1_000));
|
|
await codecRows("call, small argument: call message", codec, codec.decode(up.last!), 10_000);
|
|
await codecRows("call, small argument: result message", codec, codec.decode(down.last!), 10_000);
|
|
const data = new Uint8Array(1024 * 1024);
|
|
const bursts: number[] = [];
|
|
for (let burst = 0; burst < BIG_CALL_BURSTS; burst++) {
|
|
gc();
|
|
start = performance.now();
|
|
for (let i = 0; i < 50; i++) await files.put({ data }, counting);
|
|
bursts.push((performance.now() - start) / 50);
|
|
}
|
|
bursts.sort((a, b) => a - b);
|
|
const [median, p90] = [bursts[BIG_CALL_BURSTS / 2]!, bursts[Math.floor(BIG_CALL_BURSTS * 0.9)]!];
|
|
record("call, 1 MiB bytes argument: round trip (median, p90)", codec, `${ms(median)}, ${ms(p90)}`);
|
|
await codecRows("call, 1 MiB bytes argument: call message", codec, codec.decode(up.last!), 1_000);
|
|
await close();
|
|
}
|
|
|
|
/**
|
|
* Drops the client's parent link (the server's end closes it) and waits until the client saw the close; then connects
|
|
* a new counted link and returns it with the time it was connected.
|
|
*/
|
|
async function relink(server: Host<P>, client: Host<P>, down: Link, codec: Codec) {
|
|
down.close();
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
const [a, b] = memoryLinkPair();
|
|
const next = { down: counted(a), up: counted(b) };
|
|
const start = performance.now();
|
|
server.accept(next.down, { principal: { kind: "owner" }, codec }, BACKGROUND_CONTEXT);
|
|
client.connect(next.up, { principal: { kind: "server" }, codec }, BACKGROUND_CONTEXT);
|
|
return { ...next, start };
|
|
}
|
|
|
|
/** Resolves when `state` is live. */
|
|
async function live(state: NativeWatchable<RefState>): Promise<void> {
|
|
if (state.value.kind === "live") return;
|
|
const watch = await state.watch(BACKGROUND_CONTEXT);
|
|
const done = Promise.withResolvers<void>();
|
|
watch.start(async (call) => {
|
|
if (call.kind === "value" && call.value.kind === "live") done.resolve();
|
|
});
|
|
await done.promise;
|
|
await watch.stop();
|
|
}
|
|
|
|
/**
|
|
* Reconnects with `references` held references of the 1,000-item view (one provider object, a hold each): time from
|
|
* `connect` to every reference live again, and the bytes of the re-holds both ways; the mean of `runs` reconnects.
|
|
*/
|
|
async function reconnects(codec: Codec, references: number, runs: number): Promise<void> {
|
|
const name = `reconnect ${references}`;
|
|
const { server, client, down, close } = await setup(name, notes(), codec, false);
|
|
const views = [];
|
|
for (let i = 0; i < references; i++) views.push(await client.get(View, { name }, BACKGROUND_CONTEXT));
|
|
let link = down;
|
|
let time = 0;
|
|
let bytes = { down: 0, up: 0 };
|
|
for (let run = 0; run < runs; run++) {
|
|
const next = await relink(server, client, link, codec);
|
|
await Promise.all(views.map((view) => live(view.state)));
|
|
time += performance.now() - next.start;
|
|
bytes = { down: next.down.bytes, up: next.up.bytes };
|
|
link = next.down;
|
|
}
|
|
const label = `reconnect, ${references} reference(s) of 1,000 items`;
|
|
record(`${label}: time to all live (mean of ${runs})`, codec, ms(time / runs));
|
|
record(`${label}: bytes down / up`, codec, `${b(bytes.down)} / ${b(bytes.up)}`);
|
|
await close();
|
|
}
|
|
|
|
/** 1,000 calls made while the link is down, flushed on reconnect: time from `connect` to the last result, and order. */
|
|
async function parkedCalls(codec: Codec): Promise<void> {
|
|
const { server, client, down, close } = await setup("parked", [], codec, false);
|
|
const files = await client.get(Files, {}, BACKGROUND_CONTEXT);
|
|
let link = down;
|
|
let time = 0;
|
|
let ordered = true;
|
|
const runs = 10;
|
|
for (let run = 0; run < runs; run++) {
|
|
pings.length = 0;
|
|
const next = await relink(server, client, link, codec);
|
|
// The link is connected but not yet live: every call below parks until the re-hold is bound.
|
|
const results = await Promise.all(Array.from({ length: 1_000 }, (_, n) => files.ping({ n }, BACKGROUND_CONTEXT)));
|
|
time += performance.now() - next.start;
|
|
ordered &&= pings.every((n, index) => n === index) && results.every((result, index) => result === index + 1);
|
|
link = next.down;
|
|
}
|
|
record("1,000 calls parked while down, flushed on reconnect: time to the last result", codec, ms(time / runs));
|
|
record("1,000 parked calls: run in the order they were made", codec, ordered ? "yes" : "no");
|
|
await close();
|
|
}
|
|
|
|
for (const codec of CODECS) {
|
|
// Warm-up: the first run pays for compilation.
|
|
await snapshot("warm-up", codec, notes());
|
|
await snapshot("notes list of 1,000 items", codec, notes());
|
|
await snapshot("500 KB document", codec, document());
|
|
await commits("one appended item", codec, (store, i) => store.append(entry(10_000 + i, 80), counting));
|
|
await commits("one changed field", codec, (store, i) => store.edit({ index: 500, text: `edit ${i}` }, counting));
|
|
await commits("one removed item", codec, (store) => store.remove(0, counting), WARM_UP_COMMITS);
|
|
await stream(codec);
|
|
for (const holds of [1, 10]) {
|
|
// The median of seven trials: a single hold is small next to the collector's noise.
|
|
const trials: Awaited<ReturnType<typeof holdTrial>>[] = [];
|
|
for (let i = 0; i < 7; i++) trials.push(await holdTrial(codec, holds));
|
|
const median = (of: (memory: Memory) => number) =>
|
|
trials.map(({ perHold }) => of(perHold)).sort((a, b) => a - b)[3]!;
|
|
const retained = `${kb(median(({ heap }) => heap))} / ${b(median(({ arrayBuffers }) => arrayBuffers))}`;
|
|
record(`${holds} hold(s) of 1,000 items: heap / array buffers retained per hold (both ends)`, codec, retained);
|
|
record(`${holds} hold(s): messages per commit`, codec, `${trials[0]!.messages}`);
|
|
}
|
|
await calls(codec);
|
|
await reconnects(codec, 1, 20);
|
|
await reconnects(codec, 100, 5);
|
|
await parkedCalls(codec);
|
|
}
|
|
|
|
const rows = [...table].filter(([label]) => !label.startsWith("warm-up"));
|
|
const width = Math.max(...rows.map(([label]) => label.length));
|
|
console.log(`| ${"Case".padEnd(width)} | binary | text |`);
|
|
console.log(`| ${"-".repeat(width)} | --- | --- |`);
|
|
for (const [label, [binary, text]] of rows) console.log(`| ${label.padEnd(width)} | ${binary} | ${text} |`);
|