Skip to content

Optimized :meth:.NumberLine.number_to_point #3285

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

Closed

Conversation

chopan050
Copy link
Contributor

@chopan050 chopan050 commented Jul 13, 2023

Overview: What does this pull request change?

I've made some changes to NumberLine in order to speedup its number_to_point method, which is relevant when plotting or updating many graphs in a coordinate system:

  • Changed np.vstack(alphas) to alphas.reshape(-1, 1), which removes a major bottleneck when using vectorization.
  • Recalculated the range of the portion of the line excluding the tips, to use it instead of repeatedly calling VMobject.get_start and VMobject.get_end, which are the most important bottleneck in general.
  • Added a new class UnitLinearBase in bases.py, with methods function and inverse_function that return the same value that is passed, for an extra minor speedup.

Motivation and Explanation: Why and how do your changes improve the library?

This is the code I've been using for testing:

from manim import *

class LaplaceScene(Scene):
    def construct(self):
        axes = Axes(x_range=[-1, 7], y_range=[-1, 4], x_length=16.32, y_length=10.20).add_coordinates()
        s = ValueTracker(0)
    
        def get_min_x(k):
            if k > 3:
                return np.floor(2*np.log(k/3.5)/0.5) / 2 - 0.5
            if k < 0:
                return np.floor(2*np.log(-k/0.5)/0.5) / 2 - 0.5
            return -0.5

        draw_horizontal = lambda k: axes.plot(
            lambda t: k*np.exp(-s.get_value()*t), x_range=[get_min_x(k), 6.5, 0.5],
            stroke_width=1.0, stroke_color=RED_A,
            use_vectorized=True,
        )
        horizontals = {k: draw_horizontal(k) for k in range(-15, 96)}
        del horizontals[0]

        verticals = {
            k: Line(
                axes.c2p(k, -1), axes.c2p(k, 5),
                stroke_width=1.0, stroke_color=BLUE_A,
            )
            for k in range(1, 7)
        }

        draw_func = lambda: axes.plot(
            lambda t: t*np.exp(-s.get_value()*t), x_range=[0, 6.5, 0.5],
            stroke_color=YELLOW,
            use_vectorized=True,
        )
        func = draw_func()

        self.play(
            *[Create(verticals[key]) for key in verticals],
            *[Create(horizontals[key]) for key in horizontals],
            DrawBorderThenFill(axes),
        )
        self.play(Create(func), run_time=2)

        func.add_updater(lambda plot: plot.become(draw_func()))
        for k in horizontals:
            (lambda k: horizontals[k].add_updater(
                lambda line: line.become(draw_horizontal(k))
            ))(k)

        self.play(s.animate.set_value(0.5), run_time=5)

        func.clear_updaters()
        for k in horizontals:
            horizontals[k].clear_updaters()

        self.wait()
        self.play(VGroup(axes, func, *horizontals.values(), *verticals.values()).animate.scale(0.5))
        self.wait()
        
with tempconfig({"preview": True, "quality": "medium_quality", "disable_caching": True}):
    scene = LaplaceScene()
    scene.render()

There are 109 ParametricFunctions being updated for 5 seconds, which is costly. A big portion of the runtime is spent on ParametricFunction.generate_points, and there are 3 main culprits: VMobject.make_smooth, VMobject.add_points_as_corners, and Axes.coords_to_point. I'm addressing the first 2 of them in other PRs.

imagen

In this PR I shall address Axes.coords_to_point, or more specifically NumberLine.number_to_point (the pale green block which is two blocks below Axes.coords_to_point):

imagen

Here is the original code for NumberLine.number_to_point:

def number_to_point(self, number: float | np.ndarray) -> np.ndarray:
        number = np.asarray(number)
        scalar = number.ndim == 0
        number = self.scaling.inverse_function(number)
        alphas = (number - self.x_range[0]) / (self.x_range[1] - self.x_range[0])
        alphas = float(alphas) if scalar else np.vstack(alphas)
        val = interpolate(self.get_start(), self.get_end(), alphas)
        return val

