Skip to content

yew-macro: optimise macros' memory usage #3845

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

its-the-shrimp
Copy link
Contributor

@its-the-shrimp its-the-shrimp commented Apr 19, 2025

Description

Removes most of .to_string() calls from the impl of yew-macro by utilising the Display impls of tokens & AST values, analysing them chunk by chunk instead of allocating all of them into a string

Checklist

  • I have reviewed my own code
  • I have added tests

Copy link

github-actions bot commented Apr 19, 2025

Size Comparison

examples master (KB) pull request (KB) diff (KB) diff (%)

✅ None of the examples has changed their size significantly.

Copy link

github-actions bot commented Apr 19, 2025

Visit the preview URL for this PR (updated for commit 3c7824d):

https://yew-rs-api--pr3845-remove-to-string-cal-r1zmbvp8.web.app

(expires Sat, 10 May 2025 16:44:00 GMT)

🔥 via Firebase Hosting GitHub Action 🌎

Copy link

github-actions bot commented Apr 19, 2025

Benchmark - SSR

Yew Master

Benchmark Round Min (ms) Max (ms) Mean (ms) Standard Deviation
Baseline 10 291.007 292.821 291.544 0.538
Hello World 10 484.430 491.748 487.031 2.592
Function Router 10 1632.209 1692.450 1645.706 21.764
Concurrent Task 10 1005.133 1007.540 1006.602 0.677
Many Providers 10 1095.359 1128.443 1105.349 10.547

Pull Request

Benchmark Round Min (ms) Max (ms) Mean (ms) Standard Deviation
Baseline 10 291.046 291.850 291.326 0.255
Hello World 10 485.389 505.424 488.637 5.987
Function Router 10 1655.845 1685.228 1668.093 9.869
Concurrent Task 10 1005.800 1007.458 1006.596 0.517
Many Providers 10 1114.519 1263.079 1143.475 44.655

@its-the-shrimp its-the-shrimp force-pushed the remove_to_string_calls_from_yew_macro branch 3 times, most recently from 788cc44 to 40e4caa Compare April 19, 2025 01:02
@Madoshakalaka Madoshakalaka requested a review from Copilot April 21, 2025 05:15
Copy link

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR optimizes macro memory usage in the yew-macro crate by eliminating many unnecessary allocations using the new DisplayExt trait methods. Key changes include:

  • Replacing .to_string() comparisons with non-allocating DisplayExt methods (repr_eq, repr_eq_ignore_ascii_case, etc.).
  • Removing the custom non_capitalized_ascii helper in favor of a method on Ident and similar types.
  • Updating diff files across props, html_tree, hook, and function component modules to use the new DisplayExt APIs.

Reviewed Changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated no comments.

Show a summary per file
File Description
packages/yew-macro/src/props/prop_macro.rs Added import for DisplayExt and updated associated properties checking logic.
packages/yew-macro/src/props/prop.rs Replaced .to_string() comparisons with DisplayExt's repr_eq methods.
packages/yew-macro/src/lib.rs Removed non_capitalized_ascii and added DisplayExt trait implementation.
packages/yew-macro/src/html_tree/tag.rs Updated error replacement to pass the original error instead of its string version.
packages/yew-macro/src/html_tree/mod.rs Simplified imports and replaced non_capitalized_ascii with DisplayExt methods.
packages/yew-macro/src/html_tree/lint/mod.rs Updated attribute comparisons using repr_eq_ignore_ascii_case.
packages/yew-macro/src/html_tree/html_node.rs Updated logic to use DisplayExt for literal comparisons.
packages/yew-macro/src/html_tree/html_element.rs Replaced string comparisons with DisplayExt methods and cleaned up related logic.
packages/yew-macro/src/html_tree/html_dashed_name.rs Removed redundant eq_ignore_ascii_case helper in favor of DisplayExt-based checks.
packages/yew-macro/src/html_tree/html_component.rs Added is_component_name helper using DisplayExt, and updated component name logic.
packages/yew-macro/src/hook/mod.rs Updated hook naming checks to use starts_with directly on Identifier.
packages/yew-macro/src/hook/body.rs Replaced to_string() checks with non-allocating starts_with comparisons.
packages/yew-macro/src/function_component.rs Updated attribute filtering using DisplayExt methods for consistency.
Comments suppressed due to low confidence (1)

packages/yew-macro/src/lib.rs:88

  • Consider adding unit tests for the new DisplayExt methods to verify their correctness and prevent regressions.
