Skip to content

Conversation

tanculau
Copy link

@tanculau tanculau commented Mar 26, 2025

This change reduced around ~40-50% of allocations for intentrace.

Changed the implementation to write into the given buffer instead of allocating an own buffer.
If you do not use padding, Display should not allocate anymore.

I think the test should pass, but I'm not familiar with the test setup, so check again please

Pro:

  • a lot less allocations

Con:

  • At the moment I am not handling padding, for this, I still allocate one String and then use the pad method.
  • Code complexity: Using HelperStructs + duplicate code for colors, to not break semver

Allocations bench-marked with'valgrind --tool=dhat intentrace -q ls Allocations went from 296,761 bytes to 164,552 bytes
Notice that the benchmark is just here to give an idea, not to be reproducible or credible

TODOS:

  • Handle padding
  • Check if all tests pass

@spenserblack
Copy link
Collaborator

spenserblack commented Mar 26, 2025

Reducing unnecessary allocations sounds good. I'll review this later.

You may want to check out the owo-color crate, which IIRC advertises itself as being allocation-free. It might be a better choice for intentrance depending on priorities.

@tanculau
Copy link
Author

I'll review this later.

I would wait until my next commit, I think I have an idea how to do the padding without allocating, but I have to rework my solution for this quite a bit

@tanculau
Copy link
Author

tanculau commented Mar 27, 2025

Added now padding without alloc

Pro:

  • ColoredString::fmt should not allocate anymore

Con:

  • Added code complexity

    • Manual padding
    • Manual advance_by implementation, because not stable
    • Manual CharIndeces::offset, because MSRV 1.80 and the function is 1.82
    • Added Color::to_*_fmt function
    • Use of help structs instead of methods

    Should be ready for review now

@tanculau
Copy link
Author

tanculau commented Mar 27, 2025

Notice, this will break Allow colorizing any type implementing a formatter trait

The reason is, that at leastAsRef<str> is needed for this implementation.
I looked up the implementation of owo-colors.
It seems that they do not escape inner reset sequences.

If the input would be generic:
So either we have to allocate and escape inner reset sequence or we limit it to AsRef<str>.
(Also we could use specialization, but this feature is still unstable and has a looong way to go.)

@tanculau
Copy link
Author

Also, since your goal is not to be alloc free, you could just call T::to_string() and then format it
In this code you could just write let mut input = self.input.to_string(); and you could color any type with display and 1 allocation

@tanculau tanculau marked this pull request as ready for review March 27, 2025 18:50
Copy link
Collaborator

@spenserblack spenserblack left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the wait! This is a pretty big PR, so I'll have to review in more detail later. These are just some simple things that stood out to me.

src/color.rs Outdated
Comment on lines 78 to 106
const fn to_bg_static_str(self) -> Result<&'static str, (u8, u8, u8)> {
match self {
Self::Black => Ok("40"),
Self::Red => Ok("41"),
Self::Green => Ok("42"),
Self::Yellow => Ok("43"),
Self::Blue => Ok("44"),
Self::Magenta => Ok("45"),
Self::Cyan => Ok("46"),
Self::White => Ok("47"),
Self::BrightBlack => Ok("100"),
Self::BrightRed => Ok("101"),
Self::BrightGreen => Ok("102"),
Self::BrightYellow => Ok("103"),
Self::BrightBlue => Ok("104"),
Self::BrightMagenta => Ok("105"),
Self::BrightCyan => Ok("106"),
Self::BrightWhite => Ok("107"),
Self::TrueColor { r, g, b } => Err((r, g, b)),
}
}

pub(crate) fn to_bg_fmt(self, f: &mut core::fmt::Formatter) -> Result<(), core::fmt::Error> {
match self.to_bg_static_str() {
Ok(s) => f.write_str(s),
Err((r, g, b)) if !truecolor_support() => Self::TrueColor { r, g, b }
.closest_color_euclidean()
.to_fg_fmt(f),
Err((r, g, b)) => write!(f, "48;2;{r};{g};{b}"),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like you used Result as a clever way to "unwrap" the truecolor if it is one. Here's how I'm thinking it should be implemented instead, for clarity.

// Note that we're now borrowing, since we don't have a reason to take ownership and force a copy
const fn to_bg_static_str(&self) -> &str {
    Self::Black => "40",
    // Implement the other simple colors...
    // Panicking is OK because this is internal
    Self::TrueColor { r, g, b } => unreachable!("Shouldn't be called on True Color"),
}

pub(crate) fn to_bg_fmt(&self, f: &mut core::fmt::Formatter) -> Result<(), core::fmt::Error> {
    if let Self::TrueColor { r, g, b } = self {
        // Check for support, write value
        // Can possibly be split into a "write_true_color()"
        return Ok(());
    }
    // If we're here, we know the color can be converted to a static string
    f.write_str(self.to_bg_static_str())
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My intent was inspired by something like tokio's send method.

I try to convert the color in a static str. If it is possible, I return it, if not, I return rgb (since the color is not statically known).
The reason, I do not need a second match or any unreachable. I personally dislike the use of unreachable if it can be avoided easily.

Also, for borrowing the color, this is an internal api (so no stability needed) and Color is smaller than a pointer. So it is cheaper to hand over the color instead of an pointer. See this clippy::trivially_copy_pass_by_ref

@tanculau
Copy link
Author

tanculau commented Apr 7, 2025

Sorry for the wait! This is a pretty big PR, so I'll have to review in more detail later. These are just some simple things that stood out to me.

No problem, it is quite a big change with a lot of logic (and possible logical errors). I recommend to review the changes when the ci run through, because the changed code was also tested with the old tests.

There is still a allocation in Style. I have a fix for it, but and also added more tests. But for testing, I would like to add itertools as a dev dependency. Am I allowed to do that? If not I can just revert the commit

@Asthowen
Copy link
Contributor

Asthowen commented Jun 6, 2025

Hello @spenserblack,

Sorry for the ping 😅 . I tested this PR which seems to work well, would it be possible for you to look into potentially merge it and make a new release?

@tanculau
Copy link
Author

tanculau commented Jun 8, 2025

I've run fuzzing with two test setups: the first with 30,283,522,069 iterations, and the second — a more sophisticated version — with 8,031,822,837 iterations.

In both cases, I tested the colored_original (current commit, 68761c1) and colored_new (this PR, 2497bd0).

The only changes made were the removal of #[non_exhaustive] on ColoredString and making Style public to allow external construction — everything else remained unchanged.

No issues were found during fuzzing.

The only open question was my 'clever' way to unwrap truecolor. But I also added an own Error type (NotStaticColor) for this case, since AnsiColor was also added. So I think this should be fine, and in my opinion better than using unreachable!, which could theoretically panic.

@spenserblack
Copy link
Collaborator

Sorry all, I've been inactive for a while. I'll take another look at this.
TBH I'm always hesitant to merge large changes for projects I haven't authored until I've very carefully reviewed. You could say I'm more careful with others' projects than with my own 😅 Unfortunately, this results in me procrastinating on reviewing PRs 🙇

src/color.rs Outdated
/// # Errors
///
/// If the color is a `TrueColor` or `AnsiColor`, it will return [`NotStaticColor`] as an Error
const fn to_fg_static_str(self) -> Result<&'static str, NotStaticColor> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this would be cleaner if it returned an Option.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason I want to do it with a result and a helper struct is to future proof the code.
While doing this PR, a new type of color was added. With an Option, the match would be something like

match self.to_fg_static_str() {
  Some(s) => s,
  None => match self {
                      Self::TrueColor => ...,
                      Self::AnsiColor => ...,
                      _ => unreachable!(),
                      }
}

If you now add a new color, this would panic. With my variant, the compiler would just point out, that you do not cover all cases.

Also the Result is used as intended. Try to transform the color to a static str, if not possible, return Error.
Here, it is a ComplexColor that cannot be transformed.

If you still want it to be a option, I can do it. Just wanted to explain why I did not do it with an Option

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nvm, just changed it, since it is the second time you asked for it, I also think the solution should be okay.

src/color.rs Outdated
Comment on lines 74 to 81
Ok(s) => f.write_str(s),
Err(NotStaticColor::TrueColor { r, g, b }) if !truecolor_support() => {
Self::TrueColor { r, g, b }
.closest_color_euclidean()
.to_fg_fmt(f)
}
Err(NotStaticColor::TrueColor { r, g, b }) => write!(f, "38;2;{r};{g};{b}"),
Err(NotStaticColor::AnsiColor(code)) => write!(f, "38;5;{code}"),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to my comment about using an Option instead of a Result:

We can just use the variant of self if .to_fg_static_str returns None. I don't think we need to define a separate type (NotStaticColor) just to narrow down variants.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

src/color.rs Outdated
Comment on lines 185 to 194
fn distance(a: u8, b: u8) -> u32 {
u32::from(a.abs_diff(b)).pow(2)
}
let distance = distance(r, r1) + distance(g, g1) + distance(b, b1);
(c_original, distance)
} else {
unimplemented!("{:?} not a TrueColor", c)
}
});
distances.min_by(|(_, d1), (_, d2)| d1.cmp(d2)).unwrap().0
distances.min_by_key(|(_, distance)| *distance).unwrap().0
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a nice way to clean up the code, but, TBH, I find this unrelated to the intent of this PR (allocating less). Unless this refactor somehow helps with this PR, I'd rather it be in a separate PR. I prefer to keep unrelated changes out of PRs so that they're more reviewable. Let me know if I'm wrong, and if you feel this belongs in this PR.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the change.
I just added it, since I was already refactoring the code.

