Code size when printing floating point numbers

In an embedded project, I would like to print some f32 numbers. The way I do it now I have a lot of code in my build just to do that:

======================================== ROM =========================================
Symbol                                                                   Size    %    
======================================================================================
/                                                                        28556   90.64
  rustc/9d83ac217957eece2189eccf4a7232caec7232ee/library/core/src        22316   70.84
    fmt                                                                  19194   60.93
      float.rs                                                           14114   44.80
        core::fmt::float::float_to_decimal_common_shortest::h1e3da2...    7646   24.27
        core::fmt::float::float_to_decimal_common_exact::h20c31c523...    6434   20.42
        core::fmt::float::<impl core::fmt::Display for f32>::fmt::h...      34    0.11
      mod.rs                                                              4834   15.34
        <&T as core::fmt::Display>::fmt::hb8ff07517f36e48d                1738    5.52
        core::fmt::Formatter::write_formatted_parts::h9ff817f67e440ce9     680    2.16
        <&T as core::fmt::Debug>::fmt::hd5ef11334d94b9c4                   656    2.08
        core::fmt::Formatter::pad_integral::h78c8f775f77be6d5              414    1.31
        core::fmt::write::hb953c0c1ed7f04d2                                380    1.21
        core::fmt::Formatter::pad_formatted_parts::h43d0fd324767ebc3       364    1.16
        <&T as core::fmt::Display>::fmt::h8b94430578d58997                 236    0.75
        core::fmt::Write::write_char::hf3ebd24d878b2574                    132    0.42
        core::fmt::Write::write_char::hc24c4b48d0aaf706                    114    0.36
        core::fmt::Formatter::pad_integral::write_prefix::h1303d8f2...      70    0.22
        core::fmt::Write::write_fmt::h218dab6c88455aca                      12    0.04
        core::fmt::Write::write_fmt::h24e36af75ffeca09                      12    0.04
        <core::fmt::Arguments as core::fmt::Display>::fmt::h4f27bb3...      10    0.03
        <&T as core::fmt::Debug>::fmt::hebbdecefaed29fa9                     8    0.03
        <&T as core::fmt::Display>::fmt::hc51d50af867b00bd                   8    0.03
      num.rs                                                               246    0.78
        core::fmt::num::imp::<impl core::fmt::Display for u32>::fmt...     246    0.78

The format of the f32 I want to print is relatively simple, what I basically need is a given number of digits after the decimal (e.g. stuff like 123.45), with a given field width. So no exponents and other fancy stuff.

Of course I could write my own formatter, and the flash size of my controller currently still accomodates these near 20kB of code, but I wonder whether there is a simple way to obtain a more stripped-down code that does this simple formatting?

It may depend on what you mean by "print" and "embedded"?
I guess embedded implies #![no_std], and by print you mean getting an ASCII string or similar?
Anyways, perhaps the easiest way may be ryu. It does not seem to take much space in flash (a few hundred bytes).

If that is not an option, splitting the integer and fractional part (playground) together with itoa could work too.

5 Likes

This algorithm is really interesting!

For my case, I think I will just implement a Display trait for a derived type that includes the f32 together with a width and the number of digits to show after the decimal. That seems reasonably straightforward.

UPDATE: What I have now and works is the code below, with 532 bytes (arm thumb7 code). Since this is part of my very first Rust program (but I did lots of embedded programming in C), feedback is appreciated. I looked at the generated assembler code and saw that there are bound checks when writing to the buffer, although it is impossible to have indices that are out of bound (because of the saturating sub for pos). Is there a "trick" to make the compiler realize this and skip these tests?

(and yes, the code does not handle special values like NaN or Inf, and does not handle overflows. This is for an embedded project in which I know that what I want to print is finite and in a healthy range. Also, the devices I use for displaying handle only single-byte chars)

use core::fmt;
use core::str::from_utf8_unchecked;
use core::mem;

/// FixDec type wrapping an f32
pub struct FixDec(pub f32);

/// Helper used by Display trait
#[inline]
fn abs(x: f32) -> f32 {
    if x.is_sign_negative() {
        -x
    } else {
        x
    }
}

