Skip to content

build(linux): let the bundled jemalloc own every allocation in RocksDB - #13

Draft
kamilchodola wants to merge 2 commits into
mainfrom
feature/unified-allocator
Draft

kamilchodola wants to merge 2 commits into
mainfrom
feature/unified-allocator

Conversation

@kamilchodola

@kamilchodola kamilchodola commented Sep 5, 2026

Copy link
Copy Markdown

Status (2026-09-08): draft, performance claim withdrawn. The CPU reduction this PR was built on was a benchmark-harness artifact, not an allocator effect. Details in "What the measurements actually showed" below. The correctness content (closed hazard set, shims, denylist) is sound but does not justify the change on its own.

Summary

Make the bundled jemalloc own every allocation inside librocksdb.so, not only C++ new/delete.

Today's build (--with-jemalloc-prefix=je_ + --enable-cxx) deliberately leaves C malloc/free on libc so that memory crossing the C API boundary is never freed by the wrong allocator. The hazard that guard protects against is finite and closable; the hoped-for benefit was CPU.

What changes

  • build-rocksdb.yml gains a jemalloc input: complete (the new default), private (today's build, kept as the rollback) and none (no bundled allocator, for diagnosis).
  • complete builds jemalloc unprefixed and --wraps the libc calls whose result RocksDB frees itself onto shims in native/alloc_shims.c: strdup (SaveError in the C API), getline/getdelim (PosixHelper::GetQueueSysfsFileValueOfFd, on every DB::Open), and getcwd/realpath when passed NULL (ChrootFileSystem does that). The shims allocate from the bundled heap first and let glibc fill the buffer, so no glibc-owned pointer ever crosses an allocator boundary, which also keeps them correct when the host process interposes its own allocator through LD_PRELOAD.
  • Verify asserts that the shims linked, that malloc/free are defined but not exported, and that no glibc entry point returning caller-freed memory is imported (strdup, getline, strndup, asprintf, backtrace_symbols, scandir, open_memstream, ...), so a RocksDB upgrade cannot reintroduce the mismatch unnoticed.
  • The unstripped Linux library is uploaded as symbols-linux-<arch> so a crash can be symbolised.

Why the wrap is complete

The shipped library imports 258 symbols on x64 (259 on arm64). Of those, the only functions that leave malloc-owned memory in the caller's hands are strdup, __getdelim, getcwd and realpath; everything else that allocates (fopen, opendir, newlocale, iconv_open, ...) frees its own memory inside glibc, which can never reach the library's free because the version script keeps it local:. backtrace_symbols and strndup, which RocksDB does reference in source, are compiled out of this build and are covered by the denylist should that change. Both intermediate variants that missed a member of the set crashed deterministically in the test suite (#9 missed all four, #11 missed getline), which is what the assertion now prevents.

Tests: 345/345 on all six platforms for every build of this branch (runs 33921447156, 34096795512, 34116763361, 34129431423).

What the measurements actually showed

expb fusaka, 1000 mainnet blocks, flat layout, amd64, n=6 per arm across two mirrored dispatch orders, compared against the same Nethermind commit built with the private package, with an identical-package control image in every batch.

The claim. Five batches showed the arm jemalloc-complete (staging 11.8.1-preview.112) at −2.4% to −4.6% CPU with latency and memory unchanged.

The falsification. Three further complete builds (preview.120, .121, .123) measured within 1% of two independent private builds (.98, .92). .121 was built three minutes before .112 on the same commit and is byte-identical to it in code (0 differing bytes in .text; identical 22,670-function table). Swapping image tags between bit-identical images moved the "win" with the tag: the .121 bits under a jemalloc-complete… tag measured −3.6%, the .112 bits under a short tag −0.4%.

The cause. expb names the client expb-executor-nethermind-multi-<tag>-run<N>-nethermind and pointed Grafana Alloy's scrape target at that name. A tag of 17+ characters makes the name exceed the 63-character DNS label limit, Docker's embedded DNS cannot resolve it, Alloy never scrapes the client, and the client skips ~0.1 core of Prometheus exposition work: about 4% of run CPU, all of it in the post-benchmark idle phase, none of it in block processing. Fixed in NethermindEth/execution-payloads-benchmarks#29 (scrape through a short network alias). On the fixed harness the original jemalloc-complete image measures +0.3% CPU against private (both orders positive).

Fair verdict for complete vs private (all arms scraped, 30 runs of complete across five builds): CPU within ±1%, per-block latency inside the A/A floor with sign changing between batches, memory unchanged. Removing the bundled allocator entirely still costs +2.9% CPU (fair comparison), so the allocator matters; the C malloc calls this PR moves onto it are too few to.

Risk and rollback

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Fixed-size path wrappers break valid libc behavior, and symbol artifacts would leak into generated dependency PRs.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Makes bundled jemalloc own all Linux RocksDB allocations while preserving allocator safety.

Changes:

  • Adds allocator and performance-context build modes.
  • Adds libc allocation shims and binary verification.
  • Uploads unstripped Linux libraries for symbolication.
File summaries
File Description
AGENTS.md Documents the allocator configuration.
.github/workflows/build-rocksdb.yml Implements allocator modes, shims, verification, and symbol artifacts.
Review details

Suppressed comments (1)

.github/workflows/build-rocksdb.yml:190

  • Replacing realpath(path, NULL) with a fixed PATH_MAX buffer changes its contract: glibc's dynamic form can resolve valid canonical paths longer than PATH_MAX, while this wrapper fails with ENAMETOOLONG. Route the dynamically sized result onto jemalloc without imposing this fixed limit so valid RocksDB paths do not start failing.
          char *__wrap_realpath(const char *path, char *resolved)
          {
              if (resolved) return __real_realpath(path, resolved);
              char *p = malloc(PATH_MAX);
              if (!p) return NULL;
              if (!__real_realpath(path, p)) { free(p); return NULL; }
  • Files reviewed: 2/2 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .github/workflows/build-rocksdb.yml Outdated
Comment thread .github/workflows/build-rocksdb.yml Outdated
@kamilchodola

Copy link
Copy Markdown
Author

Addressed all three Copilot findings in 746d90b:

  • getcwd(NULL, size) / realpath(path, NULL): the wrappers no longer impose a PATH_MAX buffer. They now let glibc size the dynamic result itself (__real_getcwd(NULL, size), __real_realpath(path, NULL)), copy the bytes onto the bundled allocator, and release glibc's buffer with __libc_free — so size semantics, growth, and ERANGE/ENAMETOOLONG behaviour are exactly libc's. (getline stays reimplemented rather than delegated, because glibc would realloc the caller's existing buffer — which is now jemalloc's — with its own allocator.)
  • Symbols artifact leaking into create-pr: renamed linux-<arch>-unstrippedsymbols-linux-<arch>, outside the {linux,osx,win}-* download pattern. Verified 11.8.1-preview.112 was not affected — six runtime files, same layout and size as .92; the stray file only reached the generated branch.

Re-running the six-platform test gate on the new shim before this is merged; will note the result here.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Allocator-boundary changes can cause latent heap corruption and warrant final human review despite the included checks.

Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@kamilchodola

Copy link
Copy Markdown
Author

Gate re-run on 746d90b (the Copilot fixes): 345/345 on all six platforms (run 34096795512 → test-publish on the generated branch, tests only, nothing published; gate PR #14 closed). Verify on x64: 1749 rocksdb_* exports, __wrap_strdup/__wrap_getcwd/__wrap_realpath/__wrap___getdelim linked, none of strdup __getdelim getdelim getline imported. The branch head is ready for review; a Copilot re-review can be re-requested against it.

The library used to bundle jemalloc prefixed, serving only C++ new/delete
and leaving C malloc on libc so that memory crossing the C API could never
be freed by the wrong allocator. That split costs about half of the
allocator's benefit: on 1000 mainnet blocks the unprefixed build spends
2.9-4.2% less CPU with unchanged latency.

The hazard the split guarded against is closed instead. glibc hands RocksDB
heap memory that RocksDB later frees from four calls - strdup (SaveError),
getline (sysfs queue values on DB::Open), and getcwd/realpath with a NULL
buffer - so those are wrapped onto shims in native/alloc_shims.c that
allocate from the bundled heap. Verify asserts that the shims linked, that
malloc/free are defined but unexported, and that no glibc entry point
returning caller-freed memory is imported, so a RocksDB upgrade cannot
reintroduce the mismatch unnoticed.

The previous configuration stays available as jemalloc=private; none
bundles no allocator, for diagnosis.
The shipped library is stripped and its bundled symbols are hidden, so a
crash inside it cannot be symbolised from the package. Upload the
pre-strip build separately so that it can.
@kamilchodola
kamilchodola force-pushed the feature/unified-allocator branch from 746d90b to 9cd2617 Compare September 7, 2026 11:13
@kamilchodola kamilchodola changed the title ci: let the bundled jemalloc own every allocation in RocksDB build(linux): let the bundled jemalloc own every allocation in RocksDB Sep 7, 2026
@kamilchodola
kamilchodola marked this pull request as draft September 8, 2026 11:08
@kamilchodola

Copy link
Copy Markdown
Author

Converted to draft: the CPU reduction this PR was measured on has been traced to a benchmark-harness artifact, not to the allocator. Arms whose Docker container name exceeded 63 characters (expb-executor-nethermind-multi-<tag>-run<N>-nethermind with a tag of 17+ chars) could not be resolved by Docker's embedded DNS, so Grafana Alloy never scraped their metrics endpoint and those clients skipped ~0.1 core of Prometheus exposition work — about 4% of the run's CPU, with no effect on block processing. Swapping tags between two bit-identical images moved the 'win' with the tag. Three independent complete builds measured under fair conditions sit within 1% of two independent private builds. Full write-up follows in the description once the harness fix is validated.

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.

2 participants