np.vstack

NOTE: this problem only happened when an array (rather than a single float) is passed to NumberLine.number_to_line.

The major bottleneck is np.vstack(alphas). What np.vstack does in this case is to transform alphas into a column, i.e. if alphas was [0.4, 0.8, 0.1], it becomes [[0.4], [0.8], [0.1]]. The thing is, np.vstack does a lot of preprocessing behind the curtains, and creates a new array to hold these values. In this case, that preprocessing is not necessary, and we don't actually need to copy the array. A simple view generated with alphas.reshape(-1, 1) is sufficient:

        alphas = float(alphas) if scalar else alphas.reshape(-1, 1)

And this bottleneck is now gone.

TipableVMobject.get_start and TipableVMobject.get_end

In the last line:

        val = interpolate(self.get_start(), self.get_end(), alphas)

there are calls to TipableVMobject's methods get_start and get_end. They check if the line has a tip (calling has_tip which does return hasattr(self, tip) and self.tip in self, which is expensive) and, if it does, call its get_start method, or else call TipableVMobject.get_start. They call VMobject.throw_error_if_no_points, which is another verification process. All of that is unnecessarily expensive for this case, where we do know the NumberLine should have points, and there should be a way to get the x range of the line whether it has tips or not without recurring to these expensive calculations, right?

Well, this issue was trickier to solve, because I couldn't just plug in self.points[0] and self.points[-1] instead of self.get_start() and self.get_end(). This is because the actual "line" in NumberLine becomes shorter when tips are added, and thus the NumberLine.x_range attribute does no longer represent the range of the line without the tips, but the range of the full line including the tips. I had to do something more.

To solve this, I overrode TipableVMobject's methods add_tip and pop_tips, where I calculated the range of the actual portion of the line excluding the tips. For this, I created new attributes for NumberLine: x_range_no_tips, x_min_no_tips and x_max_no_tips.

class NumberLine(Line):
    def __init__(self, ...):
        # ...some preprocessing...
        # turn into a NumPy array to scale by just applying the function
        self.x_range = np.array(x_range, dtype=float)
        self.x_min, self.x_max, self.x_step = scaling.function(self.x_range)
        self.x_range_no_tips = self.x_range.copy()
        self.x_min_no_tips = self.x_min
        self.x_max_no_tips = self.x_max
        # ...init some attributes...
        if self.include_tip:
            self.add_tip(
                tip_length=self.tip_height,
                tip_width=self.tip_width,
                tip_shape=tip_shape,
            )
            self.tip.set_stroke(self.stroke_color, self.stroke_width)
        # ...some more processes...

    def add_tip(
        self, tip=None, tip_shape=None, tip_length=None, tip_width=None, at_start=False
    ):
        old_start = self.points[0].copy()
        old_end = self.points[-1].copy()
        super().add_tip(tip, tip_shape, tip_length, tip_width, at_start)
        new_start = self.points[0]
        new_end = self.points[-1]

        direction = old_end - old_start
        length2 = np.dot(direction, direction)
        new_start_proportion = np.dot(new_start - old_start, direction) / length2
        new_end_proportion = np.dot(new_end - old_start, direction) / length2

        range_min, range_max, _ = self.x_range_no_tips
        diff = range_max - range_min
        self.x_range_no_tips[0] = range_min + new_start_proportion * diff
        self.x_range_no_tips[1] = range_min + new_end_proportion * diff
        self.x_min_no_tips, self.x_max_no_tips, _ = self.scaling.function(
            self.x_range_no_tips
        )

        return self

    def pop_tips(self):
        result = super().pop_tips()
        self.x_range_no_tips[:] = self.x_range
        self.x_min_no_tips = self.x_min
        self.x_max_no_tips = self.x_max
        return result

With that done, now I can just plug in self.points[0] and self.points[-1] in NumberLine.number_to_point, if I use x_range_no_tips instead of x_range:

