website/docs/getting-started/memory.md
For large-scale production scenarios, Garnet's memory usage needs to be tuned to make optimal use of available memory on a machine. Here, we discuss the components of memory, and how to tune them. Configuration parameters are listed here.
Garnet stores all data in a single unified Tsavorite store, accessed through three contexts (String, Object, and Unified). A record's ValueIsObject bit distinguishes values held as inline bytes (raw strings) from values held as references to heap objects (data-structure objects such as Hash, List, Set, SortedSet). If you use only raw strings (e.g., GET, SET and their variants), HYPERLOGLOG, and BITMAP commands, you can disable support for data-structure objects using the DisableObjects (--no-obj) parameter. This avoids reserving memory for object bookkeeping.
The store's memory usage is the sum of three components:
The index size is configured using the IndexMemorySize (-i or --index) parameter. It specifies the total size in bytes
that the index occupies in main memory. The index is organized as hash buckets, where each bucket is 64 bytes long, i.e.,
the size of a cache line. The bucket holds 7 entries and a pointer to an overflow bucket, described below.
The rule of thumb for sizing the index is: if you expect the cache-store to hold K keys, set the size to K * 16 bytes. The
reasoning for this is:
K / 4 buckets64 * (K / 4) = K * 16 bytesEach hash bucket has 7 entries (slots) that store the root of a chain of records stored in the log. If the hash bucket for a given key is full, we overflow into extra buckets called overflow buckets that are allocated dynamically. While these cannot be controlled or bounded, they are typically very small and can be ignored. In case your index was sized too small, they can take up more space, and to combat this, you can dynamically grow the index as described below.
You can configure Garnet to automatically grow the index (doubling each time) as it fills up. This is done by
configuring IndexResizeFrequencySecs (--index-resize-freq) to specify how frequently to trigger the
resizing check. Index growth is triggered if the number of overflow buckets exceeds a specified percentage
of the total number of hash buckets. This threshold is specified using IndexResizeThreshold (--index-resize-threshold).
We also support IndexMaxMemorySize (--index-max-size) which identifies the maximum size until which the index
will grow in size. We do not support index size shrinking at this point.
The index described above does not hold keys or values. Instead, both keys and values are stored in a separate structure
called the hybrid log. The memory occupied by the log is configured using LogMemorySize (-m or --memory). This single
parameter covers the entire main-log memory budget, including both the inline bytes of raw-string records and the heap
memory referenced by data-structure objects.
Memory is organized as a circular buffer of pages, where each page has size configured using PageSize (-p or --page). The page
size controls the maximum key or value size you can store inline, as a record needs to fit entirely within a page.
Every record begins with an 8-byte header, called RecordInfo, which holds metadata (including the ValueIsObject bit) and
the logical address of the previous entry in a record chain, followed by the key. The value layout depends on the record type:
ValueIsObject is clear), the value is stored inline as bytes within the log page, alongside the key.ValueIsObject is set), the value is a reference to an IGarnetObject instance (such as
SortedSet, Hash, or Set) allocated on the .NET heap. The inline record holds only the reference; the object's contents live on
the heap and are charged against the same LogMemorySize budget through per-object heap accounting.Because LogMemorySize accounts for both inline bytes and referenced heap memory, it bounds the total main-log memory regardless
of the mix of raw strings and data-structure objects in the store.
Read cache helps bring in records from disk to memory in a separate read cache region without growing the main log. This helps avoid additional I/O when reading records that are already on the disk. More details on the internals of read cache are available here.
Use the --readcache option to enable the read cache. The following configuration options control its memory utilization:
--readcache-page controls the size of each read cache page.--readcache-memory controls the total read cache memory, covering both inline bytes and referenced heap memory.Direct (unbuffered) disk I/O requires sector-aligned buffers. Garnet serves these from a
SectorAlignedBufferPool, one instance per log/device, that recycles buffers instead of allocating a fresh
pinned array for every read/flush. By default this is a scalable, per-thread, origin-return pool: a returned
buffer is routed back to the thread that originally allocated it, so it scales with the many I/O-completion
threads that free buffers concurrently, under a per-pool byte budget that bounds retained memory. The internals
are described in the developer guide under
Sector-Aligned Buffer Pool.
The --use-legacy-buffer-pool (UseLegacyBufferPool) switch selects the older per-size-level
ConcurrentQueue pool instead. It is a boolean switch (default off):
ConcurrentQueue per size level with no per-pool byte budget. Provided
as a fallback; it does not scale with core count under cross-thread frees.This switch is decided once per pool at construction and should be set at program entry (like the other pool
policies). It is independent of --use-native-allocator: the buffer pool always uses managed (Pinned Object
Heap) buffers in this release, regardless of the native-allocator setting.
The --buffer-pool-memory-budget (BufferPoolMemoryBudget) setting bounds how many buffer bytes the
origin-return pool keeps cached for reuse per pool, across all I/O-completion threads. It is a
memory-size string (default 1g; e.g. 512m, 1g, 8g):
--use-legacy-buffer-pool is set (the legacy pool has no per-pool byte
budget), and, like the pool-selection switch, is applied once at program entry before any pool is created.0 disables buffer caching entirely: every I/O buffer is allocated on demand and
reclaimed by the GC on return. This trades reuse for the smallest possible steady-state pool footprint, and
is useful when you would rather give that memory to the store and let the GC absorb the buffer churn.
Unlike a positive budget, 0 applies to both pool backends (it short-circuits the pool entirely rather
than bounding it), so it is also honored under --use-legacy-buffer-pool.By default, Garnet's large, long-lived buffers — the hash index, hybrid-log pages, and recovery frames —
are allocated as pinned arrays on the managed .NET heap (the Pinned Object Heap). The optional
--use-native-allocator setting moves these allocations off the managed heap, into native memory, which reduces
GC pause times (the collector has far less to scan/compact) and removes POH fragmentation. It is off by
default and opt-in. (The SectorAlignedBufferPool IO/flush buffers always remain on the managed Pinned Object
Heap regardless of this setting.)
--use-native-allocator is a boolean switch (default off):
mmap/VirtualAlloc). These give demand-zero, first-touch-placed pages that match
the managed allocator's behavior, but off the GC heap — so a large index/log does not inflate the managed
heap. On Linux these regions are 2 MB-aligned and hinted for transparent huge pages, which lowers dTLB
misses on random index/log access (measured ~8% higher in-memory throughput vs the managed heap). No native
library is required — the direct-VM surfaces call the OS virtual-memory APIs directly. (Network buffers and
the sector-aligned IO/flush buffer pool remain managed in this release.)Native memory lives outside the managed GC heap, so:
GCHeapHardLimit/GCHeapHardLimitPercent to leave headroom for the native pools; the GC's own limit
does not account for native memory.DOTNET_GCDynamicAdaptationMode=0 — DATAS (default in .NET 9+) resizes the managed heap by throughput and
is blind to native memory, so it can grow the heap into a container OOM.native_allocator_bytes in INFO memory alongside gc_heap_bytes. For example, with a large index and
the native allocator enabled you will see the index bytes move from gc_heap_bytes into native_allocator_bytes,
with the managed heap becoming dramatically smaller.Native memory is not reported to the GC via GC.AddMemoryPressure (which would only trigger unproductive Gen2
collections that cannot reclaim it); use the hard-limit + telemetry approach above instead.
The direct-VM surfaces (hash index, log pages, frames) use mmap/VirtualAlloc and need no shipped
native binary, so they work on any platform. Transparent huge pages for those regions are a Linux optimization
(madvise(MADV_HUGEPAGE)); on Windows they use regular pages (large-page support there needs the privileged
SeLockMemoryPrivilege), so the native allocator is functionally identical on Windows, just without the huge-page
throughput bonus.