trait DisplayExt: Display {

Copy link
Member

@WorldSEnder WorldSEnder left a comment

Choose a reason for hiding this comment

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

If you've done any measurement on performance impact, feel free to add this to the PR, but since you already did the work, I still nevertheless think it's a nice idea that can serve some purpose. (in general more performance is left on the table in yew-macro by quoting, then parsing and quoting again into different token streams but that's a different issue).

Our CI doesn't measure anything inside the macro or during compilation, only of the resulting binaries, so will be a bit useless here for performance.

is_ide_completion: is_ide_completion(),
empty: true,
};
write!(writer, "{s}").is_ok_and(|_| !writer.empty)
Copy link
Member

Choose a reason for hiding this comment

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

This deserves a better comment and improved naming, as it is very hard to follow currently.

A name is, at the moment, assumed to be a component name if it

  • starts with an ascii uppercase letter
  • OR ide completion is enable and it contains any ascii uppercase

Both of these are rough approximations, for example I think the second condition could be changed to match a specific identifier passed via the RUST_IDE_PROC_MACRO_COMPLETION_DUMMY_IDENTIFIER env variable, and I'm not even sure how much adoption that experiment got at this point.

/// Needed to check the plentiful token-like values in the impl of the macros, which are
/// `Display`able but which either correspond to multiple source code tokens, or are themselves
/// tokens that don't provide a reference to their repr.
trait DisplayExt: Display {
Copy link
Member

@WorldSEnder WorldSEnder Apr 21, 2025

Choose a reason for hiding this comment

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

I like the idea! But alas, the current implementation is hard to read when you first see it.

Let me phrase the idea differently. We use write! to feed chunks to an accumulator instead of writing them into a new allocation via to_string. This could be more clear, in my opinion, if the implementations would go through a common method expressing this.

/// A pattern can consume successive chunks of a String to determine
// if it matches, until the end of string is reached.
trait Pattern {
    type Match; // bool for all in this PR. Could one imagine more uses?
    // Returns a Result to enable short-curcuit. Another idea would be std::ops::ControlFlow<Self::Match>
    fn feed_chunk(&mut self, chunk: &str) -> Result<(), Self::Match>;
    fn end_of_string(self) -> Self::Match;
}
// Or add this as a method to DisplayExt
fn match_repr<P: Pattern>(this: impl Display, pattern: P) -> P::Match {
    struct PatternWrite<P: Pattern> { pattern: P, r#match: Option<P::Match> }
    impl<P: Pattern> Write for PatternWrite<P> {
        fn write_str(&mut self, chunk: &str) -> std::fmt::Result {
            debug_assert!(self.r#match.is_none());
            self.pattern.feed_chunk(chunk).map_err(|m| {
                self.r#match = Some(m);
                std::fmt::Error // Short-curcuit further chunks
            })
        }
    }
    let mut writer = PatternWrite { pattern, r#match: None };
    match write!(writer, "{this}") {
        Ok(()) => writer.pattern.end_of_string(),
        Err(_) => writer.r#match.unwrap(),
    }
}

With this machinery in place, I think a lot of the checks become a lot more digestible. For example

fn split_off_start<'s>(s: &mut &'s str, mid: usize) -> Option<&'s str> {
    if mid <= s.len() {
        let start;
        (start, *s) = s.split_at(mid);
        Some(start)
    } else {
        None
    }
}

struct EqualsIgnoreAsciiCase<'src>(&'src str);
impl Pattern for EqualsIgnoreAsciiCase<'_> {
    type Match = bool;
    fn feed_chunk(&mut self, chunk: &str) -> Result<(), bool> {
        match split_off_start(&mut self.0, chunk.len()) {
            Some(start) if start.eq_ignore_ascii_case(chunk) => Ok(()),
            _ => Err(false),
        }
    }
    fn end_of_string(self) -> bool {
        self.0.is_empty()
    }
}

struct EqualsExact<'src>(&'src str);
impl Pattern for EqualsExact<'_> {
    type Match = bool;
    fn feed_chunk(&mut self, chunk: &str) -> Result<(), bool> {
        match split_off_start(&mut self.0, chunk.len()) {
            Some(start) if chunk == start => Ok(()),
            _ => Err(false),
        }
    }
    fn end_of_string(self) -> bool {
        self.0.is_empty()
    }
}
trait DisplayExt: Display {
    fn repr_eq_ignore_ascii_case(&self, pattern: &str) -> bool {
        match_repr(self, EqualsIgnoreAsciiCase(pattern))
    }
    // Etc...
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Having types & traits that exist solely to implement other traits seems like overengineering that wouldn't be easier to manage, I think with a good explanation & testing, my admittedly cryptic impl will be manageable

Copy link
Member

Choose a reason for hiding this comment

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

The main point is that the trait and function would translate between two different semantic domains that is worth the overhead.

Write returns a very generic std::fmt::Error "pretending" it failed. At a different point in the code (what follows write!() basically) that error is translated back into the intended result of returning early. So basically when reviewing I had to go jump from inside the write_str to some other point in the code and do that translation in my head. Writing it out explicitly makes it just so much easier to understand what's going on.
At the same time, the part after the write!() also handles the path when no early return happened and we had to look at all the chunks.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

But your suggested impl still has to use the overly generic std::fmt::Error, is still broken up into multiple parts, but with extra concepts on top, so the reader has to keep more in the head, not less. I think this algorithm will be hard to read anyway, because it has to account for chunked input

Copy link
Member

Choose a reason for hiding this comment

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

The different impls of Pattern do not have to keep this in mind, only the single instance of the glue code in match_repr has to.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

But they still do, your suggested Pattern trait just renames write_str to feed_chunk, but the difficulty of handling chunked input is still there

Copy link
Member

Choose a reason for hiding this comment

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

Renaming is not the part that makes it meaningful, changing the return type from Result<(), std::fmt::Error> to ControlFlow<bool> (the more appropriate type than Result the more I think about it) is. This is only 1-to-1 if the impl either only short-curcuits on true or false, not possible either. Feel free to bikeshed over the name of the method or just keep write_str if that doesn't lead to ambiguities.

Just because the transformation is simple doesn't mean it's not meaningful, and I still think the dozen lines of glue code make every impl much more approachable.

Copy link
Member

Choose a reason for hiding this comment

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

@Madoshakalaka feel free to chime in. Maybe i'm too critical or not smart enough to read the current code :p

@its-the-shrimp its-the-shrimp force-pushed the remove_to_string_calls_from_yew_macro branch from 40e4caa to 0e8a30a Compare May 2, 2025 10:32
@its-the-shrimp
Copy link
Contributor Author

its-the-shrimp commented May 2, 2025

I also snuck in 1 change from quote! {#tag_name}.span() to tag_name.span(), the former seemed utterly redundant and wasteful, but please tell me if I missed some detail

@its-the-shrimp its-the-shrimp force-pushed the remove_to_string_calls_from_yew_macro branch 2 times, most recently from 913135b to e5cc234 Compare May 3, 2025 16:40
    - Removed most of `.to_string()` calls
    - Factored out the check for a component-like name
@its-the-shrimp its-the-shrimp force-pushed the remove_to_string_calls_from_yew_macro branch from e5cc234 to 3c7824d Compare May 3, 2025 16:42
@its-the-shrimp
Copy link
Contributor Author

Documented the impl & added tests

@WorldSEnder
Copy link
Member

WorldSEnder commented May 3, 2025

Additional tests look good, thanks.

@Madoshakalaka
Copy link
Member

Madoshakalaka commented Jul 3, 2025

I did some memory benchmarks on the function_router example crate.

I picked the crate due to its most intensive usage of the macros:

➜  pwd
/home/me/yew/examples

➜  for dir in */ ; do
    func_count=$(find "$dir" -name '*.rs' -exec grep -o '#\[function_component\]' {} + | wc -l)
    html_count=$(find "$dir" -name '*.rs' -exec grep -o 'html!' {} + | wc -l)
    total=$((func_count + html_count))
    echo "$total $dir"
    done | sort -nr
57 function_router/
35 router/
20 function_memory_game/
12 immutable/
11 function_todomvc/
10 keyed_list/
10 contexts/
...

I tested the memory consumption of cargo build and cargo build --release
Beginning each commad with cargo clean, I used the following command to profile the 3b5c891 (before) and the PR itself (after)

psrecord --interval 0.1 --plot memory.png --log memory.csv --include-children -- 'cargo build'

memory.png looks like this:

memory

Then I used this to calculate the average memory consumption and the area under the memory consumption:

awk 'NR > 1 && $0 !~ /^#/ { sum += $3 } END {
    n = NR - 1
    print "Average memory (MB):", sum / n
    print "Approx integral (MB·s):", sum * 0.1
}' memory.csv

Result: Non Release mode (cago build)

before-1:
Average memory (MB): 535.41
Approx integral (MB·s): 6157.21
before-2:
Average memory (MB): 540.743
Approx integral (MB·s): 6110.4
before-3:
Average memory (MB): 548.339
Approx integral (MB·s): 6251.06
before-4
Average memory (MB): 539.587
Approx integral (MB·s): 6097.34
before-5
Average memory (MB): 536.559
Approx integral (MB·s): 6063.11
before-6
Average memory (MB): 537.228
Approx integral (MB·s): 6124.39

after-1:
Average memory (MB): 538.051
Approx integral (MB·s): 6349.0
after-2:
Average memory (MB): 527.153
Approx integral (MB·s): 6273.12
after-3:
Average memory (MB): 540.584
Approx integral (MB·s): 6162.66
after-4:
Average memory (MB): 537.961
Approx integral (MB·s): 6078.96
after-5:
Average memory (MB): 543.566
Approx integral (MB·s): 6196.64
after-6
Average memory (MB): 537.117
Approx integral (MB·s): 6069.42

one-sided t test
Average memory (MB) : t = -0.755, one-sided p = 0.2342, mean_before = 539.64, mean_after = 537.41
Integral (MB·s) : t = 1.046, one-sided p = 0.8372, mean_before = 6133.92, mean_after = 6188.30

Results: Release mode (cargo build --release)

before-1
Average memory (MB): 578.992
Approx integral (MB·s): 6484.71
before-2
Average memory (MB): 569.385
Approx integral (MB·s): 6547.92
before-3
Average memory (MB): 564.528
Approx integral (MB·s): 6604.97
before-4
Average memory (MB): 574.462
Approx integral (MB·s): 6491.42
before-5
Average memory (MB): 572.162
Approx integral (MB·s): 6408.22
before-6
Average memory (MB): 580.915
Approx integral (MB·s): 6506.24


after-1
Average memory (MB): 569
Approx integral (MB·s): 6491
after-2
Average memory (MB): 571
Approx integral (MB·s): 6682
after-3
Average memory (MB): 581
Approx integral (MB·s): 6513
after-4
Average memory (MB): 574
Approx integral (MB·s): 6492
after-5
Average memory (MB): 569
Approx integral (MB·s): 6492
after-6
Average memory (MB): 573
Approx integral (MB·s): 6483

one-sided t test
Average memory (MB) : t = -0.186, one-sided p = 0.4283, mean_before = 573.41, mean_after = 572.83
Integral (MB·s) : t = 0.440, one-sided p = 0.6652, mean_before = 6507.25, mean_after = 6525.50

Results: non-release Last 5 seconds

both results above didn't show significant memory change. I wondered if using the last 50 data (data from the last 5 seconds) help. Because the yew crates seems to compile at the end:

cargo build

...
   Compiling gloo-worker v0.5.0
   Compiling gloo-console v0.3.0
   Compiling gloo-net v0.5.0
   Compiling gloo-file v0.3.0
   Compiling gloo-history v0.2.2
   Compiling gloo v0.11.0
   Compiling yew v0.21.0 (/home/me/Projects/yew/packages/yew)
   Compiling yew-router v0.18.0 (/home/me/Projects/yew/packages/yew-router)
   Compiling function_router v0.1.0 (/home/me/Projects/yew/examples/function_router)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 16.30s

so I took the last 50 samples with

tail -n 50 memory.csv | awk '$0 !~ /^#/ { sum += $3 } END {
   print "Average memory (MB):", sum / 50
   print "Approx integral (MB·s):", sum * 0.1
}'

