Skip to content

Reduce overhead to add/remove asyncio readers and writers #106527

Description

@bdraco

Pitch

The current code path for adding and removing asyncio readers and writers always has to do a try except KeyError to add a new reader/writer

except KeyError:

For use cases where readers are added and removed frequently (hard to change the design without breaking changes) this adds up quickly.

Screenshot 2023-07-07 at 8 22 45 AM

Linked PRs

Activity

  1. gvanrossum commented on Jul 7, 2023

    @gvanrossum
    Member

    Interesting. Have you measured your alternative? Is it faster? I'm a little surprised that calling .get_map().get(key) is slower than calling .get_key(key) plus raising and catching an exception -- that would suggest a function call is faster than raising an catching an exception.

    Maybe we would do better trying to make exception handling faster? (I guess this code is unusual because in the common case the exception gets raised, so our "zero-overhead exceptions" don't help, since it's only zero overhead when it doesn't get raised.)

    @kumaraditya303 Thoughts? The PR seems well thought out.

  2. bdraco commented on Jul 7, 2023

    @bdraco
    ContributorAuthor

    Have you measured your alternative? Is it faster?

    The flames disappear from the py-spy after the change. I also did a cProfile and its faster. This isn't a great comparison though as its doesn't quantify the actual performance improvement and only shows more of the real world impact. I'll see if I can put together something that benchmarks it

    add_reader

  3. bdraco commented on Jul 7, 2023

    @bdraco
    ContributorAuthor
    original: 1.048221042030491
    new: 0.626125167007558
    

    I think this is close enough to benchmark the change:

    import asyncio
    import timeit
    
    loop = asyncio.get_event_loop()
    
    original_code = """
    try:
        key = selector.get_key(1)
    except KeyError:
        pass
    """
    
    new_code = """
    key = selector.get_map().get(1)
    if key is None:
        pass
    """
    
    original_time = timeit.timeit(
        original_code,
        number=1000000,
        globals={"selector": loop._selector},
    )
    new_time = timeit.timeit(
        new_code,
        number=1000000,
        globals={"selector": loop._selector},
    )
    
    print("original: %s" % original_time)
    print("new: %s" % new_time)
  4. gvanrossum commented on Jul 7, 2023

    @gvanrossum
    Member

    Yeah, that’s acceptable.

  5. bdraco commented on Jul 8, 2023

    @bdraco
    ContributorAuthor

    _SelectorMapping.__getitem__ has the same pattern
    Screenshot 2023-07-08 at 9 01 51 AM

    probably something like this would offer the same speed up

    except KeyError:

        def __getitem__(self, fileobj):
            fd = self._selector._fileobj_lookup(fileobj)
            key = self._selector._fd_to_key.get(fd)
            if key is None:
                raise KeyError("{!r} is not registered".format(fileobj))
            return key

    I'll wait for the review of the linked PR and pending the outcome there will open another PR for this. In the mean time I'll push the above code to production and run it for a bit.

    That helps a bit as it avoids one except catch but on the add_reader case the last KeyError has to raise

    Screenshot 2023-07-08 at 9 10 07 AM
  6. bdraco commented on Jul 9, 2023

    @bdraco
    ContributorAuthor

    It also looks like the selectors could remove _key_from_fd and replace it with a simple self._fd_to_key.get() unless _key_from_fd is a defined API promise. That's roughly a 10% reduction of run time of the code in selectors.py when running an asyncio event loop with ~10000 calls to select per minute since it doesn't have the extra function depth overhead.

    That should also likely be another PR. Probably worth doing since its called in the select loop and this was on a mostly idle system.

    @@ -332,7 +332,7 @@ def select(self, timeout=None):
                 if fd in w:
                     events |= EVENT_WRITE
     
    -            key = self._key_from_fd(fd)
    +            key = self._fd_to_key.get(fd)
                 if key:
                     ready.append((key, events & key.events))
             return ready
    @@ -422,7 +422,7 @@ def select(self, timeout=None):
                 if event & ~self._EVENT_WRITE:
                     events |= EVENT_READ
     
    -            key = self._key_from_fd(fd)
    +            key = self._fd_to_key.get(fd)
                 if key:
                     ready.append((key, events & key.events))
             return ready
    @@ -475,7 +475,7 @@ def select(self, timeout=None):
                     if event & ~select.EPOLLOUT:
                         events |= EVENT_READ
     
    -                key = self._key_from_fd(fd)
    +                key = self._fd_to_key.get(fd)
                     if key:
                         ready.append((key, events & key.events))
                 return ready
    @@ -570,7 +570,7 @@ def select(self, timeout=None):
                     if flag == select.KQ_FILTER_WRITE:
                         events |= EVENT_WRITE
     
    -                key = self._key_from_fd(fd)
    +                key = self._fd_to_key.get(fd)
                     if key:
                         ready.append((key, events & key.events))
                 return ready

    rough profile (different points in time with similar workloads)
    before: Screenshot 2023-07-08 at 3 32 38 PM
    after Screenshot 2023-07-08 at 3 32 34 PM

  7. AlexWaygood commented on Jul 12, 2023

    @AlexWaygood
    Member

    Maybe we would do better trying to make exception handling faster? (I guess this code is unusual because in the common case the exception gets raised, so our "zero-overhead exceptions" don't help, since it's only zero overhead when it doesn't get raised.)

    I think that would also be worthwhile, FWIW. I was quite surprised how much switching to LBYL idioms sped things up in #103318, largely because of how slow exception handling is.

    (N.B. I don't think longterm goals like that should impact whether this proposed change is accepted or not.)

  8. kumaraditya303 commented on Jul 14, 2023

    @kumaraditya303
    Contributor

    FWIW I think it is much better to optimize selectors module rather than asyncio in this case.

  9. bdraco commented on Jul 14, 2023

    @bdraco
    ContributorAuthor

    FWIW I think it is much better to optimize selectors module rather than asyncio in this case.

    I agree that selectors are the primary source of the bottleneck I'm seeing. (nothing else is called 100000x per minute on busy systems besides _run_once)

    More discussion on that in #106555

  10. bdraco commented on Jul 14, 2023

    @bdraco
    ContributorAuthor

    The linked #106528 can use the new get implementation #106665 which will avoids the exceptions in the happy path of _add_reader which I think is still valuable on its own when you have a situation where readers are added and removed serval times per second.

  11. bdraco commented on Jul 14, 2023

    @bdraco
    ContributorAuthor

    edit: sorry I screwed up and linked the wrong PR above. Should have been #106665

  12. added a commit that references this issue on Jul 22, 2023
  13. added a commit that references this issue on Jul 23, 2023
  14. added a commit that references this issue on Jul 23, 2023
  15. kumaraditya303 commented on Jul 24, 2023

    @kumaraditya303
    Contributor

    Thanks for working on this.

  16. moved this from Todo to Done in asyncioon Jul 24, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    • Status
      Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions