Embedding a (binary) Tree

rutpratheep-nilpechr-P5tWtdtY2AY-unsplash_reduced.jpg

Photo by Rutpratheep Nilpechr on Unsplash

In this post I will show how to create a binary tree in Rust language, which is suitable to be used in embedded devices

Here is the journey so far; I will mention some of the concepts already described in these posts.

  1. Growing a (binary) Tree
  2. Growing a (sorting) Tree
  3. Stacking Bits
  4. Prime Time
  5. Climbing a (binary) Tree

The code for this post is here.

So far we created and explored trees which grow into the application heap memory, this allowed us to have almost arbitrary size and use the type system to guarantee consistent states

What if we cannot use the heap? This happens in some “embedded” devices, which are an important target of Rust.

Our trees will have a maximum height (or depth using mainstream jargon) and this may seem a hard limitation, but has interesting cases in machine learning.

To simulate this case we are going to add the following line at the beginning of our file

#![no_std]

This completely disable any access to the heap, as well as other libraries which may not work on bare metal platforms.

Let’s dive in.

Choosing compromises

egor-myznik-DRs9XsNlAZw-unsplash._reduced.jpg

Photo by Egor Myznik on Unsplash

When developing code we may have constraints of memory size, execution time, storage size, I/O throughput, computation cores available.

Dealing with these constraints may require to give up perfect solutions and use some approximations; choosing carefully these approximations and account for the errors we expect is a critical path of a software architecture.

Size compromise

By forcing our data structure to work in the stack we need to know its size in advance thus:

  • we may allocate more space than needed
  • we may not grow over the maximum size we define

Type compromise

A more subtle point is we may accept that types can’t fully describe our status, i.e. our data structure may allow for inconsistent states.

To deal with this point we have two tools:

  • incapsulate the inner elements of our data structure so that they can be accessed only via our interface
  • add unit tests to verify if the interface respect the “contract”

Error management compromise

What if our data structure ends up in an inconsistent state?

We may have e.g.:

  • incomplete coverage of our tests
  • hardware failure
  • software/hardare attacks (e.g. rowhammer)

If one of these or any other failure brings our data structure in an inconsistent state, we have two options:

  • panic i.e. terminate the program
  • return a Result type

The first solution is simpler but makes it impossible to understand the reasons of the failure in a post-mortem analysis, moreover if I’m developing a library I may prefer to leave the decision about how to handle the inconsistent state to the application.

Addressing a binary tree in an array

We can put a binary tree in a fixed size array if we store the data in a certain order.

Let’s take our previous example of a sorting tree and suppose to insert the following values

  1. 4
  2. 6
  3. 2
  4. 3
  5. 5
  6. 1
  7. 8

This is how our tree would look like: remember that smaller values get are placed in the right branch and higher are placed in the left branch.

post016_rust_tree.png

Now suppose to call the root #1: we are going to label all nodes with positive integers with the following rules:

  • the right node label number is twice than the parent node label number
  • the left node label number is equal twice the parent node label plus one

Here is the result

post016_rust_tree_label.png

This rule allows to map a binary tree into an array

#1 #2 #3 #4 #5 #6 #7
(4) (2) (6) (1) (3) (5) (8)

It is no coincidence that this tree has 3 “levels” and the number of values it can host is equal to N(3)=2^3-1

So using this address rule we can use an array with a fixed lenght of 2^N to host up to N levels of a binary tree. Of course we expect some cells to be empty; so we will use an array of Option<T> objects.

