Repository navigation
perf(codec): DirectHranaJsonCodec allocates 55% more than the Jackson baseline per scan and encodes 19% slower #291
Description
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
executeresponse 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
PipelineRespBodyvalues and produced byte-identical serialized requests. - Harness:
./mvnw -q -DskipTests -pl libsql-hrana-codec,libsql-client,libsql-jdbc -am package, then a standalonemaincompiled against the moduletarget/classesplusjackson-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[])fromnew String(src, start, len, UTF_8)atDirectHranaJsonCodec.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 aString(nextFieldName, line 967) and then switch-compared. Per row, ~12 of the ~15nextStringcalls are field names (type,value,base64, …), not text values. - 14.0%
DoubleToDecimal.toString(double)fromDouble.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 aStringforDouble.parseDouble. - 10.2% the
Valuerecords themselves, 7.2% Base64 decode, 4.5%parseRow(default-capacityArrayListfor 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)
- Match field names against the source bytes (constants/enum) instead of allocating a
Stringper occurrence. - Add an ASCII fast path that builds the compact
Stringwith one copy instead ofString(byte[], off, len, UTF_8)'scopyOfRange; keep the current path for non-ASCII. - Parse numbers from the byte range directly; drop
Double.toStringon the float path and the double → String →Long.parseLonground trip for integer-as-number. - Size
rows/valuesfrom the payload (or use a small explicit capacity) instead of default-10ArrayListgrowth. - Batch ASCII writes in
FastJsonWriterand 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
magnusp commentedon Oct 31, 2026
The probe is now wired into PRs: #292 adds
libsql-perfand aPerformanceworkflow 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.perf: post codec and JDBC benchmark results on pull requests (#292)