feat(server): add private pprof debugging toolkit - #53
Merged
Conversation
Aggregate RSS and allocator counters show how much memory XERJ uses, but they cannot identify the allocation call stacks retaining it. The same is true for aggregate CPU: without stack samples, repeated full-corpus experiments turn performance work into guesswork. Add a Linux-only `debug-profiling` feature that compiles in bounded CPU and jemalloc heap profiling without changing the shipped feature sets or exposing a network endpoint. Preserve the production jemalloc decay configuration, start heap sampling inactive, and initialize the controller before config loading and engine replay so delay-zero captures include startup. Write Google pprof CPU and heap artifacts to an operator-created private directory. Bound delay, duration, and frequency; interrupt waits during shutdown; atomically publish mode-0600 files without overwrite; reject concurrent directory use; fail startup on invalid/preflight configuration; and emit machine-readable worker errors for later capture failures. Add `capture.py` to launch and terminate the complete server process group, record exact binary and artifact hashes, collect host/cgroup/process metadata, copy and hash correctness or telemetry attachments, distinguish complete/process/profile failures, and reserve five seconds after sampling for conversion and publication. Add `inspect.py` to verify hashes and formats and report when evidence is insufficient for a performance conclusion. Add fast symbolized `profiling` and release-codegen-equivalent `profiling-final` Cargo profiles. The fast profile uses thin LTO and 16 codegen units for iteration; final claims must be reconfirmed with same-profile `profiling-final` before/after runs. Add CPU, heap, and A/B prompts that force agents to separate confirmed evidence from inference, live heap from allocator/RSS slack, and absolute changes from percentage shifts. Add discoverable playbook and recipe documentation covering privacy, stale locks, SIGPROF/libgcc limitations, unverified source/build binding, and separate CPU/heap repetition. Verified with scoped normal and profiling checks, 6 Rust tests, 2 wrapper tests, and a real-server capture. The Rust tests decode actual CPU protobuf and gzipped heap protobuf artifacts. The smoke produced a 20,113-byte CPU profile and 26,336-byte heap profile with a complete manifest and mode-0600 artifacts. The fast symbolized binary is 401,304,832 bytes versus a 39,708,296-byte stripped release reference. The 344.85 MiB delta is not pure profiler overhead: debug symbols and thin-versus-fat LTO/codegen differences dominate. No runtime overhead or performance improvement is claimed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This adds an opt-in, Linux-only debugging workflow for answering the questions that aggregate RSS and allocator counters cannot answer: which call stacks consume CPU, and which allocation call stacks retain live heap bytes.
The normal XERJ binary is unchanged. Profiling code is compiled only with the new
debug-profilingfeature, exposes no network endpoint, starts inactive by default, writes only to an operator-selected local directory, and is bounded to a maximum 300-second delay and 300-second capture.The workflow is designed for AI-assisted performance investigations. Every bundle contains a reproducibility manifest, exact binary and artifact hashes, server logs, optional copied correctness/telemetry evidence, a machine-readable validation summary, and focused prompts for CPU, heap, and before/after analysis.
Why
XERJ already exposes aggregate resource evidence such as RSS and jemalloc totals, but those values cannot identify the code paths retaining memory or consuming CPU. Full-corpus FinanceBench runs are expensive, so debugging needs to move toward deterministic small-corpus reproduction backed by profiles rather than repeated guess-and-check runs.
This change provides the missing call-stack evidence without adding an unauthenticated pprof endpoint or enabling profiler overhead in shipped builds.
Usage
Build the fast iteration profile from the repository root:
(cd engine && cargo build --profile profiling -j 32 -p xerj-server --no-default-features --features debug-profiling)Capture CPU and attach correctness plus telemetry evidence:
Validate the bundle:
Repeat into fresh output and data directories with
--heap-seconds 30for the heap profile. Separate CPU-only and heap-only runs are recommended because simultaneous profilers can perturb one another.Use
--profile profiling-finalbefore making a final production-codegen performance claim.profiling-finalinherits the release fat-LTO, one-codegen-unit settings but retains symbols.Artifact contract
cpu.pbis a Google pprof protobuf generated bypprof-rs.heap.pb.gzis a gzipped Google pprof protobuf generated from jemalloc's sampled in-use heap.manifest.jsonrecords the exact binary SHA-256, command, capture settings, host/kernel/page/cgroup shape, process outcome, artifact hashes, declared workload/corpus/concurrency/cache state, declared Cargo profile/features, repository HEAD/dirty state, attachments, and missing requested artifacts.server.logcaptures combined server stdout and stderr.cpu-error.jsonandheap-error.jsonare machine-readable worker failures when a profiler cannot start, dump, convert, or publish.*.top.txtis generated when Google'spprofexecutable is available, giving agents an immediately readable top report.All bundle files are mode
0600; the wrapper-created output directory is mode0700. Profiles are written through unique temporary files, fsynced, and published with a no-overwrite hard link. A per-directory lock rejects concurrent controllers.capture.pyrejects a missing/non-executable binary before creating the output directory, bounds duration/frequency/delay, requires an explicit five-second post-capture conversion/publication window, terminates the complete server process group with SIGINT/SIGTERM/SIGKILL escalation, and fails when any requested profile is absent.inspect.pyverifies artifact and attachment hashes, validates gzip/non-empty formats, reports evidence gaps, and marks a bundle comparison-ineligible when correctness/benchmark/telemetry evidence is absent.Profiling behavior
The controller is initialized before config loading, TLS setup, engine construction/replay, and router construction. Delay zero therefore captures initialization.
XERJ_DEBUG_PROFILE_DELAY_SECONDSis process-start-relative; it is not a readiness probe.Jemalloc is compiled with profiling support only in the feature build. Its existing background-thread and one-second dirty/muzzy decay configuration is preserved. Profiling starts with
prof_active:false, uses the explicit 512 KiB sampling interval, and leaves cumulative profiling disabled so jemalloc does not retain every unique allocation backtrace.CPU sampling defaults to 100 Hz, is capped at 1000 Hz, and blocklists libc, libgcc, pthread, and vdso according to upstream
pprof-rsguidance.Invalid requested profiling configuration or controller/thread preflight failure aborts server startup. Later worker failures produce private machine-readable error artifacts and cause the wrapper to report missing requested profiles.
Normal and handled early server exits interrupt delay/capture waits and remove the directory lock. A hostile early config-failure run returned server exit 1, bundle status
profile_failed, and left no stale lock.AI analysis workflow
PROMPT_CPU.mdrequires absolute and percentage flat/cumulative stacks, source mapping, subsystem grouping, falsifiable follow-up experiments, and explicit CONFIRMED/LIKELY/UNKNOWN sections.PROMPT_HEAP.mdrequires byte accounting across HTTP bodies, JSON, prepared documents, vectors/model scratch, memtables, flush snapshots, merges, and caches while separating live heap from allocator active/resident slack and non-jemalloc RSS.PROMPT_COMPARE.mdrejects cross-profile comparisons, requires correctness before speed, requires absolute rather than percentage-only deltas, and distinguishes bytes removed from bytes moved into allocator slack, mmap/file cache, or another process.All prompts treat the binary-to-source/build relationship as
UNVERIFIEDunless independently attested. The binary hash is exact, but the wrapper cannot prove that the binary was built from the recorded checkout or declared Cargo profile/features.Build measurements
The final fast profiling build command was:
It completed in 3m55.523s and produced a 401,304,832-byte binary with SHA-256
7449fb57a6353335f5375c73221300f4847d125dabbd3b9886db872073b1fe82.The same worktree's stripped no-default-feature release reference was 39,708,296 bytes with SHA-256
a7c040d5637b22092ed94a73cb7b23fb35dd276cccb74bd835644211f0541dcf.The fast profiling artifact is 361,596,536 bytes (344.85 MiB) larger, or 10.106x the stripped release reference. This is not pure profiler code size: it is dominated by retained debug symbols and also differs in thin-versus-fat LTO and codegen-unit settings. It is a debugging binary, not a distribution artifact.
No runtime CPU, throughput, latency, or memory overhead claim is made. Those require controlled same-profile A/B measurements. A result found with fast
profilingremains provisional until reconfirmed with same-profile before/after runs usingprofiling-final.End-to-end smoke evidence
A real two-second combined startup smoke against
engine/target/profiling/xerjcompleted successfully and shut down cleanly.cpu.pb: 20,113 bytes, SHA-25621cf24356fbc002d3e7939eee1bcc456dac490a309617fbbd2f4a8e51fdc88d6.heap.pb.gz: 26,336 bytes, SHA-2567bbbe2f2c59a5109279daa8e7b866d04495869d72ede6be6b11d9dfcdef79cdd.server.log: 15,269 bytes, SHA-256099cbc9e1253056f685ad980fee5b810f1ec7b58478f8e9ba047a01168a4ef0b.The manifest status was
complete, the server exit code was 0, every artifact was mode0600, andinspect.pyvalidated hashes and formats. It correctly reportedcomparison_ready: falseand anINCONCLUSIVEperformance warning because this smoke deliberately attached no correctness or telemetry evidence.The exact-boundary shutdown experiment happened to publish both profiles once, demonstrating that equality is timing-sensitive rather than a safe contract. The wrapper now rejects
stop_after < delay + capture + 5 seconds; the new boundary test proves exact equality is rejected before an output directory is created.Tests
cargo fmt --all -- --check cargo check -p xerj-server --no-default-features --features debug-profiling cargo check -p xerj-server --no-default-features cargo test -p xerj-server --no-default-features --features debug-profiling debug_profiling::tests -- --test-threads=1 PYTHONDONTWRITEBYTECODE=1 python3 demo/playbooks/debug-profiling/test_tooling.py git diff --checkRust result: 6 passed, 0 failed. The tests cover bounded configuration, default sampling rate and delay, interruptible shutdown, no-overwrite atomic publication and
0600permissions, real CPU capture plus protobuf decoding, and real jemalloc heap capture plus gzip and protobuf decoding.Python result: 2 passed, 0 failed. The tests cover machine-readable missing-profile failure, manifest privacy/provenance, validator failure behavior, and rejection of a stop time without publication grace.
Both normal and
debug-profilingscoped checks pass.Security and limitations
Linux only.
No network profiling endpoint is added.
Profiles reveal function names, source paths, and workload-dependent execution structure. The manifest records the full server command. Operators must not place passwords, API keys, or tokens in command-line arguments and must transfer bundles only through approved private channels.
CPU profiling uses SIGPROF and libgcc unwinding. Upstream
pprof-rsdocuments that libgcc unwinding is not signal-safe and that restoring the original SIGPROF handler remains a TODO. This is controlled debugging tooling, not an always-on production profiler.Heap profiles are probabilistically sampled in-use bytes at capture time. They are not every allocation, peak RSS, allocator fragmentation, file cache, mmap residency, or another process.
Combined CPU and heap runs are convenient smoke tests but confounded for performance measurement. Use separate repetitions.
Delay is not a readiness probe. Coordinate the workload externally or use a conservative delay.
A SIGKILL cannot run destructors and can leave
.xerj-debug-profile.lock. Before removing it, confirm no XERJ process is using that exact directory, then remove only that lock.The repository HEAD/profile/features in the manifest are operator evidence, not cryptographic build attestation.
source_build_bindingis deliberatelyUNVERIFIED.No runtime-overhead or performance-improvement claim is included in this PR.