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
7 changes: 7 additions & 0 deletions .changeset/lazy-snapshots-rest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"loro-js": patch
---

Keep large latest-state snapshots encoded and hydrate containers on demand.
Local edits and later updates now use a small history overlay, while snapshot
export rewrites only dirty SSTable blocks and avoids redundant output buffers.
44 changes: 37 additions & 7 deletions context/loro-js-performance.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# loro-js Performance Architecture

Verified against code 2026-07-18.
Verified against code 2026-07-21.

The pure TypeScript runtime lives in `loro-js/src/runtime`. Its performance
target is the asymptotic behavior of the Rust runtime, while accepting a larger
Expand Down Expand Up @@ -102,12 +102,20 @@ JavaScript constant factor.
- Snapshot SSTables choose interoperable LZ4 blocks when they reduce size.
DeltaRLE state columns encode and decode as streams rather than allocating
million-item BigInt intermediates, and LZ4 decode writes into typed storage.
Importing an initial latest-state snapshot hydrates current containers and
validates its frontier blocks immediately, while retaining the other owned
history blocks in encoded form. A history query, edit, checkout, export, or
later update builds and validates all history indexes once on a staging
document before installing them; current reads, version, frontiers, and
operation count do not force that work.
Importing an initial latest-state snapshot validates every state entry and
frontier block immediately, but retains current state as an owned encoded
SSTable. Root containers are hydrated at import; referenced descendants are
decoded one SSTable block at a time when first accessed. Untouched blocks are
copied directly during snapshot export, while dirty container entries are
locally rewritten. The encoded history remains a read-only base and later
local or imported changes use a small materialized overlay. Local edits, full
update export, latest snapshot export, current reads, version, frontiers, and
operation count therefore do not build the complete history DAG. Historical
queries, checkout, partial-range export, and other APIs requiring arbitrary
dependency traversal still build and validate all history indexes once on a
staging document before installing them. Import subscribers retain eager
state hydration because their import event must describe every changed
container.

