starlette icon indicating copy to clipboard operation
starlette copied to clipboard

Improve performance of State

Open HHongSeungWoo opened this issue 2 years ago • 3 comments

Summary

https://github.com/encode/starlette/discussions/2389

I modified setattr to preserve the behavior of the existing State. Although assignment performance has slowed down compared to before, the rest of the performance significantly improves.

Checklist

  • [x] I understand that this PR may be closed in case there was no previous discussion. (This doesn't apply to typos!)
  • [x] I've added a test for each change that was introduced, and I tried as much as possible to make a single atomic change.
  • [x] I've updated the documentation accordingly.

HHongSeungWoo avatar Feb 04 '24 14:02 HHongSeungWoo

@Kludex I tested a few approaches to improve the performance of accessing State properties. Are there any approaches listed below that look good?

import typing
from types import SimpleNamespace


class State:
    """
    An object that can be used to store arbitrary state.

    Used for `request.state` and `app.state`.
    """

    _state: dict[str, typing.Any]

    def __init__(self, state: dict[str, typing.Any] | None = None):
        if state is None:
            state = {}
        super().__setattr__("_state", state)

    def __setattr__(self, key: typing.Any, value: typing.Any) -> None:
        self._state[key] = value

    def __getattr__(self, key: typing.Any) -> typing.Any:
        try:
            return self._state[key]
        except KeyError:
            message = "'{}' object has no attribute '{}'"
            raise AttributeError(message.format(self.__class__.__name__, key))

    def __delattr__(self, key: typing.Any) -> None:
        del self._state[key]


class GetAttributeState:
    """
    An object that can be used to store arbitrary state.

    Used for `request.state` and `app.state`.
    """

    _state: dict[str, typing.Any]

    def __init__(self, state: dict[str, typing.Any] | None = None):
        if state is None:
            state = {}
        super().__setattr__("_state", state)

    def __setattr__(self, key: typing.Any, value: typing.Any) -> None:
        object.__getattribute__(self, "_state")[key] = value

    def __getattribute__(self, key: typing.Any) -> typing.Any:
        state = object.__getattribute__(self, "_state")

        if key in state:
            return state[key]

        return object.__getattribute__(self, key)

    def __delattr__(self, key: typing.Any) -> None:
        del self._state[key]


class NameSpaceState(SimpleNamespace):
    """
    An object that can be used to store arbitrary state.

    Used for `request.state` and `app.state`.
    """

    _state: dict[str, typing.Any]

    def __init__(self, state: dict[str, typing.Any] | None = None):
        if state is None:
            state = {}
        super().__setattr__("_state", state)
        super().__init__(**state)

    def __setattr__(self, key: typing.Any, value: typing.Any) -> None:
        super().__setattr__(key, value)
        self._state[key] = value


lifespan_state = {"foo": 1}

old = State(lifespan_state)
new = GetAttributeState(lifespan_state)
ns_state = NameSpaceState(lifespan_state)

print(timeit.timeit(lambda: getattr(old, "foo", 1)))  # 0.8453106439992553 sec
print(timeit.timeit(lambda: old.foo))  # 0.8073957750020782 sec
print(timeit.timeit(lambda: getattr(old, "bar", 1)))  # 2.2985301509979763 sec
print(timeit.timeit(lambda: setattr(old, "foo", 1)))  # 0.16180036000150722 sec
print("---------------")
print(timeit.timeit(lambda: getattr(new, "foo", 1)))  # 0.27732334599568276 sec
print(timeit.timeit(lambda: new.foo))  # 0.26606425999489147 sec
print(timeit.timeit(lambda: getattr(new, "bar", 1)))  # 1.4273305930037168 sec
print(timeit.timeit(lambda: setattr(new, "foo", 1)))  # 0.27730098600295605 sec
print("---------------")
print(timeit.timeit(lambda: getattr(ns_state, "foo", 1)))  # 0.0628476350029814 sec
print(timeit.timeit(lambda: ns_state.foo))  # 0.05678495100437431 sec
print(timeit.timeit(lambda: getattr(ns_state, "bar", 1)))  # 0.07138194199796999 sec
print(timeit.timeit(lambda: setattr(ns_state, "foo", 1)))  # 0.42666425200150115 sec

HHongSeungWoo avatar Feb 08 '24 09:02 HHongSeungWoo

Context: I am just a random person - not a maintainer. I personally think the SimpleNamespace would work best but it can be simplified like that:

class NameSpaceState2(SimpleNamespace):
    """
    An object that can be used to store arbitrary state.

    Used for `request.state` and `app.state`.
    """

    def __init__(self, state: dict[str, typing.Any] | None = None):
        if state is not None:
            super().__init__(**state)

    @property
    def _state(self):
        return self.__dict__

There is no need to keep 2 separate dicts. It has better performance characteristic for setting attrs and does not bug out when accessing _state directly.

image

image

I confirm that this class is generally 10x faster compared to existing solution.

Zaczero avatar Apr 03 '24 20:04 Zaczero