Per-CPU Maps
A per-CPU BPF map gives every logical CPU its own private copy of each value, so a BPF program running on a core reads and writes only its own copy with no locking and no cross-core cache-line bouncing. The map types are
BPF_MAP_TYPE_PERCPU_HASHandBPF_MAP_TYPE_PERCPU_ARRAY(both since kernel 4.6),BPF_MAP_TYPE_LRU_PERCPU_HASH(since 4.10), andBPF_MAP_TYPE_PERCPU_CGROUP_STORAGE. From inside a BPF program,bpf_map_lookup_elem()transparently returns this CPU’s slot for the key — verified against the v6.12 source, the per-CPU array’s lookup is literallythis_cpu_ptr(array->pptrs[index])(arraymap.c L249). From userspace, a singlebpf_map_lookup_elem()returns an array ofnum_possible_cpus()values — one slot per CPU — which the reader sums (or otherwise reduces) to get the global figure. This split-then-aggregate design is why per-CPU maps are the canonical primitive for high-frequency counters and metrics aggregation in eBPF.
1. Mental Model — One Logical Key, N Physical Slots
Think of an ordinary BPF map as a single shared spreadsheet that every core writes into. The moment two cores increment the same cell, they must take a lock (or use an atomic instruction), and the cache line holding that cell ping-pongs between the cores’ L1 caches under the cache-coherence protocol. On a 64-core machine processing tens of millions of packets per second, that single hot counter becomes the bottleneck — the cores spend more time fighting over the cache line than doing work. This is true sharing contention.
A per-CPU map breaks the shared cell into N independent cells, one per logical CPU, all addressed by the same logical key. Core 7 increments its cell; core 31 increments its cell; the two cells live in different cache lines, owned exclusively by their respective cores, so neither core ever stalls waiting for the other. There is no lock on the BPF-side hot path because there is nothing to contend over — each core owns its slot outright. The price is that the true value of the counter is now scattered across N slots and must be summed at read time, which userspace does once per scrape interval rather than once per event. You have traded a tiny, constant aggregation cost on the cold read path for the elimination of all contention on the hot write path — an excellent trade when writes vastly outnumber reads, which is exactly the shape of a metrics counter.
flowchart TB subgraph PROG["BPF programs running concurrently"] P0["prog on CPU 0"] P1["prog on CPU 1"] PN["prog on CPU N-1"] end subgraph MAP["BPF_MAP_TYPE_PERCPU_ARRAY (one logical key K)"] S0["slot[CPU 0]<br/>own cache line"] S1["slot[CPU 1]<br/>own cache line"] SN["slot[CPU N-1]<br/>own cache line"] end P0 -->|"this_cpu_ptr → lock-free RMW"| S0 P1 -->|"this_cpu_ptr → lock-free RMW"| S1 PN -->|"this_cpu_ptr → lock-free RMW"| SN US["Userspace reader<br/>(one lookup of key K)"] S0 -. "per_cpu_ptr(0)" .-> US S1 -. "per_cpu_ptr(1)" .-> US SN -. "per_cpu_ptr(N-1)" .-> US US ==>|"sum all N slots"| AGG["global value of K"]
The per-CPU map data path. What it shows: for a single logical key K, the kernel allocates one value slot per logical CPU; each BPF program touches only its own CPU’s slot through this_cpu_ptr, so writes never contend; the userspace reader collects all N slots with per_cpu_ptr and sums them. The insight to take: the contention isn’t reduced, it is structurally eliminated on the write path — there is no shared cache line to fight over — and the only cost is a once-per-read reduction across N slots. This is the same “shard the hot counter per core, sum on read” idea the kernel uses for its own statistics; per-CPU BPF maps expose it to BPF programs.
2. The Four Per-CPU Map Types
The per-CPU family is the per-CPU variant of three base map types plus a cgroup-local-storage variant, all defined in the bpf_map_type enum in include/uapi/linux/bpf.h:
BPF_MAP_TYPE_PERCPU_HASH(introduced kernel 4.6, permap_hash.rst) — a general-purpose hash map where each key maps to a per-CPU value. Keys and values may be structs (composite keys are allowed). The documentation states plainly: “BPF_MAP_TYPE_PERCPU_HASHprovides a separate value slot per CPU. The per-cpu values are stored internally in an array.” Use when the set of keys is dynamic (e.g., per-source-IP counters) and you want per-CPU contention-free updates.BPF_MAP_TYPE_PERCPU_ARRAY(introduced 4.6, permap_array.rst) — a fixed-size array (key is au32index,max_entriesset at creation) where each index has a per-CPU value. “BPF_MAP_TYPE_PERCPU_ARRAYuses a different memory region for each CPU.” This is the workhorse for a small, fixed set of counters/histogram buckets — the lowest-overhead per-CPU map because the index lookup is a bounds-checked array offset, no hashing.BPF_MAP_TYPE_LRU_PERCPU_HASH(introduced 4.10) — a per-CPU hash that also self-evicts least-recently-used entries when full (see LRU and LPM-Trie Maps for the eviction algorithm). It combines per-CPU value slots with a bounded-memory hash. The LRU bookkeeping can itself be made per-CPU with theBPF_F_NO_COMMON_LRUflag, fully decoupling cores.BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE— per-CPU storage attached to a cgroup, used by cgroup-attached BPF programs (e.g.,BPF_PROG_TYPE_CGROUP_SKB). The key is astruct bpf_cgroup_storage_key; the value has one slot per CPU. Perlocal_storage.c, userspace reads it viabpf_percpu_cgroup_storage_copy(), which loops over every CPU exactly like the array variant. As of v6.12 this type is deprecated — thebpf_map_typeenum ininclude/uapi/linux/bpf.hdefines it asBPF_MAP_TYPE_PERCPU_CGROUP_STORAGE = BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE_DEPRECATEDwith the comment that “the new mechanism (BPF_MAP_TYPE_CGRP_STORAGE+ local percpu kptr) supports allBPF_MAP_TYPE_PERCPU_CGROUP_STORAGEfunctionality and more.” New code should preferBPF_MAP_TYPE_CGRP_STORAGE; this entry remains for understanding the per-CPU pattern, which is identical to the array/hash variants.
Uncertain
Verify: the exact introducing kernel version for
BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE. Reason: the v6.12map_*.rstdocs give explicit “introduced in version X” notes for HASH/ARRAY/LRU variants, but no equivalent dated note for the per-CPU cgroup-storage variant was found in the consulted sources. Its existence and deprecation in v6.12 are verified against the uapi enum; only the original introduction date is unconfirmed (commonly cited as kernel 4.20). To resolve:git log -- kernel/bpf/local_storage.cfor the commit addingBPF_MAP_TYPE_PERCPU_CGROUP_STORAGE. uncertain
3. Mechanical Walk-through — How the Two Sides See the Map Differently
The single most important and most misunderstood fact about per-CPU maps is that the BPF side and the userspace side see fundamentally different objects for the same key. Trace it through the v6.12 source for the array variant, which is the cleanest.
3a. BPF side: this_cpu_ptr returns your slot, no lock
When a BPF program calls bpf_map_lookup_elem(&map, &key) on a per-CPU array, the verifier/dispatcher routes to percpu_array_map_lookup_elem() (arraymap.c L241–250):
static void *percpu_array_map_lookup_elem(struct bpf_map *map, void *key)
{
struct bpf_array *array = container_of(map, struct bpf_array, map);
u32 index = *(u32 *)key;
if (unlikely(index >= array->map.max_entries))
return NULL;
return this_cpu_ptr(array->pptrs[index & array->index_mask]);
}Line-by-line: array->pptrs[index] is a void __percpu * — a per-CPU pointer, an offset into the per-CPU allocation area, not a normal pointer you can dereference. this_cpu_ptr() is the per-CPU subsystem macro that adds the current CPU’s per-CPU offset to that base, yielding a pointer to this CPU’s copy of the value. (index & array->index_mask is a Spectre-v1 hardening mask that keeps a speculatively-out-of-bounds index inside the array; the prior if already rejected genuinely out-of-bounds indices.) The returned pointer is to memory that only this CPU touches, so the BPF program can do an ordinary non-atomic read-modify-write on it — increment a counter, add to a sum — with zero synchronization. There is no spinlock taken, no atomic instruction required, no cache line shared with another core. On a hot path that is the entire point.
For per-CPU hash maps the same principle holds — the documentation states “the bpf_map_update_elem() and bpf_map_lookup_elem() helpers automatically access the hash slot for the current CPU” (map_hash.rst) — though the hash map still takes a per-bucket lock for structural operations (inserting/deleting the key element itself), the per-CPU value it returns is private. On many JIT’d architectures the per-CPU array lookup is even inlined directly into BPF bytecode by percpu_array_map_gen_lookup() (arraymap.c L253) using the BPF_MOV64_PERCPU_REG instruction, eliminating even the helper-call overhead.
3b. Userspace side: a lookup returns an array of all CPUs’ values
When userspace does bpf_map_lookup_elem(map_fd, &key, values) on the same per-CPU array, it does not get one value — it gets all of them. The kernel routes the syscall to bpf_percpu_array_copy() (arraymap.c L298–323):
int bpf_percpu_array_copy(struct bpf_map *map, void *key, void *value)
{
...
size = array->elem_size;
rcu_read_lock();
pptr = array->pptrs[index & array->index_mask];
for_each_possible_cpu(cpu) {
copy_map_value_long(map, value + off, per_cpu_ptr(pptr, cpu));
check_and_init_map_value(map, value + off);
off += size;
}
rcu_read_unlock();
return 0;
}The loop for_each_possible_cpu(cpu) walks every possible CPU (not just online ones — see §6) and copies each one’s slot, using per_cpu_ptr(pptr, cpu) (the explicit-CPU sibling of this_cpu_ptr), into successive elem_size-aligned regions of the userspace buffer. So the buffer value must be sized num_possible_cpus() * round_up(value_size, 8) bytes — an array of per-CPU values, laid out CPU 0, CPU 1, …, CPU N-1. This is the load-bearing API contract: userspace must allocate libbpf_num_possible_cpus() value slots, not one. The map_array.rst documentation’s “Semantics” section states it directly: “when accessing a BPF_MAP_TYPE_PERCPU_ARRAY in userspace, each value is an array with ncpus elements.”
3c. The aggregate-in-userspace pattern
Because the kernel hands back all N slots, userspace performs the reduction. For a counter that BPF programs only ever increment, the global value is the sum across CPUs:
__u64 total = 0;
for (int cpu = 0; cpu < ncpus; cpu++)
total += values[cpu]; /* values[] filled by bpf_map_lookup_elem */This is the aggregate-in-userspace pattern, and it generalizes: sum for counters, sum then divide for averages, take the max/min for high-water-marks, or for a histogram, sum each bucket across CPUs. The reduction runs once per scrape (e.g., once a second when Prometheus pulls), so its O(N) cost is negligible against the millions of contention-free per-CPU increments that happened in between. There is one subtlety: because each CPU’s read is taken at a slightly different instant and other CPUs keep incrementing during the loop, the summed total is eventually-consistent, not a perfect atomic snapshot. For monotonic counters this is harmless (the sum is always a valid value the counter passed through); for non-monotonic aggregates it can momentarily over- or under-count by one in-flight event.
4. Worked Example — A Contention-Free Packet Counter
A complete, realistic per-CPU array counter, BPF side and userspace side.
/* counter.bpf.c — count packets per protocol, contention-free */
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
struct {
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY); /* per-CPU: one slot per CPU per index */
__type(key, __u32); /* index = IP protocol number */
__type(value, __u64); /* packet count */
__uint(max_entries, 256); /* one bucket per protocol byte */
} pkt_count SEC(".maps");
SEC("xdp")
int count_pkts(struct xdp_md *ctx)
{
__u32 proto = /* ... parse IP protocol from ctx ... */ 6; /* e.g. TCP */
__u64 *cnt = bpf_map_lookup_elem(&pkt_count, &proto);
if (cnt)
(*cnt)++; /* plain ++ — this is OUR cpu's slot, no atomic needed */
return XDP_PASS;
}Line commentary: the map type is PERCPU_ARRAY, so each of the 256 indices has num_possible_cpus() backing slots. In the program, bpf_map_lookup_elem returns this CPU’s __u64 for index proto (via this_cpu_ptr as shown in §3a). The increment is a plain (*cnt)++ — crucially not __sync_fetch_and_add. On a shared (non-per-CPU) map you would need the atomic to avoid lost updates from concurrent cores; on a per-CPU map the atomic is pure waste because no other core can touch this slot. Eliminating that atomic is a measurable win in the XDP fast path.
Userspace reads and sums:
/* userspace reader */
int ncpus = libbpf_num_possible_cpus(); /* NOT get_nprocs() — see §6 */
__u64 *values = calloc(ncpus, sizeof(__u64)); /* one slot per possible CPU */
for (__u32 proto = 0; proto < 256; proto++) {
if (bpf_map_lookup_elem(map_fd, &proto, values) != 0)
continue;
__u64 total = 0;
for (int cpu = 0; cpu < ncpus; cpu++)
total += values[cpu]; /* reduce across CPUs */
if (total)
printf("proto %u: %llu packets\n", proto, total);
}Line commentary: values is sized ncpus __u64s because the kernel writes one per CPU (§3b). A single bpf_map_lookup_elem for key proto fills the whole array; the inner loop sums it. Note libbpf_num_possible_cpus() rather than get_nprocs() — using the online count instead of the possible count is the classic per-CPU map bug (§6). Contrast this whole flow with hash maps, where a single lookup returns one value and concurrent BPF writers must use __sync_fetch_and_add to avoid lost updates.
5. Why Per-CPU Maps Are the Canonical Metrics Primitive
Metrics and counters have a distinctive access pattern: writes are enormously more frequent than reads. A packet counter is incremented on every packet (millions/sec) but read once per scrape interval (once/sec). A per-CPU map is optimal precisely for this shape: it makes the frequent operation (the write) free of contention and pushes all the cost onto the rare operation (the read, which must aggregate). Any tool that counts events at kernel speed — bpftrace’s @[key] = count() aggregations, BCC’s BPF_HISTOGRAM/BPF_PERCPU_ARRAY, Cilium’s per-endpoint counters, network-observability exporters — uses per-CPU maps under the hood for exactly this reason. The alternative — a single shared counter guarded by __sync_fetch_and_add — works correctly but serializes every core through one cache line, collapsing throughput on many-core machines under load. Per-CPU is the difference between a counter that scales linearly with cores and one that gets slower as you add cores.
It is worth being precise about what per-CPU buys and what it does not. It buys contention-free writes and cache-line isolation. It does not buy a globally consistent instantaneous read (the sum is eventually-consistent), and it costs N× the memory of a shared map (every key is replicated per CPU), which on a 256-CPU machine with large values can be substantial — hence the per-CPU value size is bounded by PCPU_MIN_UNIT_SIZE and the kernel rejects oversized per-CPU values at map-creation time (arraymap.c L77, hashtab.c L466).
6. Failure Modes and Common Misunderstandings
Allocating one value slot in userspace instead of N. The single most common bug. bpf_map_lookup_elem on a per-CPU map writes num_possible_cpus() values into your buffer; if you sized it for one, the kernel scribbles past the end and you get heap corruption. Always allocate libbpf_num_possible_cpus() * round_up(value_size, 8) bytes.
Confusing possible CPUs with online CPUs. The kernel’s for_each_possible_cpu (§3b) iterates over every CPU the kernel could bring online — controlled by the possible CPU mask, which on many systems (especially VMs and machines with CPU hotplug or a possible_cpus= boot parameter) is larger than the number currently online. If userspace sizes its buffer with sysconf(_SC_NPROCESSORS_ONLN) (online) instead of libbpf_num_possible_cpus() (possible), the buffer is too small and the kernel overruns it. This is a real, frequently-hit footgun; libbpf provides libbpf_num_possible_cpus() specifically to get this right by parsing /sys/devices/system/cpu/possible.
Forgetting that the BPF-side value is non-atomic on purpose. Some developers reflexively wrap per-CPU increments in __sync_fetch_and_add, “to be safe.” It is not unsafe to do so, but it is wasted work — the per-CPU slot has no concurrent writers from other cores. The one genuine exception is re-entrancy on the same CPU: if your BPF program can be interrupted by another BPF program (e.g., a tracing program firing inside the same code path) that touches the same per-CPU slot, you can lose an update. In practice this is rare and per-CPU maps treat it as acceptable, but it is the reason per-CPU counters are described as “almost lock-free” rather than perfectly so.
Expecting a consistent snapshot. The userspace sum is taken slot-by-slot while other CPUs keep writing, so it is eventually-consistent, not a frozen instant. For monotonic counters this is invisible; for a metric where you compute a derived value (e.g., a ratio of two per-CPU sums read at different instants) it can produce a transiently impossible value. Read both maps in one tight loop and accept the tiny skew, or design the metric to be robust to it.
BPF_NOEXIST is rejected on per-CPU array updates. The map_array.rst documentation notes that when calling bpf_map_update_elem() on a per-CPU array, BPF_NOEXIST “can not be used” — array slots all pre-exist, so “create only if absent” is meaningless and returns -EEXIST.
7. Alternatives and When to Choose Them
- Shared (non-per-CPU) array maps — choose when reads need a single consistent value, when memory is tight (no N× replication), or when writes are rare enough that contention doesn’t matter. A shared map with
__sync_fetch_and_addis simpler to read (one lookup, no aggregation) but bottlenecks under high write rates. bpf_spin_lockon a shared map — since kernel 5.1, BPF providesstruct bpf_spin_lockto guard shared map values (map_hash.rst). Use when you need a multi-field atomic update (e.g., update count and timestamp together) that an atomic add can’t express — but accept the cross-core contention the lock reintroduces.- BPF ring buffer — for per-event data streamed to userspace (not aggregated in-kernel), a ring buffer is the right tool; per-CPU maps are for aggregation, the ring buffer is for event transport. They are complementary, not competing.
- Kernel-side aggregation via the
bpf_map_lookup_percpu_elem()helper — since this helper lets a BPF program read a specific other CPU’s slot (arraymap.c L284), you can aggregate inside BPF; but doing so reintroduces cross-CPU access on the hot path, defeating the purpose. The idiom remains: write per-CPU on the hot path, aggregate in userspace on the cold path.
8. Production Notes
The “shard per core, sum on read” technique long predates BPF — the Linux kernel uses per-CPU counters (percpu_counter, the this_cpu_* family) pervasively for its own statistics, and the per-CPU page lists in the page allocator are the same idea applied to free-list batching rather than counting: each CPU keeps a private cache of free pages so allocation rarely touches the shared, lock-protected zone freelist. The contrast is instructive — per-CPU BPF maps replicate a value per CPU to make writes contention-free, whereas per-CPU page lists replicate a cache of objects per CPU to make allocation lock-free; both exploit the same hardware reality that a cache line owned by one core is far cheaper to touch than one shared across cores. eBPF simply lifts this kernel idiom into a programmable, userspace-visible map type.
In real observability stacks, the per-CPU array (fixed buckets) is preferred for histograms and small fixed counter sets because its lookup is a bare array offset with no hashing; the per-CPU hash is used when the key space is dynamic (per-flow, per-PID, per-IP). Cilium, the BCC tool suite, and bpftrace all lean on per-CPU maps for their counting/histogram primitives; the universal gotcha they all document is the possible-vs-online CPU-count buffer-sizing bug from §6.
See Also
- BPF Maps — the overview of the BPF map family and the generic key/value model
- Hash and Array Maps — the shared (non-per-CPU) base types these specialize; the contrast on locking and aggregation
- LRU and LPM-Trie Maps —
BPF_MAP_TYPE_LRU_PERCPU_HASHcombines per-CPU value slots with LRU self-eviction - Per-CPU Page Lists — the page allocator’s per-CPU design, a genuine per-CPU-design contrast (object cache vs value replication)
- BPF Ring Buffer — the complementary primitive for streaming per-event data instead of aggregating it
- Linux eBPF MOC — the parent map of content