call-site aware memory tracking (#13344)

* Initial memory tracking design doc draft, plus first round of review comments by me with // TODO annotations

* design/memory-tracker: address first-round review TODOs

Resolves all // TODO annotations from the initial draft:

- live-block table now optional via MEMORY_TRACKING_LIVE_TRACKING knob
- drop the mmap slab pool; std::malloc + in-tracker flag is sufficient
- add MEMORY_TRACKING_FORCE_SAMPLE_BYTES so large allocations are always
  captured regardless of the count-rate sampler
- add live block / byte totals to MemoryTrackerSummary
- replace the manual coverage spot-check with a sentinel-function unit
  test that introspects the aggregation table directly
- leave ALLOC_INSTRUMENTATION alone; new hooks sit next to (not
  replacing) existing conditional ones
- drop SJLJ jargon, trim A4 alternative now that force-sample-large
  collapses the byte-rate-vs-count-rate question

* flow: add sampled per-call-site memory tracker

Adds a sampled memory attribution layer (flow/MemoryTracker.{h,cpp})
hooked into the three primary allocation paths — global operator
new/delete, FastAllocator, and ArenaBlock::create — plus a periodic
TraceEvent dump driven from SystemMonitor. Knobs gate sample rate,
force-sample threshold, report cadence, top-N, and capture depth;
prod default is off. See design/memory-tracker.md.

Test fixes uncovered while bringing the unit tests up:

- memTrackerResetForTest now resets the per-thread sample counter and
  force-sample threshold. Without this, a test that exercised the
  off-switch path left gMemTrackerCounter at INT_MAX, which then
  silently suppressed sampling for the remainder of the run.

- Slow-path reseed special-cases inverse==1 to keep the counter at 1.
  The general formula 1 + r % (2*inverse) yields counter values 1 or
  2 at inverse==1, sampling only ~67% of allocations rather than every
  one, which broke a test that asserts exact alloc counts.

- Sentinel functions in MemoryTrackerTest.cpp now route the allocated
  pointer through an asm-volatile escape() helper. Clang -O3 was
  eliding the new/delete pair (P0593 heap fusion), so the test's
  allocations never reached the operator-new override.

* design/memory-tracker: address second-round review

- threshold-based reporting (80 MB default, ~1% of 8 GB target RSS)
  replaces fixed top-N
- prod report interval 60 s -> 10 min; sim stays 30 s
- single combined MemoryTrackerAddrCmd event with one addr2line
  invocation per dump (positional mapping back to sites), keeping
  frame 0 — old design's per-site format_backtrace dropped the
  leaf alloc frame
- new R12 "Side-thread coverage" + "Side-thread safety" subsection
  documenting the FP-elision crash mode found via joshua repros
  (RandomUnitTests / IThreadPool seeds segfaulted in captureStackFP
  when walking from FastAllocator<N>::~ThreadData into glibc's
  FP-elided pthread shutdown machinery) and the stack-bounds
  mitigation via pthread_getattr_np
- R7 wording: live bytes (not cumulative)
- CallSite struct in design overview aligned with implementation;
  ForceSampledCount promoted from prose-only to struct + emitted
  detail; exemplarFrames sized to MEMORY_TRACKER_MAX_FRAMES (=10)
  matching the FRAMES knob's stated 1-10 range
- LIVE_TRACKING=false degraded-mode interaction documented
- stale "slab pool" refs removed (std::malloc was already in code)
  and fdbserver.cpp self-contradiction resolved
- R3 / Rollout reconciled (table shows steady-state, step 1 lands
  at 0)
- 4-6 frame count flagged as initial estimate, subject to refinement
- file:line citations stripped from path references (line numbers
  drift; symbol names are stable)

* flow: threshold-based memory tracker reporting + side-thread safety

Implements the second-round design changes in flow/MemoryTracker.{cpp,h},
flow/Knobs.{cpp,h}, flow/SystemMonitor.cpp.

- MEMORY_TRACKING_TOP_N -> MEMORY_TRACKING_REPORT_BYTES_THRESHOLD
  (int64_t, default 80,000,000). MEMORY_TRACKING_REPORT_INTERVAL prod
  default 60.0 -> 600.0; sim still 30.0.

- memTrackerDump(int topN) -> memTrackerDump(int64_t bytesThreshold).
  Filters by liveBytes (or cumulativeBytes when LIVE_TRACKING=false)
  >= threshold; emits MemoryTrackerSite per qualifying site plus one
  MemoryTrackerAddrCmd event with a single addr2line invocation
  covering every qualifying site's frames in dump order. The Summary
  event picks up SitesReported and ReportBytesThreshold details.

- AddrCmd is built directly here (not via platform::format_backtrace,
  which deliberately drops index 0 for its single-site use case); the
  leaf alloc frame is preserved.

- captureFramesFP gains a per-thread stack-bounds check via
  pthread_getattr_np + pthread_attr_getstack, cached in TLS. Without
  it, walking the FP chain from FastAllocator<N>::~ThreadData into
  glibc's FP-elided pthread shutdown machinery follows an
  uninitialized saved-FP slot and dereferences garbage. Fixes the
  joshua-found segfaults on RandomUnitTests seeds 3288611985,
  3731245491, and 2219741568 (all in the IThreadPool worker-exit
  path). See design/memory-tracker.md "Side-thread safety".

* flow/MemoryTracker: stub the FP walker on non-Linux

pthread_getattr_np is glibc-specific and the macOS build broke on
it. Frame-pointer walking on macOS is also unreliable on its own
(system runtime has -fomit-frame-pointer in places we can't
control), so a "loose bounds" workaround would still risk crashes.

FDB is required to compile on macOS but is not run in production
there. Gate initStackBoundsForThread + the real captureFramesFP
on __linux__; provide a return-0 stub on non-Linux. The rest of
the tracker (sample counters, aggregation, dump) still compiles
and runs; per-call-site reports on macOS will just lack stack
attribution.

* flow: clang-format fixup for memory-tracker files

Whitespace-only. Catches up flow/Arena.cpp and flow/MemoryTrackerTest.cpp
with the project's clang-format style; the original implementation
commit (d587f82b) slipped these past the format pre-flight.

* edit for clarity, brevity, and uniform voice

* flow/Arena: fix double-tracking on the >256/huge ArenaBlock paths

ArenaBlock::create's >256 and huge paths go through
allocateAndMaybeKeepalive (`new uint8_t[]`), which fires the global
operator new[] hook in addition to the explicit memTrackerOnAlloc
that fires immediately after. Two sites tracked the same pointer;
on free only the explicit-Arena fingerprint was debited, so the
operator new[] fingerprint accumulated liveBytes monotonically and
LiveBytesTotal/LiveBlocksTotal skewed by +n/+1 per arena alloc/free
pair. Reported as B1 in the PR review.

Fix at the Arena layer (so non-arena allocateAndMaybeKeepalive
callers in serialize.h's PacketBuffer code remain attributed at the
operator-new layer): a MemTrackerSuppress RAII helper held across
the underlying new[]/delete[] in ArenaBlock::create and
ArenaBlock::destroyLeaf.

Adds accounting tests for FastAllocator<32>, Arena small, Arena
medium (the B1 path), and Arena huge. The load-bearing assertion is
"exactly one site has the sentinel's frames AND nonzero bytes" --
fails pre-fix for medium/huge with sites=2. Tests gate on __linux__
since captureFramesFP is a no-op on macOS.

* flow/memory-tracker: address PR review follow-ups

- Knobs.cpp: sim default for MEMORY_TRACKING_REPORT_BYTES_THRESHOLD
  drops 80 MB -> 1 MB so sim dumps surface more sites for manual
  sanity-checking. Prod unchanged.
- B2: memTrackerForEachSite holds MemTrackerSuppress across the
  callback loop so callbacks that allocate (e.g. fprintf failure
  dumps) don't re-enter tracking under SAMPLE_INVERSE=1.
- B8: drop the always-zero g_reentrantBailouts and its
  SamplesDroppedReentry summary detail. Wiring it would require an
  atomic in the inline hot path, which R1 forbids. Design doc
  updated.
- B3/B6/B7/B9: explanatory comments only -- frame-strip-count
  inlining assumption (B3), unstable sort acceptable under R6 (B6),
  shared xorshift seed acceptable in practice (B7),
  MEMORY_TRACKING_LIVE_TRACKING is startup-only (B9).

* flow/MemoryTracker: one addr2line per site, drop chunking

Move the addr2line command back onto MemoryTrackerSite as a per-site
AddrCmd detail and remove the MemoryTrackerAddrCmd event entirely.
Each AddrCmd carries exactly that site's stack -- short, well under
the trace-detail truncation cap, ready to paste. Replaces the
consolidated-then-chunked-into-byte-buckets approach, which split
stacks across chunk boundaries and made raw events hard to read.

* add standard Apache 2.0 license headers to new memory-tracker files

The three new files (flow/MemoryTracker.{cpp,h} and
flow/MemoryTrackerTest.cpp) shipped without the project-standard
copyright/license block. Adds the standard 19-line header to each;
file-purpose comments stay below it, switched to // line comments
to keep the license block visually distinct.

AGENTS.md gains a short "Source File Headers" section so the next
contributor doesn't repeat the omission.

* address review comments; reduce cost of enable check; maintain net estimates so users dont have to do it manually

* formatting

* unit test bug fix

* gglass review comments on memory-tracker.md design doc

* design doc updates, and fill in a plan for remaining tests/benchmarks

* delete useless simulation section

* flow/bench: add memory-tracker microbenchmarks; record measured overhead

Add flow/bench/BenchMemoryTracker.cpp (Google Benchmark) measuring the tracker's
per-op cost via three benchmarks -- raw malloc/free baseline, end-to-end
operator new/delete, and isolated memTrackerOnAlloc/OnFree -- each at sample
inverse 0 / 100 / 1. Auto-picked up by the flow_bench CONFIGURE_DEPENDS glob;
run with:
  bin/flow_bench --benchmark_filter=memtracker

Replace the placeholder "< 5% delta" targets in the design doc's Microbenchmarks
section with the measured numbers and a projected per-second overhead at an
assumed 100K alloc/sec. Headline: ~1.9 ns/pair disabled, ~5.6 ns/pair at the
production 1% rate (~0.056% of a core at 100K/s, ~1/17th of R0's 1% ceiling);
the every-allocation rows are labeled a buggified worst case, not a default.

Testing: built flow_bench on the dev pod (clean, -Werror) and ran the memtracker
filter six times; results stable to within a few percent across runs.

* design/memory-tracker: describe the coverage test as implemented, not proposed

The "Coverage spot-check via sentinel functions" section described the
already-implemented `coverage` test in proposal tense ("Add an introspection
API:", "The unit test:"), which read as future work. Reword to present tense
referencing the actual `coverage`/`*Accounting` tests and the existing
`memTrackerForEachSite` API. No remaining references to unwritten test cases.

* remove extraneous detail from requirements section

* substantially revise microbenchmark results based on seeing 2M allocations/frees on a CPU-maxed storage server

* add script to drive A/B experiment for sampled memory allocation tracking

* flow/MemoryTracker: move global operator new/delete into a server-only TU

The global operator new/delete replacements that route allocations through
the memory tracker lived in flow/MemoryTracker.cpp, i.e. in the flow static
library. flow is linked into libfdb_c and every client binding, so the
interposition shipped into client artifacts and could interpose the whole
host process's allocator even with sampling off.

Move them into a new fdbserver/GlobalNewDelete.cpp, compiled directly into
the fdbserver executable (which clients never link), mirroring where the
legacy ALLOC_INSTRUMENTATION overrides already lived. This also makes the
replaceable-symbol interposition reliable (guaranteed in the final link)
rather than dependent on static-archive pull-in. The legacy
ALLOC_INSTRUMENTATION overrides move into the same file, selected by
#if defined(ALLOC_INSTRUMENTATION) / #else, so exactly one set of global
operators is ever defined. No CMake change is needed (fdbserver/*.cpp is
globbed).

Also reconcile the design doc with the code: override placement, the Files
section (drop the unnecessary flow/CMakeLists.txt edit), the sim knob-table
values (REPORT_BYTES_THRESHOLD, SAMPLE_INVERSE prod default), the
FORCE_SAMPLE_BYTES "-1 disables" sentinel wording, and drop the stale
"buggify inverse to 1" note -- the every-allocation and sampled/weighted
paths are already pinned deterministically by MemoryTrackerTest.cpp.

Testing: build green (run-ccmk5); fdbserver -r unittests -f /flow/MemoryTracker/
runs all 10 memory-tracker unit tests, 0 failed.

* contrib/mako_ab_memtracker: disable RocksDB direct I/O for tmpfs runs

RocksDB opens its DB with O_DIRECT by default; /mnt/ram (tmpfs), where the
harness puts its data dir, does not support direct I/O, so the storage
engine fails to Open and the cluster never configures (fdbcli "configure
new" hangs, mako never starts). Pass the existing
ROCKSDB_USE_DIRECT_READS / ROCKSDB_USE_DIRECT_IO_FLUSH_COMPACTION knobs (=0)
for the rocksdb arm only -- no new knobs added. redwood is unaffected.

* fdbserver/bench: move memory-tracker microbench to fdbserver_bench

The global operator new/delete override lives in fdbserver/GlobalNewDelete.cpp
(server-only, for client isolation), so flow_bench -- which links only flow --
could not exercise it: bench_memtracker_operator_new hit libc++'s operator new
and its Arg(100)/Arg(1) rows were identical to Arg(0).

Move BenchMemoryTracker.cpp to a new fdbserver/bench/ whose CMake compiles
GlobalNewDelete.cpp into the fdbserver_bench executable (via ADDL_SRCS), so the
real override is a strong definition in the bench link and the operator-new
benchmark measures the actual hooked path. It links only flow, not the fdbserver
dependency graph. Adds a BenchMain.cpp (BENCHMARK_MAIN equivalent) and wires the
subdirectory into fdbserver/CMakeLists.txt.

Testing: fdbserver_bench builds; bench_memtracker_operator_new now shows distinct
off / 1% / every-alloc costs (11.4 / 14.4 / 80.9 ns), confirming the override
fires.

* design/memory-tracker: reconcile with code and slim down

Reconcile the doc with the implementation and trim implementation detail that
duplicated the code and had begun to drift:

- Fix drift found in review: degraded-mode live/peak fields stay 0 (not
  "tracking the cumulatives"); the dump thresholds on the estimated fields (fix
  the memTrackerDump header comment too); the live-block table is allocated but
  empty when live-tracking is off (not "never allocated"); list the test file
  and its forceLinkMemoryTrackerTests() wiring.
- Slim ~220 lines: replace the CallSite struct, the full captureStackFP source,
  both TraceEvent .detail() schemas, and the file-by-file inventory with prose
  that defers exact structs/keys/constants to the code; soften the knob table
  to intent (authoritative defaults live in Knobs.cpp).
- Refresh the microbench numbers from fdbserver_bench and annotate the host
  (AMD EPYC 9R14, clang -O3); replace the A/B placeholder with the measured
  -16.5% (redwood) / -9.9% (rocksdb); note it is a point-in-time snapshot.
- Frame R0 as the target the v1 single-lock design does not yet meet (ships off
  pending lock sharding); add a one-line note on table teardown/fork behavior.

* fix clang-tidy error

* mako wrapper scripts: take care to clobber the ramdisk before runs to avoid low-space throttling

* design/memory-tracker: note the two mako A/B harnesses and the off-state result

Point at contrib/mako_ab_memtracker.py (sampling off vs on) and
contrib/mako_ab_binaries.py (vanilla main vs this PR built with tracking off),
and record the latter's measured off-state overhead of -0.53% (redwood) /
-0.10% (rocksdb) on a shared base commit — well within R0's 1% ceiling.

* Add the default code review guidance to AGENTS.md

* address big brother review comments; add some braces and trim some generated comments (hard to believe, but true)

* clang-tidy again

* Final read-through of this PR.

-- Add or enhance a few comments on important items (performance & reliability)
-- Delete some misc agent-written comments, typically exhibiting recency bias e.g. naming bugs identified in code review passes or describing mundane earlier bugs
-- Add braces (InsertBraces style)

* memory-tracker: address round-4 review

Correctness/robustness:
- operator new now runs the installed std::new_handler retry loop, so an
  allocation failure (including the tracker's own map growth) reaches FDB's
  platform::outOfMemory / FDB_EXIT_NO_MEM path instead of throwing past it.
- Reentrancy guard restored via MemTrackerSuppress RAII on every path (hot path,
  dump, reset) so an exception can't permanently disable tracking on a thread.
- Sampling reseed draws from [1, 2N-1] (mean exactly N); the old [1, 2N] biased
  the Est* estimate low by ~0.5/N.

Design:
- Drop runtime enable/disable (now an explicit Non-requirement): the sample knob
  is read at startup only; park the counter when off. Removes the DISABLED_RESEED
  re-park and the on/off reconciliation logic.
- Simulation samples 1-in-10 (was 1-in-2).

Portability (Windows is not build-tested here; lean on existing abstractions):
- Aligned operator new uses platform::aligned_alloc/aligned_free (overflow-guarded)
  instead of posix_memalign; guard <pthread.h> under __linux__; add a
  force_noinline macro (GNU-only, empty elsewhere); portable volatile-sink
  escape() in the test.

Tooling/tests/docs:
- mako A/B scripts: validate the scratch mount (realpath + tmpfs + denylist) before
  rm -rf, locate mako_storage_bench.sh via __file__, and black-format.
- Add operatorNewHonorsNewHandler and samplingRate tests; drop enableAfterOff.
- Reconcile the design doc with the code; prune low-value comments.

Testing:
- /flow/MemoryTracker/* unit tests: 11 pass, 0 fail (bin/fdbserver -r unittests).
- Joshua 100k (correctness-8.0.0): 99,995 pass / 1 fail. The single failure is
  NativeCdcAssignmentPublication -- a NativeCdc test from main, not this PR
  (CommitProxy failed_to_progress -> QuietDatabase DataDistributionActive ->
  NativeCdcEndToEnd workload start timed_out). It reproduces bit-identically
  (seed 2939264355, unseed 8776) with the tracker built off (sim
  sample_inverse=0, confirmed via the MemoryTrackerSummary SampleInverse trace
  field), so it is a pre-existing rare CDC/QuietDatabase flake unrelated to this
  change.

* memory-tracker: fix GCC IPA-clone breaking frame-attribution tests

Under GCC -O3, IPA constant-propagation cloning (-fipa-cp-clone) specializes the
MemoryTrackerTest sentinels (each called with a constant N) into `.constprop`
clones emitted at a different address than the function symbol. The executed
code -- and thus the captured return addresses -- live in the clone, so the
tests' frameInside(frame, &sentinel) window missed them: fastAlloc32Accounting
aborted (sitesWithSentinelFrames == 0) in GCC CI while clang passed.

Add `noclone` to force_noinline on GCC so each sentinel stays a single body at
the address &fn yields. clang doesn't support noclone (and doesn't clone this
way), so it keeps noinline only; other compilers stay empty.

Testing: /flow/MemoryTracker/* passes 11/11 under both a gcc-toolset-13 build
and a clang build.

* memory-tracker: cheaper disabled alloc hot path (per-thread off flag)

When sampling is off, memTrackerOnAlloc previously still did three TLS accesses
every allocation -- a gInMemTracker load, a gMemTrackerCounter load-decrement-
STORE, and a gForceSampleBytes load. Add a per-thread gMemTrackerOff flag,
checked first, that a thread sets once its slow path observes sampling is off;
the disabled alloc path then short-circuits on a single TLS load + branch (no
counter store, no gForceSampleBytes load).

The flag is per-thread, not global, on purpose: the counter still bootstraps
sampling per thread (first alloc reaches the slow path and reads the knob), so a
single global gate set by an early main-thread allocation before FLOW_KNOBS is
ready would wrongly disable worker threads that bootstrap later. Free keeps the
global g_memTrackerEnabled gate (a free-only thread must see global state to
debit). memTrackerResetForTest clears the flag. Removes the now-dead INT_MAX
counter parking.

The saving is real but small (~2 loads + 1 store per alloc, well under the
mako A/B's run-to-run noise), so the redwood off-state A/B shows no resolvable
change; the win is on principle / at high allocation rates.

Testing: /flow/MemoryTracker/* passes 11/11 (bin/fdbserver -r unittests).

* memory-tracker: add FDB_MEMORY_TRACKER compile-time gate + per-path microbench

Add a compile-time switch, FDB_MEMORY_TRACKER (CMake option, default ON), that
removes the feature entirely when set to 0: the header hooks become no-op inlines,
flow/MemoryTracker.cpp and the MemoryTrackerTest TEST_CASEs are #if'd out (the
forceLink stub stays), and fdbserver/GlobalNewDelete.cpp defines no global
operator new/delete override (libc++'s allocator is used, which still honors the
installed new_handler). This gives operators an escape hatch to zero
always-compiled footprint, and lets the microbench measure present-but-disabled
vs absent cost per allocation path.

Extend fdbserver/bench/BenchMemoryTracker.cpp with plain per-path alloc/free loops
(operator new[] at several sizes, FastAllocator<64/96/256>, Arena medium/huge) so
the same binary built =1 (tracker present, sampling off by default) vs =0 (absent)
isolates each path's unweighted off-state overhead.

Measured (EPYC 9R14 @ 3.7 GHz, ns/op, =1 minus =0):
  operator new[] small  ~+1.1 ns (~11%);  huge ~+2.5 ns
  FastAllocator<N>      ~0 (within the ~0.6 ns cross-build noise floor)
  Arena block           ~+3.3 ns (medium) / +4.8 ns (huge)
The per-op-costly paths (operator new, Arena) are the low-volume ones; the
high-volume path (FastAllocator, ~82% of redwood allocs) is ~free, so the weighted
off-state overhead is ~0.1-0.2% of a core -- under the R0 target, though R0 is now
framed as an unproven target with this gate as the escape hatch.

Testing: /flow/MemoryTracker/* passes 11/11 (default build); FDB_MEMORY_TRACKER=OFF
builds clean under -Werror.

* clang-tidy fix

* comment about why fdbserver/bench exists

* address Codex round 5 review comments (MSVC build, startup sequencing, memory allocation failures on tracker-internal bookkeeping, cross-compile, contrib script cleanup)

* add another disclaimer about MSVC/Windows being best effort

* memory-tracker: fail-open ordering + deflake huge-arena unit test

memTrackerSampleAlloc now performs both table insertions (aggregation-map and
live-map nodes) before mutating any per-site or global counter, so if the
tracker's own map growth throws std::bad_alloc the exception unwinds with all
totals in lockstep and the fail-open catch simply drops the sample. This
addresses an adversarial-review finding. Note such a failure does not occur in
practice: FDB "OOM" is an RSS threshold enforced by fdbmonitor (typically
~12-16 GB against an ~8 GB target), not a malloc/operator-new failure. This
feature is for finding leaks and untuned allocations that drive RSS growth,
well short of any allocation failure -- documented in flow/MemoryTracker.cpp and
design/memory-tracker.md so reviewers don't over-index on the bad_alloc path.

arenaHugeAccounting now identifies the huge blocks by their ~100 KB size
signature instead of the frame-pointer sentinel. The huge-Arena path is the
deepest tracked call chain; under a real (non-simulation) network the
best-effort frame-pointer walker is per-allocation nondeterministic there and
cannot reliably attribute the blocks to the test's frame, so the sentinel-frame
assertion flaked (~1-4% of runs). The recorded block size is correct regardless
of which frames were captured, no incidental or foreign-thread allocation comes
near 100 KB, and double-tracking still shows up as 2N. The shallower
fastAlloc32/arenaSmall/arenaMedium/operatorNew accounting tests keep exact
sentinel-frame attribution (reliable on their shorter paths) as the regression
guard.

Testing:
- /flow/MemoryTracker/* via `fdbserver -r unittests`: 500 runs across two seed
  spaces (including the sequence that previously flaked), 14/14 test cases pass
  every run, 0 failures.
- Builds clean with FDB_MEMORY_TRACKER on and off under -Werror; clang-format
  and clang-tidy clean on the changed files.
- Joshua 100k on the parent commit: ensemble
  20260728-160205-gglass-0b30fa74f2605807, ended=100000 pass=100000 fail=0.
  These changes are a pure counter-update reorder (no simulation-reachable
  behavior change -- bad_alloc is not injected in sim), a unit-test-only change,
  and documentation, so that result remains representative.

* memory-tracker: note why the fdbserver-local bench binary exists

Add a short comment to BenchMain.cpp explaining that most microbenchmarks
belong in flow/bench and this fdbserver-local benchmark binary exists only for
benchmarks that must link fdbserver-only code, pointing at BenchMemoryTracker.cpp
for the detailed rationale rather than duplicating it.
This commit is contained in:
gxglass 2026-07-31 10:12:50 -07:00 committed by GitHub
parent 5e7e0c348b
commit aa705c8c02
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 4289 additions and 74 deletions

View File

@ -136,6 +136,21 @@ Before changing a serialized type that persists on disk, inspect its `serializer
Edit `.actor.cpp` and `.actor.h` sources, not actorcompiler-generated output under the build directory.
## Source File Headers
Every new `.cpp` / `.h` / `.actor.cpp` / `.actor.h` file starts with the standard Apache 2.0 license block, with the filename on line 2 and the current year on the copyright line. Copy from any existing file in the tree (e.g. `flow/Knobs.cpp`). Add file-purpose comments *after* the license block, not in place of it.
## Code Review
Unless you have specific instructions to the contrary, when asked to review code (named files or a diff), address all of these explicitly:
- What is it trying to accomplish?
- Is it correct?
- Are there bugs?
- Are there omissions?
- Are there things that could be done better?
- Should it be LGTM'd? (clear yes / no / not-yet)
## Branching
PRs target `main`. Release branches receive cherry-picks rather than direct PRs — don't open backport PRs without confirming first.

View File

@ -83,6 +83,12 @@ if(WITH_ACAC)
add_compile_definitions(WITH_ACAC)
endif()
option(FDB_MEMORY_TRACKER "Compile in the sampled per-call-site memory tracker (flow/MemoryTracker)" ON)
if(NOT FDB_MEMORY_TRACKER)
message(STATUS "Building FoundationDB with the memory tracker compiled out")
add_compile_definitions(FDB_MEMORY_TRACKER=0)
endif()
###############################################################################
# Packages used for bindings
###############################################################################

365
contrib/mako_ab_binaries.py Normal file
View File

@ -0,0 +1,365 @@
#!/usr/bin/env python3
#
# mako_ab_binaries.py
#
# This source file is part of the FoundationDB open source project
#
# Copyright 2013-2026 Apple Inc. and the FoundationDB project authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""
A/B benchmark across two *different fdbserver binaries*:
A = vanilla main (a build with no memory-tracker code at all)
B = this PR's build, with memory tracking turned OFF (sample_inverse=0)
The point is to isolate the cost of the always-compiled memory-tracker hooks
when they are disabled i.e. to check the design's claim that the off-state
overhead is <=1%. Unlike mako_ab_memtracker.py (one binary, two knob settings),
each arm here runs a different --build.
Only arm B is passed the memory-tracking knob; arm A (main) does not have it and
would reject an unknown knob. Both arms get the RocksDB direct-I/O-off knobs for
the rocksdb engine (main has those long-standing knobs too) since the data dir
is on tmpfs.
Reuses contrib/mako_storage_bench.sh and the chart.js report from
mako_ab_memtracker.py. Run on the dev pod, e.g.:
python3 contrib/mako_ab_binaries.py \
--build-a /root/build_output4 --build-b /root/build_output5 \
--engines redwood rocksdb --warmup 60 --seconds 240 \
--outdir /mnt/ram/binab
"""
import argparse
import json
import os
import re
import shutil
import subprocess
from mako_ab_memtracker import parse_run, line_ds, PCTS, clobber_ramdisk
# Found next to this script rather than hard-coded to one workspace.
_HERE = os.path.dirname(os.path.abspath(__file__))
BENCH_DEFAULT = os.path.join(_HERE, "mako_storage_bench.sh")
# A = baseline blue, B = warm orange.
COLOR = {"a": "#4477CC", "b": "#EE7733"}
def rocksdb_tmpfs_knobs(engine):
"""RocksDB opens its DB with O_DIRECT, which tmpfs does not support; both
binaries need direct I/O off to run rocksdb on /mnt/ram. redwood needs
nothing."""
if engine == "rocksdb":
return [
"--knob_rocksdb_use_direct_reads=0",
"--knob_rocksdb_use_direct_io_flush_compaction=0",
]
return []
def run_arm(bench, build, engine, armkey, extra_knobs, warmup, seconds, rows, outbase):
"""Run one (engine, arm) via mako_storage_bench.sh with the arm's build."""
workdir = os.path.join(outbase, armkey)
env = dict(os.environ)
env["WORKDIR"] = workdir
env["WARMUP_SECONDS"] = str(warmup)
env["SECONDS_RUN"] = str(seconds)
env["ROWS"] = str(rows)
env["KNOBS"] = " ".join(extra_knobs + rocksdb_tmpfs_knobs(engine))
print(f"\n=== {engine} / {armkey} build={build} ===", flush=True)
print(f" WORKDIR={workdir} KNOBS={env['KNOBS']}", flush=True)
subprocess.run(["bash", bench, build, engine], env=env, check=False)
return os.path.join(workdir, engine)
def source_version(build):
"""The git source version baked into the binary (fdbserver --version). Used
to prove A and B are genuinely different builds, and to record provenance."""
fdbserver = os.path.join(build, "bin", "fdbserver")
try:
out = subprocess.run(
[fdbserver, "--version"], capture_output=True, text=True, timeout=60
).stdout
except (OSError, subprocess.SubprocessError):
return None
m = re.search(r"source version (\w+)", out)
return m.group(1) if m else None
# --------------------------------------------------------------------------
def _harvest(rundir, dst):
"""Copy the small result files off tmpfs to persistent storage before the
bulky SS/cluster data is wiped."""
os.makedirs(dst, exist_ok=True)
for name in ("mako.json", "mako-run.txt"):
src = os.path.join(rundir, name)
if os.path.exists(src):
shutil.copy2(src, os.path.join(dst, name))
def collect(bench, build_a, build_b, engines, warmup, seconds, rows, ramdir, outdir):
b_knobs = ["--knob_memory_tracking_sample_inverse=0"]
data = {}
for engine in engines:
data[engine] = {}
for armkey, build, knobs in (("a", build_a, []), ("b", build_b, b_knobs)):
rundir = run_arm(
bench, build, engine, armkey, knobs, warmup, seconds, rows, ramdir
)
dst = os.path.join(outdir, armkey, engine)
_harvest(rundir, dst) # save results to /root for persistence + debugging
metrics = parse_run(dst)
print(f" -> overallTPS={metrics['overallTPS']}", flush=True)
data[engine][armkey] = metrics
return data
def generate_html(data, outpath, meta, warmup, seconds, rows):
engines = list(data.keys())
la, lb = meta["label_a"], meta["label_b"]
rows_html = []
for eng in engines:
a = data[eng].get("a", {})
b = data[eng].get("b", {})
ta, tb = a.get("overallTPS"), b.get("overallTPS")
# B relative to A: negative = B slower (overhead).
d = (100.0 * (tb - ta) / ta) if (ta and tb) else None
p99a = a.get("latency", {}).get("p99")
p99b = b.get("latency", {}).get("p99")
dp = (100.0 * (p99b - p99a) / p99a) if (p99a and p99b) else None
def fmt(v, s=""):
return f"{v:,.0f}{s}" if isinstance(v, (int, float)) else ""
def dfmt(v):
if v is None:
return ""
return f"{'+' if v >= 0 else ''}{v:.2f}%"
rows_html.append(
f"<tr><td>{eng}</td><td>{fmt(ta)}</td><td>{fmt(tb)}</td>"
f"<td class='delta'>{dfmt(d)}</td>"
f"<td>{fmt(p99a,' µs')}</td><td>{fmt(p99b,' µs')}</td>"
f"<td class='delta'>{dfmt(dp)}</td></tr>"
)
tps_a = [data[e].get("a", {}).get("overallTPS") or 0 for e in engines]
tps_b = [data[e].get("b", {}).get("overallTPS") or 0 for e in engines]
tps_datasets = json.dumps(
[
{"label": la, "data": tps_a, "backgroundColor": COLOR["a"]},
{"label": lb, "data": tps_b, "backgroundColor": COLOR["b"]},
]
)
blocks = []
for i, eng in enumerate(engines):
a = data[eng].get("a", {})
b = data[eng].get("b", {})
ps_datasets = json.dumps(
[
line_ds(f"{eng} {la}", a.get("persec", []), COLOR["a"]),
line_ds(f"{eng} {lb}", b.get("persec", []), COLOR["b"], dashed=True),
]
)
lat_labels = json.dumps([s for _, s in PCTS])
lat_a = [a.get("latency", {}).get(s) for _, s in PCTS]
lat_b = [b.get("latency", {}).get(s) for _, s in PCTS]
lat_datasets = json.dumps(
[
{"label": la, "data": lat_a, "backgroundColor": COLOR["a"]},
{"label": lb, "data": lat_b, "backgroundColor": COLOR["b"]},
]
)
n = max(len(a.get("persec", [])), len(b.get("persec", [])), 1)
blocks.append(
f"""
<h2>{eng}</h2>
<div class="chart-box"><canvas id="ps{i}"></canvas></div>
<div class="chart-box"><canvas id="lat{i}"></canvas></div>
<script>
new Chart(document.getElementById('ps{i}'), {{
type: 'line',
data: {{ labels: [...Array({n}).keys()], datasets: {ps_datasets} }},
options: {{ animation:false, responsive:true,
scales: {{ x: {{ title:{{display:true,text:'sample (~1 s each, incl. warmup)'}} }},
y: {{ beginAtZero:true, title:{{display:true,text:'TPS'}} }} }},
plugins: {{ title:{{display:true,text:'{eng}: per-second throughput'}} }} }}
}});
new Chart(document.getElementById('lat{i}'), {{
type: 'bar',
data: {{ labels: {lat_labels}, datasets: {lat_datasets} }},
options: {{ animation:false, responsive:true,
scales: {{ y: {{ beginAtZero:true, title:{{display:true,text:'transaction latency (µs)'}} }} }},
plugins: {{ title:{{display:true,text:'{eng}: transaction latency percentiles'}} }} }}
}});
</script>"""
)
same = meta["ver_a"] and meta["ver_a"] == meta["ver_b"]
guard = ""
if same:
guard = (
"<p class='warn'><b>WARNING:</b> both builds report the same source "
"version — A and B may be the same binary; results are not meaningful.</p>"
)
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>FDB Memory Tracker A/B: vanilla main vs PR (tracking off)</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<style>
body {{ font-family: sans-serif; max-width: 960px; margin: 40px auto; padding: 0 20px; background:#fafafa; }}
h1 {{ font-size:1.5em; margin-bottom:.1em; }} h2 {{ margin-top:2em; }}
p.sub {{ color:#555; }} p.warn {{ color:#b00; }}
table {{ border-collapse:collapse; width:100%; margin:1em 0; background:#fff; }}
th,td {{ border:1px solid #ddd; padding:6px 10px; text-align:right; }}
th:first-child, td:first-child {{ text-align:left; }}
td.delta {{ font-weight:bold; }}
.chart-box {{ background:#fff; border:1px solid #ddd; border-radius:6px; padding:20px; margin-bottom:24px; }}
</style>
</head>
<body>
<h1>FDB Memory Tracker A/B &mdash; vanilla main vs PR (tracking OFF)</h1>
<p class="sub">Two different fdbserver binaries on the same host / workload, isolating the
cost of the always-compiled memory-tracker code when it is <b>disabled</b>.
<b>A</b> = {la} (build <code>{meta['build_a']}</code>, source <code>{meta['ver_a']}</code>);
<b>B</b> = {lb} (build <code>{meta['build_b']}</code>, source <code>{meta['ver_b']}</code>,
<code>memory_tracking_sample_inverse=0</code>).
Single-host 1/1/1 loopback cluster on /mnt/ram (storage isolated, CPU-bound) via
<code>contrib/mako_storage_bench.sh</code>. rows={rows:,}, warmup {warmup}s, run {seconds}s.
&Delta; is B relative to A: a small negative &Delta; is the off-state overhead; the design
target is &le;1%.</p>
{guard}
<p class="sub"><b>Caveat:</b> {meta['drift_note']}</p>
<h2>Summary</h2>
<table>
<tr><th>engine</th><th>TPS A ({la})</th><th>TPS B ({lb})</th><th>&Delta; TPS (B vs A)</th>
<th>p99 lat A</th><th>p99 lat B</th><th>&Delta; p99</th></tr>
{''.join(rows_html)}
</table>
<div class="chart-box"><canvas id="tps"></canvas></div>
<script>
new Chart(document.getElementById('tps'), {{
type: 'bar',
data: {{ labels: {json.dumps(engines)}, datasets: {tps_datasets} }},
options: {{ animation:false, responsive:true,
scales: {{ y: {{ beginAtZero:true, title:{{display:true,text:'Overall TPS'}} }} }},
plugins: {{ title:{{display:true,text:'Overall throughput (A vs B)'}} }} }}
}});
</script>
{''.join(blocks)}
</body>
</html>
"""
with open(outpath, "w") as f:
f.write(html)
return outpath
def main():
ap = argparse.ArgumentParser(
description="Vanilla-main vs PR-tracking-off A/B via mako_storage_bench.sh"
)
ap.add_argument(
"--build-a", default="/root/build_output4", help="vanilla main build (arm A)"
)
ap.add_argument(
"--build-b", default="/root/build_output5", help="this-PR build (arm B)"
)
ap.add_argument("--label-a", default="main (no tracker)")
ap.add_argument("--label-b", default="PR, tracking off")
ap.add_argument("--bench", default=BENCH_DEFAULT)
ap.add_argument("--engines", nargs="+", default=["redwood", "rocksdb"])
ap.add_argument("--warmup", type=int, default=60)
ap.add_argument("--seconds", type=int, default=240)
ap.add_argument("--rows", type=int, default=100000)
ap.add_argument(
"--ramdir",
default="/mnt/ram/binab",
help="tmpfs scratch for SS/cluster data (only SS data lives on /mnt/ram)",
)
ap.add_argument(
"--ram-mount", default="/mnt/ram", help="tmpfs mount clobbered clean at startup"
)
ap.add_argument(
"--outdir",
default="/root/binab_results",
help="persistent results dir on /root (~1 TB); harvested off tmpfs per arm",
)
ap.add_argument(
"--report",
default="/root/src/mako_binab.html",
help="HTML output path (syncs to ~/src on the Mac)",
)
ap.add_argument(
"--drift-note",
default="A and B may build from slightly different main "
"revisions; the delta bundles the PR's off-state cost with any main drift.",
)
ap.add_argument("--report-only", action="store_true")
args = ap.parse_args()
meta = {
"label_a": args.label_a,
"label_b": args.label_b,
"build_a": args.build_a,
"build_b": args.build_b,
"ver_a": source_version(args.build_a),
"ver_b": source_version(args.build_b),
"drift_note": args.drift_note,
}
print(f"A: {args.build_a} source={meta['ver_a']}")
print(f"B: {args.build_b} source={meta['ver_b']}")
if args.report_only:
data = {}
for eng in args.engines:
data[eng] = {
"a": parse_run(os.path.join(args.outdir, "a", eng)),
"b": parse_run(os.path.join(args.outdir, "b", eng)),
}
else:
clobber_ramdisk(args.ramdir, args.ram_mount) # clean slate up front; no end-of-run cleanup
os.makedirs(args.ramdir, exist_ok=True)
os.makedirs(args.outdir, exist_ok=True)
data = collect(
args.bench,
args.build_a,
args.build_b,
args.engines,
args.warmup,
args.seconds,
args.rows,
args.ramdir,
args.outdir,
)
generate_html(data, args.report, meta, args.warmup, args.seconds, args.rows)
print(f"\nReport written: {args.report}")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,570 @@
#!/usr/bin/env python3
#
# mako_ab_memtracker.py
#
# This source file is part of the FoundationDB open source project
#
# Copyright 2013-2026 Apple Inc. and the FoundationDB project authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""
A/B benchmark: FDB per-call-site memory tracker OFF vs ON (1:100 sampling).
Uses contrib/mako_storage_bench.sh (single-host 1/1/1 loopback cluster, storage
role isolated on ~one core, CPU-bound on /mnt/ram) to run each engine under both
arms back-to-back on the same host, then generates a self-contained chart.js
report (mako_memtracker_ab.html) viewable in any browser.
Both arms are the SAME build; only runtime knobs differ:
OFF: --knob_memory_tracking_sample_inverse=0
ON : --knob_memory_tracking_sample_inverse=100
Both also force --knob_memory_tracking_report_interval=30 so the tracker's
periodic dump fires within the run (equal cadence in both arms -> fair) and so
we can verify the knob took by reading MemoryTrackerSummary's SampleInverse out
of the storage process trace.
Run on the dev pod, e.g.:
python3 /root/src/fdb5/foundationdb/contrib/mako_ab_memtracker.py --build /root/build_output5 \
--engines redwood rocksdb --warmup 60 --seconds 240
Only SS/cluster data goes on tmpfs (--ramdir under /mnt/ram, clobbered clean at
startup); per-arm metrics are saved under --outdir on /root so the report
survives the next run's clobber.
"""
import argparse
import json
import os
import re
import statistics
import subprocess
import sys
# The /root and /mnt/ram defaults below are dev-pod defaults; override via flags
# (or FDB_BUILD) elsewhere. BENCH is found next to this script rather than hard-coded.
_HERE = os.path.dirname(os.path.abspath(__file__))
BENCH_DEFAULT = os.path.join(_HERE, "mako_storage_bench.sh")
BUILD_DEFAULT = os.environ.get("FDB_BUILD", "/root/build_output5")
# OFF = baseline blue, ON = warm orange.
COLOR = {"off": "#4477CC", "on": "#EE7733"}
ARMS = [("off", 0), ("on", 100)] # (label, sample_inverse)
PCTS = [
("medianLatency", "p50"),
("p95Latency", "p95"),
("p99Latency", "p99"),
("p99.9Latency", "p99.9"),
]
def _assert_safe_scratch_mount(mount):
"""Refuse to recursively delete anything that isn't clearly a dedicated tmpfs
scratch mount. Guards against a typo or a stray --ram-mount (e.g. /mnt, /, or
$HOME) wiping unrelated data before the run even starts."""
real = os.path.realpath(mount)
denylist = {"/", "/mnt", "/tmp", "/root", "/home", os.path.expanduser("~")}
if real in denylist or real.count("/") < 2:
raise SystemExit(
f"refusing to clobber unsafe scratch path: {mount!r} -> {real!r}"
)
if not os.path.ismount(real):
raise SystemExit(
f"refusing to clobber {real!r}: not a mountpoint (expected a tmpfs mount)"
)
fstype = subprocess.run(
["stat", "-f", "-c", "%T", real], capture_output=True, text=True
).stdout.strip()
if fstype != "tmpfs":
raise SystemExit(
f"refusing to clobber {real!r}: filesystem is {fstype!r}, not tmpfs"
)
def clobber_ramdisk(ramdir, ram_mount):
"""Clear only this benchmark's own scratch dir under the tmpfs so every run
starts clean, without touching anything else sharing the mount (e.g. other
processes' data under /dev/shm). The ramdisk (~24 GB) fills fast across runs.
Done at startup rather than teardown: a killed run can't be trusted to have
cleaned up, and leaving the last run's data in place until the next run keeps
it available to inspect when something fails."""
if not os.path.isdir(ramdir):
return
_assert_safe_scratch_mount(ram_mount)
real_mount = os.path.realpath(ram_mount)
real_ramdir = os.path.realpath(ramdir)
# Only ever delete a directory strictly beneath the validated tmpfs mount, so a
# stray --ramdir can't wipe the mount root or unrelated data elsewhere.
if real_ramdir == real_mount or os.path.commonpath([real_mount, real_ramdir]) != real_mount:
raise SystemExit(
f"refusing to clobber {ramdir!r} -> {real_ramdir!r}: not strictly under mount {real_mount!r}"
)
subprocess.run(["rm", "-rf", real_ramdir], check=False)
print(f"clobbered benchmark scratch dir {real_ramdir}", flush=True)
def save_metrics(dst, metrics):
"""Persist an arm's computed metrics to /root so the report survives the
next run's ramdisk clobber (the traces alloc-rate/knob-verify are derived
from live only while the run's tmpfs data exists)."""
os.makedirs(dst, exist_ok=True)
with open(os.path.join(dst, "metrics.json"), "w") as f:
json.dump(metrics, f)
def load_metrics(dst):
try:
with open(os.path.join(dst, "metrics.json")) as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return {"overallTPS": None, "persec": [], "latency": {}, "ok": False}
def run_arm(bench, build, engine, arm, inverse, warmup, seconds, rows, outbase):
"""Run one (engine, arm) via mako_storage_bench.sh; return its output dir."""
workdir = os.path.join(outbase, arm)
env = dict(os.environ)
env["WORKDIR"] = workdir
env["WARMUP_SECONDS"] = str(warmup)
env["SECONDS_RUN"] = str(seconds)
env["ROWS"] = str(rows)
knobs = [
f"--knob_memory_tracking_sample_inverse={inverse}",
"--knob_memory_tracking_report_interval=30",
]
if engine == "rocksdb":
# RocksDB opens its DB with O_DIRECT by default; tmpfs (/mnt/ram, where
# this harness runs its data dir) does not support direct I/O, so the
# storage engine fails to Open and the cluster never configures. Turn
# direct I/O off via RocksDB's existing knobs (no new knobs added).
knobs += [
"--knob_rocksdb_use_direct_reads=0",
"--knob_rocksdb_use_direct_io_flush_compaction=0",
]
env["KNOBS"] = " ".join(knobs)
print(f"\n=== {engine} / {arm} (sample_inverse={inverse}) ===", flush=True)
print(f" WORKDIR={workdir} KNOBS={env['KNOBS']}", flush=True)
subprocess.run(["bash", bench, build, engine], env=env, check=False)
return os.path.join(workdir, engine)
def verify_sample_inverse(rundir):
"""Read MemoryTrackerSummary's SampleInverse from the storage trace.
Returns the observed int, or None if no summary event was found."""
lc = os.path.join(rundir, "loopback-cluster")
observed = None
if not os.path.isdir(lc):
return None
for root, _, files in os.walk(lc):
for fn in files:
if "trace" not in fn:
continue
try:
with open(os.path.join(root, fn), errors="ignore") as fh:
for line in fh:
if "MemoryTrackerSummary" in line:
m = re.search(r'SampleInverse["\s:=]+(-?\d+)', line)
if m:
observed = int(m.group(1))
except OSError:
pass
return observed
def alloc_rate_from_trace(rundir):
"""Estimate the storage process's allocation rate from the ON arm's
MemoryTrackerSummary events (emitted every report interval, 30s here):
rate = delta(EstCumulativeAllocs)/delta(time). Only meaningful when
sampling is on. Returns {alloc_per_sec, mb_per_sec, samples_per_sec} or
None."""
lc = os.path.join(rundir, "loopback-cluster")
if not os.path.isdir(lc):
return None
def field(line, key, cast=float):
m = re.search(key + r'="?(-?[\d.]+)"?', line)
return cast(m.group(1)) if m else None
rows = []
for root, _, files in os.walk(lc):
for fn in files:
if "trace" not in fn:
continue
try:
for line in open(os.path.join(root, fn), errors="ignore"):
if "MemoryTrackerSummary" not in line:
continue
t = field(line, "Time")
ea = field(line, "EstCumulativeAllocs", int)
eb = field(line, "EstCumulativeBytes", int)
se = field(line, "SamplesEmitted", int)
mm = re.search(r'Machine="([^"]+)"', line)
rm = re.search(r'Roles="([^"]*)"', line)
if t is not None and ea is not None:
rows.append(
(
mm.group(1) if mm else "?",
rm.group(1) if rm else "",
t,
ea,
eb or 0,
se or 0,
)
)
except OSError:
pass
if not rows:
return None
# Pick the storage process: the machine with the highest peak
# EstCumulativeAllocs (storage allocates far more than stateless/log). Use
# all of that machine's summaries (early role-less ones included) so even
# short runs yield >=2 points to difference.
by_mach = {}
for r in rows:
by_mach.setdefault(r[0], []).append(r)
best = max(by_mach.values(), key=lambda rs: max(x[3] for x in rs))
best.sort(key=lambda r: r[2])
if len(best) < 2:
return None
ar, br, sr = [], [], []
for a, b in zip(best, best[1:]):
dt = b[2] - a[2]
if dt <= 0:
continue
ar.append((b[3] - a[3]) / dt)
br.append((b[4] - a[4]) / dt)
sr.append((b[5] - a[5]) / dt)
if len(ar) >= 3: # drop the first interval (startup ramp)
ar, br, sr = ar[1:], br[1:], sr[1:]
if not ar:
return None
return {
"alloc_per_sec": statistics.median(ar),
"mb_per_sec": statistics.median(br) / 1e6,
"samples_per_sec": statistics.median(sr),
}
def parse_run(rundir):
"""Extract metrics from a completed run dir."""
out = {"overallTPS": None, "persec": [], "latency": {}, "ok": False}
mj = os.path.join(rundir, "mako.json")
if os.path.exists(mj):
try:
d = json.load(open(mj))
except (OSError, json.JSONDecodeError):
return out
res = d.get("results", {})
out["overallTPS"] = res.get("overallTPS")
out["persec"] = [s["tps"] for s in d.get("samples", []) if "tps" in s]
for key, short in PCTS:
blk = res.get(key, {})
if isinstance(blk, dict) and "TRANSACTION" in blk:
out["latency"][short] = blk["TRANSACTION"] # microseconds
out["ok"] = out["overallTPS"] is not None
# Fallback: Overall TPS from the teed text report.
if out["overallTPS"] is None:
mt = os.path.join(rundir, "mako-run.txt")
if os.path.exists(mt):
for line in open(mt, errors="ignore"):
m = re.search(r"Overall TPS:\s*([\d.]+)", line)
if m:
out["overallTPS"] = float(m.group(1))
out["ok"] = True
out["rate"] = alloc_rate_from_trace(rundir)
return out
def collect(bench, build, engines, warmup, seconds, rows, ramdir, outdir):
data = {} # engine -> arm -> {metrics, verified_inverse}
for engine in engines:
data[engine] = {}
for arm, inverse in ARMS:
rundir = run_arm(
bench, build, engine, arm, inverse, warmup, seconds, rows, ramdir
)
metrics = parse_run(rundir) # reads mako.json + alloc-rate off tmpfs
observed = verify_sample_inverse(rundir) # reads the trace off tmpfs
metrics["verified_inverse"] = observed
metrics["expected_inverse"] = inverse
ok = "OK" if observed == inverse else f"MISMATCH (saw {observed})"
print(
f" -> overallTPS={metrics['overallTPS']} "
f"knob-verify SampleInverse={observed} expected={inverse} [{ok}]",
flush=True,
)
save_metrics(os.path.join(outdir, arm, engine), metrics) # persist to /root
data[engine][arm] = metrics
return data
# --------------------------------------------------------------------------
# HTML / chart.js generation
# --------------------------------------------------------------------------
def line_ds(label, series, color, dashed=False):
d = {
"label": label,
"data": series,
"borderColor": color,
"backgroundColor": color,
"pointRadius": 0,
"borderWidth": 2,
"tension": 0.2,
"fill": False,
}
if dashed:
d["borderDash"] = [6, 3]
return d
def generate_html(data, outpath, warmup, seconds, rows):
engines = list(data.keys())
# ---- Summary rows ----
rows_html = []
for eng in engines:
off = data[eng].get("off", {})
on = data[eng].get("on", {})
t_off, t_on = off.get("overallTPS"), on.get("overallTPS")
dtps = (100.0 * (t_on - t_off) / t_off) if (t_off and t_on) else None
p99_off = off.get("latency", {}).get("p99")
p99_on = on.get("latency", {}).get("p99")
dp99 = (100.0 * (p99_on - p99_off) / p99_off) if (p99_off and p99_on) else None
def fmt(v, s=""):
return f"{v:,.0f}{s}" if isinstance(v, (int, float)) else ""
def dfmt(v):
if v is None:
return ""
sign = "+" if v >= 0 else ""
return f"{sign}{v:.2f}%"
rows_html.append(
f"<tr><td>{eng}</td><td>{fmt(t_off)}</td><td>{fmt(t_on)}</td>"
f"<td class='delta'>{dfmt(dtps)}</td>"
f"<td>{fmt(p99_off,' µs')}</td><td>{fmt(p99_on,' µs')}</td>"
f"<td class='delta'>{dfmt(dp99)}</td>"
f"<td>off={off.get('verified_inverse')} / on={on.get('verified_inverse')}</td></tr>"
)
# ---- Per-engine charts ----
tps_labels = engines
tps_off = [data[e].get("off", {}).get("overallTPS") or 0 for e in engines]
tps_on = [data[e].get("on", {}).get("overallTPS") or 0 for e in engines]
tps_datasets = json.dumps(
[
{
"label": "off (inverse=0)",
"data": tps_off,
"backgroundColor": COLOR["off"],
},
{
"label": "on (inverse=100)",
"data": tps_on,
"backgroundColor": COLOR["on"],
},
]
)
blocks = []
for i, eng in enumerate(engines):
off = data[eng].get("off", {})
on = data[eng].get("on", {})
# per-second time series
ps_datasets = json.dumps(
[
line_ds(f"{eng} off", off.get("persec", []), COLOR["off"]),
line_ds(f"{eng} on", on.get("persec", []), COLOR["on"], dashed=True),
]
)
# latency percentiles
lat_labels = json.dumps([s for _, s in PCTS])
lat_off = [off.get("latency", {}).get(s) for _, s in PCTS]
lat_on = [on.get("latency", {}).get(s) for _, s in PCTS]
lat_datasets = json.dumps(
[
{"label": "off", "data": lat_off, "backgroundColor": COLOR["off"]},
{"label": "on", "data": lat_on, "backgroundColor": COLOR["on"]},
]
)
blocks.append(
f"""
<h2>{eng}</h2>
<div class="chart-box"><canvas id="ps{i}"></canvas></div>
<div class="chart-box"><canvas id="lat{i}"></canvas></div>
<script>
new Chart(document.getElementById('ps{i}'), {{
type: 'line',
data: {{ labels: [...Array({max(len(off.get('persec',[])), len(on.get('persec',[])), 1)}).keys()],
datasets: {ps_datasets} }},
options: {{ animation:false, responsive:true,
scales: {{ x: {{ title:{{display:true,text:'sample (~1 s each, incl. warmup)'}} }},
y: {{ beginAtZero:true, title:{{display:true,text:'TPS'}} }} }},
plugins: {{ title:{{display:true,text:'{eng}: per-second throughput (off vs on)'}} }} }}
}});
new Chart(document.getElementById('lat{i}'), {{
type: 'bar',
data: {{ labels: {lat_labels}, datasets: {lat_datasets} }},
options: {{ animation:false, responsive:true,
scales: {{ y: {{ beginAtZero:true, title:{{display:true,text:'transaction latency (µs)'}} }} }},
plugins: {{ title:{{display:true,text:'{eng}: transaction latency percentiles (off vs on)'}} }} }}
}});
</script>"""
)
rate_bits = []
for eng in engines:
r = data[eng].get("on", {}).get("rate")
if r:
rate_bits.append(
f"{eng} &approx; {r['alloc_per_sec'] / 1e6:.2f} M allocs/s "
f"({r['mb_per_sec']:.0f} MB/s, {r['samples_per_sec'] / 1e3:.0f}K samples/s)"
)
rate_note = (
(
"<p class='sub'><b>Observed storage-process allocation rate (on arm)</b>, "
"from the tracker's own <code>MemoryTrackerSummary</code> "
"(&Delta;EstCumulativeAllocs / report interval): "
+ "; ".join(rate_bits)
+ ". This is the rate the per-free global-lock cost scales with &mdash; far above "
"a single-threaded microbenchmark's assumed 100K/s, which (with cross-thread lock "
"contention) is why the end-to-end overhead here exceeds the &mu;bench estimate.</p>"
)
if rate_bits
else ""
)
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>FDB Memory Tracker A/B: off vs 1:100 sampling</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
<style>
body {{ font-family: sans-serif; max-width: 960px; margin: 40px auto; padding: 0 20px; background:#fafafa; }}
h1 {{ font-size:1.5em; margin-bottom:.1em; }} h2 {{ margin-top:2em; }}
p.sub {{ color:#555; }}
table {{ border-collapse:collapse; width:100%; margin:1em 0; background:#fff; }}
th,td {{ border:1px solid #ddd; padding:6px 10px; text-align:right; }}
th:first-child, td:first-child {{ text-align:left; }}
td.delta {{ font-weight:bold; }}
.chart-box {{ background:#fff; border:1px solid #ddd; border-radius:6px; padding:20px; margin-bottom:24px; }}
</style>
</head>
<body>
<h1>FDB Memory Tracker A/B &mdash; off vs 1:100 sampling</h1>
<p class="sub">Same build; only the runtime knob differs
(<code>memory_tracking_sample_inverse</code> 0 vs 100, report interval forced to 30&nbsp;s in both).
Single-host 1/1/1 loopback cluster on /mnt/ram (storage isolated, CPU-bound) via
<code>contrib/mako_storage_bench.sh</code>. Workload g18ui,
rows={rows:,}, warmup {warmup}s, run {seconds}s. A TPS drop in the "on" arm means the
tracker added CPU cost on the storage hot path; near-parity means no regression.</p>
<h2>Summary</h2>
<table>
<tr><th>engine</th><th>TPS off</th><th>TPS on</th><th>&Delta; TPS</th>
<th>p99 lat off</th><th>p99 lat on</th><th>&Delta; p99</th>
<th>knob verify (SampleInverse)</th></tr>
{''.join(rows_html)}
</table>
{rate_note}
<div class="chart-box"><canvas id="tps"></canvas></div>
<script>
new Chart(document.getElementById('tps'), {{
type: 'bar',
data: {{ labels: {json.dumps(tps_labels)}, datasets: {tps_datasets} }},
options: {{ animation:false, responsive:true,
scales: {{ y: {{ beginAtZero:true, title:{{display:true,text:'Overall TPS'}} }} }},
plugins: {{ title:{{display:true,text:'Overall throughput (off vs on)'}} }} }}
}});
</script>
{''.join(blocks)}
</body>
</html>
"""
with open(outpath, "w") as f:
f.write(html)
return outpath
def main():
ap = argparse.ArgumentParser(
description="Memory-tracker off/on A/B via mako_storage_bench.sh"
)
ap.add_argument("--build", default=BUILD_DEFAULT)
ap.add_argument("--bench", default=BENCH_DEFAULT)
ap.add_argument("--engines", nargs="+", default=["redwood", "rocksdb"])
ap.add_argument("--warmup", type=int, default=60)
ap.add_argument("--seconds", type=int, default=240)
ap.add_argument("--rows", type=int, default=100000)
ap.add_argument(
"--ramdir",
default="/mnt/ram/memtracker_ab",
help="tmpfs scratch for SS/cluster data (only SS data lives on /mnt/ram)",
)
ap.add_argument(
"--ram-mount", default="/mnt/ram", help="tmpfs mount clobbered clean at startup"
)
ap.add_argument(
"--outdir",
default="/root/memtracker_ab_results",
help="persistent results dir on /root (~1 TB); per-arm metrics saved here",
)
ap.add_argument(
"--report",
default=None,
help="HTML output path (default: /root/src/mako_memtracker_ab.html, "
"which syncs to ~/src on the Mac)",
)
ap.add_argument(
"--report-only",
action="store_true",
help="Skip running; regenerate HTML from an existing --outdir",
)
args = ap.parse_args()
# Default the report into the okteto sync root (~/src <-> /root/src) so it
# lands on the Mac for viewing without a separate copy step.
report = args.report or "/root/src/mako_memtracker_ab.html"
if args.report_only:
data = {}
for eng in args.engines:
data[eng] = {}
for arm, inverse in ARMS:
data[eng][arm] = load_metrics(os.path.join(args.outdir, arm, eng))
else:
clobber_ramdisk(args.ramdir, args.ram_mount) # clean slate up front; no end-of-run cleanup
os.makedirs(args.ramdir, exist_ok=True)
os.makedirs(args.outdir, exist_ok=True)
data = collect(
args.bench,
args.build,
args.engines,
args.warmup,
args.seconds,
args.rows,
args.ramdir,
args.outdir,
)
generate_html(data, report, args.warmup, args.seconds, args.rows)
print(f"\nReport written: {report}")
if __name__ == "__main__":
main()

1030
design/memory-tracker.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -229,3 +229,8 @@ if(NOT OPEN_FOR_IDE)
endif()
target_link_libraries(fdbserver PUBLIC fdbctl)
if(NOT FOUNDATIONDB_CROSS_COMPILING) # FIXME(swift): make this work when
# x-compiling.
add_subdirectory(bench EXCLUDE_FROM_ALL)
endif()

View File

@ -0,0 +1,248 @@
/*
* GlobalNewDelete.cpp
*
* This source file is part of the FoundationDB open source project
*
* Copyright 2013-2026 Apple Inc. and the FoundationDB project authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Process-wide replacements for the global operator new / operator delete set,
// owned by the fdbserver binary.
//
// These live here, in a translation unit compiled directly into the fdbserver
// executable, rather than in the `flow` static library, for two reasons:
//
// 1. Correctness of interposition. operator new / operator delete are
// replaceable functions; a definition sitting in a static archive is only
// pulled into the link if the linker already needs some other symbol from
// that same object file. Placing them in an executable TU guarantees the
// replacements are part of the final link instead of relying on incidental
// archive pull-in.
//
// 2. Client isolation. `flow` is linked into libfdb_c and every client
// binding; a global-new override compiled into it would interpose the
// entire host process's allocator in any application that loads the client.
// fdbserver is a standalone executable that clients never link, so keeping
// these here confines the interposition to the server.
//
// Exactly one implementation is compiled, chosen by the same ALLOC_INSTRUMENTATION
// flags the legacy accounting framework uses (so the two never define the global
// operators twice):
//
// - ALLOC_INSTRUMENTATION[_STDOUT] on -> legacy FastAlloc accounting hooks.
// - otherwise -> the sampled per-call-site memory
// tracker (flow/MemoryTracker.*).
#include <cstdlib>
#include <new>
#include "flow/MemoryTracker.h" // for FDB_MEMORY_TRACKER (default on)
// TODO: the old ALLOC_INSTRUMENTATION doesn't seem to be usable at
// scale. Consider deleting it.
#if defined(ALLOC_INSTRUMENTATION) || defined(ALLOC_INSTRUMENTATION_STDOUT)
#include "flow/FastAlloc.h"
void* operator new(std::size_t size) {
void* p = malloc(size);
if (!p) {
throw std::bad_alloc();
}
recordAllocation(p, size);
return p;
}
void operator delete(void* ptr) throw() {
recordDeallocation(ptr);
free(ptr);
}
void* operator new(std::size_t size, const std::nothrow_t&) throw() {
void* p = malloc(size);
recordAllocation(p, size);
return p;
}
void operator delete(void* ptr, const std::nothrow_t&) throw() {
recordDeallocation(ptr);
free(ptr);
}
void* operator new[](std::size_t size) {
void* p = malloc(size);
if (!p) {
throw std::bad_alloc();
}
recordAllocation(p, size);
return p;
}
void operator delete[](void* ptr) throw() {
recordDeallocation(ptr);
free(ptr);
}
void* operator new[](std::size_t size, const std::nothrow_t&) throw() {
void* p = malloc(size);
recordAllocation(p, size);
return p;
}
void operator delete[](void* ptr, const std::nothrow_t&) throw() {
recordDeallocation(ptr);
free(ptr);
}
#else // sampled memory tracker, see design/memory-tracker.md
#include "flow/Platform.h" // aligned_alloc / aligned_free (portable across MSVC/POSIX)
#if FDB_MEMORY_TRACKER
// NOTE: We (Apple) do not maintain a local facility to build FDB with MSVC on
// Windows, and CI only *configures* (not compiles) there — so the MSVC-specific
// pieces below are best-effort and not compile-verified: the exact signatures of
// the replaceable global operator new/delete set, and the
// aligned_alloc/aligned_free ↔ _aligned_malloc/_aligned_free pairing routed
// through flow/Platform.h. This code may have issues on MSVC; community help for
// the Windows build would be welcome. (See flow/MemoryTracker.cpp for the parallel
// note on the non-Linux frame walker.)
namespace {
// Retry through the installed std::new_handler on failure, as the default
// operator new does. fdbserver installs platform::outOfMemory, so an allocation
// failure (including the tracker's own map growth) reaches FDB's OOM diagnostics
// and FDB_EXIT_NO_MEM rather than throwing straight past them.
void* mallocWithNewHandler(std::size_t n) {
void* p;
while (!(p = std::malloc(n))) {
std::new_handler h = std::get_new_handler();
if (!h) {
throw std::bad_alloc();
}
h();
}
return p;
}
// Same handler loop for over-aligned allocations. C11 aligned_alloc requires the
// size to be a multiple of the alignment, so round up (harmless over-allocation)
// to accept arbitrary operator-new sizes.
void* alignedAllocWithNewHandler(std::size_t alignment, std::size_t n) {
std::size_t rounded = (n + alignment - 1) & ~(alignment - 1);
if (rounded < n) {
throw std::bad_alloc(); // round-up overflowed; the request can't be satisfied
}
void* p;
while (!(p = aligned_alloc(alignment, rounded))) {
std::new_handler h = std::get_new_handler();
if (!h) {
throw std::bad_alloc();
}
h();
}
return p;
}
} // namespace
void* operator new(std::size_t n) {
void* p = mallocWithNewHandler(n);
memTrackerOnAlloc(p, n);
return p;
}
void operator delete(void* p) noexcept {
memTrackerOnFree(p);
std::free(p);
}
void operator delete(void* p, std::size_t) noexcept {
memTrackerOnFree(p);
std::free(p);
}
void* operator new[](std::size_t n) {
void* p = mallocWithNewHandler(n);
memTrackerOnAlloc(p, n);
return p;
}
void operator delete[](void* p) noexcept {
memTrackerOnFree(p);
std::free(p);
}
void operator delete[](void* p, std::size_t) noexcept {
memTrackerOnFree(p);
std::free(p);
}
void* operator new(std::size_t n, const std::nothrow_t&) noexcept {
try {
void* p = mallocWithNewHandler(n);
memTrackerOnAlloc(p, n);
return p;
} catch (...) {
return nullptr;
}
}
void operator delete(void* p, const std::nothrow_t&) noexcept {
memTrackerOnFree(p);
std::free(p);
}
void* operator new[](std::size_t n, const std::nothrow_t&) noexcept {
try {
void* p = mallocWithNewHandler(n);
memTrackerOnAlloc(p, n);
return p;
} catch (...) {
return nullptr;
}
}
void operator delete[](void* p, const std::nothrow_t&) noexcept {
memTrackerOnFree(p);
std::free(p);
}
// C++17 over-aligned new/delete. aligned_alloc/aligned_free (flow/Platform.h)
// keep the alloc and free sides paired on MSVC (_aligned_malloc/_aligned_free).
void* operator new(std::size_t n, std::align_val_t a) {
void* p = alignedAllocWithNewHandler(static_cast<std::size_t>(a), n);
memTrackerOnAlloc(p, n);
return p;
}
void operator delete(void* p, std::align_val_t) noexcept {
memTrackerOnFree(p);
aligned_free(p);
}
void operator delete(void* p, std::size_t, std::align_val_t) noexcept {
memTrackerOnFree(p);
aligned_free(p);
}
void* operator new[](std::size_t n, std::align_val_t a) {
void* p = alignedAllocWithNewHandler(static_cast<std::size_t>(a), n);
memTrackerOnAlloc(p, n);
return p;
}
void operator delete[](void* p, std::align_val_t) noexcept {
memTrackerOnFree(p);
aligned_free(p);
}
void operator delete[](void* p, std::size_t, std::align_val_t) noexcept {
memTrackerOnFree(p);
aligned_free(p);
}
#else // !FDB_MEMORY_TRACKER — no global operator new/delete override; libc++'s is used.
#endif // FDB_MEMORY_TRACKER
#endif // ALLOC_INSTRUMENTATION

View File

@ -0,0 +1,769 @@
/*
* MemoryTrackerTest.cpp
*
* This source file is part of the FoundationDB open source project
*
* Copyright 2013-2026 Apple Inc. and the FoundationDB project authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Unit tests for the per-call-site memory tracker.
//
// The "coverage" test uses sentinel functions: each sentinel triggers exactly
// one allocation path (operator new, FastAllocator, Arena), and the test
// confirms that some call site in the aggregation table contains a frame
// inside that sentinel's body. We compare raw return-address values against
// function-pointer values at runtime, so this works on stripped builds with
// no symbolization.
#include "flow/Arena.h"
#include "flow/FastAlloc.h"
#include "flow/Knobs.h"
#include "flow/MemoryTracker.h"
#include "flow/Platform.h"
#include "flow/UnitTest.h"
#include <climits>
#include <cstdint>
#include <cstdlib>
#include <limits>
#include <new>
#include <vector>
// Force this TU to link. The TEST_CASE macro registers via a static
// initializer; in a static library, a TU containing only static initializers
// gets dropped by the linker because nothing references its symbols.
// fdbserver/workloads/UnitTests.cpp calls this function to keep the TU.
void forceLinkMemoryTrackerTests() {}
#if FDB_MEMORY_TRACKER
namespace {
// A sentinel is an out-of-line function that performs exactly one kind of
// allocation, then returns its own address. We use the returned address to
// recognize captured stack frames that fell inside the sentinel's body.
constexpr uintptr_t SENTINEL_FUNC_SIZE = 4096;
// Defeat clang -O3 heap elision (P0593): if the allocated pointer doesn't
// escape, the compiler is free to drop the new/delete pair entirely, which
// then never reaches our operator-new override and the test sees zero samples.
void* volatile gEscapeSink;
inline void escape(void* p) {
gEscapeSink = p;
}
bool frameInside(void* frame, void* sentinel) {
uintptr_t f = reinterpret_cast<uintptr_t>(frame);
uintptr_t s = reinterpret_cast<uintptr_t>(sentinel);
return f >= s && f < s + SENTINEL_FUNC_SIZE;
}
force_noinline void* triggerOperatorNewSentinel(int n, int k) {
for (int i = 0; i < n; i++) {
auto* p = new int[k];
p[0] = i;
escape(p);
delete[] p;
}
return reinterpret_cast<void*>(&triggerOperatorNewSentinel);
}
force_noinline void* triggerFastAllocSentinel(int n) {
for (int i = 0; i < n; i++) {
void* p = FastAllocator<32>::allocate();
escape(p);
FastAllocator<32>::release(p);
}
return reinterpret_cast<void*>(&triggerFastAllocSentinel);
}
force_noinline void* triggerArenaSentinel(int n) {
// Force ArenaBlock::create by allocating large enough chunks to exceed
// the small-block threshold.
for (int i = 0; i < n; i++) {
Arena a;
// One ~512-byte allocation per arena -> goes through allocateAndMaybeKeepalive
// path which has the explicit Arena hook.
auto* p = new (a) uint8_t[600];
escape(p);
}
return reinterpret_cast<void*>(&triggerArenaSentinel);
}
// ---------------------------------------------------------------------------
// Accounting tests: verify byte/block counts come out right per allocation path
// and there's no double-tracking.
force_noinline void* allocateArenaMediumSentinel(int n, std::vector<Arena>& arenas) {
for (int i = 0; i < n; i++) {
arenas.emplace_back();
auto* p = new (arenas.back()) uint8_t[600];
escape(p);
}
return reinterpret_cast<void*>(&allocateArenaMediumSentinel);
}
force_noinline void* allocateArenaHugeSentinel(int n, std::vector<Arena>& arenas) {
for (int i = 0; i < n; i++) {
arenas.emplace_back();
auto* p = new (arenas.back()) uint8_t[100000];
escape(p);
}
return reinterpret_cast<void*>(&allocateArenaHugeSentinel);
}
force_noinline void* allocateArenaSmallSentinel(int n, std::vector<Arena>& arenas) {
for (int i = 0; i < n; i++) {
arenas.emplace_back();
auto* p = new (arenas.back()) uint8_t[64];
escape(p);
}
return reinterpret_cast<void*>(&allocateArenaSmallSentinel);
}
force_noinline void* allocateOperatorNewSentinel(int n, int k, std::vector<int*>& ptrs) {
for (int i = 0; i < n; i++) {
auto* p = new int[k];
escape(p);
ptrs.push_back(p);
}
return reinterpret_cast<void*>(&allocateOperatorNewSentinel);
}
force_noinline void* allocateFastAlloc32Sentinel(int n, std::vector<void*>& ptrs) {
for (int i = 0; i < n; i++) {
void* p = FastAllocator<32>::allocate();
escape(p);
ptrs.push_back(p);
}
return reinterpret_cast<void*>(&allocateFastAlloc32Sentinel);
}
force_noinline void releaseFastAlloc32(std::vector<void*>& ptrs) {
for (void* p : ptrs) {
FastAllocator<32>::release(p);
}
ptrs.clear();
}
struct AccountingSummary {
int sitesWithSentinelFrames = 0;
int64_t cumBytesSentinel = 0;
int64_t cumAllocsSentinel = 0;
int64_t liveBytesSentinel = 0;
int64_t liveCountSentinel = 0;
int totalSites = 0;
};
AccountingSummary collectAccounting(void* sentinel) {
AccountingSummary acc;
memTrackerForEachSite([&](const MemoryTrackerCallSite& s) {
acc.totalSites++;
bool touches = false;
for (int i = 0; i < s.exemplarFrameCount; i++) {
if (frameInside(s.exemplarFrames[i], sentinel)) {
touches = true;
break;
}
}
if (touches && s.cumulativeBytes > 0) {
acc.sitesWithSentinelFrames++;
acc.cumBytesSentinel += s.cumulativeBytes;
acc.cumAllocsSentinel += s.cumulativeAllocs;
acc.liveBytesSentinel += s.liveBytes;
acc.liveCountSentinel += s.liveCount;
}
});
return acc;
}
void dumpSitesForFailure(const char* tag) {
fprintf(stderr, "[%s] dumping all tracker sites:\n", tag);
memTrackerForEachSite([&](const MemoryTrackerCallSite& s) {
fprintf(stderr,
" fp=%016llx liveBytes=%lld liveCount=%lld cumBytes=%lld cumAllocs=%lld frames=",
(unsigned long long)s.fingerprint,
(long long)s.liveBytes,
(long long)s.liveCount,
(long long)s.cumulativeBytes,
(long long)s.cumulativeAllocs);
for (int i = 0; i < s.exemplarFrameCount; i++) {
fprintf(stderr, "%p ", s.exemplarFrames[i]);
}
fprintf(stderr, "\n");
});
}
class KnobOverride {
public:
explicit KnobOverride(int inverse = 1) : prevInverse(FLOW_KNOBS->MEMORY_TRACKING_SAMPLE_INVERSE) {
auto* k = const_cast<FlowKnobs*>(FLOW_KNOBS);
k->MEMORY_TRACKING_SAMPLE_INVERSE = inverse;
}
~KnobOverride() {
auto* k = const_cast<FlowKnobs*>(FLOW_KNOBS);
k->MEMORY_TRACKING_SAMPLE_INVERSE = prevInverse;
}
private:
int prevInverse;
};
} // namespace
TEST_CASE("/flow/MemoryTracker/coverage") {
#ifndef __linux__
// captureFramesFP is a no-op stub on non-Linux (FP walking through libc
// can't be made reliable on macOS); tests that inspect captured frames
// have nothing to inspect. Skip cleanly. The tracker still compiles
// and the non-frame tests (offSwitch, freeOfUntrackedPtrIsNoop) still
// run.
return Void();
#endif
// Sample everything, reset, run sentinels, check.
KnobOverride ko;
memTrackerResetForTest();
void* opNew = triggerOperatorNewSentinel(50, 4);
void* fastAlloc = triggerFastAllocSentinel(50);
void* arena = triggerArenaSentinel(50);
bool foundOpNew = false;
bool foundFastAlloc = false;
bool foundArena = false;
int siteCount = 0;
memTrackerForEachSite([&](const MemoryTrackerCallSite& s) {
siteCount++;
for (int i = 0; i < s.exemplarFrameCount; i++) {
if (frameInside(s.exemplarFrames[i], opNew)) {
foundOpNew = true;
}
if (frameInside(s.exemplarFrames[i], fastAlloc)) {
foundFastAlloc = true;
}
if (frameInside(s.exemplarFrames[i], arena)) {
foundArena = true;
}
}
});
if (!foundOpNew || !foundFastAlloc || !foundArena) {
fprintf(stderr,
"MemoryTracker/coverage: sites=%d opNewSentinel=%p fastAllocSentinel=%p arenaSentinel=%p\n",
siteCount,
opNew,
fastAlloc,
arena);
fprintf(stderr,
"MemoryTracker/coverage: foundOpNew=%d foundFastAlloc=%d foundArena=%d\n",
foundOpNew,
foundFastAlloc,
foundArena);
memTrackerForEachSite([&](const MemoryTrackerCallSite& s) {
fprintf(stderr,
" site fp=%016llx liveBytes=%lld cumAllocs=%lld frames=",
(unsigned long long)s.fingerprint,
(long long)s.liveBytes,
(long long)s.cumulativeAllocs);
for (int i = 0; i < s.exemplarFrameCount; i++) {
fprintf(stderr, "%p ", s.exemplarFrames[i]);
}
fprintf(stderr, "\n");
});
}
ASSERT(foundOpNew);
ASSERT(foundFastAlloc);
ASSERT(foundArena);
return Void();
}
TEST_CASE("/flow/MemoryTracker/offSwitch") {
// With sample inverse 0, no allocations are attributed. memTrackerResetForTest
// arms this thread's off-latch straight from the knob (as memTrackerInit does
// at startup), so even the first allocation short-circuits.
auto* k = const_cast<FlowKnobs*>(FLOW_KNOBS);
int prev = k->MEMORY_TRACKING_SAMPLE_INVERSE;
k->MEMORY_TRACKING_SAMPLE_INVERSE = 0;
memTrackerResetForTest();
for (int i = 0; i < 100; i++) {
auto* p = new int[4];
p[0] = i;
delete[] p;
}
int siteCount = 0;
memTrackerForEachSite([&](const MemoryTrackerCallSite&) { siteCount++; });
ASSERT_EQ(siteCount, 0);
// The enabled flag gates the free hot path: with sampling off it must be
// false, so memTrackerOnFree short-circuits before taking g_mtLock.
ASSERT(!g_memTrackerEnabled.value.load(std::memory_order_relaxed));
k->MEMORY_TRACKING_SAMPLE_INVERSE = prev;
return Void();
}
TEST_CASE("/flow/MemoryTracker/operatorNewHonorsNewHandler") {
// The global operator new override (fdbserver/GlobalNewDelete.cpp) must run
// the std::new_handler retry loop so an allocation failure reaches FDB's OOM
// path instead of throwing straight past it. (Where the override isn't linked,
// the standard library's operator new provides the same contract, so this
// still passes.)
static bool handlerRan;
handlerRan = false;
std::new_handler prev = std::set_new_handler([]() {
handlerRan = true;
throw std::bad_alloc(); // break the retry loop
});
bool caught = false;
try {
// volatile so the compiler can't fold the size and warn (-Walloc-size); malloc
// reliably fails for SIZE_MAX, driving the handler loop.
volatile std::size_t huge = std::numeric_limits<std::size_t>::max();
void* p = ::operator new(huge);
escape(p);
} catch (const std::bad_alloc&) {
caught = true;
}
std::set_new_handler(prev);
ASSERT(handlerRan);
ASSERT(caught);
return Void();
}
TEST_CASE("/flow/MemoryTracker/samplingRate") {
// The reseed gap is uniform on [1, 2N-1] (mean N), so at inverse N the sampled
// fraction should be ~1/N. Wide bounds keep it non-flaky across RNG state.
constexpr int N = 10;
constexpr int ALLOCS = 200000;
KnobOverride ko(N);
memTrackerResetForTest();
std::vector<int*> ptrs;
ptrs.reserve(ALLOCS);
for (int i = 0; i < ALLOCS; i++) {
auto* p = new int[4];
escape(p);
ptrs.push_back(p);
}
int64_t sampled = 0;
memTrackerForEachSite([&](const MemoryTrackerCallSite& s) {
if (s.forceSampledCount == 0) {
sampled += s.cumulativeAllocs;
}
});
for (auto* p : ptrs) {
delete[] p;
}
ptrs.clear();
double frac = double(sampled) / ALLOCS;
ASSERT(frac > 0.06 && frac < 0.15); // expect ~0.1
memTrackerResetForTest();
return Void();
}
TEST_CASE("/flow/MemoryTracker/freeOfUntrackedPtrIsNoop") {
// memTrackerOnFree on a pointer the tracker never recorded must be a no-op.
KnobOverride ko;
memTrackerResetForTest();
int x = 0;
memTrackerOnFree(&x); // not in any table
memTrackerOnFree(nullptr);
int siteCount = 0;
memTrackerForEachSite([&](const MemoryTrackerCallSite&) { siteCount++; });
ASSERT_EQ(siteCount, 0);
return Void();
}
TEST_CASE("/flow/MemoryTracker/cumulativeIsMonotonic") {
#ifndef __linux__
return Void(); // see /coverage for rationale
#endif
// liveCount must return to ~0 after we free everything we allocated;
// cumulativeAllocs must NOT decrement.
KnobOverride ko;
memTrackerResetForTest();
void* sentinel = triggerOperatorNewSentinel(100, 8);
int64_t maxCumulative = 0;
int64_t finalLive = 0;
memTrackerForEachSite([&](const MemoryTrackerCallSite& s) {
for (int i = 0; i < s.exemplarFrameCount; i++) {
if (frameInside(s.exemplarFrames[i], sentinel)) {
if (s.cumulativeAllocs > maxCumulative) {
maxCumulative = s.cumulativeAllocs;
}
finalLive += s.liveCount;
}
}
});
ASSERT(maxCumulative >= 100);
ASSERT_EQ(finalLive, 0); // every alloc was paired with delete
return Void();
}
TEST_CASE("/flow/MemoryTracker/estimateScaling") {
// End-to-end estimate check. With a fixed inverse N > 1 and no force-sampled
// blocks, every sample at a site carries weight N, so the site's estimated
// usage must be *exactly* N times its raw sampled counters. This verifies
// the reported Est* numbers without depending on which specific allocations
// happened to be sampled. Runs on all platforms (no frame inspection).
constexpr int N = 8;
KnobOverride ko(N);
memTrackerResetForTest();
// Small allocations far below the force-sample threshold, so none
// are force-sampled and every sampled block gets weight N.
std::vector<int*> ptrs;
ptrs.reserve(5000);
for (int i = 0; i < 5000; i++) {
auto* p = new int[4];
escape(p);
ptrs.push_back(p);
}
int checked = 0;
int64_t rawLiveBefore = 0;
memTrackerForEachSite([&](const MemoryTrackerCallSite& s) {
if (s.forceSampledCount != 0) {
return; // ignore any incidental force-sampled site (weight 1, not N)
}
ASSERT_EQ(s.estCumulativeBytes, s.cumulativeBytes * N);
ASSERT_EQ(s.estCumulativeAllocs, s.cumulativeAllocs * N);
ASSERT_EQ(s.estLiveBytes, s.liveBytes * N);
ASSERT_EQ(s.estLiveCount, s.liveCount * N);
ASSERT_EQ(s.estPeakBytes, s.peakBytes * N);
rawLiveBefore += s.liveBytes;
checked++;
});
ASSERT(checked > 0);
ASSERT(rawLiveBefore > 0);
for (auto* p : ptrs) {
delete[] p;
}
ptrs.clear();
// Symmetric debit: the per-site scaling invariant must still hold after the
// frees (each free debits the estimate by exactly weight×size), and the live
// total must have dropped. We check the invariant rather than "live == 0"
// because incidental still-live allocations (e.g. the ptrs vector's own
// backing buffer) legitimately remain tracked.
int64_t rawLiveAfter = 0;
int64_t estLiveAfter = 0;
memTrackerForEachSite([&](const MemoryTrackerCallSite& s) {
if (s.forceSampledCount != 0) {
return;
}
ASSERT_EQ(s.estLiveBytes, s.liveBytes * N);
rawLiveAfter += s.liveBytes;
estLiveAfter += s.estLiveBytes;
});
ASSERT_EQ(estLiveAfter, rawLiveAfter * N);
ASSERT(rawLiveAfter < rawLiveBefore); // the freed blocks were debited
memTrackerResetForTest();
return Void();
}
// ---------------------------------------------------------------------------
// Accounting tests. The "sites with the sentinel's frames" assertion is the
// main check here.
TEST_CASE("/flow/MemoryTracker/fastAlloc32Accounting") {
#ifndef __linux__
return Void(); // see /coverage for rationale
#endif
KnobOverride ko;
constexpr int N = 30;
std::vector<void*> ptrs;
ptrs.reserve(N);
memTrackerResetForTest();
void* sentinel = allocateFastAlloc32Sentinel(N, ptrs);
auto pre = collectAccounting(sentinel);
if (pre.sitesWithSentinelFrames != 1) {
dumpSitesForFailure("fastAlloc32Accounting/post-alloc");
}
ASSERT_EQ(pre.sitesWithSentinelFrames, 1);
ASSERT_EQ(pre.cumAllocsSentinel, N);
ASSERT_EQ(pre.liveCountSentinel, N);
ASSERT_EQ(pre.cumBytesSentinel, int64_t(N) * 32);
ASSERT_EQ(pre.liveBytesSentinel, pre.cumBytesSentinel);
// Global totals are intentionally not asserted: at inverse=1 a foreign-thread
// allocation in the window would break a strict global equality (flaky at
// Joshua scale); the sentinel-scoped checks above pin the regression.
releaseFastAlloc32(ptrs);
auto post = collectAccounting(sentinel);
ASSERT_EQ(post.liveBytesSentinel, 0);
ASSERT_EQ(post.liveCountSentinel, 0);
// Global live totals intentionally not asserted (flaky at inverse=1; see above).
ASSERT_EQ(post.cumAllocsSentinel, N); // cumulative never decrements
return Void();
}
TEST_CASE("/flow/MemoryTracker/arenaSmallAccounting") {
#ifndef __linux__
return Void(); // see /coverage for rationale
#endif
KnobOverride ko;
constexpr int N = 30;
std::vector<Arena> arenas;
arenas.reserve(N);
memTrackerResetForTest();
void* sentinel = allocateArenaSmallSentinel(N, arenas);
auto pre = collectAccounting(sentinel);
if (pre.sitesWithSentinelFrames != 1) {
dumpSitesForFailure("arenaSmallAccounting/post-alloc");
}
ASSERT_EQ(pre.sitesWithSentinelFrames, 1);
ASSERT_EQ(pre.cumAllocsSentinel, N);
ASSERT_EQ(pre.liveCountSentinel, N);
ASSERT_EQ(pre.liveBytesSentinel, pre.cumBytesSentinel);
// Global totals are intentionally not asserted: at inverse=1 a foreign-thread
// allocation in the window would break a strict global equality (flaky at
// Joshua scale); the sentinel-scoped checks above pin the regression.
arenas.clear();
auto post = collectAccounting(sentinel);
ASSERT_EQ(post.liveBytesSentinel, 0);
ASSERT_EQ(post.liveCountSentinel, 0);
// Global live totals intentionally not asserted (flaky at inverse=1; see above).
ASSERT_EQ(post.cumAllocsSentinel, N);
return Void();
}
TEST_CASE("/flow/MemoryTracker/arenaMediumAccounting") {
#ifndef __linux__
return Void(); // see /coverage for rationale
#endif
// Make sure arenas aren't counted twice, once due to their direct
// instrumentation and a second time due to their use of operator new.
KnobOverride ko;
constexpr int N = 30;
std::vector<Arena> arenas;
arenas.reserve(N);
memTrackerResetForTest();
void* sentinel = allocateArenaMediumSentinel(N, arenas);
auto pre = collectAccounting(sentinel);
if (pre.sitesWithSentinelFrames != 1) {
dumpSitesForFailure("arenaMediumAccounting/post-alloc");
}
ASSERT_EQ(pre.sitesWithSentinelFrames, 1);
ASSERT_EQ(pre.cumAllocsSentinel, N);
ASSERT_EQ(pre.liveCountSentinel, N);
ASSERT_EQ(pre.liveBytesSentinel, pre.cumBytesSentinel);
// Global totals intentionally not asserted (flaky at inverse=1; see above).
arenas.clear();
auto post = collectAccounting(sentinel);
ASSERT_EQ(post.liveBytesSentinel, 0);
ASSERT_EQ(post.liveCountSentinel, 0);
// Global live totals intentionally not asserted (flaky at inverse=1; see above).
ASSERT_EQ(post.cumAllocsSentinel, N);
return Void();
}
TEST_CASE("/flow/MemoryTracker/arenaHugeAccounting") {
#ifndef __linux__
return Void(); // see /coverage for rationale
#endif
// Verify the huge Arena-block path (reqSize >= LARGE), including that a block is
// tracked once (not double-counted by both the explicit Arena hook and the inner
// operator new[]). This is the deepest tracked call chain, and under a real
// (non-simulation) network its frame-pointer backtrace is unreliable — the
// best-effort walker may, run to run and even alloc to alloc, fail to climb to
// the test's frame or attribute the blocks to varying fingerprints. So instead of
// the sentinel-frame approach the other *Accounting tests use, we identify the
// huge blocks by their unmistakable ~100 KB size signature: the recorded size is
// correct regardless of which frames were captured, and no incidental or
// foreign-thread allocation comes anywhere near this large.
KnobOverride ko;
constexpr int N = 10;
constexpr int64_t BLOCK = 100000;
constexpr int64_t HUGE_MIN = 90000; // mean bytes/alloc of a huge site; nothing else is this big
std::vector<Arena> arenas;
arenas.reserve(N);
memTrackerResetForTest();
allocateArenaHugeSentinel(N, arenas);
int64_t cumAllocs = 0, cumBytes = 0, liveBytes = 0, liveCount = 0;
memTrackerForEachSite([&](const MemoryTrackerCallSite& s) {
if (s.cumulativeAllocs > 0 && s.cumulativeBytes / s.cumulativeAllocs >= HUGE_MIN) {
cumAllocs += s.cumulativeAllocs;
cumBytes += s.cumulativeBytes;
liveBytes += s.liveBytes;
liveCount += s.liveCount;
}
});
// Exactly N huge blocks, each tracked once (double-tracking would show as 2N),
// all currently live, with live bytes == cumulative bytes (nothing freed yet).
ASSERT_EQ(cumAllocs, N);
ASSERT_EQ(liveCount, N);
ASSERT_EQ(liveBytes, cumBytes);
ASSERT(cumBytes >= int64_t(N) * BLOCK);
arenas.clear();
int64_t cumAllocsPost = 0, liveBytesPost = 0, liveCountPost = 0;
memTrackerForEachSite([&](const MemoryTrackerCallSite& s) {
if (s.cumulativeAllocs > 0 && s.cumulativeBytes / s.cumulativeAllocs >= HUGE_MIN) {
cumAllocsPost += s.cumulativeAllocs;
liveBytesPost += s.liveBytes;
liveCountPost += s.liveCount;
}
});
// After freeing, the huge blocks are debited: live returns to 0, cumulative persists.
ASSERT_EQ(liveBytesPost, 0);
ASSERT_EQ(liveCountPost, 0);
ASSERT_EQ(cumAllocsPost, N);
return Void();
}
TEST_CASE("/flow/MemoryTracker/operatorNewAccounting") {
#ifndef __linux__
return Void(); // see /coverage for rationale
#endif
KnobOverride ko;
constexpr int N = 30;
constexpr int K = 8; // new int[8] -> 32 bytes; int is trivial so no array cookie
std::vector<int*> ptrs;
ptrs.reserve(N);
memTrackerResetForTest();
void* sentinel = allocateOperatorNewSentinel(N, K, ptrs);
auto pre = collectAccounting(sentinel);
if (pre.sitesWithSentinelFrames != 1) {
dumpSitesForFailure("operatorNewAccounting/post-alloc");
}
ASSERT_EQ(pre.sitesWithSentinelFrames, 1);
ASSERT_EQ(pre.cumAllocsSentinel, N);
ASSERT_EQ(pre.liveCountSentinel, N);
ASSERT_EQ(pre.cumBytesSentinel, static_cast<int64_t>(N) * K * static_cast<int64_t>(sizeof(int)));
ASSERT_EQ(pre.liveBytesSentinel, pre.cumBytesSentinel);
// Global totals intentionally not asserted (flaky at inverse=1; see above).
for (auto* p : ptrs) {
delete[] p;
}
ptrs.clear();
auto post = collectAccounting(sentinel);
ASSERT_EQ(post.liveBytesSentinel, 0);
ASSERT_EQ(post.liveCountSentinel, 0);
// Global live totals intentionally not asserted (flaky at inverse=1; see above).
ASSERT_EQ(post.cumAllocsSentinel, N);
return Void();
}
TEST_CASE("/flow/MemoryTracker/failOpenOnMetadataAllocFailure") {
// The tracker must fail open: if its own metadata allocation throws, the
// underlying user allocation still succeeds and tracking recovers on this
// thread afterward (the reentrancy guard is restored, not leaked). Runs on all
// platforms — no frame inspection.
KnobOverride ko; // inverse = 1: sample every allocation
memTrackerResetForTest();
// Arm the one-shot; it is consumed by the next sampled allocation, which throws
// inside the tracker. new/delete must not observe that exception.
memTrackerFailNextSampleForTest();
int* p = new int[4];
ASSERT(p != nullptr);
p[0] = 42;
int observed = p[0];
delete[] p;
ASSERT_EQ(observed, 42);
// Tracking must still work after the injected failure.
memTrackerResetForTest();
std::vector<int*> ptrs;
ptrs.reserve(8);
for (int i = 0; i < 8; i++) {
auto* q = new int[4];
escape(q);
ptrs.push_back(q);
}
int siteCount = 0;
memTrackerForEachSite([&](const MemoryTrackerCallSite&) { siteCount++; });
for (auto* q : ptrs) {
delete[] q;
}
ASSERT(siteCount > 0);
return Void();
}
TEST_CASE("/flow/MemoryTracker/initEnablesFromKnob") {
// Exercises the *production* enablement path (memTrackerInit), not the test-only
// reset — the reviewer noted that memTrackerResetForTest masks the startup race.
// memTrackerInit must set the global enabled flag and this thread's fast-path
// off-latch straight from MEMORY_TRACKING_SAMPLE_INVERSE, and (the regression)
// must re-arm a thread that a prior off-configuration had already latched off.
auto* k = const_cast<FlowKnobs*>(FLOW_KNOBS);
int prev = k->MEMORY_TRACKING_SAMPLE_INVERSE;
// Sampling off: init publishes disabled and latches this thread off. This is the
// state an early startup allocation used to get stuck in before init existed.
k->MEMORY_TRACKING_SAMPLE_INVERSE = 0;
memTrackerInit();
ASSERT(!g_memTrackerEnabled.value.load(std::memory_order_relaxed));
ASSERT(gMemTrackerOff); // alloc hot path short-circuits
// Knobs now configured with sampling on: init must re-arm THIS thread. The bug
// was that nothing re-armed the network thread once it latched off before the
// knobs were ready, so sampling stayed dead for the life of the process.
k->MEMORY_TRACKING_SAMPLE_INVERSE = 8;
memTrackerInit();
ASSERT(g_memTrackerEnabled.value.load(std::memory_order_relaxed));
ASSERT(!gMemTrackerOff); // re-armed: alloc hot path now reaches the sampler
// And the reverse transition: a subsequent off-configuration re-latches it.
k->MEMORY_TRACKING_SAMPLE_INVERSE = 0;
memTrackerInit();
ASSERT(!g_memTrackerEnabled.value.load(std::memory_order_relaxed));
ASSERT(gMemTrackerOff);
k->MEMORY_TRACKING_SAMPLE_INVERSE = prev;
memTrackerInit(); // restore tracker state from the baseline knob for later tests
return Void();
}
#endif // FDB_MEMORY_TRACKER

View File

@ -0,0 +1,29 @@
/*
* BenchMain.cpp
*
* This source file is part of the FoundationDB open source project
*
* Copyright 2013-2026 Apple Inc. and the FoundationDB project authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Most microbenchmarks belong in flow/bench; this fdbserver-local benchmark
// binary exists only for benchmarks that must link against fdbserver-only code.
// See BenchMemoryTracker.cpp for why that one lives here.
#include "flow/BenchMain.h"
int main(int argc, char** argv) {
return runBenchmarks(argc, argv);
}

View File

@ -0,0 +1,167 @@
/*
* BenchMemoryTracker.cpp
*
* This source file is part of the FoundationDB open source project
*
* Copyright 2013-2026 Apple Inc. and the FoundationDB project authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Microbenchmarks for the per-call-site memory tracker (see
// design/memory-tracker.md, Testing Considerations -> Microbenchmarks).
//
// Two questions, both about "is it cheap enough to leave on?" (R0):
// * off-state cost — the always-compiled hooks with sampling disabled;
// * enabled-state cost — hooks at the envisioned production 1% rate
// and the pessimal every-allocation rate, which includes both the
// sampled-alloc slow path and the per-free lock+probe (the
// dominant enabled-state cost).
//
// Run: bin/fdbserver_bench --benchmark_filter=memtracker
//
// This bench lives under fdbserver/ (not flow/) and its CMake compiles
// fdbserver/GlobalNewDelete.cpp into the executable, so the real global
// operator new/delete override is active here and bench_memtracker_operator_new
// actually exercises the tracker. flow_bench links only flow (no override), so
// the operator-new path could not be measured there.
//
// FLOW_KNOBS points at the process-default (non-simulated) bootstrap knobs in
// fdbserver_bench, so MEMORY_TRACKING_SAMPLE_INVERSE starts at 0 (off); we drive
// it per benchmark via const_cast, exactly like the unit tests do.
#include "benchmark/benchmark.h"
#include "flow/Arena.h"
#include "flow/FastAlloc.h"
#include "flow/Knobs.h"
#include "flow/MemoryTracker.h"
#include <cstdlib>
namespace {
constexpr int kSize = 64;
// Set the sample-inverse knob (0=off, N=1-in-N) and clear tracker state so the
// run starts clean. Returns the previous inverse for restoration.
int setInverseAndReset(int inverse) {
auto* k = const_cast<FlowKnobs*>(FLOW_KNOBS);
int prev = k->MEMORY_TRACKING_SAMPLE_INVERSE;
k->MEMORY_TRACKING_SAMPLE_INVERSE = inverse;
memTrackerResetForTest();
return prev;
}
} // namespace
// Baseline: raw libc malloc/free. std::malloc is NOT hooked (we override
// operator new, not libc malloc), so this is the tracker-free reference the
// operator-new benchmark is compared against.
static void bench_memtracker_malloc_free(benchmark::State& state) {
for (auto _ : state) {
void* p = std::malloc(kSize);
benchmark::DoNotOptimize(p);
std::free(p);
}
state.SetItemsProcessed(state.iterations());
}
// End-to-end cost of a hooked allocation: global operator new[]/delete[] (which
// fire memTrackerOnAlloc/OnFree) at sample inverse Arg(0). 0 = off, 100 = prod
// 1%, 1 = every allocation. Compare Arg(0) against bench_memtracker_malloc_free
// for the disabled-hook cost, and Arg(100)/Arg(1) against Arg(0) for sampling.
static void bench_memtracker_operator_new(benchmark::State& state) {
int prev = setInverseAndReset(state.range(0));
for (auto _ : state) {
char* p = new char[kSize];
benchmark::DoNotOptimize(p);
delete[] p;
}
state.SetItemsProcessed(state.iterations());
setInverseAndReset(prev); // restore so later benchmarks aren't sampled
}
// Isolated tracker-hook cost: call memTrackerOnAlloc/OnFree directly on one
// preallocated buffer, with no real allocation in the loop, so only the
// tracker's own work is measured. At inverse>0 with live-tracking on, every
// OnFree still takes the global lock and probes the live table (the dominant
// enabled-state cost), while ~1/inverse of the OnAlloc calls take the sampling
// slow path (frame walk + table insert).
//
// This is doing a stack unwind against the same stack, and is going
// to hit the same hash table entries each iteration, so this is definitely
// a best-case estimate.
static void bench_memtracker_hooks(benchmark::State& state) {
int prev = setInverseAndReset(state.range(0));
void* p = std::malloc(kSize);
for (auto _ : state) {
memTrackerOnAlloc(p, kSize);
memTrackerOnFree(p);
}
state.SetItemsProcessed(state.iterations());
std::free(p);
setInverseAndReset(prev);
}
BENCHMARK(bench_memtracker_malloc_free);
BENCHMARK(bench_memtracker_operator_new)->Arg(0)->Arg(100)->Arg(1);
BENCHMARK(bench_memtracker_hooks)->Arg(0)->Arg(100)->Arg(1);
// --- Per-path unweighted overhead (for the FDB_MEMORY_TRACKER compile gate) ---
// Plain allocation loops with no knob manipulation: in a default build
// (FDB_MEMORY_TRACKER=1) they run with the tracker present but sampling off; in a
// FDB_MEMORY_TRACKER=0 build the tracker code is absent. Diffing the two builds
// gives each path's always-compiled off-state cost per op, unweighted by how
// often the path is actually taken at runtime.
// operator new[]/delete[] at size Arg(0): exercises GlobalNewDelete.cpp (our
// override when FDB_MEMORY_TRACKER, else libc++'s operator new).
static void bench_path_operator_new(benchmark::State& state) {
size_t n = state.range(0);
for (auto _ : state) {
char* p = new char[n];
benchmark::DoNotOptimize(p);
delete[] p;
}
state.SetItemsProcessed(state.iterations());
}
// FastAllocator<Size> allocate/release: exercises the flow/FastAlloc.cpp hook.
template <int Size>
static void bench_path_fastalloc(benchmark::State& state) {
for (auto _ : state) {
void* p = FastAllocator<Size>::allocate();
benchmark::DoNotOptimize(p);
FastAllocator<Size>::release(p);
}
state.SetItemsProcessed(state.iterations());
}
// Arena block create/destroy: exercises the flow/Arena.cpp hook. Arg(0) is the
// user allocation size, which selects the block class (medium vs huge).
static void bench_path_arena(benchmark::State& state) {
int n = state.range(0);
for (auto _ : state) {
Arena a;
uint8_t* p = new (a) uint8_t[n];
benchmark::DoNotOptimize(p);
}
state.SetItemsProcessed(state.iterations());
}
BENCHMARK(bench_path_operator_new)->Arg(64)->Arg(96)->Arg(256)->Arg(100000);
BENCHMARK_TEMPLATE(bench_path_fastalloc, 64);
BENCHMARK_TEMPLATE(bench_path_fastalloc, 96);
BENCHMARK_TEMPLATE(bench_path_fastalloc, 256);
BENCHMARK(bench_path_arena)->Arg(600)->Arg(100000);

View File

@ -0,0 +1,28 @@
# Most bench stuff is in flow/bench. This is in fdbserver because it links with
# code we specifically do not want in flow/ (to avoid pulling into clients).
include(FDBBenchmark)
fdb_find_sources(FDBSERVER_BENCH_SRCS)
# Compile fdbserver/GlobalNewDelete.cpp directly into this benchmark executable.
# It defines the global operator new/delete overrides that route through the
# memory tracker; as a strong definition in the executable link it wins over
# libc++'s weak one and is active process-wide here, exactly as in fdbserver.
# Without it the bench would link libc++'s operator new and the operator-new
# microbench would measure the unhooked allocator (its Arg(100)/Arg(1) rows
# would equal Arg(0)). It only needs flow (the tracker hooks it calls), not the
# rest of the fdbserver dependency graph.
add_flow_target(EXECUTABLE NAME fdbserver_bench
SRCS ${FDBSERVER_BENCH_SRCS}
ADDL_SRCS "${CMAKE_CURRENT_SOURCE_DIR}/../GlobalNewDelete.cpp")
fdb_setup_googlebenchmark()
target_include_directories(
fdbserver_bench
PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}"
"${CMAKE_CURRENT_SOURCE_DIR}/.."
"${CMAKE_CURRENT_SOURCE_DIR}/include")
target_link_libraries(fdbserver_bench PRIVATE Threads::Threads fdb_google_benchmark flow)

View File

@ -77,6 +77,7 @@
#include "flow/ProtocolVersion.h"
#include "SimpleOpt/SimpleOpt.h"
#include "flow/SystemMonitor.h"
#include "flow/MemoryTracker.h"
#include "flow/TLSConfig.h"
#include "fdbclient/Tracing.h"
#include "flow/WriteOnlySet.h"
@ -705,54 +706,9 @@ static void printUsage(const char* name, bool devhelp) {
extern bool g_crashOnError;
#if defined(ALLOC_INSTRUMENTATION) || defined(ALLOC_INSTRUMENTATION_STDOUT)
void* operator new(std::size_t size) {
void* p = malloc(size);
if (!p)
throw std::bad_alloc();
recordAllocation(p, size);
return p;
}
void operator delete(void* ptr) throw() {
recordDeallocation(ptr);
free(ptr);
}
// scalar, nothrow new and it matching delete
void* operator new(std::size_t size, const std::nothrow_t&) throw() {
void* p = malloc(size);
recordAllocation(p, size);
return p;
}
void operator delete(void* ptr, const std::nothrow_t&) throw() {
recordDeallocation(ptr);
free(ptr);
}
// array throwing new and matching delete[]
void* operator new[](std::size_t size) {
void* p = malloc(size);
if (!p)
throw std::bad_alloc();
recordAllocation(p, size);
return p;
}
void operator delete[](void* ptr) throw() {
recordDeallocation(ptr);
free(ptr);
}
// array, nothrow new and matching delete[]
void* operator new[](std::size_t size, const std::nothrow_t&) throw() {
void* p = malloc(size);
recordAllocation(p, size);
return p;
}
void operator delete[](void* ptr, const std::nothrow_t&) throw() {
recordDeallocation(ptr);
free(ptr);
}
#endif
// The global operator new / operator delete replacements (both the legacy
// ALLOC_INSTRUMENTATION accounting hooks and the sampled memory tracker) live in
// fdbserver/GlobalNewDelete.cpp.
Optional<bool> checkBuggifyOverride(const char* testFile) {
std::ifstream ifs;
@ -1955,6 +1911,13 @@ int main(int argc, char* argv[]) {
// Reinitialize knobs in order to update knobs that are dependent on explicitly set knobs
initializeServerKnobs(Randomize::True, role == ServerRole::Simulation ? IsSimulated::True : IsSimulated::False);
// Knobs are now final; initialize the sampled memory tracker from them on
// this (soon-to-be network) thread, before any serving role starts. Reading
// the sample-inverse knob explicitly here — rather than inferring it from
// the first allocation — keeps early startup allocations from latching the
// tracker off before the knobs were configured. See design/memory-tracker.md.
memTrackerInit();
// evictionPolicyStringToEnum will throw an exception if the string is not recognized as a valid
EvictablePageCache::evictionPolicyStringToEnum(FLOW_KNOBS->CACHE_EVICTION_POLICY);

View File

@ -35,6 +35,7 @@ void forceLinkJsonWebKeySetTests();
void forceLinkVersionVectorTests();
void forceLinkRESTClientTests();
void forceLinkRESTUtilsTests();
void forceLinkMemoryTrackerTests();
void forceLinkCompressedIntTests();
void forceLinkAtomicTests();
void forceLinkIdempotencyIdTests();
@ -115,6 +116,7 @@ struct UnitTestWorkload : TestWorkload {
forceLinkVersionVectorTests();
forceLinkRESTClientTests();
forceLinkRESTUtilsTests();
forceLinkMemoryTrackerTests();
forceLinkCompressedIntTests();
forceLinkAtomicTests();
forceLinkIdempotencyIdTests();

View File

@ -19,6 +19,7 @@
*/
#include "flow/Arena.h"
#include "flow/MemoryTracker.h"
#include "flow/ScopeExit.h"
#include "flow/SimpleCounter.h"
#include "flow/UnitTest.h"
@ -460,36 +461,55 @@ ArenaBlock* ArenaBlock::create(int dataSize, Reference<ArenaBlock>& next) {
b = (ArenaBlock*)FastAllocator<256>::allocate();
b->bigSize = 256;
INSTRUMENT_ALLOCATE("Arena256");
} else if (reqSize <= 512) {
b = (ArenaBlock*)allocateAndMaybeKeepalive(512);
b->bigSize = 512;
INSTRUMENT_ALLOCATE("Arena512");
} else if (reqSize <= 1024) {
b = (ArenaBlock*)allocateAndMaybeKeepalive(1024);
b->bigSize = 1024;
INSTRUMENT_ALLOCATE("Arena1024");
} else if (reqSize <= 2048) {
b = (ArenaBlock*)allocateAndMaybeKeepalive(2048);
b->bigSize = 2048;
INSTRUMENT_ALLOCATE("Arena2048");
} else if (reqSize <= 4096) {
b = (ArenaBlock*)allocateAndMaybeKeepalive(4096);
b->bigSize = 4096;
INSTRUMENT_ALLOCATE("Arena4096");
} else {
b = (ArenaBlock*)allocateAndMaybeKeepalive(8192);
b->bigSize = 8192;
INSTRUMENT_ALLOCATE("Arena8192");
// Suppress the operator-new[] memory-tracker hook around the
// underlying `new uint8_t[]`; the explicit memTrackerOnAlloc
// below is the sole hook for these blocks. Without this
// guard the same pointer would be tracked twice under two
// different fingerprints.
MemTrackerSuppress _suppress;
if (reqSize <= 512) {
b = (ArenaBlock*)allocateAndMaybeKeepalive(512);
b->bigSize = 512;
INSTRUMENT_ALLOCATE("Arena512");
} else if (reqSize <= 1024) {
b = (ArenaBlock*)allocateAndMaybeKeepalive(1024);
b->bigSize = 1024;
INSTRUMENT_ALLOCATE("Arena1024");
} else if (reqSize <= 2048) {
b = (ArenaBlock*)allocateAndMaybeKeepalive(2048);
b->bigSize = 2048;
INSTRUMENT_ALLOCATE("Arena2048");
} else if (reqSize <= 4096) {
b = (ArenaBlock*)allocateAndMaybeKeepalive(4096);
b->bigSize = 4096;
INSTRUMENT_ALLOCATE("Arena4096");
} else {
b = (ArenaBlock*)allocateAndMaybeKeepalive(8192);
b->bigSize = 8192;
INSTRUMENT_ALLOCATE("Arena8192");
}
}
b->totalSizeEstimate = b->bigSize;
b->tinySize = b->tinyUsed = NOT_TINY;
b->bigUsed = sizeof(ArenaBlock);
b->secure = 0;
// Block-level attribution for >256 sizes (sizes <=256 use FastAllocator,
// which fires its own memTrackerOnAlloc hook).
if (b->bigSize > 256) {
memTrackerOnAlloc(b, b->bigSize);
}
} else {
#ifdef ALLOC_INSTRUMENTATION
allocInstr["ArenaHugeKB"].alloc((reqSize + 1023) >> 10);
#endif
b = (ArenaBlock*)allocateAndMaybeKeepalive(reqSize);
{
// Suppress the operator-new[] hook so the explicit
// memTrackerOnAlloc below is the sole tracker for huge
// arena blocks (see comment in the small-block branch).
MemTrackerSuppress _suppress;
b = (ArenaBlock*)allocateAndMaybeKeepalive(reqSize);
}
b->tinySize = b->tinyUsed = NOT_TINY;
b->bigSize = reqSize;
b->totalSizeEstimate = b->bigSize;
@ -505,6 +525,9 @@ ArenaBlock* ArenaBlock::create(int dataSize, Reference<ArenaBlock>& next) {
}
#endif
g_hugeArenaMemory.fetch_add(reqSize);
// Block-level attribution for huge arena blocks. allocateAndMaybeKeepalive
// bypasses FastAllocator, so this is the only hook for these blocks.
memTrackerOnAlloc(b, reqSize);
// If the new block has less free space than the old block, make the old block depend on it
if (next && !next->isTiny() && next->unused() >= reqSize - dataSize) {
@ -585,26 +608,50 @@ void ArenaBlock::destroyLeaf() {
FastAllocator<256>::release(this);
INSTRUMENT_RELEASE("Arena256");
} else if (bigSize <= 512) {
freeOrMaybeKeepalive(this);
memTrackerOnFree(this);
{
MemTrackerSuppress _suppress;
freeOrMaybeKeepalive(this);
}
INSTRUMENT_RELEASE("Arena512");
} else if (bigSize <= 1024) {
freeOrMaybeKeepalive(this);
memTrackerOnFree(this);
{
MemTrackerSuppress _suppress;
freeOrMaybeKeepalive(this);
}
INSTRUMENT_RELEASE("Arena1024");
} else if (bigSize <= 2048) {
freeOrMaybeKeepalive(this);
memTrackerOnFree(this);
{
MemTrackerSuppress _suppress;
freeOrMaybeKeepalive(this);
}
INSTRUMENT_RELEASE("Arena2048");
} else if (bigSize <= 4096) {
freeOrMaybeKeepalive(this);
memTrackerOnFree(this);
{
MemTrackerSuppress _suppress;
freeOrMaybeKeepalive(this);
}
INSTRUMENT_RELEASE("Arena4096");
} else if (bigSize <= 8192) {
freeOrMaybeKeepalive(this);
memTrackerOnFree(this);
{
MemTrackerSuppress _suppress;
freeOrMaybeKeepalive(this);
}
INSTRUMENT_RELEASE("Arena8192");
} else {
#ifdef ALLOC_INSTRUMENTATION
allocInstr["ArenaHugeKB"].dealloc((bigSize + 1023) >> 10);
#endif
g_hugeArenaMemory.fetch_sub(bigSize);
freeOrMaybeKeepalive(this);
memTrackerOnFree(this);
{
MemTrackerSuppress _suppress;
freeOrMaybeKeepalive(this);
}
}
}
}

View File

@ -20,6 +20,7 @@
#include "flow/FastAlloc.h"
#include "flow/MemoryTracker.h"
#include "flow/ThreadPrimitives.h"
#include "flow/Trace.h"
#include "flow/Error.h"
@ -432,6 +433,7 @@ void* FastAllocator<Size>::allocate() {
#if defined(ALLOC_INSTRUMENTATION) || defined(ALLOC_INSTRUMENTATION_STDOUT)
recordAllocation(p, Size);
#endif
memTrackerOnAlloc(p, Size);
return p;
}
@ -509,6 +511,7 @@ void FastAllocator<Size>::release(void* ptr) {
#if defined(ALLOC_INSTRUMENTATION) || defined(ALLOC_INSTRUMENTATION_STDOUT)
recordDeallocation(ptr);
#endif
memTrackerOnFree(ptr);
}
template <int Size>

View File

@ -86,6 +86,15 @@ void FlowKnobs::initialize(Randomize randomize, IsSimulated isSimulated) {
init( MEMORY_USAGE_CHECK_INTERVAL, 1.0 );
// Per-call-site sampled memory tracker. See design/memory-tracker.md.
// Initial rollout: prod default off (=0). Simulation defaults to 1-in-10 sampling so the path is exercised.
init( MEMORY_TRACKING_SAMPLE_INVERSE, 0 ); if( isSimulated ) MEMORY_TRACKING_SAMPLE_INVERSE = 10;
init( MEMORY_TRACKING_FORCE_SAMPLE_BYTES, 100000 );
init( MEMORY_TRACKING_LIVE_TRACKING, true );
init( MEMORY_TRACKING_REPORT_INTERVAL, 600.0 ); if( isSimulated ) MEMORY_TRACKING_REPORT_INTERVAL = 30.0;
init( MEMORY_TRACKING_REPORT_BYTES_THRESHOLD, 80000000 ); if( isSimulated ) MEMORY_TRACKING_REPORT_BYTES_THRESHOLD = 1000000;
init( MEMORY_TRACKING_FRAMES, 6 );
// Chaos testing - enabled for simulation by default
init( ENABLE_CHAOS_FEATURES, isSimulated );
init( CHAOS_LOGGING_INTERVAL, 5.0 );

695
flow/MemoryTracker.cpp Normal file
View File

@ -0,0 +1,695 @@
/*
* MemoryTracker.cpp
*
* This source file is part of the FoundationDB open source project
*
* Copyright 2013-2026 Apple Inc. and the FoundationDB project authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Implementation of the sampled per-call-site memory tracker.
// See design/memory-tracker.md and flow/include/flow/MemoryTracker.h.
//
// What this is for: finding memory LEAKS and untuned / oversized allocations
// that drive RSS growth. FDB effectively never sees a real malloc / operator new
// failure — a process is killed by fdbmonitor when its RSS crosses a configured
// ceiling (typically ~12-16 GB against an ~8 GB target), i.e. "OOM" here is a
// self-imposed RSS threshold, not an allocator failure. So the interesting range
// is memory growth well SHORT of any allocation failure, and the tracker's
// behaviour under an actual malloc/new failure is not a scenario we optimize for:
// the hooks fail open (drop the sample; see memTrackerSampleAlloc). The sampled
// path is nonetheless ordered so its table growth happens before any counter
// update, so even that never-in-practice case leaves the accounting consistent.
#include "flow/MemoryTracker.h"
#include "flow/Knobs.h"
#include "flow/Platform.h"
#include "flow/ThreadPrimitives.h"
#include "flow/Trace.h"
#include "flow/flow.h"
#include <algorithm>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <new>
#include <unordered_map>
#include <vector>
#ifdef __linux__
#include <pthread.h>
#endif
#if FDB_MEMORY_TRACKER
// Thread-local sampling state.
// gMemTrackerCounter starts at 1 so the first allocation per thread is
// sampled (and the slow path then reseeds from the knob).
// gForceSampleBytes initialized to ~0 so force-sample never fires before we've
// loaded the knob value at least once.
thread_local bool gInMemTracker = false;
thread_local int gMemTrackerCounter = 1;
thread_local std::size_t gForceSampleBytes = static_cast<std::size_t>(-1);
// Starts false so the first allocation on each thread reaches the slow path to
// read the knob; set true there if sampling is off (see memTrackerSampleAlloc).
thread_local bool gMemTrackerOff = false;
// Test-only one-shot: when set, the next sampled allocation throws to simulate a
// tracker metadata-allocation failure (see memTrackerFailNextSampleForTest).
static thread_local bool gFailNextSampleForTest = false;
// Definition of the cache-line-isolated enabled flag declared in the header.
MemTrackerEnabledFlag g_memTrackerEnabled;
// Same initial seed for every thread; cheap and adequate. Threads in
// production start at different times and call into the slow path at
// uncorrelated rates, so any phase correlation washes out within the
// first handful of samples. If profiling ever shows correlated bursts
// at startup, mix in a thread-id-derived value here.
thread_local uint32_t gMemTrackerSeed = 0x9E3779B9u;
namespace {
// FNV-1a 64-bit over the captured frame array.
uint64_t fnv64(const void* data, std::size_t len) {
uint64_t h = 0xcbf29ce484222325ULL;
const auto* p = static_cast<const std::uint8_t*>(data);
for (std::size_t i = 0; i < len; i++) {
h ^= p[i];
h *= 0x100000001b3ULL;
}
return h;
}
inline std::uint32_t xorshift32(std::uint32_t& s) {
std::uint32_t x = s ? s : 0x9E3779B9u;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
s = x;
return x;
}
// Per-thread stack bounds, populated lazily on first use of captureFramesFP.
// Used by captureFramesFP to terminate the FP walk when it crosses into
// FP-elided code (notably glibc's pthread shutdown / TLS-destructor
// machinery). Without this guard, the FP-elided frame leaves an
// uninitialized saved-FP slot and the walk dereferences garbage. See
// design/memory-tracker.md, "Side-thread safety".
//
// You've heard of optimistic concurrency control in database systems? This is
// basically *optimistic segfault avoidance* to enable fast stack unwinding.
// Caveat: the heuristics here aren't perfect. They seem pretty effective
// so far.
thread_local uintptr_t gStackLow = 0;
thread_local uintptr_t gStackHigh = 0;
#ifdef __linux__
void initStackBoundsForThread() {
pthread_attr_t attr;
if (pthread_getattr_np(pthread_self(), &attr) == 0) {
void* base = nullptr;
size_t size = 0;
if (pthread_attr_getstack(&attr, &base, &size) == 0) {
gStackLow = reinterpret_cast<uintptr_t>(base);
gStackHigh = gStackLow + size;
}
pthread_attr_destroy(&attr);
}
}
// Manual frame-pointer walk. Captures the return-address chain starting at
// the caller of this function (and up). Relies on -fno-omit-frame-pointer.
// Annotated noinline + no_instrument_function so the compiler can't fold the
// frame chain in unexpected ways.
//
// Bounds the walk by the current thread's stack range so that crossing into
// FP-elided code (which leaves the saved-FP slot uninitialized rather than
// NULL) terminates cleanly instead of dereferencing garbage (see above).
__attribute__((no_instrument_function, noinline)) int captureFramesFP(void** out, int max) {
if (!gStackLow) {
initStackBoundsForThread();
}
void** fp = static_cast<void**>(__builtin_frame_address(0));
// Fallback for threads where pthread_getattr_np failed: ±8 MB around
// the initial frame.
// Caveat: this may need to be constrained more tightly to deal with
// smaller stacks.
uintptr_t lo = gStackLow ? gStackLow : reinterpret_cast<uintptr_t>(fp);
uintptr_t hi = gStackHigh ? gStackHigh : reinterpret_cast<uintptr_t>(fp) + (8u << 20);
int n = 0;
while (fp && n < max) {
uintptr_t a = reinterpret_cast<uintptr_t>(fp);
// Reject out-of-stack or misaligned fp before dereferencing.
if (a < lo || a + 16 > hi) {
break;
}
if (a & (sizeof(void*) - 1)) {
break;
}
void* ra = fp[1];
if (!ra) {
break;
}
out[n++] = ra;
void** next = static_cast<void**>(fp[0]);
// Sanity: stack grows down, so each next frame address must be larger.
if (next <= fp) {
break;
}
fp = next;
}
return n;
}
#else // !__linux__
// NOTE: We (Apple) do not maintain a local facility to build FDB with
// MSVC on Windows. The code in this file **may** have issues. We are
// doing a best-effort attempt not to break the build. Support for
// this memory tracking feature by community users of Windows would be
// welcome.
// macOS / non-Linux: stack walking is unreliable here (system runtime
// has -fomit-frame-pointer in places we can't avoid, and pthread_getattr_np
// is Linux-specific). FDB is required to compile on macOS/Windows but is not run
// in production there, so we just no-op the walker. The rest of the
// tracker still compiles and runs; per-call-site reports will simply
// lack stack attribution.
force_noinline int captureFramesFP(void**, int) {
return 0;
}
#endif // __linux__
// TODO: when memory tracking is enabled, regardless of the sampling
// rate, every deallocation has to acquire this mutex. At O(1M)
// frees/second this is noticeable overhead. This could be sped up by
// sharding the mutex and the global state protected by the mutex
// (the global state is the 2 maps and ~10 scalars defined below).
// Presumably hash of the malloc buffer address itself could direct
// the sharding. Consumers of the global state would have to merge
// across shards but that is intended only to be the periodic log
// reporter so making it do costly work is fine since it only runs
// O(1/minute).
ThreadSpinLock g_mtLock;
struct LiveEntry {
std::uint64_t fingerprint;
std::uint64_t size;
std::int64_t weight; // inverse inclusion probability at sample time (≈ SampleInverse, or 1 if
// force-sampled); estimated contribution of this block is size * weight.
// Stored so free debits the estimate by exactly what alloc credited, even
// if the sampling knob changed in between.
};
// Lazily-constructed maps. Allocated under the spinlock the first time we
// reach the sampled path. Heap allocations from the maps' internals go
// through our overridden operator new, which short-circuits (gInMemTracker
// is true on the sampled path) and falls through to std::malloc — so map
// growth never recurses into tracking.
std::unordered_map<std::uint64_t, MemoryTrackerCallSite>* g_aggMap = nullptr;
std::unordered_map<std::uintptr_t, LiveEntry>* g_liveMap = nullptr;
// Sampled-totals (i.e. across what we actually saw, not population estimates).
std::int64_t g_liveBytesTotal = 0;
std::int64_t g_liveBlocksTotal = 0;
std::int64_t g_cumulativeBytesTotal = 0;
std::int64_t g_cumulativeAllocsTotal = 0;
std::int64_t g_samplesEmitted = 0;
// Estimated population totals (sampling correction applied; see LiveEntry::weight).
std::int64_t g_estLiveBytesTotal = 0;
std::int64_t g_estLiveBlocksTotal = 0;
std::int64_t g_estCumulativeBytesTotal = 0;
std::int64_t g_estCumulativeAllocsTotal = 0;
void ensureMaps() {
if (!g_aggMap) {
g_aggMap = new std::unordered_map<std::uint64_t, MemoryTrackerCallSite>();
}
if (!g_liveMap) {
g_liveMap = new std::unordered_map<std::uintptr_t, LiveEntry>();
}
}
} // namespace
// Publish the enabled flag and arm the calling thread from the current
// MEMORY_TRACKING_SAMPLE_INVERSE. Shared by memTrackerInit (once, at startup)
// and memTrackerResetForTest. Reading the knob explicitly here — rather than
// inferring the enabled state from the first sampled allocation — is what keeps
// an early main-thread allocation from latching the tracker off before the
// knobs are configured.
static void memTrackerArmFromKnobs() {
int inverse = FLOW_KNOBS ? FLOW_KNOBS->MEMORY_TRACKING_SAMPLE_INVERSE : 0;
bool enabled = (inverse > 0);
g_memTrackerEnabled.value.store(enabled, std::memory_order_relaxed);
// If enabled, clear this thread's off-latch and force its next allocation onto
// the slow path (counter==1) to seed the reseed; if disabled, latch off so the
// alloc hot path short-circuits on a single TLS load.
gMemTrackerOff = !enabled;
gMemTrackerCounter = 1;
gForceSampleBytes = FLOW_KNOBS ? static_cast<std::size_t>(FLOW_KNOBS->MEMORY_TRACKING_FORCE_SAMPLE_BYTES)
: static_cast<std::size_t>(-1);
}
void memTrackerInit() {
memTrackerArmFromKnobs();
}
static void memTrackerSampleAllocImpl(void* p, std::size_t n) {
if (gFailNextSampleForTest) {
gFailNextSampleForTest = false;
throw std::bad_alloc(); // simulate a tracker metadata-allocation failure (test only)
}
int inverse = 0;
int frames = 6;
bool liveTracking = true;
if (FLOW_KNOBS) {
inverse = FLOW_KNOBS->MEMORY_TRACKING_SAMPLE_INVERSE;
frames = FLOW_KNOBS->MEMORY_TRACKING_FRAMES;
liveTracking = FLOW_KNOBS->MEMORY_TRACKING_LIVE_TRACKING;
gForceSampleBytes = static_cast<std::size_t>(FLOW_KNOBS->MEMORY_TRACKING_FORCE_SAMPLE_BYTES);
}
if (frames < 1) {
frames = 1;
}
if (frames > MEMORY_TRACKER_MAX_FRAMES) {
frames = MEMORY_TRACKER_MAX_FRAMES;
}
// Bound the reseed's `2 * inverse` arithmetic to int range for absurd knob
// values; 1-in-256M sampling is already effectively off.
if (inverse > (1 << 28)) {
inverse = 1 << 28;
}
if (inverse <= 0) {
// Off (knob is startup-only). Flag this thread so the alloc hot path
// short-circuits on one TLS load, without touching the counter again.
gMemTrackerOff = true;
return;
}
if (inverse == 1) {
// Sample every allocation — keep counter at 1 so the next decrement
// drops it to 0 and re-enters the slow path. Bypass the random
// reseed below, which would otherwise leave counter==2 half the
// time and cause us to miss every other allocation.
gMemTrackerCounter = 1;
} else if (gMemTrackerCounter <= 0) {
// The random countdown actually expired: draw a fresh gap uniformly from
// [1, 2*inverse-1], whose mean is exactly `inverse` — so the 1-in-inverse
// sampling rate is unbiased and the integer `weight` below is exact.
std::uint32_t r = xorshift32(gMemTrackerSeed);
gMemTrackerCounter = 1 + static_cast<int>(r % static_cast<std::uint32_t>(2 * inverse - 1));
}
bool isForceSampled = (n >= gForceSampleBytes);
// Weight = inverse inclusion probability of this sample, i.e. how many
// allocations in the population it stands in for. A randomly-sampled block
// (1-in-inverse) represents ~inverse allocations; a force-sampled block was
// captured with certainty and represents only itself. The reseed draws
// uniformly from [1, 2*inverse-1] (mean exactly `inverse`), so this integer
// weight matches the true mean sampling gap with no bias.
std::int64_t weight = (isForceSampled || inverse <= 1) ? 1 : inverse;
// Capture frames; skip the topmost two (this function and captureFramesFP
// itself) so the recorded stack starts at the caller of memTrackerOnAlloc.
// The strip count of 2 assumes memTrackerOnAlloc is inlined into its
// caller (it's declared `inline` and the body is trivial). Production
// builds run at -O3 and the inliner cooperates; at -O0 the inline hint
// can be ignored and the recorded stack starts one frame too deep
// (frame 0 = memTrackerOnAlloc body rather than the user's
// allocation site). Acceptable: -O0 builds are not load-bearing for
// memory attribution; the off-by-one is harmless for that workflow.
void* tmp[MEMORY_TRACKER_MAX_FRAMES + 4];
int captured = captureFramesFP(tmp, frames + 2);
int kept = 0;
void* keep[MEMORY_TRACKER_MAX_FRAMES];
for (int i = 2; i < captured && kept < frames; i++) {
keep[kept++] = tmp[i];
}
std::uint64_t fp = (kept == 0) ? 0 : fnv64(keep, static_cast<std::size_t>(kept) * sizeof(void*));
ThreadSpinLockHolder lk(g_mtLock);
ensureMaps();
auto nBytes = static_cast<std::int64_t>(n);
auto estBytes = nBytes * weight;
// Do both node-allocating map operations up front, before mutating any
// counter, so that if one throws (a bad_alloc while a table grows) the
// exception unwinds with all per-site and global totals still consistent and
// the fail-open catch in memTrackerSampleAlloc simply drops the sample. FDB
// never sees a real malloc/new failure in practice (see the file header), so
// this is cheap hygiene rather than a hot path.
auto& site = (*g_aggMap)[fp]; // inserts a node for a new fingerprint; may throw
if (site.fingerprint == 0 && site.cumulativeAllocs == 0) {
site.fingerprint = fp;
site.exemplarFrameCount = static_cast<std::uint8_t>(kept);
for (int i = 0; i < kept; i++) {
site.exemplarFrames[i] = keep[i];
}
}
// Reserve the live-block slot before crediting anything. A brand-new key
// allocates a node here (may throw); an existing key — a stale entry whose
// free was suppressed (e.g. during memTrackerDump or a memTrackerForEachSite
// callback) so it was never debited — reuses its slot and cannot throw. We
// capture the stale value so it can be debited below; otherwise its live
// credit would leak once the address is reused and live totals would creep up.
bool hadStale = false;
LiveEntry stalePrev{};
if (liveTracking) {
auto key = reinterpret_cast<std::uintptr_t>(p);
auto res = g_liveMap->try_emplace(key, LiveEntry{ fp, static_cast<std::uint64_t>(n), weight });
if (!res.second) {
hadStale = true;
stalePrev = res.first->second;
res.first->second = LiveEntry{ fp, static_cast<std::uint64_t>(n), weight };
}
}
// ---- Nothing below allocates or throws; per-site and global totals move in lockstep. ----
if (hadStale) {
auto oldBytes = static_cast<std::int64_t>(stalePrev.size);
auto oldEst = oldBytes * stalePrev.weight;
auto oldSite = g_aggMap->find(stalePrev.fingerprint);
if (oldSite != g_aggMap->end()) {
oldSite->second.liveBytes -= oldBytes;
oldSite->second.liveCount -= 1;
oldSite->second.estLiveBytes -= oldEst;
oldSite->second.estLiveCount -= stalePrev.weight;
}
g_liveBytesTotal -= oldBytes;
g_liveBlocksTotal -= 1;
g_estLiveBytesTotal -= oldEst;
g_estLiveBlocksTotal -= stalePrev.weight;
}
site.cumulativeAllocs += 1;
site.cumulativeBytes += nBytes;
site.estCumulativeAllocs += weight;
site.estCumulativeBytes += estBytes;
if (isForceSampled) {
site.forceSampledCount += 1;
}
if (liveTracking) {
site.liveBytes += nBytes;
site.liveCount += 1;
if (site.liveBytes > site.peakBytes) {
site.peakBytes = site.liveBytes;
}
site.estLiveBytes += estBytes;
site.estLiveCount += weight;
if (site.estLiveBytes > site.estPeakBytes) {
site.estPeakBytes = site.estLiveBytes;
}
g_liveBytesTotal += nBytes;
g_liveBlocksTotal += 1;
g_estLiveBytesTotal += estBytes;
g_estLiveBlocksTotal += weight;
}
g_cumulativeBytesTotal += nBytes;
g_cumulativeAllocsTotal += 1;
g_estCumulativeBytesTotal += estBytes;
g_estCumulativeAllocsTotal += weight;
g_samplesEmitted += 1;
}
void memTrackerSampleAlloc(void* p, std::size_t n) {
// Fail open: the tracker is a diagnostic; a std::bad_alloc from its own map
// growth (or the test injection) must never propagate into the caller's
// allocation path, which has already handed out the underlying block.
try {
memTrackerSampleAllocImpl(p, n);
} catch (...) {
}
}
static void memTrackerSampleFreeImpl(void* p) {
bool liveTracking = FLOW_KNOBS ? FLOW_KNOBS->MEMORY_TRACKING_LIVE_TRACKING : true;
if (!liveTracking) {
return;
}
ThreadSpinLockHolder lk(g_mtLock);
if (!g_liveMap) {
return;
}
auto it = g_liveMap->find(reinterpret_cast<std::uintptr_t>(p));
if (it == g_liveMap->end()) {
return;
}
LiveEntry e = it->second;
g_liveMap->erase(it);
auto eBytes = static_cast<std::int64_t>(e.size);
auto eEstBytes = eBytes * e.weight;
if (g_aggMap) {
auto sit = g_aggMap->find(e.fingerprint);
if (sit != g_aggMap->end()) {
sit->second.liveBytes -= eBytes;
sit->second.liveCount -= 1;
sit->second.estLiveBytes -= eEstBytes;
sit->second.estLiveCount -= e.weight;
}
}
g_liveBytesTotal -= eBytes;
g_liveBlocksTotal -= 1;
g_estLiveBytesTotal -= eEstBytes;
g_estLiveBlocksTotal -= e.weight;
}
void memTrackerSampleFree(void* p) {
// Fail open (see memTrackerSampleAlloc): operator delete is noexcept, so the
// tracker must never let an exception escape the free path.
try {
memTrackerSampleFreeImpl(p);
} catch (...) {
}
}
void memTrackerForEachSite(std::function<void(const MemoryTrackerCallSite&)> cb) {
// Suppress for the entire call so callbacks that allocate (e.g.
// fprintf or std::vector growth in test failure paths) don't
// re-enter the tracker and pollute the agg map mid-iteration.
MemTrackerSuppress _suppress;
std::vector<MemoryTrackerCallSite> snapshot;
{
ThreadSpinLockHolder lk(g_mtLock);
if (g_aggMap) {
snapshot.reserve(g_aggMap->size());
for (auto& kv : *g_aggMap) {
snapshot.push_back(kv.second);
}
}
}
for (auto& s : snapshot) {
cb(s);
}
}
void memTrackerResetForTest() {
MemTrackerSuppress _suppress;
{
ThreadSpinLockHolder lk(g_mtLock);
if (g_aggMap) {
g_aggMap->clear();
}
if (g_liveMap) {
g_liveMap->clear();
}
g_liveBytesTotal = 0;
g_liveBlocksTotal = 0;
g_cumulativeBytesTotal = 0;
g_cumulativeAllocsTotal = 0;
g_samplesEmitted = 0;
g_estLiveBytesTotal = 0;
g_estLiveBlocksTotal = 0;
g_estCumulativeBytesTotal = 0;
g_estCumulativeAllocsTotal = 0;
}
// Publish the enabled flag and arm this thread from the current knob value
// (a test typically sets MEMORY_TRACKING_SAMPLE_INVERSE via KnobOverride just
// before calling this), mirroring memTrackerInit at process startup.
gFailNextSampleForTest = false;
memTrackerArmFromKnobs();
}
void memTrackerFailNextSampleForTest() {
gFailNextSampleForTest = true;
}
static void memTrackerDumpImpl(int64_t bytesThreshold) {
MemTrackerSuppress _suppress;
std::vector<MemoryTrackerCallSite> sites;
int aggSize = 0;
int liveSize = 0;
std::int64_t liveBytesTotalSnap = 0;
std::int64_t liveBlocksTotalSnap = 0;
std::int64_t cumBytesSnap = 0;
std::int64_t cumAllocsSnap = 0;
std::int64_t samplesEmittedSnap = 0;
std::int64_t estLiveBytesTotalSnap = 0;
std::int64_t estLiveBlocksTotalSnap = 0;
std::int64_t estCumBytesSnap = 0;
std::int64_t estCumAllocsSnap = 0;
{
ThreadSpinLockHolder lk(g_mtLock);
if (g_aggMap) {
sites.reserve(g_aggMap->size());
for (auto& kv : *g_aggMap) {
sites.push_back(kv.second);
}
aggSize = static_cast<int>(g_aggMap->size());
}
liveSize = g_liveMap ? static_cast<int>(g_liveMap->size()) : 0;
liveBytesTotalSnap = g_liveBytesTotal;
liveBlocksTotalSnap = g_liveBlocksTotal;
cumBytesSnap = g_cumulativeBytesTotal;
cumAllocsSnap = g_cumulativeAllocsTotal;
samplesEmittedSnap = g_samplesEmitted;
estLiveBytesTotalSnap = g_estLiveBytesTotal;
estLiveBlocksTotalSnap = g_estLiveBlocksTotal;
estCumBytesSnap = g_estCumulativeBytesTotal;
estCumAllocsSnap = g_estCumulativeAllocsTotal;
}
bool liveTracking = FLOW_KNOBS ? FLOW_KNOBS->MEMORY_TRACKING_LIVE_TRACKING : true;
// Rank and threshold on the *estimated* usage, since that is the real
// per-site cost the report is about; the threshold knob is expressed in
// real bytes (~1% of target RSS), not sampled bytes.
// std::sort is unstable and unordered_map iteration is bucket-order, so
// MemoryTrackerSite events for sites with tied byte values may appear in
// different orders across same-seed sim2 runs. The R5 determinism
// requirement is on aggregate counts, not event ordering — those are
// unaffected — so we don't pay for stable_sort here.
auto byLive = [](const MemoryTrackerCallSite& a, const MemoryTrackerCallSite& b) {
return a.estLiveBytes > b.estLiveBytes;
};
auto byCum = [](const MemoryTrackerCallSite& a, const MemoryTrackerCallSite& b) {
return a.estCumulativeBytes > b.estCumulativeBytes;
};
if (liveTracking) {
std::sort(sites.begin(), sites.end(), byLive);
} else {
std::sort(sites.begin(), sites.end(), byCum);
}
// Filter: a site qualifies when its estimated currently-live bytes (or
// estimated cumulative bytes in degraded mode) exceed the threshold. Sites
// are already sorted descending, so we can stop at the first non-qualifier.
std::vector<MemoryTrackerCallSite> qualifying;
qualifying.reserve(sites.size());
for (const auto& s : sites) {
int64_t v = liveTracking ? s.estLiveBytes : s.estCumulativeBytes;
if (v < bytesThreshold) {
break;
}
qualifying.push_back(s);
}
// Build addr2line prefix once per dump. Built directly here rather
// than via platform::format_backtrace, which deliberately drops index
// 0 of its input (its single-site use case treats that as the helper's
// caller); we want every captured frame including the leaf.
std::string addrCmdPrefix;
uintptr_t pieOffset = 0;
if (!qualifying.empty()) {
platform::ImageInfo img = platform::getImageInfo();
#ifdef __clang__
const char* addr2lineTool = "/usr/local/bin/llvm-addr2line";
#else
const char* addr2lineTool = "/usr/bin/addr2line";
#endif
addrCmdPrefix = format("%s -e %s -p -C -f -i", addr2lineTool, img.symbolFileName.c_str());
pieOffset = reinterpret_cast<uintptr_t>(img.offset);
}
for (const auto& s : qualifying) {
std::string addrCmd = addrCmdPrefix;
for (int i = 0; i < s.exemplarFrameCount; i++) {
uintptr_t pieRelative = reinterpret_cast<uintptr_t>(s.exemplarFrames[i]) - pieOffset;
addrCmd += format(" 0x%lx", pieRelative);
}
TraceEvent("MemoryTrackerSite")
.detail("Fingerprint", format("%016llx", static_cast<unsigned long long>(s.fingerprint)))
.detail("EstLiveBytes", s.estLiveBytes)
.detail("EstLiveCount", s.estLiveCount)
.detail("EstPeakBytes", s.estPeakBytes)
.detail("EstCumulativeBytes", s.estCumulativeBytes)
.detail("EstCumulativeAllocs", s.estCumulativeAllocs)
.detail("LiveBytes", s.liveBytes)
.detail("LiveCount", s.liveCount)
.detail("PeakBytes", s.peakBytes)
.detail("CumulativeBytes", s.cumulativeBytes)
.detail("CumulativeAllocs", s.cumulativeAllocs)
.detail("ForceSampledCount", s.forceSampledCount)
.detail("AddrCmd", addrCmd);
}
TraceEvent("MemoryTrackerSummary")
.detail("SitesTracked", aggSize)
.detail("SitesReported", static_cast<int>(qualifying.size()))
.detail("EstLiveBytesTotal", estLiveBytesTotalSnap)
.detail("EstLiveBlocksTotal", estLiveBlocksTotalSnap)
.detail("EstCumulativeBytes", estCumBytesSnap)
.detail("EstCumulativeAllocs", estCumAllocsSnap)
.detail("LiveBlocks", liveSize)
.detail("LiveBytesTotal", liveBytesTotalSnap)
.detail("LiveBlocksTotal", liveBlocksTotalSnap)
.detail("CumulativeAllocs", cumAllocsSnap)
.detail("CumulativeBytes", cumBytesSnap)
.detail("SamplesEmitted", samplesEmittedSnap)
.detail("SampleInverse", FLOW_KNOBS ? FLOW_KNOBS->MEMORY_TRACKING_SAMPLE_INVERSE : 0)
.detail("ForceSampleBytes",
FLOW_KNOBS ? FLOW_KNOBS->MEMORY_TRACKING_FORCE_SAMPLE_BYTES : static_cast<std::int64_t>(-1))
.detail("ReportBytesThreshold", bytesThreshold)
// Caveat: Est* values are statistical estimates. Each randomly-sampled block is
// scaled by SampleInverse; force-sampled blocks (>= ForceSampleBytes) count once.
// Accuracy improves with SamplesEmitted; a site with few samples is noisy.
.detail("EstimateBasis", "Est*=sampled*SampleInverse; force-sampled weight 1; statistical estimate");
}
void memTrackerDump(int64_t bytesThreshold) {
// Disabled: nothing is sampled, so skip the dump entirely rather than emit an
// empty MemoryTrackerSummary every report interval (the production default is
// off, and SystemMonitor calls this on a fixed cadence regardless).
if (!g_memTrackerEnabled.value.load(std::memory_order_relaxed)) {
return;
}
// Fail open (see memTrackerSampleAlloc): a diagnostic dump must never crash the
// server, e.g. on an allocation failure while building the report.
try {
memTrackerDumpImpl(bytesThreshold);
} catch (...) {
}
}
// The global operator new / operator delete replacements that route through
// memTrackerOnAlloc/OnFree live in fdbserver/GlobalNewDelete.cpp, not here, so
// the interposition is confined to the fdbserver executable and never ships in
// libfdb_c / client bindings. This TU provides only the tracker machinery those
// overrides (and the FastAllocator / ArenaBlock hooks) call into.
#endif // FDB_MEMORY_TRACKER

View File

@ -22,6 +22,8 @@
#include "flow/Platform.h"
#include "flow/TDMetric.h"
#include "flow/SystemMonitor.h"
#include "flow/Knobs.h"
#include "flow/MemoryTracker.h"
#if defined(ALLOC_INSTRUMENTATION) && defined(__linux__)
#include <cxxabi.h>
@ -489,6 +491,20 @@ SystemStatistics customSystemMonitor(std::string const& eventName, StatisticsSta
#endif
statState->networkMetricsState = g_network->networkInfo.metrics;
statState->networkState = netData;
// Periodic dump of the per-call-site memory tracker; cadence from the
// MEMORY_TRACKING_REPORT_INTERVAL knob (<=0 disables). In simulation the
// tracker's tables and this static are shared across all simulated
// processes, so one dump fires per interval cluster-wide and its site
// numbers blend every process — correct only in a real single-process server.
if (FLOW_KNOBS && FLOW_KNOBS->MEMORY_TRACKING_REPORT_INTERVAL > 0) {
static double lastMemTrackerDump = 0;
if (now() - lastMemTrackerDump >= FLOW_KNOBS->MEMORY_TRACKING_REPORT_INTERVAL) {
memTrackerDump(FLOW_KNOBS->MEMORY_TRACKING_REPORT_BYTES_THRESHOLD);
lastMemTrackerDump = now();
}
}
return currentStats;
}

View File

@ -127,6 +127,17 @@ public:
double MEMORY_USAGE_CHECK_INTERVAL;
// Per-call-site sampled memory tracker. See design/memory-tracker.md and flow/MemoryTracker.h.
// All of these are startup-only: they are read once and not meant to change at runtime
// (dynamic enable/disable is a Non-requirement -- edit the config and restart).
int MEMORY_TRACKING_SAMPLE_INVERSE; // 0=off, N=1-in-N
int64_t MEMORY_TRACKING_FORCE_SAMPLE_BYTES; // always sample allocations >= this many bytes; -1 disables
bool MEMORY_TRACKING_LIVE_TRACKING; // when false, skip the pointer-keyed live-block table
double MEMORY_TRACKING_REPORT_INTERVAL; // seconds between dumps; 0 disables reporting
int64_t MEMORY_TRACKING_REPORT_BYTES_THRESHOLD; // sites with live bytes >= this are reported each dump (~1% of an 8
// GB target RSS)
int MEMORY_TRACKING_FRAMES; // captured stack depth (1..MEMORY_TRACKER_MAX_FRAMES)
// Chaos testing
bool ENABLE_CHAOS_FEATURES;
double CHAOS_LOGGING_INTERVAL;

View File

@ -0,0 +1,223 @@
/*
* MemoryTracker.h
*
* This source file is part of the FoundationDB open source project
*
* Copyright 2013-2026 Apple Inc. and the FoundationDB project authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Sampled per-call-site memory attribution.
//
// See design/memory-tracker.md for the full design.
//
// Hot path: memTrackerOnAlloc is header-inlined; when the feature is disabled
// (the common production default) it short-circuits on a single per-thread TLS
// load + branch. memTrackerOnFree is header-inlined, one relaxed read of a
// cache-line-isolated enabled flag + one branch when disabled -- no lock, no
// table probe.
//
// Sampled path delegates to memTrackerSampleAlloc / memTrackerSampleFree,
// which take a private spinlock, capture a frame-pointer-walk backtrace, and
// update two tables (aggregation by fingerprint, and an optional pointer-
// keyed live-block table).
//
// Reentrancy: the gInMemTracker thread-local guard is set to true while the
// tracker is doing its own work. Any allocator hook called recursively
// during that window observes the guard and bails out, leaving the
// underlying allocation un-tracked. Higher-level hooks (e.g. ArenaBlock::create)
// may also set this guard to suppress an inner allocator hook so the same
// block is attributed at exactly one level.
#ifndef FLOW_MEMORY_TRACKER_H
#define FLOW_MEMORY_TRACKER_H
#pragma once
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <functional>
// FDB_MEMORY_TRACKER gates the whole feature at compile time; on (1) by default.
// Build with -DFDB_MEMORY_TRACKER=0 (cmake -DFDB_MEMORY_TRACKER=OFF) to compile it
// out entirely: the hooks become no-ops and the global operator new/delete override
// (fdbserver/GlobalNewDelete.cpp) is not defined, so libc++'s allocator is used.
#ifndef FDB_MEMORY_TRACKER
#define FDB_MEMORY_TRACKER 1
#endif
#if FDB_MEMORY_TRACKER
// Maximum number of stack frames the tracker can capture per sample.
// MEMORY_TRACKING_FRAMES knob controls the runtime depth (1..MEMORY_TRACKER_MAX_FRAMES).
constexpr int MEMORY_TRACKER_MAX_FRAMES = 10;
// Per-site aggregate, exposed for tests via memTrackerForEachSite.
//
// Two families of numbers are kept per site:
// * Est* — the estimated *population* usage, i.e. what the site is really
// costing. Each sampled block is weighted by its inverse inclusion
// probability (≈ SampleInverse for randomly-sampled blocks, 1 for
// force-sampled blocks) at sample time, so these already have the sampling
// math applied — a consumer (logging, etc) reads them directly, no scaling required.
// * the raw sampled counters (liveBytes, cumulativeAllocs, …) — the
// uninterpreted "what we actually observed" numbers, kept for auditing the
// estimate and gauging its confidence (few samples ⇒ noisy estimate).
struct MemoryTrackerCallSite {
uint64_t fingerprint;
int64_t estLiveBytes;
int64_t estLiveCount;
int64_t estPeakBytes;
int64_t estCumulativeBytes;
int64_t estCumulativeAllocs;
int64_t liveBytes;
int64_t liveCount;
int64_t peakBytes;
int64_t cumulativeAllocs;
int64_t cumulativeBytes;
int64_t forceSampledCount;
void* exemplarFrames[MEMORY_TRACKER_MAX_FRAMES];
uint8_t exemplarFrameCount;
};
extern thread_local bool gInMemTracker;
extern thread_local int gMemTrackerCounter;
extern thread_local std::size_t gForceSampleBytes;
// Set true (per thread) once this thread's slow path observes sampling is off,
// so the alloc hot path then short-circuits on a single TLS load instead of
// decrementing the counter and reading gForceSampleBytes every call. Per-thread
// (not global) so an early main-thread allocation before FLOW_KNOBS is ready
// can't disable sampling on worker threads that bootstrap later. Cleared by
// memTrackerResetForTest.
extern thread_local bool gMemTrackerOff;
// Global "is the tracker enabled" flag, kept in its own cache line. Published
// once, from the first slow-path visit, and thereafter constant: the sample-
// inverse knob is read at startup only (dynamic enable/disable is a
// Non-requirement -- see design/memory-tracker.md). The flag stays in MESI
// shared state across cores, so the free hot path's relaxed read is cached:
// when disabled, a free is one read + one branch, no lock. This is the
// free-path off switch (a free has no per-thread sampling counter to gate on,
// unlike an alloc).
//
// Relaxed ordering is sufficient: a free of a sampled pointer is always
// preceded (via the pointer handoff that let the freeing thread learn the
// pointer at all) by the sampling alloc that inserted it, and that alloc set
// this flag true before inserting -- so the happens-before edge guarantees the
// freeing thread observes the flag as true.
struct alignas(64) MemTrackerEnabledFlag {
std::atomic<bool> value{ false };
char pad[64 - sizeof(std::atomic<bool>)];
};
extern MemTrackerEnabledFlag g_memTrackerEnabled;
// RAII suppressor: while alive, allocator hooks short-circuit. Used by code
// paths that call into a lower-level allocator (e.g. ArenaBlock wrapping
// `new uint8_t[]`) and want their explicit memTrackerOnAlloc/OnFree call to
// be the sole tracker for the block — without this guard the inner
// allocator's hook fires too and the same pointer is double-tracked under
// two different fingerprints. Nest-safe: saves and restores prev.
class MemTrackerSuppress {
bool prev;
public:
MemTrackerSuppress() : prev(gInMemTracker) { gInMemTracker = true; }
~MemTrackerSuppress() { gInMemTracker = prev; }
MemTrackerSuppress(const MemTrackerSuppress&) = delete;
MemTrackerSuppress& operator=(const MemTrackerSuppress&) = delete;
};
// Initialize the tracker from the current knob values. Call once, from process
// startup, AFTER all knobs are finalized and BEFORE any serving role starts (see
// fdbserver.cpp). Publishes the enabled state and arms the calling (network)
// thread from MEMORY_TRACKING_SAMPLE_INVERSE, so early startup allocations on the
// main thread cannot latch the tracker off before the knob is configured. The
// sample-inverse knob is read here (and in memTrackerResetForTest) rather than
// inferred from the first allocation; dynamic runtime enable/disable is a
// Non-requirement (see design/memory-tracker.md).
void memTrackerInit();
void memTrackerSampleAlloc(void* p, std::size_t n);
void memTrackerSampleFree(void* p);
inline void memTrackerOnAlloc(void* p, std::size_t n) {
if (gMemTrackerOff) [[likely]] {
return;
}
if (gInMemTracker || !p) {
return;
}
if (--gMemTrackerCounter > 0 && n < gForceSampleBytes) {
return;
}
MemTrackerSuppress _suppress;
memTrackerSampleAlloc(p, n);
}
inline void memTrackerOnFree(void* p) {
if (gInMemTracker || !p) {
return;
}
// Cheap cache-line-shared read: when the tracker is disabled there is no
// live-block table to debit, so skip all lock/table work.
if (!g_memTrackerEnabled.value.load(std::memory_order_relaxed)) {
return;
}
MemTrackerSuppress _suppress;
memTrackerSampleFree(p);
}
// Periodic dump — emits one TraceEvent("MemoryTrackerSite") per site whose
// estLiveBytes (or estCumulativeBytes when MEMORY_TRACKING_LIVE_TRACKING is off)
// exceeds bytesThreshold. The threshold is compared against the sampling-corrected
// estimate, not the raw sampled bytes. Each site event carries an "AddrCmd"
// detail: a ready-to-paste addr2line invocation covering just that site's frames.
// A final TraceEvent("MemoryTrackerSummary") reports aggregate totals.
void memTrackerDump(int64_t bytesThreshold);
// Snapshot iteration for tests. The callback runs while a copy of the
// aggregation table is held; the spinlock is not held during the callback.
void memTrackerForEachSite(std::function<void(const MemoryTrackerCallSite&)> cb);
// Reset all state. Tests only — not safe for production use.
void memTrackerResetForTest();
// Test only: arm a one-shot so the next sampled allocation simulates a tracker
// metadata-allocation failure (the sampled path throws std::bad_alloc, which the
// tracker swallows). Lets tests verify the tracker fails open — the underlying
// allocation still succeeds and tracking recovers afterward. Never used in
// production.
void memTrackerFailNextSampleForTest();
#else // !FDB_MEMORY_TRACKER — compiled out: hooks are no-ops, no operator new override.
inline void memTrackerInit() {}
inline void memTrackerOnAlloc(void*, std::size_t) {}
inline void memTrackerOnFree(void*) {}
inline void memTrackerDump(int64_t) {}
inline void memTrackerResetForTest() {}
class MemTrackerSuppress {
public:
MemTrackerSuppress() {}
~MemTrackerSuppress() {}
MemTrackerSuppress(const MemTrackerSuppress&) = delete;
MemTrackerSuppress& operator=(const MemTrackerSuppress&) = delete;
};
#endif // FDB_MEMORY_TRACKER
#endif // FLOW_MEMORY_TRACKER_H

View File

@ -95,6 +95,20 @@
#error Missing force inline
#endif
// Keep a function un-inlined and (on GCC) un-cloned so its address stays stable
// for return-address matching in tests. GCC -O3 IPA cloning (.constprop/.isra)
// otherwise runs the code under a synthetic clone symbol at a different address
// than &fn, so a captured frame won't fall in [&fn, &fn+size); noclone disables
// it. clang lacks noclone and doesn't clone this way, so it gets noinline only;
// empty elsewhere (e.g. MSVC, which we cannot compile-test).
#if defined(__clang__)
#define force_noinline __attribute__((noinline))
#elif defined(__GNUC__)
#define force_noinline __attribute__((noinline, noclone))
#else
#define force_noinline
#endif
/*
* Visual Studio (.NET 2003 and beyond) has an __assume compiler
* intrinsic to hint to the compiler that a given condition is true