here are the results:

before 1
Average memory (MB): 411.926
Approx integral (MB·s): 2059.63
before 2
Average memory (MB): 415.82
Approx integral (MB·s): 2079.1
before 3
Average memory (MB): 407.386
Approx integral (MB·s): 2036.93
before 4
Average memory (MB): 425.2
Approx integral (MB·s): 2126
before 5
Average memory (MB): 406.58
Approx integral (MB·s): 2032.9
before 6
Average memory (MB): 430.978
Approx integral (MB·s): 2154.89


after 1
Average memory (MB): 433.3
Approx integral (MB·s): 2166.5
after 2
Average memory (MB): 421.763
Approx integral (MB·s): 2108.81
after 3
Average memory (MB): 418.676
Approx integral (MB·s): 2093.38
after 4
Average memory (MB): 409.603
Approx integral (MB·s): 2048.02
after 5
Average memory (MB): 418.754
Approx integral (MB·s): 2093.77
after 6
Average memory (MB): 418.521
Approx integral (MB·s): 2092.6

one-sided t test:

Average memory (MB) : t = 0.742, one-sided p = 0.7620, mean_before = 416.32, mean_after = 420.10
Integral (MB·s) : t = 0.742, one-sided p = 0.7619, mean_before = 2081.57, mean_after = 2100.51

It still doesn't seem to show a reduction in memory usage 🫠

I'm not sure if my methodology is broken, advice welcome!

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