sagemath / sage

Main repository of SageMath. Now open for Issues and Pull Requests.
https://www.sagemath.org
Other
1.21k stars 421 forks source link

Cythoned homsets #14214

Open simon-king-jena opened 11 years ago

simon-king-jena commented 11 years ago

Homsets arise whenever a coercion map or a conversion map is involved. Hence, homsets are ubiquitous in Sage and should thus be as a fast as possible.

Therefore I suggest change sage.categories.homset into a cython module. A clean solution seems out of reach at the moment (see comments), and so I just provide a couple of speed-ups here.

Apply

Depends on #14279 Depends on #18758

CC: @nthiery

Component: performance

Keywords: Hom, cached method

Author: Simon King

Issue created by migration from https://trac.sagemath.org/ticket/14214

simon-king-jena commented 11 years ago
comment:1

Ideally, sage.categories.homset.Homset should be a cdef class, with cdef attributes _domain, _codomain, __category.

Problem and potential long term solutions

sage.modular.abvar.homspace.EndomorphimSubring inherits from sage.categories.homset.Homset and from sage.rings.ring.Ring. Making Homset cdef with cdef attributes results in incompatible base classes.

Short term improvements suggested in my patch

Making Homset cdef requires to put Set_generic into sage.structure.parent.pxd.

Timings will be in the next post.

simon-king-jena commented 11 years ago
comment:2

Timings with the patch:

sage: E = J0(11)
sage: F = J0(22)
sage: H = Hom(ZZ,QQ)
sage: HE = Hom(E,E)
sage: HF = Hom(E,F)
sage: D = {H:1,HE:2,HE:3}

sage: timeit("Hom(E,E)", number = 10^4)
10000 loops, best of 3: 9.25 µs per loop
sage: timeit("Hom(E,F)", number = 10^4)
10000 loops, best of 3: 122 µs per loop
sage: timeit("Hom(ZZ,QQ)", number = 10^4)
10000 loops, best of 3: 12 µs per loop

sage: timeit("hash(HE)", number = 10^6)
1000000 loops, best of 3: 2.41 µs per loop
sage: timeit("hash(H)", number = 10^6)
1000000 loops, best of 3: 1.46 µs per loop
sage: timeit("hash(HF)", number = 10^6)
1000000 loops, best of 3: 1.49 µs per loop

sage: timeit("H in D", number = 10^6)
1000000 loops, best of 3: 1.48 µs per loop
sage: timeit("HE in D", number = 10^6)
1000000 loops, best of 3: 2.38 µs per loop
sage: timeit("HF in D", number = 10^6)
1000000 loops, best of 3: 1.48 µs per loop

sage: timeit("HF.domain()", number = 10^6)
1000000 loops, best of 3: 392 ns per loop
sage: timeit("HE.domain()", number = 10^6)
1000000 loops, best of 3: 415 ns per loop
sage: timeit("H.domain()", number = 10^6)
1000000 loops, best of 3: 356 ns per loop
sage: timeit("HF._domain", number = 10^6)
1000000 loops, best of 3: 524 ns per loop
sage: timeit("HE._domain", number = 10^6)
1000000 loops, best of 3: 881 ns per loop
sage: timeit("H._domain", number = 10^6)
1000000 loops, best of 3: 502 ns per loop

sage: %timeit TestSuite(H).run()
100 loops, best of 3: 7.01 ms per loop
sage: %timeit TestSuite(HE).run(skip=['_test_elements'])
10 loops, best of 3: 38.8 ms per loop
sage: %timeit TestSuite(HF).run(skip=['_test_elements'])
10 loops, best of 3: 91.3 ms per loop

Timings without the patch:

sage: timeit("hash(HE)", number = 10^6)
1000000 loops, best of 3: 3.97 µs per loop
sage: timeit("hash(H)", number = 10^6)
1000000 loops, best of 3: 2.74 µs per loop
sage: timeit("hash(HF)", number = 10^6)
1000000 loops, best of 3: 2.7 µs per loop

sage: timeit("Hom(E,E)", number = 10^4)
10000 loops, best of 3: 12.9 µs per loop
sage: timeit("Hom(E,F)", number = 10^4)
10000 loops, best of 3: 132 µs per loop
sage: timeit("Hom(ZZ,QQ)", number = 10^4)
10000 loops, best of 3: 21.6 µs per loop

sage: timeit("H in D", number = 10^6)
1000000 loops, best of 3: 2.77 µs per loop
sage: timeit("HE in D", number = 10^6)
1000000 loops, best of 3: 4 µs per loop
sage: timeit("HF in D", number = 10^6)
1000000 loops, best of 3: 2.81 µs per loop

sage: timeit("HF.domain()", number = 10^6)
1000000 loops, best of 3: 1.13 µs per loop
sage: timeit("HE.domain()", number = 10^6)
1000000 loops, best of 3: 1.74 µs per loop
sage: timeit("H.domain()", number = 10^6)
1000000 loops, best of 3: 1.18 µs per loop
sage: timeit("HF._domain", number = 10^6)
1000000 loops, best of 3: 520 ns per loop
sage: timeit("HE._domain", number = 10^6)
1000000 loops, best of 3: 933 ns per loop
sage: timeit("H._domain", number = 10^6)
1000000 loops, best of 3: 522 ns per loop

sage: %timeit TestSuite(H).run()
100 loops, best of 3: 7.79 ms per loop
sage: %timeit TestSuite(HE).run(skip=['_test_elements'])
10 loops, best of 3: 42.3 ms per loop
sage: %timeit TestSuite(HF).run(skip=['_test_elements'])
1 loops, best of 3: 95.7 ms per loop

Conclusion

Some details I am not yet totally happy with (todo on a different ticket?):

Doctests pass for me. Needs review!

simon-king-jena commented 11 years ago
comment:3

