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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,35 +21,36 @@ BuffJsonEncoder.encode(message)
holder.buffJsonEncoder().writeFields(jw, msg, writer) # typed getters, no reflection
-> nested: OtherEncoder.INSTANCE.writeFields(jw, nested, writer) # direct call
-> WKT Timestamp/Duration: writeTimestampDirect(seconds, nanos) # no reflection
-> field name: if (utf8) writeNameRaw(NAME_X_BYTES); else writeNameRaw(NAME_X);
-> WKT wrappers: jsonWriter.writeInt32(wrap.getValue()) # no reflection
-> field name: writeName<n>Raw(NAME_X_W0[, ...]) # packed words, no branch
-> 2. if useTyped && !(message instanceof DynamicMessage) # TYPED-ACCESSOR
TypedMessageSchema.forMessage(...).writeFields(jw, msg, this) # LambdaMetafactory-bound getters
-> ToIntFunction<Message>, ToLongFunction<Message>, Predicate<Message>, Function<Message,?>
-> RepeatedIntAccessor / RepeatedLongAccessor / RepeatedStringAccessor / RepeatedMessageAccessor
-> RepeatedInt/Long/Double/Float/Bool/String/MessageAccessor # primitive lists, no boxing
-> TypedMapAccessor uses getXxxMap() / getXxxValueMap()
-> 3. MessageSchema.forDescriptor(...) iteration # PURE REFLECTION
Object value = message.getField(fd); FieldWriter.writeValue(...) # boxing
-> field name: if (utf8) writeNameRaw(nameWithColonUtf8); else writeNameRaw(nameWithColon);
-> field name: FieldName.writeTo(jw) -> isUTF8() ? byte[] : char[] # same as tier 2
```

**1. Codegen path** (~10x JsonFormat) — protoc plugin generates `*JsonEncoder` per message. Each encoder calls typed getters directly (`msg.getId()` → `int`), eliminating reflection, boxing, runtime type dispatch, and ConcurrentHashMap lookups.

Codegen optimizations:
- **Direct nested encoder calls** — `AddressJsonEncoder.INSTANCE.writeFields(jw, msg, writer)` instead of routing through `ProtobufMessageWriter` (avoids ConcurrentHashMap lookup + instanceof check per nested message).
- **Inline WKT Timestamp/Duration** — `WellKnownTypes.writeTimestampDirect(jsonWriter, ts.getSeconds(), ts.getNanos())` bypasses descriptor string switch, field cache lookup, and `getField()` reflection+boxing.
- **Inline WKT wrappers** — an `Int32Value`/`StringValue`/… field emits `jsonWriter.writeInt32(wrap.getValue())` etc. directly, so the nine wrapper types skip `WellKnownTypes.write()`'s full-name String switch, descriptor cache lookup and `getField()` boxing entirely.
- **Pre-cached enum name arrays** — static `String[]` built at class init from enum descriptor values, replaces `forNumber()` + `getValueDescriptor().getName()` per write.
- **String map key optimization** — avoids redundant `toString()` for String-typed map keys.
- **Zero-alloc int64**: `writeString((long) v)` (signed) and `WellKnownTypes.writeUnsignedLongString(jw, v)` (unsigned) — no `Long.toString()` / `Long.toUnsignedString()` String allocation.
- **Zero-alloc bytes**: `jsonWriter.writeBase64(v.toByteArray())` — no Base64 String intermediate.
- **`isFinite()`-first** float/double NaN/Inf branches.
- **Pre-encoded UTF-8 byte[] field names** — both `char[] NAME_X` and `byte[] NAME_X_BYTES` emitted; `boolean utf8 = jsonWriter.isUTF8()` hoisted at top of `writeFields`; per-field branch via `emitWriteName` — JIT specializes per call site.
- **Word-packed field names** — a name of 2–16 printable-ASCII characters (i.e. essentially every protobuf field) is packed into one or two `long`s at class init and written with fastjson2's `writeName2Raw(long)` … `writeName16Raw(long, long)`: one or two `Unsafe.putLong` stores instead of a static array load plus `arraycopy`. `JSONWriterUTF16` widens the *same* packed bytes on the way out, so there is **no `isUTF8()` branch** on this path — the `char[]`/`byte[]` pair and the hoisted `boolean utf8` local are emitted only for the rare unpackable `json_name`. See `FieldNames`.
- **No boxing in repeated numeric fields** — repeated int32/int64/double/float/bool lists are `instanceof`-narrowed to `Internal.IntList`/`LongList`/`DoubleList`/`FloatList`/`BooleanList` and read with `getInt(i)`/`getLong(i)`/…, avoiding the `Integer.valueOf`/`Long.valueOf` allocation `List.get(i)` performs per element.
- **Bulk repeated strings** — `jsonWriter.writeString(values)` hands the whole `List<String>` to fastjson2, which writes brackets, commas and per-element escaping with one capacity check.

**2. Typed-accessor path** (~6x JsonFormat) — used when codegen is disabled or unavailable. `TypedMessageSchema` caches per-Descriptor arrays of `TypedFieldAccessor` records (sealed interface, ~20 variants) whose getter slots are bound via `LambdaMetafactory` to the protoc-generated typed getters. No `getField()` reflection, no boxing. Specialized `RepeatedInt/Long/String/MessageAccessor` and `TypedMapAccessor` eliminate per-element switch dispatch. A `FAILED` sentinel marks descriptors where lambda binding failed (silent fallback to path 3).
**2. Typed-accessor path** (~6x JsonFormat) — used when codegen is disabled or unavailable. `TypedMessageSchema` caches per-Descriptor arrays of `TypedFieldAccessor` records (sealed interface, ~20 variants) whose getter slots are bound via `LambdaMetafactory` to the protoc-generated typed getters. No `getField()` reflection, no boxing. Specialized `RepeatedInt/Long/Double/Float/Bool/String/MessageAccessor` and `TypedMapAccessor` eliminate per-element switch dispatch. A `FAILED` sentinel marks descriptors where lambda binding failed (silent fallback to path 3).

**3. Pure-reflection path** — used for `DynamicMessage` (no compiled getters → can't bind lambdas) or descriptors where `LambdaMetafactory` failed. Iterates cached `MessageSchema.FieldInfo[]`, calls `message.getField(fd)` (boxes primitives), `FieldWriter` switches on `JavaType`. Field-name dispatch (`nameWithColon` / `nameWithColonUtf8`) matches the codegen UTF-8 win.

fastjson2 handles: buffer pooling, number formatting, string escaping, UTF-8 encoding, Base64 encoding (`writeBase64(byte[])`).
We handle: protobuf field extraction, proto3 JSON spec compliance, well-known types, epoch→calendar arithmetic for timestamps.
**3. Pure-reflection path** — used for `DynamicMessage` (no compiled getters → can't bind lambdas) or descriptors where `LambdaMetafactory` failed. Iterates cached `MessageSchema.FieldInfo[]`, calls `message.getField(fd)` (boxes primitives) — after `hasField` for fields with presence, so an absent optional field costs no boxed read — and `FieldWriter` switches on `JavaType`. Field names go through the same `FieldName` (`char[]`/`byte[]`, dispatched on `isUTF8()`) as the typed path.

fastjson2 handles: buffer pooling, number formatting, string escaping, UTF-8 encoding, Base64 encoding (`writeBase64(byte[])`).
We handle: protobuf field extraction, proto3 JSON spec compliance, well-known types, epoch→calendar arithmetic for timestamps.
Expand Down Expand Up @@ -96,7 +97,10 @@ JSONFactory.getDefaultObjectReaderProvider().register(decoder.readerModule());
- **Module exposure** — `encoder.writerModule()` and `decoder.readerModule()` return fastjson2 modules backed by configured writer/reader instances, for mixed pojo+protobuf projects using `JSON.toJSONString()`.
- **`MessageSchema` caching**: One-time cost per Descriptor. Avoids `getAllFields()` TreeMap allocation.
- **`TypedMessageSchema` caching**: Per-Descriptor `LambdaMetafactory`-bound typed accessors, lazily built. `FAILED` sentinel marks descriptors where binding failed (silent fallback to reflection path).
- **Pre-computed field names — both `char[]` and `byte[]`**: `writeNameRaw(byte[])` is faster on UTF-8 writers (direct `arraycopy`), but throws `UnsupportedOperation` on `JSONWriterUTF16`. Both runtime (`MessageSchema.FieldInfo`) and codegen (`*JsonEncoder`) and typed-accessor (`FieldName` record) hoist `boolean utf8 = jsonWriter.isUTF8()` once and dispatch per write.
- **Typed `Struct`/`Value`/`ListValue` (`WellKnownTypes`)**: `Struct`, `Value` and `ListValue` are ordinary compiled protobuf-java classes, so `writeStruct`/`writeValue`/`writeListValue` take an `instanceof` fast path — `struct.getFieldsMap()` instead of materializing the synthetic MapEntry list and pulling key/value back through `getField()`, and `value.getKindCase()` (an int switch, with a `default` arm so a future kind cannot emit a name with no value) instead of `desc.getOneofs().get(0)` — which allocates an `Arrays.asList` + `unmodifiableList` pair on *every* call — plus `getOneofFieldDescriptor()` and a switch on the field's String name. `DynamicMessage` keeps the reflective path, with the map-entry descriptors hoisted out of the per-entry loop. Worth +57–64% and −657 B/op on `WktBenchmark.struct`, the largest single win in the encode audit.
- **Word-packed field names (`FieldNames`)**: fastjson2's `writeName<n>Raw(long[, long])` family takes the name already packed into machine words and stores it with one or two `Unsafe.putLong`s — it is what fastjson2's own ASM/`@JSONCompiled` writers use, and it is the main remaining gap to the POJO ceiling. `FieldNames` derives the constants by reading native-order words straight out of the literal `"name":` text (offset 0, except lengths 8 and 16 where fastjson2 emits the opening quote itself). **The same constants serve UTF-8 and UTF-16 writers**, because `JSONWriterUTF16` widens the packed ASCII to `char`s on the way out — so packed names need no `isUTF8()` branch at all. **Codegen only, by measurement**: the plugin knows each name's length so it emits the exact `writeName<n>Raw` call with no dispatch (+12–13% on `SimpleMessage`). Routing the runtime paths through it too requires a `switch` on the length — an indirect branch in the hottest code keyed on a per-field value — which cost 10% on `ComplexMessage.buffJsonRuntime`, so `FieldName` keeps the pre-encoded `char[]`/`byte[]` pair. **Not universally usable**: the quote characters are split between us and fastjson2 and the split *differs per length* (both quotes baked for 2–6 and 9–14, only the opening one for 7 and 15, neither for 8 and 16), so a `UseSingleQuotes` writer would emit `"name'` for lengths 7/15 — invalid JSON, where the arrays merely ignored the feature; and `JSONWriterUTF16` widens the word by extracting bytes at hard-coded little-endian positions with no `BIG_ENDIAN` branch, which native-order packing cannot satisfy on a big-endian host. So `FieldNames.PACKED_NAMES_SUPPORTED` self-checks the layout at class init, and `ProtobufMessageWriter.canUsePackedNames` additionally vetoes the codegen tier for a single-quote writer — one check per message, falling back to the typed path, which owns every byte it emits. `FieldNames` still supplies those arrays, and codegen falls back to them for the rare unpackable `json_name`. A `json_name` that is not `FieldNames.isRawWritable` at all (non-ASCII, or carrying a quote/backslash/control char) goes through `writeName(String)` + `writeColon()` so fastjson2 escapes and transcodes it — the old `(byte) charAt(i)` array builder corrupted such names, and the generator emitted them into Java literals unescaped, which did not compile.
- **Shared `JSONWriter.Context`**: `JSONWriter.of()` allocates a fresh `Context` per call to carry configuration that never changes between encodes. `BuffJsonEncoder` holds one `JSONFactory.createWriteContext()` and passes it to `JSONWriter.of(ctx)`/`ofUTF8(ctx)`. The context is read-only during writing, so one per encoder is safe to share across threads.
- **No boxing on repeated primitives**: protobuf-java backs repeated numeric fields with `Internal.IntList`/`LongList`/`DoubleList`/`FloatList`/`BooleanList`; `List.get(i)` on those boxes every element. Both codegen and the typed accessors `instanceof`-narrow and read via the primitive getters, keeping a generic `List` branch for non-protobuf list implementations.
- **`message.getField(descriptor)`** for field access in pure-reflection path only (involves boxing for primitives). Codegen and typed-accessor paths bypass it entirely.
- **`Float.floatToRawIntBits() == 0`** for default value checks (correctly handles `-0.0`).
- **Native fastjson2 methods** for zero-allocation writes: `writeString(long)` for signed int64 (no `Long.toString()` allocation), `writeBase64(byte[])` for bytes fields (no intermediate Base64 String), `writeNameRaw(byte[])` for field names on UTF-8 path (direct `arraycopy`).
Expand All @@ -110,6 +114,13 @@ JSONFactory.getDefaultObjectReaderProvider().register(decoder.readerModule());
- **Decoder descriptor cache**: `GeneratedDecoderRegistry` is a simple `ConcurrentHashMap<Descriptor, Decoder>` populated as a side-effect of `instanceof` lookups. Used only for the descriptor-only decode path (nested messages in the runtime reflection path).
- **Baked JSON Schema resource (single home for comments, no injected code)**: The protoc plugin bakes one full JSON Schema per message to `META-INF/buff-json/schema/<fullName>.json` by reusing `ProtobufSchema` at code-gen time (comments from `SourceCodeInfo`, which protoc always sends to plugins; buf.validate constraints from a registered `ExtensionRegistry`). Nothing is injected into protobuf's generated classes — they keep depending only on `protobuf-java`. There is **no** separate comments resource: comments exist only inside the baked schema. At runtime `ProtobufSchema.generateJson()` returns the baked text verbatim, and `ProtobufSchema.generate()→Map` does the live descriptor walk (for exact `Integer`/`Long`/`Float`/`Double` types a JSON round-trip can't preserve) then **overlays the `description` fields** from the baked schema (parsed with fastjson2, structurally matched). When no baked schema exists (e.g. a `DynamicMessage` from a `.desc` with `--include_source_info`), comments come from `SourceCodeInfo`. GraalVM native-image friendly: `buff-json-schema` ships one `resource-config.json` whose glob includes the schema resources from any jar on the classpath, so consumers need no native-image config of their own.