/// Display trait for FixDec
///
/// Examples of possible formatting options:
///
/// {:8}       -> no decimals printed, field width of 8
/// {:8.2}     -> as above, but two decimals are printed
/// {:+12.2}   -> prints sign as + or -
/// {:+08.1}   -> as above, but with leading zeroes
/// {:_^10.2}  -> centered, using _ for padding
/// {:_<10.2}  -> left-justified, using _ for padding
/// {:_>10.2}  -> right-justified, using _ for padding
/// {:>10.2}   -> as above, using space for padding
impl fmt::Display for FixDec {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        // this is the maximum output width achievable in an u32, incl. decimal and sign,
        // +2 bytes for safety
        const MAX_WIDTH: usize = 14;
        // buffer to store the result
        let mut result = [b' ' as u8; MAX_WIDTH];

        // number of digits after the decimal
        let mut decimals = f.precision().unwrap_or(0) as isize;

        // check if negative and turn f32 into u32 that includes the decimals
        let is_negative: bool = self.0.is_sign_negative();
        let mut remainder: u32 =
            (abs(self.0) * (10_u32.pow(decimals as u32) as f32) + 0.5) as u32;

        // start filling from right
        let mut pos : usize = result.len();

        loop {
            let digit: u32;
            pos = pos.saturating_sub(1);
            (remainder, digit) = (remainder / 10, remainder % 10);
            result[pos] = b'0' + (digit as u8);
            decimals -= 1;
            if decimals == 0 {
                pos = pos.saturating_sub(1);
                result[pos] = b'.';
            }
            // done when >1 digit before decimals outputted and remainder = 0
            if decimals < 0 && remainder == 0 {
                break;
            }
        }

        // add leading zeroes, but only if field width specified
        if f.sign_aware_zero_pad() {
            if let Some(mut w) = f.width() {
                // subtract what we already have
                w = w.saturating_sub(MAX_WIDTH - pos);
                if is_negative || f.sign_plus() {
                    // a sign will need to be added, reserve this space
                    w = w.saturating_sub(1);
                }
                while w > 0 {
                    pos = pos.saturating_sub(1);
                    w -= 1;
                    result[pos] = b'0';
                }
            }
        }

        // add sign if necessary
        if is_negative {
            pos = pos.saturating_sub(1);
            result[pos] = b'-';
        } else if f.sign_plus() {
            pos = pos.saturating_sub(1);
            result[pos] = b'+';
        }

        // add padding according to alignment
        let number_as_str = unsafe { from_utf8_unchecked(&result[pos..]) };
        let mut padleft : usize = 0;
        let mut padright : usize = 0;
        if let Some(w) = f.width() {
            padleft = w.saturating_sub(number_as_str.len());
            match f.align() {
                Some(fmt::Alignment::Left) => {
                    mem::swap(&mut padleft, &mut padright)
                },
                Some(fmt::Alignment::Center) => {
                    padright = padleft / 2;
                    padleft -= padright;
                },
                _ => {}
            }
        }

        // there is no write_char in core::fmt...
        // also: f.fill assumed to be ASCII, not a multi-byte utf8 char
        let fillbuf = [f.fill() as u8 & 0x7f];
        let fillch = unsafe { from_utf8_unchecked(&fillbuf) };

        // result = left_padding + number_as_str + right_padding
        while padleft > 0 {
            let _ = f.write_str(fillch);
            padleft -= 1;
        }

        let _ = f.write_str(number_as_str);

        while padright > 0 {
            let _ = f.write_str(fillch);
            padright -= 1;
        }
        Ok(())
    }
}

Yes. You can use get_unchecked_mut(): Compiler Explorer

Or you can do it in entirely safe code by replacing the loop with an iterator: Compiler Explorer

I didn't check the correctness of this code, but that's the idea.

Have you looked at lexical-write-float — Rust formatting library // Lib.rs as an alternative to core or rolling your own formatter? It has a feature flag named compact whose purpose is small code size. A lot of the time, the right answer in Rust is "use an existing crate". That's less code for you to write, test, and maintain at the cost of potential bugs and regressions that are outside of your control. (Take advantage of the lockfile to avoid breakage in dependencies!)

