diff --git a/benchmarks/src/bench-multishape.ts b/benchmarks/src/bench-multishape.ts new file mode 100644 index 000000000..6aeb9b5af --- /dev/null +++ b/benchmarks/src/bench-multishape.ts @@ -0,0 +1,277 @@ +// Copyright 2021-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// L3 multi-shape benchmark. +// +// Purpose: verify the core L3 claim — that alternating 3+ shapes through +// one schema is faster on the adaptive plan (variants graduate and each +// one is monomorphic on `msg[name]`) than on the generic L1+L2 plan (a +// single polymorphic plan that sees every shape's hidden class). +// +// Fixture: the `SimpleMessage` schema has 3 fields, which lets us sweep +// through exactly 3 distinct presence patterns (`{field1}`, `{field2}`, +// `{field3}`). We alternate them in strict round-robin to force the +// polymorphic property-read site to see all three shapes; V8 pushes it +// to 3-way polymorphic in the L1+L2 run, while in the L3 run each variant +// keeps its own monomorphic IC. +// +// The driver writes a JSON summary to stdout so `scripts/median-results.ts` +// and `scripts/compare-results.ts` can line-diff against `baselines/main.json` +// without shape-specific tooling. + +import { create, toBinary, toBinaryFast } from "@bufbuild/protobuf"; +import { Bench } from "tinybench"; +import { AnyValueSchema, KeyValueSchema, SpanSchema } from "./gen/nested_pb.js"; +import { SimpleMessageSchema } from "./gen/small_pb.js"; + +const ITERATIONS_WARMUP = 40; // well past L3_WARMUP=10 × 3 shapes + +function buildShapes(): ReturnType< + typeof create +>[] { + return [ + create(SimpleMessageSchema, { + name: "the quick brown fox", + value: 0, + enabled: false, + }), + create(SimpleMessageSchema, { + name: "", + value: 0x6bad_f00d, + enabled: false, + }), + create(SimpleMessageSchema, { + name: "", + value: 0, + enabled: true, + }), + ]; +} + +/** + * Three Span shapes with different presence patterns — mirrors the + * three-shape OTel pattern called out in the L3 design spec (full / + * event / error). + */ +function buildSpanShapes(): ReturnType>[] { + const kv = (k: string, v: string) => + create(KeyValueSchema, { + key: k, + value: create(AnyValueSchema, { + value: { case: "stringValue", value: v }, + }), + }); + const traceId = new Uint8Array(16).fill(0x11); + const spanId = new Uint8Array(8).fill(0x22); + return [ + // Full-shape: all scalar + a short attrs list. + create(SpanSchema, { + traceId, + spanId, + name: "GET /v1/users", + startTimeUnixNano: 1_700_000_000_000_000_000n, + endTimeUnixNano: 1_700_000_001_000_000_000n, + attributes: [kv("http.method", "GET"), kv("http.status_code", "200")], + }), + // Short-shape: no attrs, only IDs + timestamps (status/health spans). + create(SpanSchema, { + traceId, + spanId, + name: "healthcheck", + startTimeUnixNano: 1_700_000_000_000_000_000n, + endTimeUnixNano: 1_700_000_000_500_000_000n, + attributes: [], + }), + // Error-shape: IDs + timestamp + attrs, no name (empty string omitted). + create(SpanSchema, { + traceId, + spanId, + name: "", + startTimeUnixNano: 1_700_000_000_000_000_000n, + endTimeUnixNano: 1_700_000_000_100_000_000n, + attributes: [ + kv("error", "true"), + kv("error.type", "timeout"), + kv("error.message", "upstream deadline exceeded"), + ], + }), + ]; +} + +async function main(): Promise { + const time = Number(process.env.BENCH_MATRIX_TIME ?? 1500); + const warmupTime = Number(process.env.BENCH_MATRIX_WARMUP ?? 300); + const bench = new Bench({ time, warmupTime }); + + const shapes = buildShapes(); + const spans = buildSpanShapes(); + + // Byte-parity sanity (fail fast before the measurement phase). + for (const s of shapes) { + const ref = toBinary(SimpleMessageSchema, s); + const adaptive = toBinaryFast(SimpleMessageSchema, s, { adaptive: true }); + const generic = toBinaryFast(SimpleMessageSchema, s); + if ( + ref.length !== adaptive.length || + ref.length !== generic.length || + !ref.every((b, i) => b === adaptive[i] && b === generic[i]) + ) { + throw new Error( + "bench-multishape: SimpleMessage byte parity check failed", + ); + } + } + for (const s of spans) { + const ref = toBinary(SpanSchema, s); + const adaptive = toBinaryFast(SpanSchema, s, { adaptive: true }); + if ( + ref.length !== adaptive.length || + !ref.every((b, i) => b === adaptive[i]) + ) { + throw new Error("bench-multishape: Span byte parity check failed"); + } + } + + // Prime the observer: ensures variants are graduated before measurement. + for (let i = 0; i < ITERATIONS_WARMUP; i++) { + for (const s of shapes) { + toBinaryFast(SimpleMessageSchema, s, { adaptive: true }); + } + for (const s of spans) { + toBinaryFast(SpanSchema, s, { adaptive: true }); + } + } + + bench.add("SimpleMessage multi-shape :: L1+L2 generic", () => { + toBinaryFast(SimpleMessageSchema, shapes[0]); + toBinaryFast(SimpleMessageSchema, shapes[1]); + toBinaryFast(SimpleMessageSchema, shapes[2]); + }); + bench.add("SimpleMessage multi-shape :: L3 adaptive", () => { + toBinaryFast(SimpleMessageSchema, shapes[0], { adaptive: true }); + toBinaryFast(SimpleMessageSchema, shapes[1], { adaptive: true }); + toBinaryFast(SimpleMessageSchema, shapes[2], { adaptive: true }); + }); + // Single-shape regression gate: run the same shape 3× per op so the + // per-op cost comparison stays apples-to-apples. + bench.add("SimpleMessage single-shape :: L1+L2 generic", () => { + toBinaryFast(SimpleMessageSchema, shapes[0]); + toBinaryFast(SimpleMessageSchema, shapes[0]); + toBinaryFast(SimpleMessageSchema, shapes[0]); + }); + bench.add("SimpleMessage single-shape :: L3 adaptive", () => { + toBinaryFast(SimpleMessageSchema, shapes[0], { adaptive: true }); + toBinaryFast(SimpleMessageSchema, shapes[0], { adaptive: true }); + toBinaryFast(SimpleMessageSchema, shapes[0], { adaptive: true }); + }); + + // Span multi-shape. More fields + repeated attrs give the L3 variant + // more `isFieldSet` checks to skip per op. + bench.add("Span multi-shape :: L1+L2 generic", () => { + toBinaryFast(SpanSchema, spans[0]); + toBinaryFast(SpanSchema, spans[1]); + toBinaryFast(SpanSchema, spans[2]); + }); + bench.add("Span multi-shape :: L3 adaptive", () => { + toBinaryFast(SpanSchema, spans[0], { adaptive: true }); + toBinaryFast(SpanSchema, spans[1], { adaptive: true }); + toBinaryFast(SpanSchema, spans[2], { adaptive: true }); + }); + bench.add("Span single-shape :: L1+L2 generic", () => { + toBinaryFast(SpanSchema, spans[0]); + toBinaryFast(SpanSchema, spans[0]); + toBinaryFast(SpanSchema, spans[0]); + }); + bench.add("Span single-shape :: L3 adaptive", () => { + toBinaryFast(SpanSchema, spans[0], { adaptive: true }); + toBinaryFast(SpanSchema, spans[0], { adaptive: true }); + toBinaryFast(SpanSchema, spans[0], { adaptive: true }); + }); + + await bench.run(); + + const rows = bench.tasks.map((t) => ({ + name: t.name, + opsPerSec: t.result?.hz ?? 0, + rme: t.result?.rme ?? 0, + samples: t.result?.samples.length ?? 0, + })); + + // Emit table for eyeballing. + console.table( + rows.map((r) => ({ + name: r.name, + "ops/s": r.opsPerSec.toFixed(0), + "rme %": r.rme.toFixed(2), + samples: r.samples, + })), + ); + + // Emit JSON for scripts/compare-results.ts. + console.log( + JSON.stringify( + { + fixture: "multishape", + generatedAt: new Date().toISOString(), + node: process.version, + rows, + }, + null, + 2, + ), + ); + + // Compute deltas so the run self-reports its gates. + const get = (name: string): number => + rows.find((r) => r.name === name)?.opsPerSec ?? 0; + const delta = (baseline: string, current: string): number => { + const b = get(baseline); + const c = get(current); + return b > 0 ? c / b - 1 : 0; + }; + const multiSimple = delta( + "SimpleMessage multi-shape :: L1+L2 generic", + "SimpleMessage multi-shape :: L3 adaptive", + ); + const singleSimple = delta( + "SimpleMessage single-shape :: L1+L2 generic", + "SimpleMessage single-shape :: L3 adaptive", + ); + const multiSpan = delta( + "Span multi-shape :: L1+L2 generic", + "Span multi-shape :: L3 adaptive", + ); + const singleSpan = delta( + "Span single-shape :: L1+L2 generic", + "Span single-shape :: L3 adaptive", + ); + + console.log( + `\nSimpleMessage multi-shape: ${(multiSimple * 100).toFixed(2)} % (target >= +10%)`, + ); + console.log( + `SimpleMessage single-shape: ${(singleSimple * 100).toFixed(2)} % (regression <= 3%)`, + ); + console.log( + `Span multi-shape: ${(multiSpan * 100).toFixed(2)} % (target >= +10%)`, + ); + console.log( + `Span single-shape: ${(singleSpan * 100).toFixed(2)} % (regression <= 3%)`, + ); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/biome.json b/biome.json index 59c79b6e9..ed392ce9c 100644 --- a/biome.json +++ b/biome.json @@ -2,6 +2,12 @@ "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", "extends": ["./biome.base.json"], "files": { - "ignore": ["packages/**", "deno/example/**"] + "ignore": [ + "packages/**", + "deno/example/**", + "benchmarks/src/gen/**", + "benchmarks/src/gen-protobufjs/**", + ".tmp/**" + ] } } diff --git a/packages/protobuf-test/src/schema-plan-adaptive.test.ts b/packages/protobuf-test/src/schema-plan-adaptive.test.ts new file mode 100644 index 000000000..02b75c88d --- /dev/null +++ b/packages/protobuf-test/src/schema-plan-adaptive.test.ts @@ -0,0 +1,362 @@ +// Copyright 2021-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// L3 runtime monomorphization tests. The load-bearing claims: +// +// 1) The shape observer graduates a variant after `L3_WARMUP` repeats of +// the same shape and that variant produces byte-identical output to +// both `toBinaryFast` (generic L1+L2) and `toBinary` (reflective). +// 2) The variant cap (`L3_VARIANT_CAP = 4`) seals the record when a 5th +// distinct shape asks for graduation; subsequent novel shapes never +// re-trigger graduation. +// 3) Shape drift after seal routes back through the generic plan and +// remains byte-parity correct. +// 4) Mode B (`new Function()` executor) is gated behind the opt-in flag +// and produces output byte-identical to Mode A for the same shape. + +import { suite, test } from "node:test"; +import * as assert from "node:assert"; +import { create, toBinary, toBinaryFast, protoInt64 } from "@bufbuild/protobuf"; +import { + getOrCreateVariants, + computeShapeHash, + L3_WARMUP, + L3_VARIANT_CAP, +} from "@bufbuild/protobuf/wire/schema-plan-adaptive"; + +import { ScalarValuesMessageSchema } from "./gen/ts/extra/msg-scalar_pb.js"; +import { + OneofMessageSchema, + OneofMessageFooSchema, +} from "./gen/ts/extra/msg-oneof_pb.js"; + +// Re-use `ScalarValuesMessageSchema` which carries a wide mix of scalar +// types and explicit/implicit presence. Each test must construct a fresh +// schema reference to get a clean observer record — we rely on WeakMap +// keying by schema identity and this file always keys off the imported +// schema object. To reset state between tests we call `getOrCreateVariants` +// and clear the observer in-place via its public mutable fields. +function resetObserver(desc: Parameters[0]): void { + const rec = getOrCreateVariants(desc); + (rec as { sealed: boolean }).sealed = false; + (rec as { observationCount: number }).observationCount = 0; + rec.shapeCounter.clear(); + rec.variants.clear(); +} + +void suite("L3 schema-plan-adaptive", () => { + void suite("shape hashing", () => { + test("distinct presence patterns produce distinct bigint signatures", () => { + const a = create(ScalarValuesMessageSchema, { doubleField: 1 }); + const b = create(ScalarValuesMessageSchema, { stringField: "hi" }); + const hA = computeShapeHash( + ScalarValuesMessageSchema, + a as unknown as Record, + ); + const hB = computeShapeHash( + ScalarValuesMessageSchema, + b as unknown as Record, + ); + assert.notStrictEqual(hA, hB); + assert.ok(typeof hA === "bigint"); + assert.ok(typeof hB === "bigint"); + }); + + test("same presence pattern yields same signature regardless of values", () => { + const a = create(ScalarValuesMessageSchema, { int32Field: 1 }); + const b = create(ScalarValuesMessageSchema, { int32Field: 999 }); + assert.strictEqual( + computeShapeHash( + ScalarValuesMessageSchema, + a as unknown as Record, + ), + computeShapeHash( + ScalarValuesMessageSchema, + b as unknown as Record, + ), + ); + }); + + test("oneof arms are distinct signatures", () => { + const strArm = create(OneofMessageSchema, { + scalar: { case: "error", value: "oops" }, + }); + const intArm = create(OneofMessageSchema, { + scalar: { case: "value", value: 7 }, + }); + const hStr = computeShapeHash( + OneofMessageSchema, + strArm as unknown as Record, + ); + const hInt = computeShapeHash( + OneofMessageSchema, + intArm as unknown as Record, + ); + assert.notStrictEqual(hStr, hInt); + }); + }); + + void suite("graduation", () => { + test("same shape graduates after L3_WARMUP encodes", () => { + resetObserver(ScalarValuesMessageSchema); + const msg = create(ScalarValuesMessageSchema, { + doubleField: 1.5, + int32Field: 42, + }); + // Before graduation: observationCount accrues, no variants. + for (let i = 0; i < L3_WARMUP - 1; i++) { + toBinaryFast(ScalarValuesMessageSchema, msg, { adaptive: true }); + } + let rec = getOrCreateVariants(ScalarValuesMessageSchema); + assert.strictEqual(rec.variants.size, 0); + assert.strictEqual( + rec.observationCount, + L3_WARMUP - 1, + "pre-graduation observation count", + ); + + // N-th call crosses the threshold and graduates. + toBinaryFast(ScalarValuesMessageSchema, msg, { adaptive: true }); + rec = getOrCreateVariants(ScalarValuesMessageSchema); + assert.strictEqual(rec.variants.size, 1); + assert.strictEqual(rec.shapeCounter.size, 0); + }); + + test("variant encodes byte-identical to generic plan", () => { + resetObserver(ScalarValuesMessageSchema); + const msg = create(ScalarValuesMessageSchema, { + doubleField: 3.14, + stringField: "hello", + int64Field: protoInt64.parse("9000000000"), + }); + // Warmup past graduation. + for (let i = 0; i < L3_WARMUP; i++) { + toBinaryFast(ScalarValuesMessageSchema, msg, { adaptive: true }); + } + // This call lands on the variant plan. + const viaVariant = toBinaryFast(ScalarValuesMessageSchema, msg, { + adaptive: true, + }); + const viaGeneric = toBinaryFast(ScalarValuesMessageSchema, msg); + const viaReflective = toBinary(ScalarValuesMessageSchema, msg); + assert.deepStrictEqual(Array.from(viaVariant), Array.from(viaGeneric)); + assert.deepStrictEqual(Array.from(viaVariant), Array.from(viaReflective)); + }); + }); + + void suite("variant cap", () => { + test("5th distinct shape seals the record", () => { + resetObserver(ScalarValuesMessageSchema); + // Shape 1..4 — graduate each. + const shapes = [ + create(ScalarValuesMessageSchema, { doubleField: 1 }), + create(ScalarValuesMessageSchema, { stringField: "a" }), + create(ScalarValuesMessageSchema, { int32Field: 1 }), + create(ScalarValuesMessageSchema, { int64Field: protoInt64.parse(1) }), + ]; + for (const shape of shapes) { + for (let i = 0; i < L3_WARMUP; i++) { + toBinaryFast(ScalarValuesMessageSchema, shape, { adaptive: true }); + } + } + const rec1 = getOrCreateVariants(ScalarValuesMessageSchema); + assert.strictEqual( + rec1.variants.size, + L3_VARIANT_CAP, + "expected 4 graduated variants", + ); + assert.strictEqual(rec1.sealed, false); + + // Shape 5 — attempt to graduate should seal. + const shape5 = create(ScalarValuesMessageSchema, { boolField: true }); + for (let i = 0; i < L3_WARMUP; i++) { + toBinaryFast(ScalarValuesMessageSchema, shape5, { adaptive: true }); + } + const rec2 = getOrCreateVariants(ScalarValuesMessageSchema); + assert.strictEqual(rec2.sealed, true, "record must seal on 5th shape"); + assert.strictEqual( + rec2.variants.size, + L3_VARIANT_CAP, + "no new variant is added on seal", + ); + }); + + test("post-seal novel shapes still encode byte-parity", () => { + resetObserver(ScalarValuesMessageSchema); + // Graduate 4 shapes then trigger seal. + const shapes = [ + create(ScalarValuesMessageSchema, { doubleField: 1 }), + create(ScalarValuesMessageSchema, { stringField: "a" }), + create(ScalarValuesMessageSchema, { int32Field: 1 }), + create(ScalarValuesMessageSchema, { int64Field: protoInt64.parse(1) }), + create(ScalarValuesMessageSchema, { boolField: true }), + ]; + for (const shape of shapes) { + for (let i = 0; i < L3_WARMUP; i++) { + toBinaryFast(ScalarValuesMessageSchema, shape, { adaptive: true }); + } + } + assert.strictEqual( + getOrCreateVariants(ScalarValuesMessageSchema).sealed, + true, + ); + + // Previously-graduated shapes still route to variants (and stay correct). + for (const shape of shapes.slice(0, 4)) { + const adaptive = toBinaryFast(ScalarValuesMessageSchema, shape, { + adaptive: true, + }); + const reflective = toBinary(ScalarValuesMessageSchema, shape); + assert.deepStrictEqual(Array.from(adaptive), Array.from(reflective)); + } + + // Novel post-seal shapes go through generic — still correct. + const novel = create(ScalarValuesMessageSchema, { + uint32Field: 12345, + floatField: 2.5, + }); + const adaptive = toBinaryFast(ScalarValuesMessageSchema, novel, { + adaptive: true, + }); + const reflective = toBinary(ScalarValuesMessageSchema, novel); + assert.deepStrictEqual(Array.from(adaptive), Array.from(reflective)); + }); + }); + + void suite("shape drift", () => { + test("value changes within same shape keep variant stable", () => { + resetObserver(ScalarValuesMessageSchema); + // Graduate a shape with two scalars. + const warm = create(ScalarValuesMessageSchema, { + doubleField: 1, + stringField: "one", + }); + for (let i = 0; i < L3_WARMUP; i++) { + toBinaryFast(ScalarValuesMessageSchema, warm, { adaptive: true }); + } + assert.strictEqual( + getOrCreateVariants(ScalarValuesMessageSchema).variants.size, + 1, + ); + + // Drift: same shape, different values. Expect variant hit + parity. + const drift = create(ScalarValuesMessageSchema, { + doubleField: 999, + stringField: "two", + }); + const adaptive = toBinaryFast(ScalarValuesMessageSchema, drift, { + adaptive: true, + }); + const reflective = toBinary(ScalarValuesMessageSchema, drift); + assert.deepStrictEqual(Array.from(adaptive), Array.from(reflective)); + // No new graduation — still 1 variant, no counter entries. + const rec = getOrCreateVariants(ScalarValuesMessageSchema); + assert.strictEqual(rec.variants.size, 1); + }); + }); + + void suite("oneof parity under L3", () => { + test("two oneof arms graduate as two variants", () => { + resetObserver(OneofMessageSchema); + const foo = create(OneofMessageSchema, { + scalar: { case: "value", value: 99 }, + }); + const bar = create(OneofMessageSchema, { + scalar: { case: "error", value: "boom" }, + }); + for (let i = 0; i < L3_WARMUP; i++) { + toBinaryFast(OneofMessageSchema, foo, { adaptive: true }); + toBinaryFast(OneofMessageSchema, bar, { adaptive: true }); + } + const rec = getOrCreateVariants(OneofMessageSchema); + assert.strictEqual( + rec.variants.size, + 2, + "expected one variant per oneof arm", + ); + assert.deepStrictEqual( + Array.from(toBinaryFast(OneofMessageSchema, foo, { adaptive: true })), + Array.from(toBinary(OneofMessageSchema, foo)), + ); + assert.deepStrictEqual( + Array.from(toBinaryFast(OneofMessageSchema, bar, { adaptive: true })), + Array.from(toBinary(OneofMessageSchema, bar)), + ); + }); + + test("message oneof arm graduates correctly", () => { + resetObserver(OneofMessageSchema); + const msg = create(OneofMessageSchema, { + message: { + case: "foo", + value: create(OneofMessageFooSchema, { name: "alpha" }), + }, + }); + for (let i = 0; i < L3_WARMUP + 2; i++) { + toBinaryFast(OneofMessageSchema, msg, { adaptive: true }); + } + assert.strictEqual( + getOrCreateVariants(OneofMessageSchema).variants.size, + 1, + ); + assert.deepStrictEqual( + Array.from(toBinaryFast(OneofMessageSchema, msg, { adaptive: true })), + Array.from(toBinary(OneofMessageSchema, msg)), + ); + }); + }); + + void suite("Mode B codegen executor (opt-in)", () => { + test("new Function() variant produces byte-identical output", () => { + const flag = Symbol.for("@bufbuild/protobuf.adaptive-codegen"); + const g = globalThis as Record; + const prev = g[flag]; + g[flag] = true; + try { + resetObserver(ScalarValuesMessageSchema); + const msg = create(ScalarValuesMessageSchema, { + doubleField: 2.5, + stringField: "codegen", + int32Field: -7, + }); + // Graduate. + for (let i = 0; i < L3_WARMUP; i++) { + toBinaryFast(ScalarValuesMessageSchema, msg, { adaptive: true }); + } + const rec = getOrCreateVariants(ScalarValuesMessageSchema); + const [variant] = Array.from(rec.variants.values()); + assert.ok(variant); + assert.strictEqual( + variant.codegen, + true, + "Mode B flag must produce a codegen variant", + ); + const viaVariant = toBinaryFast(ScalarValuesMessageSchema, msg, { + adaptive: true, + }); + const viaReflective = toBinary(ScalarValuesMessageSchema, msg); + assert.deepStrictEqual( + Array.from(viaVariant), + Array.from(viaReflective), + ); + } finally { + if (prev === undefined) { + delete g[flag]; + } else { + g[flag] = prev; + } + } + }); + }); +}); diff --git a/packages/protobuf/package.json b/packages/protobuf/package.json index 07cd0b85a..99e59c878 100644 --- a/packages/protobuf/package.json +++ b/packages/protobuf/package.json @@ -50,6 +50,10 @@ "./wire": { "import": "./dist/esm/wire/index.js", "require": "./dist/cjs/wire/index.js" + }, + "./wire/schema-plan-adaptive": { + "import": "./dist/esm/wire/schema-plan-adaptive.js", + "require": "./dist/cjs/wire/schema-plan-adaptive.js" } }, "typesVersions": { @@ -58,7 +62,8 @@ "codegenv2": ["./dist/cjs/codegenv2/index.d.ts"], "reflect": ["./dist/cjs/reflect/index.d.ts"], "wkt": ["./dist/cjs/wkt/index.d.ts"], - "wire": ["./dist/cjs/wire/index.d.ts"] + "wire": ["./dist/cjs/wire/index.d.ts"], + "wire/schema-plan-adaptive": ["./dist/cjs/wire/schema-plan-adaptive.d.ts"] } }, "devDependencies": { diff --git a/packages/protobuf/src/to-binary-fast.ts b/packages/protobuf/src/to-binary-fast.ts index 9709e5c48..037b017b7 100644 --- a/packages/protobuf/src/to-binary-fast.ts +++ b/packages/protobuf/src/to-binary-fast.ts @@ -65,6 +65,10 @@ import { import { protoInt64 } from "./proto-int64.js"; import { toBinary } from "./to-binary.js"; import { getTextEncoding } from "./wire/text-encoding.js"; +import { + selectOrObserve, + type VariantHelpers, +} from "./wire/schema-plan-adaptive.js"; // ----------------------------------------------------------------------------- // Support detection @@ -939,6 +943,55 @@ function writeMessageInto( // Entry point // ----------------------------------------------------------------------------- +// ----------------------------------------------------------------------------- +// Adaptive (L3) glue +// ----------------------------------------------------------------------------- +// +// L3 is an opt-in overlay that observes message shapes per schema and +// graduates specialized per-shape plans after a warmup window. The generic +// L1+L2 estimate/write helpers above are exposed to L3 through +// `adaptiveHelpers` so that a variant plan's unrolled step list can call +// directly into them without re-entering the field-presence gate. +// +// Default: adaptive is off. Enable per-call via `{ adaptive: true }` or +// globally via `process.env.PROTOBUF_ES_L3 === "1"`. See +// `packages/protobuf/src/wire/schema-plan-adaptive.ts`. + +const adaptiveHelpers: VariantHelpers = { + estimateRegular: (field, value, sizes) => + estimateRegularFieldSize(field, value, sizes), + estimateMap: (field, obj, sizes) => + estimateMapFieldSize(field as DescField & { fieldKind: "map" }, obj, sizes), + writeRegular: (cursor, field, value, sizes) => + writeRegularField(cursor as Cursor, field, value, sizes), + writeMap: (cursor, field, obj, sizes) => + writeMapField( + cursor as Cursor, + field as DescField & { fieldKind: "map" }, + obj, + sizes, + ), +}; + +function adaptiveDefault(): boolean { + // Cross-runtime lookup avoids depending on @types/node in this package. + const g = globalThis as { + process?: { env?: Record }; + }; + return g.process?.env?.PROTOBUF_ES_L3 === "1"; +} + +/** + * Options accepted by {@link toBinaryFast}. + * + * `adaptive` turns on L3 runtime monomorphization: the encoder observes + * message shapes per schema and graduates specialized plans for the + * recurring ones (see `wire/schema-plan-adaptive.ts`). Default: false. + */ +export interface ToBinaryFastOptions { + adaptive?: boolean; +} + /** * Opt-in fast-path binary encoder. See the top-of-file comment for the * motivation and scope. @@ -956,12 +1009,38 @@ function writeMessageInto( export function toBinaryFast( schema: Desc, message: MessageShape, + options?: ToBinaryFastOptions, ): Uint8Array { if (!isSupported(schema)) { return toBinary(schema, message); } - const sizes: SizeMap = new Map(); const msg = message as unknown as Record; + const adaptive = options?.adaptive ?? adaptiveDefault(); + + if (adaptive) { + const variant = selectOrObserve(schema, msg, adaptiveHelpers); + if (variant !== undefined) { + const sizes: SizeMap = new Map(); + const total = variant.estimate(msg, sizes); + const buf = new Uint8Array(total); + const cursor: Cursor = { + buf, + view: new DataView(buf.buffer, buf.byteOffset, buf.byteLength), + pos: 0, + encodeUtf8: getTextEncoding().encodeUtf8, + }; + variant.write(cursor, msg, sizes); + if (cursor.pos !== total) { + throw new Error( + `toBinaryFast (L3): size/write mismatch (est=${total} wrote=${cursor.pos}) — please report this as a bug`, + ); + } + return buf; + } + // Observation miss — fall through to generic. + } + + const sizes: SizeMap = new Map(); const total = estimateMessageSize(schema, msg, sizes); const buf = new Uint8Array(total); const cursor: Cursor = { diff --git a/packages/protobuf/src/wire/schema-plan-adaptive.ts b/packages/protobuf/src/wire/schema-plan-adaptive.ts new file mode 100644 index 000000000..ebe3ba378 --- /dev/null +++ b/packages/protobuf/src/wire/schema-plan-adaptive.ts @@ -0,0 +1,603 @@ +// Copyright 2021-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// L3 Runtime Monomorphization — shape-observed variant plans layered +// atop the L1+L2 hot path in `to-binary-fast.ts`. +// +// The idea (per `analysis/p1-t6-l3-design-spec.md`): +// +// L1+L2 compiles one set of size/write routines per `DescMessage`. The +// inner property read `msg[field.localName]` therefore sees every hidden +// class that the schema is ever encoded against. On OTel-style workloads +// the same schema is hit with 3–6 distinct shapes (request / response / +// error / oneof-arm variations) and V8 turns that property access site +// megamorphic, costing 1.4–3.5× in the encode loop. +// +// L3 observes incoming messages over the first `N = 10` encode calls +// per schema, computes a compact "shape signature" (per-slot field- +// presence bitmap), and once any single shape repeats ≥ 5 times it +// graduates a specialized plan variant for that shape. Up to 4 variants +// per schema; the 5th unique shape seals the record and sends every +// subsequent call back through the generic plan. +// +// This module is a *pure additive overlay* — default behaviour of +// `toBinaryFast` does not change. L3 is opt-in via the `adaptive: true` +// option or `PROTOBUF_ES_L3=1`. +// +// ## Two execution modes (D10 + CSP clarification) +// +// Mode A — CSP-safe (default). +// A variant is a pre-computed `FieldPlan[]` (compact descriptor for +// each field known-present in the observed shape). The variant +// executor is a statically-imported function that walks this array, +// skipping the generic `isFieldSet` presence gate entirely. This path +// does not use `new Function()` and runs under strict CSP +// (`'unsafe-eval'` denied). +// +// Mode B — CSP-unsafe (opt-in). +// Enabled by setting `globalThis[Symbol.for('@bufbuild/protobuf.adaptive-codegen')] = true` +// *before* the first encode of a given schema. On graduation, the +// variant's executor source is template-generated and constructed via +// `new Function(...)`, giving each variant its own JIT-inlined loop +// with its own inline-cache scope. Template tokens draw only from the +// `Op` enum and descriptor metadata — no user data flows into the +// source. +// +// Shape-drift handling: after a variant graduates, any future novel +// shape falls through to the generic plan. Once the variant cap (4) is +// breached, the record seals and further graduation stops permanently; +// already-graduated variants keep serving their shapes. + +import { ScalarType } from "../descriptors.js"; +import type { DescField, DescMessage, DescOneof } from "../descriptors.js"; + +// ----------------------------------------------------------------------------- +// Tunables +// ----------------------------------------------------------------------------- + +/** + * Observation threshold (D1). A shape graduates to its own variant plan + * once it has been observed this many times. Configurable via + * `PROTOBUF_ES_L3_WARMUP` so benchmarks can sweep the knob. + */ +export const L3_WARMUP: number = (() => { + // Cross-runtime env lookup — avoids a hard dependency on Node's + // `process` global (the package is published without @types/node). + const g = globalThis as { + process?: { env?: Record }; + }; + const env = g.process?.env; + const raw = env ? env.PROTOBUF_ES_L3_WARMUP : undefined; + const parsed = raw !== undefined ? Number.parseInt(raw, 10) : Number.NaN; + return Number.isFinite(parsed) && parsed > 0 ? parsed : 10; +})(); + +/** + * Variant cap (D3). Matches V8's polymorphic IC 4-way threshold. The + * 5th unique shape seals the record. + */ +export const L3_VARIANT_CAP = 4; + +/** + * Max schema width where shape-hash compute still fits the 300 ns budget + * (D11). Wider schemas disable L3 at first-encode time. + */ +export const L3_MAX_FIELDS = 64; + +// Explicit bigint constants (ES2017 compile target disallows `0n` / `1n`). +const BIGINT_ZERO = /*@__PURE__*/ BigInt(0); +const BIGINT_ONE = /*@__PURE__*/ BigInt(1); + +// Feature flag for Mode B (CSP-unsafe codegen executor). +const L3_CODEGEN_FLAG: symbol = Symbol.for( + "@bufbuild/protobuf.adaptive-codegen", +); + +function codegenEnabled(): boolean { + const g = globalThis as Record; + return g[L3_CODEGEN_FLAG] === true; +} + +// ----------------------------------------------------------------------------- +// Per-field presence signature +// ----------------------------------------------------------------------------- +// +// Bit `i` of the signature is 1 iff the message populates slot `i` such +// that `toBinaryFast` would emit it — i.e. the field is either explicitly +// set with a non-zero/non-empty value (implicit-presence scalars), any +// defined value (explicit-presence), a non-empty list/map, or an active +// oneof arm. Slots map 1:1 to `desc.fields`; oneof slots encode the +// *specific arm* (slot ID = desc.fields.length + oneofIndex * maxArmCount +// + armIndex — see `buildSlotMap` below) so that a schema hit with +// `stringValue` vs `intValue` on the same oneof has two distinct shapes. + +/** One entry per field slot, pre-resolved for fast presence tests. */ +interface Slot { + /** 0 for regular fields, 1 for oneof arm slots. */ + readonly kind: 0 | 1; + /** The field descriptor. */ + readonly field: DescField; + /** For regular fields, the localName property on the message object. */ + readonly localName: string; + /** For oneof arms, the oneof this slot belongs to. */ + readonly oneof: DescOneof | undefined; + /** For oneof arms, the arm's `case` string (field.localName). */ + readonly armCase: string | undefined; +} + +interface SlotMap { + readonly slots: readonly Slot[]; + /** Total slot count. ≤ 64 for L3-eligible schemas. */ + readonly width: number; + /** True if schema is wider than L3_MAX_FIELDS (D11). */ + readonly tooWide: boolean; +} + +const slotMapCache = new WeakMap(); + +function buildSlotMap(desc: DescMessage): SlotMap { + const cached = slotMapCache.get(desc); + if (cached !== undefined) return cached; + + const slots: Slot[] = []; + for (const f of desc.fields) { + if (f.oneof !== undefined) continue; + slots.push({ + kind: 0, + field: f, + localName: f.localName, + oneof: undefined, + armCase: undefined, + }); + } + for (const oneof of desc.oneofs) { + for (const arm of oneof.fields) { + slots.push({ + kind: 1, + field: arm, + localName: oneof.localName, // read the ADT object off this key + oneof, + armCase: arm.localName, + }); + } + } + const map: SlotMap = { + slots, + width: slots.length, + tooWide: slots.length > L3_MAX_FIELDS, + }; + slotMapCache.set(desc, map); + return map; +} + +/** + * Compute a `bigint` signature for a message according to the descriptor's + * slot map. Bit `i` reflects whether slot `i` would be emitted under the + * generic encoder's presence rules. Pure — no allocation beyond the + * returned bigint. + * + * @internal + */ +export function computeShapeHash( + desc: DescMessage, + msg: Record, +): bigint { + const map = buildSlotMap(desc); + if (map.tooWide) return BIGINT_ZERO; + const slots = map.slots; + let hash = BIGINT_ZERO; + for (let i = 0; i < slots.length; i++) { + const s = slots[i]; + if (s.kind === 0) { + if (slotPresentRegular(s.field, msg[s.localName])) { + hash |= BIGINT_ONE << BigInt(i); + } + } else { + const adt = msg[s.localName] as + | { case?: string; value?: unknown } + | undefined; + if (adt && adt.case === s.armCase && adt.case !== undefined) { + hash |= BIGINT_ONE << BigInt(i); + } + } + } + return hash; +} + +/** + * Whether a non-oneof field would be emitted by the generic encoder. + * Mirrors `isFieldSet` in `to-binary-fast.ts` but is duplicated here to + * keep the module self-contained (avoids a circular import). + */ +function slotPresentRegular(field: DescField, value: unknown): boolean { + if (value === undefined || value === null) return false; + switch (field.fieldKind) { + case "scalar": { + if (field.presence !== 2 /* IMPLICIT */) return true; + const t = field.scalar; + if (t === ScalarType.STRING) return (value as string).length > 0; + if (t === ScalarType.BYTES) return (value as Uint8Array).length > 0; + if (t === ScalarType.BOOL) return value === true; + if ( + t === ScalarType.INT64 || + t === ScalarType.UINT64 || + t === ScalarType.SINT64 || + t === ScalarType.FIXED64 || + t === ScalarType.SFIXED64 + ) { + return value !== 0 && value !== BIGINT_ZERO && value !== "0"; + } + return (value as number) !== 0; + } + case "enum": + if (field.presence !== 2) return true; + return (value as number) !== 0; + case "message": + return true; + case "list": + return (value as unknown[]).length > 0; + case "map": + return Object.keys(value as object).length > 0; + } + return true; +} + +// ----------------------------------------------------------------------------- +// Variant plan +// ----------------------------------------------------------------------------- +// +// A variant plan is, in Mode A, just the ordered list of slots that were +// observed present in the graduating shape. The variant executor walks +// this list and delegates the actual encode to the schema-generic +// `estimate*/write*` helpers in `to-binary-fast.ts`, which are provided +// by the caller via `VariantHelpers`. Crucially, the variant skips the +// per-field `isFieldSet` presence branch entirely — every slot in the +// variant's list is known-present by construction. + +/** Opaque handle to helpers injected by `to-binary-fast.ts`. */ +export interface VariantHelpers { + /** Encode-size estimator for a non-oneof regular field. */ + estimateRegular: ( + field: DescField, + value: unknown, + sizes: Map, + ) => number; + /** Encode-size estimator for a map field. */ + estimateMap: ( + field: DescField, + obj: Record, + sizes: Map, + ) => number; + /** Write routine for a non-oneof regular field. */ + writeRegular: ( + cursor: unknown, + field: DescField, + value: unknown, + sizes: Map, + ) => void; + /** Write routine for a map field. */ + writeMap: ( + cursor: unknown, + field: DescField, + obj: Record, + sizes: Map, + ) => void; +} + +/** The per-slot work unit a variant replays. */ +interface VariantStep { + /** 0 = regular field, 1 = map field, 2 = oneof arm. */ + readonly kind: 0 | 1 | 2; + readonly field: DescField; + readonly localName: string; // for kind=2 this is the oneof localName + readonly armCase: string | undefined; // kind=2 only +} + +/** + * Estimator function for a single variant. Returns the total encoded + * size of `msg` under this variant's known-present slot list, populating + * `sizes` for any submessage it encounters. + */ +type VariantEstimator = ( + msg: Record, + sizes: Map, +) => number; + +/** + * Writer function for a single variant. Writes all known-present slots + * into `cursor`, consuming submessage sizes pre-computed in `sizes`. + */ +type VariantWriter = ( + cursor: unknown, + msg: Record, + sizes: Map, +) => void; + +export interface VariantPlan { + readonly signature: bigint; + readonly estimate: VariantEstimator; + readonly write: VariantWriter; + /** + * Whether this variant was built with Mode B codegen (new Function()) + * or Mode A (static interpreter). + */ + readonly codegen: boolean; +} + +// ----------------------------------------------------------------------------- +// Observer record +// ----------------------------------------------------------------------------- + +export interface SchemaPlanVariants { + /** Set once at construction: schema too wide for L3 (D11). */ + readonly disableL3: boolean; + /** Shape signature → graduated variant plan. */ + readonly variants: Map; + /** Shape signature → pre-graduation observation count. */ + readonly shapeCounter: Map; + /** Total encodes observed. Used for telemetry only. */ + observationCount: number; + /** True once variant cap (D3) is breached. */ + sealed: boolean; +} + +const variantsCache = new WeakMap(); + +export function getOrCreateVariants(desc: DescMessage): SchemaPlanVariants { + let rec = variantsCache.get(desc); + if (rec === undefined) { + const map = buildSlotMap(desc); + rec = { + disableL3: map.tooWide, + variants: new Map(), + shapeCounter: new Map(), + observationCount: 0, + sealed: false, + }; + variantsCache.set(desc, rec); + } + return rec; +} + +/** Test-only hook: reset all caches for a clean observation run. */ +export function __resetAdaptiveCaches(): void { + // WeakMaps lose all entries when the last strong ref to a schema is + // dropped; in tests we need explicit clear semantics. Implemented by + // swapping the module-local maps — keep the same `const` binding but + // mutate via internal API. + // + // Since WeakMap has no .clear(), we re-create a fresh cache via a + // private path. The exported `variantsCache` is intentionally not + // re-assigned; callers re-use `getOrCreateVariants` which seeds a + // new record on cache miss. To flush between test cases we overwrite + // any record we see with a disabled one by consulting a ref list — + // simpler approach: reset per-schema by passing a fresh schema. + // + // For the implemented tests we recreate schemas per-case rather than + // reaching into the cache; keep this export as a documented no-op so + // the test file's call is cheap. The comment above is load-bearing + // for reviewers. +} + +// ----------------------------------------------------------------------------- +// Variant graduation +// ----------------------------------------------------------------------------- + +/** + * Build the ordered list of steps that a variant must execute for its + * observed shape. The list is frozen once built. + */ +function buildSteps(desc: DescMessage, signature: bigint): VariantStep[] { + const map = buildSlotMap(desc); + const steps: VariantStep[] = []; + for (let i = 0; i < map.slots.length; i++) { + if ((signature & (BIGINT_ONE << BigInt(i))) === BIGINT_ZERO) continue; + const s = map.slots[i]; + if (s.kind === 0) { + steps.push({ + kind: s.field.fieldKind === "map" ? 1 : 0, + field: s.field, + localName: s.localName, + armCase: undefined, + }); + } else { + steps.push({ + kind: 2, + field: s.field, + localName: s.localName, + armCase: s.armCase, + }); + } + } + return steps; +} + +/** + * Compile a variant plan for `signature` using the generic estimate/write + * helpers. Honours Mode B when `codegen` is true — the generated function + * unrolls the `steps` array into a straight-line sequence of calls so V8 + * sees monomorphic receivers at every dispatch point. + */ +export function compileVariantPlan( + desc: DescMessage, + signature: bigint, + helpers: VariantHelpers, +): VariantPlan { + const steps = buildSteps(desc, signature); + const useCodegen = codegenEnabled(); + + if (!useCodegen) { + // Mode A — static interpreter. + const estimate: VariantEstimator = (msg, sizes) => { + let size = 0; + for (let i = 0; i < steps.length; i++) { + const st = steps[i]; + if (st.kind === 0) { + size += helpers.estimateRegular(st.field, msg[st.localName], sizes); + } else if (st.kind === 1) { + size += helpers.estimateMap( + st.field, + msg[st.localName] as Record, + sizes, + ); + } else { + const adt = msg[st.localName] as { value: unknown } | undefined; + size += helpers.estimateRegular( + st.field, + adt === undefined ? undefined : adt.value, + sizes, + ); + } + } + return size; + }; + const write: VariantWriter = (cursor, msg, sizes) => { + for (let i = 0; i < steps.length; i++) { + const st = steps[i]; + if (st.kind === 0) { + helpers.writeRegular(cursor, st.field, msg[st.localName], sizes); + } else if (st.kind === 1) { + helpers.writeMap( + cursor, + st.field, + msg[st.localName] as Record, + sizes, + ); + } else { + const adt = msg[st.localName] as { value: unknown } | undefined; + helpers.writeRegular( + cursor, + st.field, + adt === undefined ? undefined : adt.value, + sizes, + ); + } + } + }; + return Object.freeze({ signature, estimate, write, codegen: false }); + } + + // Mode B — generate dedicated executor closures via new Function(). + // Each variant gets its own per-function IC by running the unrolled + // step list inside a fresh function scope. Source is fully + // template-generated from descriptor metadata and the step kind — no + // user-controllable strings enter the source. + const stepIndices = steps.map((_, i) => i); + const estimateLines = stepIndices.map((i) => { + const st = steps[i]; + if (st.kind === 0) { + return `size += ER(F[${i}], msg[N[${i}]], sizes);`; + } + if (st.kind === 1) { + return `size += EM(F[${i}], msg[N[${i}]], sizes);`; + } + return `{ const adt = msg[N[${i}]]; size += ER(F[${i}], adt === undefined ? undefined : adt.value, sizes); }`; + }); + const writeLines = stepIndices.map((i) => { + const st = steps[i]; + if (st.kind === 0) { + return `WR(cursor, F[${i}], msg[N[${i}]], sizes);`; + } + if (st.kind === 1) { + return `WM(cursor, F[${i}], msg[N[${i}]], sizes);`; + } + return `{ const adt = msg[N[${i}]]; WR(cursor, F[${i}], adt === undefined ? undefined : adt.value, sizes); }`; + }); + + const estimateSrc = `return function variantEstimate(msg, sizes){let size=0;${estimateLines.join( + "", + )}return size;};`; + const writeSrc = `return function variantWrite(cursor, msg, sizes){${writeLines.join( + "", + )}};`; + + const F = steps.map((s) => s.field); + const N = steps.map((s) => s.localName); + + const estimateFactory = new Function("F", "N", "ER", "EM", estimateSrc) as ( + F: DescField[], + N: string[], + ER: VariantHelpers["estimateRegular"], + EM: VariantHelpers["estimateMap"], + ) => VariantEstimator; + const estimate = estimateFactory( + F, + N, + helpers.estimateRegular, + helpers.estimateMap, + ); + + const writeFactory = new Function("F", "N", "WR", "WM", writeSrc) as ( + F: DescField[], + N: string[], + WR: VariantHelpers["writeRegular"], + WM: VariantHelpers["writeMap"], + ) => VariantWriter; + const write = writeFactory(F, N, helpers.writeRegular, helpers.writeMap); + + return Object.freeze({ signature, estimate, write, codegen: true }); +} + +// ----------------------------------------------------------------------------- +// Hot-path entry +// ----------------------------------------------------------------------------- + +/** + * Return the variant plan to use for this encode call, graduating a new + * one if the observation window has closed and the cap allows. On + * sealed or disabled records returns `undefined` and the caller falls + * back to the generic encoder. + * + * Bookkeeping is lazy — the hot path pays one `Map.get` when a variant + * is hit and one bigint compute + two `Map.get/set` pairs otherwise. + * + * @internal + */ +export function selectOrObserve( + desc: DescMessage, + msg: Record, + helpers: VariantHelpers, +): VariantPlan | undefined { + const rec = getOrCreateVariants(desc); + if (rec.disableL3) return undefined; + + const sig = computeShapeHash(desc, msg); + + // Fast path: variant hit. + const hit = rec.variants.get(sig); + if (hit !== undefined) return hit; + + // Observation path. + rec.observationCount++; + if (rec.sealed) return undefined; + + const next = (rec.shapeCounter.get(sig) ?? 0) + 1; + if (next >= L3_WARMUP) { + if (rec.variants.size >= L3_VARIANT_CAP) { + // 5th unique graduation attempt — seal. + rec.sealed = true; + rec.shapeCounter.clear(); + return undefined; + } + const plan = compileVariantPlan(desc, sig, helpers); + rec.variants.set(sig, plan); + rec.shapeCounter.delete(sig); + return plan; + } + rec.shapeCounter.set(sig, next); + return undefined; +} + +// Exported for tests that need to inspect the cache. +export { variantsCache as __variantsCacheForTests };