Comment on lines +329 to +331
#[test]
/// Test that `fmt` and `to_str` are the same
fn fmt_and_to_str_same() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this test touches on something interesting. the to_*_fmt and to_*_str copy a lot of the same logic.
You've defined wrappers the implement Display here, but, TBH, I think this is a sign to de-duplicate the logic between the format and string methods. The string methods should call the format methods.

But that's probably for a separate PR. The reason I bring it up is that, if I'm reading this correctly, what this tests is "did we copy and paste the logic between the two functions?" I'm not a fan of that type of test. Rather a test like this:

#[test]
fn are_some() {
    // Do foo and bar return the same value?
    assert_eq!(x.foo(), x.bar());
}

I'd rather have tests like this:

#[test]
fn test_foo() {
    assert_eq!(x.foo(), some_expected_value);
}

#[test]
fn test_bar() {
    assert_eq!(x.bar(), some_expected_value);
}

That might look redundant, but redundancy is OK in tests IMO.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reworked the code, so that there should be as little code duplication as possible.
The problem is, that this is a public interface. As such I cannot change it.

The requirement is, that formatting and the to_str method should always have the same output.
But we cannot write a static str, such that we cannot just call formatting from to_str.
We also want to support precision and advanced formatting. So we also cannot call to_str from the formatting code.
So, if I see it correctly, we have duplicate public interfaces. To check, that both interfaces behave the same, we just call them against each other. So in the end, I only need to test one of them to make sure both work.
The test is also there to protect in the future, if a change occurs, that this requirement is not forgotten.

I'd rather have tests like this:
The problem is, i have to duplicate almost each test case. If a change occurs, the chance of forgetting to update the other one is quite high.

Another option would be to just write a doc comment and hope that everyone reads it.
I think the solution right now should be okay.

src/lib.rs Outdated
Comment on lines 519 to 525
struct EscapeInnerResetSequencesHelper<'a> {
input: &'a str,
fgcolor: Option<Color>,
bgcolor: Option<Color>,
style: style::Style,
is_plain: bool,
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lib.rs is getting pretty big. Can you move this to a separate module/file, e.g. helpers.rs, along with the related tests?

Also, even though these are private, and you add doc comments to these new types to describe what they're for?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants