Catch Performance Regressions: Evolving eBPF Program

This is the third in a five-part series. Read Part 1 and Part 2.
In this series we learned what eBPF is, the tools to work with it, why eBPF performance is so important, and how to track it with continuous benchmarking. We created a basic eBPF XDP program, line by line in Rust using Aya. In this next entry we will discuss how to evolve this basic eBPF XDP program to new feature requirements. All of the source code for the project is open source and is available on GitHub.
Programs in eBPF are in and of themselves completely stateless. Every invocation is a fresh eBPF program. To keep state, report back on its operations or change its behavior, an eBPF program needs to use “maps”. Maps are persistent data structures available to both eBPF and userspace programs. There are several different kinds of eBPF maps: arrays, hash maps, stacks, queues and many more. They are the only way to reliably communicate between an eBPF program and userspace and vice versa. In the next iteration of our eBPF XDP program, we will use maps to communicate from our eBPF program back to our userspace program.
In the next version of our application, Version 1, we will implement a “Fizz Feature.” This Fizz Feature requires:
- Push “Fizz” into the queue if the IPv4 source address is divisible by 3.
- Otherwise, just return
XDP_PASS
.
To implement this Fizz Feature, we need to create the message that will be passed inside of the queue map:
- #[repr(C)]
- #[derive(Clone, Copy)]
- #[cfg_attr(feature = “user”, derive(Debug))]
- pub enum SourceAddr {
- Fizz,
- }
- #[cfg(feature = “user”)]
- unsafe impl aya::Pod for SourceAddr {}
Going line by line:
- This macro tells the Rust compiler to represent this data just like a C program would.
- The
derive
macro automatically implements theClone
(the ability to duplicate our message) andCopy
(the ability to duplicate our message by simply copying bits). - This third macro is a bit more complex. It is conditional on whether the “user” feature is enabled. The “user” feature is only used by the userspace side of things, and it allows us to display our message type as
Debug
output. - Our message type is an enum named
SourceAddr
. Fizz
is the one and only message variant we currently have.- –
- –
- Again, we have a conditional macro. The line below is only used when the “user” feature is enabled, by userspace.
- Implement a trait required by Aya that marks
SourceAddr
as being eBPF map friendly.
With our message type created, we can now make changes to the eBPF side of things:
- #[map]
- pub static mut SOURCE_ADDR_QUEUE: Queue<SourceAddr> = Queue::with_max_entries(1024, 0);
- fn try_fun_xdp(ctx: &XdpContext) -> Result<u32, ()> {
- …
- info!(ctx, “IPv4 Source Address: {}”, source_addr);
- let opt_source_addr = (source_addr % 3 == 0).then_some(SourceAddr::Fizz);
- if let Some(source_addr) = opt_source_addr {
- unsafe {
- if let Err(e) = SOURCE_ADDR_QUEUE.push(&source_addr, 0) {
- error!(ctx, “Failed to push source address into queue: {}”, e);
- }
- }
- }
- Ok(xdp_action::XDP_PASS)
- }
Going line by line:
- This Aya macro creates an eBPF map from the line below.
- The eBPF map for our
SourceAddr
messages is going to be a queue namedSOURCE_ADDR_QUEUE
with a maximum of 1,024 entries. - –
- We are going to update our
try_fun_xdp
helper function that we [created in the previous part of the series](/2). - –
- Remove the line that simply logs the IPv4 source address.
- If the IPv4 source address is divisible by 3, then store
Some
Fizz message. Otherwise, storeNone
. - –
- If the source address message is
Some
then… - The following operations are considered
unsafe
, so we must explicitly opt in. - Try to push our Fizz message onto our
SOURCE_ADDR_QUEUE
. If this returns an error then… - Log the error along with the eBPF context.
- –
- –
- –
- –
- Just as before, return pass!
- –
Now to receive that message on the userspace side:
- #[tokio::main]
- async fn main() -> Result<(), anyhow::Error> {
- …
- spawn_agent(&mut bpf).await?;
- info!(“Waiting for Ctrl-C…”);
- signal::ctrl_c().await?;
- info!(“Exiting…”);
- Ok(())
- }
The main
function is going to stay mostly the same. Except we are going to add a spawn_agent
helper function at Line 5. We will be evolving spawn_agent
throughout the rest of this series.
- async fn spawn_agent(bpf: &mut Bpf) -> Result<(), anyhow::Error> {
- let mut xdp_map: Queue<_, SourceAddr> =
- Queue::try_from(bpf.map_mut(“SOURCE_ADDR_QUEUE”)?)?;
- loop {
- while let Ok(source_addr) = xdp_map.pop(0) {
- info!(“{:?}”, source_addr);
- }
- }
- }
Going line by line:
- The
spawn_agent
function is asynchronous, which is why it had to be.await
ed inmain
. It takes in a mutable reference to our eBPF program; it returns aResult
that is either an emptyOk
or that same flexibleErr
used inmain
. - Create a userspace side of the
SOURCE_ADDR_QUEUE
eBPF map. - –
- –
loop
over the source address queue.- Attempt to
pop
off of the source address queue. If successful… - Log the source address message, ie
Fizz
.
With that complete, we have now finished Version 1 of our application. Fizz Feature complete. Now let’s not rest on our laurels for too long. Let’s add a simple update. Maybe you can see where this is going…
The next version of our application, Version 2, we will implement a “FizzBuzz Feature.” This FizzBuzz Feature requires:
- Push “Fizz” into the queue if the IPv4 source address is divisible by 3.
- Push “Buzz” into the queue if divisible by 5.
- Push “FizzBuzz” into the queue if divisible by both 3 and 5.
- Otherwise, just return
XDP_PASS
.
Just as before, were going to start by modifying the shared message type, SourceAddr
:
- #[repr(C)]
- #[derive(Clone, Copy)]
- #[cfg_attr(feature = “user”, derive(Debug))]
- pub enum SourceAddr {
- Fizz,
- Buzz,
- FizzBuzz,
- }
- #[cfg(feature = “user”)]
- unsafe impl aya::Pod for SourceAddr {}
The only changes here are on line 6 and 7, where we added Buzz
and FizzBuzz
as potential SourceAddr
messages.
Next, we will update the eBPF XDP program. It will also stay very similar, except we’re going to implement FizzBuzz.
- fn try_fun_xdp(ctx: &XdpContext) -> Result<u32, ()> {
- …
- let opt_source_addr = (source_addr % 3 == 0).then_some(SourceAddr::Fizz);
- let opt_source_addr = match (source_addr % 3, source_addr % 5) {
- (0, 0) => Some(SourceAddr::FizzBuzz),
- (0, _) => Some(SourceAddr::Fizz),
- (_, 0) => Some(SourceAddr::Buzz),
- _ => None,
- };
- …
- Ok(xdp_action::XDP_PASS)
- }
The only changes here are removing line 3 and replacing it with lines 4 through 9. Here we see if the source address is divisible by both 3 and 5. If both, FizzBuzz
. If 3, Fizz
. If 5, Buzz
. Otherwise, None
.
There are no changes to make in the userspace program. It will just log whatever message, Fizz
, Buzz
or FizzBuzz
that is sent over to it. We are done with Version 2. Now we’re cracking the coding interview! Though we’re not quite done yet. There is one last feature we would like to add.
The next version of our application, Version 3, we will implement a “FizzBuzzFibonacci Feature.” This FizzBuzzFibonacci Feature requires:
- Push “Fizz” into the queue if the IPv4 source address is divisible by 3.
- Push “Buzz” into the queue if divisible by 5.
- Push “FizzBuzz” into the queue if divisible by both 3 and 5.
- Except if the remainder of the IPv4 source address divided by 256 is part of the fibonacci sequence, then push “Fibonacci”
- Otherwise, just return
XDP_PASS
.
Just as before, were going to start by modifying the shared message type, SourceAddr
:
- #[repr(C)]
- #[derive(Clone, Copy)]
- #[cfg_attr(feature = “user”, derive(Debug))]
- pub enum SourceAddr {
- Fizz,
- Buzz,
- FizzBuzz,
- Fibonacci,
- }
- #[cfg(feature = “user”)]
- unsafe impl aya::Pod for SourceAddr {}
The only changes made here are on line 8, where we added Fibonacci
as a potential SourceAddr
message.
Next we will update the eBPF XDP program try_fun_xdp
function:
- fn try_fun_xdp(ctx: &XdpContext) -> Result<u32, ()> {
- …
- let opt_source_addr = match (source_addr % 3, source_addr % 5) { … };
- let opt_source_addr = is_fibonacci(source_addr as u8)
- .then_some(SourceAddr::Fibonacci)
- .or(match (source_addr % 3, source_addr % 5) {
- (0, 0) => Some(SourceAddr::FizzBuzz),
- (0, _) => Some(SourceAddr::Fizz),
- (_, 0) => Some(SourceAddr::Buzz),
- _ => None,
- });
- …
- Ok(xdp_action::XDP_PASS)
- }
- fn is_fibonacci(n: u8) -> bool {
- let (mut a, mut b) = (0, 1);
- while b < n {
- let c = a + b;
- a = b;
- b = c;
- }
- b == n
- }
Instead of just doing FizzBuzz, we will implement our FizzBuzzFibonacci logic. This will entail calling a is_fibonacci
helper function. If that returns true
, then our message is Fibonacci
. Otherwise, we do the same FizzBuzz logic as before. The is_fibonacci
function calculates the Fibonacci sequence until it reaches or exceeds the argument passed in n
. It then checks if those two values are equal, indicating that the argument is indeed part of the Fibonacci sequence.
Again there are no changes to make in the userspace program. It will just log whatever message, Fizz
, Buzz
, FizzBuzz
or FizzBuzzFibonacci
that is sent over to it. We are done with Version 3. Now nothing is ever going to go wrong due to this change. Everything will be 🌈🦄🌞, I promise… until we get to our next installment in the series where production is on fire 🔥🔥🔥, and you’re reconsidering all of your life choices that have led you to this moment.