Measuring an eBPF Cache Without Leaving the Kernel

snaveen3 pts0 comments

Measuring an eBPF Cache Without Leaving the Kernel :: naveen srinivasan — Write code.Loves to read<br>5 minutes

Measuring an eBPF Cache Without Leaving the Kernel<br>When testing our eBPF agent, I don’t always get the same experience as our users, especially in performance critical sections. I realize that the benchmark test suite isn’t always enough, because user&rsquo;s environments can be completely different from our benchmarks.<br>I recently implemented a perf feature, which was inode caching for file access policies, and wanted to understand how it is being used in the users&rsquo; environments based on their workloads. I wanted to proactively gather insights about the cache, including cache hits and misses, so I built metrics to measure the feature.<br>My goal was to gather eBPF metrics based on the user’s usage and quickly answer questions about why things are slow (improve MTTR). To do this, I wanted:<br>Record perf/usage counters at the kernel to show how that particular feature is being used.<br>Performance is essential, as our metrics collection will be at the kernel.So I cannot use ring buffers for sending messages from kernel to userspace for the above-mentioned counters.<br>I didn’t want any spin locks or shared maps, or even LRU caches.

I wanted metrics collection to be “on” always for obvious reasons.<br>I wanted the metrics to be a rolling window instead of a counter (more on this later).<br>The Data Structure<br>A per CPU map for storing metrics (to avoid locks on the hot path)<br>The inode_cache_stats struct,<br>And a one entry array holding the epoch.<br>The per CPU map stores the inode_cache_stats, which avoids locking and the perf penalty. But this comes with a downside: userspace has to aggregate metrics from multiple maps, as each CPU has its own map, which in turn has its own store of inode_cache_stats. I run a timer from userspace to collect and aggregate this data. I understand that when there are no locks, the scrape can land in between increments and counts will be approximate, which is fine for ratios.

One array of 300 slots per CPU<br>I store the epoch timestamp from the very first time in the array that every CPU shares, and there aren’t using any sleepable hooks lsm.s/<br>#define BUCKET_WIDTH_NS 1000000000ULL // 1 second buckets<br>#define MAX_BUCKETS 300 // 300 seconds = 5 minutes

struct inode_cache_stats {<br>__u64 abs_bucket;<br>__u64 lookups;<br>__u64 hits_no_policy;<br>__u64 hits_allow;<br>__u64 hits_deny;<br>__u64 misses_walk;<br>__u64 fills;<br>};

In inode_cache_stats, every field is a counter except abs_bucket.<br>now = bpf_ktime_get_ns()<br>abs_bucket = (now - epoch) / BUCKET_WIDTH_NS<br>slot = abs_bucket % MAX_BUCKETS

Two seconds, five minutes apart, same slot<br>The abs_bucket is an absolute bucket number since epoch, and I use it to calculate the slot. For example, if abs_bucket is either 347 or 547, it will land in slot 47.<br>Every slot in the map will be 56 bytes, and there are 300 slots per CPU. On a 16-CPU box, the whole thing is about 270 KB of kernel memory, which IMO is good for the value it adds.<br>What I am building is my own version of the RRDtool https://en.wikipedia.org/wiki/RRDtool.<br>Here is our increment counter, which increments the values in inode_cache_stats, and this will be invoked wherever the cache is being used, like fetching, hits, etc.<br>void stats_inc(u32 counter)<br>u64 now = ktime();<br>u64 abs_bucket = (now - epoch) / BUCKET_WIDTH_NS;<br>u32 slot = abs_bucket % MAX_BUCKETS;

struct inode_cache_stats *b = &stats_map[slot]; // per CPU map

if (b->abs_bucket != abs_bucket) {<br>memset(b, 0, sizeof(*b));<br>b->abs_bucket = abs_bucket;

switch (counter) {<br>case CACHE_STATS_LOOKUPS: b->lookups++; break;<br>case CACHE_STATS_HITS_NO_POLICY: b->hits_no_policy++; break;<br>case CACHE_STATS_HITS_ALLOW: b->hits_allow++; break;<br>case CACHE_STATS_HITS_DENY: b->hits_deny++; break;<br>case CACHE_STATS_MISSES_WALK: b->misses_walk++; break;<br>case CACHE_STATS_FILLS: b->fills++; break;

Results<br>Here are the results from our test suite, where I opened the same file in a loop, and the VM was doing its normal work at the same time, so not every lookup was because of our test suite.<br>With the data available as a map globally (yes, I am aware of the threat vector where anyone with escalated privileges can manipulate the map), I can scrape the map data and calculate the summary. In this example, I am using bpftool and some python scripting.<br>➜ main ✗ % sudo bpftool -j map dump id 17238 | python3 -c '<br>import json,sys<br>keys=["lookups","hits_no_policy","hits_allow","hits_deny","misses_walk","fills"]<br>tot={k:0 for k in keys}<br>for e in json.load(sys.stdin):<br>for cpu in e["values"]:<br>b=bytes(int(x,16) for x in cpu["value"])<br>for k,n in zip(keys,[int.from_bytes(b[i:i+8],"little") for i in range(8,56,8)]):<br>tot[k]+=n<br>print(json.dumps(tot, indent=2))<br>lookups=tot["lookups"]<br>hits=tot["hits_no_policy"]+tot["hits_allow"]+tot["hits_deny"]<br>print("hit_rate", round(hits/lookups, 4) if lookups else None)

Results after our test suite run.<br>"lookups": 466908,<br>"hits_no_policy": 462005,<br>"hits_allow":...

abs_bucket lookups kernel metrics inode_cache_stats __u64

Related Articles