Improve speed and robustness of Mobject and OpenGLMobject add/remove operations - #4957
Conversation
- 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.
|
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!
|
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
|
I decided to handle the unhashable input problem in a separate PR (#4975), so I don't think I have anything else that I'm uncertain about. Happy to hear any remaining feedback and concerns. |
behackl
left a comment
There was a problem hiding this comment.
This is some really cool stuff here, thanks for all your work!
Should the Mobject.add_to_back docstring mention explicitly that in case of duplicate mobjects being passed, only the last is kept (also w.r.t. their passed order)? If you feel this is intuitive enough as is, I am fine with it too.
|
Should the OpenGL methods be using using an internal insert too? |
|
@behackl I think that's a good idea. I would suggest an explicit callout in @GniLudio ideally yes, though |
This Pr already changed the behavior of |
Sure! I'd be glad to see an outline of how that change would look – I don't have a good concept of it right now. |
- And add note explaining the concrete behavior of Mobject.insert
- Add one backing method for add, add_to_back, and insert. - Add an "update parent lists of children" method for OpenGLMobject - Update docstrings to specify ordering and deduplication behavior - Add fast path to Mobject._insert_submobjects when inserting at the end - Add opengl tests for add_to_back and insert
- The future plan is that `insert` should actually handle this logic rather than the private backing method.
- Also add tests to verify calls to add with multiple input mobjects
|
LGTM |







Overview: What does this pull request change?
Adds a number of optimizations to (mostly) methods related to adding and removing submobjects in both
MobjectandOpenGLMobject.MobjectandOpenGLMobjecthave each been given a single backing method,_insert_submobjects(index, mobjects)which is used for their respectiveadd,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. Note that the method behaves subtly differently for each type of mobject (see added comment), but this is a preexisting inconsistency.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. While this is technically a fix, it probably constitutes a breaking change.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.insertandOpenGLMobject.insertMobject.add_to_backandOpenGLMobject.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.Docstrings have been updated for all of the above. 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 (with a few early cutoffs for the implementation currently on main).
Benchmark code
As you can see, performance is particularly improved for
add_to_backops as well as operations where the submobject list is unchanged. This is becauseassemble_familyis much, much more time consuming than the actual list operations. I think that would be a good subject for a future change :)As this benchmark shows, single-item
insertperforms significantly worse than the existing implementation. This is necessary to ensure we don't insert duplicates — in the future, some careful data structure work might improve this.I should also 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