Files
json/tests/benchmarks/json_view/compare.py
T
Niels Lohmann b38c056204 json_view benchmarks: compare fairly and state what each row means
- bench_view: add "(editable)" rows for json_editable_document and a
  parse+dump workload, so the cost of lazy float conversion shows next to the
  parse row (the traverse workload already includes the parse). Check that
  all engines' dumps agree where outputs are comparable.
- compare.py: add --aligned, which also builds and runs with aligned code and
  reports both tables, so code-layout artifacts become visible. Off by
  default; the default output is unchanged. Not run end to end yet.
- README: say which engines are like-for-like peers (yyjson, simdjson DOM) and
  which are mutable reference points (Boost.JSON, json::parse, the editable
  json_view); explain the lazy float conversion and the eager prototype that
  was measured and dropped.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
2026-10-25 10:54:04 +01:00

365 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
# __ _____ _____ _____
# __| | __| | | | JSON for Modern C++ (supporting code)
# | | |__ | | | | | | version 3.12.0
# |_____|_____|_____|_|___| https://github.com/nlohmann/json
#
# SPDX-FileCopyrightText: 2013-2026 Niels Lohmann <https://nlohmann.me>
# SPDX-License-Identifier: MIT
"""Compare json_view with yyjson, simdjson, Boost.JSON, and json::parse.
Builds bench_view.cpp, bench_corpus.cpp, and bench_edit.cpp against the include/ directory of
this checkout, runs them, and writes the results with everything needed to
reproduce them (date, commit, CPU, OS, compiler, library versions, flags) to
results/<date>-<host>.md and .csv next to this script.
The other libraries come from the system (--system, the default: pkg-config
or Homebrew) or are downloaded as pinned releases and checked against their
SHA-256 (--download). Boost.JSON is optional: without Boost headers, its
columns are skipped, and the results say so.
Only the Python 3 standard library is used; a C++17 compiler is needed.
"""
import argparse
import datetime
import hashlib
import os
import platform
import re
import shlex
import shutil
# runs only the compilers and benchmark binaries this script builds
import subprocess # nosec B404
import sys
import tarfile
import tempfile
import urllib.request
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, '..', '..', '..'))
# seconds without data after which a download fails
DOWNLOAD_TIMEOUT = 60
# pinned releases for --download; the hashes are those of the archives
#
# The yyjson and simdjson archives are the tag archives GitHub generates
# (archive/refs/tags/...), whose bytes GitHub does not guarantee to be stable. A SHA-256
# mismatch for one of them means that GitHub regenerated the archive: check the upstream
# tag, then pin the new hash.
PINNED = {
'yyjson': {
'version': '0.13.0',
'url': 'https://github.com/ibireme/yyjson/archive/refs/tags/0.13.0.tar.gz',
'sha256': '34e0f62a2bc11ab20d601e8ca1cc2b2079503aa45119a19133d89d19b94a0fae',
'dir': 'yyjson-0.13.0',
},
'simdjson': {
'version': '4.6.11',
'url': 'https://github.com/simdjson/simdjson/archive/refs/tags/v4.6.11.tar.gz',
'sha256': '61d948fc24f0d793829ad658058e7597d064988a89b4607ea02e401a82df98ff',
'dir': 'simdjson-4.6.11',
},
'boost': {
'version': '1.92.0',
'url': 'https://archives.boost.io/release/1.92.0/source/boost_1_92_0.tar.gz',
'sha256': 'c4a3b310ddd2472416e091067166b0713be97c63f38c212c484ada022fd296ce',
'dir': 'boost_1_92_0',
},
}
# the documents of bench_view.cpp, relative to the json_test_data directory
DEFAULT_CORPUS = [
'nativejson-benchmark/twitter.json',
'nativejson-benchmark/citm_catalog.json',
'nativejson-benchmark/canada.json',
'jeopardy/jeopardy.json',
]
def run(cmd, **kwargs):
print('+ ' + ' '.join(shlex.quote(c) for c in cmd), flush=True)
# cmd is an argument list built by this script, never a shell string
return subprocess.run(cmd, check=True, **kwargs) # nosec B603
def output(cmd):
try:
# cmd is an argument list built by this script, never a shell string
return subprocess.run(cmd, check=True, capture_output=True, text=True).stdout.strip() # nosec B603
except (OSError, subprocess.CalledProcessError):
return ''
# ---------------------------------------------------------------------------
# libraries
# ---------------------------------------------------------------------------
class Library:
"""include directories, sources to compile, and linker flags of a library"""
def __init__(self, name, include=None, sources=None, link=None, version=''):
self.name = name
self.include = include or []
self.sources = sources or []
self.link = link or []
self.version = version
def header_version(path, pattern):
try:
with open(path, encoding='utf-8', errors='replace') as f:
m = re.search(pattern, f.read())
return m.group(1) if m else ''
except OSError:
return ''
def library_version(name, include_dirs):
patterns = {
'yyjson': ('yyjson.h', r'#define\s+YYJSON_VERSION_STRING\s+"([^"]+)"'),
'simdjson': ('simdjson.h', r'#define\s+SIMDJSON_VERSION\s+"?([0-9.]+)"?'),
'boost': (os.path.join('boost', 'version.hpp'), r'#define\s+BOOST_LIB_VERSION\s+"([^"]+)"'),
}
header, pattern = patterns[name]
for d in include_dirs:
v = header_version(os.path.join(d, header), pattern)
if v:
return v.replace('_', '.')
return ''
def system_library(name):
"""a library found with pkg-config or Homebrew, or None"""
flags = output(['pkg-config', '--cflags', '--libs', name]).split()
if flags:
include = [f[2:] for f in flags if f.startswith('-I')]
link = [f for f in flags if f.startswith('-L') or f.startswith('-l')]
libdirs = [f[2:] for f in link if f.startswith('-L')]
link += ['-Wl,-rpath,' + d for d in libdirs]
return Library(name, include, [], link, library_version(name, include))
prefix = output(['brew', '--prefix', name]) if shutil.which('brew') else ''
if prefix and os.path.isdir(os.path.join(prefix, 'include')):
include = [os.path.join(prefix, 'include')]
link = []
if name != 'boost':
lib = os.path.join(prefix, 'lib')
link = ['-L' + lib, '-l' + name, '-Wl,-rpath,' + lib]
return Library(name, include, [], link, library_version(name, include))
if name == 'boost':
for d in ['/usr/include', '/usr/local/include']:
if os.path.isfile(os.path.join(d, 'boost', 'json.hpp')):
return Library(name, [d], [], [], library_version(name, [d]))
return None
def sha256_file(path):
"""SHA-256 of a file, read in chunks (the Boost archive is more than 100 MB)"""
h = hashlib.sha256()
with open(path, 'rb') as f:
for chunk in iter(lambda: f.read(1 << 20), b''):
h.update(chunk)
return h.hexdigest()
def download_library(name, work):
"""a pinned release, downloaded and checked, or an error"""
pin = PINNED[name]
archive = os.path.join(work, 'download', os.path.basename(pin['url']))
os.makedirs(os.path.dirname(archive), exist_ok=True)
if not os.path.isfile(archive):
print(f'downloading {pin["url"]}', flush=True)
# the URLs are the https constants in PINNED, and the SHA-256 is checked below
# (into a .part file first, so that an interrupted download is not kept)
with urllib.request.urlopen(pin['url'], timeout=DOWNLOAD_TIMEOUT) as response: # nosec B310
with open(archive + '.part', 'wb') as f:
shutil.copyfileobj(response, f)
os.replace(archive + '.part', archive)
digest = sha256_file(archive)
if digest != pin['sha256']:
os.remove(archive) # downloaded again by the next run
sys.exit(f'error: SHA-256 of {archive} is {digest}, expected {pin["sha256"]} (removed)')
src = os.path.join(work, 'download', pin['dir'])
if not os.path.isdir(src):
# extracted into a temporary directory first, so that an interrupted extraction
# is not mistaken for a complete one
tmp = tempfile.mkdtemp(prefix=pin['dir'] + '.part-', dir=os.path.join(work, 'download'))
try:
with tarfile.open(archive) as t:
# (the 'data' filter rejects links and paths outside the target where Python has it)
kwargs = {'filter': 'data'} if hasattr(tarfile, 'data_filter') else {}
t.extractall(tmp, **kwargs) # noqa: S202 (checked archive) # nosec B202
os.rename(os.path.join(tmp, pin['dir']), src)
finally:
shutil.rmtree(tmp, ignore_errors=True)
if name == 'yyjson':
return Library(name, [os.path.join(src, 'src')], [os.path.join(src, 'src', 'yyjson.c')], [], pin['version'])
if name == 'simdjson':
single = os.path.join(src, 'singleheader')
return Library(name, [single], [os.path.join(single, 'simdjson.cpp')], [], pin['version'])
return Library(name, [src], [], [], pin['version'])
# ---------------------------------------------------------------------------
# machine description
# ---------------------------------------------------------------------------
def cpu_model():
if sys.platform == 'darwin':
return output(['sysctl', '-n', 'machdep.cpu.brand_string'])
try:
with open('/proc/cpuinfo', encoding='utf-8') as f:
for line in f:
if line.startswith('model name') or line.startswith('Model'):
return line.split(':', 1)[1].strip()
except OSError:
pass
# (AArch64 Linux: /proc/cpuinfo has no model name, lscpu knows it)
for line in output(['lscpu']).splitlines():
if line.startswith('Model name:'):
return line.split(':', 1)[1].strip()
return platform.processor() or platform.machine()
def git_commit():
commit = output(['git', '-C', REPO, 'rev-parse', '--short=12', 'HEAD'])
dirty = output(['git', '-C', REPO, 'status', '--porcelain', '--untracked-files=no'])
return commit + (' (with local changes)' if dirty else '')
# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--data', required=True, help='json_test_data directory (with nativejson-benchmark/ and jeopardy/)')
ap.add_argument('--download', action='store_true', help='use pinned downloads instead of system libraries')
ap.add_argument('--no-boost', action='store_true', help='skip Boost.JSON')
ap.add_argument('--native', action='store_true', help='compile for this CPU (-march=native / -mcpu=native)')
ap.add_argument('--aligned', action='store_true',
help='also build and run with all functions (and loops) aligned, to make code layout artifacts visible')
ap.add_argument('--rounds', type=int, default=30, help='rounds of bench_view (default: 30)')
ap.add_argument('--corpus', nargs='*', default=[], help='more files for bench_corpus')
ap.add_argument('--build-dir', default=os.path.join(HERE, 'build'), help='where to build (default: build/ next to this script)')
args = ap.parse_args()
# the benchmarks run in the build directory: make the paths absolute
args.data = os.path.abspath(args.data)
args.corpus = [os.path.abspath(f) for f in args.corpus]
args.build_dir = os.path.abspath(args.build_dir)
# (CXX and CC may contain arguments, e.g. 'ccache g++')
cxx = shlex.split(os.environ.get('CXX', 'c++'))
cc = shlex.split(os.environ.get('CC', 'cc'))
os.makedirs(args.build_dir, exist_ok=True)
libs = {}
for name in ['yyjson', 'simdjson', 'boost']:
if name == 'boost' and args.no_boost:
continue
lib = download_library(name, args.build_dir) if args.download else system_library(name)
if lib is None and name != 'boost':
sys.exit(f'error: {name} not found; install it, or use --download')
if lib is not None:
libs[name] = lib
with_boost = 'boost' in libs
if not with_boost:
print('Boost.JSON not found: its columns are skipped', flush=True)
flags = ['-std=c++17', '-O3', '-DNDEBUG', f'-DJSON_VIEW_BENCH_BOOST={1 if with_boost else 0}']
if args.native:
flags.append('-mcpu=native' if platform.machine().lower() in ('arm64', 'aarch64') else '-march=native')
include = ['-I' + os.path.join(REPO, 'include')] + ['-I' + d for lib in libs.values() for d in lib.include]
link = [f for lib in libs.values() for f in lib.link]
# C sources of downloaded libraries are compiled once
objects = []
for lib in libs.values():
for src in lib.sources:
obj = os.path.join(args.build_dir, os.path.basename(src) + '.o')
is_c = src.endswith('.c')
run((cc if is_c else cxx) + ([] if is_c else ['-std=c++17']) + ['-O3', '-DNDEBUG', '-c', src, '-o', obj]
+ ['-I' + d for d in lib.include])
objects.append(obj)
cxx_version = output(cxx + ['--version'])
is_clang = 'clang' in cxx_version.lower()
# alignment flags: functions and (gcc) loops on 64-byte boundaries
align_flags = ['-mllvm', '-align-all-functions=6'] if is_clang else ['-falign-functions=64', '-falign-loops=64']
# the builds: the default one and, with --aligned, one with aligned code
variants = [('default build', args.build_dir, [])]
if args.aligned:
variants.append(('aligned build', os.path.join(args.build_dir, 'aligned'), align_flags))
corpus = [os.path.join(args.data, f) for f in DEFAULT_CORPUS] + args.corpus
# label -> {benchmark: output}
all_outputs = {}
for label, build_dir, extra in variants:
os.makedirs(build_dir, exist_ok=True)
binaries = {}
for bench in ['bench_view', 'bench_corpus', 'bench_edit']:
exe = os.path.join(build_dir, bench)
run(cxx + flags + extra + include + [os.path.join(HERE, bench + '.cpp')] + objects + link + ['-o', exe])
binaries[bench] = exe
# run: bench_view on its documents, bench_corpus on those and the given files
outputs = {}
outputs['bench_view'] = run([binaries['bench_view'], args.data, str(args.rounds)], cwd=build_dir,
capture_output=True, text=True).stdout
outputs['bench_corpus'] = run([binaries['bench_corpus']] + corpus, cwd=build_dir,
capture_output=True, text=True).stdout
outputs['bench_edit'] = run([binaries['bench_edit'], args.data, str(max(1, args.rounds // 2))], cwd=build_dir,
capture_output=True, text=True).stdout
for name, text in outputs.items():
print(text)
all_outputs[label] = outputs
# results with their metadata
now = datetime.datetime.now()
host = re.sub(r'[^A-Za-z0-9-]+', '-', platform.node().split('.')[0]) or 'host'
stem = os.path.join(HERE, 'results', f'{now:%Y-%m-%d}-{host}')
os.makedirs(os.path.dirname(stem), exist_ok=True)
compiler_version = cxx_version.splitlines()[0] if cxx_version else ' '.join(cxx)
meta = [
('date', f'{now:%Y-%m-%d %H:%M}'),
('commit', git_commit()),
('CPU', cpu_model()),
('OS', f'{platform.system()} {platform.release()} ({platform.machine()})'),
('compiler', compiler_version),
('flags', ' '.join(flags)),
('aligned results', ('included (flags: ' + ' '.join(align_flags) + ')') if args.aligned else 'not included'),
('yyjson', libs['yyjson'].version),
('simdjson', libs['simdjson'].version),
('Boost.JSON', libs['boost'].version if with_boost else 'skipped (not found)'),
('libraries from', 'pinned downloads' if args.download else 'the system'),
('rounds', str(args.rounds)),
]
with open(stem + '.md', 'w', encoding='utf-8') as f:
f.write(f'# json_view comparison, {now:%Y-%m-%d}\n\n')
f.write('Generated by `tests/benchmarks/json_view/compare.py`; best of the interleaved rounds.\n\n')
f.write('| | |\n|---|---|\n')
for key, value in meta:
f.write(f'| {key} | {value} |\n')
for label, outputs in all_outputs.items():
# (without --aligned there is only the default build, and no extra heading level)
prefix = f'{label}: ' if args.aligned else ''
for name, text in outputs.items():
f.write(f'\n## {prefix}{name}\n\n```\n{text.rstrip()}\n```\n')
with open(stem + '.csv', 'w', encoding='utf-8') as out:
out.write(''.join(f'# {key}: {value}\n' for key, value in meta))
for label, build_dir, _ in variants:
for name in ['bench_view', 'bench_corpus', 'bench_edit']:
path = os.path.join(build_dir, name + '.csv')
if os.path.isfile(path):
with open(path, encoding='utf-8') as f:
# (the default build keeps its plain section names)
out.write(f'# {name}' + (f' ({label})' if args.aligned else '') + '\n' + f.read())
print(f'results: {stem}.md, {stem}.csv')
if __name__ == '__main__':
main()