When does a BPF LRU hash map evict an entry, and what changes when its LRU list is per-CPU instead of shared across CPUs?
Eviction happens only on the insert or update path, and only once the table has already reached max_entries. A lookup never directly frees a slot: a read does not release a node. What a read does is set a lazy recently-used bit on the node it hit; the actual list movement and the actual slot release happen later, when an update is forced to free a node and the list rotation scans run. The LRU is a two-phase active and inactive list, not a single least-used queue, so "least recently used" is a heuristic the kernel maintains incrementally, not a value you can read off at any instant.
The BPF_F_NO_COMMON_LRU flag does not change when eviction fires. It changes which entry survives and the lock profile. Without the flag every CPU shares one global LRU list, so all rotation and free-node accounting goes through shared state; with it, each CPU keeps its own LRU list and evicts from its own list before stealing from another CPU. The trigger stays exactly the same in both cases: an insert at capacity. The flag trades the globally-least-used property for per-CPU locality and a cheaper lock, not for fewer evictions.
The mechanism
-
Eviction is on the insert path.
htab_lru_map_update_elemfirst callsprealloc_lru_pop, which asks the LRU list for a free node (bpf_lru_pop_free). While the table is belowmax_entriesthere are already free nodes in the preallocated pool and nothing is evicted. When the table is at capacity,bpf_lru_pop_freehas no free node, so it forces one out: it scans the inactive list (__bpf_lru_list_shrink_inactive), and for a node whose recently-used bit is clear it calls the map delete hook and moves the node onto the free list. That is the moment a live key is actually dropped from the hash table. -
Recency is a lazy bit, not a position. The in-BPF lookup helper sets the node's recently-used bit (
bpf_lru_node_set_ref); the user-space lookup helper does not. Nothing is moved on a read. The lists are touched only during the rotation scans (__bpf_lru_list_rotate_activeand__bpf_lru_list_rotate_inactive): a scanned node with the bit set goes back to the head of the active list for one more chance to survive, and a node without it falls from active to inactive. Nodes are freed only from the tail of the inactive list. -
The
BPF_F_NO_COMMON_LRUswitch.bpf_lru_pop_freedispatches on the per-CPU flag. Without it,bpf_common_lru_pop_freekeeps one shared LRU list plus per-CPU pending lists and takes a shared lock to account for them. With it,bpf_percpu_lru_pop_freegives every CPU its own list guarded by its own lock, and a CPU only steals a node from another CPU, in round-robin, when its own list is exhausted. The batch size to pull per update,target_free, is computed at map init with a minimum of one node and a budget of 128, with the aggregate reservation across all CPUs capped at half the map size. -
OBI trace context map as a concrete case. OBI's pinned
traces_ctx_v1is aBPF_MAP_TYPE_LRU_HASHwithmax_entriesof1 << 14and noBPF_F_NO_COMMON_LRU, so it uses the shared global LRU list. OBI does not reserve a slot for any thread, so when the number of live stamped threads is high the least-recently-used entry is evicted on the next insert, and a currently active thread can miss its own context.
Verification and debugging path
-
Check the live element count.
bpf_map_get_infoorbpftool map showon the pinned map reports the element count againstmax_entries. Eviction only becomes possible once the count is atmax_entries; below that, inserts never drop a key. -
Watch the update return code, not the lookup. A full non-LRU hash returns
-E2BIGon a new-key insert. A full LRU hash instead evicts the LRU victim and returns0, or rarely-ENOMEMwhen it cannot secure a free node under lock contention. If a map you declared LRU is failing inserts with-E2BIGat capacity, it is a plainBPF_MAP_TYPE_HASH, not an LRU variant. -
Trace the victim with the delete hook. Probe the map update path, or attach to the map delete, and watch which key disappears when you push one element past capacity. The key that vanishes is the LRU victim the list selected.
-
For the flag question, measure victim locality. With the shared LRU the evicted key is the globally least-recently-used one. With
BPF_F_NO_COMMON_LRUit is the least-recently-used key on the CPU that did the insert, which on a hot multi-CPU workload can be a different, still-active key than the global one.
The limitation
- The LRU property is a heuristic. The kernel maintains two lists and rotates them in bounded scans; it is not a strict least-recently-used queue, and the kernel documents these internals as subject to change.
- Recency is not symmetric. An in-BPF read marks the node; a user-space read does not. Two identical workloads that differ only in who reads the map will evict different keys.
- The flag changes victims and lock cost, not the trigger.
BPF_F_NO_COMMON_LRUdoes not stop eviction and does not keep the globally least-used entry; it makes eviction per-CPU. - A miss on a full LRU map is not proof the key was never written. It means the key was the LRU victim at the last insert, so the value may have existed and then been evicted.
References
- kernel Documentation/bpf/map_hash.rst -
BPF_MAP_TYPE_LRU_HASHautomatically evicts the least-recently-used entry when the table reaches capacity; theBPF_F_NO_COMMON_LRUflag requests a per-CPU LRU list instead of the shared one. - kernel kernel/bpf/hashtab.c -
htab_lru_map_update_elemandprealloc_lru_popare the insert path where eviction happens; the lookup helper sets the recently-used bit in-BPF and skips it from user space. - kernel kernel/bpf/bpf_lru_list.c - the two-phase active and inactive LRU:
__bpf_lru_list_rotate_active,__bpf_lru_list_shrink_inactive, andbpf_lru_pop_freewith the per-CPU versus common dispatch. - kernel commit 3a08c2fd7634 ("bpf: LRU List") - the original LRU list implementation and the eviction steps taken during a map update.
- OBI bpf/shared/obi_ctx.h -
traces_ctx_v1, a pinnedBPF_MAP_TYPE_LRU_HASHwithmax_entriesof1 << 14and noBPF_F_NO_COMMON_LRU.
Community discussion today
The two opt-in archive channels (the two CNCF OpenTelemetry instrumentation channels) produced zero messages this run; the 24-hour window advanced to an empty snapshot. With no new question in the archive and the recent days already covering the FUSE and OBI trace context map miss axes, this entry takes the natural continuation of that same map: traces_ctx_v1 is an LRU hash, so the follow-on question is what actually evicts from it and what the per-CPU flag would change. The answer is grounded in the kernel LRU map source and the OBI map spec rather than in a new archive question.
Channel coverage for this run: the two opt-in archives supplied zero messages. The visible-browser-only sources - the eunomia-bpf and sched-ext Discord servers, the bpf mailing list, and r/eBPF - could not be reviewed this run because no visible-browser session was available, so they are marked not-covered, not quiet.
Continue exploring
Back to index
eBPF Q&A
Daily, source-grounded answers to recurring questions about eBPF, Linux observability, profiling, runtime extension, and security.
Previous
When a FUSE daemon lookup of the OBI trace context map returns an entry, is that entry the trace context of the current request, and when can the hit be stale?
Not necessarily. A hit is not a promise that the entry is the trace context of the FUSE request you are about to serve. The OBI pinned trace context map tracesctxv1 holds, per OS thread, the trace context of the
Next
Why do OBI HTTP spans carry high-cardinality names, and how does the OpenTelemetry semantic convention bound span name cardinality?
The span name is the single highest-cardinality field a trace system carries, so the OpenTelemetry HTTP semantic convention pins the name to {method} {target} and requires a low-cardinality target: http.route on the
- Last updated
- Oct 20, 2026
- First published
- Oct 20, 2026
- Contributors
- Yunwei 123
Was this page helpful?