]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
A performance fix related to the usage of the :func:`.defer` option
authorMike Bayer <mike_mp@zzzcomputing.com>
Sat, 13 Jul 2013 20:28:42 +0000 (16:28 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Sat, 13 Jul 2013 20:28:42 +0000 (16:28 -0400)
when loading mapped entities.   The function overhead of applying
a per-object deferred callable to an instance at load time was
significantly higher than that of just loading the data from the row
(note that ``defer()`` is meant to reduce DB/network overhead, not
necessarily function call count); the function call overhead is now
less than that of loading data from the column in all cases.  There
is also a reduction in the number of "lazy callable" objects created
per load from N (total deferred values in the result) to 1 (total
number of deferred cols).
[ticket:2778]

doc/build/changelog/changelog_08.rst
lib/sqlalchemy/orm/attributes.py
lib/sqlalchemy/orm/loading.py
lib/sqlalchemy/orm/state.py
lib/sqlalchemy/orm/strategies.py
test/aaa_profiling/test_orm.py
test/orm/test_attributes.py
test/profiles.txt

index 55fd7949a107039244ca1c5313e1fd9d99da6932..ded97c8d844b23a68fc8458c56b63f037f707e74 100644 (file)
@@ -6,6 +6,21 @@
 .. changelog::
     :version: 0.8.3
 
+    .. change::
+        :tags: bug, orm
+        :tickets: 2778
+
+        A performance fix related to the usage of the :func:`.defer` option
+        when loading mapped entities.   The function overhead of applying
+        a per-object deferred callable to an instance at load time was
+        significantly higher than that of just loading the data from the row
+        (note that ``defer()`` is meant to reduce DB/network overhead, not
+        necessarily function call count); the function call overhead is now
+        less than that of loading data from the column in all cases.  There
+        is also a reduction in the number of "lazy callable" objects created
+        per load from N (total deferred values in the result) to 1 (total
+        number of deferred cols).
+
     .. change::
         :tags: bug, sqlite
         :tickets: 2781
index 7f1b5e58c483cda0d65e06db7c674ca3f1201569..b95677f0ba28fe359d9d9241e48116e3f754e578 100644 (file)
@@ -608,7 +608,7 @@ class AttributeImpl(object):
 
                 if key in state.callables:
                     callable_ = state.callables[key]
-                    value = callable_(passive)
+                    value = callable_(state, passive)
                 elif self.callable_:
                     value = self.callable_(state, passive)
                 else:
index 5937197fd95220c4af11c0cd8ef19a36dc060047..1006d45c1ad4baf5f5e9df504181988d67c29df2 100644 (file)
@@ -161,7 +161,7 @@ def get_from_identity(session, key, passive):
                 # expired state will be checked soon enough, if necessary
                 return instance
             try:
-                state(passive)
+                state(state, passive)
             except orm_exc.ObjectDeletedError:
                 session._remove_newly_deleted([state])
                 return None
index 6ade91b3e078a629dd3448353e0215394b8f3555..c479d880d87cb7e852498be9b683d201b428547c 100644 (file)
@@ -305,14 +305,19 @@ class InstanceState(interfaces._InspectionAttr):
         dict_.pop(key, None)
         self.callables[key] = self
 
-    def _set_callable(self, dict_, key, callable_):
-        """Remove the given attribute and set the given callable
-           as a loader."""
-
-        old = dict_.pop(key, None)
-        if old is not None and self.manager[key].impl.collection:
-            self.manager[key].impl._invalidate_collection(old)
-        self.callables[key] = callable_
+    @classmethod
+    def _row_processor(cls, manager, fn, key):
+        impl = manager[key].impl
+        if impl.collection:
+            def _set_callable(state, dict_, row):
+                old = dict_.pop(key, None)
+                if old is not None:
+                    impl._invalidate_collection(old)
+                state.callables[key] = fn
+        else:
+            def _set_callable(state, dict_, row):
+                state.callables[key] = fn
+        return _set_callable
 
     def _expire(self, dict_, modified_set):
         self.expired = True
@@ -359,7 +364,7 @@ class InstanceState(interfaces._InspectionAttr):
 
         self.manager.dispatch.expire(self, attribute_names)
 
-    def __call__(self, passive):
+    def __call__(self, state, passive):
         """__call__ allows the InstanceState to act as a deferred
         callable for loading expired attributes, which is also
         serializable (picklable).
index 6984f4127abca5ab97058b5525ab139a5dc46661..7535a8c8810c4e7dc1d3fd54de9b04440c01fb73 100644 (file)
@@ -14,6 +14,7 @@ from . import (
         attributes, interfaces, exc as orm_exc, loading,
         unitofwork, util as orm_util
     )
+from .state import InstanceState
 from .util import _none_set
 from .interfaces import (
     LoaderStrategy, StrategizedOption, MapperOption, PropertyOption,
@@ -181,9 +182,9 @@ class DeferredColumnLoader(LoaderStrategy):
                                 context, path, mapper, row, adapter)
 
         elif not self.is_class_level:
-            def set_deferred_for_local_state(state, dict_, row):
-                state._set_callable(
-                    dict_, key, LoadDeferredColumns(state, key))
+            set_deferred_for_local_state = InstanceState._row_processor(
+                                                mapper.class_manager,
+                                                LoadDeferredColumns(key), key)
             return set_deferred_for_local_state, None, None
         else:
             def reset_col_for_deferred(state, dict_, row):
@@ -256,12 +257,11 @@ log.class_logger(DeferredColumnLoader)
 class LoadDeferredColumns(object):
     """serializable loader object used by DeferredColumnLoader"""
 
-    def __init__(self, state, key):
-        self.state = state
+    def __init__(self, key):
         self.key = key
 
-    def __call__(self, passive=attributes.PASSIVE_OFF):
-        state, key = self.state, self.key
+    def __call__(self, state, passive=attributes.PASSIVE_OFF):
+        key = self.key
 
         localparent = state.manager.mapper
         prop = localparent._props[key]
@@ -602,16 +602,18 @@ class LazyLoader(AbstractRelationshipLoader):
                                     mapper, row, adapter):
         key = self.key
         if not self.is_class_level:
-            def set_lazy_callable(state, dict_, row):
-                # we are not the primary manager for this attribute
-                # on this class - set up a
-                # per-instance lazyloader, which will override the
-                # class-level behavior.
-                # this currently only happens when using a
-                # "lazyload" option on a "no load"
-                # attribute - "eager" attributes always have a
-                # class-level lazyloader installed.
-                state._set_callable(dict_, key, LoadLazyAttribute(state, key))
+            # we are not the primary manager for this attribute
+            # on this class - set up a
+            # per-instance lazyloader, which will override the
+            # class-level behavior.
+            # this currently only happens when using a
+            # "lazyload" option on a "no load"
+            # attribute - "eager" attributes always have a
+            # class-level lazyloader installed.
+            set_lazy_callable = InstanceState._row_processor(
+                                        mapper.class_manager,
+                                        LoadLazyAttribute(key), key)
+
             return set_lazy_callable, None, None
         else:
             def reset_for_lazy_callable(state, dict_, row):
@@ -634,12 +636,11 @@ log.class_logger(LazyLoader)
 class LoadLazyAttribute(object):
     """serializable loader object used by LazyLoader"""
 
-    def __init__(self, state, key):
-        self.state = state
+    def __init__(self, key):
         self.key = key
 
-    def __call__(self, passive=attributes.PASSIVE_OFF):
-        state, key = self.state, self.key
+    def __call__(self, state, passive=attributes.PASSIVE_OFF):
+        key = self.key
         instance_mapper = state.manager.mapper
         prop = instance_mapper._props[key]
         strategy = prop._strategies[LazyLoader]
index b9eeb836102b2ad41d63ce9417aacc357718f949..0bafd7f04a806317ac45e7562cd8f677103f8d5d 100644 (file)
@@ -2,7 +2,7 @@ from sqlalchemy.testing import eq_, assert_raises, \
     assert_raises_message
 from sqlalchemy import exc as sa_exc, util, Integer, String, ForeignKey
 from sqlalchemy.orm import exc as orm_exc, mapper, relationship, \
-    sessionmaker, Session
+    sessionmaker, Session, defer
 from sqlalchemy import testing
 from sqlalchemy.testing import profiling
 from sqlalchemy.testing import fixtures
@@ -257,4 +257,56 @@ class MergeBackrefsTest(fixtures.MappedTest):
         ]:
             s.merge(a)
 
+class DeferOptionsTest(fixtures.MappedTest):
+    @classmethod
+    def define_tables(cls, metadata):
+        Table('a', metadata,
+            Column('id', Integer, primary_key=True),
+            Column('x', String(5)),
+            Column('y', String(5)),
+            Column('z', String(5)),
+            Column('q', String(5)),
+            Column('p', String(5)),
+            Column('r', String(5)),
+        )
+
+    @classmethod
+    def setup_classes(cls):
+        class A(cls.Basic):
+            pass
+
+    @classmethod
+    def setup_mappers(cls):
+        A = cls.classes.A
+        a = cls.tables.a
+        mapper(A, a)
+
+    @classmethod
+    def insert_data(cls):
+        A = cls.classes.A
+        s = Session()
+        s.add_all([
+            A(id=i,
+                **dict((letter, "%s%d" % (letter, i)) for letter in
+                        ['x', 'y', 'z', 'p', 'q', 'r'])
+            ) for i in range(1, 1001)
+        ])
+        s.commit()
+
+    @profiling.function_call_count(variance=.10)
+    def test_baseline(self):
+        # as of [ticket:2778], this is at 39025
+        A = self.classes.A
+        s = Session()
+        s.query(A).all()
+
+    @profiling.function_call_count(variance=.10)
+    def test_defer_many_cols(self):
+        # with [ticket:2778], this goes from 50805 to 32817,
+        # as it should be fewer function calls than the baseline
+        A = self.classes.A
+        s = Session()
+        s.query(A).options(
+            *[defer(letter) for letter in ['x', 'y', 'z', 'p', 'q', 'r']]).\
+            all()
 
index 7b2bf0e3ee553d92361b723e21577d94a471864a..dbe82f585135fbb8e52f60139673cf6de64410e0 100644 (file)
@@ -11,12 +11,19 @@ from sqlalchemy.util import jython
 from sqlalchemy import event
 from sqlalchemy import testing
 from sqlalchemy.testing.mock import Mock, call
+from sqlalchemy.orm.state import InstanceState
 
 # global for pickling tests
 MyTest = None
 MyTest2 = None
 
 
+
+def _set_callable(state, dict_, key, callable_):
+    fn = InstanceState._row_processor(state.manager, callable_, key)
+    fn(state, dict_, None)
+
+
 class AttributeImplAPITest(fixtures.MappedTest):
     def _scalar_obj_fixture(self):
         class A(object):
@@ -602,8 +609,10 @@ class AttributesTest(fixtures.ORMTest):
 
         """
 
-        class Post(object):pass
-        class Blog(object):pass
+        class Post(object):
+            pass
+        class Blog(object):
+            pass
         instrumentation.register_class(Post)
         instrumentation.register_class(Blog)
 
@@ -618,10 +627,10 @@ class AttributesTest(fixtures.ORMTest):
         # create objects as if they'd been freshly loaded from the database (without history)
         b = Blog()
         p1 = Post()
-        attributes.instance_state(b)._set_callable(attributes.instance_dict(b),
-                                                    'posts', lambda passive:[p1])
-        attributes.instance_state(p1)._set_callable(attributes.instance_dict(p1),
-                                                    'blog', lambda passive:b)
+        _set_callable(attributes.instance_state(b), attributes.instance_dict(b),
+                                                    'posts', lambda state, passive:[p1])
+        _set_callable(attributes.instance_state(p1), attributes.instance_dict(p1),
+                                                    'blog', lambda state, passive:b)
         p1, attributes.instance_state(b)._commit_all(attributes.instance_dict(b))
 
         # no orphans (called before the lazy loaders fire off)
@@ -2628,7 +2637,7 @@ class TestUnlink(fixtures.TestBase):
         coll = a1.bs
         a1.bs.append(B())
         state = attributes.instance_state(a1)
-        state._set_callable(state.dict, "bs", lambda: B())
+        _set_callable(state, state.dict, "bs", lambda: B())
         assert_raises(
             Warning,
             coll.append, B()
index 3c22397bbb9186070ef8917f5372bcc778cfab66..7e5c808c1733599635a07cf7eacf92a53176c166 100644 (file)
@@ -1,15 +1,15 @@
 # /Users/classic/dev/sqlalchemy/test/profiles.txt
 # This file is written out on a per-environment basis.
-# For each test in aaa_profiling, the corresponding function and
+# For each test in aaa_profiling, the corresponding function and 
 # environment is located within this file.  If it doesn't exist,
 # the test is skipped.
-# If a callcount does exist, it is compared to what we received.
+# If a callcount does exist, it is compared to what we received. 
 # assertions are raised if the counts do not match.
-#
-# To add a new callcount test, apply the function_call_count
-# decorator and re-run the tests using the --write-profiles
+# 
+# To add a new callcount test, apply the function_call_count 
+# decorator and re-run the tests using the --write-profiles 
 # option - this file will be rewritten including the new count.
-#
+# 
 
 # TEST: test.aaa_profiling.test_compiler.CompileTest.test_insert
 
@@ -59,6 +59,24 @@ test.aaa_profiling.test_compiler.CompileTest.test_update_whereclause 2.7_postgre
 test.aaa_profiling.test_compiler.CompileTest.test_update_whereclause 2.7_sqlite_pysqlite_cextensions 130
 test.aaa_profiling.test_compiler.CompileTest.test_update_whereclause 2.7_sqlite_pysqlite_nocextensions 130
 
+# TEST: test.aaa_profiling.test_orm.DeferOptionsTest.test_baseline
+
+test.aaa_profiling.test_orm.DeferOptionsTest.test_baseline 2.7_mysql_mysqldb_cextensions 30052
+test.aaa_profiling.test_orm.DeferOptionsTest.test_baseline 2.7_mysql_mysqldb_nocextensions 39069
+test.aaa_profiling.test_orm.DeferOptionsTest.test_baseline 2.7_postgresql_psycopg2_cextensions 42032
+test.aaa_profiling.test_orm.DeferOptionsTest.test_baseline 2.7_postgresql_psycopg2_nocextensions 51049
+test.aaa_profiling.test_orm.DeferOptionsTest.test_baseline 2.7_sqlite_pysqlite_cextensions 30008
+test.aaa_profiling.test_orm.DeferOptionsTest.test_baseline 2.7_sqlite_pysqlite_nocextensions 39025
+
+# TEST: test.aaa_profiling.test_orm.DeferOptionsTest.test_defer_many_cols
+
+test.aaa_profiling.test_orm.DeferOptionsTest.test_defer_many_cols 2.7_mysql_mysqldb_cextensions 29994
+test.aaa_profiling.test_orm.DeferOptionsTest.test_defer_many_cols 2.7_mysql_mysqldb_nocextensions 32867
+test.aaa_profiling.test_orm.DeferOptionsTest.test_defer_many_cols 2.7_postgresql_psycopg2_cextensions 29830
+test.aaa_profiling.test_orm.DeferOptionsTest.test_defer_many_cols 2.7_postgresql_psycopg2_nocextensions 32835
+test.aaa_profiling.test_orm.DeferOptionsTest.test_defer_many_cols 2.7_sqlite_pysqlite_cextensions 29812
+test.aaa_profiling.test_orm.DeferOptionsTest.test_defer_many_cols 2.7_sqlite_pysqlite_nocextensions 32817
+
 # TEST: test.aaa_profiling.test_orm.LoadManyToOneFromIdentityTest.test_many_to_one_load_identity
 
 test.aaa_profiling.test_orm.LoadManyToOneFromIdentityTest.test_many_to_one_load_identity 2.5_sqlite_pysqlite_nocextensions 17987