When an element's deleted flag, tree parent/position, or map visibility changes,
mutate it through its owning index helper. Direct mutation leaves subtree or
Expand All @@ -130,6 +138,28 @@ with:
pnpm --dir loro-js bench:complexity -- 1000,2000,4000,8000
```

Measure a real latest-state snapshot through import, a local Map edit, a remote
update import, full update export, and snapshot export with:

```sh
pnpm --dir loro-js bench:snapshot-memory -- /path/to/document.snapshot
```

For the 11,387,982-byte test document with 423,797 operations and 115,147
containers, Node 26.4.0 reports 70.92 MiB RSS after loading the input and a
160.70 MiB process peak after snapshot export: an 89.78 MiB incremental peak.
Used JS heap peaks at 8.58 MiB. Snapshot import takes about 0.90 seconds, the
local commit about 1.9 ms, full update export about 6.6 ms, and snapshot export
about 57 ms on the measured Apple M5 Pro. Before lazy state and history-overlay
integration, the same workflow retained roughly 703 MiB heap immediately after
import, exceeded 860 MiB after the first local edit, and reached roughly 1.66 GB
RSS during snapshot export.

The ordinary fully materialized snapshot path remains neutral in a same-machine
A/B check. After three warmups, two 15-sample B4 snapshot-export runs measured
110.5/107.0 ms medians at the parent revision and 107.7/107.7 ms with lazy
snapshots. Both revisions emitted the same 309,780-byte snapshot.

On an Apple M5 Pro with Node 26.4.0, the complete 259,778-action B4 trace now
applies in a 353.5 ms three-sample median (351.8–354.9 ms samples) and finishes
at 104,852 UTF-16 code units. The resulting process reported 107.1 MB of used JS
Expand Down
89 changes: 89 additions & 0 deletions loro-js/benchmarks/snapshot-memory.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/* eslint-disable no-console */

import { readFileSync } from "node:fs";
import process from "node:process";

import { LoroDoc } from "../dist/index.js";

const positionalArguments = process.argv.slice(2).filter((argument) => argument !== "--");
const snapshotPath = positionalArguments[0];
const rootName = positionalArguments[1] ?? "root";
if (snapshotPath === undefined) {
throw new TypeError(
"usage: pnpm bench:snapshot-memory -- <snapshot-path> [root-map-name]",
);
}

const mib = 1024 * 1024;
const phases = [];
function measure(phase, extra = {}) {
globalThis.gc?.();
const memory = process.memoryUsage();
const sample = {
phase,
heapUsedMiB: memory.heapUsed / mib,
externalMiB: memory.external / mib,
rssMiB: memory.rss / mib,
maxRssMiB: process.resourceUsage().maxRSS / 1024,
...extra,
};
phases.push(sample);
console.log(JSON.stringify(sample));
}

function timed(callback) {
const start = performance.now();
const value = callback();
return { value, milliseconds: performance.now() - start };
}

const input = readFileSync(snapshotPath);
measure("input-loaded", { bytes: input.length });
const baselineRss = phases[0].rssMiB;

const doc = new LoroDoc();
let result = timed(() => doc.import(input));
measure("snapshot-imported", { milliseconds: result.milliseconds });

result = timed(() => {
doc.getMap(rootName).set("__loro_js_memory_bench_local", 1);
doc.commit();
});
measure("local-change-committed", { milliseconds: result.milliseconds });

const remote = new LoroDoc();
remote.setPeerId(0xffff_ffffn);
remote.getMap(rootName).set("__loro_js_memory_bench_remote", 2);
remote.commit();
const remoteUpdate = remote.export({ mode: "update" });
result = timed(() => doc.import(remoteUpdate));
measure("update-imported", {
milliseconds: result.milliseconds,
bytes: remoteUpdate.length,
});

result = timed(() => doc.export({ mode: "update" }));
const update = result.value;
measure("update-exported", {
milliseconds: result.milliseconds,
bytes: update.length,
});

result = timed(() => doc.export({ mode: "snapshot" }));
const snapshot = result.value;
measure("snapshot-exported", {
milliseconds: result.milliseconds,
bytes: snapshot.length,
});

const peakRss = process.resourceUsage().maxRSS / 1024;
console.log(
JSON.stringify({
phase: "summary",
baselineRssMiB: baselineRss,
peakRssMiB: peakRss,
incrementalPeakRssMiB: peakRss - baselineRss,
peakHeapUsedMiB: Math.max(...phases.map(({ heapUsedMiB }) => heapUsedMiB)),
under100MiB: peakRss - baselineRss < 100,
}),
);
1 change: 1 addition & 0 deletions loro-js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"scripts": {
"bench:b4": "pnpm build && node --expose-gc benchmarks/b4.mjs",
"bench:complexity": "pnpm build && node --expose-gc benchmarks/complexity.mjs",
"bench:snapshot-memory": "pnpm build && node --expose-gc benchmarks/snapshot-memory.mjs",
"bench:subscribers": "pnpm build && node --expose-gc benchmarks/container-subscriber.mjs",
"build": "vp pack",
"check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test && pnpm build",
Expand Down
4 changes: 3 additions & 1 deletion loro-js/src/codec/bytes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,9 @@ export class ByteWriter {
}

toUint8Array(): Uint8Array {
return this.#buffer.slice(0, this.#length);
return this.#length === this.#buffer.length
? this.#buffer
: this.#buffer.slice(0, this.#length);
}

private ensureCapacity(extra: number): void {
Expand Down
72 changes: 44 additions & 28 deletions loro-js/src/codec/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,16 +63,17 @@ export function encodeDocument(mode: EncodeMode, body: Uint8Array): Uint8Array {
if (mode !== EncodeMode.FastSnapshot && mode !== EncodeMode.FastUpdates) {
throw new LoroEncodeError(`unsupported document mode ${mode as number}`);
}
const checksumInput = new ByteWriter(2 + body.length);
checksumInput.writeU16BE(mode);
checksumInput.writeBytes(body);
const checksumBytes = checksumInput.toUint8Array();
const writer = new ByteWriter(DOCUMENT_HEADER_LENGTH + body.length);
writer.writeBytes(DOCUMENT_MAGIC);
writer.writeBytes(new Uint8Array(12));
writer.writeU32LE(xxhash32(checksumBytes, LORO_XXHASH_SEED));
writer.writeBytes(checksumBytes);
return writer.toUint8Array();
const output = new Uint8Array(DOCUMENT_HEADER_LENGTH + body.length);
output.set(DOCUMENT_MAGIC, 0);
output[20] = mode >>> 8;
output[21] = mode & 0xff;
output.set(body, DOCUMENT_HEADER_LENGTH);
new DataView(output.buffer, output.byteOffset, output.byteLength).setUint32(
16,
xxhash32(output.subarray(20), LORO_XXHASH_SEED),
true,
);
return output;
}

export function decodeFastSnapshotBody(body: Uint8Array): FastSnapshotBody {
Expand All @@ -85,13 +86,27 @@ export function decodeFastSnapshotBody(body: Uint8Array): FastSnapshotBody {
}

export function encodeFastSnapshotBody(snapshot: FastSnapshotBody): Uint8Array {
const writer = new ByteWriter(
for (const [value, label] of [
[snapshot.oplog, "oplog"],
[snapshot.state, "state"],
[snapshot.shallowRootState, "shallow root state"],
] as const) {
if (value.length > 0xffff_ffff) {
throw new LoroEncodeError(`${label} is too large`);
}
}
const output = new Uint8Array(
12 + snapshot.oplog.length + snapshot.state.length + snapshot.shallowRootState.length,
);
writeU32LengthPrefixed(writer, snapshot.oplog, "oplog");
writeU32LengthPrefixed(writer, snapshot.state, "state");
writeU32LengthPrefixed(writer, snapshot.shallowRootState, "shallow root state");
return writer.toUint8Array();
const view = new DataView(output.buffer, output.byteOffset, output.byteLength);
let offset = 0;
for (const value of [snapshot.oplog, snapshot.state, snapshot.shallowRootState]) {
view.setUint32(offset, value.length, true);
offset += 4;
output.set(value, offset);
offset += value.length;
}
return output;
}

export function decodeFastUpdatesBody(body: Uint8Array): Uint8Array[] {
Expand All @@ -105,14 +120,27 @@ export function decodeFastUpdatesBody(body: Uint8Array): Uint8Array[] {
}

export function encodeFastUpdatesBody(blocks: readonly Uint8Array[]): Uint8Array {
const writer = new ByteWriter();
const length = blocks.reduce(
(sum, block) => sum + ulebByteLength(block.length) + block.length,
0,
);
const writer = new ByteWriter(length);
for (const block of blocks) {
writeUleb128(writer, block.length);
writer.writeBytes(block);
}
return writer.toUint8Array();
}

function ulebByteLength(value: number): number {
let length = 1;
while (value >= 0x80) {
value = Math.floor(value / 0x80);
length += 1;
}
return length;
}

export function decodeFastSnapshot(
bytes: Uint8Array,
options?: DecodeDocumentOptions,
Expand Down Expand Up @@ -155,15 +183,3 @@ function readU32LengthPrefixed(reader: ByteReader, label: string): Uint8Array {
}
return reader.readBytes(length);
}

function writeU32LengthPrefixed(
writer: ByteWriter,
value: Uint8Array,
label: string,
): void {
if (value.length > 0xffff_ffff) {
throw new LoroEncodeError(`${label} is too large`);
}
writer.writeU32LE(value.length);
writer.writeBytes(value);
}
Loading
Loading