Skip to content

refactor: Add yyjson as Primary UniValue Storage Backend#326

Open
pdath wants to merge 4 commits into
bitcoinknots:29.x-knotsfrom
pdath:lib-univalue-yyjson-pr
Open

refactor: Add yyjson as Primary UniValue Storage Backend#326
pdath wants to merge 4 commits into
bitcoinknots:29.x-knotsfrom
pdath:lib-univalue-yyjson-pr

Conversation

@pdath

@pdath pdath commented Jul 8, 2026

Copy link
Copy Markdown

refactor: Add yyjson as Primary UniValue Storage Backend

Summary

This pull request introduces yyjson as an optional, high-performance primary storage backend for the UniValue library. yyjson is a battle-hardened, widely deployed JSON parser that is recognised as one of the fastest available JSON libraries.

Key Features:

  • Public API UniValue Compatible: This implementation maintains compatibility with the existing UniValue library API, requiring zero changes to the rest of Bitcoin Knots codebase.
  • Disabled by default, users must explicitly enable it using -DWITH_YYJSON=ON. Existing users unaffected.
  • Performance Optimized: Specifically designed to optimise Bitcoin Knots' performance for mining operations

Architecture Overview

The implementation uses yyjson as the primary storage mechanism for all UniValue containers, with the original val, keys, and values members serving as lazy caches. This approach ensures that all UniValues benefit from yyjson's optimised serialisation, not just those parsed from JSON input.

Semantics Change

Neither the original UniValue nor this yyjson version are thread-safe. The caller must take precautions (such as using a mutex) to protect access.

This yyjson version is even less thread-safe. A "const" UniValue parameter could potentially be changed by a different concurrent thread, breaking the "const" guarantee. This limitation is accepted to maintain public API compatibility (and to avoid changes to anything else in Bitcoin Knots). Everything in Bitcoin Knots already either uses a mutex to protect access to UniValue or is a per-request local, which prevents this from being an issue.

Implementation Details

Build System Integration

The implementation uses a CMake option -DWITH_YYJSON to toggle between the original UniValue implementation and the new yyjson-based implementation:

  • -DWITH_YYJSON=OFF (default): Uses the original UniValue files
  • -DWITH_YYJSON=ON: Uses the new yyjson-based implementation

This dual-path approach enables zero-risk development and deployment.

Code Organisation

To minimise duplication and maintain consistency, common methods shared between both implementations have been extracted to:

  • univalue_common.cpp
  • univalue_common.h

These common files contain approximately 99% identical code to the originals. Several bugs resulting from failure to perform proper bounds checking were identified and fixed

The yyjson-specific implementation consists of:

  • univalue_yyjson.cpp - Main yyjson backend implementation
  • univalue_yyjson_get.cpp - Getter methods for yyjson backend
  • univalue_yyjson_read.cpp - JSON reading/parsing with yyjson
  • univalue_yyjson_write.cpp - JSON writing/serialization with yyjson
  • yyjson/yyjson.c and yyjson/yyjson.h - The yyjson library itself

Problem Being Solved

This pull request addresses the performance bottlenecks in Bitcoin Knots JSON serialisation and deserialization, particularly for mining-related operations. The standard UniValue implementation, while correct and reliable, has become a performance-limiting factor in high-throughput scenarios such as:

  • Block to JSON conversion for mining
  • Mempool RPC operations (getblocktemplate, getblock, etc.)
  • Block template generation
  • General JSON serialization/deserialization across the node

Testing

Extensive testing has been performed on Ubuntu to ensure correctness, stability, and memory safety:
All tests have been run with both WITH_YYJSON=OFF and WITH_YYJSON=ON:

  • All 57 existing UniValue unit tests (verifying API compatibility, behavioural compatibility, edge cases, error handling)
  • All UniValue object tests
  • All ctest tests
  • All ctest tests with Valgrind memory analysis
  • All ctest tests with Address Sanitiser
  • All functional tests

All tests pass cleanly with both the original and yyjson implementations.

I mined a block on [testnet4] against commit a103f93: 143281
I mined a block on [testnet4] against commit 2a3b190: 144657

Benchmark Results (on my machine)

Comprehensive benchmarking demonstrates significant improvements in RPC performance across various operations. The test BlockToJsonVerboseWrite models the getblocktemplate function, and rpcmempool models mempool processing.
It is likely that there will be other flow-on benefits from reduced global mutex processing, but these have not been measured.