## Encoding Performance

`docs/encoding-performance.md` is the standing audit of the encode path: what was measured and
applied, what measured as *nothing* and why (HotSpot already handled it), and the ranked backlog
of remaining opportunities with the reasoning behind each. Read it before optimizing the write
path — several attractive-looking ideas are recorded there as already-tried or already-refuted.

## Allocation Regression Check

`./allocation-check.sh` runs JMH `-prof gc` on a representative subset of benchmarks (SimpleMessage codegen+runtime × UTF-16+UTF-8, ComplexMessage codegen+runtime, DoubleHeavy codegen × UTF-16+UTF-8) and asserts `gc.alloc.rate.norm` (bytes per `@Benchmark` invocation) stays within per-benchmark budgets defined in the script. Total runtime ~1 minute. `--quick` flag for local iteration. Wired into CI as a separate `allocation-check` job in `.github/workflows/ci.yml`. Catches regressions like a missed zero-alloc path, a forgotten try-with-resources, or a new String/byte[] allocation per call.
Expand Down
22 changes: 12 additions & 10 deletions allocation-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,23 @@ RESULTS_FILE="${RESULTS_FILE:-/tmp/buff-json-alloc-check.json}"
# across runs and platforms. If you tighten or widen a budget, update the
# baseline comment.
BUDGETS=(
# SimpleMessage (6 fields, ~80-byte JSON output) — String/byte[] return is the
# dominant allocation; the encoder itself is near-zero.
"io.suboptimal.buffjson.benchmarks.SimpleMessageBenchmark.compiledUtf16:380" # baseline ~296 B/op
"io.suboptimal.buffjson.benchmarks.SimpleMessageBenchmark.compiledUtf8:350" # baseline ~272 B/op
"io.suboptimal.buffjson.benchmarks.SimpleMessageBenchmark.runtimeUtf16:400" # baseline ~296 B/op (typed-accessor)
"io.suboptimal.buffjson.benchmarks.SimpleMessageBenchmark.runtimeUtf8:380" # baseline ~272 B/op (typed-accessor)
# SimpleMessage (6 fields, ~80-byte JSON output) — the String/byte[] return is
# now essentially the *only* allocation; the encoder itself is zero. Dropped
# by 88 B/op when BuffJsonEncoder started sharing one JSONWriter.Context
# instead of letting JSONWriter.of() allocate one per call.
"io.suboptimal.buffjson.benchmarks.SimpleMessageBenchmark.compiledUtf16:290" # baseline ~208 B/op
"io.suboptimal.buffjson.benchmarks.SimpleMessageBenchmark.compiledUtf8:260" # baseline ~184 B/op
"io.suboptimal.buffjson.benchmarks.SimpleMessageBenchmark.runtimeUtf16:290" # baseline ~208 B/op (typed-accessor)
"io.suboptimal.buffjson.benchmarks.SimpleMessageBenchmark.runtimeUtf8:260" # baseline ~184 B/op (typed-accessor)

# ComplexMessage (nested + repeated + maps + oneof) — bigger output, more
# internal collections.
"io.suboptimal.buffjson.benchmarks.ComplexMessageBenchmark.buffJsonCompiled:2300" # baseline ~1773 B/op
"io.suboptimal.buffjson.benchmarks.ComplexMessageBenchmark.buffJsonRuntime:2500" # baseline ~1773 B/op
"io.suboptimal.buffjson.benchmarks.ComplexMessageBenchmark.buffJsonCompiled:2000" # baseline ~1444 B/op
"io.suboptimal.buffjson.benchmarks.ComplexMessageBenchmark.buffJsonRuntime:1900" # baseline ~1308 B/op

# DoubleHeavy (25 doubles, IoT/telemetry profile) — number formatting cost.
"io.suboptimal.buffjson.benchmarks.DoubleHeavyBenchmark.compiledUtf16:2200" # baseline ~1773 B/op
"io.suboptimal.buffjson.benchmarks.DoubleHeavyBenchmark.compiledUtf8:2100" # baseline ~1749 B/op
"io.suboptimal.buffjson.benchmarks.DoubleHeavyBenchmark.compiledUtf16:2200" # baseline ~1685 B/op
"io.suboptimal.buffjson.benchmarks.DoubleHeavyBenchmark.compiledUtf8:2100" # baseline ~1661 B/op
)

# Parse args
Expand Down
Loading
Loading