For simplicty we have this T type to implement Copy so we can return it by value. The height of our tree (more commonly referred as depth) will be calculated as depth=\lceil log_2(argmax_i(node(i)!=None) \rceil

In this example we decide to fix the maximum depth to 8 so our tree will be placed into an array of length = 2^8 = 256

struct STree8<T>{
    nodes: [Option<T>;256]
}

impl<T : Copy> STree8<T>{
    // create an empty tree
    fn new() -> STree8<T>{
      STree8{
          nodes: [None; 256]
      }
    }

    // calculate the tree depth
    fn depth(& self) -> u32{
        let mut result : usize = 0;
        // find the highest index of a non empty cell
        // there is no check about the array integrity here
        for (i, value) in self.nodes.into_iter().enumerate(){
            if let Some(_) = value{
                if i > result{
                    result = i;
                }
            }
        }
        // the cell 0 is always ignored with our assignment
        if result == 0 {
            return 0;
        }
        result.ilog2() + 1
    }

    // this function returns the content of a cell
    // but checks that the index is below the maximum allowed:
    // we can't afford panic in an embedded code
    // error types are explained later on
    fn peek(& self, cell: usize) -> Result<Option<T>,TreeError>{

        if cell >= 256{
            return Err(TreeError::TreeOverflowCell)
        }
        Ok(self.nodes[cell])
    }
}

With our labelling rule we can create a sorting tree provided the type T implements the Ord trait

trait SortTree<T : Ord>{
    fn insert(& mut self, value: T) -> Result<usize, & 'static str>;
}

impl<T : Ord> SortTree<T> for STree8<T>{
    fn insert(& mut self, value: T) -> Result<usize, & 'static str>{
        let mut node : usize = 1;
        loop {
            if node > 255{
                return Err("level greater than 8")
            }
            match self.nodes[node]{
                None => {
                    self.nodes[node] = Some(value);
                    return Ok(node);
                }
                Some(ref node_value) => {
                    // if we have the value in the tree already
                    // then stop
                    if value == *node_value{
                        return Ok(node);
                    }
                    // the shift 1 operation is equivalent
                    // to multiply by 2
                    node <<= 1;

                    if value > *node_value{
                        // if the value is greater than
                        // the one in the current cell
                        // go to the "left" node
                        node += 1;
                    }
                }
            }
        }
    }
}

We can now test our insert and depth methods

mod tests{
    use super::*;

    #[test]
    fn can_insert(){
        let mut tree : STree8<i64> = STree8::new();
        let test_list = [4,5,2,8,6,1];
        let mut count = 0;
        for value in test_list{
            let result = tree.insert(value);
            match result {
                Err(message) => {
                    panic!("failed insertion {}",message);
                },
                Ok(node) => {
                    assert!(node < 256);
                    count += 1;
                }
            }
        }
        assert_eq!(count,test_list.len());
        let result = tree.peek(1);
        assert_eq!(Ok(Some(4)),result);
        let result = tree.peek(2);
        assert_eq!(Ok(Some(2)),result);
        let result = tree.peek(3);
        assert_eq!(Ok(Some(5)),result);
    }

    #[test]
    fn test_depth(){
        let mut tree : STree8<i64> = STree8::new();
        assert_eq!(tree.depth(),0);
        let _ = tree.insert(4);
        assert_eq!(tree.depth(),1);
        let _ = tree.insert(5);
        assert_eq!(tree.depth(),2);
        let _ = tree.insert(2);
        assert_eq!(tree.depth(),2);
        let _ = tree.insert(8);
        assert_eq!(tree.depth(),3);
        let _ = tree.insert(6);
        assert_eq!(tree.depth(),4);
        let _ = tree.insert(1);
        assert_eq!(tree.depth(),4);
    }
}

Design a Depth First Traversal Iterator

As in Climbing a (binary) Tree post we need a stack structure to store

  • the return address
  • the node we are currently exploring

Storing the current node

In a previous post ( Stacking Bits ) I described how to create a stack of boolean using shift operators on a usize word.

it turns out that is exactly working as our address rule – and this is not a coincidence: we already saw how trees and stacks are mutually connected.

By masking the topmost bit this the state is representing the exact address od our array cell. The following methods are extracted from the extended implementation.

    pub fn size(& self) -> u32 {
        usize::BITS - usize::leading_zeros(self.stack) - 1
    }

    pub fn get_state(& self) -> usize {
        self.stack ^ (1 << self.size())
    }

by placing the binary stack code into a different file btree.rs we can access it using module commands in our main library lib.rs

mod bstack;

Storing the return address

As we cannot use a flexible data structure like Vec<T> to store the return address we may leverage the stack property to create an array to store it in the same index of each traversed cell

Thus our iterator structure looks like this:

struct STree8Iter<'a, T>{
    tree: & 'a STree8<T>,
    stack: bstack::BStack,
    addresses: [Option<Address>; 256]
}

Before implementing it we make a little dirgression about errors

Managing errors

kenny-eliason--Cmz06-0btw-unsplash_reduced.jpg

Photo by Kenny Eliason on Unsplash

We cannot use String object to represent an error value, due to our heap constraint.

As we saw that & str objects in the stack do not live enough we may choose to use constant strings which have infinite lifetime & 'static str but this has three drawbacks:

  • we cannot add dynamic information about why and how the system failed
  • this will make it more complex for the users of our library to match and handle errors
  • this may require more space than using other solutions

A common approach is to define an enum which describes the expected failure modes. As we are using another library (bstack) which has its own errors it is a common practice to create one enumeration case also including the error type from this library

#[derive(Debug, Clone, Copy, PartialEq)]
enum TreeError{
    MissingReturnAddress(usize),
    StackError(bstack::BStackError),
    IteratorCompleted,
    TreeOverflowCell
}

Rust has a very nice way to manage the error monad which include some syntax sugar like using a question mark at the end of an expression.

The std crate defines also an Error trait, which I will ignore in this specific case because:

  • in our emebedded environment may not work
  • I need to keep this post simple

To use this shortcut when we call a method from bstack library (which may return a different kind of error respect to our current signature) we need some kind of automatic translation. This can be done implementing the From trait.

In our case we will just wrap the bstack error in our TreeError variant:

impl From<bstack::BStackError> for TreeError{
    fn from(value: bstack::BStackError) -> Self {
        TreeError::StackError(value)
    }
}

This method is suitable for small applications like this one: more complex libraries are available for larger projects e.g. thiserror

Implement the Depth First Traversal Iterator

In a previous post I explained how to create an iterator for a binary tree: here we are going to implement the same sequence using our different stack structure.

Here is the address enumeration described there:

#[derive(Debug, Clone, Copy, PartialEq)]
enum Address{
    Enter,
    AfterLeft,
    ValueYielded,
    Completed
}

To make paths more explicit I decided to use an enumeration to represent the possible connections from a node:

#[derive(Debug, Clone, Copy, PartialEq)]
enum Branch{
    Left,
    Right
}

The first step is to incapsulate the push and pop calls to avoid misalignments: in this case there are two push methods

  • push_branch to describe when accessing a chidren node with a relative path (i.e. left or right) from the current
  • push_cell is used to push a node with an absolute path, usually when a parent node is pushed back into the call stack with a changed return address
impl<'a, T : Copy> STree8Iter<'a, T>{
    pub fn new(tree : & 'a STree8<T>) -> STree8Iter<'a, T>{
        let mut iterator = STree8Iter::<'a, T>{
            tree,
            stack: bstack::BStack::new(),
            addresses: [None; 256]
        };
        // prepare the stack if the tree has a root node
        if let Ok(Some(_)) = iterator.tree.peek(1) {
            // ignore errors as iterator is just created
            let _ = iterator.push_branch(Branch::Right, Address::Enter);
        }
        iterator
    }

    // relative access from the current node
    fn push_branch(& mut self, branch: Branch, address: Address) -> Result<usize, TreeError>{
        let _ = self.stack.push(branch == Branch::Right)?;
        let cell = self.stack.get_state();
        self.addresses[self.stack.get_state()] = Some(address);
        Ok(cell)
    }

    // used to push back parent nodes in the call stack
    // when we need to change their return address
    fn push_cell(& mut self, cell: usize, address: Address) -> Result<usize,TreeError>{
        let _ = self.stack.push(cell & 1 == 1)?;
        let cell = self.stack.get_state();
        self.addresses[self.stack.get_state()] = Some(address);
        Ok(cell)
    }

    fn pop(& mut self) -> Result<(usize, Address), TreeError> {
        let cell = self.stack.get_state();
        let _branch = self.stack.pop()?;
        let address = self.addresses[cell].ok_or(TreeError::MissingReturnAddress(cell))?;
        Ok((cell, address))
    }

