ClickHouse Intercepts Every Allocation to Charge It
ClickHouse replaces operator new and interposes the libc malloc symbols so every allocation is charged to the memory tracker with its real, size-class-adjusted byte count.
TL;DR: To charge memory you need to know that an allocation happened and how many bytes it was. ClickHouse solves the first problem by replacing
operator new/operator deleteand interposing the libcmallocfamily; the second by charging the actual size class (je_nallocx), by passing the size explicitly tofree, and by asking jemalloc for the size of a pointer at free time (je_sallocx).
Memory accounting has two halves, and they are not equally hard.
The easy half is noticing that an allocation happened. Every allocator funnels through a small number of entry points, and a program can replace them.
The hard half is knowing how many bytes to charge. malloc(100) may
consume 112 bytes, because allocators work in size classes. And free(p)
does not say how big p was — the standard free has no size parameter at
all. Charge the requested size and you under-count by the rounding on every
allocation; charge the wrong thing on free and the counters drift apart
permanently.
ClickHouse’s answer is three-layered, and each layer solves a slightly different version of the same problem.
Layer 1: the C++ operators
The most direct interception point is operator new. ClickHouse replaces all
eight overloads:
// src/Common/AllocationInterceptors.cpp:54
void * operator new(std::size_t size)
{
AllocationTrace trace;
std::size_t actual_size = Memory::trackMemory(size, trace);
void * ptr = Memory::newNoExcept(size);
if (!ptr) [[unlikely]]
{
[[maybe_unused]] auto rollback_trace = CurrentMemoryTracker::free(actual_size);
throw std::bad_alloc{};
}
trace.onAlloc(ptr, actual_size);
return ptr;
}Three things to notice.
The charge happens before the allocation. Memory::trackMemory is the
first call. If the memory tracker refuses, Memory::newNoExcept is never
reached and no bytes are touched.
The rollback on failure. If the underlying allocator returns null, the
charge is undone before the std::bad_alloc propagates. A tracker that
charges for an allocation that never happened is a leak that never gets
collected.
Memory::newNoExcept is a raw allocator call, deliberately bypassing the
tracking wrapper in malloc.cpp:
// src/Common/AllocationInterceptors.h:12
/// These macros provide direct access to the underlying allocator,
/// bypassing the tracking wrappers in malloc.cpp.
/// Use them in code that does its own memory tracking (e.g. Allocator, AllocatorWithMemoryTracking)
/// to avoid double-counting.
This is the double-counting problem, and it is worth dwelling on.
ClickHouse interposes malloc at the libc level. If operator new called
malloc, the bytes would be charged twice — once by operator new’s own
trackMemory, and once by the interposed malloc. So operator new calls
je_malloc directly:
// src/Common/AllocationInterceptors.h:21
#define __real_malloc(size) je_malloc(size)
#define __real_calloc(nmemb, size) je_calloc(nmemb, size)
#define __real_realloc(ptr, size) je_realloc(ptr, size)
#define __real_posix_memalign(memptr, alignment, size) je_posix_memalign(memptr, alignment, size)
#define __real_aligned_alloc(alignment, size) je_aligned_alloc(alignment, size)
#define __real_free je_freeThe same __real_* escape hatch is what makes the scheme work under
sanitizers, which have their own malloc interception.
The size problem, part one: charge the real size class
Memory::trackMemory does not charge the requested size. It charges what
the allocator will actually consume:
// src/Common/memory.h:92
template <std::same_as<std::align_val_t>... TAlign>
requires DB::OptionalArgument<TAlign...>
inline ALWAYS_INLINE size_t getActualAllocationSize(size_t size, TAlign... align [[maybe_unused]])
{
size_t actual_size = size;
#if USE_JEMALLOC
/// The nallocx() function allocates no memory, but it performs the same size computation as the mallocx() function
/// @note je_mallocx() != je_malloc(). It's expected they don't differ much in allocation logic.
size_t size_for_nallocx = size;
if (size_for_nallocx == 0) [[unlikely]]
size_for_nallocx = 1;
if constexpr (sizeof...(TAlign) == 1)
{
actual_size = je_nallocx(size_for_nallocx, MALLOCX_ALIGN(alignToSizeT(align...)));
}
else
{
actual_size = je_nallocx(size_for_nallocx, 0);
}
#endif
return actual_size;
}je_nallocx computes jemalloc’s size class for a request without
allocating anything. So the tracker is charged the number of bytes that will
actually be consumed — 112 for a malloc(100), 8192 for a malloc(5000),
and so on — and the accounting matches what the process really spends.
This is why jemalloc is not optional for the accounting to be exact. Without
it, actual_size is just size, and the tracked total systematically
under-counts by the size-class rounding.
Note the size_for_nallocx == 0 special case: a zero-byte allocation is
legal in C, and jemalloc’s nallocx would misbehave on it.
The size problem, part two: the free side
The free side is where the size is genuinely unknown. There are three solutions in the tree, and they are used in different places.
Solution A: pass the size explicitly
ClickHouse’s own Allocator does not have a free(void*). It has a
free(void*, size_t):
// src/Common/Allocator.h:49
/// Allocate memory range.
void * alloc(size_t size, size_t alignment = 0);
/// Free memory range.
void free(void * buf, size_t size, size_t alignment = 0);
/** Enlarge memory range.
* Data from old range is moved to the beginning of new range.
* Address of memory range could change.
*/
void * realloc(void * buf, size_t old_size, size_t new_size, size_t alignment = 0);The header calls this out as a deliberate difference from std::allocator:
// src/Common/Allocator.h:34
* The interface is different from std::allocator
* - the presence of the method realloc, which for large chunks of memory uses mremap;
* - passing the size into the `free` method;
* - by the presence of the `alignment` argument;
* - the possibility of zeroing memory (used in hash tables);The free implementation is correspondingly explicit:
// src/Common/Allocator.cpp:143
template <bool clear_memory_, bool populate>
void Allocator<clear_memory_, populate>::free(void * buf, size_t size, size_t /* alignment */)
{
try
{
checkSize(size);
freeImpl(buf);
auto trace = CurrentMemoryTracker::free(size);
trace.onFree(buf, size);
}
catch (...)
{
DB::tryLogCurrentException("Allocator::free");
throw;
}
}Note that freeImpl is called before the tracker is credited back. If the
tracker somehow threw, the memory would already be gone — and free is
documented as not throwing in practice, but the try/catch is there to make
sure a logging failure does not turn into a std::terminate.
AllocatorWithMemoryTracking does the same for the standard containers, with
the element count times the element size:
// src/Common/AllocatorWithMemoryTracking.h:61
[[nodiscard]] T * allocate(size_t n)
{
if (n > std::numeric_limits<size_t>::max() / sizeof(T)) [[unlikely]]
throwBadAllocFromAllocatorWithMemoryTracking();
constexpr size_t alignment = alignof(T) > alignof(std::max_align_t) ? alignof(T) : 0;
return static_cast<T *>(allocateWithMemoryTracking(n * sizeof(T), alignment));
}
void deallocate(T * p, size_t n) noexcept
{
deallocateWithMemoryTracking(p, n * sizeof(T));
}The overflow check on the multiply is the kind of thing that only matters once every hour, but when it matters it matters.
Solution B: the sized operator delete
C++17 added sized deallocation:
// src/Common/AllocationInterceptors.cpp:209
void operator delete(void * ptr, std::size_t size) noexcept
{
AllocationTrace trace;
std::size_t actual_size = Memory::untrackMemory(ptr, trace, size);
trace.onFree(ptr, actual_size);
Memory::deleteSized(ptr, size);
}and ClickHouse defines both the sized and the unsized variants, as the standard requires:
// src/Common/AllocationInterceptors.cpp:168
/// C++17 std 21.6.2.1 (11)
/// If a function without a size parameter is defined, the program should also define the corresponding function with a size parameter.
/// If a function with a size parameter is defined, the program shall also define the corresponding version without the size parameter.
///
/// cppreference:
/// It's unspecified whether size-aware or size-unaware version is called when deleting objects of
/// incomplete type and arrays of non-class and trivially-destructible class types.
The comment is a genuine portability hazard that ClickHouse had to think about: for some types the compiler may call either overload, so both must work.
Solution C: ask jemalloc
The unsized operator delete(void*) has no size at all. jemalloc does: given
a pointer it owns, je_sallocx reports the usable size.
// src/Common/memory.h:153
template <std::same_as<std::align_val_t>... TAlign>
requires DB::OptionalArgument<TAlign...>
inline ALWAYS_INLINE size_t untrackMemory(void * ptr [[maybe_unused]], AllocationTrace & trace, std::size_t size [[maybe_unused]] = 0, TAlign... align [[maybe_unused]]) noexcept
{
std::size_t actual_size = 0;
try
{
#if USE_JEMALLOC
/// @note It's also possible to use je_malloc_usable_size() here.
if (ptr != nullptr) [[likely]]
{
if constexpr (sizeof...(TAlign) == 1)
actual_size = je_sallocx(ptr, MALLOCX_ALIGN(alignToSizeT(align...)));
else
actual_size = je_sallocx(ptr, 0);
}
#else
if (size)
actual_size = size;
# if defined(_GNU_SOURCE)
/// It's innaccurate resource free for sanitizers. malloc_usable_size() result is greater or equal to allocated size.
else
actual_size = malloc_usable_size(ptr);
# elif defined(OS_DARWIN)
else
actual_size = malloc_size(ptr);
# endif
#endif
trace = CurrentMemoryTracker::free(actual_size);
}
catch (...) // NOLINT(bugprone-empty-catch) Ok: operator delete must not throw
{
}
return actual_size;
}This function is the linchpin of the whole scheme, and it has three properties worth naming:
- It is
noexcept.operator deletemust not throw, and the emptycatchis the enforcement. If the tracker threw here, the program’sdeletewould throw, and the accounting would be broken anyway. - It falls back to
malloc_usable_sizewhen the size is not passed. That path is marked as inaccurate for sanitizers, and the comment says so. - It uses
je_sallocx, notje_malloc_usable_size. The comment notes the alternative exists but does not use it —sallocxis the cheaper size-class query.
Layer 2: the libc symbols
Interposing operator new only covers C++ code that goes through new.
Anything that calls malloc directly — a C library, a third-party
dependency, a std::vector inside a library that was compiled elsewhere —
is invisible.
ClickHouse therefore interposes the libc allocation symbols themselves. On
Linux and FreeBSD, the whole malloc family is replaced:
// src/Common/malloc.cpp:30
void * malloc(size_t size)
{
AllocationTrace trace;
size_t actual_size = Memory::trackMemoryFromC(size, trace);
void * ptr = je_malloc(size);
if (unlikely(!ptr))
{
trace = CurrentMemoryTracker::free(actual_size);
return nullptr;
}
trace.onAlloc(ptr, actual_size);
return ptr;
}The file’s header comment explains the mechanism and, importantly, why it changed:
// src/Common/malloc.cpp:14
/// Define standard allocation functions that perform memory tracking
/// and delegate to jemalloc via je_* prefixed functions.
///
/// This replaces the previous --wrap linker approach with direct symbol
/// interposition: malloc.cpp compiles to a dedicated object (clickhouse_malloc.o)
/// that appears before libjemalloc.a in the link order.
///
/// jemalloc with je_ prefix does not export valloc/memalign/pvalloc,
/// so we implement them via je_posix_memalign / je_aligned_alloc.
The --wrap linker approach used to be the way to do this. Direct symbol
interposition is simpler: define malloc in an object file that comes first
in the link order, and the dynamic linker binds every reference to it.
There is a second layer of trickiness on glibc: glibc’s internal calls to
malloc do not go through the public symbol, so they would escape
interception. ClickHouse aliases them:
// src/Common/malloc.cpp:243
/// On Linux (non-musl), glibc internally calls __libc_malloc etc.
/// We provide aliases so those internal calls also go through our wrappers.
#if !defined(USE_MUSL) && defined(OS_LINUX)
void * __libc_malloc(size_t size) __attribute__((alias("malloc"))); // NOLINT(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp)
void __libc_free(void * ptr) __attribute__((alias("free"))); // NOLINT(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp)
void * __libc_calloc(size_t nmemb, size_t size) __attribute__((alias("calloc"))); // NOLINT(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp)
void * __libc_realloc(void * ptr, size_t size) __attribute__((alias("realloc"))); // NOLINT(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp)
void * __libc_memalign(size_t alignment, size_t size) __attribute__((alias("memalign"))); // NOLINT(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp)
void * __libc_valloc(size_t size) __attribute__((alias("valloc"))); // NOLINT(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp)
void * __libc_pvalloc(size_t size) __attribute__((alias("pvalloc"))); // NOLINT(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp)
The whole family is covered: malloc, free, calloc, realloc,
posix_memalign, aligned_alloc, valloc, memalign, pvalloc and
reallocarray.
The C API cannot throw
The libc-level wrappers use a different tracking entry point:
// src/Common/memory.h:142
/// We cannot throw from C API
template <std::same_as<std::align_val_t>... TAlign>
requires DB::OptionalArgument<TAlign...>
inline ALWAYS_INLINE size_t trackMemoryFromC(size_t size, AllocationTrace & trace, TAlign... align)
{
[[maybe_unused]] MemoryTrackerUntrackedAllocationsBlockerInThread blocker;
std::size_t actual_size = getActualAllocationSize(size, align...);
trace = CurrentMemoryTracker::allocNoThrow(actual_size);
return actual_size;
}Two differences from the C++ path:
allocNoThrowinstead ofallocThrow— the tracker may count the allocation but must not refuse it, because a C library’smallochas no way to report “out of memory” other than returning null.- A
MemoryTrackerUntrackedAllocationsBlockerInThreadscope. That blocker suppresses theMemoryAllocatedWithoutChecktelemetry, which would otherwise report every C-library allocation as an unchecked one.
realloc is the interesting one
realloc is where accounting usually breaks, because the old block may be
kept (in place) or freed and a new one allocated. ClickHouse handles it by
charging the new size first and freeing the old size after:
// src/Common/malloc.cpp:73
void * realloc(void * ptr, size_t size)
{
size_t old_actual_size = 0;
if (ptr)
old_actual_size = je_sallocx(ptr, 0);
AllocationTrace trace;
size_t actual_size = Memory::trackMemoryFromC(size, trace);
void * res = je_realloc(ptr, size);
if (unlikely(!res))
{
trace = CurrentMemoryTracker::free(actual_size);
/// With jemalloc opt.zero_realloc:free (the default), realloc(ptr, 0)
/// frees ptr and returns nullptr. That is not a failure — untrack the old allocation.
if (size == 0 && ptr)
{
AllocationTrace free_trace = CurrentMemoryTracker::free(old_actual_size);
free_trace.onFree(ptr, old_actual_size);
}
return nullptr;
}
if (ptr)
{
AllocationTrace free_trace = CurrentMemoryTracker::free(old_actual_size);
free_trace.onFree(ptr, old_actual_size);
}
trace.onAlloc(res, actual_size);
return res;
}The size == 0 special case is a genuine jemalloc subtlety, documented in
the comment: with the default opt.zero_realloc:free, realloc(ptr, 0)
frees the pointer and returns null. That is not a failure, so the old
allocation must be untracked even though the function “failed”.
And ClickHouse’s own Allocator::realloc takes the other approach — charge
the whole new size up front, because it does not know which of the two
behaviours the underlying realloc will pick:
// src/Common/Allocator.cpp:172
if (alignment <= MALLOC_MIN_ALIGNMENT) [[likely]]
{
/// Resize malloc'd memory region with no special alignment requirement.
/// Realloc can do 2 possible things:
/// - expand existing memory region
/// - allocate new memory block and free the old one
/// Because we don't know which option will be picked we need to make sure there is enough
/// memory for all options
auto trace_alloc = CurrentMemoryTracker::alloc(new_size);Charging new_size up front and then crediting old_size back means the
tracker briefly holds old_size + new_size bytes for one buffer. That is
deliberate: it guarantees the limit is never violated by the realloc itself,
at the cost of a transient over-count.
Layer 3: macOS
On Linux the symbol interposition works. On macOS it does not, and the reason is structural:
// src/Common/malloc.cpp:278
/// macOS counterpart of the symbol interposition above.
///
/// On Linux/FreeBSD we override the libc allocation symbols so every raw malloc/free is tracked.
/// That does not work on Mach-O: `free` is dispatched through the "malloc zone" that owns the
/// pointer, so overriding the `free` symbol with one that calls `je_free` would corrupt pointers
/// belonging to other zones, and dyld interposition cannot reach the malloc calls libSystem makes
/// internally. Instead we lean on the fact that jemalloc installs itself as the process' default
/// malloc zone (contrib/jemalloc/src/zone.c): every allocation and free flows through that zone's
/// callbacks, so wrapping them tracks the whole process while staying zone-consistent.
///
/// `initializeJemallocZoneMemoryTracking` must run once, after jemalloc has registered its zone.
So on macOS, ClickHouse replaces the zone callbacks instead:
// src/Common/malloc.cpp:350
static void * trackedZoneMalloc(malloc_zone_t * zone, size_t size)
{
ReentrancyGuard guard;
if (!guard.engaged) [[unlikely]]
return saved_zone.malloc(zone, size);
AllocationTrace trace;
size_t actual_size = Memory::trackMemoryFromC(size, trace);
void * ptr = saved_zone.malloc(zone, size);
if (ptr == nullptr) [[unlikely]]
{
[[maybe_unused]] auto rollback_trace = CurrentMemoryTracker::free(actual_size);
return nullptr;
}
trace.onAlloc(ptr, actual_size);
return ptr;
}The ReentrancyGuard exists because of a macOS-specific bootstrapping
problem:
// src/Common/malloc.cpp:301
/// Reentrancy guard. macOS allocates thread-local storage lazily, on first access per thread, by
/// calling malloc. Our tracking touches thread-locals (CurrentMemoryTracker), so the first
/// allocation on a new thread would recurse: malloc -> track -> TLV init -> malloc -> ... until the
/// stack overflows. We detect the reentry with a pthread key, whose thread-specific slot lives in a
/// preallocated array and never itself calls malloc, and pass such allocations straight through
/// untracked. This is macOS-specific: on Linux TLS is set up at thread creation, not lazily.
And the guard’s cost is documented honestly, including the drift it causes:
// src/Common/malloc.cpp:303
/// Known limitation: the pass-through allocation (the thread's ~40 KiB dyld TLV block) is charged
/// to no tracker, but its eventual free runs the normal tracked path and subtracts it. Charging it
/// is impossible - MemoryTracker::allocImpl itself reads thread-locals, so it would re-enter the
/// same lazy TLV init. The effect is one ~40 KiB block per thread: a static under-count while the
/// thread lives, and a ~40 KiB downward drift of the *global* tracker per completed thread
/// create->destroy cycle. It does not affect per-query max_memory_usage (TLV frees happen at thread
/// teardown, charged to the global tracker, not a query's). Measured on a server that spun up ~865
/// threads: ~35 MiB total, and no drift was realized under 1000-connection churn because pooled
/// threads persist. Accepted as a bounded, macOS-only, dev-platform limitation rather than paying
/// a per-free pointer-set lookup on the allocation hot path.
That is a bug report, a root-cause analysis, a measurement, and a decision to accept the trade — in one comment. The “~40 KiB downward drift of the global tracker per completed thread create->destroy cycle” is a real accounting error, on a real platform, and the comment says so.
Throwing or not
Not every interception point is allowed to refuse an allocation. The C++
operator new can, but only above a size threshold, and only if the server
opted in:
// src/Common/CurrentMemoryTracker.cpp:133
AllocationTrace CurrentMemoryTracker::allocThrow(Int64 size)
{
const bool enforce_memory_limit = static_cast<UInt64>(size) >= min_allocation_size_to_throw_on_memory_limit.load(std::memory_order_relaxed);
return allocImpl(size, enforce_memory_limit);
}The threshold defaults to disabled, encoded as UINT64_MAX so the hot
path is a single comparison:
// src/Common/CurrentMemoryTracker.cpp:18
namespace
{
std::atomic<UInt64> min_allocation_size_to_throw_on_memory_limit{std::numeric_limits<UInt64>::max()};
}and the setter maps the user-facing 0 onto that sentinel:
// src/Common/CurrentMemoryTracker.cpp:139
void CurrentMemoryTracker::setMinAllocationSizeBytesToThrow(UInt64 value)
{
min_allocation_size_to_throw_on_memory_limit.store(
value == 0 ? std::numeric_limits<UInt64>::max() : value,
std::memory_order_relaxed);
}The server setting explains why the default is “never”:
// src/Core/ServerSettings.cpp:438
DECLARE(UInt64, min_allocation_size_to_throw_on_memory_limit, 0, R"(
Minimum size, in bytes, of a generic C++ allocation (the kind made by standard containers, strings, `std::vector` growth, smart pointers, etc.) that is allowed to raise `MEMORY_LIMIT_EXCEEDED` once `max_server_memory_usage` is reached. Smaller generic allocations are still counted against the memory tracker but are allowed to succeed even past the limit, which reduces spurious failures during cleanup and exception-handling paths near OOM.
Allocations made by ClickHouse's own large buffers — column data, hash tables, arenas, query pipelines, IO buffers — always honour the memory limit and may throw regardless of this value. This setting only affects implicit allocations that flow through `operator new`.
A value of `0` (default) preserves the legacy behaviour: implicit `operator new` allocations are never refused by the memory tracker, and only explicit ClickHouse allocations can raise `MEMORY_LIMIT_EXCEEDED`.
Note, to avoid side effects it is recommended to set value greater then `max_untracked_memory`.
)", 0) \
So the split is:
| Allocation site | Throws on limit? |
|---|---|
operator new (containers, strings, std::vector) | Only if min_allocation_size_to_throw_on_memory_limit is set and the size is above it |
libc malloc family | Never — the C API cannot report it |
ClickHouse’s own Allocator (columns, hash tables, arenas) | Always |
The reasoning is sound: refusing a two-byte std::string allocation during
exception unwinding is how you turn a recoverable out-of-memory into a
std::terminate. ClickHouse’s own big buffers are different — if a hash
table cannot grow, the query has to fail now, cleanly, while it still
holds a coherent state.
Sampling
Charging every allocation is cheap; tracing every allocation is not. The
AllocationTrace returned by the tracker carries a probability, and the
trace is only taken if the pointer hashes into the sample:
// src/Common/MemoryTracker.cpp:101
bool shouldTrackAllocation(Float64 probability, void * ptr)
{
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wimplicit-const-int-float-conversion"
return static_cast<double>(intHash64(uintptr_t(ptr))) < static_cast<double>(std::numeric_limits<uint64_t>::max()) * probability;
#pragma clang diagnostic pop
}The hash is of the pointer, not of the size or the call site. That gives two useful properties: the same allocation site is sampled consistently across a run (so a hot site shows up reliably), and no shared state is needed to decide.
The AllocationTrace itself is designed so that the common case — not
sampling — costs one branch:
// src/Common/AllocationTrace.h:13
ALWAYS_INLINE void onAlloc(void * ptr, size_t size) const
{
if (likely(sample_probability <= 0))
return;
onAllocImpl(ptr, size);
}Overflow guards
Two overflow guards protect the accounting from a size computation that has gone wrong.
The first is in the allocator:
// src/Common/Allocator.cpp:122
void checkSize(size_t size)
{
/// More obvious exception in case of possible overflow (instead of just "Cannot mmap").
if (size >= MAX_ALLOCATION_SIZE) [[unlikely]]
throw DB::Exception(DB::ErrorCodes::LOGICAL_ERROR, "Too large size ({}) passed to allocator. It indicates an error.", size);
}with
// src/Common/Allocator.h:24
/// The allocators refuse anything of this size or larger: such an allocation can never succeed,
/// and a size that large is a sign of an overflow in the computation of the size.
static constexpr size_t MAX_ALLOCATION_SIZE = 0x8000000000000000ULL;0x8000000000000000 is 2^63 — the point where a signed 64-bit size would go
negative. An allocation that large can never succeed, so refusing it with a
clear message beats letting it turn into a nonsense size.
The second is in the tracker itself:
// src/Common/MemoryTracker.cpp:349
if (size < 0)
throw DB::Exception(DB::ErrorCodes::LOGICAL_ERROR, "Negative size ({}) is passed to MemoryTracker. It is a bug.", size);Prefaulting
One more thing the interception layer does: large allocations are pre-faulted, so that the pages exist before anyone touches them.
// src/Common/Allocator.cpp:59
void prefaultPages([[maybe_unused]] void * buf_, [[maybe_unused]] size_t len_)
{
#if defined(MADV_POPULATE_WRITE)
if (len_ < POPULATE_THRESHOLD)
return;
if (!is_supported_by_kernel) [[unlikely]]
return;
auto [buf, len] = adjustToPageSize(buf_, len_, ::getPageSize());
::madvise(buf, len, MADV_POPULATE_WRITE);
#endif
}and the threshold is, in the source’s own words, arbitrary:
// src/Common/Allocator.cpp:132
/// Constant is chosen almost arbitrarily, what I observed is 128KB is too small, 1MB is almost indistinguishable from 64MB and 1GB is too large.
extern const size_t POPULATE_THRESHOLD = std::max(Int64{16 * 1024 * 1024}, ::getPageSize());16 MiB. The madvise is skipped on kernels older than 5.14, which do not
have MADV_POPULATE_WRITE.
This is not memory tracking, but it happens in the same place, and it is worth knowing that the allocator is not just handing out addresses — for large buffers it is making sure the pages are real.
What it costs
Every allocation pays a tracker call. Not a sample, not a probability — a full walk up the four-level chain (mitigated by the per-thread batching, covered in the sibling article). The interception layer is the reason that batching exists.
je_nallocx and je_sallocx are not free. They are cheap size-class
computations, but they are called on every allocation and every free
respectively. Without jemalloc the fallback is malloc_usable_size, which
the source describes as inaccurate under sanitizers.
The macOS path has a known drift. ~40 KiB per thread, documented above.
The operator new path can throw. Not by default, but if
min_allocation_size_to_throw_on_memory_limit is set, a generic C++
allocation above the threshold can raise MEMORY_LIMIT_EXCEEDED from
somewhere deep inside a library.
What to go do about it
The one thing to remember: the number ClickHouse reports is the size
class the allocator actually consumed, not the size the code asked for. If
you are comparing system.processes.memory_usage against the sum of your
column sizes, expect the tracked number to be larger.
The part people get wrong: max_memory_usage does not bound every
allocation on the query’s path. operator new allocations are counted but
not refused by default, and libc allocations are counted but never refused.
Only ClickHouse’s own big buffers are hard-bounded. If you want a hard bound
on generic allocations too:
SELECT value FROM system.server_settings
WHERE name = 'min_allocation_size_to_throw_on_memory_limit';Setting it to a value above max_untracked_memory is the recommendation in
the setting’s own documentation, and it is worth following: below that
threshold, the batching would flush a small allocation as part of a batch
larger than the threshold, and the accounting would be confusing.
What to go do about it. If you suspect the tracked total and the real RSS disagree, compare them directly:
SELECT
(SELECT value FROM system.metrics WHERE metric = 'MemoryTracking') AS tracked,
(SELECT value FROM system.metrics WHERE metric = 'MemoryTrackingUncorrected') AS uncorrected;MemoryTracking is the corrected value, MemoryTrackingUncorrected is the
raw counter. A large gap between them and the process’s RSS is the
accounting drift the sibling articles describe.