Improve speed and robustness of Mobject and OpenGLMobject add/remove operations - #4957
Improve speed and robustness of Mobject and OpenGLMobject add/remove operations#4957nikolajmunk wants to merge 15 commits into
Mobject and OpenGLMobject add/remove operations#4957Conversation
- Replace repeated membership checks with single replacement sweep - Replace unnecessary search-then-remove operations with removal with suppressed ValueErrors. - Change type hints of list_update and list_difference_update to permit passing iterables of different types
- Doesn't save much time, but it saves having to fully build the slice lists just to discard them after iteration
This makes a surprisingly big difference in benchmarks.
- Check if any mobjects were actually added to/removed from self and only assemble_family if yes
- otherwise an exception will prevent subsequent parent lists from being updated. - moved test to more appropriate file
Matches existing behavior in Mobject.
There was a problem hiding this comment.
The add_to_back method should behave the same way as add:
- Should also through a warning when trying to add a mobject multiple times.
- Should use
remove_list_redundancies(mobjects)instead oflist(dict.fromkeys(mobjects)).- Small remark: That would alter the behavior, as currently
add_to_backkeep the first occurence of duplicates in the passedmobjects.
- Small remark: That would alter the behavior, as currently
GniLudio
left a comment
There was a problem hiding this comment.
add and add_to_back should use the same strategy to handle removing already contained mobjects. Currently, they handle it that way:
# add
self.submobjects = list_update(self.submobjects, unique_mobjects)
# add_to_back
self.remove(*mobjects)
self.submobjects = unique_mobjects + self.submobjects
|
@GniLudio, re: I like the idea of throwing an error when trying to add multiple mobjects. In the case of I'm personally more inclined to leave the submobject ordering stuff as-is right now, just to keep the purpose of this PR relatively self-contained. But I think it would be a very good use of everybody's time to sit down and figure out what the "official" placement of an added submobject should be! Personally I do like the "keep-first" approach of I totally agree that it's a good idea to make the two implementations a bit more similar, though. How about something like this? def add_to_back(self, *mobjects: Mobject) -> Self:
self._assert_valid_submobjects(mobjects)
unique_mobjects = dict.fromkeys(mobjects)
if len(mobjects) != len(unique_mobjects):
logger.warning(
"Attempted adding some Mobject as a child more than once, "
"this is not possible. Repetitions are ignored.",
)
existing_mobs = self.submobjects
self.submobjects = list(unique_mobjects)
self.submobjects.extend(m for m in existing_mobs if m not in unique_mobjects)
return selfThis is roughly equivalent to |
Should be slightly faster and more in line with the rest of the add/remove implementations. Also added warning for duplicate mobjects.
| if mobject not in self.submobjects: | ||
| self.submobjects.append(mobject) | ||
| if self not in mobject.parents: | ||
| mobject.parents.append(self) |
There was a problem hiding this comment.
Arent the two following statements equivalent?
if mobject not in self.submobjects:
if self not in mobject.parents:If yes, then the inner check is superfluous.
There was a problem hiding this comment.
Also, if that's true, maybe only using the second term could be more efficient. (as mobjects typically have more submobjects than parents)
There was a problem hiding this comment.
I've had the same thought. They're not 100% equivalent - my assumption is that whoever wrote the code initially wanted to catch a case where a child was somehow added to a parent without adding the parent to its own parent-list (in which case I should maybe move the latter part out by one level to catch the reverse case?).
I decided to leave it as it originally was and maybe think about parent handling in a future PR; IMO this needs an entirely different data structure.
|
LGTM |
behackl
left a comment
There was a problem hiding this comment.
Thanks for your efforts! Overall, this looks good (and efficient!) to me; there are a few concerns i'd like to share and discuss before getting this merged:
- The new implementation of
list_difference_updateintroduces a small regression compared to the current main branch for both renderers: if the passed updater is an unhashable callable object (and thus cant be slotted into aset) an exception is raised. If we still wanted to support unhashable callables, we'd need a different approach here -- but I am not sure that we absolutely need that? - plus two additional comments concerning parent links and Mobject.add and duplicate mobjects.
Please take a look and let me know what you think! Either way, thanks again for contributing!
|
|
||
|
|
||
| def list_update(l1: Iterable[T], l2: Iterable[T]) -> list[T]: | ||
| def list_update(l1: Iterable[T], l2: Iterable[U]) -> list[T]: |
There was a problem hiding this comment.
just noticed the type here: if l1 and l2 are Iterables over T and U, respectively, then the output is like a list[T | U] or so, no?
There was a problem hiding this comment.
I guess so! The type semantics are a little iffy for this one in general, I wouldn't be opposed to just doing Iterable[object] for all of them similar to how all_elements_are_instances does it.
|
Thanks for the notes, @behackl, everything makes a lot of sense. I think it would make sense to have all insertion methods preserve the "no duplicate mobjects" invariant rather than trying to deduplicate at a later point. Good catch on unhashables in edit: some quick testing suggests that |
- OpenGLMobject.insert now checks the parent list even if the submobject was already present - Force Mobject.insert to disallow duplicate submobjects, instead the existing one is popped and reinserted. - Force OpenGLMobject.replace_submobject to disallow duplicate submobjects - instead we "move" the existing one. - Update parents of both new and old submobjects in replace_submobject - add tests for new behavior
Since input can be T and/or U, we return a list of their union. This could probably be done more elegantly.
|
Okay, I've addressed most of the feedback. After some discussion with @behackl in the Discord server, we decided it was best to add the "no duplicate submobjects" invariant to One thing is haven't touched is this problem:
I'd like a bit of input from someone else before I handle this issue. Here are the options as I see them:
Do we like any of these options? edit: changed the type hint in option 3 from |
- Use a common backing method for add, add_to_back, and insert. This combines the previous optimizations into one method. This is harder to do for OpenGLMobject, but it might be possible. - Allow use of single-mobject fast path in OpenGLMobject.add if only adding one unique mobject. - Test for add_to_back
|
OK, @GniLudio and I collabed behind the scenes to write a common backing method for It would still be nice to make some sort of decision about def list_difference_update(l1: Iterable[T], l2: Collection[U]) -> list[T]:
"""Returns a list containing all the elements of l1 not in l2.
Examples
--------
.. code-block:: pycon
>>> list_difference_update([1, 2, 3, 4], [2, 4])
[1, 3]
"""
if not isinstance(l2, (set, frozenset, dict)):
# l2 is not a set-like object, so try to convert it to a set for faster lookups
with suppress(TypeError):
l2 = set(l2)
return [e for e in l1 if e not in l2]The only downside is that the try-catch attempt to convert Any thoughts? |
Mobject and OpenGLMobject add/remove operations
| """ | ||
| return self._insert_submobjects(index, (mobject,)) | ||
|
|
||
| def _insert_submobjects(self, index: int, mobjects: Sequence[Mobject]) -> Self: |
There was a problem hiding this comment.
Is there a reason to have this as a separate method instead of putting it directly in the insert method?
There was a problem hiding this comment.
Mostly I didn't want to change the signature of insert, which currently only accepts a single mobject as input. I think it would be a great idea to replace it fully with the implementation of _insert_submobjects down the line.
There was a problem hiding this comment.
What do you think about changing the signature to this?
def insert(self, index: int, *mobjects: Mobject) -> Self:Would still break passing by keyword...
There was a problem hiding this comment.
I'm very open to it, but perhaps in a later PR which adds a temporary backwards-compatible layer and a deprecation warning for the singular mobject keyword?
| try: | ||
| old_index = self.submobjects.index(mobject) | ||
| except ValueError: # mobject isn't already present | ||
| self.submobjects.insert(norm_index, mobject) | ||
| return self | ||
|
|
||
| Highly adapted from ``Mobject.add``. | ||
| # Compensate for list shifting after popping | ||
| new_index = norm_index if norm_index < old_index else norm_index - 1 | ||
| self.submobjects.pop(old_index) | ||
| self.submobjects.insert(new_index, mobject) | ||
| return self |
There was a problem hiding this comment.
This could be a bit simplified.
with suppress(ValueError):
old_index = self.submobjects.index(mobject)
# Compensate for list shifting after popping
if old_index <= norm_index: # <= or <?
norm_index -= 1
self.submobjects.pop(old_index)
self.submobjects.insert(norm_index, mobject)
return selfThere was a problem hiding this comment.
IMO the early return in line 620 makes it more clear what is happening, even if it means a bit of duplicated code. I'm inclined to leave it alone for now.





Overview: What does this pull request change?
Adds a number of optimizations to (mostly) methods related to adding and removing submobjects in both
MobjectandOpenGLMobject.Mobjecthas been given a single backing method,_insert_submobjects(index, mobjects)which is used for theadd,add_to_back, andinsertmethods. This single method contains a fast path for single-mobject insertions, and all insertions take at mostO(len(submobjects) + len(new_mobjects))time.OpenGLMobjecthas received many of the same optimizations, though not in a single backing method because its add/remove operations are more complex thanMobject's.Mobject.insertandOpenGLMobject.replace_submobjecthave so far allowed for the insertion of duplicate submobjects. This has now been fixed, so both methods remove the existing occurrence of the inserted mobject if it already existed.list_difference_updaterather than removing them individually which would take a total ofOpenGLMobjectnow generally only performs theassemble_family()call when its submobjects list was actually modified by the operation. This is a huge performance gain in situations where updating the family is not necessary.>=3.11, expressions of the formfor a, b in zip(seq[:-1], seq[1:])have been replaced withfor a, b in itertools.pairwise(seq)to save the creation of two sliced lists.Mobject.insertMobject.add_to_backOpenGLMobject.replace_submobjectOpenGLMobjectupdates its family (or not!) as expected for various operations.list_updateto reflect thatl1andl2do not have to contain elements of the same type.As far as I can tell, with the exception of
insertandreplace_submobjects, all behavior is entirely unchanged.Motivation and Explanation: Why and how do your changes improve the library?
Many of these operations are currently of quadratic time complexity and/or perform unnecessary work. I wrote the following benchmark to compare the current and proposed implementations.
Benchmark code
As you can see, performance is particularly improved for operations where the submobject list is unchanged. This is because
assemble_familyis much, much more time consuming than the actual list operations. I think that would be a good subject for a future change :)I should note that there's a very slight decrease in performance for
Mobject.removein theremove_singleandremove_single_nonexistingcases due to the repeated catching ofValueError, but IMO this is a microscopic worsening compared to the other much larger benefits.Further Information and Comments
Reviewer Checklist