diff --git a/benchmarks/.gitignore b/benchmarks/.gitignore new file mode 100644 index 000000000..d3f042269 --- /dev/null +++ b/benchmarks/.gitignore @@ -0,0 +1,5 @@ +src/gen +src/gen-protobufjs +node_modules +dist +bench-results.json diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 000000000..68f8023c7 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,214 @@ +# protobuf-es Benchmark Suite + +## Context + +This directory contains microbenchmarks for the `@bufbuild/protobuf` serialization workloads. It addresses the measurement gap discussed in [#333](https://github.com/bufbuild/protobuf-es/issues/333) and [#1035](https://github.com/bufbuild/protobuf-es/issues/1035), where performance arguments have historically relied on ad-hoc user-provided numbers without a reproducible suite living alongside the library. + +The OTLP-like fixture in `proto/nested.proto` is modelled on the real-world workload that produced [open-telemetry/opentelemetry-js#6221](https://github.com/open-telemetry/opentelemetry-js/issues/6221) (a ~13x serialization regression when protobuf-es was briefly adopted via the `fromJsonString + toBinary` path). Exercising the same message shape under controlled conditions makes that regression class observable against future protobuf-es versions. + +## Running + +```bash +# From the monorepo root (first time only) +npm ci +npx turbo run build --filter=@bufbuild/protobuf +npx turbo run generate --filter=@bufbuild/protobuf-benchmarks + +# Run the full suite +npm run bench -w @bufbuild/protobuf-benchmarks + +# Or run individual suites +npm run bench:create -w @bufbuild/protobuf-benchmarks +npm run bench:toBinary -w @bufbuild/protobuf-benchmarks +npm run bench:create-toBinary -w @bufbuild/protobuf-benchmarks +npm run bench:fromBinary -w @bufbuild/protobuf-benchmarks +npm run bench:fromJson-path -w @bufbuild/protobuf-benchmarks +npm run bench:comparison -w @bufbuild/protobuf-benchmarks +npm run bench:matrix -w @bufbuild/protobuf-benchmarks +npm run bench:memory -w @bufbuild/protobuf-benchmarks +``` + +## Benchmarks + +| File | What it measures | +|------|------------------| +| `bench-create.ts` | Cost of `create(Schema, init)` in isolation — small flat message vs. full OTLP-like tree constructed via many nested `create()` calls | +| `bench-toBinary.ts` | Cost of `toBinary(Schema, message)` on pre-built messages — serialization-only, no allocation of the message graph | +| `bench-create-toBinary.ts` | Combined workload: build the message graph fresh each iteration, then serialize. This is the end-to-end shape of one OTLP export call | +| `bench-fromBinary.ts` | Cost of `fromBinary(Schema, bytes)` on pre-encoded payloads — reflective decoder walk in isolation | +| `bench-fromJson-path.ts` | `fromJsonString + toBinary` and `fromJson + toBinary` paths on the same fixture. The first one is the #6221 regression shape; the second is the partial-fix midpoint | +| `bench-comparison-protobufjs.ts` | Cross-library comparison: protobuf-es vs `protobufjs` (pbjs static codegen) on the same `.proto` fixture. Covers full roundtrip, encode-only, decode-only | +| `bench-matrix.ts` | `toBinary` + `fromBinary` across the full realistic-fixture matrix (OTel traces/metrics/logs, K8s Pod list, GraphQL request/response, RPC envelope, stress). Emits a JSON summary on stdout for CI diffing | +| `bench-memory.ts` | Heap allocations per operation (`heapUsed` delta after forced GC) for both libraries. Requires `--expose-gc` | + +## Methodology + +- Uses [tinybench](https://github.com/tinylibs/tinybench) for sampling, CI, and stats. +- 500 ms warmup, 2000 ms measurement per case. +- Node version taken from `.nvmrc` at repo root. +- Results are sensitive to host load. For tighter numbers pin to a single core: + ```bash + taskset -c 0 npm run bench -w @bufbuild/protobuf-benchmarks + ``` + +## Fixtures + +The suite runs across a matrix of payload shapes so a regression can be +attributed to a class of workload rather than lumped into a single "encoder +is slower" result. All fixtures live under `proto/` and are built by +helpers in `src/fixtures.ts`. + +| Fixture | `.proto` | Shape | Typical encoded size | Notes | +|---------|----------|-------|---------------------:|-------| +| `SimpleMessage` | `small.proto` | 3 scalar fields | ~19 B | per-call overhead baseline | +| `ExportTraceRequest` | `nested.proto` | OTel traces: 100 spans × 10 attrs, fixed64 timestamps, bytes IDs | ~35 KB | repro of [open-telemetry/opentelemetry-js#6221](https://github.com/open-telemetry/opentelemetry-js/issues/6221) | +| `ExportMetricsRequest` | `otel-metrics.proto` | OTel metrics: 50 series with Gauge/Sum/Histogram mix, explicit bucket bounds | ~17 KB | exercises the `oneof data` dispatch + repeated doubles/uint64s | +| `ExportLogsRequest` | `otel-logs.proto` | OTel logs: 100 LogRecords, severity, string body, trace/span IDs | ~21 KB | string-heavy with attribute maps | +| `K8sPodList` | `k8s-pod.proto` | 20 Pods with labels/annotations, 2 containers each, ports + env + resource limits | ~29 KB | map-dominant config payload | +| `GraphQLRequest` | `graphql.proto` | Long query string + JSON-encoded variables map | ~0.6 KB | mixes a large string with `map` | +| `GraphQLResponse` | `graphql.proto` | JSON-encoded `data` + structured errors | ~1.4 KB | bytes + repeated messages with string paths | +| `RpcRequest` | `rpc-simple.proto` | Routing fields + header map + 256-byte payload | ~0.5 KB | baseline RPC envelope | +| `RpcResponse` | `rpc-simple.proto` | Status + header map + 512-byte payload | ~0.6 KB | baseline RPC response | +| `StressMessage` | `stress.proto` | Depth-8 self-nested + 200-wide int32/string/attr arrays + 4KB blob + every scalar type | ~13 KB | synthetic — surfaces per-scalar-type regressions | + +### Design notes + +- **Map-heavy vs. list-heavy.** Kubernetes payloads stress `map` + encode paths; OTel payloads stress repeated nested messages. Both show up in + production consumers and the encoder walks differ. +- **Deep nesting.** The stress fixture recurses through `StressMessage.child` + eight levels deep. The encoder pays a length-prefix per level (fork buffer + + measure + prefix), so depth is a distinct failure mode from total size. +- **All scalar types exactly once.** `StressMessage` declares each proto3 + scalar in a fixed slot so a regression specific to `sfixed64` or `sint32` + varint zig-zag is visible in this fixture but not in realistic ones. +- **GraphQL/RPC payloads use `bytes` for opaque data** rather than structured + sub-messages because real clients carry JSON-encoded variables and + opaque RPC payloads as bytes on the wire; the benchmark reflects that. + +### Future work + +- `bench-matrix` currently measures `toBinary` + `fromBinary` on pre-built + messages. A follow-up pass should add `create + toBinary` (full roundtrip) + and `fromJsonString + toBinary` paths across the matrix to catch + regressions in the JSON-input code paths that the existing + `bench-fromJson-path` only exercises on the OTLP traces fixture. +- GraphQL variables are currently modelled as `map` with JSON + blobs per value. A richer fixture using a `google.protobuf.Struct`-like + shape would exercise the same code paths the `@bufbuild/protobuf/wkt` + `Value` type uses in real services. + +## Report snapshot + +Generated by `npm run bench:report -w @bufbuild/protobuf-benchmarks`. The +script writes `bench-results.json`, `chart.svg`, and the table below. See +`src/report.ts` for the generator and `src/report-helpers.ts` for the +rendering helpers. + +![chart](./chart.svg) + + + +| Fixture | Bytes | toBinary | toBinaryFast | protobufjs | Best | +| ---------------------------------- | -----: | -------: | -----------: | ---------: | -------------------- | +| SimpleMessage | 19 | 1.06M | 1.23M | - | toBinaryFast (1.16x) | +| ExportTraceRequest (100 spans) | 32,926 | 1,778 | 3,218 | 2,676 | toBinaryFast (1.20x) | +| ExportMetricsRequest (50 series) | 17,696 | 3,316 | 6,429 | - | toBinaryFast (1.94x) | +| ExportLogsRequest (100 records) | 21,319 | 3,257 | 6,032 | - | toBinaryFast (1.85x) | +| K8sPodList (20 pods) | 28,900 | 3,458 | 5,444 | - | toBinaryFast (1.57x) | +| GraphQLRequest | 624 | 256,431 | 312,765 | - | toBinaryFast (1.22x) | +| GraphQLResponse | 1,366 | 293,549 | 402,902 | - | toBinaryFast (1.37x) | +| RpcRequest | 501 | 379,472 | 455,529 | - | toBinaryFast (1.20x) | +| RpcResponse | 602 | 579,153 | 766,432 | - | toBinaryFast (1.32x) | +| StressMessage (depth=8, width=200) | 12,868 | 10,848 | 18,034 | - | toBinaryFast (1.66x) | + + + +## Current results + +First local run on Node v25.8.1, linux/x64 (non-isolated host, margins of error are realistic for an unpinned benchmark machine — numbers are directionally stable but will shift on quieter hardware). All ops/sec are medians from `tinybench.table()`. + +### create() cost + +| Case | ops/sec (median) | ± | +|------|------------------|---| +| `create() SimpleMessage (3 scalar fields)` | 2,123,142 | 16% | +| `create() ExportTraceRequest nested (100 spans, 10 attrs each)` | 3,674 | 3.4% | + +### toBinary() cost on pre-built messages + +| Case | ops/sec (median) | ± | +|------|------------------|---| +| `toBinary() SimpleMessage (pre-built)` | 690,608 | 18% | +| `toBinary() ExportTraceRequest (pre-built, 100 spans)` | 267 | 26% | + +### create() + toBinary() combined workload + +| Case | ops/sec (median) | ± | +|------|------------------|---| +| `create() + toBinary() SimpleMessage` | 402,091 | 42% | +| `create() + toBinary() ExportTraceRequest (100 spans, OTel-like)` | 285 | 19% | + +### fromJson / fromJsonString + toBinary paths + +| Case | ops/sec (median) | ± | +|------|------------------|---| +| `fromJsonString + toBinary (100 spans) — OTel #6221 shape` | 235 | 15% | +| `fromJson + toBinary (100 spans) — plainObject path` | 275 | 12% | + +### fromBinary() parsing cost + +| Case | ops/sec (median) | ± | +|------|------------------|---| +| `fromBinary() SimpleMessage (19 B)` | 1,663,894 | 0.02% | +| `fromBinary() ExportTraceRequest (100 spans, 35,283 B)` | 1,501 | 0.40% | + +Parsing (decode) is materially faster than encoding on the same nested workload: ~1,500 ops/s decode vs ~600 ops/s encode. The encode walk pays varint length prefixing (writing length-delimited sub-messages requires allocating a fork buffer, encoding into it, then measuring its length to write the length prefix) that does not have a symmetric cost in the decode path. + +### protobuf-es vs protobufjs (same `.proto`, same host) + +Bench run on the OTLP-like 100-span fixture; protobufjs generated via `pbjs -t static-module -w commonjs --force-long`. Numbers below are medians from standalone runs of `bench:comparison` (host not isolated; margins of error on protobuf-es cases are wider than on protobufjs because each protobuf-es iteration takes longer, giving fewer samples in the 2000 ms measurement window). + +| Workload | protobuf-es ops/s | protobufjs ops/s | Ratio | +|----------|---------------------:|--------------------:|--------:| +| create + encode (100 spans) | 666 | 3,788 | **5.7x slower** | +| encode pre-built (100 spans) | 622 | 4,041 | **6.5x slower** | +| decode 100 spans | 1,501 | 6,868 | **4.6x slower** | + +Run-to-run variance on an unpinned host moves these ratios by roughly ±20%. Observed ranges across multiple runs: 5.3x–6.3x on create+encode, 4.4x–6.5x on decode. For tighter numbers pin to a single core. + +### Memory (heap bytes per operation) + +Coarse measurement via `heapUsed` delta across 1,000 iterations after forced GC. Requires `--expose-gc`. + +| Case | Bytes/op (avg) | Ratio vs protobufjs | +|------|----------------:|---------------------:| +| protobuf-es: create + toBinary (100 spans) | 23,524 | 3.2x more | +| protobufjs: create + encode (100 spans) | 7,449 | baseline | +| protobuf-es: fromBinary (100 spans) | 31,569 | 0.94x (less) | +| protobufjs: decode (100 spans) | 33,594 | baseline | + +Observations: +- Encode side: protobuf-es allocates ~3x more heap per operation than protobufjs. Consistent with the reflective encoder path constructing intermediate length-prefix buffers via `BinaryWriter.fork()` + array joins per sub-message. +- Decode side: allocations are within jitter — both libraries materialize the message tree, and the decoded object graph dominates the delta. + +### Reading these numbers + +- On the 100-span nested workload, `toBinary` dominates: pre-built `toBinary` (267 ops/s) and combined `create() + toBinary()` (285 ops/s) are within jitter of each other, i.e. constructing the message graph is cheap compared to the reflective binary encode walk. +- The `fromJsonString + toBinary` path is roughly 20% slower than direct `create + toBinary` on this fixture (235 vs 285 ops/s median). The OTel incident report observed ~13x — most of that gap is the extra transformer-level traversals building the JSON-shaped object tree upstream of `fromJsonString`, which this benchmark does not exercise. Here we isolate just the `fromJsonString + toBinary` step, so the observed ratio is the lower bound on the regression's protobuf-es-side contribution. +- The `SimpleMessage` numbers illustrate per-call overhead on a trivial shape. Relevant when many small messages are serialized in tight loops (e.g. gRPC unary call payloads). +- The comparison vs protobufjs is consistent with the OTel report's directional claim (protobuf-es is slower on this shape), but the observed ratio here is ~5–7x, not the 13x–30x sometimes cited from external measurements. The difference is attributable to (a) pbjs static-module codegen producing ahead-of-time encoders, which isolates only the encoder/decoder walk; real-world numbers include app-level traversal, JSON conversion, BigInt handling, and allocator pressure which this suite deliberately does not measure; (b) different Node versions and host conditions. This suite reports what protobuf-es actually spends on encode/decode under controlled conditions — use those numbers for tracking, not for headline claims. + +## Methodology notes + +- The `bench-fromJson-path` cases deliberately reproduce a known-pathological pattern. Do not read the numbers there as "protobuf-es is slow" — they show the cost of an unnecessary extra traversal. See `bench-create-toBinary` for the idiomatic path. +- `create()` is called per sub-message in the nested benchmark (every `KeyValue`, `Span`, `ScopeSpans`, etc.) because protobuf-es's reflective `toBinary` relies on the `$typeName`-tagged prototype established by `create` — this matches the real-world cost of constructing an OTLP-like tree. +- The comparison benchmark uses pbjs static-module codegen (ahead-of-time encoder/decoder), which is the protobufjs mode most commonly adopted in production. pbjs reflection-mode numbers would be slower and not representative of what protobufjs users actually deploy. +- The memory benchmark uses a `heapUsed` delta across 1,000 iterations with `gc()` sandwiching the measurement. This is coarse — it does not separate young-gen allocations cleared between minor GCs from steady-state retained memory — but it is internally consistent across the libraries compared here. For finer attribution use `node --heap-prof` and inspect the resulting `.heapprofile` in Chrome DevTools. + +## Future work + +- CI integration — run on PR and publish trends; flag regressions above a configurable threshold. +- `ts-proto` comparison on the same fixtures (separate package, opt-in dependency). Would round out the "ahead-of-time codegen" comparison group alongside protobufjs. +- Streaming write benchmarks (`sizeDelimitedEncode`) for gRPC-style workloads. +- Allocation tracking via `node --heap-prof` for per-call-site attribution (replacing the coarse `heapUsed` delta in `bench-memory`). diff --git a/benchmarks/biome.json b/benchmarks/biome.json new file mode 100644 index 000000000..21d607114 --- /dev/null +++ b/benchmarks/biome.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "extends": ["../biome.base.json"], + "files": { + "ignore": ["dist", "src/gen", "src/gen-protobufjs"] + } +} diff --git a/benchmarks/buf.gen.yaml b/benchmarks/buf.gen.yaml new file mode 100644 index 000000000..95af5a26f --- /dev/null +++ b/benchmarks/buf.gen.yaml @@ -0,0 +1,10 @@ +# Learn more: https://buf.build/docs/configuration/v2/buf-gen-yaml +version: v2 +inputs: + - directory: proto +clean: true +plugins: + - local: protoc-gen-es + opt: target=ts + out: src/gen + include_imports: true diff --git a/benchmarks/chart.svg b/benchmarks/chart.svg new file mode 100644 index 000000000..383423f26 --- /dev/null +++ b/benchmarks/chart.svg @@ -0,0 +1,132 @@ + + + + Encoder throughput by fixture (ops/sec, log scale) + + ops/sec (log10) + + + + 10 + + 100 + + 1K + + 10K + + 100K + + 1M + + 10M + + + toBinary: 1,064,677 ops/sec (SimpleMessage) + + + toBinaryFast: 1,230,371 ops/sec (SimpleMessage) + + + SimpleMessage + + + toBinary: 1,778 ops/sec (ExportTraceRequest (100 spans)) + + + toBinaryFast: 3,218 ops/sec (ExportTraceRequest (100 spans)) + + + protobufjs: 2,676 ops/sec (ExportTraceRequest (100 spans)) + + + ExportTraceRequest (100 spans) + + + toBinary: 3,316 ops/sec (ExportMetricsRequest (50 series)) + + + toBinaryFast: 6,429 ops/sec (ExportMetricsRequest (50 series)) + + + ExportMetricsRequest (50 series) + + + toBinary: 3,257 ops/sec (ExportLogsRequest (100 records)) + + + toBinaryFast: 6,032 ops/sec (ExportLogsRequest (100 records)) + + + ExportLogsRequest (100 records) + + + toBinary: 3,458 ops/sec (K8sPodList (20 pods)) + + + toBinaryFast: 5,444 ops/sec (K8sPodList (20 pods)) + + + K8sPodList (20 pods) + + + toBinary: 256,431 ops/sec (GraphQLRequest) + + + toBinaryFast: 312,765 ops/sec (GraphQLRequest) + + + GraphQLRequest + + + toBinary: 293,549 ops/sec (GraphQLResponse) + + + toBinaryFast: 402,902 ops/sec (GraphQLResponse) + + + GraphQLResponse + + + toBinary: 379,472 ops/sec (RpcRequest) + + + toBinaryFast: 455,529 ops/sec (RpcRequest) + + + RpcRequest + + + toBinary: 579,153 ops/sec (RpcResponse) + + + toBinaryFast: 766,432 ops/sec (RpcResponse) + + + RpcResponse + + + toBinary: 10,848 ops/sec (StressMessage (depth=8, width=200)) + + + toBinaryFast: 18,034 ops/sec (StressMessage (depth=8, width=200)) + + + StressMessage (depth=8, width=200) + + + + toBinary + + toBinaryFast + + protobufjs + + diff --git a/benchmarks/package.json b/benchmarks/package.json new file mode 100644 index 000000000..55efeb1f6 --- /dev/null +++ b/benchmarks/package.json @@ -0,0 +1,35 @@ +{ + "name": "@bufbuild/protobuf-benchmarks", + "version": "2.11.0", + "private": true, + "scripts": { + "bench": "tsx src/index.ts", + "bench:create": "tsx src/bench-create.ts", + "bench:toBinary": "tsx src/bench-toBinary.ts", + "bench:create-toBinary": "tsx src/bench-create-toBinary.ts", + "bench:fromJson-path": "tsx src/bench-fromJson-path.ts", + "bench:fromBinary": "tsx src/bench-fromBinary.ts", + "bench:comparison": "tsx src/bench-comparison-protobufjs.ts", + "bench:matrix": "tsx src/bench-matrix.ts", + "bench:memory": "node --expose-gc --import tsx src/bench-memory.ts", + "bench:report": "tsx src/report.ts", + "build": "../node_modules/typescript/bin/tsc --noEmit", + "generate": "buf generate && npm run generate:protobufjs", + "generate:protobufjs": "mkdir -p src/gen-protobufjs && pbjs -t static-module -w commonjs --force-long -o src/gen-protobufjs/nested.cjs proto/nested.proto", + "format": "biome format --write", + "license-header": "license-header", + "lint": "biome lint --error-on-warnings" + }, + "license": "Apache-2.0", + "type": "module", + "dependencies": { + "@bufbuild/buf": "^1.66.1", + "@bufbuild/protobuf": "2.11.0", + "@bufbuild/protoc-gen-es": "2.11.0", + "protobufjs": "^7.5.5", + "protobufjs-cli": "^1.1.3", + "tinybench": "^4.0.1", + "tsx": "^4.21.0", + "typescript": "^5.6.3" + } +} diff --git a/benchmarks/proto/buf.yaml b/benchmarks/proto/buf.yaml new file mode 100644 index 000000000..b8699818a --- /dev/null +++ b/benchmarks/proto/buf.yaml @@ -0,0 +1,3 @@ +version: v2 +modules: + - path: . diff --git a/benchmarks/proto/graphql.proto b/benchmarks/proto/graphql.proto new file mode 100644 index 000000000..659daed93 --- /dev/null +++ b/benchmarks/proto/graphql.proto @@ -0,0 +1,57 @@ +// 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. + +syntax = "proto3"; +package bench.v1; + +// GraphQL request/response envelope wrapped in protobuf. This is the shape +// of a GraphQL-over-gRPC gateway: `query` + `operation_name` + variables +// (stored as JSON-encoded bytes so the schema can carry arbitrary value +// types without leaking the GraphQL scalar system into protobuf). +// +// Benchmark value: large string payloads (query text), map +// (variables), and a nested error array with paths (repeated string). + +message GraphQLSourceLocation { + uint32 line = 1; + uint32 column = 2; +} + +message GraphQLError { + string message = 1; + repeated GraphQLSourceLocation locations = 2; + // GraphQL errors carry a JSON `path` that alternates string keys and + // int indices. We flatten to repeated string here (numeric indices are + // stringified) because protobuf doesn't have a heterogeneous list type + // and this matches what a typed client would serialize over the wire. + repeated string path = 3; + map extensions = 4; +} + +message GraphQLRequest { + string query = 1; + string operation_name = 2; + // Variables are JSON-encoded per key so that numbers, strings, nulls, + // and nested objects all fit without inventing a sum type. This + // matches the pattern used by apollo-rs and graphql-go gateways. + map variables = 3; + map extensions = 4; +} + +message GraphQLResponse { + // `data` is the top-level GraphQL `data` field, JSON-encoded. + bytes data = 1; + repeated GraphQLError errors = 2; + map extensions = 3; +} diff --git a/benchmarks/proto/k8s-pod.proto b/benchmarks/proto/k8s-pod.proto new file mode 100644 index 000000000..ae53e0ec2 --- /dev/null +++ b/benchmarks/proto/k8s-pod.proto @@ -0,0 +1,103 @@ +// 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. + +syntax = "proto3"; +package bench.v1; + +// Subset of the Kubernetes core/v1 Pod object. Field numbers are NOT the +// upstream JSON-generated numbers (the canonical k8s API serializes as +// JSON, not protobuf); this is a plausible protobuf-shaped representation +// used to exercise map-heavy configuration payloads with the same shape +// kube-apiserver/kubelet would traffic over gRPC-equivalent channels. +// +// Why this shape matters for the benchmark: k8s payloads are map-dominant +// (labels, annotations, limits, requests), carry multiple small nested +// messages (containers, ports, env), and are a common integration target +// for protobuf-based RPC inside clusters. + +message K8sObjectMeta { + string name = 1; + string namespace = 2; + string uid = 5; + string resource_version = 6; + int64 generation = 17; + map labels = 11; + map annotations = 12; + fixed64 creation_timestamp_unix_nano = 8; +} + +message K8sEnvVar { + string name = 1; + string value = 2; +} + +message K8sPort { + string name = 1; + int32 container_port = 3; + string protocol = 4; +} + +message K8sResourceRequirements { + map limits = 1; + map requests = 2; +} + +message K8sContainer { + string name = 1; + string image = 2; + repeated string command = 3; + repeated string args = 4; + repeated K8sEnvVar env = 7; + repeated K8sPort ports = 6; + K8sResourceRequirements resources = 8; + string image_pull_policy = 14; +} + +message K8sPodSpec { + repeated K8sContainer containers = 2; + string restart_policy = 3; + string node_name = 10; + string service_account_name = 9; + int64 termination_grace_period_seconds = 4; +} + +message K8sContainerStatus { + string name = 1; + bool ready = 4; + int32 restart_count = 5; + string image = 6; + string image_id = 7; + string container_id = 8; + bool started = 10; +} + +message K8sPodStatus { + string phase = 1; + string pod_ip = 6; + string host_ip = 5; + fixed64 start_time_unix_nano = 7; + repeated K8sContainerStatus container_statuses = 8; +} + +message K8sPod { + K8sObjectMeta metadata = 1; + K8sPodSpec spec = 2; + K8sPodStatus status = 3; +} + +// Convenience list wrapper — exercises the repeated-of-complex-message +// path at the outer level. Matches how a PodList would be serialized. +message K8sPodList { + repeated K8sPod items = 2; +} diff --git a/benchmarks/proto/nested.proto b/benchmarks/proto/nested.proto new file mode 100644 index 000000000..066a65e8d --- /dev/null +++ b/benchmarks/proto/nested.proto @@ -0,0 +1,80 @@ +// 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. + +syntax = "proto3"; +package bench.v1; + +// nested.proto intentionally mirrors the shape of the OTLP trace export +// request (opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest), +// now extended to exercise the full AnyValue oneof and a map +// so that the fast-path encoder benchmark matches a realistic OTel payload. +// See open-telemetry/opentelemetry-js#6221 for the production incident that +// motivates measuring this shape. + +// AnyValue is the oneof-typed value carried by every KeyValue attribute in +// OTLP. We include the common leaf types; array/kvlist are intentionally +// omitted — they nest through KeyValue again and would dominate the +// benchmark on a distribution we don't observe in practice. +message AnyValue { + oneof value { + string string_value = 1; + bool bool_value = 2; + int64 int_value = 3; + double double_value = 4; + bytes bytes_value = 7; + } +} + +message KeyValue { + string key = 1; + AnyValue value = 2; +} + +message InstrumentationScope { + string name = 1; + string version = 2; + repeated KeyValue attributes = 3; +} + +message Span { + bytes trace_id = 1; + bytes span_id = 2; + string name = 5; + fixed64 start_time_unix_nano = 7; + fixed64 end_time_unix_nano = 8; + repeated KeyValue attributes = 9; +} + +message ScopeSpans { + InstrumentationScope scope = 1; + repeated Span spans = 2; +} + +message Resource { + repeated KeyValue attributes = 1; + // `labels` exercises map encoding on the fast path. + // Real OTLP payloads don't carry a labels map, but maps show up in + // many other ConnectRPC schemas and we want the benchmark to reflect + // that fast-path cost too. + map labels = 2; +} + +message ResourceSpans { + Resource resource = 1; + repeated ScopeSpans scope_spans = 2; +} + +message ExportTraceRequest { + repeated ResourceSpans resource_spans = 1; +} diff --git a/benchmarks/proto/otel-logs.proto b/benchmarks/proto/otel-logs.proto new file mode 100644 index 000000000..1a984c75b --- /dev/null +++ b/benchmarks/proto/otel-logs.proto @@ -0,0 +1,78 @@ +// 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. + +syntax = "proto3"; +package bench.v1; + +// Simplified subset of the OTLP logs export request. Modelled after +// opentelemetry.proto.collector.logs.v1.ExportLogsServiceRequest. Local +// AnyValue/KeyValue/Resource/Scope prefixed with `Log` to avoid name +// collision with nested.proto when compiled together. + +message LogAnyValue { + oneof value { + string string_value = 1; + bool bool_value = 2; + int64 int_value = 3; + double double_value = 4; + bytes bytes_value = 7; + } +} + +message LogKeyValue { + string key = 1; + LogAnyValue value = 2; +} + +message LogInstrumentationScope { + string name = 1; + string version = 2; + repeated LogKeyValue attributes = 3; +} + +message LogResource { + repeated LogKeyValue attributes = 1; +} + +// SeverityNumber replicates the OTLP severity enum as plain int32 — we +// avoid proto3 enums in the fixture because the fast path + protobufjs +// handling of unknown enum values complicates the comparison. +message LogRecord { + fixed64 time_unix_nano = 1; + fixed64 observed_time_unix_nano = 11; + int32 severity_number = 2; + string severity_text = 3; + LogAnyValue body = 5; + repeated LogKeyValue attributes = 6; + uint32 dropped_attributes_count = 7; + fixed32 flags = 8; + bytes trace_id = 9; + bytes span_id = 10; +} + +message ScopeLogs { + LogInstrumentationScope scope = 1; + repeated LogRecord log_records = 2; + string schema_url = 3; +} + +message ResourceLogs { + LogResource resource = 1; + repeated ScopeLogs scope_logs = 2; + string schema_url = 3; +} + +message ExportLogsRequest { + repeated ResourceLogs resource_logs = 1; +} diff --git a/benchmarks/proto/otel-metrics.proto b/benchmarks/proto/otel-metrics.proto new file mode 100644 index 000000000..2587397dd --- /dev/null +++ b/benchmarks/proto/otel-metrics.proto @@ -0,0 +1,116 @@ +// 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. + +syntax = "proto3"; +package bench.v1; + +// Simplified subset of the OTLP metrics export request. Intentionally shaped +// after opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceRequest +// to be representative of a real batched metric export call without pulling +// in the full dependency graph. Shares AnyValue / KeyValue / Resource / +// InstrumentationScope with nested.proto by re-declaring them locally — buf +// keeps each .proto self-contained here (the per-file `bench.v1` scope is +// preserved but message names are prefixed with `Metric` to avoid a +// collision with nested.proto when compiled together). + +message MetricAnyValue { + oneof value { + string string_value = 1; + bool bool_value = 2; + int64 int_value = 3; + double double_value = 4; + bytes bytes_value = 7; + } +} + +message MetricKeyValue { + string key = 1; + MetricAnyValue value = 2; +} + +message MetricInstrumentationScope { + string name = 1; + string version = 2; + repeated MetricKeyValue attributes = 3; +} + +message MetricResource { + repeated MetricKeyValue attributes = 1; +} + +// NumberDataPoint is the leaf type shared by Gauge and Sum. Matches the +// OTLP shape with a scalar oneof (`as_double` / `as_int`). +message NumberDataPoint { + repeated MetricKeyValue attributes = 7; + fixed64 start_time_unix_nano = 2; + fixed64 time_unix_nano = 3; + oneof value { + double as_double = 4; + int64 as_int = 6; + } +} + +// HistogramDataPoint mirrors the shape used by Prometheus-style histograms +// exported via OTLP — explicit bucket bounds + per-bucket counts. +message HistogramDataPoint { + repeated MetricKeyValue attributes = 9; + fixed64 start_time_unix_nano = 2; + fixed64 time_unix_nano = 3; + uint64 count = 4; + double sum = 5; + repeated uint64 bucket_counts = 6; + repeated double explicit_bounds = 7; + double min = 11; + double max = 12; +} + +message Gauge { + repeated NumberDataPoint data_points = 1; +} + +message Sum { + repeated NumberDataPoint data_points = 1; + int32 aggregation_temporality = 2; + bool is_monotonic = 3; +} + +message Histogram { + repeated HistogramDataPoint data_points = 1; + int32 aggregation_temporality = 2; +} + +message Metric { + string name = 1; + string description = 2; + string unit = 3; + oneof data { + Gauge gauge = 5; + Sum sum = 7; + Histogram histogram = 9; + } +} + +message ScopeMetrics { + MetricInstrumentationScope scope = 1; + repeated Metric metrics = 2; +} + +message ResourceMetrics { + MetricResource resource = 1; + repeated ScopeMetrics scope_metrics = 2; +} + +message ExportMetricsRequest { + repeated ResourceMetrics resource_metrics = 1; +} diff --git a/benchmarks/proto/rpc-simple.proto b/benchmarks/proto/rpc-simple.proto new file mode 100644 index 000000000..a6e3dd5b6 --- /dev/null +++ b/benchmarks/proto/rpc-simple.proto @@ -0,0 +1,40 @@ +// 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. + +syntax = "proto3"; +package bench.v1; + +// RpcRequest/RpcResponse is the most common RPC envelope shape seen in +// production — a handful of routing fields + string-keyed headers + an +// opaque payload carried as bytes. This fixture measures the baseline +// per-call overhead: no deep nesting, a small map, a moderate-size +// bytes blob. The ratio of this vs. the OTLP workload tells us how much +// of the slowdown comes from per-message fixed costs vs. per-field walk. + +message RpcRequest { + string service = 1; + string method = 2; + map headers = 3; + bytes payload = 4; + fixed64 request_id = 5; + int64 deadline_ms = 6; +} + +message RpcResponse { + fixed64 request_id = 1; + int32 status_code = 2; + map headers = 3; + bytes payload = 4; + string error_message = 5; +} diff --git a/benchmarks/proto/small.proto b/benchmarks/proto/small.proto new file mode 100644 index 000000000..ff9666e54 --- /dev/null +++ b/benchmarks/proto/small.proto @@ -0,0 +1,25 @@ +// 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. + +syntax = "proto3"; +package bench.v1; + +// SimpleMessage is a tiny, flat, scalar-only message used to measure +// per-call overhead of create() and toBinary() without allocation noise +// from nested messages or repeated fields. +message SimpleMessage { + string name = 1; + int32 value = 2; + bool enabled = 3; +} diff --git a/benchmarks/proto/stress.proto b/benchmarks/proto/stress.proto new file mode 100644 index 000000000..21c82b0f0 --- /dev/null +++ b/benchmarks/proto/stress.proto @@ -0,0 +1,68 @@ +// 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. + +syntax = "proto3"; +package bench.v1; + +// Synthetic stress payload. Exists to exercise encoder edge cases that +// realistic fixtures don't always hit in the same message: +// - deep recursive nesting via `child` +// - wide repeated of scalars (int32) and strings +// - map-ish attribute list via repeated StressKeyValue +// - a large opaque string (`blob`) and bytes (`blob_bytes`) +// - every scalar type exactly once so we can attribute per-type cost +// +// Intentionally NOT representative of any production schema. Useful to +// detect type-specific regressions (e.g. a change that makes sfixed64 10x +// slower would show up here but not on OTLP-only benchmarks). + +message StressKeyValue { + string key = 1; + string value = 2; +} + +message StressMessage { + // Recursive nesting — fixture builder caps the depth. The encoder + // length-prefix walk pays O(depth) per iteration so deep nesting is + // an interesting failure mode. + StressMessage child = 1; + + // Wide repeated — scalar + message arrays. Numbers > 100 items are + // where packed-encoding path becomes dominant. + repeated int32 ids = 2; + repeated string tags = 3; + repeated StressKeyValue attrs = 4; + + // Large contiguous strings/bytes — exercise TextEncoder + raw copy. + string blob = 5; + bytes blob_bytes = 20; + + // Every proto3 scalar exactly once. Field numbers spaced to keep + // varint tag widths consistent (single-byte tags for the common + // hot-path fields 1-15, multi-byte for 16+). + int32 i32 = 10; + int64 i64 = 11; + uint32 u32 = 12; + uint64 u64 = 13; + sint32 s32 = 14; + sint64 s64 = 15; + bool b = 16; + float f32 = 17; + double f64 = 18; + string str = 19; + fixed32 fx32 = 21; + fixed64 fx64 = 22; + sfixed32 sfx32 = 23; + sfixed64 sfx64 = 24; +} diff --git a/benchmarks/src/bench-comparison-protobufjs.ts b/benchmarks/src/bench-comparison-protobufjs.ts new file mode 100644 index 000000000..d609df6af --- /dev/null +++ b/benchmarks/src/bench-comparison-protobufjs.ts @@ -0,0 +1,225 @@ +// 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. + +// Cross-library comparison: protobuf-es vs protobufjs on the same .proto +// fixture. Both libraries consume `proto/nested.proto` (OTLP-like). The +// protobufjs build uses pbjs static-module codegen so the measurements +// reflect the ahead-of-time encoder path (no Reflect/runtime descriptor +// lookups). This makes the comparison apples-to-apples against +// protobuf-es's reflective `toBinary`. +// +// Motivation: the OTel regression report +// (open-telemetry/opentelemetry-js#6221) attributed a ~13x serialization +// regression to protobuf-es adoption. Our earlier investigation measured a +// larger ~30x gap on a similar shape. This suite reproduces the +// comparison against a pinned protobufjs version on the same host so +// future protobuf-es changes can be tracked against a stable baseline. + +import { Bench } from "tinybench"; +import { create, toBinary, toBinaryFast, fromBinary } from "@bufbuild/protobuf"; +import { ExportTraceRequestSchema } from "./gen/nested_pb.js"; +import { SPAN_COUNT } from "./fixtures.js"; + +// protobufjs is generated as CommonJS via pbjs static-module -w commonjs. +// The pbjs-generated module attaches the schema tree to $protobuf.roots +// via side effects, which requires a single shared `protobufjs/minimal` +// instance. Loading it via createRequire from ESM keeps that singleton +// intact and avoids the ESM namespace wrapping that breaks +// `$protobuf.roots["default"]`. +import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); +// biome-ignore lint/suspicious/noExplicitAny: generated pbjs has dynamic shape +const pbjsMod = require("./gen-protobufjs/nested.cjs") as any; + +interface PbjsMessageCtor { + create(properties: Record): PbjsMessage; + encode(message: PbjsMessage): PbjsWriter; + decode(reader: Uint8Array): PbjsMessage; +} +type PbjsMessage = Record; +interface PbjsWriter { + finish(): Uint8Array; +} + +const ExportTraceRequestJs = pbjsMod.bench.v1 + .ExportTraceRequest as PbjsMessageCtor; + +// Plain JS init shared by both libraries. Values mirror buildOtelLikePayload +// in spirit; kept inline to make this file self-contained. +function buildOtelLikePayload(): Record { + const spans = [] as unknown[]; + for (let i = 0; i < SPAN_COUNT; i++) { + const attributes: unknown[] = []; + for (let j = 0; j < 10; j++) { + // AnyValue oneof: mostly string, some int, some bool — matches the + // distribution the fast-path benchmark feeds into the reflective + // encoder via the same fixture. + let anyValue: Record; + if (j === 2 || j === 5) { + anyValue = { value: { case: "intValue", value: BigInt(200 + j) } }; + } else if (j === 8) { + anyValue = { value: { case: "boolValue", value: (i + j) % 7 === 0 } }; + } else { + anyValue = { + value: { case: "stringValue", value: `v${i}-${j}` }, + }; + } + attributes.push({ key: `k${j}`, value: anyValue }); + } + spans.push({ + traceId: new Uint8Array(16), + spanId: new Uint8Array(8), + name: `span-${i}`, + startTimeUnixNano: 1_700_000_000_000_000_000n, + endTimeUnixNano: 1_700_000_000_000_001_000n, + attributes, + }); + } + return { + resourceSpans: [ + { + resource: { + attributes: [], + labels: { + env: "production", + region: "us-east-1", + cluster: "bench-cluster", + }, + }, + scopeSpans: [ + { + scope: { name: "@example/tracer", version: "1.0.0" }, + spans, + }, + ], + }, + ], + }; +} + +// protobufjs uses Long for 64-bit fields by default. We generate with +// --force-long to get consistent typing; here we still pass BigInt-like +// plain numbers via strings so both paths encode the same timestamp. +// Prepare a payload variant for pbjs with: +// - string timestamps (no JSON conversion cost inside the hot loop) +// - AnyValue oneof represented as a plain field rather than the +// `{ case, value }` ADT protobuf-es uses, because protobufjs stores +// oneof members directly on the parent message. +function buildOtelLikePayloadForPbjs(): Record { + const base = buildOtelLikePayload(); + // biome-ignore lint/suspicious/noExplicitAny: in-place shape munging + const resourceSpans = (base.resourceSpans as any[])[0]; + for (const span of resourceSpans.scopeSpans[0].spans) { + span.startTimeUnixNano = "1700000000000000000"; + span.endTimeUnixNano = "1700000000000001000"; + // biome-ignore lint/suspicious/noExplicitAny: in-place shape munging + for (const attr of span.attributes as any[]) { + const adt = attr.value?.value as + | { case: string; value: unknown } + | undefined; + if (adt) { + const pbjsKey = + adt.case === "intValue" + ? "intValue" + : adt.case === "boolValue" + ? "boolValue" + : "stringValue"; + // protobufjs also accepts int64 as string without Long. + attr.value = { + [pbjsKey]: + adt.case === "intValue" + ? (adt.value as bigint).toString() + : adt.value, + }; + } + } + } + return base; +} + +export async function runComparisonBench() { + const bench = new Bench({ time: 2000, warmupTime: 500 }); + + const initEs = buildOtelLikePayload(); + const initPbjs = buildOtelLikePayloadForPbjs(); + + // --- Full roundtrip: create + encode + bench.add( + `protobuf-es: create+toBinary (${SPAN_COUNT} spans, OTel-like)`, + () => { + const msg = create(ExportTraceRequestSchema, initEs); + toBinary(ExportTraceRequestSchema, msg); + }, + ); + + bench.add( + `protobuf-es: create+toBinaryFast (${SPAN_COUNT} spans, OTel-like)`, + () => { + const msg = create(ExportTraceRequestSchema, initEs); + toBinaryFast(ExportTraceRequestSchema, msg); + }, + ); + + bench.add( + `protobufjs: create+encode (${SPAN_COUNT} spans, OTel-like)`, + () => { + const msg = ExportTraceRequestJs.create(initPbjs); + ExportTraceRequestJs.encode(msg).finish(); + }, + ); + + // --- Encode-only (pre-built messages) — fair comparison of encoder walk + const esPrebuilt = create(ExportTraceRequestSchema, initEs); + const pbjsPrebuilt = ExportTraceRequestJs.create(initPbjs); + + bench.add(`protobuf-es: toBinary pre-built (${SPAN_COUNT} spans)`, () => { + toBinary(ExportTraceRequestSchema, esPrebuilt); + }); + + bench.add(`protobuf-es: toBinaryFast pre-built (${SPAN_COUNT} spans)`, () => { + toBinaryFast(ExportTraceRequestSchema, esPrebuilt); + }); + + bench.add(`protobufjs: encode pre-built (${SPAN_COUNT} spans)`, () => { + ExportTraceRequestJs.encode(pbjsPrebuilt).finish(); + }); + + // --- Decode-only — pre-encode once, then measure parse path + const encodedByEs = toBinary(ExportTraceRequestSchema, esPrebuilt); + const encodedByPbjs = ExportTraceRequestJs.encode(pbjsPrebuilt).finish(); + + bench.add( + `protobuf-es: fromBinary (${SPAN_COUNT} spans, bytes from protobuf-es)`, + () => { + fromBinary(ExportTraceRequestSchema, encodedByEs); + }, + ); + + bench.add( + `protobufjs: decode (${SPAN_COUNT} spans, bytes from protobufjs)`, + () => { + ExportTraceRequestJs.decode(encodedByPbjs); + }, + ); + + await bench.run(); + return bench; +} + +// Run standalone: `tsx src/bench-comparison-protobufjs.ts` +if (import.meta.url === `file://${process.argv[1]}`) { + const bench = await runComparisonBench(); + console.log("\n=== protobuf-es vs protobufjs ==="); + console.table(bench.table()); +} diff --git a/benchmarks/src/bench-create-toBinary.ts b/benchmarks/src/bench-create-toBinary.ts new file mode 100644 index 000000000..9fd129d9e --- /dev/null +++ b/benchmarks/src/bench-create-toBinary.ts @@ -0,0 +1,153 @@ +// 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. + +// Combined `create() + toBinary()` workload. This is the direct-construction +// path from the OTel regression investigation (Phase 3): build the message +// graph fresh every iteration, then serialize. Matches the end-to-end shape +// of an OTLP trace export call made once per batch. + +import { Bench } from "tinybench"; +import { create, toBinary, toBinaryFast } from "@bufbuild/protobuf"; +import { SimpleMessageSchema } from "./gen/small_pb.js"; +import { + AnyValueSchema, + ExportTraceRequestSchema, + ResourceSpansSchema, + ScopeSpansSchema, + SpanSchema, + KeyValueSchema, + ResourceSchema, + InstrumentationScopeSchema, +} from "./gen/nested_pb.js"; +import { SPAN_COUNT } from "./fixtures.js"; + +export async function runCreateToBinaryBench() { + const bench = new Bench({ time: 2000, warmupTime: 500 }); + + bench.add("create() + toBinary() SimpleMessage", () => { + const m = create(SimpleMessageSchema, { + name: "bench-message", + value: 42, + enabled: true, + }); + toBinary(SimpleMessageSchema, m); + }); + + bench.add("create() + toBinaryFast() SimpleMessage", () => { + const m = create(SimpleMessageSchema, { + name: "bench-message", + value: 42, + enabled: true, + }); + toBinaryFast(SimpleMessageSchema, m); + }); + + bench.add( + `create() + toBinary() ExportTraceRequest (${SPAN_COUNT} spans, OTel-like)`, + () => { + const spans = [] as ReturnType>[]; + for (let i = 0; i < SPAN_COUNT; i++) { + const attrs = [] as ReturnType>[]; + for (let j = 0; j < 10; j++) { + attrs.push( + create(KeyValueSchema, { + key: `k${j}`, + value: create(AnyValueSchema, { + value: { case: "stringValue", value: `v${i}-${j}` }, + }), + }), + ); + } + spans.push( + create(SpanSchema, { + traceId: new Uint8Array(16), + spanId: new Uint8Array(8), + name: `span-${i}`, + startTimeUnixNano: 1_700_000_000_000_000_000n, + endTimeUnixNano: 1_700_000_000_000_001_000n, + attributes: attrs, + }), + ); + } + const scope = create(InstrumentationScopeSchema, { + name: "@example/tracer", + version: "1.0.0", + }); + const resource = create(ResourceSchema, { attributes: [] }); + const req = create(ExportTraceRequestSchema, { + resourceSpans: [ + create(ResourceSpansSchema, { + resource, + scopeSpans: [create(ScopeSpansSchema, { scope, spans })], + }), + ], + }); + toBinary(ExportTraceRequestSchema, req); + }, + ); + + bench.add( + `create() + toBinaryFast() ExportTraceRequest (${SPAN_COUNT} spans, OTel-like)`, + () => { + const spans = [] as ReturnType>[]; + for (let i = 0; i < SPAN_COUNT; i++) { + const attrs = [] as ReturnType>[]; + for (let j = 0; j < 10; j++) { + attrs.push( + create(KeyValueSchema, { + key: `k${j}`, + value: create(AnyValueSchema, { + value: { case: "stringValue", value: `v${i}-${j}` }, + }), + }), + ); + } + spans.push( + create(SpanSchema, { + traceId: new Uint8Array(16), + spanId: new Uint8Array(8), + name: `span-${i}`, + startTimeUnixNano: 1_700_000_000_000_000_000n, + endTimeUnixNano: 1_700_000_000_000_001_000n, + attributes: attrs, + }), + ); + } + const scope = create(InstrumentationScopeSchema, { + name: "@example/tracer", + version: "1.0.0", + }); + const resource = create(ResourceSchema, { attributes: [] }); + const req = create(ExportTraceRequestSchema, { + resourceSpans: [ + create(ResourceSpansSchema, { + resource, + scopeSpans: [create(ScopeSpansSchema, { scope, spans })], + }), + ], + }); + toBinaryFast(ExportTraceRequestSchema, req); + }, + ); + + await bench.run(); + return bench; +} + +// Run standalone: `tsx src/bench-create-toBinary.ts` +if (import.meta.url === `file://${process.argv[1]}`) { + const bench = await runCreateToBinaryBench(); + console.log("\n=== create() + toBinary() combined workload ==="); + console.table(bench.table()); +} diff --git a/benchmarks/src/bench-create.ts b/benchmarks/src/bench-create.ts new file mode 100644 index 000000000..9208ae800 --- /dev/null +++ b/benchmarks/src/bench-create.ts @@ -0,0 +1,102 @@ +// 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. + +// Isolates the cost of `create(Schema, init)` — message graph allocation +// without any serialization. Useful for comparing: small flat message vs +// nested OTLP-like tree where `create` is invoked per sub-message. + +import { Bench } from "tinybench"; +import { create } from "@bufbuild/protobuf"; +import { SimpleMessageSchema } from "./gen/small_pb.js"; +import { + AnyValueSchema, + ExportTraceRequestSchema, + ResourceSpansSchema, + ScopeSpansSchema, + SpanSchema, + KeyValueSchema, + ResourceSchema, + InstrumentationScopeSchema, +} from "./gen/nested_pb.js"; +import { SPAN_COUNT } from "./fixtures.js"; + +export async function runCreateBench() { + const bench = new Bench({ time: 2000, warmupTime: 500 }); + + bench.add("create() SimpleMessage (3 scalar fields)", () => { + create(SimpleMessageSchema, { + name: "bench-message", + value: 42, + enabled: true, + }); + }); + + // Nested: construct the full OTLP-like tree via repeated `create` calls. + // This is the path used by the OTel direct-serializer experiment + // (Phase 3 of otel-protobuf-regression analysis) — every sub-message + // wrapped in create() because reflective toBinary relies on the + // $typeName-tagged prototype set by create. + bench.add( + `create() ExportTraceRequest nested (${SPAN_COUNT} spans, 10 attrs each)`, + () => { + const spans = [] as ReturnType>[]; + for (let i = 0; i < SPAN_COUNT; i++) { + const attrs = [] as ReturnType>[]; + for (let j = 0; j < 10; j++) { + attrs.push( + create(KeyValueSchema, { + key: `k${j}`, + value: create(AnyValueSchema, { + value: { case: "stringValue", value: `v${i}-${j}` }, + }), + }), + ); + } + spans.push( + create(SpanSchema, { + traceId: new Uint8Array(16), + spanId: new Uint8Array(8), + name: `span-${i}`, + startTimeUnixNano: 1_700_000_000_000_000_000n, + endTimeUnixNano: 1_700_000_000_000_001_000n, + attributes: attrs, + }), + ); + } + const scope = create(InstrumentationScopeSchema, { + name: "@example/tracer", + version: "1.0.0", + }); + const resource = create(ResourceSchema, { attributes: [] }); + create(ExportTraceRequestSchema, { + resourceSpans: [ + create(ResourceSpansSchema, { + resource, + scopeSpans: [create(ScopeSpansSchema, { scope, spans })], + }), + ], + }); + }, + ); + + await bench.run(); + return bench; +} + +// Run standalone: `tsx src/bench-create.ts` +if (import.meta.url === `file://${process.argv[1]}`) { + const bench = await runCreateBench(); + console.log("\n=== create() cost ==="); + console.table(bench.table()); +} diff --git a/benchmarks/src/bench-fromBinary.ts b/benchmarks/src/bench-fromBinary.ts new file mode 100644 index 000000000..0bf787b4b --- /dev/null +++ b/benchmarks/src/bench-fromBinary.ts @@ -0,0 +1,67 @@ +// 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. + +// Parsing benchmarks — symmetric counterpart of bench-toBinary.ts. +// Pre-encodes a payload once, then measures the cost of the reflective +// `fromBinary` walk on the resulting bytes. Isolates decoder hot-path +// work (varint decoding, UTF-8 decode, nested message construction). +// +// Useful because protobuf-es performance arguments often focus only on +// the encode path — but most RPC servers are dominated by the decode +// side (one encoded request per RPC, one or more decoded fields +// traversed by application code). + +import { Bench } from "tinybench"; +import { toBinary, fromBinary } from "@bufbuild/protobuf"; +import { SimpleMessageSchema } from "./gen/small_pb.js"; +import { ExportTraceRequestSchema } from "./gen/nested_pb.js"; +import { + buildSmallMessage, + buildExportTraceRequest, + SPAN_COUNT, +} from "./fixtures.js"; + +export async function runFromBinaryBench() { + const bench = new Bench({ time: 2000, warmupTime: 500 }); + + // Encode once outside the measurement window so the hot loop is just + // the decoder walk — no allocation of the source message graph, no + // re-encoding work per iteration. + const smallBytes = toBinary(SimpleMessageSchema, buildSmallMessage()); + const traceBytes = toBinary( + ExportTraceRequestSchema, + buildExportTraceRequest(), + ); + + bench.add(`fromBinary() SimpleMessage (${smallBytes.byteLength} B)`, () => { + fromBinary(SimpleMessageSchema, smallBytes); + }); + + bench.add( + `fromBinary() ExportTraceRequest (${SPAN_COUNT} spans, ${traceBytes.byteLength} B)`, + () => { + fromBinary(ExportTraceRequestSchema, traceBytes); + }, + ); + + await bench.run(); + return bench; +} + +// Run standalone: `tsx src/bench-fromBinary.ts` +if (import.meta.url === `file://${process.argv[1]}`) { + const bench = await runFromBinaryBench(); + console.log("\n=== fromBinary() parsing cost ==="); + console.table(bench.table()); +} diff --git a/benchmarks/src/bench-fromJson-path.ts b/benchmarks/src/bench-fromJson-path.ts new file mode 100644 index 000000000..48a2e361a --- /dev/null +++ b/benchmarks/src/bench-fromJson-path.ts @@ -0,0 +1,83 @@ +// 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. + +// The "indirect" path — build a JSON-shaped plain object, stringify, then +// fromJsonString → toBinary. Included specifically because this exact +// pattern caused a 13x serialization regression in +// open-telemetry/opentelemetry-js#6221. Measuring it here makes the +// regression reproducible against protobuf-es versions and schema shapes. +// +// Also includes the `fromJson(plainObject)` path (no JSON.stringify +// intermediate) as the partial-fix midpoint: fewer traversals but still +// reflective parsing. + +import { Bench } from "tinybench"; +import { fromJsonString, fromJson, toBinary } from "@bufbuild/protobuf"; +import { ExportTraceRequestSchema } from "./gen/nested_pb.js"; +import { buildExportTraceRequestJsonShape, SPAN_COUNT } from "./fixtures.js"; + +export async function runFromJsonPathBench() { + const bench = new Bench({ time: 2000, warmupTime: 500 }); + + const jsonShape = buildExportTraceRequestJsonShape(); + // protobuf-es's fromJson expects Uint8Array bytes fields to be encoded + // as base64 strings in JSON mode. Pre-encode once so we compare parse + // paths without measuring base64 overhead per iteration. + const jsonEncodedShape = deepEncodeBytesToBase64(jsonShape); + const jsonString = JSON.stringify(jsonEncodedShape); + + bench.add( + `fromJsonString + toBinary (${SPAN_COUNT} spans) — OTel #6221 shape`, + () => { + const msg = fromJsonString(ExportTraceRequestSchema, jsonString); + toBinary(ExportTraceRequestSchema, msg); + }, + ); + + bench.add( + `fromJson + toBinary (${SPAN_COUNT} spans) — plainObject path`, + () => { + const msg = fromJson(ExportTraceRequestSchema, jsonEncodedShape); + toBinary(ExportTraceRequestSchema, msg); + }, + ); + + await bench.run(); + return bench; +} + +// biome-ignore lint/suspicious/noExplicitAny: deep traversal of anonymous JSON +function deepEncodeBytesToBase64(value: any): any { + if (value instanceof Uint8Array) { + return Buffer.from(value).toString("base64"); + } + if (Array.isArray(value)) { + return value.map(deepEncodeBytesToBase64); + } + if (value !== null && typeof value === "object") { + const out: Record = {}; + for (const key of Object.keys(value)) { + out[key] = deepEncodeBytesToBase64(value[key]); + } + return out; + } + return value; +} + +// Run standalone: `tsx src/bench-fromJson-path.ts` +if (import.meta.url === `file://${process.argv[1]}`) { + const bench = await runFromJsonPathBench(); + console.log("\n=== fromJson / fromJsonString + toBinary paths ==="); + console.table(bench.table()); +} diff --git a/benchmarks/src/bench-matrix.ts b/benchmarks/src/bench-matrix.ts new file mode 100644 index 000000000..6266d6d78 --- /dev/null +++ b/benchmarks/src/bench-matrix.ts @@ -0,0 +1,220 @@ +// 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. + +// Matrix benchmark runner. +// +// Runs each realistic fixture through the `toBinary` + `fromBinary` path +// and reports a combined table. Unlike bench-toBinary / bench-fromBinary +// which focus on a single shape in depth, bench-matrix is the "spread" +// view — useful for spotting whether a regression lands on one class of +// payloads (e.g. map-heavy k8s) vs another (e.g. deep nesting stress). +// +// Fixtures covered (see fixtures.ts and proto/ for the shapes): +// - SimpleMessage — 3 scalar fields, baseline per-call cost +// - ExportTraceRequest — OTel traces (existing fixture) +// - ExportMetricsRequest — OTel metrics: Gauge/Sum/Histogram mix +// - ExportLogsRequest — OTel logs: LogRecord batch +// - K8sPodList — Kubernetes: map-heavy configuration payload +// - GraphQLRequest — GraphQL query + variables (JSON-in-bytes) +// - GraphQLResponse — GraphQL response payload +// - RpcRequest/RpcResponse — baseline RPC envelope +// - StressMessage — synthetic: deep nesting + all scalar types +// +// Output format: tinybench table + JSON dump to stdout (for CI). Each row +// is a ` / ` pair so downstream tooling can diff across runs. + +import { Bench } from "tinybench"; +import { toBinary, fromBinary } from "@bufbuild/protobuf"; + +import { SimpleMessageSchema } from "./gen/small_pb.js"; +import { ExportTraceRequestSchema } from "./gen/nested_pb.js"; +import { ExportMetricsRequestSchema } from "./gen/otel-metrics_pb.js"; +import { ExportLogsRequestSchema } from "./gen/otel-logs_pb.js"; +import { K8sPodListSchema } from "./gen/k8s-pod_pb.js"; +import { + GraphQLRequestSchema, + GraphQLResponseSchema, +} from "./gen/graphql_pb.js"; +import { RpcRequestSchema, RpcResponseSchema } from "./gen/rpc-simple_pb.js"; +import { StressMessageSchema } from "./gen/stress_pb.js"; + +import { + buildSmallMessage, + buildExportTraceRequest, + buildExportMetricsRequest, + buildExportLogsRequest, + buildK8sPodList, + buildGraphQLRequest, + buildGraphQLResponse, + buildRpcRequest, + buildRpcResponse, + buildStressMessage, + SPAN_COUNT, + METRICS_SERIES_COUNT, + LOGS_RECORD_COUNT, + K8S_POD_COUNT, + STRESS_DEPTH, + STRESS_ARRAY_WIDTH, +} from "./fixtures.js"; + +// GenMessage is how protobuf-es ties a runtime schema to a message type. +// We accept `unknown` here and rely on tinybench to just call into the +// right callback; there's no actual type relationship that survives the +// matrix dispatch, so the inner cast is load-bearing. +// biome-ignore lint/suspicious/noExplicitAny: matrix dispatch is intentionally loose +type AnySchema = any; +// biome-ignore lint/suspicious/noExplicitAny: matrix dispatch is intentionally loose +type AnyMsg = any; + +interface MatrixCase { + name: string; + schema: AnySchema; + build: () => AnyMsg; + /** + * Short description of what makes this fixture distinctive — shown in + * the summary so downstream readers can correlate a regression row with + * the payload class without having to open fixtures.ts. + */ + shape: string; +} + +const cases: MatrixCase[] = [ + { + name: "SimpleMessage", + schema: SimpleMessageSchema, + build: () => buildSmallMessage(), + shape: "3 scalar fields, baseline per-call cost", + }, + { + name: `ExportTraceRequest (${SPAN_COUNT} spans)`, + schema: ExportTraceRequestSchema, + build: () => buildExportTraceRequest(), + shape: "OTel traces: repeated nested KeyValue + fixed64 timestamps", + }, + { + name: `ExportMetricsRequest (${METRICS_SERIES_COUNT} series)`, + schema: ExportMetricsRequestSchema, + build: () => buildExportMetricsRequest(), + shape: "OTel metrics: Gauge/Sum/Histogram oneof + buckets + bounds", + }, + { + name: `ExportLogsRequest (${LOGS_RECORD_COUNT} records)`, + schema: ExportLogsRequestSchema, + build: () => buildExportLogsRequest(), + shape: "OTel logs: LogRecord batch, string body, trace/span IDs", + }, + { + name: `K8sPodList (${K8S_POD_COUNT} pods)`, + schema: K8sPodListSchema, + build: () => buildK8sPodList(), + shape: + "map-heavy: labels, annotations, limits, requests + repeated containers", + }, + { + name: "GraphQLRequest", + schema: GraphQLRequestSchema, + build: () => buildGraphQLRequest(), + shape: "long query string + map variables", + }, + { + name: "GraphQLResponse", + schema: GraphQLResponseSchema, + build: () => buildGraphQLResponse(), + shape: "JSON-in-bytes data + errors with paths", + }, + { + name: "RpcRequest", + schema: RpcRequestSchema, + build: () => buildRpcRequest(), + shape: "baseline RPC envelope: small map + 256-byte payload", + }, + { + name: "RpcResponse", + schema: RpcResponseSchema, + build: () => buildRpcResponse(), + shape: "baseline RPC response: small map + 512-byte payload", + }, + { + name: `StressMessage (depth=${STRESS_DEPTH}, width=${STRESS_ARRAY_WIDTH})`, + schema: StressMessageSchema, + build: () => buildStressMessage(), + shape: "synthetic: deep nesting + all scalar types + 4KB blob", + }, +]; + +export async function runMatrixBench() { + const bench = new Bench({ time: 1000, warmupTime: 200 }); + + // Pre-build every message + pre-encoded bytes outside the measurement + // loop so the encoder/decoder benchmarks reflect the encode/decode walk + // in isolation. + const prepared = cases.map((c) => { + const msg = c.build(); + const bytes = toBinary(c.schema, msg); + return { ...c, msg, bytes }; + }); + + for (const p of prepared) { + bench.add(`${p.name} :: toBinary (pre-built, ${p.bytes.length} B)`, () => { + toBinary(p.schema, p.msg); + }); + } + for (const p of prepared) { + bench.add(`${p.name} :: fromBinary (${p.bytes.length} B)`, () => { + fromBinary(p.schema, p.bytes); + }); + } + + await bench.run(); + return { bench, prepared }; +} + +function summarize( + bench: Bench, +): Array<{ name: string; opsPerSec: number; rme: number; samples: number }> { + return bench.tasks.map((t) => ({ + name: t.name, + // tinybench exposes hz (ops/sec) + rme (%) + samples count on the task + // result. We serialize this to JSON so CI can diff runs deterministically. + opsPerSec: t.result?.hz ?? 0, + rme: t.result?.rme ?? 0, + samples: t.result?.samples.length ?? 0, + })); +} + +// Run standalone: `tsx src/bench-matrix.ts` +if (import.meta.url === `file://${process.argv[1]}`) { + const { bench, prepared } = await runMatrixBench(); + console.log("\n=== Matrix: encoded sizes ==="); + console.table( + prepared.map((p) => ({ + fixture: p.name, + bytes: p.bytes.length, + shape: p.shape, + })), + ); + console.log("\n=== Matrix: toBinary + fromBinary across fixtures ==="); + console.table(bench.table()); + + // Emit machine-readable JSON on the last line — consumable by CI diff + // tooling without having to scrape the tinybench table. + const payload = { + node: process.version, + platform: `${process.platform}/${process.arch}`, + timestamp: new Date().toISOString(), + results: summarize(bench), + }; + console.log("\n=== Matrix JSON ==="); + console.log(JSON.stringify(payload)); +} diff --git a/benchmarks/src/bench-memory.ts b/benchmarks/src/bench-memory.ts new file mode 100644 index 000000000..0ab8588af --- /dev/null +++ b/benchmarks/src/bench-memory.ts @@ -0,0 +1,237 @@ +// 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. + +// Memory / allocation benchmark. +// +// Approach: force GC before and after a tight loop of N iterations of +// the workload and report (heapUsed_after - heapUsed_before) / N as the +// per-op heap delta. This is a coarse approximation — V8 allocates +// young-gen objects in TLABs that are free to V8-manage until a minor +// GC sweeps them — but it lets us compare libraries on the same host +// under the same conditions, which is the only claim we make here. +// +// Requires --expose-gc. Run: +// node --expose-gc --import tsx src/bench-memory.ts +// (or) +// npm run bench:memory (package.json wires --expose-gc) + +import { create, toBinary, toBinaryFast, fromBinary } from "@bufbuild/protobuf"; +import { ExportTraceRequestSchema } from "./gen/nested_pb.js"; +import { SPAN_COUNT } from "./fixtures.js"; + +import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); +// biome-ignore lint/suspicious/noExplicitAny: generated pbjs has dynamic shape +const pbjsMod = require("./gen-protobufjs/nested.cjs") as any; +const ExportTraceRequestJs = pbjsMod.bench.v1.ExportTraceRequest as { + create(init: Record): Record; + encode(msg: Record): { finish(): Uint8Array }; + decode(bytes: Uint8Array): Record; +}; + +const ITERATIONS = 1000; + +function buildInit(): Record { + const spans = [] as unknown[]; + for (let i = 0; i < SPAN_COUNT; i++) { + const attributes: unknown[] = []; + for (let j = 0; j < 10; j++) { + // Mirror the distribution used in bench-comparison-protobufjs so the + // memory numbers reflect the same workload as the throughput runs. + let anyValue: Record; + if (j === 2 || j === 5) { + anyValue = { value: { case: "intValue", value: BigInt(200 + j) } }; + } else if (j === 8) { + anyValue = { value: { case: "boolValue", value: (i + j) % 7 === 0 } }; + } else { + anyValue = { + value: { case: "stringValue", value: `v${i}-${j}` }, + }; + } + attributes.push({ key: `k${j}`, value: anyValue }); + } + spans.push({ + traceId: new Uint8Array(16), + spanId: new Uint8Array(8), + name: `span-${i}`, + startTimeUnixNano: 1_700_000_000_000_000_000n, + endTimeUnixNano: 1_700_000_000_000_001_000n, + attributes, + }); + } + return { + resourceSpans: [ + { + resource: { + attributes: [], + labels: { + env: "production", + region: "us-east-1", + cluster: "bench-cluster", + }, + }, + scopeSpans: [ + { + scope: { name: "@example/tracer", version: "1.0.0" }, + spans, + }, + ], + }, + ], + }; +} + +function buildInitForPbjs(): Record { + const base = buildInit(); + // biome-ignore lint/suspicious/noExplicitAny: in-place shape munging + const resourceSpans = (base.resourceSpans as any[])[0]; + for (const span of resourceSpans.scopeSpans[0].spans) { + span.startTimeUnixNano = "1700000000000000000"; + span.endTimeUnixNano = "1700000000000001000"; + // biome-ignore lint/suspicious/noExplicitAny: in-place shape munging + for (const attr of span.attributes as any[]) { + const adt = attr.value?.value as + | { case: string; value: unknown } + | undefined; + if (adt) { + const pbjsKey = + adt.case === "intValue" + ? "intValue" + : adt.case === "boolValue" + ? "boolValue" + : "stringValue"; + attr.value = { + [pbjsKey]: + adt.case === "intValue" + ? (adt.value as bigint).toString() + : adt.value, + }; + } + } + } + return base; +} + +interface MemSample { + label: string; + totalBytes: number; + bytesPerOp: number; + iterations: number; +} + +function ensureGc(): () => void { + if (typeof global.gc !== "function") { + console.error( + "bench-memory requires --expose-gc. Run with `node --expose-gc --import tsx ...` or `npm run bench:memory`.", + ); + process.exit(1); + } + return global.gc; +} + +function measure(label: string, body: () => void): MemSample { + const gc = ensureGc(); + // Warm the code paths once so shape transitions settle before + // the measured run. Otherwise first-call IC pollution adds noise. + body(); + gc(); + gc(); + const before = process.memoryUsage().heapUsed; + for (let i = 0; i < ITERATIONS; i++) { + body(); + } + const after = process.memoryUsage().heapUsed; + const totalBytes = Math.max(0, after - before); + return { + label, + totalBytes, + bytesPerOp: totalBytes / ITERATIONS, + iterations: ITERATIONS, + }; +} + +async function main() { + console.log( + `memory bench — Node ${process.version}, ${ITERATIONS} iterations per case`, + ); + console.log( + "Approach: heapUsed delta after forced GC. Directional, not exact.", + ); + + const initEs = buildInit(); + const initPbjs = buildInitForPbjs(); + const preEncoded = toBinary( + ExportTraceRequestSchema, + create(ExportTraceRequestSchema, initEs), + ); + const preEncodedPbjs = ExportTraceRequestJs.encode( + ExportTraceRequestJs.create(initPbjs), + ).finish(); + + const samples: MemSample[] = []; + + samples.push( + measure(`protobuf-es: create + toBinary (${SPAN_COUNT} spans)`, () => { + const msg = create(ExportTraceRequestSchema, initEs); + toBinary(ExportTraceRequestSchema, msg); + }), + ); + + samples.push( + measure(`protobuf-es: create + toBinaryFast (${SPAN_COUNT} spans)`, () => { + const msg = create(ExportTraceRequestSchema, initEs); + toBinaryFast(ExportTraceRequestSchema, msg); + }), + ); + + samples.push( + measure(`protobufjs: create + encode (${SPAN_COUNT} spans)`, () => { + const msg = ExportTraceRequestJs.create(initPbjs); + ExportTraceRequestJs.encode(msg).finish(); + }), + ); + + samples.push( + measure( + `protobuf-es: fromBinary (${SPAN_COUNT} spans, ${preEncoded.byteLength} B)`, + () => { + fromBinary(ExportTraceRequestSchema, preEncoded); + }, + ), + ); + + samples.push( + measure( + `protobufjs: decode (${SPAN_COUNT} spans, ${preEncodedPbjs.byteLength} B)`, + () => { + ExportTraceRequestJs.decode(preEncodedPbjs); + }, + ), + ); + + console.log(); + console.table( + samples.map((s) => ({ + Case: s.label, + "Total heap delta (B)": s.totalBytes.toLocaleString(), + "Bytes/op (avg)": Math.round(s.bytesPerOp).toLocaleString(), + Iterations: s.iterations, + })), + ); +} + +main().catch((err) => { + console.error(err); + process.exitCode = 1; +}); diff --git a/benchmarks/src/bench-toBinary.ts b/benchmarks/src/bench-toBinary.ts new file mode 100644 index 000000000..5f80db6cb --- /dev/null +++ b/benchmarks/src/bench-toBinary.ts @@ -0,0 +1,66 @@ +// 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. + +// Isolates the cost of `toBinary(Schema, message)` on PRE-BUILT messages. +// Messages are constructed once outside the measurement loop so this +// reflects the reflective binary encoder cost in isolation. + +import { Bench } from "tinybench"; +import { toBinary, toBinaryFast } from "@bufbuild/protobuf"; +import { SimpleMessageSchema } from "./gen/small_pb.js"; +import { ExportTraceRequestSchema } from "./gen/nested_pb.js"; +import { + buildSmallMessage, + buildExportTraceRequest, + SPAN_COUNT, +} from "./fixtures.js"; + +export async function runToBinaryBench() { + const bench = new Bench({ time: 2000, warmupTime: 500 }); + + const small = buildSmallMessage(); + const traceRequest = buildExportTraceRequest(); + + bench.add("toBinary() SimpleMessage (pre-built)", () => { + toBinary(SimpleMessageSchema, small); + }); + + bench.add( + `toBinary() ExportTraceRequest (pre-built, ${SPAN_COUNT} spans)`, + () => { + toBinary(ExportTraceRequestSchema, traceRequest); + }, + ); + + bench.add("toBinaryFast() SimpleMessage (pre-built)", () => { + toBinaryFast(SimpleMessageSchema, small); + }); + + bench.add( + `toBinaryFast() ExportTraceRequest (pre-built, ${SPAN_COUNT} spans)`, + () => { + toBinaryFast(ExportTraceRequestSchema, traceRequest); + }, + ); + + await bench.run(); + return bench; +} + +// Run standalone: `tsx src/bench-toBinary.ts` +if (import.meta.url === `file://${process.argv[1]}`) { + const bench = await runToBinaryBench(); + console.log("\n=== toBinary() cost on pre-built messages ==="); + console.table(bench.table()); +} diff --git a/benchmarks/src/fixtures.ts b/benchmarks/src/fixtures.ts new file mode 100644 index 000000000..402a15062 --- /dev/null +++ b/benchmarks/src/fixtures.ts @@ -0,0 +1,794 @@ +// 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. + +import { create } from "@bufbuild/protobuf"; +import { SimpleMessageSchema, type SimpleMessage } from "./gen/small_pb.js"; +import { + AnyValueSchema, + ExportTraceRequestSchema, + type ExportTraceRequest, + KeyValueSchema, + SpanSchema, + ScopeSpansSchema, + ResourceSpansSchema, + ResourceSchema, + InstrumentationScopeSchema, +} from "./gen/nested_pb.js"; +import { + ExportMetricsRequestSchema, + type ExportMetricsRequest, + GaugeSchema, + HistogramDataPointSchema, + HistogramSchema, + MetricAnyValueSchema, + MetricInstrumentationScopeSchema, + MetricKeyValueSchema, + MetricResourceSchema, + MetricSchema, + NumberDataPointSchema, + ResourceMetricsSchema, + ScopeMetricsSchema, + SumSchema, +} from "./gen/otel-metrics_pb.js"; +import { + ExportLogsRequestSchema, + type ExportLogsRequest, + LogAnyValueSchema, + LogInstrumentationScopeSchema, + LogKeyValueSchema, + LogRecordSchema, + LogResourceSchema, + ResourceLogsSchema, + ScopeLogsSchema, +} from "./gen/otel-logs_pb.js"; +import { + K8sContainerSchema, + K8sContainerStatusSchema, + K8sEnvVarSchema, + K8sObjectMetaSchema, + K8sPodListSchema, + type K8sPodList, + K8sPodSchema, + K8sPodSpecSchema, + K8sPodStatusSchema, + K8sPortSchema, + K8sResourceRequirementsSchema, +} from "./gen/k8s-pod_pb.js"; +import { + GraphQLErrorSchema, + GraphQLRequestSchema, + type GraphQLRequest, + GraphQLResponseSchema, + type GraphQLResponse, + GraphQLSourceLocationSchema, +} from "./gen/graphql_pb.js"; +import { + RpcRequestSchema, + type RpcRequest, + RpcResponseSchema, + type RpcResponse, +} from "./gen/rpc-simple_pb.js"; +import { + StressKeyValueSchema, + StressMessageSchema, + type StressMessage, +} from "./gen/stress_pb.js"; + +// Shared fixture construction. Kept deterministic so benchmark runs are +// comparable: string lengths, attribute counts, and span counts are fixed. + +export const SMALL_INIT = { + name: "bench-message", + value: 42, + enabled: true, +} as const; + +export function buildSmallMessage(): SimpleMessage { + return create(SimpleMessageSchema, { ...SMALL_INIT }); +} + +// Produce a 16-byte Uint8Array deterministically from a numeric seed. +// Emulates the shape of trace IDs (16 bytes) / span IDs (8 bytes) without +// the cost of hex parsing — we care about the encoder path, not ID generation. +function bytes(seed: number, length: number): Uint8Array { + const out = new Uint8Array(length); + for (let i = 0; i < length; i++) { + out[i] = (seed + i) & 0xff; + } + return out; +} + +// Mimics OTLP attribute cardinality in realistic trace exports: +// ~10 attributes per span, short ASCII keys, and a mix of AnyValue leaf +// types so that the oneof dispatch on the fast path gets exercised on +// every variant we encode in production (string dominates; bool, int, +// and double show up on status / error flags / durations). +function buildAttributes(spanIdx: number) { + const out = [] as ReturnType>[]; + const descriptors = [ + { key: "http.method", kind: "string" as const }, + { key: "http.url", kind: "string" as const }, + { key: "http.status_code", kind: "int" as const }, + { key: "http.user_agent", kind: "string" as const }, + { key: "net.peer.name", kind: "string" as const }, + { key: "net.peer.port", kind: "int" as const }, + { key: "service.name", kind: "string" as const }, + { key: "service.version", kind: "string" as const }, + { key: "error", kind: "bool" as const }, + { key: "rpc.system", kind: "string" as const }, + ]; + for (let i = 0; i < descriptors.length; i++) { + const d = descriptors[i]; + let anyInit: Parameters>[1]; + if (d.kind === "string") { + anyInit = { + value: { case: "stringValue", value: `value-${spanIdx}-${i}` }, + }; + } else if (d.kind === "int") { + anyInit = { + value: { case: "intValue", value: BigInt(200 + (i % 5)) }, + }; + } else { + anyInit = { + value: { case: "boolValue", value: (spanIdx + i) % 7 === 0 }, + }; + } + out.push( + create(KeyValueSchema, { + key: d.key, + value: create(AnyValueSchema, anyInit), + }), + ); + } + return out; +} + +// Build a single Span matching the OTLP ExportTraceServiceRequest.Span shape +// we use in bench-create-toBinary.ts. Not a literal copy of opentelemetry.proto; +// see benchmarks/proto/nested.proto for the simplified schema used here. +function buildSpan(i: number) { + return create(SpanSchema, { + traceId: bytes(i, 16), + spanId: bytes(i + 1, 8), + name: `span-${i}`, + startTimeUnixNano: 1_700_000_000_000_000_000n + BigInt(i) * 1000n, + endTimeUnixNano: 1_700_000_000_000_001_000n + BigInt(i) * 1000n, + attributes: buildAttributes(i), + }); +} + +// OTLP-like payload with SPAN_COUNT spans grouped under a single +// resource + scope. Matches the shape produced by a real OTLP exporter +// batching spans from one process. +export const SPAN_COUNT = 100; + +export function buildExportTraceRequest(): ExportTraceRequest { + const spans = [] as ReturnType[]; + for (let i = 0; i < SPAN_COUNT; i++) { + spans.push(buildSpan(i)); + } + const scope = create(InstrumentationScopeSchema, { + name: "@example/tracer", + version: "1.0.0", + }); + const scopeSpans = create(ScopeSpansSchema, { scope, spans }); + const resource = create(ResourceSchema, { + attributes: [ + create(KeyValueSchema, { + key: "service.name", + value: create(AnyValueSchema, { + value: { case: "stringValue", value: "bench-service" }, + }), + }), + create(KeyValueSchema, { + key: "service.version", + value: create(AnyValueSchema, { + value: { case: "stringValue", value: "1.0.0" }, + }), + }), + ], + // Exercise map encoding on the fast path. Realistic + // cardinality: a handful of per-process deployment labels. + labels: { + env: "production", + region: "us-east-1", + cluster: "bench-cluster", + az: "us-east-1a", + tenant: "bench-tenant", + }, + }); + const resourceSpans = create(ResourceSpansSchema, { + resource, + scopeSpans: [scopeSpans], + }); + return create(ExportTraceRequestSchema, { + resourceSpans: [resourceSpans], + }); +} + +// Plain-object shape accepted by `create(ExportTraceRequestSchema, init)` — +// no pre-wrapped messages. Used by the fromJson-path benchmark to emulate +// the pattern that produced the OTel regression (build JS object, stringify, +// re-parse via fromJsonString). +export function buildExportTraceRequestJsonShape() { + const spans = [] as unknown[]; + const attributeDescriptors = [ + { key: "http.method", kind: "string" as const }, + { key: "http.url", kind: "string" as const }, + { key: "http.status_code", kind: "int" as const }, + { key: "http.user_agent", kind: "string" as const }, + { key: "net.peer.name", kind: "string" as const }, + { key: "net.peer.port", kind: "int" as const }, + { key: "service.name", kind: "string" as const }, + { key: "service.version", kind: "string" as const }, + { key: "error", kind: "bool" as const }, + { key: "rpc.system", kind: "string" as const }, + ]; + for (let i = 0; i < SPAN_COUNT; i++) { + const attributes: unknown[] = []; + for (let j = 0; j < attributeDescriptors.length; j++) { + const d = attributeDescriptors[j]; + let value: unknown; + if (d.kind === "string") { + value = { case: "stringValue", value: `value-${i}-${j}` }; + } else if (d.kind === "int") { + value = { case: "intValue", value: (200 + (j % 5)).toString() }; + } else { + value = { case: "boolValue", value: (i + j) % 7 === 0 }; + } + attributes.push({ key: d.key, value }); + } + spans.push({ + traceId: bytes(i, 16), + spanId: bytes(i + 1, 8), + name: `span-${i}`, + startTimeUnixNano: ( + 1_700_000_000_000_000_000n + + BigInt(i) * 1000n + ).toString(), + endTimeUnixNano: ( + 1_700_000_000_000_001_000n + + BigInt(i) * 1000n + ).toString(), + attributes, + }); + } + return { + resourceSpans: [ + { + resource: { + attributes: [ + { + key: "service.name", + value: { case: "stringValue", value: "bench-service" }, + }, + { + key: "service.version", + value: { case: "stringValue", value: "1.0.0" }, + }, + ], + labels: { + env: "production", + region: "us-east-1", + cluster: "bench-cluster", + az: "us-east-1a", + tenant: "bench-tenant", + }, + }, + scopeSpans: [ + { + scope: { name: "@example/tracer", version: "1.0.0" }, + spans, + }, + ], + }, + ], + }; +} + +// --------------------------------------------------------------------------- +// Matrix fixtures — each builder is parameterized by a scale `n` so that the +// same shape can be measured at a few realistic cardinalities. Defaults are +// chosen to match what we see in production traffic for each payload class. +// --------------------------------------------------------------------------- + +// ---- OTel metrics --------------------------------------------------------- +// +// A batched metrics export: one Resource, one Scope, a mix of Gauge/Sum and +// a Histogram with explicit bucket bounds. `n` controls the number of +// distinct metric series; each series contributes a handful of data points. + +export const METRICS_SERIES_COUNT = 50; + +function buildMetricAttributes(metricIdx: number) { + const keys = [ + "service.name", + "service.version", + "deployment.environment", + "host.name", + "cloud.region", + ]; + return keys.map((key, i) => + create(MetricKeyValueSchema, { + key, + value: create(MetricAnyValueSchema, { + value: { case: "stringValue", value: `v-${metricIdx}-${i}` }, + }), + }), + ); +} + +function buildNumberDataPoint(idx: number, asDouble: boolean) { + return create(NumberDataPointSchema, { + attributes: buildMetricAttributes(idx), + startTimeUnixNano: 1_700_000_000_000_000_000n, + timeUnixNano: 1_700_000_000_000_001_000n + BigInt(idx) * 1000n, + value: asDouble + ? { case: "asDouble", value: 1.0 + idx * 0.125 } + : { case: "asInt", value: BigInt(idx * 17) }, + }); +} + +function buildHistogramDataPoint(idx: number) { + const buckets = [0, 1, 5, 10, 50, 100]; + return create(HistogramDataPointSchema, { + attributes: buildMetricAttributes(idx), + startTimeUnixNano: 1_700_000_000_000_000_000n, + timeUnixNano: 1_700_000_000_000_001_000n + BigInt(idx) * 1000n, + count: BigInt(100 + idx), + sum: 123.456 * idx, + bucketCounts: buckets.map((b) => BigInt(b + idx)), + explicitBounds: [0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0], + min: 0, + max: 9999.0, + }); +} + +export function buildExportMetricsRequest( + n: number = METRICS_SERIES_COUNT, +): ExportMetricsRequest { + const metrics = [] as ReturnType>[]; + for (let i = 0; i < n; i++) { + const kind = i % 3; + if (kind === 0) { + metrics.push( + create(MetricSchema, { + name: `metric.gauge.${i}`, + description: "gauge metric", + unit: "1", + data: { + case: "gauge", + value: create(GaugeSchema, { + dataPoints: [ + buildNumberDataPoint(i, true), + buildNumberDataPoint(i + 1, true), + ], + }), + }, + }), + ); + } else if (kind === 1) { + metrics.push( + create(MetricSchema, { + name: `metric.sum.${i}`, + description: "monotonic counter", + unit: "By", + data: { + case: "sum", + value: create(SumSchema, { + aggregationTemporality: 2, + isMonotonic: true, + dataPoints: [ + buildNumberDataPoint(i, false), + buildNumberDataPoint(i + 1, false), + ], + }), + }, + }), + ); + } else { + metrics.push( + create(MetricSchema, { + name: `metric.histogram.${i}`, + description: "request duration", + unit: "s", + data: { + case: "histogram", + value: create(HistogramSchema, { + aggregationTemporality: 2, + dataPoints: [buildHistogramDataPoint(i)], + }), + }, + }), + ); + } + } + const scope = create(MetricInstrumentationScopeSchema, { + name: "@example/metrics", + version: "1.0.0", + }); + const resource = create(MetricResourceSchema, { + attributes: buildMetricAttributes(0), + }); + return create(ExportMetricsRequestSchema, { + resourceMetrics: [ + create(ResourceMetricsSchema, { + resource, + scopeMetrics: [create(ScopeMetricsSchema, { scope, metrics })], + }), + ], + }); +} + +// ---- OTel logs ------------------------------------------------------------ +// +// Batched logs export with string body, severity, attributes, and trace +// correlation IDs. `n` controls the number of log records in the batch. + +export const LOGS_RECORD_COUNT = 100; + +function buildLogAttributes(recordIdx: number) { + const keys = ["code.namespace", "code.function", "thread.id", "log.source"]; + return keys.map((key, i) => + create(LogKeyValueSchema, { + key, + value: create(LogAnyValueSchema, { + value: { case: "stringValue", value: `v-${recordIdx}-${i}` }, + }), + }), + ); +} + +export function buildExportLogsRequest( + n: number = LOGS_RECORD_COUNT, +): ExportLogsRequest { + const records = [] as ReturnType>[]; + for (let i = 0; i < n; i++) { + records.push( + create(LogRecordSchema, { + timeUnixNano: 1_700_000_000_000_000_000n + BigInt(i) * 1000n, + observedTimeUnixNano: 1_700_000_000_000_001_000n + BigInt(i) * 1000n, + severityNumber: 9 + (i % 4), + severityText: ["INFO", "WARN", "ERROR", "DEBUG"][i % 4], + body: create(LogAnyValueSchema, { + value: { + case: "stringValue", + value: `log message #${i}: operation completed in ${i % 100}ms`, + }, + }), + attributes: buildLogAttributes(i), + droppedAttributesCount: 0, + flags: i & 0xff, + traceId: bytes(i, 16), + spanId: bytes(i + 1, 8), + }), + ); + } + const scope = create(LogInstrumentationScopeSchema, { + name: "@example/logger", + version: "1.0.0", + }); + const resource = create(LogResourceSchema, { + attributes: buildLogAttributes(0), + }); + return create(ExportLogsRequestSchema, { + resourceLogs: [ + create(ResourceLogsSchema, { + resource, + scopeLogs: [ + create(ScopeLogsSchema, { + scope, + logRecords: records, + schemaUrl: "", + }), + ], + schemaUrl: "", + }), + ], + }); +} + +// ---- K8s Pod list --------------------------------------------------------- +// +// Representative payload for a kubelet → apiserver listing call. Maps +// (labels, annotations, limits, requests) dominate; each pod has 2 +// containers with env vars, ports, resource requirements, and a few +// container statuses. + +export const K8S_POD_COUNT = 20; + +function buildK8sContainer(podIdx: number, containerIdx: number) { + return create(K8sContainerSchema, { + name: `container-${containerIdx}`, + image: `ghcr.io/example/app:v1.${podIdx}.${containerIdx}`, + command: ["/bin/app", "--config=/etc/app/config.yaml"], + args: ["--log-level=info", `--instance=pod-${podIdx}`], + env: [ + create(K8sEnvVarSchema, { name: "NODE_ENV", value: "production" }), + create(K8sEnvVarSchema, { name: "PORT", value: "8080" }), + create(K8sEnvVarSchema, { + name: "POD_NAME", + value: `example-pod-${podIdx}`, + }), + create(K8sEnvVarSchema, { name: "POD_NAMESPACE", value: "default" }), + ], + ports: [ + create(K8sPortSchema, { + name: "http", + containerPort: 8080, + protocol: "TCP", + }), + create(K8sPortSchema, { + name: "metrics", + containerPort: 9090, + protocol: "TCP", + }), + ], + resources: create(K8sResourceRequirementsSchema, { + limits: { cpu: "1000m", memory: "512Mi" }, + requests: { cpu: "100m", memory: "128Mi" }, + }), + imagePullPolicy: "IfNotPresent", + }); +} + +function buildK8sPod(i: number) { + const meta = create(K8sObjectMetaSchema, { + name: `example-pod-${i}`, + namespace: "default", + uid: `uid-${i.toString().padStart(8, "0")}`, + resourceVersion: `${100000 + i}`, + generation: BigInt(1), + labels: { + app: "example", + component: "api", + "app.kubernetes.io/name": "example", + "app.kubernetes.io/instance": `instance-${i}`, + tier: "backend", + }, + annotations: { + "prometheus.io/scrape": "true", + "prometheus.io/port": "9090", + "kubectl.kubernetes.io/last-applied-configuration": "{}", + }, + creationTimestampUnixNano: 1_700_000_000_000_000_000n + BigInt(i) * 1000n, + }); + const spec = create(K8sPodSpecSchema, { + containers: [buildK8sContainer(i, 0), buildK8sContainer(i, 1)], + restartPolicy: "Always", + nodeName: `node-${i % 5}.cluster.local`, + serviceAccountName: "default", + terminationGracePeriodSeconds: BigInt(30), + }); + const status = create(K8sPodStatusSchema, { + phase: "Running", + podIp: `10.0.${(i >> 8) & 0xff}.${i & 0xff}`, + hostIp: `10.1.0.${i % 255}`, + startTimeUnixNano: 1_700_000_000_000_000_000n + BigInt(i) * 1000n, + containerStatuses: [ + create(K8sContainerStatusSchema, { + name: "container-0", + ready: true, + restartCount: 0, + image: `ghcr.io/example/app:v1.${i}.0`, + imageId: `sha256:${"a".repeat(64)}`, + containerId: `containerd://${"b".repeat(64)}`, + started: true, + }), + create(K8sContainerStatusSchema, { + name: "container-1", + ready: true, + restartCount: 0, + image: `ghcr.io/example/app:v1.${i}.1`, + imageId: `sha256:${"c".repeat(64)}`, + containerId: `containerd://${"d".repeat(64)}`, + started: true, + }), + ], + }); + return create(K8sPodSchema, { metadata: meta, spec, status }); +} + +export function buildK8sPodList(n: number = K8S_POD_COUNT): K8sPodList { + const items = [] as ReturnType[]; + for (let i = 0; i < n; i++) { + items.push(buildK8sPod(i)); + } + return create(K8sPodListSchema, { items }); +} + +// ---- GraphQL -------------------------------------------------------------- +// +// A medium-size GraphQL request/response pair: long query string, several +// variables (JSON-encoded bytes), small response payload. Mirrors a typical +// authenticated GraphQL API call. + +const GRAPHQL_QUERY = ` + query GetUser($id: ID!, $includePosts: Boolean!, $postLimit: Int!) { + user(id: $id) { + id + email + displayName + avatarUrl + createdAt + lastSeenAt + posts(limit: $postLimit) @include(if: $includePosts) { + id + title + body + createdAt + tags + author { id displayName } + } + followers(first: 10) { + edges { node { id displayName } } + pageInfo { hasNextPage endCursor } + } + } + } +`.trim(); + +export function buildGraphQLRequest(): GraphQLRequest { + const encoder = new TextEncoder(); + return create(GraphQLRequestSchema, { + query: GRAPHQL_QUERY, + operationName: "GetUser", + variables: { + id: encoder.encode('"user-42"'), + includePosts: encoder.encode("true"), + postLimit: encoder.encode("25"), + }, + extensions: { + traceId: "00000000000000000000000000000000", + "x-client-version": "web/1.2.3", + }, + }); +} + +export function buildGraphQLResponse(): GraphQLResponse { + const encoder = new TextEncoder(); + // Smallish JSON response body — 2 KB-ish. + const data = encoder.encode( + JSON.stringify({ + user: { + id: "user-42", + email: "alice@example.com", + displayName: "Alice", + avatarUrl: "https://cdn.example.com/avatars/42.png", + createdAt: "2024-01-15T10:00:00Z", + lastSeenAt: "2026-04-19T09:30:00Z", + posts: Array.from({ length: 5 }, (_, i) => ({ + id: `post-${i}`, + title: `Example post ${i}`, + body: `Lorem ipsum dolor sit amet, consectetur ${i}`, + createdAt: "2025-01-01T00:00:00Z", + tags: ["news", "update"], + author: { id: "user-42", displayName: "Alice" }, + })), + }, + }), + ); + return create(GraphQLResponseSchema, { + data, + errors: [ + create(GraphQLErrorSchema, { + message: "deprecated field 'lastSeenAt' will be removed in v2", + locations: [ + create(GraphQLSourceLocationSchema, { line: 7, column: 7 }), + ], + path: ["user", "lastSeenAt"], + extensions: { + code: encoder.encode('"DEPRECATED_FIELD"'), + }, + }), + ], + extensions: { + traceId: encoder.encode('"00000000000000000000000000000000"'), + }, + }); +} + +// ---- RPC simple ----------------------------------------------------------- +// +// Baseline lightweight RPC envelope. Small headers map, modest payload, +// routing fields. This is the shape of a typical gRPC unary call; useful as +// a lower bound on per-call overhead. + +export function buildRpcRequest(): RpcRequest { + return create(RpcRequestSchema, { + service: "example.api.v1.UserService", + method: "GetUser", + headers: { + "x-request-id": "req-00000000-0000-0000-0000-000000000000", + authorization: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", + "content-type": "application/grpc", + "grpc-accept-encoding": "identity,gzip", + }, + payload: new Uint8Array(256).fill(0xab), + requestId: 0x0123456789abcdefn, + deadlineMs: BigInt(5_000), + }); +} + +export function buildRpcResponse(): RpcResponse { + return create(RpcResponseSchema, { + requestId: 0x0123456789abcdefn, + statusCode: 0, + headers: { + "content-type": "application/grpc", + "grpc-status": "0", + "x-response-time-ms": "12", + }, + payload: new Uint8Array(512).fill(0xcd), + errorMessage: "", + }); +} + +// ---- Stress --------------------------------------------------------------- +// +// Synthetic payload with deep nesting, wide repeated fields, every scalar +// type exercised once, and a large opaque blob. Used to surface +// type-specific encoder regressions. + +export const STRESS_DEPTH = 8; +export const STRESS_ARRAY_WIDTH = 200; +export const STRESS_BLOB_SIZE = 4096; + +export function buildStressMessage( + depth: number = STRESS_DEPTH, + width: number = STRESS_ARRAY_WIDTH, + blobSize: number = STRESS_BLOB_SIZE, +): StressMessage { + const ids = new Array(width); + const tags = new Array(width); + const attrs = [] as ReturnType>[]; + for (let i = 0; i < width; i++) { + ids[i] = i; + tags[i] = `tag-${i}`; + attrs.push(create(StressKeyValueSchema, { key: `k${i}`, value: `v${i}` })); + } + const blob = "x".repeat(blobSize); + const blobBytes = new Uint8Array(blobSize).fill(0x42); + + // Build from the leaf up so we don't recurse through `create()` closures. + let current = create(StressMessageSchema, { + ids, + tags, + attrs, + blob, + blobBytes, + i32: -1, + i64: -1n, + u32: 0xffffffff >>> 0, + u64: 0xffffffffffffffffn, + s32: -2, + s64: -2n, + b: true, + f32: 3.14, + f64: Math.E, + str: "stress", + fx32: 0xdeadbeef >>> 0, + fx64: 0xcafebabedeadbeefn, + sfx32: -3, + sfx64: -3n, + }); + for (let i = 1; i < depth; i++) { + current = create(StressMessageSchema, { + child: current, + // Leave all other fields at defaults at intermediate levels to keep + // the message size growth bounded by depth rather than exploding. + }); + } + return current; +} diff --git a/benchmarks/src/index.ts b/benchmarks/src/index.ts new file mode 100644 index 000000000..c8ef1ae91 --- /dev/null +++ b/benchmarks/src/index.ts @@ -0,0 +1,59 @@ +// 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. + +// Aggregate runner. Each suite is also runnable standalone — see +// package.json scripts `bench:create`, `bench:toBinary`, etc. + +import { runCreateBench } from "./bench-create.js"; +import { runToBinaryBench } from "./bench-toBinary.js"; +import { runCreateToBinaryBench } from "./bench-create-toBinary.js"; +import { runFromJsonPathBench } from "./bench-fromJson-path.js"; +import { runFromBinaryBench } from "./bench-fromBinary.js"; +import { runComparisonBench } from "./bench-comparison-protobufjs.js"; + +async function main() { + console.log("protobuf-es benchmark suite"); + console.log( + `Node ${process.version} on ${process.platform}/${process.arch}\n`, + ); + + const create = await runCreateBench(); + console.log("\n=== create() cost ==="); + console.table(create.table()); + + const toBin = await runToBinaryBench(); + console.log("\n=== toBinary() cost on pre-built messages ==="); + console.table(toBin.table()); + + const combined = await runCreateToBinaryBench(); + console.log("\n=== create() + toBinary() combined workload ==="); + console.table(combined.table()); + + const fromBinary = await runFromBinaryBench(); + console.log("\n=== fromBinary() parsing cost ==="); + console.table(fromBinary.table()); + + const fromJson = await runFromJsonPathBench(); + console.log("\n=== fromJson / fromJsonString + toBinary paths ==="); + console.table(fromJson.table()); + + const comparison = await runComparisonBench(); + console.log("\n=== protobuf-es vs protobufjs ==="); + console.table(comparison.table()); +} + +main().catch((err) => { + console.error(err); + process.exitCode = 1; +}); diff --git a/benchmarks/src/report-helpers.ts b/benchmarks/src/report-helpers.ts new file mode 100644 index 000000000..48b4ff048 --- /dev/null +++ b/benchmarks/src/report-helpers.ts @@ -0,0 +1,399 @@ +// 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. + +// Report helpers: markdown table generator, SVG chart builder, and README +// marker-based injector. Pattern lifted from packages/bundle-size/src/util.ts +// so two reports in this repo share a look-and-feel, but the inputs differ: +// bundle-size plots bytes vs file count as line series; we plot ops/sec as +// grouped bar charts keyed on fixture, with one bar per encoder variant. +// +// SVG is produced as a raw string, no external charting dependency. We +// stay at a fixed viewBox and compute x/y positions per bar so the output +// is stable under re-runs (barring genuine benchmark variance). + +import { readFileSync, writeFileSync } from "node:fs"; + +/** + * Single benchmark measurement. One row per (fixture × encoder) pair. + * `opsPerSec` is the median throughput reported by tinybench; `bytesPerOp` + * is the encoded size divided by one (we do not currently measure heap per + * op in this report — see bench-memory.ts for that). + */ +export interface BenchmarkResult { + fixture: string; + encoder: string; + opsPerSec: number; + bytesPerOp?: number; + encodedSize: number; +} + +/** + * Encoders we plot. Order matters — it drives the legend and bar ordering + * within a fixture group. Kept small and fixed so the chart is legible; + * when a new encoder is added, extend this array and the colors map. + */ +export const ENCODERS = ["toBinary", "toBinaryFast", "protobufjs"] as const; +export type Encoder = (typeof ENCODERS)[number]; + +export const ENCODER_COLORS: Record = { + toBinary: "#8b8b8b", + toBinaryFast: "#ffa600", + protobufjs: "#347fc4", +}; + +// --- Markdown table -------------------------------------------------------- + +/** + * Format an ops/sec number the way the tinybench tables in README.md do: + * three significant digits for the common ~100..10M range, thousand + * separators on the integer part. "-" for missing (encoder not applicable + * to this fixture). + */ +function formatOps(ops: number | undefined): string { + if (ops === undefined || ops === 0 || !Number.isFinite(ops)) return "-"; + if (ops >= 1_000_000) return `${(ops / 1_000_000).toFixed(2)}M`; + if (ops >= 10_000) + return new Intl.NumberFormat("en-US").format(Math.round(ops)); + if (ops >= 1000) + return new Intl.NumberFormat("en-US").format(Math.round(ops)); + return Math.round(ops).toString(); +} + +function formatBytes(bytes: number | undefined): string { + if (bytes === undefined || bytes === 0) return "-"; + return new Intl.NumberFormat("en-US").format(bytes); +} + +/** + * Best-ratio column summary. For each fixture we pick the fastest encoder + * and report " x vs ". Useful at-a-glance + * signal: if toBinary is the winner on every row, the fast path isn't + * helping; if protobufjs always wins, we still have ground to cover. + */ +function bestEncoderRatio(row: Record) { + const entries = ENCODERS.map( + (enc) => [enc, row[enc]?.opsPerSec ?? 0] as const, + ) + .filter(([, ops]) => ops > 0) + .sort((a, b) => b[1] - a[1]); + if (entries.length < 2) return "-"; + const [winner, winnerOps] = entries[0]; + const [, runnerUpOps] = entries[1]; + const ratio = winnerOps / runnerUpOps; + return `${winner} (${ratio.toFixed(2)}x)`; +} + +/** + * Group the flat result list by fixture, pick one row per encoder. Fixtures + * keep the order they were first encountered — the matrix preserves a + * meaningful layout (simple → complex → synthetic) that we do not want to + * re-sort alphabetically. + */ +function groupByFixture(results: BenchmarkResult[]): Array<{ + fixture: string; + encodedSize: number; + perEncoder: Record; +}> { + const order: string[] = []; + const groups = new Map< + string, + { + fixture: string; + encodedSize: number; + perEncoder: Record; + } + >(); + for (const r of results) { + let g = groups.get(r.fixture); + if (!g) { + g = { + fixture: r.fixture, + encodedSize: r.encodedSize, + perEncoder: { + toBinary: undefined, + toBinaryFast: undefined, + protobufjs: undefined, + }, + }; + groups.set(r.fixture, g); + order.push(r.fixture); + } + if ((ENCODERS as readonly string[]).includes(r.encoder)) { + g.perEncoder[r.encoder as Encoder] = r; + } + // Keep the largest observed encoded size — encoders produce wire- + // identical bytes for the same input, but if a future variant ever + // diverges this prevents an accidental zero. + if (r.encodedSize > g.encodedSize) g.encodedSize = r.encodedSize; + } + return order.flatMap((n) => { + const group = groups.get(n); + return group ? [group] : []; + }); +} + +/** + * Generate a markdown table with one row per fixture and one column per + * encoder (ops/sec). Extra columns: encoded bytes and the best-encoder + * ratio. Emitted between the README markers by `injectTable`. + */ +export function generateBenchmarkMarkdownTable( + results: BenchmarkResult[], +): string { + const groups = groupByFixture(results); + const header = [ + "Fixture", + "Bytes", + "toBinary", + "toBinaryFast", + "protobufjs", + "Best", + ]; + const rows: string[][] = groups.map((g) => [ + g.fixture, + formatBytes(g.encodedSize), + formatOps(g.perEncoder.toBinary?.opsPerSec), + formatOps(g.perEncoder.toBinaryFast?.opsPerSec), + formatOps(g.perEncoder.protobufjs?.opsPerSec), + bestEncoderRatio(g.perEncoder), + ]); + + const colWidths = header.map((h, i) => + Math.max(h.length, ...rows.map((r) => r[i].length)), + ); + + // Alignment: fixture left, everything else right — ops/sec and bytes + // read better right-aligned because they are variable-width numbers. + const align = ["left", "right", "right", "right", "right", "left"] as const; + + const pad = (s: string, w: number, a: (typeof align)[number]) => + a === "left" ? s.padEnd(w) : s.padStart(w); + + const sep = (w: number, a: (typeof align)[number]) => { + if (a === "left") return "-".repeat(w); + return `${"-".repeat(w - 1)}:`; + }; + + const lines: string[] = []; + lines.push( + `| ${header.map((h, i) => pad(h, colWidths[i], align[i])).join(" | ")} |`, + ); + lines.push(`| ${colWidths.map((w, i) => sep(w, align[i])).join(" | ")} |`); + for (const r of rows) { + lines.push( + `| ${r.map((c, i) => pad(c, colWidths[i], align[i])).join(" | ")} |`, + ); + } + return `\n${lines.join("\n")}\n\n`; +} + +// --- README injector ------------------------------------------------------- + +const TABLE_START = "\n"; +const TABLE_END = ""; + +/** + * Replace the content between the `` and + * `` markers with the provided table. If the + * markers are missing the function inserts a new section right after the + * top-level title so the first run of `bench:report` on a freshly + * authored README still works. + */ +export function injectTable(filePath: string, table: string): void { + const fileContent = readFileSync(filePath, "utf-8"); + const iStart = fileContent.indexOf(TABLE_START); + const iEnd = fileContent.indexOf(TABLE_END); + if (iStart < 0 || iEnd < 0) { + // Markers missing — append a new section so the README remains the + // canonical home for the table without a manual editing step. + const section = `\n## Report output\n\n${TABLE_START}${table}${TABLE_END}\n`; + writeFileSync(filePath, fileContent + section); + return; + } + const head = fileContent.substring(0, iStart + TABLE_START.length); + const foot = fileContent.substring(iEnd); + const newContent = head + table + foot; + if (newContent !== fileContent) { + writeFileSync(filePath, newContent); + } +} + +// --- SVG chart ------------------------------------------------------------- + +/** + * Log-base-10 of an ops/sec value, clamped at 0 so a missing / zero + * measurement does not blow up the axis or produce a negative bar. The + * report spans ~100..2M ops/sec across fixtures, so a log scale is the + * only way to keep SimpleMessage and ExportTraceRequest readable on the + * same chart. + */ +function log10Ops(ops: number): number { + if (!Number.isFinite(ops) || ops <= 1) return 0; + return Math.log10(ops); +} + +/** + * Escape characters that would break out of a node or attribute + * value. Fixtures can contain `&` via future naming — keep this resilient. + */ +function escapeXml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/** + * Render a grouped bar chart to SVG as a plain string. Each fixture is a + * group on the X axis; each encoder is a colored bar within the group. The + * Y axis is log(ops/sec). Dimensions are generous enough to fit long + * fixture names rotated 35 degrees without overlapping adjacent groups. + */ +export function generateBenchmarkChart(results: BenchmarkResult[]): string { + const groups = groupByFixture(results); + const n = groups.length; + + // Layout constants. `barWidth` is per-encoder; `groupWidth` is all + // three encoders plus a gap before the next fixture group. Changing + // any of these propagates downstream — they are the only magic numbers + // in this function. + const barWidth = 20; + const groupGap = 18; + const groupWidth = ENCODERS.length * barWidth + groupGap; + const marginLeft = 90; + const marginRight = 20; + const marginTop = 60; + const marginBottom = 150; + const chartHeight = 320; + const chartWidth = n * groupWidth; + const totalWidth = marginLeft + chartWidth + marginRight; + const totalHeight = marginTop + chartHeight + marginBottom; + + // Y axis: pick the smallest decade below the fastest encoder across all + // fixtures so the tallest bar reaches ~95% of the chart area. log10 + // grid lines every decade. + const maxOps = Math.max( + 1, + ...results.map((r) => (Number.isFinite(r.opsPerSec) ? r.opsPerSec : 0)), + ); + const yMaxLog = Math.ceil(log10Ops(maxOps)); + const yMinLog = 1; // 10 ops/sec floor — anything slower is not in scope. + const yRange = yMaxLog - yMinLog; + + const yToPixel = (opsLog: number) => { + const clamped = Math.max(yMinLog, Math.min(yMaxLog, opsLog)); + return ( + marginTop + chartHeight - ((clamped - yMinLog) / yRange) * chartHeight + ); + }; + + const parts: string[] = []; + parts.push( + `\n` + + `\n` + + ` \n`, + ); + + // Title + Y axis label (rotated so it runs vertically along the axis). + parts.push( + ` ` + + `Encoder throughput by fixture (ops/sec, log scale)` + + `\n`, + ); + parts.push( + ` \n` + + ` ops/sec (log10)\n` + + ` \n`, + ); + + // Y axis line + decade grid lines + labels. + parts.push( + ` \n`, + ); + for (let tick = yMinLog; tick <= yMaxLog; tick++) { + const y = yToPixel(tick); + const value = 10 ** tick; + const label = + value >= 1_000_000 + ? `${value / 1_000_000}M` + : value >= 1000 + ? `${value / 1000}K` + : `${value}`; + parts.push( + ` \n` + + ` ${label}\n`, + ); + } + + // X axis line. + parts.push( + ` \n`, + ); + + // Bars + fixture labels (rotated 35 degrees to keep long names legible). + for (let i = 0; i < groups.length; i++) { + const g = groups[i]; + const groupX = marginLeft + i * groupWidth + groupGap / 2; + for (let j = 0; j < ENCODERS.length; j++) { + const enc = ENCODERS[j]; + const r = g.perEncoder[enc]; + if (!r || !Number.isFinite(r.opsPerSec) || r.opsPerSec <= 0) continue; + const barX = groupX + j * barWidth; + const barTop = yToPixel(log10Ops(r.opsPerSec)); + const barH = marginTop + chartHeight - barTop; + const color = ENCODER_COLORS[enc]; + parts.push( + ` \n` + + ` ${escapeXml(enc)}: ${Math.round(r.opsPerSec).toLocaleString("en-US")} ops/sec (${g.fixture})\n` + + ` \n`, + ); + } + // Fixture label rotated to avoid clipping against neighbours. + const labelX = groupX + (ENCODERS.length * barWidth) / 2; + const labelY = marginTop + chartHeight + 12; + parts.push( + ` \n` + + ` ${escapeXml(g.fixture)}\n` + + ` \n`, + ); + } + + // Legend: a row of (swatch, label) pairs placed horizontally near the + // top of the chart area. Kept on one line — three encoders fit. + const legendY = marginTop - 24; + let legendX = marginLeft; + parts.push(` \n`); + for (const enc of ENCODERS) { + const color = ENCODER_COLORS[enc]; + parts.push( + ` \n` + + ` ${enc}\n`, + ); + legendX += 18 + enc.length * 7 + 16; + } + parts.push(` \n`); + + parts.push(`\n`); + return parts.join(""); +} diff --git a/benchmarks/src/report.ts b/benchmarks/src/report.ts new file mode 100644 index 000000000..4badd37b7 --- /dev/null +++ b/benchmarks/src/report.ts @@ -0,0 +1,324 @@ +// 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. + +// Benchmark report generator. +// +// Runs a multi-encoder matrix (toBinary, toBinaryFast, protobufjs-where- +// generated) across the fixture set exposed by bench-matrix.ts, then emits: +// +// 1. bench-results.json — machine-readable raw data for CI diffing. +// 2. chart.svg — grouped-bar SVG chart (log ops/sec per fixture). +// 3. README.md — markdown table injected between the +// markers. +// +// Inspired by the packages/bundle-size/src/report.ts pattern: read the raw +// stats, pass them through table+chart generators, write files next to the +// README. Unlike bundle-size which bundles TypeScript via esbuild on every +// run, we run tinybench — so this script takes tens of seconds even at the +// reduced 600ms-per-case setting below. +// +// Usage: +// npm run bench:report -w @bufbuild/protobuf-benchmarks +// +// To re-render from an existing bench-results.json without re-running the +// benchmark (useful for iterating on the chart layout), set +// `BENCH_REPORT_READ_ONLY=1`. + +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { Bench } from "tinybench"; +import { toBinary, toBinaryFast } from "@bufbuild/protobuf"; + +import { SimpleMessageSchema } from "./gen/small_pb.js"; +import { ExportTraceRequestSchema } from "./gen/nested_pb.js"; +import { ExportMetricsRequestSchema } from "./gen/otel-metrics_pb.js"; +import { ExportLogsRequestSchema } from "./gen/otel-logs_pb.js"; +import { K8sPodListSchema } from "./gen/k8s-pod_pb.js"; +import { + GraphQLRequestSchema, + GraphQLResponseSchema, +} from "./gen/graphql_pb.js"; +import { RpcRequestSchema, RpcResponseSchema } from "./gen/rpc-simple_pb.js"; +import { StressMessageSchema } from "./gen/stress_pb.js"; + +import { + buildSmallMessage, + buildExportTraceRequest, + buildExportMetricsRequest, + buildExportLogsRequest, + buildK8sPodList, + buildGraphQLRequest, + buildGraphQLResponse, + buildRpcRequest, + buildRpcResponse, + buildStressMessage, + SPAN_COUNT, + METRICS_SERIES_COUNT, + LOGS_RECORD_COUNT, + K8S_POD_COUNT, + STRESS_DEPTH, + STRESS_ARRAY_WIDTH, +} from "./fixtures.js"; + +import { + type BenchmarkResult, + generateBenchmarkChart, + generateBenchmarkMarkdownTable, + injectTable, +} from "./report-helpers.js"; + +// biome-ignore lint/suspicious/noExplicitAny: matrix dispatch is loose by design +type AnySchema = any; +// biome-ignore lint/suspicious/noExplicitAny: matrix dispatch is loose by design +type AnyMsg = any; + +interface FixtureCase { + name: string; + schema: AnySchema; + build: () => AnyMsg; +} + +const cases: FixtureCase[] = [ + { + name: "SimpleMessage", + schema: SimpleMessageSchema, + build: buildSmallMessage, + }, + { + name: `ExportTraceRequest (${SPAN_COUNT} spans)`, + schema: ExportTraceRequestSchema, + build: buildExportTraceRequest, + }, + { + name: `ExportMetricsRequest (${METRICS_SERIES_COUNT} series)`, + schema: ExportMetricsRequestSchema, + build: buildExportMetricsRequest, + }, + { + name: `ExportLogsRequest (${LOGS_RECORD_COUNT} records)`, + schema: ExportLogsRequestSchema, + build: buildExportLogsRequest, + }, + { + name: `K8sPodList (${K8S_POD_COUNT} pods)`, + schema: K8sPodListSchema, + build: buildK8sPodList, + }, + { + name: "GraphQLRequest", + schema: GraphQLRequestSchema, + build: buildGraphQLRequest, + }, + { + name: "GraphQLResponse", + schema: GraphQLResponseSchema, + build: buildGraphQLResponse, + }, + { + name: "RpcRequest", + schema: RpcRequestSchema, + build: buildRpcRequest, + }, + { + name: "RpcResponse", + schema: RpcResponseSchema, + build: buildRpcResponse, + }, + { + name: `StressMessage (depth=${STRESS_DEPTH}, width=${STRESS_ARRAY_WIDTH})`, + schema: StressMessageSchema, + build: buildStressMessage, + }, +]; + +// protobufjs is only generated for nested.proto (the OTel traces shape), +// via the `generate:protobufjs` script. We still want it on the chart +// because it is the external baseline referenced in #6221; other fixtures +// simply leave the protobufjs bar missing, which the chart + table handle. +interface PbjsCtor { + create(properties: Record): Record; + encode(message: Record): { finish(): Uint8Array }; +} + +function loadPbjsExportTraceRequest(): PbjsCtor | null { + try { + const require = createRequire(import.meta.url); + // biome-ignore lint/suspicious/noExplicitAny: generated pbjs has dynamic shape + const mod = require("./gen-protobufjs/nested.cjs") as any; + return mod.bench.v1.ExportTraceRequest as PbjsCtor; + } catch { + // Missing codegen is non-fatal for the report — we just skip the + // protobufjs bar. Running `npm run generate:protobufjs` remedies this. + return null; + } +} + +/** + * Construct a plain-JS init object for protobufjs's `ExportTraceRequest`. + * Mirrors the init shape used in bench-comparison-protobufjs.ts, because + * protobufjs accepts oneof fields on the parent message directly rather + * than via the `{ case, value }` ADT protobuf-es uses — passing a + * protobuf-es init object into pbjs silently produces an empty encode. + */ +function buildPbjsOtelInit(): Record { + const spans: unknown[] = []; + for (let i = 0; i < SPAN_COUNT; i++) { + const attributes: unknown[] = []; + for (let j = 0; j < 10; j++) { + let anyValue: Record; + if (j === 2 || j === 5) { + anyValue = { intValue: (200 + j).toString() }; + } else if (j === 8) { + anyValue = { boolValue: (i + j) % 7 === 0 }; + } else { + anyValue = { stringValue: `v${i}-${j}` }; + } + attributes.push({ key: `k${j}`, value: anyValue }); + } + spans.push({ + traceId: new Uint8Array(16), + spanId: new Uint8Array(8), + name: `span-${i}`, + startTimeUnixNano: "1700000000000000000", + endTimeUnixNano: "1700000000000001000", + attributes, + }); + } + return { + resourceSpans: [ + { + resource: { + attributes: [], + labels: { + env: "production", + region: "us-east-1", + cluster: "bench-cluster", + }, + }, + scopeSpans: [ + { + scope: { name: "@example/tracer", version: "1.0.0" }, + spans, + }, + ], + }, + ], + }; +} + +/** + * Run the matrix and return flat per-(fixture × encoder) rows. + * + * tinybench `time`/`warmupTime` here are intentionally tighter than the + * main bench-matrix.ts (1000ms / 200ms) because the report runs 2–3x as + * many cases as the matrix and we want it to finish in a single + * development cycle. The noise floor is correspondingly higher; consumers + * of the raw numbers should use bench-matrix.ts, not the report file. + */ +async function runReportBench(): Promise { + const bench = new Bench({ time: 600, warmupTime: 150 }); + + const prepared = cases.map((c) => { + const msg = c.build(); + const bytes = toBinary(c.schema, msg); + return { ...c, msg, bytes }; + }); + + // protobuf-es encoders. We never change the schema/message references + // inside the benchmark function body — that would pull allocation cost + // into the measurement. Everything is captured in the closure once. + for (const p of prepared) { + bench.add(`${p.name} :: toBinary`, () => { + toBinary(p.schema, p.msg); + }); + bench.add(`${p.name} :: toBinaryFast`, () => { + toBinaryFast(p.schema, p.msg); + }); + } + + // protobufjs is only available for the OTel traces fixture. If the + // CommonJS module is missing we quietly skip, which is handled + // gracefully in the report output (table: "-", chart: no bar). + const pbjs = loadPbjsExportTraceRequest(); + const pbjsFixtureName = `ExportTraceRequest (${SPAN_COUNT} spans)`; + if (pbjs) { + const init = buildPbjsOtelInit(); + const preBuilt = pbjs.create(init); + bench.add(`${pbjsFixtureName} :: protobufjs`, () => { + pbjs.encode(preBuilt).finish(); + }); + } + + await bench.run(); + + // Flatten tinybench tasks into the BenchmarkResult shape. Task names + // use the " :: " separator we constructed above — any future formatting + // change must stay in sync here, which is why the split is load-bearing. + const results: BenchmarkResult[] = []; + for (const task of bench.tasks) { + const separator = " :: "; + const sepIdx = task.name.lastIndexOf(separator); + if (sepIdx < 0) continue; + const fixture = task.name.substring(0, sepIdx); + const encoder = task.name.substring(sepIdx + separator.length); + const prep = prepared.find((p) => p.name === fixture); + const encodedSize = prep ? prep.bytes.length : 0; + results.push({ + fixture, + encoder, + opsPerSec: task.result?.hz ?? 0, + encodedSize, + }); + } + return results; +} + +// --- main ------------------------------------------------------------------ + +const outDir = new URL("../", import.meta.url).pathname; +const resultsPath = `${outDir}bench-results.json`; +const chartPath = `${outDir}chart.svg`; +const readmePath = `${outDir}README.md`; + +let results: BenchmarkResult[]; +if (process.env.BENCH_REPORT_READ_ONLY === "1" && existsSync(resultsPath)) { + // Re-render mode: useful while iterating on chart / table layout so the + // author does not pay the ~30s benchmark cost for each rendering tweak. + const raw = JSON.parse(readFileSync(resultsPath, "utf-8")) as { + results: BenchmarkResult[]; + }; + results = raw.results; + console.log(`Loaded ${results.length} results from ${resultsPath}`); +} else { + console.log("Running benchmark matrix for report (this takes ~30s)..."); + results = await runReportBench(); + const payload = { + node: process.version, + platform: `${process.platform}/${process.arch}`, + timestamp: new Date().toISOString(), + results, + }; + writeFileSync(resultsPath, `${JSON.stringify(payload, null, 2)}\n`); + console.log(`Wrote ${resultsPath}`); +} + +// Build outputs. The chart and table see identical inputs, so any +// divergence between them is a layout bug in one of the generators. +const table = generateBenchmarkMarkdownTable(results); +injectTable(readmePath, table); +console.log(`Injected table into ${readmePath}`); + +const chart = generateBenchmarkChart(results); +writeFileSync(chartPath, chart); +console.log(`Wrote ${chartPath}`); diff --git a/benchmarks/src/verify-correctness.ts b/benchmarks/src/verify-correctness.ts new file mode 100644 index 000000000..78287b56a --- /dev/null +++ b/benchmarks/src/verify-correctness.ts @@ -0,0 +1,66 @@ +// 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. + +// Correctness check for the experimental `toBinaryFast` encoder. +// +// We don't claim byte-identical output against `toBinary` — repeated +// scalar ordering and presence-zero handling could legitimately differ +// on future descriptors. The load-bearing claim is *semantic* round-trip +// equivalence: decoding either encoding produces structurally-equal +// messages. This file exercises that on the OTel-shaped fixture used by +// the benchmarks. + +import assert from "node:assert/strict"; +import { toBinary, toBinaryFast, fromBinary } from "@bufbuild/protobuf"; +import { ExportTraceRequestSchema } from "./gen/nested_pb.js"; +import { SimpleMessageSchema } from "./gen/small_pb.js"; +import { buildExportTraceRequest, buildSmallMessage } from "./fixtures.js"; + +function summarize(label: string, slow: Uint8Array, fast: Uint8Array): void { + const byteMatch = + slow.length === fast.length && slow.every((b, i) => b === fast[i]); + console.log( + `[${label}] slow=${slow.length}B fast=${fast.length}B bytesIdentical=${byteMatch}`, + ); +} + +// OTel-shaped ExportTraceRequest with 100 spans. +{ + const msg = buildExportTraceRequest(); + const slow = toBinary(ExportTraceRequestSchema, msg); + const fast = toBinaryFast(ExportTraceRequestSchema, msg); + + // Decode both — require structural equality of the resulting messages. + const decodedSlow = fromBinary(ExportTraceRequestSchema, slow); + const decodedFast = fromBinary(ExportTraceRequestSchema, fast); + assert.deepStrictEqual( + decodedFast, + decodedSlow, + "toBinaryFast produced a payload that decodes differently than toBinary", + ); + summarize("ExportTraceRequest", slow, fast); +} + +// SimpleMessage (scalars only): ensures the flat-scalar path works. +{ + const msg = buildSmallMessage(); + const slow = toBinary(SimpleMessageSchema, msg); + const fast = toBinaryFast(SimpleMessageSchema, msg); + const decodedSlow = fromBinary(SimpleMessageSchema, slow); + const decodedFast = fromBinary(SimpleMessageSchema, fast); + assert.deepStrictEqual(decodedFast, decodedSlow); + summarize("SimpleMessage", slow, fast); +} + +console.log("\nOK — semantic round-trip verified for all fixtures"); diff --git a/benchmarks/tsconfig.json b/benchmarks/tsconfig.json new file mode 100644 index 000000000..81bb00496 --- /dev/null +++ b/benchmarks/tsconfig.json @@ -0,0 +1,14 @@ +{ + "include": ["src/**/*.ts"], + "exclude": ["src/gen", "src/gen-protobufjs"], + "compilerOptions": { + "target": "es2022", + "moduleResolution": "Node10", + "module": "ES2022", + "strict": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true + } +} diff --git a/benchmarks/turbo.json b/benchmarks/turbo.json new file mode 100644 index 000000000..133a7e8cf --- /dev/null +++ b/benchmarks/turbo.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "dependsOn": ["^build", "generate"], + "outputs": [], + "outputLogs": "new-only" + } + } +} diff --git a/package-lock.json b/package-lock.json index 8e4fcadb1..9b8c175ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "packages/protobuf-example", "packages/upstream-protobuf", "packages/typescript-compat/*", + "benchmarks", "deno/conformance", "bun/conformance" ], @@ -34,6 +35,21 @@ "npm": ">=10" } }, + "benchmarks": { + "name": "@bufbuild/protobuf-benchmarks", + "version": "2.11.0", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/buf": "^1.66.1", + "@bufbuild/protobuf": "2.11.0", + "@bufbuild/protoc-gen-es": "2.11.0", + "protobufjs": "^7.5.5", + "protobufjs-cli": "^1.1.3", + "tinybench": "^4.0.1", + "tsx": "^4.21.0", + "typescript": "^5.6.3" + } + }, "bun/conformance": { "name": "@bufbuild/bun-conformance", "devDependencies": { @@ -125,6 +141,52 @@ "node": ">=14.17" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@biomejs/biome": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", @@ -461,6 +523,10 @@ "resolved": "packages/protobuf", "link": true }, + "node_modules/@bufbuild/protobuf-benchmarks": { + "resolved": "benchmarks", + "link": true + }, "node_modules/@bufbuild/protobuf-conformance": { "resolved": "packages/protobuf-conformance", "link": true @@ -916,6 +982,18 @@ "node": ">=18" } }, + "node_modules/@jsdoc/salty": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@jsdoc/salty/-/salty-0.2.12.tgz", + "integrity": "sha512-TuB0x50EoAvEX/UEWITd8Mkn3WhiTjSvbTMCLj0BhsQEl5iUzjXdA0bETEVpTk+5TGTLR6QktI9H4hLviVeaAQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.18.1" + }, + "engines": { + "node": ">=v12.0.0" + } + }, "node_modules/@loaderkit/resolve": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@loaderkit/resolve/-/resolve-1.0.4.tgz", @@ -926,6 +1004,70 @@ "@braidai/lang": "^1.0.0" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -954,6 +1096,28 @@ "@types/node": "*" } }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.0.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz", @@ -1017,6 +1181,15 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/acorn-node": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", @@ -1086,7 +1259,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -1105,6 +1277,12 @@ "dev": true, "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/asn1.js": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", @@ -1198,6 +1376,12 @@ "platform": "^1.3.3" } }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" + }, "node_modules/bn.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz", @@ -1539,11 +1723,22 @@ "upper-case": "^1.1.1" } }, + "node_modules/catharsis": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/catharsis/-/catharsis-0.9.0.tgz", + "integrity": "sha512-prMTQVpcns/tzFgFVkVp6ak6RykZyWb3gu8ckUpd6YkTlacOd3DXGJjIpD4Q6zJirizvaiAjSSHlOsA+6sNh2A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -1650,7 +1845,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -1663,7 +1857,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combine-source-map": { @@ -2028,6 +2221,12 @@ } } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "license": "MIT" + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -2256,6 +2455,18 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-string": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/env-string/-/env-string-1.0.1.tgz", @@ -2368,12 +2579,125 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", + "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=4.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/escodegen/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-is-member-expression": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/estree-is-member-expression/-/estree-is-member-expression-1.0.0.tgz", "integrity": "sha512-Ec+X44CapIGExvSZN+pGkmr5p7HwUVQoPQSd458Lqwvaf4/61k/invHSh4BYK8OXnCkfEhWuIoG5hayKLQStIg==", "license": "Apache-2.0" }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -2408,6 +2732,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "license": "MIT" + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -2638,7 +2968,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3017,6 +3346,65 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/js2xmlparser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/js2xmlparser/-/js2xmlparser-4.0.2.tgz", + "integrity": "sha512-6n4D8gLlLf1n5mNLQPRfViYzu9RATblzPEtm1SthMX1Pjao0r9YI9nw7ZIfRxQMERS87mcswrg+r/OYrPRX6jA==", + "license": "Apache-2.0", + "dependencies": { + "xmlcreate": "^2.0.4" + } + }, + "node_modules/jsdoc": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-4.0.5.tgz", + "integrity": "sha512-P4C6MWP9yIlMiK8nwoZvxN84vb6MsnXcHuy7XzVOvQoCizWX5JFCBsWIIWKXBltpoRZXddUOVQmCTOZt9yDj9g==", + "license": "Apache-2.0", + "dependencies": { + "@babel/parser": "^7.20.15", + "@jsdoc/salty": "^0.2.1", + "@types/markdown-it": "^14.1.1", + "bluebird": "^3.7.2", + "catharsis": "^0.9.0", + "escape-string-regexp": "^2.0.0", + "js2xmlparser": "^4.0.2", + "klaw": "^3.0.0", + "markdown-it": "^14.1.0", + "markdown-it-anchor": "^8.6.7", + "marked": "^4.0.10", + "mkdirp": "^1.0.4", + "requizzle": "^0.2.3", + "strip-json-comments": "^3.1.0", + "underscore": "~1.13.2" + }, + "bin": { + "jsdoc": "jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/jsdoc/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jsdoc/node_modules/marked": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", + "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -3087,6 +3475,15 @@ "readable-stream": "2 || 3" } }, + "node_modules/klaw": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", + "integrity": "sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.9" + } + }, "node_modules/labeled-stream-splicer": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/labeled-stream-splicer/-/labeled-stream-splicer-2.0.2.tgz", @@ -3097,10 +3494,32 @@ "stream-splicer": "^2.0.0" } }, + "node_modules/levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "license": "MIT", + "dependencies": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.memoize": { @@ -3164,6 +3583,33 @@ "semver": "bin/semver.js" } }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it-anchor": { + "version": "8.6.7", + "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-8.6.7.tgz", + "integrity": "sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==", + "license": "Unlicense", + "peerDependencies": { + "@types/markdown-it": "*", + "markdown-it": "*" + } + }, "node_modules/marked": { "version": "9.1.6", "resolved": "https://registry.npmjs.org/marked/-/marked-9.1.6.tgz", @@ -3232,6 +3678,12 @@ "safe-buffer": "^5.1.2" } }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, "node_modules/merge-source-map": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/merge-source-map/-/merge-source-map-1.0.4.tgz", @@ -3293,6 +3745,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -3631,6 +4095,23 @@ "node": ">=4" } }, + "node_modules/optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "license": "MIT", + "dependencies": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/os-browserify": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", @@ -3767,6 +4248,14 @@ "node": ">= 0.4" } }, + "node_modules/prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/pretty-hrtime": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", @@ -3805,6 +4294,99 @@ "node": ">=20.0.0" } }, + "node_modules/protobufjs": { + "version": "7.5.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz", + "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/protobufjs-cli": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/protobufjs-cli/-/protobufjs-cli-1.2.0.tgz", + "integrity": "sha512-+YvqJEmsmZHGzE5j0tvEzFeHm0sX7pzRFpyj7+GazhkS4Y0r+jgbioVvFxxSWIlPzUel/lxeOnLChBmV8NmyHA==", + "license": "BSD-3-Clause", + "dependencies": { + "chalk": "^4.0.0", + "escodegen": "^1.13.0", + "espree": "^9.0.0", + "estraverse": "^5.1.0", + "glob": "^8.0.0", + "jsdoc": "^4.0.0", + "minimist": "^1.2.0", + "semver": "^7.1.2", + "tmp": "^0.2.1", + "uglify-js": "^3.7.7" + }, + "bin": { + "pbjs": "bin/pbjs", + "pbts": "bin/pbts" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "protobufjs": "^7.0.0" + } + }, + "node_modules/protobufjs-cli/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/protobufjs-cli/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/protobufjs-cli/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/protoc": { "version": "33.2.0", "resolved": "https://registry.npmjs.org/protoc/-/protoc-33.2.0.tgz", @@ -3864,6 +4446,15 @@ "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", "license": "MIT" }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", @@ -3970,6 +4561,15 @@ "node": ">=0.10.0" } }, + "node_modules/requizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/requizzle/-/requizzle-0.2.4.tgz", + "integrity": "sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -4446,6 +5046,18 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/subarg": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/subarg/-/subarg-1.0.0.tgz", @@ -4459,7 +5071,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -4575,6 +5186,24 @@ "node": ">=0.6.0" } }, + "node_modules/tinybench": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-4.1.0.tgz", + "integrity": "sha512-8JZoQRJgWWEIIeAmpiNmMHIREmUY3oGX8GRmlmNapLr/qtgMe+K76vM2qabh85hNScnE2lqTVTajVETjuD9Ixg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-buffer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", @@ -4806,6 +5435,18 @@ "win32" ] }, + "node_modules/type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", + "license": "MIT", + "dependencies": { + "prelude-ls": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-component": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/type-component/-/type-component-0.0.1.tgz", @@ -4844,6 +5485,24 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "license": "BSD-2-Clause", + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/umd": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/umd/-/umd-3.0.3.tgz", @@ -4869,6 +5528,12 @@ "undeclared-identifiers": "bin.js" } }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -5000,6 +5665,15 @@ "integrity": "sha512-uf7tHf+tw0B1y+x+mKTLHkykBgK2KMs3g+KlzmyMbLvICSHQyB/xOFjTT8qZ3oeTFyU7Bbj4FzXitGG6jvKhYw==", "license": "BSD-2-Clause" }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -5024,6 +5698,12 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xmlcreate": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/xmlcreate/-/xmlcreate-2.0.4.tgz", + "integrity": "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==", + "license": "Apache-2.0" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 8f8ed42b1..c3374f286 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "packages/protobuf-example", "packages/upstream-protobuf", "packages/typescript-compat/*", + "benchmarks", "deno/conformance", "bun/conformance" ], diff --git a/packages/protobuf-test/src/to-binary-fast.test.ts b/packages/protobuf-test/src/to-binary-fast.test.ts new file mode 100644 index 000000000..9bb48630e --- /dev/null +++ b/packages/protobuf-test/src/to-binary-fast.test.ts @@ -0,0 +1,254 @@ +// 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. + +// Feature-coverage tests for the experimental `toBinaryFast` encoder. +// The load-bearing claim is byte-identical output against the reflective +// `toBinary` for the feature surfaces the fast path claims to handle — +// maps (every legal K, every legal V) and oneofs (scalar, message, enum). +// Semantic round-trip is also asserted as a defense-in-depth check. + +import { suite, test } from "node:test"; +import * as assert from "node:assert"; +import { + create, + toBinary, + toBinaryFast, + fromBinary, + protoInt64, +} from "@bufbuild/protobuf"; +import { MapsMessageSchema, MapsEnum } from "./gen/ts/extra/msg-maps_pb.js"; +import { + OneofMessageSchema, + OneofMessageFooSchema, + OneofMessageBarSchema, + OneofEnum, +} from "./gen/ts/extra/msg-oneof_pb.js"; +import { ScalarValuesMessageSchema } from "./gen/ts/extra/msg-scalar_pb.js"; + +void suite("toBinaryFast", () => { + void suite("map field parity", () => { + test("map with scalar/bytes values", () => { + const msg = create(MapsMessageSchema, { + strStrField: { a: "alpha", b: "beta", c: "gamma" }, + strInt32Field: { a: 1, b: -2, c: 0x7fff_ffff }, + strInt64Field: { + a: protoInt64.parse(1), + // Literal `n` requires ES2020; this package is compiled for ES2017. + b: protoInt64.parse(BigInt("-9007199254740993")), + }, + strBoolField: { true_key: true, false_key: false }, + strBytesField: { + a: new Uint8Array([0, 1, 2, 3]), + b: new Uint8Array([0xff, 0xfe]), + }, + }); + const slow = toBinary(MapsMessageSchema, msg); + const fast = toBinaryFast(MapsMessageSchema, msg); + assert.deepStrictEqual( + Array.from(fast), + Array.from(slow), + "byte-identical expected for string-keyed maps", + ); + assert.deepStrictEqual( + fromBinary(MapsMessageSchema, fast), + fromBinary(MapsMessageSchema, slow), + ); + }); + + test("map and map keys parse and encode", () => { + const msg = create(MapsMessageSchema, { + int32StrField: { 1: "one", [-2]: "neg-two", 100: "hundred" }, + int64StrField: { + "1": "one", + "-2": "neg-two", + "9007199254740993": "big", + }, + }); + const slow = toBinary(MapsMessageSchema, msg); + const fast = toBinaryFast(MapsMessageSchema, msg); + // Byte-identical requires same field ordering and same map iteration + // order. Both encoders iterate descriptor order + Object.keys order, + // so parity should hold. + assert.deepStrictEqual(Array.from(fast), Array.from(slow)); + assert.deepStrictEqual( + fromBinary(MapsMessageSchema, fast), + fromBinary(MapsMessageSchema, slow), + ); + }); + + test("map", () => { + const msg = create(MapsMessageSchema, { + boolStrField: { true: "yes", false: "no" }, + }); + const slow = toBinary(MapsMessageSchema, msg); + const fast = toBinaryFast(MapsMessageSchema, msg); + assert.deepStrictEqual(Array.from(fast), Array.from(slow)); + }); + + test("map<*,message> encodes the value submessage", () => { + const inner = create(MapsMessageSchema, { + strStrField: { nested: "ok" }, + }); + const msg = create(MapsMessageSchema, { + strMsgField: { first: inner, second: inner }, + int32MsgField: { 1: inner, 2: inner }, + }); + const slow = toBinary(MapsMessageSchema, msg); + const fast = toBinaryFast(MapsMessageSchema, msg); + assert.deepStrictEqual(Array.from(fast), Array.from(slow)); + assert.deepStrictEqual( + fromBinary(MapsMessageSchema, fast), + fromBinary(MapsMessageSchema, slow), + ); + }); + + test("map<*,enum>", () => { + const msg = create(MapsMessageSchema, { + strEnuField: { a: MapsEnum.YES, b: MapsEnum.NO }, + int32EnuField: { 1: MapsEnum.YES, 2: MapsEnum.NO }, + }); + const slow = toBinary(MapsMessageSchema, msg); + const fast = toBinaryFast(MapsMessageSchema, msg); + assert.deepStrictEqual(Array.from(fast), Array.from(slow)); + }); + + test("empty maps do not emit anything", () => { + const msg = create(MapsMessageSchema, {}); + const slow = toBinary(MapsMessageSchema, msg); + const fast = toBinaryFast(MapsMessageSchema, msg); + assert.strictEqual(fast.length, 0); + assert.deepStrictEqual(Array.from(fast), Array.from(slow)); + }); + }); + + void suite("oneof parity", () => { + test("scalar oneof — int value case", () => { + const msg = create(OneofMessageSchema, { + scalar: { case: "value", value: 42 }, + }); + const slow = toBinary(OneofMessageSchema, msg); + const fast = toBinaryFast(OneofMessageSchema, msg); + assert.deepStrictEqual(Array.from(fast), Array.from(slow)); + }); + + test("scalar oneof — zero value must still be emitted", () => { + // Oneof presence is carried by the discriminator, so a `value: 0` + // case is *still* considered set. This is the tricky corner that + // the fast-path oneof dispatch has to get right (a non-oneof + // IMPLICIT int with value 0 would be omitted). + const msg = create(OneofMessageSchema, { + scalar: { case: "value", value: 0 }, + }); + const slow = toBinary(OneofMessageSchema, msg); + const fast = toBinaryFast(OneofMessageSchema, msg); + assert.deepStrictEqual(Array.from(fast), Array.from(slow)); + assert.ok(fast.length > 0, "expected tag+value for zero-valued oneof"); + }); + + test("scalar oneof — string case with empty string", () => { + const msg = create(OneofMessageSchema, { + scalar: { case: "error", value: "" }, + }); + const slow = toBinary(OneofMessageSchema, msg); + const fast = toBinaryFast(OneofMessageSchema, msg); + assert.deepStrictEqual(Array.from(fast), Array.from(slow)); + }); + + test("scalar oneof — bytes case", () => { + const msg = create(OneofMessageSchema, { + scalar: { case: "bytes", value: new Uint8Array([1, 2, 3, 255]) }, + }); + const slow = toBinary(OneofMessageSchema, msg); + const fast = toBinaryFast(OneofMessageSchema, msg); + assert.deepStrictEqual(Array.from(fast), Array.from(slow)); + }); + + test("message oneof — foo case", () => { + const foo = create(OneofMessageFooSchema, { + name: "hello", + toggle: true, + }); + const msg = create(OneofMessageSchema, { + message: { case: "foo", value: foo }, + }); + const slow = toBinary(OneofMessageSchema, msg); + const fast = toBinaryFast(OneofMessageSchema, msg); + assert.deepStrictEqual(Array.from(fast), Array.from(slow)); + }); + + test("message oneof — bar case", () => { + const bar = create(OneofMessageBarSchema, { a: 3, b: 4 }); + const msg = create(OneofMessageSchema, { + message: { case: "bar", value: bar }, + }); + const slow = toBinary(OneofMessageSchema, msg); + const fast = toBinaryFast(OneofMessageSchema, msg); + assert.deepStrictEqual(Array.from(fast), Array.from(slow)); + }); + + test("enum oneof", () => { + const msg = create(OneofMessageSchema, { + enum: { case: "e", value: OneofEnum.A }, + }); + const slow = toBinary(OneofMessageSchema, msg); + const fast = toBinaryFast(OneofMessageSchema, msg); + assert.deepStrictEqual(Array.from(fast), Array.from(slow)); + }); + + test("multiple oneof groups each contribute their selected case", () => { + const foo = create(OneofMessageFooSchema, { name: "n", toggle: false }); + const msg = create(OneofMessageSchema, { + scalar: { case: "value", value: 7 }, + message: { case: "foo", value: foo }, + enum: { case: "e", value: OneofEnum.B }, + }); + const slow = toBinary(OneofMessageSchema, msg); + const fast = toBinaryFast(OneofMessageSchema, msg); + assert.deepStrictEqual(Array.from(fast), Array.from(slow)); + }); + + test("empty oneofs emit nothing", () => { + const msg = create(OneofMessageSchema, {}); + const slow = toBinary(OneofMessageSchema, msg); + const fast = toBinaryFast(OneofMessageSchema, msg); + assert.strictEqual(fast.length, 0); + assert.deepStrictEqual(Array.from(fast), Array.from(slow)); + }); + }); + + void suite("regression — scalars still match", () => { + test("ScalarValuesMessage parity", () => { + const msg = create(ScalarValuesMessageSchema, { + doubleField: 0.75, + floatField: -0.75, + int64Field: protoInt64.parse(-1), + uint64Field: protoInt64.uParse(1), + int32Field: -123, + fixed64Field: protoInt64.uParse(1), + fixed32Field: 123, + boolField: true, + stringField: "hello world", + bytesField: new Uint8Array([1, 2, 3]), + uint32Field: 42, + sfixed32Field: -42, + sfixed64Field: protoInt64.parse(-42), + sint32Field: -42, + sint64Field: protoInt64.parse(-42), + }); + const slow = toBinary(ScalarValuesMessageSchema, msg); + const fast = toBinaryFast(ScalarValuesMessageSchema, msg); + assert.deepStrictEqual(Array.from(fast), Array.from(slow)); + }); + }); +}); diff --git a/packages/protobuf/src/index.ts b/packages/protobuf/src/index.ts index 433b3bf7c..f984d3f69 100644 --- a/packages/protobuf/src/index.ts +++ b/packages/protobuf/src/index.ts @@ -23,6 +23,7 @@ export * from "./registry.js"; export type { JsonValue, JsonObject } from "./json-value.js"; export { toBinary } from "./to-binary.js"; export type { BinaryWriteOptions } from "./to-binary.js"; +export { toBinaryFast } from "./to-binary-fast.js"; export { fromBinary, mergeFromBinary } from "./from-binary.js"; export type { BinaryReadOptions } from "./from-binary.js"; export * from "./to-json.js"; diff --git a/packages/protobuf/src/to-binary-fast.ts b/packages/protobuf/src/to-binary-fast.ts new file mode 100644 index 000000000..9709e5c48 --- /dev/null +++ b/packages/protobuf/src/to-binary-fast.ts @@ -0,0 +1,980 @@ +// 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. + +// Experimental opt-in fast-path encoder. +// +// Pattern adapted from open-telemetry/opentelemetry-js#6390 (the +// ProtobufLogsSerializer in @opentelemetry/otlp-transformer), ported to +// protobuf-es' reflective encode. The existing BinaryWriter relies on +// fork/join per length-delimited field — every nested message and every +// packed repeated field pushes its accumulator onto a stack, serializes +// into its own list of chunks, then re-emits the length prefix and +// concatenates. On OTel-shaped workloads (deeply nested ResourceSpans → +// ScopeSpans → Span → KeyValue) that produces a lot of small allocations +// and a double copy on `finish()`. +// +// `toBinaryFast` instead makes two passes: +// 1) estimate the exact encoded size of every field by walking the +// message graph and accumulating bytes-needed; +// 2) allocate a single Uint8Array of that size and write bytes into it +// at fixed offsets. +// +// Because the estimate is exact, the write pass never reallocates, never +// copies, and never needs to stack fork/join state. Length prefixes are +// computed during pass 1 and cached so that pass 2 can write the varint +// before it descends into the submessage. The entire hot path lives in a +// single tight loop with no intermediate `Uint8Array`/`number[]` objects +// per field. +// +// Scope: +// - supported: scalar fields (all 15 types), enums, nested messages, +// repeated scalar (packed + unpacked), repeated message, +// map for all legal K and any scalar/enum/message V, +// oneof groups +// - unsupported: extensions, delimited/group encoding, unknown fields +// +// For unsupported schemas `toBinaryFast` falls back to the existing +// reflective `toBinary`. The decision is computed once per `DescMessage` +// and cached in a `WeakMap`, so the fallback check does not dominate the +// hot path after the first call. +// +// Output is semantic-identical to `toBinary`: `fromBinary(schema, +// toBinaryFast(schema, msg))` and `fromBinary(schema, toBinary(schema, +// msg))` produce structurally-equal messages. Byte-identical output is +// not guaranteed (field ordering matches descriptor order, which matches +// `toBinary`'s non-unknown path, but future tweaks may diverge). + +import type { MessageShape } from "./types.js"; +import { + ScalarType, + type DescField, + type DescMessage, + type DescOneof, +} from "./descriptors.js"; +import { protoInt64 } from "./proto-int64.js"; +import { toBinary } from "./to-binary.js"; +import { getTextEncoding } from "./wire/text-encoding.js"; + +// ----------------------------------------------------------------------------- +// Support detection +// ----------------------------------------------------------------------------- + +const supportCache = new WeakMap(); + +// `0n` requires target >= ES2020, but this package is compiled for ES2017. +// Materialize the bigint zero once at module load so closures can compare +// against it without the BigInt() call on the hot path. Marked PURE so +// unused-path eliminators (esbuild, Rollup, Terser) can drop this module +// when toBinaryFast is never referenced. +const BIGINT_ZERO = /*@__PURE__*/ BigInt(0); + +/** + * Walk the descriptor (including transitive message fields) and return + * true iff every field in the subtree uses an MVP-supported shape. The + * result is cached per `DescMessage` — most schemas have small, bounded + * field trees and the walk is cheap but not free, so we amortize. + */ +function isSupported( + desc: DescMessage, + visiting: Set = new Set(), +): boolean { + const cached = supportCache.get(desc); + if (cached !== undefined) return cached; + // Guard against recursive message types (e.g. google.protobuf.Value). + // While a cycle is in flight we optimistically assume support; if a + // descendant turns out to be unsupported, we overwrite the cache + // entry below. + if (visiting.has(desc)) return true; + visiting.add(desc); + + let ok = true; + for (const field of desc.fields) { + // Delimited (group) encoding is not handled — the legacy wire format + // requires paired start/end tags which don't fit the single-pass + // write model. Map fields and message-typed map values cannot use + // delimited encoding (enforced by the descriptor), so we only need + // to check singular messages and repeated messages. + if ( + (field.fieldKind === "message" || + (field.fieldKind === "list" && field.listKind === "message")) && + (field as { delimitedEncoding?: boolean }).delimitedEncoding === true + ) { + ok = false; + break; + } + // Recurse into message fields. + if (field.fieldKind === "message" && field.message) { + if (!isSupported(field.message, visiting)) { + ok = false; + break; + } + } + if ( + field.fieldKind === "list" && + field.listKind === "message" && + field.message + ) { + if (!isSupported(field.message, visiting)) { + ok = false; + break; + } + } + // Recurse into map value messages. + if ( + field.fieldKind === "map" && + field.mapKind === "message" && + field.message + ) { + if (!isSupported(field.message, visiting)) { + ok = false; + break; + } + } + } + visiting.delete(desc); + supportCache.set(desc, ok); + return ok; +} + +// ----------------------------------------------------------------------------- +// Wire format helpers +// ----------------------------------------------------------------------------- + +const WIRE_VARINT = 0; +const WIRE_BIT64 = 1; +const WIRE_LENGTH_DELIMITED = 2; +const WIRE_BIT32 = 5; + +/** Size in bytes of an unsigned 32-bit varint. */ +function varintSize32(v: number): number { + if (v < 0x80) return 1; + if (v < 0x4000) return 2; + if (v < 0x200000) return 3; + if (v < 0x10000000) return 4; + return 5; +} + +/** Size in bytes of an int32 varint (negatives use 10 bytes). */ +function int32Size(v: number): number { + if (v < 0) return 10; + return varintSize32(v); +} + +/** Size of a zigzag-encoded 32-bit signed integer. */ +function sint32Size(v: number): number { + return varintSize32(((v << 1) ^ (v >> 31)) >>> 0); +} + +/** + * Size of a 64-bit varint given its (lo, hi) two's-complement halves. + * The varint writer emits while (hi > 0 || lo > 0x7f) and then one more + * byte, so we count in 7-bit chunks across the 64 bits. + */ +function varintSize64(lo: number, hi: number): number { + // Normalize to uint32. + let l = lo >>> 0; + let h = hi >>> 0; + let bytes = 1; + while (h > 0 || l > 0x7f) { + bytes++; + l = ((l >>> 7) | (h << 25)) >>> 0; + h >>>= 7; + } + return bytes; +} + +function tagSize(fieldNo: number, wireType: number): number { + return varintSize32(((fieldNo << 3) | wireType) >>> 0); +} + +/** + * UTF-8 byte length of a JS string without encoding. Mirrors the helper + * used in opentelemetry-js#6390 — correct for valid UTF-16 input (which + * all JS strings are). Surrogate pairs contribute 4 bytes. + */ +function utf8ByteLength(str: string): number { + const len = str.length; + let byteLen = 0; + for (let i = 0; i < len; i++) { + const code = str.charCodeAt(i); + if (code < 0x80) { + byteLen += 1; + } else if (code < 0x800) { + byteLen += 2; + } else if (code < 0xd800 || code >= 0xe000) { + byteLen += 3; + } else { + // Lead of a surrogate pair — skip the trail, account for 4 bytes. + i++; + byteLen += 4; + } + } + return byteLen; +} + +// ----------------------------------------------------------------------------- +// Encoded-size cache +// ----------------------------------------------------------------------------- +// +// We compute the size of each submessage exactly once (pass 1) and reuse +// that number in pass 2 to write the length prefix. A WeakMap keyed by +// the message object isolates this state to the current toBinaryFast call +// without leaking across calls (the map itself is scoped to one encode). + +type SizeMap = Map; + +// ----------------------------------------------------------------------------- +// Pass 1 — size estimation +// ----------------------------------------------------------------------------- + +function scalarSize(type: ScalarType, value: unknown): number { + switch (type) { + case ScalarType.STRING: { + const byteLen = utf8ByteLength(value as string); + return varintSize32(byteLen) + byteLen; + } + case ScalarType.BOOL: + return 1; + case ScalarType.DOUBLE: + return 8; + case ScalarType.FLOAT: + return 4; + case ScalarType.INT32: + return int32Size(value as number); + case ScalarType.UINT32: + return varintSize32((value as number) >>> 0); + case ScalarType.SINT32: + return sint32Size(value as number); + case ScalarType.FIXED32: + case ScalarType.SFIXED32: + return 4; + case ScalarType.INT64: + case ScalarType.UINT64: { + const tc = + type === ScalarType.UINT64 + ? protoInt64.uEnc(value as string | number | bigint) + : protoInt64.enc(value as string | number | bigint); + return varintSize64(tc.lo, tc.hi); + } + case ScalarType.SINT64: { + const tc = protoInt64.enc(value as string | number | bigint); + const sign = tc.hi >> 31; + const lo = (tc.lo << 1) ^ sign; + const hi = ((tc.hi << 1) | (tc.lo >>> 31)) ^ sign; + return varintSize64(lo, hi); + } + case ScalarType.FIXED64: + case ScalarType.SFIXED64: + return 8; + case ScalarType.BYTES: { + const b = value as Uint8Array; + return varintSize32(b.length) + b.length; + } + } + // Unreachable for well-formed descriptors; fall back to 0 so that + // misconfigured types don't silently corrupt the buffer — the size/ + // write mismatch assertion will catch it. + return 0; +} + +function scalarWireType(type: ScalarType): number { + switch (type) { + case ScalarType.BYTES: + case ScalarType.STRING: + return WIRE_LENGTH_DELIMITED; + case ScalarType.DOUBLE: + case ScalarType.FIXED64: + case ScalarType.SFIXED64: + return WIRE_BIT64; + case ScalarType.FIXED32: + case ScalarType.SFIXED32: + case ScalarType.FLOAT: + return WIRE_BIT32; + default: + return WIRE_VARINT; + } +} + +/** + * Should this non-oneof field be emitted for the given message? + * Oneof members are dispatched separately and never flow through this + * predicate. + */ +function isFieldSet(field: DescField, value: unknown): boolean { + // Explicit presence (proto2 / proto3 optional): the generated setters + // only assign when the property was set. Missing ⇒ undefined. + if (value === undefined || value === null) return false; + + // Implicit presence (proto3 singular scalar/enum): zero value means + // "not set" and must not be emitted. Lists/maps handled separately + // (empty list/map means "not set" too). + switch (field.fieldKind) { + case "scalar": { + const t = field.scalar; + if (field.presence !== 2 /* IMPLICIT */) { + // Explicit / legacy required: any defined value counts as set. + return true; + } + 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 + ) { + // bigint zero, numeric zero, "0" string all represent unset. + // Compare via coercion so 0n / 0 / "0" all return false. + // Literal `0n` requires ES2020; see BIGINT_ZERO above. + return value !== 0 && value !== BIGINT_ZERO && value !== "0"; + } + return (value as number) !== 0; + } + case "enum": + if (field.presence !== 2 /* IMPLICIT */) return true; + return (value as number) !== 0; + case "message": + return true; // already filtered by undefined check above + case "list": + return (value as unknown[]).length > 0; + case "map": + // Map fields carry their own "any entry" gate here — empty object + // ⇒ not set ⇒ omit. Same semantics as reflect.unsafeIsSet. + return Object.keys(value as object).length > 0; + } + // Exhaustive switch; unreachable. Return true so unexpected shapes + // surface as a size/write mismatch error rather than silent data loss. + return true; +} + +// ----------------------------------------------------------------------------- +// Map key helpers +// ----------------------------------------------------------------------------- +// +// protobuf-es stores map fields as plain JS objects keyed by the stringified +// map key (see reflectMap.mapKeyToLocal). On the fast path we iterate +// `Object.keys`, so every key we see is a string. For integer and boolean +// map keys we parse back to the typed value before computing the scalar +// size or writing the scalar bytes — matching what the reflective encoder +// does via ReflectMap's iterator. + +type MapKeyScalar = Exclude< + ScalarType, + ScalarType.FLOAT | ScalarType.DOUBLE | ScalarType.BYTES +>; + +function coerceMapKey(stringKey: string, keyType: MapKeyScalar): unknown { + switch (keyType) { + case ScalarType.STRING: + return stringKey; + case ScalarType.BOOL: + // Object keys for boolean maps are always "true" / "false" strings. + return stringKey === "true"; + case ScalarType.INT64: + case ScalarType.SINT64: + case ScalarType.SFIXED64: + return protoInt64.parse(stringKey); + case ScalarType.UINT64: + case ScalarType.FIXED64: + return protoInt64.uParse(stringKey); + default: + // INT32, SINT32, FIXED32, SFIXED32, UINT32 — parse back to number. + return Number.parseInt(stringKey, 10); + } +} + +/** + * Body-size of a single map entry message `{ key, value }`, excluding + * the outer field tag and length prefix. Returns both the body size and, + * for message-typed values, the submessage body size (so the writer + * doesn't recompute it). + */ +function estimateMapEntryBody( + field: DescField & { fieldKind: "map" }, + keyTyped: unknown, + value: unknown, + sizes: SizeMap, +): { body: number; valueSubSize: number } { + // Entry key is always field number 1. + const keySize = + tagSize(1, scalarWireType(field.mapKey)) + + scalarSize(field.mapKey, keyTyped); + let valSize: number; + let valueSubSize = 0; + switch (field.mapKind) { + case "scalar": + valSize = + tagSize(2, scalarWireType(field.scalar)) + + scalarSize(field.scalar, value); + break; + case "enum": + valSize = tagSize(2, WIRE_VARINT) + int32Size(value as number); + break; + case "message": { + const sub = value as Record; + valueSubSize = estimateMessageSize(field.message, sub, sizes); + sizes.set(sub, valueSubSize); + valSize = + tagSize(2, WIRE_LENGTH_DELIMITED) + + varintSize32(valueSubSize) + + valueSubSize; + break; + } + } + return { body: keySize + valSize, valueSubSize }; +} + +/** + * Size contribution of a map field: for every entry, an outer tag + length + * prefix + entry body. Map entries are always length-delimited — map fields + * cannot use delimited (group) encoding. + */ +function estimateMapFieldSize( + field: DescField & { fieldKind: "map" }, + obj: Record, + sizes: SizeMap, +): number { + const tagBytes = tagSize(field.number, WIRE_LENGTH_DELIMITED); + let size = 0; + for (const strKey of Object.keys(obj)) { + const keyTyped = coerceMapKey(strKey, field.mapKey); + const { body } = estimateMapEntryBody(field, keyTyped, obj[strKey], sizes); + size += tagBytes + varintSize32(body) + body; + } + return size; +} + +/** + * Size contribution of a single non-oneof non-map "regular" field. Broken + * out so that the oneof dispatch can reuse the same switch. + */ +function estimateRegularFieldSize( + field: DescField, + value: unknown, + sizes: SizeMap, +): number { + switch (field.fieldKind) { + case "scalar": + return ( + tagSize(field.number, scalarWireType(field.scalar)) + + scalarSize(field.scalar, value) + ); + case "enum": + return tagSize(field.number, WIRE_VARINT) + int32Size(value as number); + case "message": { + const sub = value as Record; + const subSize = estimateMessageSize(field.message, sub, sizes); + sizes.set(sub, subSize); + return ( + tagSize(field.number, WIRE_LENGTH_DELIMITED) + + varintSize32(subSize) + + subSize + ); + } + case "list": { + const list = value as unknown[]; + let size = 0; + if (field.listKind === "message") { + const tagBytes = tagSize(field.number, WIRE_LENGTH_DELIMITED); + for (let k = 0; k < list.length; k++) { + const sub = list[k] as Record; + const subSize = estimateMessageSize(field.message, sub, sizes); + sizes.set(sub, subSize); + size += tagBytes + varintSize32(subSize) + subSize; + } + return size; + } + if (field.listKind === "enum") { + if (field.packed) { + let body = 0; + for (let k = 0; k < list.length; k++) { + body += int32Size(list[k] as number); + } + return ( + tagSize(field.number, WIRE_LENGTH_DELIMITED) + + varintSize32(body) + + body + ); + } + const tagBytes = tagSize(field.number, WIRE_VARINT); + for (let k = 0; k < list.length; k++) { + size += tagBytes + int32Size(list[k] as number); + } + return size; + } + // listKind === "scalar" + const t = field.scalar; + const wt = scalarWireType(t); + if (field.packed && wt !== WIRE_LENGTH_DELIMITED) { + let body = 0; + for (let k = 0; k < list.length; k++) { + body += scalarSize(t, list[k]); + } + return ( + tagSize(field.number, WIRE_LENGTH_DELIMITED) + + varintSize32(body) + + body + ); + } + const tagBytes = tagSize(field.number, wt); + for (let k = 0; k < list.length; k++) { + size += tagBytes + scalarSize(t, list[k]); + } + return size; + } + case "map": + // Map fields flow through estimateMapFieldSize; this branch is + // defensive and never taken on the estimation hot path. + return estimateMapFieldSize( + field as DescField & { fieldKind: "map" }, + value as Record, + sizes, + ); + } + return 0; +} + +function estimateMessageSize( + desc: DescMessage, + message: Record, + sizes: SizeMap, +): number { + let size = 0; + const fields = desc.fields; + for (let i = 0; i < fields.length; i++) { + const field = fields[i]; + // Oneof members are dispatched via the `desc.oneofs` loop below. + if (field.oneof !== undefined) continue; + + if (field.fieldKind === "map") { + const obj = message[field.localName] as + | Record + | undefined; + if (!obj || Object.keys(obj).length === 0) continue; + size += estimateMapFieldSize( + field as DescField & { fieldKind: "map" }, + obj, + sizes, + ); + continue; + } + + const value = message[field.localName]; + if (!isFieldSet(field, value)) continue; + size += estimateRegularFieldSize(field, value, sizes); + } + // Oneof dispatch: at most one field per oneof contributes, identified by + // the `case` discriminator on the oneof ADT object. Zero values are + // emitted when a oneof case is explicitly set — that's the whole point + // of the oneof: presence is carried by the discriminator, not by value. + const oneofs = desc.oneofs; + for (let i = 0; i < oneofs.length; i++) { + const oneof = oneofs[i]; + const adt = message[oneof.localName] as + | { case: string | undefined; value?: unknown } + | undefined; + if (!adt || adt.case === undefined) continue; + const selected = findOneofField(oneof, adt.case); + if (!selected) continue; + size += estimateRegularFieldSize(selected, adt.value, sizes); + } + return size; +} + +function findOneofField( + oneof: DescOneof, + caseName: string, +): DescField | undefined { + const fs = oneof.fields; + for (let i = 0; i < fs.length; i++) { + if (fs[i].localName === caseName) return fs[i]; + } + return undefined; +} + +// ----------------------------------------------------------------------------- +// Pass 2 — write into pre-allocated buffer +// ----------------------------------------------------------------------------- + +/** + * Writer state bundled into a plain object so that helper functions can + * mutate `pos` without paying for method-call indirection on a class. + */ +interface Cursor { + buf: Uint8Array; + view: DataView; + pos: number; + encodeUtf8: (s: string) => Uint8Array; +} + +function writeVarint32(c: Cursor, v: number): void { + // Callers pre-coerce to uint32 where needed. + while (v > 0x7f) { + c.buf[c.pos++] = (v & 0x7f) | 0x80; + v = v >>> 7; + } + c.buf[c.pos++] = v; +} + +function writeTag(c: Cursor, fieldNo: number, wireType: number): void { + writeVarint32(c, ((fieldNo << 3) | wireType) >>> 0); +} + +function writeVarint64(c: Cursor, lo: number, hi: number): void { + let l = lo >>> 0; + let h = hi >>> 0; + while (h > 0 || l > 0x7f) { + c.buf[c.pos++] = (l & 0x7f) | 0x80; + l = ((l >>> 7) | (h << 25)) >>> 0; + h >>>= 7; + } + c.buf[c.pos++] = l & 0x7f; +} + +function writeInt32(c: Cursor, v: number): void { + // Negative int32 is sign-extended to 64 bits and written as 10-byte varint. + if (v >= 0) { + writeVarint32(c, v); + } else { + writeVarint64(c, v | 0, -1); + } +} + +function writeSInt32(c: Cursor, v: number): void { + writeVarint32(c, ((v << 1) ^ (v >> 31)) >>> 0); +} + +function writeScalar(c: Cursor, type: ScalarType, value: unknown): void { + switch (type) { + case ScalarType.STRING: { + const s = value as string; + // ASCII fast path: write char codes directly; otherwise materialize + // via TextEncoder. Size was already accounted for. + let isAscii = true; + const len = s.length; + for (let i = 0; i < len; i++) { + if (s.charCodeAt(i) > 127) { + isAscii = false; + break; + } + } + if (isAscii) { + writeVarint32(c, len); + for (let i = 0; i < len; i++) { + c.buf[c.pos++] = s.charCodeAt(i); + } + } else { + const bytes = c.encodeUtf8(s); + writeVarint32(c, bytes.length); + c.buf.set(bytes, c.pos); + c.pos += bytes.length; + } + return; + } + case ScalarType.BOOL: + c.buf[c.pos++] = (value as boolean) ? 1 : 0; + return; + case ScalarType.DOUBLE: + c.view.setFloat64(c.pos, value as number, true); + c.pos += 8; + return; + case ScalarType.FLOAT: + c.view.setFloat32(c.pos, value as number, true); + c.pos += 4; + return; + case ScalarType.INT32: + writeInt32(c, value as number); + return; + case ScalarType.UINT32: + writeVarint32(c, (value as number) >>> 0); + return; + case ScalarType.SINT32: + writeSInt32(c, value as number); + return; + case ScalarType.FIXED32: + c.view.setUint32(c.pos, (value as number) >>> 0, true); + c.pos += 4; + return; + case ScalarType.SFIXED32: + c.view.setInt32(c.pos, value as number, true); + c.pos += 4; + return; + case ScalarType.INT64: + case ScalarType.UINT64: { + const tc = + type === ScalarType.UINT64 + ? protoInt64.uEnc(value as string | number | bigint) + : protoInt64.enc(value as string | number | bigint); + writeVarint64(c, tc.lo, tc.hi); + return; + } + case ScalarType.SINT64: { + const tc = protoInt64.enc(value as string | number | bigint); + const sign = tc.hi >> 31; + const lo = (tc.lo << 1) ^ sign; + const hi = ((tc.hi << 1) | (tc.lo >>> 31)) ^ sign; + writeVarint64(c, lo, hi); + return; + } + case ScalarType.FIXED64: { + const tc = protoInt64.uEnc(value as string | number | bigint); + c.view.setUint32(c.pos, tc.lo >>> 0, true); + c.view.setUint32(c.pos + 4, tc.hi >>> 0, true); + c.pos += 8; + return; + } + case ScalarType.SFIXED64: { + const tc = protoInt64.enc(value as string | number | bigint); + c.view.setInt32(c.pos, tc.lo | 0, true); + c.view.setInt32(c.pos + 4, tc.hi | 0, true); + c.pos += 8; + return; + } + case ScalarType.BYTES: { + const b = value as Uint8Array; + writeVarint32(c, b.length); + c.buf.set(b, c.pos); + c.pos += b.length; + return; + } + } +} + +function writeMapEntry( + c: Cursor, + field: DescField & { fieldKind: "map" }, + keyTyped: unknown, + value: unknown, + sizes: SizeMap, +): void { + // Entry key: field number 1. + writeTag(c, 1, scalarWireType(field.mapKey)); + writeScalar(c, field.mapKey, keyTyped); + // Entry value: field number 2. + switch (field.mapKind) { + case "scalar": + writeTag(c, 2, scalarWireType(field.scalar)); + writeScalar(c, field.scalar, value); + return; + case "enum": + writeTag(c, 2, WIRE_VARINT); + writeInt32(c, value as number); + return; + case "message": { + const sub = value as Record; + const subSize = sizes.get(sub) ?? 0; + writeTag(c, 2, WIRE_LENGTH_DELIMITED); + writeVarint32(c, subSize); + writeMessageInto(c, field.message, sub, sizes); + return; + } + } +} + +function writeMapField( + c: Cursor, + field: DescField & { fieldKind: "map" }, + obj: Record, + sizes: SizeMap, +): void { + for (const strKey of Object.keys(obj)) { + const keyTyped = coerceMapKey(strKey, field.mapKey); + const value = obj[strKey]; + // Body size is recomputed here rather than cached because caching it + // per-entry would require either (1) a second identity-keyed cache + // separate from `sizes` or (2) wrapping each entry in a synthetic + // object. Recompute is cheap — scalar types only, except for the + // `value` submessage which reads from `sizes` anyway. + const { body } = estimateMapEntryBody(field, keyTyped, value, sizes); + writeTag(c, field.number, WIRE_LENGTH_DELIMITED); + writeVarint32(c, body); + writeMapEntry(c, field, keyTyped, value, sizes); + } +} + +/** + * Write one non-oneof non-map field. Matches estimateRegularFieldSize + * exactly so that pass 1 and pass 2 stay in sync. + */ +function writeRegularField( + c: Cursor, + field: DescField, + value: unknown, + sizes: SizeMap, +): void { + switch (field.fieldKind) { + case "scalar": + writeTag(c, field.number, scalarWireType(field.scalar)); + writeScalar(c, field.scalar, value); + return; + case "enum": + writeTag(c, field.number, WIRE_VARINT); + writeInt32(c, value as number); + return; + case "message": { + const sub = value as Record; + const subSize = sizes.get(sub) ?? 0; + writeTag(c, field.number, WIRE_LENGTH_DELIMITED); + writeVarint32(c, subSize); + writeMessageInto(c, field.message, sub, sizes); + return; + } + case "list": { + const list = value as unknown[]; + if (field.listKind === "message") { + for (let k = 0; k < list.length; k++) { + const sub = list[k] as Record; + const subSize = sizes.get(sub) ?? 0; + writeTag(c, field.number, WIRE_LENGTH_DELIMITED); + writeVarint32(c, subSize); + writeMessageInto(c, field.message, sub, sizes); + } + return; + } + if (field.listKind === "enum") { + if (field.packed) { + let body = 0; + for (let k = 0; k < list.length; k++) { + body += int32Size(list[k] as number); + } + writeTag(c, field.number, WIRE_LENGTH_DELIMITED); + writeVarint32(c, body); + for (let k = 0; k < list.length; k++) { + writeInt32(c, list[k] as number); + } + return; + } + for (let k = 0; k < list.length; k++) { + writeTag(c, field.number, WIRE_VARINT); + writeInt32(c, list[k] as number); + } + return; + } + // scalar list + const t = field.scalar; + const wt = scalarWireType(t); + if (field.packed && wt !== WIRE_LENGTH_DELIMITED) { + let body = 0; + for (let k = 0; k < list.length; k++) { + body += scalarSize(t, list[k]); + } + writeTag(c, field.number, WIRE_LENGTH_DELIMITED); + writeVarint32(c, body); + for (let k = 0; k < list.length; k++) { + writeScalar(c, t, list[k]); + } + return; + } + for (let k = 0; k < list.length; k++) { + writeTag(c, field.number, wt); + writeScalar(c, t, list[k]); + } + return; + } + case "map": + // Map fields are dispatched through writeMapField from the caller; + // this branch is unreachable on the hot path but defensive. + writeMapField( + c, + field as DescField & { fieldKind: "map" }, + value as Record, + sizes, + ); + return; + } +} + +function writeMessageInto( + c: Cursor, + desc: DescMessage, + message: Record, + sizes: SizeMap, +): void { + const fields = desc.fields; + for (let i = 0; i < fields.length; i++) { + const field = fields[i]; + // Oneof members: dispatched via the oneof loop below. + if (field.oneof !== undefined) continue; + + if (field.fieldKind === "map") { + const obj = message[field.localName] as + | Record + | undefined; + if (!obj || Object.keys(obj).length === 0) continue; + writeMapField(c, field as DescField & { fieldKind: "map" }, obj, sizes); + continue; + } + + const value = message[field.localName]; + if (!isFieldSet(field, value)) continue; + writeRegularField(c, field, value, sizes); + } + const oneofs = desc.oneofs; + for (let i = 0; i < oneofs.length; i++) { + const oneof = oneofs[i]; + const adt = message[oneof.localName] as + | { case: string | undefined; value?: unknown } + | undefined; + if (!adt || adt.case === undefined) continue; + const selected = findOneofField(oneof, adt.case); + if (!selected) continue; + writeRegularField(c, selected, adt.value, sizes); + } +} + +// ----------------------------------------------------------------------------- +// Entry point +// ----------------------------------------------------------------------------- + +/** + * Opt-in fast-path binary encoder. See the top-of-file comment for the + * motivation and scope. + * + * Falls back to {@link toBinary} when the schema uses features not yet + * supported by the fast path (extensions or delimited/group encoding). + * Unknown fields on messages are always dropped by the fast path — if + * you need to round-trip unknowns, use `toBinary` instead. + * + * @experimental This API is experimental and may change or be removed + * without notice. The intent is to explore whether a two-pass encode + * meaningfully improves OTel-shaped workloads; once stabilized, the + * improvement may fold into the default `toBinary`. + */ +export function toBinaryFast( + schema: Desc, + message: MessageShape, +): Uint8Array { + if (!isSupported(schema)) { + return toBinary(schema, message); + } + const sizes: SizeMap = new Map(); + const msg = message as unknown as Record; + const total = estimateMessageSize(schema, 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, + }; + writeMessageInto(cursor, schema, msg, sizes); + if (cursor.pos !== total) { + throw new Error( + `toBinaryFast: size/write mismatch (est=${total} wrote=${cursor.pos}) — please report this as a bug`, + ); + } + return buf; +}