def number_to_point(self, number: float | np.ndarray) -> np.ndarray:
        number = np.asarray(number)
        scalar = number.ndim == 0
        number = self.scaling.inverse_function(number)
        range_min, range_max, _ = self.x_range_no_tips
        alphas = (number - range_min) / (range_max - range_min)
        alphas = float(alphas) if scalar else alphas.reshape(-1, 1)
        val = interpolate(self.points[0], self.points[-1], alphas)
        return val

IMPORTANT NOTE: With those parameters, I also modified some other functions to optimize them: see point_to_number, get_unit_vector and get_unit_size.

NumberLine.scaling.inverse_function and the adding of UnitLinearBase

Finally, there's a very small portion of NumberLine.number_to_point (the small paler green block at the right of NumberLine.number_to_line) where we call NumberLine.scaling.inverse_function. Now, the default base of NumberLine is a LinearBase, whose default scale factor is 1, and its function and inverse function consist of return self.scale_factor * value and return value / self.scale_factor respectively. If the default scale factor is 1, this just returns the value itself, but multiplying and dividing by 1 still creates some small overhead.

It is truly a small detail in comparison to the previous two points, but as I wanted to optimize NumberLine.number_to_point as much as possible for my use case, I decided to create a new UnitLinearBase whose function and inverse function consist solely of returning the same value passed as parameter:

class UnitLinearBase(LinearBase):
    def __init__(self):
        super().__init__(scale_factor=1.0)

    def function(self, value: float) -> float:
        return value

    def inverse_function(self, value: float) -> float:
        return value

Then I imported it in number_line.py and used it as the default NumberLine.scaling attribute. It's around 20x faster than the original "multiply/divide by 1" approach.

Results

I managed a speedup of around 3.5x in NumberLine.number_to_line! Compare the results:

Before After
imagen imagen
imagen imagen

Links to added or changed documentation pages

Further Information and Comments

Reviewer Checklist

  • The PR title is descriptive enough for the changelog, and the PR is labeled correctly
  • If applicable: newly added non-private functions and classes have a docstring including a short summary and a PARAMETERS section
  • If applicable: newly added functions and classes are tested

@chopan050 chopan050 changed the title Optimized number to point Optimized :meth:NumberLine.number_to_point Jul 13, 2023
@chopan050
Copy link
Contributor Author

The tests have failed, but only by errors of really small orders of magnitude (around 1e-16 or less). Can these be ignored (as these errors can be negligible) or not really?

@chopan050 chopan050 changed the title Optimized :meth:NumberLine.number_to_point Optimized :meth:.NumberLine.number_to_point Jul 15, 2023
@behackl behackl self-requested a review July 16, 2023 08:22
@MrDiver
Copy link
Collaborator

MrDiver commented Aug 5, 2023

I would still suggest taking a look at the currently failing tests. There maybe is a way to make it perfectly centered again with some small nudging.

@behackl behackl added this to the v0.18.1 milestone Nov 12, 2023
@chopan050 chopan050 marked this pull request as draft December 10, 2023 23:14
@chopan050
Copy link
Contributor Author

Converted to draft while I try to figure out what to do with this. I tried some stuff, and it still doesn't pass the tests:

  • round the result returned by n2p to "fix" the floating point errors
  • reverted the calculation of endpoints when there are tips
  • reverted almost everything

and it still errors out 😅 I'll figure out later what to do with this.

@behackl behackl modified the milestones: v0.18.1, v0.19.0 Apr 23, 2024
@JasonGrace2282 JasonGrace2282 modified the milestones: v0.19.0, v0.20.0 Jul 23, 2024
@chopan050 chopan050 closed this Jun 13, 2025
@github-project-automation github-project-automation bot moved this from 🆕 New to ❌ Rejected in Dev Board Jun 13, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: Rejected
Development

Successfully merging this pull request may close these issues.

4 participants