Skip to content

Commit 4ea6c86

Browse files
committed
memory-tier: mlock warm KV buffer to prevent kernel swap-out (MAD-179)
Warm tier was a std::vector<uint8_t> sized 3.2 GB at 1M context. On hosts with low RAM headroom the kernel paged it out during prefill, causing major-fault thrashing (prefill collapsed to ~13 tok/s while GPU stayed at 100%). Fix: introduce LockedBuffer (mmap + best-effort mlock, MAP_LOCKED on Linux with mlock() fallback, VirtualLock on Windows) and back the warm tier with it. New CLI flag --kv-tier-warm-mlock (default on) plumbed through llama_context_params -> llama_memory_params -> TieredConfig.warm_mlock. Locking failure is logged with remediation (raise RLIMIT_MEMLOCK) but does not abort init; the buffer still works, just swappable.
1 parent 354429a commit 4ea6c86

12 files changed

Lines changed: 277 additions & 8 deletions

File tree

common/arg.cpp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1418,6 +1418,16 @@ common_params_context common_params_parser_init(common_params & params, llama_ex
14181418
params.kv_warm_device = value;
14191419
}
14201420
).set_env("LLAMA_ARG_KV_WARM_DEVICE").set_examples({LLAMA_EXAMPLE_SERVER, LLAMA_EXAMPLE_CLI}));
1421+
add_opt(common_arg(
1422+
{"--kv-tier-warm-mlock"}, "0|1",
1423+
"mlock the host-RAM warm tier to prevent kernel swap-out (default: 1). "
1424+
"Required for predictable performance at long context on RAM-constrained "
1425+
"hosts. If the lock fails (e.g. RLIMIT_MEMLOCK too small or no "
1426+
"CAP_IPC_LOCK), allocation still succeeds and a warning is logged.",
1427+
[](common_params & params, int value) {
1428+
params.kv_tier_warm_mlock = (value != 0);
1429+
}
1430+
).set_env("LLAMA_ARG_KV_TIER_WARM_MLOCK").set_examples({LLAMA_EXAMPLE_SERVER, LLAMA_EXAMPLE_CLI}));
14211431
add_opt(common_arg(
14221432
{"--kv-tier-total-ctx"}, "N",
14231433
"full ctx budget across all tiers (0 = use n_ctx)",

common/common.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1541,6 +1541,7 @@ struct llama_context_params common_context_params_to_llama(const common_params &
15411541
cparams.kv_tier_compression = params.kv_tier_compression;
15421542
cparams.kv_tier_attention_threshold = params.kv_tier_attention_threshold;
15431543
cparams.kv_tier_warm_device = params.kv_warm_device;
1544+
cparams.kv_tier_warm_mlock = params.kv_tier_warm_mlock;
15441545
cparams.kv_tier_total_ctx = params.kv_tier_total_ctx;
15451546
cparams.kv_tier_semantic_index = params.kv_semantic_index.empty()
15461547
? nullptr

common/common.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,7 @@ struct common_params {
602602
int kv_tier_compression = 1; // 0=none, 1=int4, 2=int8, 3=lz4, 4=quantized
603603
float kv_tier_attention_threshold = 0.1f; // attention threshold for eviction
604604
int kv_warm_device = -1; // HIP device index for warm KV tier (-1 = disabled)
605+
bool kv_tier_warm_mlock = true; // mlock the host-RAM warm tier so it's not eligible for kernel swap (see mt-locked-buffer.h)
605606
int kv_tier_total_ctx = 0; // full ctx budget across all tiers (set at load time)
606607
std::string kv_semantic_index = ""; // path to embedding model for semantic KV index (empty = disabled)
607608
float kv_semantic_threshold = 0.65f; // minimum cosine similarity threshold for prefetch hints

include/llama.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,7 @@ extern "C" {
402402
int32_t kv_tier_compression; // mt::Compression
403403
float kv_tier_attention_threshold;
404404
int32_t kv_tier_warm_device; // -1 = host RAM
405+
bool kv_tier_warm_mlock; // mlock the host-RAM warm tier (prevents kernel swap-out)
405406
int32_t kv_tier_total_ctx; // 0 = use n_ctx
406407
const char * kv_tier_semantic_index; // nullptr => disabled
407408
float kv_tier_semantic_threshold;

src/llama-context.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@ llama_context::llama_context(
295295
/*.kv_tier_compression =*/ params.kv_tier_compression,
296296
/*.kv_tier_attention_threshold =*/ params.kv_tier_attention_threshold,
297297
/*.kv_tier_warm_device =*/ params.kv_tier_warm_device,
298+
/*.kv_tier_warm_mlock =*/ params.kv_tier_warm_mlock,
298299
/*.kv_tier_total_ctx =*/ params.kv_tier_total_ctx,
299300
/*.kv_tier_semantic_index =*/ params.kv_tier_semantic_index,
300301
/*.kv_tier_semantic_threshold =*/ params.kv_tier_semantic_threshold,
@@ -3354,6 +3355,7 @@ llama_context_params llama_context_default_params() {
33543355
/*.kv_tier_compression =*/ 1,
33553356
/*.kv_tier_attention_threshold =*/ 0.1f,
33563357
/*.kv_tier_warm_device =*/ -1,
3358+
/*.kv_tier_warm_mlock =*/ true,
33573359
/*.kv_tier_total_ctx =*/ 0,
33583360
/*.kv_tier_semantic_index =*/ nullptr,
33593361
/*.kv_tier_semantic_threshold =*/ 0.65f,

src/llama-memory.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ struct llama_memory_params {
3333
int32_t kv_tier_compression;
3434
float kv_tier_attention_threshold;
3535
int32_t kv_tier_warm_device;
36+
bool kv_tier_warm_mlock; // mlock the host-RAM warm tier (prevents kernel swap-out)
3637
int32_t kv_tier_total_ctx;
3738
const char * kv_tier_semantic_index;
3839
float kv_tier_semantic_threshold;

src/llama-model.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2354,6 +2354,7 @@ llama_memory_i * llama_model::create_memory(const llama_memory_params & params,
23542354
tcfg.compression = (mt::Compression) params.kv_tier_compression;
23552355
tcfg.attention_threshold = params.kv_tier_attention_threshold;
23562356
tcfg.warm_device = params.kv_tier_warm_device;
2357+
tcfg.warm_mlock = params.kv_tier_warm_mlock;
23572358
if (params.kv_tier_semantic_index && params.kv_tier_semantic_index[0]) {
23582359
tcfg.semantic_index = params.kv_tier_semantic_index;
23592360
}

src/memory-tier/mt-config.h

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,16 @@ struct TieredConfig {
7474
// implement device-warm; the field is preserved for forward compat.
7575
int warm_device = -1;
7676

77+
// Pin the host-RAM warm tier in physical memory via mlock() / VirtualLock()
78+
// so the kernel cannot page it out to swap under memory pressure. Default
79+
// ON because the failure mode is silent and catastrophic: at long context
80+
// on RAM-constrained hosts, warm-tier reads turn into swap-disk reads,
81+
// collapsing prefill throughput to ~10% of expected. Disable only if the
82+
// host cannot grant the lock (RLIMIT_MEMLOCK / CAP_IPC_LOCK) and the lock
83+
// failure is acceptable for the workload. When mlock fails, allocation
84+
// still succeeds and a warning is logged with the remediation steps.
85+
bool warm_mlock = true;
86+
7787
// Optional semantic-similarity prefetch. Non-empty enables it.
7888
std::string semantic_index;
7989
float semantic_threshold = 0.65f;
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// mt-locked-buffer.cpp — see header for rationale.
2+
#include "mt-locked-buffer.h"
3+
4+
#include "llama-impl.h" // LLAMA_LOG_*
5+
6+
#include <cerrno>
7+
#include <cstring>
8+
9+
#ifdef _WIN32
10+
#include <windows.h>
11+
#else
12+
#include <sys/mman.h>
13+
#include <unistd.h>
14+
#endif
15+
16+
namespace mt {
17+
18+
LockedBuffer::~LockedBuffer() {
19+
reset();
20+
}
21+
22+
LockedBuffer::LockedBuffer(LockedBuffer && other) noexcept
23+
: data_(other.data_), size_(other.size_), locked_(other.locked_) {
24+
other.data_ = nullptr;
25+
other.size_ = 0;
26+
other.locked_ = false;
27+
}
28+
29+
LockedBuffer & LockedBuffer::operator=(LockedBuffer && other) noexcept {
30+
if (this != &other) {
31+
reset();
32+
data_ = other.data_;
33+
size_ = other.size_;
34+
locked_ = other.locked_;
35+
other.data_ = nullptr;
36+
other.size_ = 0;
37+
other.locked_ = false;
38+
}
39+
return *this;
40+
}
41+
42+
void LockedBuffer::reset() {
43+
if (!data_) {
44+
size_ = 0;
45+
locked_ = false;
46+
return;
47+
}
48+
#ifdef _WIN32
49+
if (locked_) {
50+
VirtualUnlock(data_, size_);
51+
}
52+
VirtualFree(data_, 0, MEM_RELEASE);
53+
#else
54+
if (locked_) {
55+
munlock(data_, size_);
56+
}
57+
munmap(data_, size_);
58+
#endif
59+
data_ = nullptr;
60+
size_ = 0;
61+
locked_ = false;
62+
}
63+
64+
bool LockedBuffer::allocate(size_t size_bytes, bool lock) {
65+
reset();
66+
if (size_bytes == 0) {
67+
return true;
68+
}
69+
70+
#ifdef _WIN32
71+
data_ = static_cast<uint8_t *>(
72+
VirtualAlloc(nullptr, size_bytes, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE));
73+
if (!data_) {
74+
LLAMA_LOG_ERROR(
75+
"mt::LockedBuffer: VirtualAlloc(%zu bytes / %.1f MiB) failed (error %lu)\n",
76+
size_bytes, (double) size_bytes / (1024.0 * 1024.0),
77+
(unsigned long) GetLastError());
78+
return false;
79+
}
80+
size_ = size_bytes;
81+
if (lock) {
82+
if (VirtualLock(data_, size_bytes)) {
83+
locked_ = true;
84+
} else {
85+
LLAMA_LOG_WARN(
86+
"mt::LockedBuffer: VirtualLock(%.1f MiB) failed (error %lu). "
87+
"Buffer is allocated but eligible for page-out under RAM pressure. "
88+
"Raise the process working-set quota to fix.\n",
89+
(double) size_bytes / (1024.0 * 1024.0),
90+
(unsigned long) GetLastError());
91+
}
92+
}
93+
#else
94+
int flags = MAP_PRIVATE | MAP_ANONYMOUS;
95+
bool tried_map_locked = false;
96+
97+
#ifdef MAP_LOCKED
98+
if (lock) {
99+
flags |= MAP_LOCKED;
100+
tried_map_locked = true;
101+
}
102+
#endif
103+
104+
void * p = mmap(nullptr, size_bytes, PROT_READ | PROT_WRITE, flags, -1, 0);
105+
106+
#ifdef MAP_LOCKED
107+
if (p == MAP_FAILED && tried_map_locked) {
108+
// MAP_LOCKED often fails on RLIMIT_MEMLOCK; retry without it and try
109+
// mlock() after, which gives a clearer error path.
110+
int saved_errno = errno;
111+
flags &= ~MAP_LOCKED;
112+
p = mmap(nullptr, size_bytes, PROT_READ | PROT_WRITE, flags, -1, 0);
113+
if (p == MAP_FAILED) {
114+
LLAMA_LOG_ERROR(
115+
"mt::LockedBuffer: mmap(%.1f MiB) failed: %s "
116+
"(also failed with MAP_LOCKED: %s)\n",
117+
(double) size_bytes / (1024.0 * 1024.0),
118+
strerror(errno), strerror(saved_errno));
119+
return false;
120+
}
121+
// fall through — we have an unlocked mapping; mlock() pass below.
122+
tried_map_locked = false;
123+
} else if (p != MAP_FAILED && tried_map_locked) {
124+
// MAP_LOCKED succeeded — pages are pinned.
125+
locked_ = true;
126+
}
127+
#endif
128+
129+
if (p == MAP_FAILED) {
130+
LLAMA_LOG_ERROR(
131+
"mt::LockedBuffer: mmap(%.1f MiB) failed: %s\n",
132+
(double) size_bytes / (1024.0 * 1024.0), strerror(errno));
133+
return false;
134+
}
135+
136+
data_ = static_cast<uint8_t *>(p);
137+
size_ = size_bytes;
138+
139+
// If we asked for a lock but MAP_LOCKED wasn't used or didn't apply,
140+
// do a follow-up mlock() so we get a clear errno path.
141+
if (lock && !locked_) {
142+
if (mlock(data_, size_bytes) == 0) {
143+
locked_ = true;
144+
} else {
145+
LLAMA_LOG_WARN(
146+
"mt::LockedBuffer: mlock(%.1f MiB) failed: %s. "
147+
"Buffer is allocated but eligible for swap-out under RAM pressure. "
148+
"Raise RLIMIT_MEMLOCK (systemd unit: LimitMEMLOCK=infinity) "
149+
"or run with CAP_IPC_LOCK to fix.\n",
150+
(double) size_bytes / (1024.0 * 1024.0), strerror(errno));
151+
}
152+
}
153+
#endif
154+
155+
return true;
156+
}
157+
158+
} // namespace mt

src/memory-tier/mt-locked-buffer.h

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// mt-locked-buffer.h
2+
//
3+
// LockedBuffer: a page-aligned byte buffer that can be optionally pinned in
4+
// RAM via mlock() / VirtualLock(). Used by the tiered-KV warm staging tier so
5+
// the kernel cannot page warm-tier KV out to swap under memory pressure (the
6+
// failure mode that turned warm-tier reads into swap-disk reads, observed on
7+
// long-context workloads with constrained host RAM).
8+
//
9+
// Owns its allocation via mmap (POSIX) or VirtualAlloc (Windows) — does NOT
10+
// use the C++ heap, so it bypasses any allocator-level placement assumptions
11+
// the kernel could apply to vector/new memory.
12+
//
13+
// Locking is BEST-EFFORT: if the kernel refuses mlock (e.g. RLIMIT_MEMLOCK
14+
// too small or no CAP_IPC_LOCK), allocation still succeeds and the buffer
15+
// is unlocked. A warning is logged with a concrete remediation hint.
16+
//
17+
// Drop-in replacement for `std::vector<uint8_t>` in the narrow API surface
18+
// the warm-tier code uses today: data(), size(), and a single bulk allocate.
19+
//
20+
// Cross-platform:
21+
// Linux : mmap(MAP_PRIVATE|MAP_ANONYMOUS [|MAP_LOCKED]) + fallback mlock()
22+
// macOS : mmap(MAP_PRIVATE|MAP_ANONYMOUS) + mlock() (no MAP_LOCKED)
23+
// Win32 : VirtualAlloc(MEM_COMMIT|MEM_RESERVE) + VirtualLock()
24+
#pragma once
25+
26+
#include <cstddef>
27+
#include <cstdint>
28+
29+
namespace mt {
30+
31+
class LockedBuffer {
32+
public:
33+
LockedBuffer() = default;
34+
~LockedBuffer();
35+
36+
// Allocate `size_bytes` of zero-initialized memory.
37+
//
38+
// If `lock` is true, attempts to pin the memory in RAM. On failure the
39+
// buffer remains allocated (operation succeeds) but `is_locked()` will
40+
// return false and a warning is logged.
41+
//
42+
// Returns false only if the underlying allocation itself fails.
43+
bool allocate(size_t size_bytes, bool lock = true);
44+
45+
// Release the buffer (munlock + munmap / VirtualUnlock + VirtualFree).
46+
// Safe to call on an empty buffer. Called automatically by the destructor.
47+
void reset();
48+
49+
uint8_t * data() { return data_; }
50+
const uint8_t * data() const { return data_; }
51+
size_t size() const { return size_; }
52+
bool is_locked() const { return locked_; }
53+
54+
// Disable copy. Allow move so we can reassign in the tiered cache class.
55+
LockedBuffer(const LockedBuffer &) = delete;
56+
LockedBuffer & operator=(const LockedBuffer &) = delete;
57+
LockedBuffer(LockedBuffer && other) noexcept;
58+
LockedBuffer & operator=(LockedBuffer && other) noexcept;
59+
60+
private:
61+
uint8_t * data_ = nullptr;
62+
size_t size_ = 0;
63+
bool locked_ = false;
64+
};
65+
66+
} // namespace mt

0 commit comments

Comments
 (0)