Skip to content

perf(server): jemalloc global allocator + fat LTO release profile - #939

Merged
membphis merged 2 commits into
mainfrom
claude/jemalloc-fat-lto-landing-36aad1
Aug 11, 2026
Merged

perf(server): jemalloc global allocator + fat LTO release profile#939
membphis merged 2 commits into
mainfrom
claude/jemalloc-fat-lto-landing-36aad1

Conversation

@membphis

@membphis membphis commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

What

Two compile-time changes to the shipped binary, no code-path or default-behavior changes:

  • jemalloc as the global allocator (tikv-jemallocator 0.6, the maintained binding, default features) for the aisix binary, gated to the targets we ship and bench: cfg(all(target_os = "linux", target_env = "gnu")) — the Docker image (bookworm, glibc) and both supported production arches (x86_64, aarch64). Every other target (macOS dev builds, musl, msvc) keeps the system allocator.
  • Release profile lto = "thin""fat". codegen-units was already 1; opt-level stays at the release default of 3; strip stays "debuginfo" so shipped binaries remain profileable in the field (Establish the on-CPU profiling workflow: adopt cargo-flamegraph and add a dedicated profiling build profile #847); panic stays unwind for per-request isolation.

Why

The performance program's earlier flamegraphs at the c=128 saturation point put glibc malloc/free at 15.5 % of request CPU (prior rig, where glibc internals symbolized fully); the composite spike leg for AISIX-Cloud#1259 item 4 then measured the allocator swap alone at −11.2 µs/req (+9.2 % rps) on an anchored same-scene basis — the largest single verified item in the program, at one dependency + one attribute of code.

This is the standard posture across mainstream Rust network infrastructure: maintained jemalloc bindings as the global allocator (gated off non-shipped targets) are shipped by mainstream Rust data stores, proxies, and AI gateways alike, because glibc malloc's arena locking and cache behavior under many-threads/many-small-allocations workloads is a known tax that jemalloc's per-thread caching removes. Fat LTO + codegen-units = 1 is likewise the common release-profile shape for latency-sensitive Rust servers; we deliberately do not adopt the full strip = true some projects pair with it, because field profileability of the exact shipped binary is a supported workflow here (#847).

Measured result (same rig, anchored before/after)

All legs c=128 saturation + c=768 @ 10 ms TTFT, 4 valid windows each, zero failed requests and zero invalid windows in the whole scene, gateway pinned to 4 cores at 399.3–399.4 % CPU, front/back anchor drift +0.07 % (far inside the ±5 % noise band):

c=128 rps (spread) CPU µs/req p50 / p99 ms
base, front anchor (2cbf39c) 31,377 (0.5 %) 127.3 3.90 / 5.94
this branch (cea29be) 36,110 (0.8 %) 110.6 3.46 / 4.87
base, back anchor (2cbf39c) 31,400 (0.7 %) 127.2 3.92 / 5.85
delta vs anchor pool +15.0 % −16.7 µs −11 % / −18 %

Shape sanity at c=768 @ 10 ms TTFT (deep queue): 27,548 → 31,396 rps (+14.0 %), 144.8 → 127.0 µs/req, anchor drift on the point +0.01 % — the win holds under queue depth, no shape anomaly.

Memory (an allocator swap must answer this): idle RSS 117.2 → 112.7 MB (−4.5 MB); peak RSS (VmHWM, includes the 768-connection point) 203.1 → 219.2 MB (+16.1 MB / +7.9 %). The peak growth is jemalloc’s documented decay-based purging — dirty pages are returned on a decay schedule instead of immediately — and is the deliberate trade for the allocation-path speed.

Flamegraph (same scene, self-time accounting over allocator symbols): glibc allocator frames 4.10 % / 3.78 % on the two base legs → 0.13 % on this branch (the residue is C-dependency allocations, which deliberately stay on glibc); jemalloc frames appear at 2.70 % self. Accounting caveat, stated for honesty: on this rig’s kernel the DWARF unwind through glibc breaks into [unknown] towers, so SVG-visible glibc shares undercount the true allocator cost — the quantitative claim rests on the anchored CPU counters above, the flamegraph is corroborating shape evidence (glibc allocator symbols vanish, jemalloc symbols appear at the mature share the composite-spike leg also showed).

The composite-spike leg (measured on the #925-head base) predicted −11.2 µs for jemalloc alone, fat LTO worth ~2 µs more. The landing measures −16.7 µs on a base nine feature commits newer — the same direction with the allocator win growing as the request path gains per-request work, which is exactly how an allocator-side win should scale.

Compile-time cost (the trade this buys)

build (12-thread x86 dev box) thin LTO (before) fat LTO + jemalloc (after)
cold release 200.7 s 392.1 s (~2.0×)
touch aisix-server + rebuild 74.9 s 263.4 s (~3.5×)
peak build RSS 2.43 GB 3.73 GB

The release binary shrinks from 57.3 MiB to 52.5 MiB (−8.3 %): whole-graph LTO prunes more than the embedded jemalloc adds. Dev builds are untouched (profile.release only). The cost lands on release builds: CI docker builds and bench-rig builds take the ~2× cold hit; the touch-rebuild ~3.5× is felt only when iterating on release binaries locally.

Compatibility checklist

  • aarch64 page size: jemalloc bakes the build host's page size into the binary at configure time (tikv-jemalloc-sys README, JEMALLOC_SYS_WITH_LG_PAGE; verified in the crate's build.rs — no per-arch override on linux). Our release matrix is unaffected: the published Docker image is x86_64 (4 K everywhere), and aarch64 deployments build from source on the deploy host, so configure autodetects the right value. The one hazard is cross-building an aarch64 binary on a 4 K-page host and running it on a 64 K-page kernel (RHEL-family aarch64): jemalloc aborts at startup with an explicit page-size error. Documented in the code comment; escape hatch is JEMALLOC_SYS_WITH_LG_PAGE=16 at build time, which yields a binary that runs on both page sizes.
  • musl / static: no musl or static artifacts exist in the release matrix (Dockerfile is glibc bookworm; CI is ubuntu-latest gnu). The dependency is target-gated to linux-gnu, so a hypothetical musl build simply has no jemalloc in its graph and links exactly as before with the system allocator.
  • Background threads: default crate features compile in runtime support but leave jemalloc's background threads off (crate default background_threads_runtime_support; background_thread:true is only injected by the non-default background_threads feature — verified in the crate build.rs). No extra threads appear in the process; this matches the spike leg byte-for-byte.
  • Operator-facing docs: tracked in api7/docs#2076 (peak-RSS profile, _RJEM_MALLOC_CONF vs glibc tunables, aarch64 source-build note). CI now positively asserts the jemalloc symbols are present in the shipped image (the Establish the on-CPU profiling workflow: adopt cargo-flamegraph and add a dedicated profiling build profile #847 verify step).
  • Allocator scope: without the unprefixed_malloc_on_supported_platforms feature, jemalloc serves Rust's #[global_allocator] only; C-library allocations inside dependencies keep glibc. Identical to the measured spike leg.

Tests

Full workspace suite: 3,017 tests pass, clippy/fmt clean. One environmental failure documented for honesty: aisix-mcp::bridge::tests::connect_timeout_bounds_an_unreachable_upstream fails on the author's dev box only because a local transparent-proxy TUN answers SYNs for the reserved TEST-NET-3 black-hole address in 4 ms, defeating the test's environmental assumption; it passes in CI and is untouched by this diff (the crate is byte-identical; the diff touches only the server bin crate and the release profile).

Summary by CodeRabbit

  • Performance

    • Improved release build optimization for better runtime performance.
    • Added a production memory allocator for Linux GNU builds, while retaining the system allocator on other platforms.
  • Compatibility

    • Added guidance for Linux ARM64 deployments using systems with 64K memory pages.
  • Reliability

    • Added automated validation to confirm production Linux builds include the expected memory allocator.

Two compile-time changes, measured together on the c=128 saturation
grid (same-rig before/after in the PR):

- tikv-jemallocator 0.6 as the global allocator, gated to the targets
  we ship and bench (Linux glibc). Flamegraphs put glibc malloc/free
  at 15.5% of request CPU under saturation; jemalloc runs the same
  load at ~6%.
- release profile thin -> fat LTO. codegen-units was already 1 and
  opt-level stays at the release default of 3.

Other targets (macOS dev builds, musl) keep the system allocator.
strip stays at "debuginfo" so shipped binaries remain profileable in
the field (#847), and panic stays unwind for per-request isolation.
Copilot AI balanced review requested due to automatic review settings August 11, 2026 17:43
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bec2b6de-adaf-4c22-80ce-00521c15b55f

📥 Commits

Reviewing files that changed from the base of the PR and between 2cbf39c and fb97c9c.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (5)
  • .github/workflows/docker-image.yml
  • Cargo.toml
  • Dockerfile
  • crates/aisix-server/Cargo.toml
  • crates/aisix-server/src/main.rs

📝 Walkthrough

Walkthrough

Release builds now use fat LTO. Linux GNU server builds use jemalloc as the global allocator. Other targets retain the system allocator. Docker image checks verify that jemalloc symbols are present.

Changes

Release optimization

Layer / File(s) Summary
Fat LTO configuration
Cargo.toml
The release profile changes LTO from thin to fat and documents cross-crate inlining.
Linux GNU jemalloc integration
crates/aisix-server/Cargo.toml, crates/aisix-server/src/main.rs, Dockerfile, .github/workflows/docker-image.yml
Linux GNU builds add tikv-jemallocator and use Jemalloc as the global allocator. Build documentation describes the arm64 page-size setting. The Docker workflow checks for prefixed jemalloc symbols.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related issues

  • api7/docs#2076 — Documents the operational impact and configuration of the jemalloc integration.

Suggested reviewers: moonming

🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the two main changes: jemalloc as the global allocator and fat LTO for release builds.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
E2e Test Quality Review ✅ Passed The diff adds a checked container smoke flow and jemalloc-symbol assertion; existing CI runs the binary through E2E tests in both serving modes. No changed error handling, test dependency, or concu...
Security Check ✅ Passed Diff only adds Linux-GNU jemalloc, fat LTO, lockfile entries, and build verification/docs; it adds no secret handling, persistence, authorization, ownership, TLS, shared-resource, or reference-reso...
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/jemalloc-fat-lto-landing-36aad1

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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.

Pull request overview

Adds Linux GNU performance optimizations to the shipped AISIX binary.

Changes:

  • Uses jemalloc globally on Linux GNU targets.
  • Enables fat LTO for release builds.
  • Updates dependency locking for jemalloc.

Reviewed changes

Copilot reviewed 3 out of 4 changed files in this pull request and generated no comments.

File Description
Cargo.toml Enables fat LTO in release builds.
Cargo.lock Locks jemalloc dependencies.
crates/aisix-server/Cargo.toml Adds target-gated jemalloc dependency.
crates/aisix-server/src/main.rs Configures jemalloc as the global allocator.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Audit follow-ups for the allocator PR:

- The #847 symbol-table step now also requires prefixed _rjem_
  symbols, so a future drift of the cfg gates in aisix-server cannot
  silently fall back to glibc malloc (the regression would cost the
  throughput this change bought with no other signal).
- Dockerfile: note the JEMALLOC_SYS_WITH_LG_PAGE=16 requirement next
  to the release build RUN, where a future linux/arm64 buildx target
  would otherwise bake QEMU's 4K page size into the image.
@membphis

Copy link
Copy Markdown
Contributor Author

Independent cold-start audit ran against this PR (correctness / reliability / security / leakage / breaking / coverage). Verdict: no HIGH; 2 MEDIUM, 3 LOW. Disposition:

  • MEDIUM — no positive assertion jemalloc is actually linked: fixed in fb97c9c. The Establish the on-CPU profiling workflow: adopt cargo-flamegraph and add a dedicated profiling build profile #847 shipped-binary CI step now also requires prefixed _rjem_ symbols, so a future drift of the two cfg gates cannot silently fall back to glibc malloc.
  • MEDIUM — operator-visible changes documented only in the PR body: filed api7/docs#2076 (peak-RSS profile under decay-based purging, _RJEM_MALLOC_CONF replacing glibc malloc tunables for Rust allocations, aarch64 source-build page-size note). A release-note line ships with the next release draft.
  • LOW — docker job duration headroom: thin-LTO history was 7–9 min per build; fat LTO roughly doubles the cold build. Will confirm this PR's actual docker-build duration before merge and bump timeout-minutes: 45 only if it lands above ~30 min.
  • LOW — tikv-jemallocator 0.6 pinned while 0.7.0 exists: deliberate — byte-parity with the measured spike configuration. The 0.7 upgrade is queued in the perf program as a re-measure item, not taken silently here.
  • LOW — latent arm64 page-size trap if buildx multi-arch is ever added: warning comment added next to the Dockerfile release-build RUN in fb97c9c.

Audit also verified: cfg-gate consistency across targets, lockfile checksums against crates.io (no advisories, nothing yanked), no fork() users in the server (atfork safety moot), release matrix claims (no musl/static artifacts, codegen-units already 1, strip/#847 contract intact), and that CI exercises the gated cfg at runtime (unit + e2e run the bin on x86_64-linux-gnu).

@membphis

Copy link
Copy Markdown
Contributor Author

Closing the audit's remaining LOW: the fat-LTO docker build measured 10m38s on this PR's run (thin-LTO history was 7–9 min) — well inside timeout-minutes: 45, no change needed. All checks green, including the new _rjem_ symbol assertion against the shipped image (the positive guard is live and passing).

@membphis

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@membphis
membphis merged commit b84d77b into main Aug 11, 2026
13 of 14 checks passed
@membphis
membphis deleted the claude/jemalloc-fat-lto-landing-36aad1 branch August 11, 2026 22:40
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