    pub fn next_item(& mut self) -> Result<T, TreeError>{
        todo!("to be developed");
    }
}

Finally where we have the actual implementation of next_item, which works in the same way we implemented it in the heap based tree.

    pub fn next_item(& mut self) -> Result<T, TreeError>{
        while self.stack.size() > 0{
            let (cell, address) = self.pop()?;
            match address{
                Address::Enter => {
                    let left_address = cell << 1;
                    match self.tree.peek(left_address)?{
                        None => {
                            self.push_cell(cell, Address::AfterLeft)?;
                        }
                        Some(_) =>{
                            self.push_cell(cell, Address::AfterLeft)?;
                            self.push_branch(Branch::Left, Address::Enter)?;
                        }
                    }
                },
                Address::AfterLeft => {
                    self.push_cell(cell, Address::ValueYielded)?;
                    if let Some(ref result) = self.tree.peek(cell)?{
                        return Ok(*result);
                    }else{
                        return Err(TreeError::IteratorCompleted)
                    }

                },
                Address::ValueYielded => {
                    let right_address = (cell << 1) | 1;
                    match self.tree.peek(right_address)?{
                        None => {
                            self.push_cell(cell, Address::Completed)?;
                        },
                        Some(_) => {
                            self.push_cell(cell, Address::Completed)?;
                            self.push_branch(Branch::Right, Address::Enter)?;
                        }
                    }
                },
                Address::Completed =>{

                }
            }
        }
        Err(TreeError::IteratorCompleted)
    }

Debugging

We may not have a debugger easily running in a bare metal platform; moreover we have no print! macro available and also writing results on the serial connection with the host may alter the platform behavior.

You certainly noticed that the next_item implementation does not conform the iterator trait this time. Of course we can create one anyway.

impl<'a, T : Copy> Iterator for STree8Iter<'a, T>{
    type Item = T;
    fn next(& mut self) -> Option<Self::Item> {
        // WARNING
        // this implicitly discard any error
        self.next_item().ok()
    }
}

While next_node provides a rich return type explaining failures (mostly useful for debugging), this implementation removes all failure information to gain the rich Iterator echosystem: the library user is free to chose wathever is more appropriate.

A test suite is not solving all bare metal issues but may help when possible, to solve issues in a frendlier environment

#[cfg(test)]
mod tests{
    use super::*;

    #[test]
    fn can_create_iterator(){
        let mut tree : STree8<i64> = STree8::new();
        let test_list = [4,5,2,8,6,1];
        for value in test_list{
            let _result = tree.insert(value);
        }
        let mut iterator = STree8Iter::new(& tree);
        assert_eq!(iterator.stack.size(),1);
        assert_eq!(iterator.pop(),Ok((1,Address::Enter)));
    }

    #[test]
    fn can_extract_with_next_item(){
        let mut tree : STree8<i64> = STree8::new();
        let test_list = [4,5,2,8,6,1];
        for value in test_list{
            let _result = tree.insert(value);
        }
        let mut iterator = STree8Iter::new(& tree);
        let mut result = iterator.next_item();
        assert_eq!(Ok(1),result);
        result = iterator.next_item();
        assert_eq!(Ok(2),result);
    }

    #[test]
    fn sort_works(){
        let mut tree : STree8<i64> = STree8::new();
        let test_list = [4,5,2,8,6,1];
        for value in test_list{
            let _result = tree.insert(value);
        }
        let expected = [1,2,4,5,6,8];
        let mut count = 0;
        for (i,v) in tree.into_iter().enumerate(){
            assert_eq!(v,expected[i]);
            count += 1;
        }
        assert_eq!(count,test_list.len());
    }
}

Conclusions

Rust allows pretty complex abstractions to run on bare metal with very little or no runtime cost (iterators are a well known example).

marco.p.v.vezzoli

Self taught assembler programming at 11 on my C64 (1983). Never stopped since then -- always looking up for curious things in the software development, data science and AI. Linux and FOSS user since 1994. MSc in physics in 1996. Working in large semiconductor companies since 1997 (STM, Micron) developing analytics and full stack web infrastructures, microservices, ML solutions

You may also like...