Hooray, the startup_time plugin reports:

+With 37% confidence, startup time decreased by at least 0.5%
+With 82% confidence, startup time decreased by at least 0.25%
+With 91% confidence, startup time decreased by at least 0.1%

I think it would only be significant if it was 95% confidence. Nevertheless, it is a decrease of the startup time!

nthiery commented 11 years ago
comment:4

Replying to @simon-king-jena:

Some details I am not yet totally happy with (todo on a different ticket?):

  • Getting a homset out of the cache could still be faster. The time-limiting factor is that one needs to find an appropriate category, if it is not explicitly passed as an argument. But since the cache is weak anyway, couldn't we simply cache the same homset twice, namely as Hom(X,Y) and as Hom(X,Y,C)? This would give a considerable speed-up.

Yes, I don't why we should not cache Hom(X,Y). By the way, is there a reason why Hom is not a (weak) cached function rather than handling it's cache by hand?

Thanks for your work on this! Get in touch with me in case no one volunteers shortly for the review.

Cheers, Nicolas

simon-king-jena commented 11 years ago
comment:5

Replying to @nthiery:

Yes, I don't why we should not cache Hom(X,Y).

OK, doing it soon.

In fact, what I do now: Try to implement the new coercion framework for all homsets (hence: Remove __call__, use element classes). I am already getting less than a couple of hundred errors...

By the way, is there a reason why Hom is not a (weak) cached function rather than handling it's cache by hand?

Yes, actually three reasons:

simon-king-jena commented 11 years ago
comment:6

The second patch is not tested yet. But it yields:

sage: sage: timeit("Hom(E,E)", number = 10^4)
10000 loops, best of 3: 1.9 µs per loop
sage: sage: timeit("Hom(E,F)", number = 10^4)
10000 loops, best of 3: 1.43 µs per loop
sage: sage: timeit("Hom(ZZ,QQ)", number = 10^4)
10000 loops, best of 3: 1.52 µs per loop

Apply trac_14214-cython_homset.patch trac_14214-backup_cache.patch

simon-king-jena commented 11 years ago
comment:7

The patchbot finds one failing test, and this is actually a test that relies on non-unique parent behaviour:

        Coercing a vector space morphism into the parent of a second vector
        space morphism will unify their parents. ::

            sage: U = QQ^3
            sage: V = QQ^4
            sage: W = QQ^3
            sage: X = QQ^4
            sage: H = Hom(U, V)
            sage: K = Hom(W, X)

            sage: A = matrix(QQ, 3, 4, [0]*12)
            sage: f = H(A)
            sage: B = matrix(QQ, 3, 4, range(12))
            sage: g = K(B)
            sage: f.parent() is g.parent()
            False

            sage: h = H(g)
            sage: f.parent() is h.parent()
            True

But in this example, at least with my patch, we have U is W and H is K. So, I think this test can be removed. Will do so in a minute.

simon-king-jena commented 11 years ago
comment:8

Done!

Apply trac_14214-cython_homset.patch trac_14214-backup_cache.patch

nbruin commented 11 years ago
comment:9

Replying to @simon-king-jena:

... those parts of Sage that still work with non-unique parents.

What follows is a bit of a rant that probably doesn't immediately affect your patch here. However, it's something I've seen crop up around the coercion/category framework in the last couple of weeks a little and I think has some relevance for work in this direction in general:

One design consideration I haven't seen mentioned much is that some parent constructors have very good reasons to return a fresh copy every time one is requested (i.e., don't do the cached_function metaclass thing that UniqueRepresentation does). The reason is that some parents are complicated structures (they tend to model complicated mathematical objects) that have too many aspects about them for them to be implemented as immutable. An example is EllipticCurve, where a Mordell-Weil basis is definitely not part of the construction data, but the basis choice (if found at all!) can be important and dealing with that unexpectedly changing is just too painful.

One could design these things differently, e.g., don't make the Mordell-Weil basis part of the elliptic curve itself, but the reality is that design choice hasn't been made in sage and changing that design now will be very difficult.

The problem arises elsewhere too: Sometimes you DO want multiple isomorphic-but-not-identical copies of objects. It's convenient to have multiple bivariate polynomial rings over QQ, for instance. We have that in sage by making generator names part of the definition of polynomial rings.

For other objects, for instance elliptic curves, there are no convenient generator names to distinguish multiple copies (the fundamental constructor being EllipticCurve([a1,a2,a3,a4,a6])), but there is still a reasonable need for having multiple nonidentical copies (never mind the practical mutability properties).

It terms of the coercion framework I have hopes these are not too much of an issue. I think elliptic curves are sufficiently high up the chain of complexity that people are happy to apply explicit homomorphisms to move things around, and hopefully this is true for most objects (the elephants in the room here are number fields: they are expected to behave nicely wrt coercion and yet are sufficiently complex that they have data hanging off them that makes it hard to consider them immutable. A lot of the problems may be hidden because it's relatively hard to run into exactly the same representation of the field twice)

The consistent thing for such non-unique-by-necessity parents is to make "==" indeed just "is", so that uniqueness is simply forced. This means

EllipticCurve([1,2,3,4,5]) != EllipticCurve([1,2,3,4,5])

which is fine with me, since they are isomorphic but not uniquely so. However, I'm not sure how easy it will be to convince other people of such a strict interpretation of "==".

Of course such EqualityById but non-CachedRepresentative parents are useless as dictionary keys in most situations in the sense that D[p]=a can always be written as p.D=a (the only way of getting a is by having a hold of p itself anyway and there is no way to regain a hold of p once you've lost it), but that might not be too much of an issue.

Anyway, please keep in mind: It's very unlikely (and probably even undesirable) that all parents will ever be essentially CachedRepresentative (or equivalent) and convenience requirements will likely prevent us from making all of them EqualityById. I suspect this puts bounds on how far you can push the coercion framework and various homomorphism caching strategies.

simon-king-jena commented 11 years ago
comment:10

Replying to @nbruin:

Replying to @simon-king-jena:

... those parts of Sage that still work with non-unique parents.

What follows is a bit of a rant that probably doesn't immediately affect your patch here.

Since the patch doesn't touch the way how things are cached, it doesn't affect the patch. But it does affect what I try to do next, so, let's discuss it.

One design consideration I haven't seen mentioned much is that some parent constructors have very good reasons to return a fresh copy every time one is requested (i.e., don't do the cached_function metaclass thing that UniqueRepresentation does). ...

For other objects, for instance elliptic curves, there are no convenient generator names to distinguish multiple copies (the fundamental constructor being EllipticCurve([a1,a2,a3,a4,a6])), but there is still a reasonable need for having multiple nonidentical copies (never mind the practical mutability properties).

It terms of the coercion framework I have hopes these are not too much of an issue.

They are an issue, if the equality-by-id paradigma is used in some parts and isn't used in other parts of the coercion framework. They are not an issue, if the coercion framework consistently uses equality-by-id even on non-unique parents, as I am now explaining.

I am fine with parents P1, P2 that evaluate equal but are non-identical. But then, I think it makes sense that Hom(P1,X) and Hom(P2,X) are non-identical, too. The question is whether they should evaluate equal, and here I actually have no very strong opinion, except that I like fast hash and fast equality test...

Let f be a coercion map from P1 to X. Now, we consider an element p of P2. In elder versions of Sage, it used to be the case that f was stored in a usual dictionary as an attribute of X. "Usual dictionary" means: If you have P2 and inspect the cache for a coercion to X, you'll find f. But f.domain() is not the same as P2.

Being "not the same as" P2 means: We can't simply coerce p via f---we must first coerce p into P1 and then apply f. Namely, if P1==P2, we certainly expect that there is a coercion from P2 to P1. But it could be that, for example, a non-trivial base change is involved in this coercion.

This is why now the coercion maps are stored in a MonoDict, whose keys are compared by identity: Looking for a coercion from P2 to X, you will correctly find that it has not been found yet, and so you have the chance to detect the coercion from P2 to X via P1 with a non-trivial base change.

I think this is a sane approach and it works fine with non-unique parents.

This is why I think that non-unique parents are not an issue in the coercion framework---but it is essential to be consistent, i.e., inside of the coercion framework one should consistently use comparison by identity even for non-unique parents (as in the P1-versus-P2 example above).

The consistent thing for such non-unique-by-necessity parents is to make "==" indeed just "is", so that uniqueness is simply forced. This means

EllipticCurve([1,2,3,4,5]) != EllipticCurve([1,2,3,4,5])

which is fine with me, since they are isomorphic but not uniquely so. However, I'm not sure how easy it will be to convince other people of such a strict interpretation of "==".

I think it is enough to convince the coercion model...

Of course such EqualityById but non-CachedRepresentative parents are useless as dictionary keys

In fact, this would be a problem. We can not simply use CachedRepresentation here, because this uses comparison-by-== (and, as I tried to explain, in the coercion model we want comparison-by-id even for non-unique parents).

But couldn't we make Homset have the ClasscallMetaclass, inherit from EqualityById and use the current cache logic from Hom in its __classcall__ method? The advantage would be that in some parts of Sage Homsets are created without calling the Hom function. Putting the Hom-logic into a __classcall__, we would have Homsets cached in this case, too!

Anyway, please keep in mind: It's very unlikely (and probably even undesirable) that all parents will ever be essentially CachedRepresentative (or equivalent) and convenience requirements will likely prevent us from making all of them EqualityById.

Inside of the coercion framework, I think nothing prevents us from comparing non-unique parents by identity---except old code. In that way, we have freedom to choose non-trivial coercions between non-identical copies of a parent.

simon-king-jena commented 11 years ago
comment:11

PS: What I currently try on top of this patch is merely to implement the new coercion framework (_elementconstructor and friends) for Homsets. Only if this is achieved, I would think of changing the cache logic.

simon-king-jena commented 11 years ago
comment:12

One remark on the reliability of the startup_time plugin:

With the previous version of the patches, we had:

-With 25% confidence, startup time increased by at least 0.25%
-With 44% confidence, startup time increased by at least 0.1%
+With 82% confidence, startup time decreased by at least 0.5%
+With 97% confidence, startup time decreased by at least 0.25%
+With 99.3% confidence, startup time decreased by at least 0.1%

Hence, we had a significant speed-up of 0.25% of startup time.

With the current version of the patches (which only differ in the removal of one doc test, but there is no change in the code!!), we have

-With 25% confidence, startup time increased by at least 0.25%
-With 44% confidence, startup time increased by at least 0.1%
+With 85% confidence, startup time increased by at least 0.5%
+With 98% confidence, startup time increased by at least 0.25%
+With 99.4% confidence, startup time increased by at least 0.1%

Hence, we have a significant slow-down of 0.25% of startup time.

But since the code didn't change, perhaps it isn't significant, after all?

nbruin commented 11 years ago
comment:13

Replying to @simon-king-jena:

I think it is enough to convince the coercion model...

Yes, I agree that the coercion model can work with "is" even if "==" doesn't. It may surprise people every now and again, but the alternative is almost certainly worse (besides being less efficient).

In fact, this would be a problem. We can not simply use CachedRepresentation here, because this uses comparison-by-== (and, as I tried to explain, in the coercion model we want comparison-by-id even for non-unique parents).

Good catch. However, the comparison-by-== happens on construction parameters. Hence, if someone implements some equal-but-not-identical rings R and S then we'll have

sage: R=my_equal_but_not_identical_ring()
sage: S=my_equal_but_not_identical_ring()
sage: P1=PolynomialRing(R,'x')
sage: P2=PolynomialRing(S,'x') #this will look up P1
sage: P1 is P2
True
sage: P2.base_ring() is R
True
sage: S(1)+P2.1
Error: No coercion from S into P2.

That's a serious problem. If you're implementing something that can be used in further (functorial) (cached) parent constructions that are expected to have coercions they have to be EqualityById. Otherwise you get the mess as above.

I doubt things like elliptic curves disappear into such cached parent constructions, which was why I thought the problem might not be that bad.

But couldn't we make Homset have the ClasscallMetaclass, inherit from EqualityById and use the current cache logic from Hom in its __classcall__ method?

Homsets are important in coercion and have proper mathematical meaning. If you make them consider their arguments by identity, you're basically telling people "you can use == as an equivalence relation on your class to some extent, but as far as maps are concerned, id is what really matters". I'm fine with that, but you are restricting people's ability to define and use == as they see fit.

Inside of the coercion framework, I think nothing prevents us from comparing non-unique parents by identity---except old code. In that way, we have freedom to choose non-trivial coercions between non-identical copies of a parent.

I think you're right on that in principle. There is a bit of an issue that "coercions between equal-but-not identical" parents are fundamentally between ""equivalent" objects, whereas the coercion framework seems to work best when there's a hierarchy. It's an invitation to loops in the coercion graph, inviting non-commuting paths. However, I think that's more an issue of the mathematical problem that of how we're modelling it.

simon-king-jena commented 11 years ago
comment:14

Needed to update both patches, because a patch in #14159 has changed.

Apply trac_14214-cython_homset.patch trac_14214-backup_cache.patch

simon-king-jena commented 11 years ago
comment:15

I made some changes to the __cmp__ method of homsets. The cmp behaviour changed in a way that is a problem in my attempt to use the new coercion model for morphisms.

Hence, cmp should be changed, and this ticket needs work!

simon-king-jena commented 11 years ago

Work Issues: Do not change cmp too much

simon-king-jena commented 11 years ago
comment:16

Updated!

Apply trac_14214-cython_homset.patch trac_14214-backup_cache.patch

simon-king-jena commented 11 years ago

Description changed:

--- 
+++ 
@@ -1,3 +1,8 @@
 Homsets arise whenever a coercion map or a conversion map is involved. Hence, homsets are ubiquitous in Sage and should thus be as a fast as possible.

 Therefore I suggest change sage.categories.homset into a cython module. A clean solution seems out of reach at the moment (see comments), and so I just provide a couple of speed-ups here.
+
+__Apply__
+
+* trac_14214-cython_homset.patch
+* trac_14214-backup_cache.patch
nbruin commented 11 years ago
comment:17

Do you have a reason to make domain and codomain cached? Their implementation already consists of just an attribute lookup. Are you getting a performance increase from it? There definitely are drawbacks:

simon-king-jena commented 11 years ago
comment:18

Replying to @nbruin:

Do you have a reason to make domain and codomain cached? Their implementation already consists of just an attribute lookup. Are you getting a performance increase from it?

Yes. See comment:2. A cached method is much much faster than a Python method that does nothing but return an attribute, and it is a little faster than directly accessing the attribute in a "timeit".

Things are slightly different if you access an attribute inside of a method. At some point, I had added a test method that accesses self._domain a million times or calls self.domain() a million times. In the case of HF in comment:2, the latter was faster, but in other cases (it seems: In cases with a short mro), direct attribute access was faster.

Anyway, that's why I like to keep the cached method and self._domain and self._codomain around.

Note, however, that one could get rid of self._domain and self._codomain. Namely, instead of

    self._domain = X

one could do

    self.domain.set_cache(X)

in Homset.__init__, and change the cached method into

@cached_method
def domain(self):
    pass

Note that keeping the _domain and _codomain attributes is actually a cheat:

Do you see a way to solve this problem in a clean way (i.e., with cdef attributes)? I'd appreciate to know!

nbruin commented 11 years ago
comment:19

Replying to @simon-king-jena:

Ah, I see your problems. So the issue is that for Homset methods to function, both self._domain and self._codomain must be accessible, but with your patch, neither of these attributes can be assigned or set. In that sense Homset is a kind of "abstract" base class: only subclasses can be instantiated. I think that is wrong, so in my opinion this puts the currently proposed solution on the wrong side of positive review. I think it's really quite a bad hack and we both know that "temporary" solutions rarely are that.

Perhaps declaring _domain and _codomain properties rather than cdef attributes solves the problem? Then at least providing the storage for those is solved within the class itself. You could use the store that the cached domain and codomain provide to store and fetch the value, since the natural thing -- a cdef attribute -- doesn't seem to work due to layout issues.

The alternative solution is to hack it so that the Homset type has tp_dictoffset initialized, if you absolutely want to store _domain and _codomain as dynamic attributes. This is something the cython folk have thought about in response to declaring a __dict__ attribute, but it didn't make it in (because it would disable various method lookup optimizations that we wouldn't have to disable here, if I understand the discussions there).

Note, however, that one could get rid of self._domain and self._codomain. Namely, instead of

    self._domain = X

one could do

    self.domain.set_cache(X)

Right ... with declaring them properties I suspect you could make this transparent for _domain and _codomain. Then we can still support them.

in Homset.__init__, and change the cached method into

@cached_method
def domain(self):
    pass

Note that keeping the _domain and _codomain attributes is actually a cheat:

Yes, thanks for explaining. Unfortunately that makes me really opposed to the current solution as explained above :-).

  • I would like to make _codomain and _domain cdef attributes, because this would be faster than a cached method. But this would result in a layout conflict, as stated in comment:1

I think they should be cdef attributes because that would allow for way faster access in cdef-type-declared cython code than property access would.

However, earlier experiments I've done make me hopeful that property access on python level will be fully competitive with cached methods (and even with python access of cdef public attributes, because I'm sure those are implemented via property).

The "proper" solution is to bite the bullet and resolve the layout conflict, either by code duplication (or can we avoid that via templating?) or by changing the inheritance structure. I can see why you don't want to do that on this ticket, but I think we need a better temporary solution than relying on attributes that can only be instantiated on a python subclass (which prevents us from having fully cdef Homset subclasses!)

tscrim commented 11 years ago
comment:21

Dependency on #13184 which modifies homset.py.

tscrim commented 11 years ago

Changed dependencies from #14159, #12951 to #14159, #12951, #13184

simon-king-jena commented 11 years ago
comment:22

Replying to @nbruin:

Perhaps declaring _domain and _codomain properties rather than cdef attributes solves the problem? Then at least providing the storage for those is solved within the class itself.

How?

I guess you refer to this documentation. It seems that you still need to provide your own way to store the property: The storage place is not provided automatically.

Hence, to provide some value in a property, you need to store the value, e.g., in a cdef attribute or in a usual Python attribute. You'd run into the same problems that made me suggest to use cached methods.

Properties have a getter, a setter and a deleter. These three methods are provided by cached methods: set_cache is the setter, calling it is the getter, and clear_cache is the deleter.

So, when you suggest to use properties, then I'd say dropping the attributes _domain and _codomain and using @cached_method instead (setting the domain or codomain during __init__ by means of set_cache) is more than a temporary hack.

You could use the store that the cached domain and codomain provide to store and fetch the value, since the natural thing -- a cdef attribute -- doesn't seem to work due to layout issues.

Right, there is the __cached_methods attribute. This could be used to store that values. Would be slower than calling a cached method without arguments, though.

  • I would like to make _codomain and _domain cdef attributes, because this would be faster than a cached method. But this would result in a layout conflict, as stated in comment:1

I think they should be cdef attributes because that would allow for way faster access in cdef-type-declared cython code than property access would.

However, earlier experiments I've done make me hopeful that property access on python level will be fully competitive with cached methods (and even with python access of cdef public attributes, because I'm sure those are implemented via property).

But properties still need to access the storage space. The storage space of a CachedMethodCallerNoArgs is very fast, since you won't even need a dictionary lookup (since it does not use __cached_methods for storing the value, in contrast to cached methods with arguments!).

... but I think we need a better temporary solution than relying on attributes that can only be instantiated on a python subclass (which prevents us from having fully cdef Homset subclasses!)

OK. Then dropping _domain and _codomain and using @cached_methods as an elegant substitute for properties seems the right thing to do to me.

simon-king-jena commented 11 years ago
comment:23

Replying to @simon-king-jena:

Right, there is the __cached_methods attribute. This could be used to store that values. Would be slower than calling a cached method without arguments, though.

No, it is more tricky. If you have a class that allows attribute assignment, then __cached_method is indeed not used. But if you have a class that does not allow attribute assignment, then __cached_method is used to hold the cached method caller itself (i.e., it does not contain the cache but the cached method).

What do you think of doing the following in __init__:

    try:
        self._domain = D
    except AttributeError:
        self.__cached_method['_domain'] = D

This would be enough to provide _domain as an attribute. For speed reasons, it might still be a good idea to have a cached method domain().

nbruin commented 11 years ago
comment:24

For me, the self.__cached_method['_domain'] = D does not work. Furthermore, are you sure property access is not competitive? My tests seem to indicate it is:

sage: cython("""
sage: from sage.misc.cachefunc import cached_method
sage: from sage.structure.parent cimport Parent
sage: cdef class T(sage.structure.parent.Parent):
...       @cached_method
...       def C(self):
...           return 10
...       property B:
...           def __get__(self):
...               return self.__cached_methods['C'].cache
...           def __set__(self,value):
...               self.__cached_methods['C'].cache=value
...
sage: """)
sage: A=T()
sage: A.C()
10
sage: A.B
10
sage: timeit('A.C()')
625 loops, best of 3: 389 ns per loop
sage: timeit('A.B')
625 loops, best of 3: 229 ns per loop
sage: A.C()
20
nbruin commented 11 years ago
comment:25

Oh, OK, but WHERE things are stored may change if the subclass has a writeable attribute dictionary, so we have to be a little more careful:

sage: cython("""
sage: from sage.misc.cachefunc import cached_method
sage: from sage.structure.parent cimport Parent
sage: cdef class T(sage.structure.parent.Parent):
...       @cached_method
...       def C(self):
...           return 10
...       property B:
...           def __get__(self):
...               if self.__cached_methods is not None:
...                   return self.__cached_methods['C'].cache
...               else:
...                   return self.C.cache
...           def __set__(self,value):
...               if self.__cached_methods is not None:
...                   self.__cached_methods['C'].cache=value
...               else:
...                   self.C.cache=value
...
sage: """)
sage: A=T()
sage: timeit('A.C()')
625 loops, best of 3: 389 ns per loop
sage: timeit('A.B')
625 loops, best of 3: 201 ns per loop
sage: class TT(T): pass
sage: D=TT()
sage: timeit('D.C()')
625 loops, best of 3: 198 ns per loop
sage: timeit('D.B')
625 loops, best of 3: 209 ns per loop
sage: D.v=10
sage: timeit('D.v')
625 loops, best of 3: 202 ns per loop

and indeed, now we see that a cached method (if possible) inserts things a little further down the MRO if possible and hence gets a speed increase.

nbruin commented 11 years ago
comment:26

Hm, two observations:

First Parent derived.

sage: %cpaste
Pasting code; enter '--' alone on the line to stop or use Ctrl-D.
:class T(sage.structure.parent.Parent):
:    w=100
:    def __init__(self):
:        self.v=100
:    @cached_method
:    def t(self):
:        return 100
:--
sage: 
sage: P=T
sage: for i in [1..1000]:
....:         P=type("T%d"%i,(P,),{"a%i"%i:1,"b%i"%i:2})
....:         globals()["T%d"%i]=P
....:     
sage: C=T1000(); c0=T()
sage: timeit('C.v',number=20000)
20000 loops, best of 3: 26 µs per loop
sage: timeit('c0.v',number=20000)
20000 loops, best of 3: 111 ns per loop
sage: timeit('C.w',number=20000)
20000 loops, best of 3: 22.6 µs per loop
sage: timeit('c0.w',number=20000)
20000 loops, best of 3: 73.8 ns per loop
sage: timeit('C.t()',number=20000)
20000 loops, best of 3: 38.3 µs per loop
sage: timeit('c0.t()',number=20000)
20000 loops, best of 3: 101 ns per loop

As you can see, everything on C is way slower than on c0, probably because there's a 1000 deep MRO to contend with. However, I see no indication that cached_method is ever faster than a straight attribute lookup.

Next object derived:

sage: %cpaste
Pasting code; enter '--' alone on the line to stop or use Ctrl-D.
:class T(object):
:    w=100
:    def __init__(self):
:        self.v=100
:    @cached_method
:    def t(self):
:        return 100
:--
sage: 
sage: P=T
sage: for i in [1..1000]:
....:         P=type("T%d"%i,(P,),{"a%i"%i:1,"b%i"%i:2})
....:         globals()["T%d"%i]=P
....:     
sage: C=T1000(); c0=T()
sage: timeit('C.v',number=200000)
200000 loops, best of 3: 69.8 ns per loop
sage: timeit('c0.v',number=200000)
200000 loops, best of 3: 66.7 ns per loop
sage: timeit('C.w',number=200000)
200000 loops, best of 3: 53.6 ns per loop
sage: timeit('c0.w',number=200000)
200000 loops, best of 3: 54.1 ns per loop
sage: timeit('C.t()',number=200000)
200000 loops, best of 3: 77.4 ns per loop
sage: timeit('c0.t()',number=200000)
200000 loops, best of 3: 77.6 ns per loop

No penalty at all for large inheritance depth! Same pattern: instance attribute is a little slower than a class attribute and cached_method is always a little slower than either.

simon-king-jena commented 11 years ago
comment:27

Replying to @nbruin:

For me, the self.__cached_method['_domain'] = D does not work.

Should be self.__cached_methods['_domain'] = D with "s".

Furthermore, are you sure property access is not competitive? My tests seem to indicate it is:

sage: cython("""
sage: from sage.misc.cachefunc import cached_method
sage: from sage.structure.parent cimport Parent
sage: cdef class T(sage.structure.parent.Parent):
...       @cached_method
...       def C(self):
...           return 10
...       property B:
...           def __get__(self):
...               return self.__cached_methods['C'].cache
...           def __set__(self,value):
...               self.__cached_methods['C'].cache=value
...
sage: """)
sage: A=T()
sage: A.C()
10
sage: A.B
10
sage: timeit('A.C()')
625 loops, best of 3: 389 ns per loop
sage: timeit('A.B')
625 loops, best of 3: 229 ns per loop
sage: A.C()
20

I did not say that it is not competitive. I said that a CachedMethodCallerNoArgs is a property, since it has a setter and a getter, with the only difference that for accessing it you need to do A.C() and not just A.C.

If you would define it as a property, then you needed to implement setter and getter by yourself. A cached method is more comfortable to use.

simon-king-jena commented 11 years ago
comment:28

Replying to @nbruin:

No penalty at all for large inheritance depth! Same pattern: instance attribute is a little slower than a class attribute and cached_method is always a little slower than either.

That's interesting. I can reproduce it, but it totally contradicts all my previous experience with cached methods.

nbruin commented 11 years ago
comment:29

Replying to @simon-king-jena:

I said that a CachedMethodCallerNoArgs is a property, since it has a setter and a getter

It doesn't have a setter as far as I can see, so it's a "nondata descriptor". Python treats those differently, e.g., they can be overridden by an instance attribute (which is very important for your application!)

with the only difference that for accessing it you need to do A.C() and not just A.C.

which means extra overhead: After looking up A.C, executing C.__get__, there is still a call to be processed on the returned object from that! A "property" would already be done by then.

If you would define it as a property, then you needed to implement setter and getter by yourself. A cached method is more comfortable to use.

but with more overhead.

I suspect that (an optimized version of) lazy_attribute is potentially better for argumentless "cached methods". Incidentally, looking at the code there, it seems that lazy_attribute prefers to store the result in __cached_methods rather than use setattr to override the descriptor. Overriding the descriptor (i.e., storing the value in the instance attribute dict if available) should be faster and preferable to looking up in __cached_methods.

Shouldn't lazy_attribute be cythonized too?

nbruin commented 11 years ago
comment:30

Replying to @simon-king-jena:

That's interesting. I can reproduce it, but it totally contradicts all my previous experience with cached methods.

I think we got to the root of this: up to 0.18, cython would compile its classes without "attribute lookup cache support". Some unfortunate semantics in python mean that for instance attributes, the whole MRO needs to be walked, so they form the worst case. I think that's why you were finding cached_method faster sometimes: That's a class attribute lookup so likely succeeds a little earlier. With a deep MRO that might be enough to offset the overhead from calling that's involved in a cached_method.

In all likelihood from 0.19 onwards (or earlier if we patch cython in sage), will enable "attribute lookup cache support" on the classes it defines, in which case the result from a full MRO lookup will be cached (also when it doesn't find anything), so after the first access, we don't have to walk the MRO anymore. This speeds up all attribute access considerably (sage doctesting seems to speed up something like 5-10% on average) and it makes normal instance attribute access faster than a cached_method call.

Given that, I think we SHOULD generally prefer instance attributes over no-arg cached_method.

In fact, with the attribute lookup cache in place, the difference between a property an a cached_method becomes even more pronounced:

sage: cython("""
....: include "../ext/stdsage.pxi"
....: include "../ext/python_bool.pxi"
....: from sage.misc.cachefunc import cached_method
....: from sage.structure.parent cimport Parent
....: cdef class T(Parent):
....:         cdef public int D
....:         @cached_method
....:         def C(self):
....:                 return 10
....:         property B:
....:                 def __get__(self):
....:                         return self.__cached_methods['c']
....:                 def __set__(self,value):
....:                         self.__cached_methods['c']=value
....:             
....:             """)
....:             
sage: A=T()
sage: A.C()
10
sage: A.B=10
sage: timeit('A.C()',number=800000,repeat=30)
800000 loops, best of 30: 183 ns per loop
sage: timeit('A.B',number=800000,repeat=30)
800000 loops, best of 30: 58.1 ns per loop
sage: timeit('A.D',number=800000,repeat=30)
800000 loops, best of 30: 47 ns per loop

For reference, without attribute cache this is:

sage: timeit('A.C()',number=800000,repeat=30)
800000 loops, best of 30: 195 ns per loop
sage: timeit('A.B',number=800000,repeat=30)
800000 loops, best of 30: 68.5 ns per loop
sage: timeit('A.D',number=800000,repeat=30)
800000 loops, best of 30: 60.7 ns per loop

So in order of decreasing precedence, storage should be:

The main advantage of cached_method is its spelling. So if we could make a version of @cached_method (say @computed_property) that produces a data descriptor (based on self.__cached_methods), where the __get__ can basically be what is now the __call__ you'd get a different spelling and better performance out of noarg cached "methods". If you make sure that __set__ is NULL you could even use self.__dict__ to cache the result and never get called on __get__ again (you'd provide a nondata descriptor, so you'd get shadowed by the instance attribute once installed)

simon-king-jena commented 11 years ago
comment:31

Sorry, I lost track on this ticket. I am now trying to catch up.

simon-king-jena commented 11 years ago
comment:32

The "Work issue" field was totally outdated, because "cmp" has already been addressed---see comment:16

Currently, the issue is the "property" thingy. I will do some tests (as we have seen elsewhere, using @property in Cython is slower than "property Bla: def get...".

simon-king-jena commented 11 years ago

Changed work issues from Do not change cmp too much to Use property for domain and codomain

simon-king-jena commented 11 years ago
comment:33

This is why I am not happy with "property":

sage: cython("""
from sage.structure.parent cimport Parent
cdef class Bla(Parent):
    property _domain:
        def __get__(self):
            try:
                return self.__cached_methods['_domain']
            except KeyError:
                raise AttributeError("%s instance has no attribute '_domain'"%type(self))
            except TypeError:
                self.__cached_methods = {}
                raise AttributeError("%s instance has no attribute '_domain'"%type(self))
        def __set__(self, value):
            try:
                self.__cached_methods['_domain'] = value
            except TypeError:
                self.__cached_methods = {}
                self.__cached_methods['_domain'] = value
""")
....: 
sage: class Blubb(Bla): pass
sage: b = Bla()
sage: c = Blubb()
sage: b._domain = 1
sage: c._domain = 1
sage: c._codomain = 2
sage: %timeit x = b._domain
10000000 loops, best of 3: 133 ns per loop
sage: %timeit x = c._domain
10000000 loops, best of 3: 144 ns per loop
sage: %timeit x = c._codomain
10000000 loops, best of 3: 110 ns per loop

So, the property becomes slower on the subclass than on the base class, and a usual attribute on the subclass is faster than the property on the base class.

I don't know if a solution to this problem is proposed in your comments (perhaps "cythoning lazy attributes"?).

simon-king-jena commented 11 years ago

Changed work issues from Use property for domain and codomain to How to store domain and codomain?

simon-king-jena commented 11 years ago
comment:34

I don't know why, but it seems that putting something into __cached_methods does not mean that it will be available as an attribute. But this used to be the case! What is happening here?

simon-king-jena commented 11 years ago
comment:35

This is Parent.__getattr__:

    def __getattr__(self, str name):
        if (name.startswith('__') and not name.endswith('_')) or self._category is None:
            dummy_error_message.cls = type(self)
            dummy_error_message.name = name
            raise dummy_attribute_error
        try:
            return self.__cached_methods[name]
        except KeyError:
            attr = getattr_from_other_class(self, self._category.parent_class, name)
            self.__cached_methods[name] = attr
            return attr
        except TypeError:
            attr = getattr_from_other_class(self, self._category.parent_class, name)
            self.__cached_methods = {name:attr}
            return attr

So, it 'should' be possible to get the argument from __cached_methods, isn't it?

simon-king-jena commented 11 years ago
comment:36

Aha! Now I see the problem. In my example above, I did not properly initialise the category.

simon-king-jena commented 11 years ago
comment:37
sage: cython("""from sage.all import Sets
from sage.structure.parent cimport Parent
cdef class Bla(Parent):
    def __init__(self, domain, codomain):
        try:
            self._domain_ = domain
            self._codomain_ = codomain
        except AttributeError:
            if self.__cached_methods is None:
                self.__cached_methods = {'_domain_':domain, '_codomain_':codomain}
            else:
                self.__cached_methods['_domain_'] = domain
                self.__cached_methods['_codomain_'] = codomain
        Parent.__init__(self, category= Sets())
""")
....: 
sage: class Blubb(Bla): pass
sage: b = Bla(1,2)
sage: c = Blubb(1,2)
sage: %timeit x = b._domain_
1000000 loops, best of 3: 826 ns per loop
sage: %timeit x = c._domain_
10000000 loops, best of 3: 114 ns per loop

Hmmmm. If it is possible to set attributes directly, then it is faster than a property. But otherwise, a property is much faster than using __cached_method for attribute lookup, even if this property uses __cached_method. And the property tends to become slower on sub-classes.

What a dilemma...

simon-king-jena commented 11 years ago
comment:38

My frustration level raises.

Ideally, one would write a descriptor

Didn't you say that a property that only defines __get__ can be overridden in the instance's dict? So, I thought I just define __get__ and not set, and put data into __dict__ when possible.

However, it does not work:

sage: cython("""
....: from sage.all import Sets
....: from sage.structure.parent cimport Parent
....: cdef class Bla(Parent):
....:     def __init__(self, D,C):
....:         try:
....:             self.__dict__['_domain'] = D
....:             self.__dict__['_codomain'] = C
....:         except AttributeError:
....:             if self.__cached_methods is None:
....:                 self.__cached_methods = {'_domain':D, '_codomain':C}                                                                                                                                
....:             else:
....:                 self.__cached_methods['_domain'] = D                                                                                                                                                
....:                 self.__cached_methods['_codomain'] = C                                                                                                                                              
....:         Parent.__init__(self, category = Sets())
....:     property _domain:
....:         def __get__(self):                                                                                                                                                                          
....:             try:                                                                                                                                                                                    
....:                 return self.__cached_methods['_domain']
....:             except KeyError:                                                                                                                                                                        
....:                 raise AttributeError("%s instance has no attribute '_domain'"%type(self))
....: """)
....: 
sage: class Blubb(Bla): pass                                                                                                                                                                              
sage: b = Bla(1,2)
sage: c = Blubb(1,2)
sage: b._domain                                                                                                                                                                                           
1                                                                                                                                                                                                         
sage: c._domain                                                                                                                                                                                          
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-19-9505626a8e7d> in <module>()
----> 1 c._domain

/home/simon/SAGE/prerelease/sage-5.9.rc0/local/lib/python2.7/site-packages/sage/structure/parent.so in sage.structure.parent.Parent.__getattr__ (sage/structure/parent.c:6501)()

/home/simon/SAGE/prerelease/sage-5.9.rc0/local/lib/python2.7/site-packages/sage/structure/misc.so in sage.structure.misc.getattr_from_other_class (sage/structure/misc.c:1591)()

AttributeError: 'Blubb_with_category' object has no attribute '_domain'
sage: c.__dict__['_domain']
1

So, can you please clarify: What kind of descriptors can be overridden in __dict__?

simon-king-jena commented 11 years ago
comment:39

Apparently it is "new style versus old style classes:

sage: class Bla(object):
....:     @property
....:     def _domain(self):
....:         return 1
....:     
sage: b = Bla()
sage: b._domain
1
sage: b._domain = 3
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-25-355f52a2815c> in <module>()
----> 1 b._domain = Integer(3)

AttributeError: can't set attribute
sage: b.__dict__['_domain'] = 3
sage: b._domain
1

versus

sage: class Bla:
    @property
    def _domain(self):
        return 1
....:     
sage: b = Bla()
sage: b._domain
1
sage: b._domain = 4
sage: b._domain
4
sage: del b.__dict__['_domain']
sage: b._domain
1
sage: b.__dict__['_domain'] = 3
sage: b._domain
3

So, we are stuck.

nbruin commented 11 years ago
comment:40

I think you want the following code:

from sage.all import Sets
from sage.structure.parent cimport Parent

cdef class CachedAttributeType(object):
    cdef public str name
    def __init__(self, name):
        self.name = name
    def __get__(self,instance,cls):
        try:
            return instance.__cached_methods[self.name]
        except KeyError:
            raise AttributeError("%s instance has no attribute '%s'"%(cls,self.name))

cdef class Bla(Parent):
    def __init__(self, D,C):
        try:
            self.__dict__['_domain'] = D
            self.__dict__['_codomain'] = C
        except AttributeError:
            if self.__cached_methods is None:
                self.__cached_methods = {'_domain':D, '_codomain':C}
            else:
                self.__cached_methods['_domain'] = D
                self.__cached_methods['_codomain'] = C
        Parent.__init__(self, category = Sets())
    _domain=CachedAttributeType("_domain")
    _codomain=CachedAttributeType("_codomain")

Timings we'll be getting with the cython upgrade (with Python's attribute lookup cache enabled)

sage: b=Bla("dommi","codommi")
sage: b._domain
'dommi'
sage: %timeit b._domain
10000000 loops, best of 3: 97.6 ns per loop
sage: c=Cla("dommi","codommi")
sage: %timeit c._domain
10000000 loops, best of 3: 50 ns per loop

With the old cython (where MRO depth is a big factor in attribute lookup):

sage: %timeit b._domain
10000000 loops, best of 3: 130 ns per loop
sage: %timeit c._domain
10000000 loops, best of 3: 78.5 ns per loop

What we should really do is get rid of __cached_methods make sure these objects have a __dict__ instead, where such information can be stored much more effectively. There is an "empty dict" value one can use as a place holder for empty dictionaries. There's really no good reason why cython types cannot have a dictionary.

simon-king-jena commented 11 years ago
comment:41

What do you think? Should we create a new dependency for this ticket, namely: "Use cython for lazy_attribute and create a fast non-data descriptor"?

simon-king-jena commented 11 years ago
comment:42

Needs to be rebased, as it seems .

simon-king-jena commented 11 years ago

Changed work issues from How to store domain and codomain? to Rebase; how to store domain and codomain?

simon-king-jena commented 11 years ago
comment:43

I don't see what additional dependencies we need for the first patch.

simon-king-jena commented 11 years ago

Cache for Hom with default category

simon-king-jena commented 11 years ago
comment:44

Attachment: trac_14214-backup_cache.patch.gz

Updated.

Apply trac_14214-cython_homset.patch trac_14214-backup_cache.patch