Benchmark OFF (ns) ON (ns) Improvement Speedup
BlockToJsonVerboseWrite 36,976,768 7,258,135 +80.4% 5.1x
BlockToJsonVerbosity1 542,752 530,374 +2.3% 1.0x
BlockToJsonVerbosity2 78,950,889 72,443,709 +8.2% 1.1x
BlockToJsonVerbosity3 78,781,516 72,291,663 +8.2% 1.1x
RpcMempool 13,163,229 12,055,689 +8.4% 1.1x

Reproducing Benchmark Results

To reproduce the benchmark results, use the following commands. Note that each benchmark will take 2.5 minutes to run.

Build Without yyjson

cmake -B build-yyjson-off \
  -DRDTS_CONSENT=IMPLICIT \
  -DCMAKE_BUILD_TYPE=Release \
  -DBUILD_BENCH=ON

cmake --build build-yyjson-off -j$(nproc) -t bench_bitcoin 

./build-yyjson-off/bin/bench_bitcoin -filter="BlockToJson.*|RpcMempool" -min-time=30000 |  awk -F'|' '/,/{print $2,$NF}'

Build With yyjson

cmake -B build-yyjson-on \
  -DRDTS_CONSENT=IMPLICIT \
  -DWITH_YYJSON=ON \
  -DCMAKE_BUILD_TYPE=Release \
  -DBUILD_BENCH=ON 

cmake --build build-yyjson-on -j$(nproc) -t bench_bitcoin 

./build-yyjson-on/bin/bench_bitcoin -filter="BlockToJson.*|RpcMempool" -min-time=30000 |  awk -F'|' '/,/{print $2,$NF}'

The above commands will output "ns/op" followed by the test name.
Compare the results to verify the performance improvements shown in the benchmark table above.

Comment thread src/univalue/lib/univalue_yyjson.cpp Outdated
Comment thread src/univalue/lib/univalue_yyjson.cpp Outdated
Comment thread src/univalue/lib/univalue_yyjson.cpp
Comment thread src/univalue/lib/univalue.cpp Outdated
@kwsantiago

Copy link
Copy Markdown

The lint failure here is caused by this PR, not by pre-existing CI breakage.

Exactly one linter exits non-zero:

src/univalue/yyjson/yyjson.h seems to be missing the expected include guard:
  #ifndef BITCOIN_UNIVALUE_YYJSON_YYJSON_H

yyjson.h carries upstream's #ifndef YYJSON_H, and src/univalue/yyjson/ is not in the lint exclusion list. The two failure markers in the log are one root cause: lint-include-guards.py fails, and all_python_linters re-runs the same script.

I checked out the PR head and ran every test/lint/lint-*.py. lint-include-guards.py is the only one that fails.

The rest of the lint log is non-fatal output that also appears on 29.x-knots, which passes CI:

  • Duplicate include(s) in ...lint-includes.py prints these and exits 0.
  • FAIL: subtree directory was touched without subtree merge for src/minisketch / src/secp256k1 — printed, not fatal, and this PR touches neither.
  • circular-dependencies.py:29: unreachable code — vulture output, prints Python dead code detection found some issues and continues.
  • codespell optin ==> option — explicitly a warning.

On lint having been updated since the branch: the merge-base is f41f01e1e6de, which is the current 29.x-knots tip, zero commits behind. The linters are in-tree, and that commit's own CI run passed.

Suggested fix, which keeps yyjson.h byte-identical to upstream 0.12.0:

# test/lint/lint-include-guards.py
EXCLUDE_FILES_WITH_PREFIX = ['contrib/devtools/bitcoin-tidy',
                             'src/crypto/ctaes',
                             'src/tinyformat.h',
                             'src/bench/nanobench.h',
                             'src/univalue/yyjson/',
                             'src/test/fuzz/FuzzedDataProvider.h'] + SHARED_EXCLUDED_SUBTREES

With that one line, lint-include-guards.py exits 0 and all test/lint/lint-*.py pass on this branch.

Related: if src/univalue/yyjson/ were registered as a subtree the way secp256k1 and minisketch are, it would land in SHARED_EXCLUDED_SUBTREES automatically and there would be a documented path for pulling upstream fixes.

@pdath

pdath commented Jul 10, 2026

Copy link
Copy Markdown
Author
# test/lint/lint-include-guards.py
EXCLUDE_FILES_WITH_PREFIX = ['contrib/devtools/bitcoin-tidy',
                             'src/crypto/ctaes',
                             'src/tinyformat.h',
                             'src/bench/nanobench.h',
                             'src/univalue/yyjson/',
                             'src/test/fuzz/FuzzedDataProvider.h'] + SHARED_EXCLUDED_SUBTREES

