Skip to content

perf(codec): DirectHranaJsonCodec allocates 55% more than the Jackson baseline per scan and encodes 19% slower #291

Open

Description

@magnusp

Summary

A perf probe against main (db24924) found that the default DirectHranaJsonCodec is only ~5–7% faster than the optional Jackson codec on decode while allocating ~55% more per response. On encode it is ~19% slower. Because the direct codec is the default for every HTTP pipeline call, every large scan pays the extra garbage.

Both codecs were verified to return equal objects and byte-identical request JSON in the probe, so this is a performance gap, not a correctness difference.

Method

  • HEAD db24924, JDK 25.0.3 (Corretto), Linux x86_64, single-threaded, -Xmx2g, default G1.
  • Payload: Hrana execute response for 5,000 and 20,000 rows × 6 columns (INTEGER, TEXT, REAL, BLOB, TEXT, INTEGER), integers as string values, floats as JSON numbers, 16-byte base64 blobs, nulls. 1.5 MiB and 6.0 MiB of JSON. Shape: {"baton":…,"results":[{"type":"ok","response":{"type":"execute","result":{"cols":[…],"rows":[[{"type":"integer","value":"0"},{"type":"text","value":"user-0-name"},{"type":"float","value":0.25},{"type":"blob","base64":"…"},{"type":"text","value":"…"},{"type":"integer","value":"1"}],…]}}}]}.
  • 1.5 s warmup, then seven 0.5 s windows; median ops/s. Allocation from ThreadMXBean.getCurrentThreadAllocatedBytes(). Five separate JVMs per case; the table shows the median of the five medians.
  • Sanity check: both codecs decoded the same payload to equal PipelineRespBody values and produced byte-identical serialized requests.
  • Harness: ./mvnw -q -DskipTests -pl libsql-hrana-codec,libsql-client,libsql-jdbc -am package, then a standalone main compiled against the module target/classes plus jackson-core-2.22.4.jar.
Workload Direct Jackson Delta
Decode, 5k rows 348 ops/s (1.73M rows/s), 8.30 MB/op 330 ops/s (1.65M rows/s), 5.34 MB/op +5.5% throughput, +55.5% allocation
Decode, 20k rows 85 ops/s (1.69M rows/s), 33.14 MB/op 79 ops/s (1.58M rows/s), 21.30 MB/op +7.6% throughput, +55.6% allocation
Encode, one execute 1,238,817 ops/s, 1,376 B/op 1,523,768 ops/s, 1,840 B/op −18.7% throughput, −25% allocation

End-to-end calibration against a local ghcr.io/tursodatabase/libsql-server:v0.24.33 over HTTP, 20k-row table: scan 210–230k rows/s (90 ms for the 6 MB result), point select p50 0.34 ms / p99 0.67 ms, single-row insert ~700/s (server fsync bound). Nothing anomalous at the JDBC/transport level; the codec is where the headroom is.

Where the allocations come from (JFR, direct decode, 5k rows, 8.30 MB/op)

Shares are sampled allocation pressure, so approximate; the totals are exact.

  • 30.4% Arrays.copyOfRange(byte[]) from new String(src, start, len, UTF_8) at DirectHranaJsonCodec.java:1035. The JDK copies the byte slice before decoding, so every string costs an extra array.
  • 24.8% FastJsonParser.nextString(): every field name is materialized as a String (nextFieldName, line 967) and then switch-compared. Per row, ~12 of the ~15 nextString calls are field names (type, value, base64, …), not text values.
  • 14.0% DoubleToDecimal.toString(double) from Double.toString(floatVal) (line 451). It runs for every float value and the result is unused on the float path. For integers encoded as JSON numbers the parser also round-trips double → String → Long.parseLong.
  • 7.5% nextDouble() allocates a String for Double.parseDouble.
  • 10.2% the Value records themselves, 7.2% Base64 decode, 4.5% parseRow (default-capacity ArrayList for 6 values).

The Jackson path reuses a TextBuffer and does not allocate a String per field name or a byte-slice copy.

Encode

FastJsonWriter.writeString writes one byte at a time with a capacity check, and writeRaw calls String.getBytes per literal (line 798); Jackson writes in bulk. The wire bytes are identical, so this is pure CPU overhead in the hand-written writer, not an allocation problem (Direct allocates less here).

Suggested fixes (impact order)

  1. Match field names against the source bytes (constants/enum) instead of allocating a String per occurrence.
  2. Add an ASCII fast path that builds the compact String with one copy instead of String(byte[], off, len, UTF_8)'s copyOfRange; keep the current path for non-ASCII.
  3. Parse numbers from the byte range directly; drop Double.toString on the float path and the double → String → Long.parseLong round trip for integer-as-number.
  4. Size rows/values from the payload (or use a small explicit capacity) instead of default-10 ArrayList growth.
  5. Batch ASCII writes in FastJsonWriter and pre-size the buffer.

Why file this

The module exists to be the zero-dependency, high-performance codec. A 55% allocation surplus buys only 5–7% throughput, and the encoder is slower than the baseline it replaces. At 1.7M rows/s the surplus is roughly 2.7 GB/s of young-gen garbage per decode thread on large scans.

Happy to land the probe as a committed benchmark (JMH or a perf module) and to prototype fixes 1–3.

Activity

  1. added
    enhancementNew feature or request
    P2Normal priority: edge case, feature gap, or test/process improvement
    on Oct 31, 2026
  2. magnusp commented on Oct 31, 2026

    @magnusp
    OwnerAuthor

    The probe is now wired into PRs: #292 adds libsql-perf and a Performance workflow that builds the PR merge commit and its base commit, runs three interleaved rounds, and posts the medians as a sticky comment. The first comment on that PR is head-only because the base has no module yet; later PRs get deltas. The payload shapes are the ones from this investigation, so the direct-vs-Jackson allocation gap stays visible in review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Normal priority: edge case, feature gap, or test/process improvementenhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions