Skip to content

Commit

Permalink
Special case FieldProperty objects and avoid the descriptor protocol.
Browse files Browse the repository at this point in the history
This nets us another big increase when we're dealing with FieldProperty objects.

+-----------------------------+------------------------+-------------------------------+-------------------------------+---------------------------------+
| Benchmark                   | bench27_master_20iface | bench27_issue54_1_20iface     | bench27_issue54_2_20iface     | bench27_issue54_6_20iface       |
+=============================+========================+===============================+===============================+=================================+
| Create WideInheritance      | 63.8 ns                | 58.0 ns: 1.10x faster (-9%)   | 61.3 ns: 1.04x faster (-4%)   | 66.0 ns: 1.03x slower (+3%)     |
+-----------------------------+------------------------+-------------------------------+-------------------------------+---------------------------------+
| Create SCWideInheritance    | 294 us                 | 39.8 us: 7.39x faster (-86%)  | 16.2 us: 18.11x faster (-94%) | 19.1 us: 15.42x faster (-94%)   |
+-----------------------------+------------------------+-------------------------------+-------------------------------+---------------------------------+
| Create DeepestInheritance   | 77.8 ns                | 93.3 ns: 1.20x slower (+20%)  | 96.4 ns: 1.24x slower (+24%)  | 102 ns: 1.31x slower (+31%)     |
+-----------------------------+------------------------+-------------------------------+-------------------------------+---------------------------------+
| Create SCDeepestInheritance | 654 us                 | 57.0 us: 11.47x faster (-91%) | 16.2 us: 40.23x faster (-98%) | 2.54 us: 257.52x faster (-100%) |
+-----------------------------+------------------------+-------------------------------+-------------------------------+---------------------------------+
| Create ShallowInheritance   | 66.0 ns                | 57.9 ns: 1.14x faster (-12%)  | 61.1 ns: 1.08x faster (-7%)   | 63.7 ns: 1.04x faster (-4%)     |
+-----------------------------+------------------------+-------------------------------+-------------------------------+---------------------------------+
| Create SCShallowInheritance | 304 us                 | 57.1 us: 5.33x faster (-81%)  | 33.1 us: 9.20x faster (-89%)  | 2.30 us: 132.53x faster (-99%)  |
+-----------------------------+------------------------+-------------------------------+-------------------------------+---------------------------------+

Fixes #54
  • Loading branch information
jamadden committed May 1, 2020
1 parent ecc5221 commit 21ddbb4
Show file tree
Hide file tree
Showing 4 changed files with 262 additions and 11 deletions.
9 changes: 7 additions & 2 deletions CHANGES.rst
Expand Up @@ -2,10 +2,15 @@
Changes
=========

1.14.1 (unreleased)
1.15.0 (unreleased)
===================

- Nothing changed yet.
- Improve the speed of ``SchemaConfigured`` subclasses. See `issue 54
<https://github.com/NextThought/nti.schema/issues/54>`_.

This involves some caching, so be sure to read the documentation for
``nti.schema.schema`` if you ever mutate classes directly, or mutate
the results of ``schemadict``.


1.14.0 (2020-03-27)
Expand Down
149 changes: 149 additions & 0 deletions benchmarks/bench_schemaconfigured.py
@@ -0,0 +1,149 @@
from __future__ import print_function, absolute_import
import pyperf

from zope.interface import Interface
from zope.interface import classImplements
from zope.interface import implementer
from zope.interface.interface import InterfaceClass

from nti.schema.field import Bool
from nti.schema.schema import SchemaConfigured
from nti.schema.fieldproperty import createFieldProperties

# The number of interfaces seems to directly relate to the speed, with more
# being much slower.
INTERFACE_COUNT = 20
INNERLOOPS = 100
# A bunch of interfaces, each declaring
# one field. No inheritance.
shallow_ifaces = [
InterfaceClass(
'I' + ('0' * 20) + str(i),
(Interface,),
{'field_' + str(i): Bool()}
)
for i in range(INTERFACE_COUNT)
]

def make_shallow_classes():
classes = []
for iface in shallow_ifaces:
cls = type(
'Class' + iface.__name__,
(object,),
{}
)
classImplements(cls, iface)
classes.append(cls)
return classes

shallow_classes = make_shallow_classes()

IWideInheritance = InterfaceClass(
'IWideInheritance',
tuple(shallow_ifaces),
{'__doc__': "Inherits from unrelated interfaces"}
)

WideInheritance = type(
'WideInheritance',
tuple(shallow_classes),
{'__doc__': "Inherits from unrelated classes"}
)

class SCWideInheritance(WideInheritance, SchemaConfigured):
pass

@implementer(*shallow_ifaces)
class ShallowInheritance(object):
"""
Implements each individual interface.
"""
for iface in shallow_ifaces:
createFieldProperties(iface)

class SCShallowInheritance(ShallowInheritance, SchemaConfigured):
pass


def make_deep_ifaces():
children = []
base = Interface
for i, iface in enumerate(shallow_ifaces):
child = InterfaceClass(
'IDerived' + base.__name__,
(iface, base,),
{'field_' + str(i): Bool()}
)
base = child
children.append(child)
return children

deep_ifaces = make_deep_ifaces()
# An interface that inherits from 99 other interfaces, each
# declaring a single field.
IDeepestInheritance = deep_ifaces[-1]

def make_deep_classes(extra_bases=()):
classes = []
base = extra_bases + (object,)
for iface in deep_ifaces:
cls = type(
'Class' + iface.__name__,
base,
{}
)
classImplements(cls, iface)
base = (cls,)
classes.append(cls)

return classes

deep_classes = make_deep_classes()

@implementer(IDeepestInheritance)
class DeepestInheritance(deep_classes[-1]):
"""
A single class that implements an interface that's deeply inherited.
Has field properties.
"""
createFieldProperties(IDeepestInheritance)

sc_deep_classes = make_deep_classes((SchemaConfigured,))

@implementer(IDeepestInheritance)
class SCDeepestInheritance(sc_deep_classes[-1], SchemaConfigured):
"""
Has field properties.
"""
createFieldProperties(IDeepestInheritance)

def bench_create(loops, cls):
t0 = pyperf.perf_counter()
for _ in range(loops):
for _ in range(INNERLOOPS):
cls()
return pyperf.perf_counter() - t0



runner = pyperf.Runner()

for bench_cls in (
WideInheritance, # These have no field properties.
SCWideInheritance,
DeepestInheritance,
SCDeepestInheritance,
ShallowInheritance,
SCShallowInheritance
):

runner.bench_time_func(
'Create ' + bench_cls.__name__,
bench_create,
bench_cls,
inner_loops=INNERLOOPS
)

#bench_create(10000, SCDeepestInheritance)
101 changes: 92 additions & 9 deletions src/nti/schema/schema.py
Expand Up @@ -16,7 +16,8 @@
from zope.interface import providedBy
from zope.interface import implementer

from zope.schema._bootstrapinterfaces import IValidatable
from zope.schema.interfaces import IValidatable
from zope.schema.fieldproperty import FieldProperty

from .interfaces import ISchemaConfigured

Expand All @@ -35,10 +36,12 @@ def schemaitems(spec, _field_key=lambda x: x[1].order):

def schemadict(spec):
"""
schemadict(spec) -> dict
The schema part (fields) of interface specification *spec* as map
from name to field.
The return value should be treated as immutable.
The return value *must* be treated as immutable.
*spec* can be:
Expand All @@ -57,6 +60,7 @@ def schemadict(spec):
.. versionchanged:: 1.15.0
Added caching and re-implemented the schemadict algorithm for speed.
The return value must now be treated as immutable.
"""
try:
cache_in = spec._v_attrs # pylint:disable=protected-access
Expand Down Expand Up @@ -122,7 +126,27 @@ def schemadict(spec):

@implementer(ISchemaConfigured)
class SchemaConfigured(object):
"""Mixin class to provide configuration by the provided schema components."""
"""
Mixin class to provide configuration by the provided schema
components.
This class is fastest if most of the attributes are represented
by ``FieldProperty`` objects.
.. versionchanged:: 1.15
Special case ``FieldProperty`` instances found in the type
when checking whether a value has been provided. We now assume
that if there is no matching item in the dict with the same name,
no value was provided. Note that if the schema field contained in the
``FieldProperty`` did something funky in its ``bind()`` method to
this object, that will no longer happen at construction time.
This can be turned of by setting ``SC_OPTIMIZE_FIELD_PROPERTY`` to false.
If you add a FieldProperty to a ``SchemaConfigured`` class after an instance
has been created, you must call ``sc_changed``.
"""

SC_OPTIMIZE_FIELD_PROPERTY = True

def __init__(self, **kw):
schema = schemadict(self.sc_schema_spec())
Expand All @@ -131,12 +155,72 @@ def __init__(self, **kw):
if k not in schema:
raise TypeError('non schema keyword argument: %s' % k)
setattr(self, k, v)
# provide default values for schema fields not set
for f, fields in schema.items():
if getattr(self, f, _marker) is _marker:

# provide default values for schema fields not set.
# In bench_schemaconfigured.py, if the fields are FieldProperty objects found in the
# type, checking for whether they are set or not took 96% of the total time.
# We can be much faster (33us -> 9.1us) if we special case this, without hurting
# the non-FieldProperty case too much.
if self.SC_OPTIMIZE_FIELD_PROPERTY:
schema = self.__elide_fieldproperty(schema)

for field_name, schema_field in schema.items():
if field_name in kw:
continue
# TODO: I think we could do better by first checking
# to see if field_name is in vars(type(self))?
if getattr(self, field_name, _marker) is _marker:
# The point of this is to avoid hiding exceptions (which the builtin
# hasattr() does on Python 2)
setattr(self, f, fields.default)
setattr(self, field_name, schema_field.default)

__FP_KEY = '__SchemaConfigured_elide_fieldproperty'

@classmethod
def __elide_fieldproperty(cls, schema):
try:
matches = cls.__dict__[cls.__FP_KEY]
except KeyError:
matches = cls.__find_FieldProperty_that_match_schema(schema)
setattr(cls, cls.__FP_KEY, matches)

return {k: v for k, v in schema.items() if k not in matches}


@classmethod
def __find_FieldProperty_that_match_schema(cls, schema_dict):
result = set()
for field_name, schema_field in schema_dict.items():
try:
# If these are descriptors, this runs code. We don't look in the
# type's __dict__ because we would need to manually walk up the mro().
kind_value = getattr(cls, field_name)
except AttributeError:
continue

# pylint:disable=protected-access
if isinstance(kind_value, FieldProperty) \
and kind_value._FieldProperty__field == schema_field:
# These are data-descriptors, with both __get__ and
# __set__, so they're in full control. They automatically return
# the default value of the field (that they have), so we don't
# need to copy it down from our schema field.
result.add(field_name)
return result


@classmethod
def sc_changed(cls, orig_changed=None):
"""
Call this method if you assign a fieldproperty to this class after creation.
"""
if cls.__FP_KEY in cls.__dict__:
# If this happens concurrently and we hit a super class, that's
# fine.
try:
delattr(cls, cls.__FP_KEY)
except AttributeError: # pragma: no cover
pass

# provide control over which interfaces define the data schema
SC_SCHEMAS = None
Expand All @@ -146,8 +230,7 @@ def sc_schema_spec(self):
This is determined by `SC_SCHEMAS` and defaults to `providedBy(self)`.
"""
spec = self.SC_SCHEMAS
return spec or providedBy(self)
return self.SC_SCHEMAS or providedBy(self)

class PermissiveSchemaConfigured(SchemaConfigured):
"""
Expand Down
14 changes: 14 additions & 0 deletions src/nti/schema/tests/test_schema.py
Expand Up @@ -73,6 +73,20 @@ class A(PermissiveSchemaConfigured):
a = A(field=1)
assert_that(a, has_property('field', 1))

def test_changed(self):
class IA(interface.Interface):
field = Number()

@interface.implementer(IA)
class A(PermissiveSchemaConfigured):
pass

self.assertNotIn('__SchemaConfigured_elide_fieldproperty', A.__dict__)
A()
self.assertIn('__SchemaConfigured_elide_fieldproperty', A.__dict__)
A.sc_changed()
self.assertNotIn('__SchemaConfigured_elide_fieldproperty', A.__dict__)

def test_readonly(self):
from nti.schema.fieldproperty import createDirectFieldProperties
class IA(interface.Interface):
Expand Down

0 comments on commit 21ddbb4

Please sign in to comment.