Classless Inter-Domain Routing
(CIDR
) is a method of allocating IP addresses
. This method is used in low-level routing and other Computer Science fields, especially in the Cloud
. For example, in AWS
, you manage a VPC
and subnets
. They contain a set of IP ranges
in the form of CIDR blocks
.
As I mentioned earlier, CIDR
is used for routing. The main idea of routing is to identify the most exact CIDR block
for a given IP address
or indicate that such does not exist. This task becomes more complex when you have hundreds of thousands of CIDR blocks
.
Routers are bones of the internet. That’s why it’s crucial to deliver the fastest software possible to provide maximum performance and throughput.
In this article, I will implement the basic CIDR routing table
in Rust
, optimize the routing algorithm, and benchmark different solutions for comparison.
Project setup
First of all, we need to create a project. To do that, we use this command:
cargo new --lib cidr-routing-table
That’s it. Open the created folder and start coding.
Errors module
Let’s start our project by defining the error type. It should be located in the file called errors.rs
. There are 4 possible errors during the execution:
- CIDR string is wrong
- CIDR prefix string can’t be converted to a number
- IPv4 address string is wrong
- The prefix length is wrong
use std::{net::AddrParseError, num::ParseIntError};
#[derive(PartialEq, Eq, Debug)]
pub enum NetworkParseError {
AddrParseError(AddrParseError),
ParseIntError(ParseIntError),
CidrParseError,
NetworkLengthError,
}
Utils module
Before implementing the Routing Table
, we need to define some useful functions. Good practice in software development is to create a separate file called utils.rs
, which contains some shared code for the project. Let’s create such a class inside the src
folder and paste this code.
use crate::errors::NetworkParseError;
use std::net::Ipv4Addr;
pub const MAX_LENGTH: u8 = 32;
pub fn get_cidr_mask(len: u8) -> Result<u32, NetworkParseError> {
if len > MAX_LENGTH {
Err(NetworkParseError::NetworkLengthError)
} else {
let right_len = MAX_LENGTH - len;
let all_bits = u32::MAX as u64;
let mask = (all_bits >> right_len) << right_len;
Ok(mask as u32)
}
}
pub fn cut_addr(addr: Ipv4Addr, len: u8) -> Result<Ipv4Addr, NetworkParseError> {
if len > MAX_LENGTH {
Err(NetworkParseError::NetworkLengthError)
} else {
let right_len = MAX_LENGTH - len;
let bits = u32::from(addr) as u64;
let new_bits = (bits >> right_len) << right_len;
Ok(Ipv4Addr::from(new_bits as u32))
}
}
First, I defined constant MAX_LENGTH
, which equals 32
, because IPv4 is 32
bits long. We will use it sometimes in our program.
Then, I added to functions:
get_cidr_mask
takes CIDR prefix length in range 0..32 and returns binary mask in the form....111000...
. This function should handle the wrong input when the prefix length is bigger than 32. That’s why its return type isResult<..>
.cut_addr
is another shared function. It’s used to modify the given IPv4 address by applying binary AND operation between the address and CIDR mask of lengthlen
. In the same way, this function may return an error for invalid input.
CIDR definition
After that, we should define the CIDR
structure. Rust std library includes an implementation for IPv4 address, but CIDR
is missing. You can use the one from cidr crate, but we will implement our own.
CIDR structure
CIDR
consists of two parts: IPv4 address
and prefix length
. Let’s write it in Rust code:
use std::{net::Ipv4Addr, str::FromStr};
use crate::{
errors::NetworkParseError,
utils::{get_cidr_mask, MAX_LENGTH},
};
#[derive(PartialEq, Eq, Debug, Clone, Copy, Hash)]
pub struct Ipv4Cidr {
addr: Ipv4Addr,
len: u8,
}
CIDR implementation
Let’s implement this structure. The next part contains a lot of code, but I will try to describe all the parts one by one.
new
method is used to createCIDR
from a given IPv4 address and prefix length. This method may return an error if the prefix length is longer than 32 or it’s now compatible with the IPv4 address (IPv4 address mask contains more 1s than the prefix length).new_host
returnsCIDR
from a given IPv4 address with a prefix length equal to 32.len
returns prefix length.min
returns the first available address in the block.max
returns the latest available address. It’s done by reverting all 0s to 1s in the given address skippinglen
bits from left.contains
verifies if the given address belongs to the block.
impl Ipv4Cidr {
pub fn new(addr: Ipv4Addr, len: u8) -> Result<Self, NetworkParseError> {
let mask = get_cidr_mask(len)?;
let bits = u32::from(addr);
if (bits & mask) != bits {
Err(NetworkParseError::NetworkLengthError)
} else {
Ok(Self { addr, len })
}
}
pub fn new_host(addr: Ipv4Addr) -> Self {
Self {
addr,
len: MAX_LENGTH,
}
}
pub fn len(&self) -> u8 {
self.len
}
pub fn min(&self) -> Ipv4Addr {
self.addr
}
pub fn max(&self) -> Ipv4Addr {
let bits = u32::from(self.addr);
let mask = get_cidr_mask(self.len).expect(&format!(
"{} should always be lower than or equal to 32",
self.len
));
let reversed_mask = u32::MAX ^ mask;
let max_bits = bits | reversed_mask;
Ipv4Addr::from(max_bits)
}
pub fn contains(&self, addr: Ipv4Addr) -> bool {
let lower = self.min();
let upper = self.max();
lower <= addr && addr <= upper
}
}
FromStr implementation
As an exercise, we should allow library users to convert strings to CIDR
. To do that, implement FromStr
trait for Ipv4Cidr
:
impl FromStr for Ipv4Cidr {
type Err = NetworkParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let parts: Vec<&str> = s.split('/').collect();
if parts.len() != 2 {
return Err(NetworkParseError::CidrParseError);
}
let addr = Ipv4Addr::from_str(parts[0]).map_err(NetworkParseError::AddrParseError)?;
let len = parts[1]
.parse::<u8>()
.map_err(NetworkParseError::ParseIntError)?;
Self::new(addr, len)
}
}
Routing Table Implementation
Routing Table trait
Finally, we can start implementing CIDR Routing Table trait
. Let’s make it simple and add only these methods:
add_cidr
to addCIDR Block
to the table.remove_cidr
to removeCIDR Block
from the table.find_exact_cidr
to find the most exactCIDR
for thisIpv4Addr
.
pub trait RoutingTable {
fn add_cidr(&mut self, cidr: Ipv4Cidr);
fn remove_cidr(&mut self, cidr: Ipv4Cidr);
fn find_exact_cidr(&self, addr: Ipv4Addr) -> Option<Ipv4Cidr>;
}
List Routing Table
Brute Force implementation of CIDR Routing Table
uses Vec
. add_cidr
and remove_cidr
operations are push
and retain
methods of Vec
accordingly. find_exact_cidr
method is more interesting. The idea is to iterate over all stored CIDR Blocks
and select one that contains this address and has the most extended prefix length.
#[derive(Default)]
pub struct ListRoutingTable {
cidrs: Vec<Ipv4Cidr>,
}
impl ListRoutingTable {
pub fn new() -> Self {
Self { cidrs: Vec::new() }
}
}
impl RoutingTable for ListRoutingTable {
fn add_cidr(&mut self, cidr: Ipv4Cidr) {
self.cidrs.push(cidr);
}
fn remove_cidr(&mut self, cidr: Ipv4Cidr) {
self.cidrs.retain(|cur| cur != &cidr);
}
fn find_exact_cidr(&self, addr: Ipv4Addr) -> Option<Ipv4Cidr> {
self.cidrs.iter().fold(None, |acc, cidr| {
if cidr.contains(addr) {
match acc {
None => Some(*cidr),
Some(other) if other.len() < cidr.len() => Some(*cidr),
Some(_) => acc,
}
} else {
acc
}
})
}
}
This implementation takes amortized O(1)
for add_cidr
, O(n)
for remove_cidr
and O(n)
for find_exact_cidr
. This implementation is widespread among libraries. It’s easy to implement to extend and works perfectly fine for small route tables.
Hash Routing Table
We can implement CIDR Routing Table
by using a hashmap. It will give us O(1)
for add_cidr
and remove_cidr
, but we still need to iterate over all CIDR Blocks
to find the most appropriate one.
But, instead of iterating over all blocks, we can do this:
- given
Ipv4Addr
, let’s iterate over all possibleprefix lengths
from32
to0
; - apply
cut_addr
, discussed earlier, to the address; - generate
CIDR Block
from this address andprefix length
; - if
CIDR Routing Table
contains thisCIDR Block
, then return it, else continue from step 2
#[derive(Default)]
pub struct HashRoutingTable {
cidrs: HashSet<Ipv4Cidr>,
}
impl HashRoutingTable {
pub fn new() -> Self {
Self {
cidrs: HashSet::new(),
}
}
}
impl RoutingTable for HashRoutingTable {
fn add_cidr(&mut self, cidr: Ipv4Cidr) {
self.cidrs.insert(cidr);
}
fn remove_cidr(&mut self, cidr: Ipv4Cidr) {
self.cidrs.remove(&cidr);
}
fn find_exact_cidr(&self, addr: Ipv4Addr) -> Option<Ipv4Cidr> {
for len in (0..=32).rev() {
let new_addr = cut_addr(addr, len).expect("Len is correct");
let cidr =
Ipv4Cidr::new(new_addr, len).expect("Len and Ipv4Addr should always be valid.");
if self.cidrs.contains(&cidr) {
return Some(cidr);
}
}
None
}
}
The advantage of this algorithm is time complexity, which is O(1)
. That’s a way we have a routing table that works in O(1)
for all methods. Of course, you will need to spend O(n)
to build a table with n
CIDR Blocks
, but it’s impossible to do that faster.
Compare and benchmark two approaches
Cargo.toml update
The best way to compare the performance of the two approaches is to benchmark them. In Rust, we can use the criterion
crate. Add these lines to your Cargo.toml
file:
[dev-dependencies]
criterion = "0.3"
rand = "0.8.4"
[[bench]]
name = "routing_table_bench"
harness = false
Benchmark implementation
Then you need to create a file called routing_table_bench.rs
inside the benches
folder (note, this folder is neighbor to the src
folder, not inside it).
First of all, we should create a utility function to generate Ipv4Cidr
randomly. We will use the rand
crate to generate two numbers: u32
to represent address
and u8
in range 0..=32
to represent prefix length
. Then we can create Ipv4Cidr
with this utility function:
fn generate_cidr(bits: u32, len: u8) -> Ipv4Cidr {
let mask = get_cidr_mask(len).expect("Len should be smaller than equal to 32");
let new_bits = bits & mask;
let addr = Ipv4Addr::from(new_bits);
Ipv4Cidr::new(addr, len).expect("Input is correct")
}
To implement benchmark add this code below generate_cidr
function:
fn bench_routing_table(c: &mut Criterion) {
let mut rng = rand::thread_rng();
let mut group = c.benchmark_group("CidrManager");
let sizes = [10, 100, 1000, 10000, 100000, 1000000];
for size in sizes {
let cidrs = repeat_with(|| generate_cidr(rng.gen(), rng.gen_range(0..=32))).take(size);
let mut hash_routing_table = HashRoutingTable::new();
let mut list_routing_table = ListRoutingTable::new();
for cidr in cidrs {
hash_routing_table.add_cidr(cidr);
list_routing_table.add_cidr(cidr);
}
group.bench_function(BenchmarkId::new("HashCidrManager", size), |b| {
let mut addresses = repeat_with(|| Ipv4Addr::from(rng.gen::<u32>()));
b.iter_batched(
|| addresses.next().unwrap(),
|addr| {
hash_routing_table.find_exact_cidr(addr);
},
criterion::BatchSize::SmallInput,
);
});
group.bench_function(BenchmarkId::new("ListCidrManager", size), |b| {
let mut addresses = repeat_with(|| Ipv4Addr::from(rng.gen::<u32>()));
b.iter_batched(
|| addresses.next().unwrap(),
|addr| {
list_routing_table.find_exact_cidr(addr);
},
criterion::BatchSize::SmallInput,
);
});
}
group.finish();
}
criterion_group!(benches, bench_routing_table);
criterion_main!(benches);
This code does the following:
- Create a random number generator, benchmarking group, and different routing table sizes to test.
- Iterate over different sizes
- Generate
size
differentCIDR Blocks
and append them toList
andHash
routing tables. - Benchmark these routing tables by generating random addresses and calling the
find_exact_cidr
method.
Benchmark results
To benchmark in Rust, we should type cargo bench
in the console. It will evaluate our routing tables by running several million tests, doing some maths, and storing results in HTML
for further investigation.
I received these results on my ryzen 7 5800h
(Note, it’s benchmarking only on 1 core. this table can have a much higher throughput if run on several threads).
CidrManager/HashCidrManager/10
time: [472.52 ns 473.57 ns 474.74 ns]
change: [+0.6508% +1.2357% +1.8285%] (p = 0.00 < 0.05)
Change within noise threshold.
Found 7 outliers among 100 measurements (7.00%)
6 (6.00%) high mild
1 (1.00%) high severe
CidrManager/ListCidrManager/10
time: [18.294 ns 18.348 ns 18.406 ns]
change: [+3.4341% +4.1794% +5.0281%] (p = 0.00 < 0.05)
Performance has regressed.
Found 7 outliers among 100 measurements (7.00%)
3 (3.00%) high mild
4 (4.00%) high severe
CidrManager/HashCidrManager/100
time: [449.58 ns 451.30 ns 453.26 ns]
change: [-14.021% -13.608% -13.187%] (p = 0.00 < 0.05)
Performance has improved.
Found 10 outliers among 100 measurements (10.00%)
10 (10.00%) high mild
CidrManager/ListCidrManager/100
time: [128.38 ns 128.79 ns 129.20 ns]
change: [+5.1381% +5.7605% +6.4238%] (p = 0.00 < 0.05)
Performance has regressed.
Found 6 outliers among 100 measurements (6.00%)
6 (6.00%) high mild
CidrManager/HashCidrManager/1000
time: [567.11 ns 568.42 ns 569.90 ns]
change: [+2.5129% +3.0587% +3.6174%] (p = 0.00 < 0.05)
Performance has regressed.
Found 11 outliers among 100 measurements (11.00%)
11 (11.00%) high mild
CidrManager/ListCidrManager/1000
time: [1.2644 us 1.2680 us 1.2721 us]
change: [+1.1998% +1.8870% +2.6534%] (p = 0.00 < 0.05)
Performance has regressed.
Found 15 outliers among 100 measurements (15.00%)
9 (9.00%) high mild
6 (6.00%) high severe
CidrManager/HashCidrManager/10000
time: [348.91 ns 350.75 ns 352.68 ns]
change: [+0.3763% +1.2080% +2.1543%] (p = 0.01 < 0.05)
Change within noise threshold.
Found 4 outliers among 100 measurements (4.00%)
3 (3.00%) high mild
1 (1.00%) high severe
CidrManager/ListCidrManager/10000
time: [25.319 us 25.379 us 25.442 us]
change: [-1.5982% -0.9720% -0.3965%] (p = 0.00 < 0.05)
Change within noise threshold.
Found 9 outliers among 100 measurements (9.00%)
1 (1.00%) low mild
3 (3.00%) high mild
5 (5.00%) high severe
CidrManager/HashCidrManager/100000
time: [347.88 ns 351.19 ns 354.72 ns]
Found 9 outliers among 100 measurements (9.00%)
6 (6.00%) high mild
3 (3.00%) high severe
CidrManager/ListCidrManager/100000
time: [265.57 us 266.80 us 268.05 us]
Found 5 outliers among 100 measurements (5.00%)
1 (1.00%) low mild
2 (2.00%) high mild
2 (2.00%) high severe
CidrManager/HashCidrManager/1000000
time: [478.77 ns 490.45 ns 502.73 ns]
change: [-7.5643% -1.9065% +3.8173%] (p = 0.53 > 0.05)
No change in performance detected.
Found 8 outliers among 100 measurements (8.00%)
3 (3.00%) high mild
5 (5.00%) high severe
CidrManager/ListCidrManager/1000000
time: [2.5858 ms 2.6177 ms 2.6495 ms]
change: [-1.3429% +0.3593% +2.0520%] (p = 0.68 > 0.05)
No change in performance detected.
So, finding an address in a routing table with 1 million CIDR Blocks
will take an average of 512 ns
in HashRoutingTable
and 2.65 ms
in ListRoutingTable
. The difference is enormous!
This chart shows that ListRoutingTable
is linearly growing with the number of CIDR Blocks
, while HashRoutingTable
is constant every time. Below is the same chart, but with logarithmic scaling. You can see that List
implementation is faster for tables with lower than 1000 CIDR Blocks
, but it’s much slower after that.
Summary
This article built a CIDR Routing Table
from scratch and implemented two different approaches: ListRoutingTable
and HashRoutingTable
. While the former is easy to implement and extend, it’s much slower than the latter one when the number of CIDR Blocks
is bigger than 1000
.
You can view the final code in my public repo.