And with all of your code replaced by a crate (if you choose to do so) there is nothing left for me to review. But if you are interested in more feedback, feel free to share more code.

2 Likes

As a point of interest, writing floats is so expensive in code size because it's necessary to exactly and minimally reproduce decimal values like "3.1" instead of "3.10...02" or whatever; especially when exponents come into play. If you're getting sensor values in a known range you shouldn't have any of those values.

Yes, and that is why I had the idea to write a "simple" formatter. I really don't need the last digit of precision that exists in an f32, plus will not have any exponential notation.

Thanks for the hint; I had a quick look at the crate and will explore it further. I totally agree that using an existing well-tested crate is almost always the best approach.

Here, my idea also was to play around with the Display trait and see whether I could implement it. I am new to Rust and I think I can learn a lot about the language by implementing such relative manageable things. So yes, feedback is appreciated, not so much to get the thing done (it works I think) but to see how it could be done more the Rust way.

As you suggested, I implemented the loop with an iterator, and the out-of-bounds checks are now gone and the code smaller. I tried to use "enumerate()" to count how many characters already are in the buffer, which I need to know for the padding with leading zeroes and to extract the slice when the number is constructed, but this seemed overly complicated. I now count these with the additional integer n. This of course is not nice, and it would be better to use something like (sorry for the C++ way of expressing things) a std::distance from the iterator to one of the ends of the buffer. But I don't know how to express this in Rust.

impl fmt::Display for FixDec {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        const MAX_WIDTH: usize = 14;
        let mut result = [b' ' as u8; MAX_WIDTH];
        let mut n = 0;
        let mut decimals = f.precision().unwrap_or(0) as isize;
        let is_negative: bool = self.0.is_sign_negative();
        let mut remainder: u32 =
            (abs(self.0) * (10_u32.pow(decimals as u32) as f32) + 0.5) as u32;

        let mut res_iter = result.iter_mut().rev();
        if decimals > 0 {
            decimals += 1;
        }

        while let Some(ref mut r) = res_iter.next() {
            n += 1;
            decimals -= 1;
            if decimals == 0 {
                **r = b'.';
            } else {
                let digit: u32;
                (remainder, digit) = (remainder / 10, remainder % 10);
                **r = b'0' + (digit as u8);
                if decimals < 0 && remainder == 0 {
                    break;
                }
            }
        }

        if f.sign_aware_zero_pad() {
            if let Some(mut w) = f.width() {
                w = w.saturating_sub(n);
                if is_negative || f.sign_plus() {
                    w = w.saturating_sub(1);
                }
                for _ in 0..w {
                    if let Some(ref mut r) = res_iter.next() {
                        **r = b'0';
                        n += 1;
                    }
                }
            }
        }

        // add sign if necessary
        if is_negative {
            if let Some(ref mut r) = res_iter.next() {
                **r = b'-';
                n += 1;
            }
        } else if f.sign_plus() {
            if let Some(ref mut r) = res_iter.next() {
                **r = b'+';
                n += 1;
            }
        }

        // add padding according to alignment

        let number_as_str = unsafe { from_utf8_unchecked(&result[(MAX_WIDTH.saturating_sub(n))..]) };
        let mut padleft : usize = 0;
        let mut padright : usize = 0;
        if let Some(w) = f.width() {
            padleft = w.saturating_sub(number_as_str.len());
            match f.align() {
                Some(fmt::Alignment::Left) => {
                    mem::swap(&mut padleft, &mut padright)
                },
                Some(fmt::Alignment::Center) => {
                    padright = padleft / 2;
                    padleft -= padright;
                },
                _ => {}
            }
        }

        // there is no write_char in core::fmt...
        // also: f.fill assumed to be ASCII, not a multi-byte utf8 char
        let fillbuf = [f.fill() as u8 & 0x7f];
        let fillch = unsafe { from_utf8_unchecked(&fillbuf) };

        // result = left_padding + number_as_str + right_padding
        while padleft > 0 {
            let _ = f.write_str(fillch);
            padleft -= 1;
        }

        let _ = f.write_str(number_as_str);

        while padright > 0 {
            let _ = f.write_str(fillch);
            padright -= 1;
        }
        Ok(())
    }
}