With that one line, lint-include-guards.py exits 0 and all test/lint/lint-*.py pass on this branch.

I have used your recommended fix.

- Thread safety: Use std::atomic<bool> and std::mutex for lazy const access
  in materialization to support re-entry when materialized state changes
- Performance: Preserve yyjson tree through copy operations using
  yyjson_mut_val_mut_copy() to avoid performance regression from unconditional
  tree discarding
- Fix push_backV to handle nested aliases and prevent iterator invalidation
- Change pushKVs to take UniValue by value to avoid aliasing issues
- Remove unnecessary std::string allocation in UniValue::read
- Fix writeYyjson to not serialize entire document for child nodes
- Add vendor.md for yyjson version tracking
- Add Doxygen documentation for thread safety and performance considerations
- Add lint exclusion for yyjson vendor directory
@pdath

pdath commented Jul 11, 2026

Copy link
Copy Markdown
Author

I have added commit 62cc41f to address @kwsantiago comments.

Specifically addressed includes:

  • Thread safety: Use std::atomic and std::mutex for lazy const access
    in materialization to support re-entry when materialized state changes
  • Performance: Preserve yyjson tree through copy operations using
    yyjson_mut_val_mut_copy() to avoid performance regression from unconditional
    tree discarding
  • Fix push_backV to handle nested aliases and prevent iterator invalidation
  • Change pushKVs to take UniValue by value to avoid aliasing issues
  • Remove unnecessary std::string allocation in UniValue::read
  • Fix writeYyjson to not serialize entire document for child nodes
  • Add vendor.md for yyjson version tracking
  • Add Doxygen documentation for thread safety and performance considerations
  • Add lint exclusion for yyjson vendor directory

Comment thread src/univalue/include/univalue.h Outdated
mutable std::shared_ptr<yyjson_mut_doc> m_yyjson_doc; //!< Shared pointer to yyjson mutable document (primary storage)
mutable yyjson_mut_val* m_yyjson_node{nullptr}; //!< Pointer to the root node in the yyjson tree
mutable std::atomic<bool> m_materialized{false}; //!< Whether lazy caches (val/keys/values) have been populated
mutable std::mutex m_materialize_mutex; //!< Protects materialization to allow re-entry when m_materialized changes

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A std::mutex per UniValue is heavy: 40 bytes plus a non-trivial ctor/dtor on every instance, and UniValue is created per field and per array element. A getblock/getblocktemplate response builds and tears down thousands of them. A single doc-level lock, or the std::call_once you mentioned, would avoid paying this on every node.

@pdath pdath Jul 12, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did attempt to use std::call_once, but I ran into an issue with rematerialization, and was forced to change.
The UniValue design requires re-running materialization when the yyjson tree changes. Operations like push_back and pushKV modify the tree, then call "m_materialized.store(false);".

I like your idea of a single doc-level lock. I plan to implement this idea.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The single doc-level lock sounded so simple. It has had a little complexity explosion. :-)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have now removed all mutexes to match the original UniValue implementation.

UniValue::UniValue(UniValue::VType type, std::string str) : typ(type) {
// Containers need yyjson documents for tree building
if (type == VOBJ || type == VARR) {
m_yyjson_doc = std::shared_ptr<yyjson_mut_doc>(yyjson_mut_doc_new(nullptr), yyjson_doc_deleter);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The copy ctor now allocates a fresh yyjson doc and deep-copies the tree on every container copy. That fixes the dropped-tree issue but copies are cheap in the legacy path, so this could be net slower. Worth re-running the benchmark table, it predates this commit and both this and the mutex change affect it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current benchmark numbers in the top description have been re-run, but I will look into this further.

Comment thread src/univalue/lib/univalue_yyjson.cpp Outdated
@pdath pdath changed the title refactor: Add yyjson as Primary UniValue Storage Backend wip: refactor: Add yyjson as Primary UniValue Storage Backend Jul 13, 2026
@pdath
pdath force-pushed the lib-univalue-yyjson-pr branch from 7e882ed to 2a3b190 Compare July 18, 2026 03:48
@pdath pdath changed the title wip: refactor: Add yyjson as Primary UniValue Storage Backend refactor: Add yyjson as Primary UniValue Storage Backend Jul 19, 2026
@pdath
pdath force-pushed the lib-univalue-yyjson-pr branch 2 times, most recently from 6a27316 to 660880e Compare July 19, 2026 07:20
@pdath
pdath requested a review from kwsantiago July 19, 2026 07:58
@pdath
pdath force-pushed the lib-univalue-yyjson-pr branch from 660880e to 838afc8 Compare July 20, 2026 03:53
@pdath
pdath force-pushed the lib-univalue-yyjson-pr branch from 838afc8 to 84a23e3 Compare July 20, 2026 09:28
@mweinberg

Copy link
Copy Markdown

tACK 2a3b190

Tested using four different AWS Graviton-based instances:

t4g.medium (2 x Graviton2 ARM)
m8g.medium (1 x Graviton4 ARM)
c8g.large (2 x Graviton4 ARM)
m9g.medium (1 x Graviton5 ARM)

bench_bitcoin results — yyjson vs stock UniValue

Bitcoin Knots v29.3.knots20260507 + pdath cherry-picks (yyjson, ARM crypto fix, jemalloc)
Values in nanoseconds per iteration (lower is better).

Compile flags

cmake -B build \
  -DENABLE_WALLET=OFF -DWITH_ZMQ=ON -DWITH_JEMALLOC=ON \
  -DWITH_YYJSON=ON \    # OFF for stock UniValue build
  -DENABLE_TOR_SUBPROCESS=OFF -U RDTS_CONSENT \
  -DBUILD_BENCH=ON -DBUILD_GUI=OFF -DCMAKE_BUILD_TYPE=Release \
  -DCMAKE_CXX_FLAGS_RELEASE="-O2 -g0 -march=armv8.2-a+crypto" \
  -DCMAKE_C_FLAGS_RELEASE="-O2 -g0 -march=armv8.2-a+crypto"

Benchmark command

bench_bitcoin -filter="BlockToJson.*|RpcMempool" -min-time=30000

yyjson ON

Instance Type CPU VerboseWrite Verbosity1 Verbosity2 Verbosity3 RpcMempool
m9g.medium 1× Graviton5 2,648,488 346,482 26,621,077 26,367,255 4,640,323
m8g.medium 1× Graviton4 3,268,988 424,728 32,297,741 32,169,720 5,885,388
c8g.large 2× Graviton4 3,283,406 420,935 31,970,116 31,983,013 5,841,669
t4g.medium 2× Graviton2 6,305,617 691,679 59,804,034 59,750,441 11,033,945

yyjson OFF (stock UniValue)

Instance Type CPU VerboseWrite Verbosity1 Verbosity2 Verbosity3 RpcMempool
m9g.medium 1× Graviton5 19,948,626 350,061 28,734,921 28,586,425 4,007,772
m8g.medium 1× Graviton4 23,582,258 417,550 31,818,755 31,961,577 5,118,767
c8g.large 2× Graviton4 22,921,433 412,095 32,526,511 32,556,474 5,255,708
t4g.medium 2× Graviton2 38,520,707 651,211 61,295,996 62,122,416 11,089,145

BlockToJsonVerboseWrite speedup (getblocktemplate path)

Instance Type CPU Improvement Speedup
m9g.medium 1× Graviton5 86.7% 7.53×
m8g.medium 1× Graviton4 86.1% 7.22×
c8g.large 2× Graviton4 85.7% 6.98×
t4g.medium 2× Graviton2 83.6% 6.11×

RpcMempool regression

Instance Type CPU OFF (ns) ON (ns) Regression
m9g.medium 1× Graviton5 4,007,772 4,640,323 -15.8%
m8g.medium 1× Graviton4 5,118,767 5,885,388 -15.0%
c8g.large 2× Graviton4 5,255,708 5,841,669 -11.1%
t4g.medium 2× Graviton2 11,089,145 11,033,945 +0.5%

Key findings

  • yyjson GBT speedup is consistent: 6.1–7.5× across all Graviton generations
  • Graviton5 is fastest: 2.6ms per GBT serialization (vs 3.3ms Graviton4, 6.3ms Graviton2)
  • Core count does not affect serialization benchmarks (single-threaded)
  • RpcMempool regression is Graviton4/5 specific (~11-16%); absent on Graviton2 — possibly related to memory allocation patterns on newer microarchitectures
  • Graviton2 → Graviton4 jump is ~1.9× on VerboseWrite; Graviton4 → Graviton5 is ~1.24×

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants