On Sat, 19 Aug 2023 at 22:52, David Bailey <
da...@dbailey.co.uk> wrote:
>
> On 16/08/2023 16:13, Oscar Benjamin wrote:
> > Hi all,
> >
> > I have written a blog post about recent work on SymPy and what I think
> > should be future development plans:
> >
> >
https://oscarbenjamin.github.io/blog/czi/post1.html
> >
> > This is the first in a series of posts and in this one I describe what
> > I think are the core parts of SymPy and outline from a high level what
> > I think should be done going forwards. Subsequent posts will describe
> > specific components in more detail.
> >
> > Oscar
> >
> I do wonder slightly if pushing for higher speed in symbolic operations
> is worth it if it causes a lot of disruption, or distracts from other
> work that might be more useful.
I think it is worth noting that most of the changes I am proposing are
also good ideas for other reasons besides just speed. I have to
emphasise speed though because I have promised to improve speed and I
need to write about that. The changes are also good though for
reducing bugs, making the codebase easier to maintain, adding features
that users want etc.
> Symbolic calculations are wonderful in the right circumstances, however,
> the cost of symbolic calculations grows pretty steeply with the size of
> the problem.
>
> Everything becomes clumsier, for example fractional coefficients get
> much more complicated after a few mathematical steps.
What you say is true but...
> That means that multiplying the raw computational speed by 10 (say) may
> only expand the pool of problems that can be solved in a reasonable time
> very slightly.
I think that you greatly underestimate the enormity of the problem of
some things in SymPy being *much* slower than they could be. Here is a
simple example:
With SymPy 1.12 I will time computing the inverse of a matrix or
integers (note that I have gmpy2 installed in all cases which does
affect the timings).
The randMatrix function creates a matrix of a given size having
integer entries from 0 to 99:
In [3]: print(repr(randMatrix(4)))
Matrix([
[ 4, 61, 23, 16],
[88, 22, 34, 57],
[84, 77, 47, 60],
[74, 33, 66, 32]])
Let's time with random matrices of size 1x1, 2x2, 5x5 etc up to 18x18
(Wall time is the thing to look at):
In [5]: for n in [1,2,5,10,15,16,17,18]:
...: M = randMatrix(n)
...: print(f'\ntime for inverse of {n}x{n} matrix of small
integers (SymPy 1.12)')
...: %time ok = M.inv()
...:
time for inverse of 1x1 matrix of small integers (SymPy 1.12)
CPU times: user 4.12 ms, sys: 17 µs, total: 4.13 ms
Wall time: 4.16 ms
time for inverse of 2x2 matrix of small integers (SymPy 1.12)
CPU times: user 3.7 ms, sys: 0 ns, total: 3.7 ms
Wall time: 3.72 ms
time for inverse of 5x5 matrix of small integers (SymPy 1.12)
CPU times: user 25.2 ms, sys: 0 ns, total: 25.2 ms
Wall time: 25 ms
time for inverse of 10x10 matrix of small integers (SymPy 1.12)
CPU times: user 60.3 ms, sys: 0 ns, total: 60.3 ms
Wall time: 60.1 ms
time for inverse of 15x15 matrix of small integers (SymPy 1.12)
CPU times: user 2.29 s, sys: 13.3 ms, total: 2.3 s
Wall time: 2.3 s
time for inverse of 16x16 matrix of small integers (SymPy 1.12)
CPU times: user 7.36 s, sys: 10 ms, total: 7.37 s
Wall time: 7.53 s
time for inverse of 17x17 matrix of small integers (SymPy 1.12)
CPU times: user 17.9 s, sys: 33.7 ms, total: 18 s
Wall time: 18 s
time for inverse of 18x18 matrix of small integers (SymPy 1.12)
CPU times: user 1min 30s, sys: 117 ms, total: 1min 30s
Wall time: 1min 30s
There is no way that it should be that slow. The big-O is also all
wrong and is exponential or worse.
With the current SymPy master branch it is:
In [24]: for n in [1,2,5,10,15,16,17,18]:
...: M = randMatrix(n)
...: print(f'\ntime for inverse of {n}x{n} matrix of small
integers (master)')
...: %time ok = M.inv()
...:
time for inverse of 1x1 matrix of small integers (master)
CPU times: user 34.8 ms, sys: 0 ns, total: 34.8 ms
Wall time: 34.4 ms
time for inverse of 2x2 matrix of small integers (master)
CPU times: user 1.58 ms, sys: 0 ns, total: 1.58 ms
Wall time: 1.59 ms
time for inverse of 5x5 matrix of small integers (master)
CPU times: user 3.15 ms, sys: 0 ns, total: 3.15 ms
Wall time: 3.16 ms
time for inverse of 10x10 matrix of small integers (master)
CPU times: user 16 ms, sys: 0 ns, total: 16 ms
Wall time: 16 ms
time for inverse of 15x15 matrix of small integers (master)
CPU times: user 21.7 ms, sys: 0 ns, total: 21.7 ms
Wall time: 21.7 ms
time for inverse of 16x16 matrix of small integers (master)
CPU times: user 21.2 ms, sys: 0 ns, total: 21.2 ms
Wall time: 21.2 ms
time for inverse of 17x17 matrix of small integers (master)
CPU times: user 18.8 ms, sys: 0 ns, total: 18.8 ms
Wall time: 18.8 ms
time for inverse of 18x18 matrix of small integers (master)
CPU times: user 22.9 ms, sys: 0 ns, total: 22.9 ms
Wall time: 22.9 ms
The difference for an 18x18 matrix is that master is 4000x faster than
SymPy 1.12 but this ratio increases as you go to larger sizes. With
SymPy 1.12 I have to wait 1.5 minutes for an 18x18 matrix. With
current master I can invert a 300x300 matrix in that time:
In [6]: %time ok = randMatrix(300).inv()
CPU times: user 1min 29s, sys: 136 ms, total: 1min 29s
Wall time: 1min 29s
I estimate that with SymPy 1.12 the complexity of this operation is
something like O(3^n) and so the time to invert a 300x300 matrix would
be something like:
In [9]: 90*(3**(300-18)) # units of seconds
Out[9]:
318006751451727010731725715838119971940403301857387549847246646448097631356170140331478920672468737
83545669933976619042089991647160696810
That's about 1e120 times the age of the universe.
So no some things are very slow and can be made a lot faster. It does
also mean a significant increase in problem size (e.g. from 18x18 to
300x300 in this example). The particular operation that I showed here
can be made faster still but hopefully it gives a sense of the
magnitude of speedups that are possible.
Another example is this:
https://github.com/sympy/sympy/issues/19920
In that issue SymPy is essentially unable to compute the inverse of a
3x3 matrix with simple symbolic expressions as the entries:
In [19]: from sympy import symbols, Matrix
...:
...: a, b, c, d = symbols('a b c d')
...:
...: Z = Matrix([
...: [1/b + 1/a, -1/b, 0],
...: [ -1/b, 1/c + 1/b, -1/c],
...: [ 0, -1/c, 1/d + 1/c]])
...:
...: %time Zinv = Z.inv()
^C---------------------------------------------------------------------------
KeyboardInterrupt
I left that running for 5 minutes before giving up. It was previously
faster in older SymPy versions but then slowed down because of a
change in algorithm. The change of algorithm was motivated by speeding
up some other things though. The problem is that in the current
implementation it is difficult to choose the best algorithm because we
do not have any concept like "domains" that we can use to classify
arbitrary expressions and then consider which algorithm to use.
There is now a faster matrix class called DomainMatrix that can do
this and uses domains to distinguish different kinds of expressions.
With current master you can create this matrix with the to_DM()
method:
In [20]: dZ = Z.to_DM()
In [21]: dZ
Out[21]: DomainMatrix({0: {0: (a + b)/(a*b), 1: -1/b}, 1: {0: -1/b, 1:
(b + c)/(b*c), 2: -1/c}, 2: {1: -1/c, 2: (c + d)/(c*d)}}, (3, 3),
ZZ(a,b,c,d))
The important part to notice is the domain: ZZ(a,b,c,d) which says
that we use rational functions (ratios of polynomials) in the 4
symbols a, b, c and d. We can convert a matrix to this format and
compute the result and convert back to an ordinary matrix:
In [22]: %time Zinv = Z.to_DM().inv().to_Matrix()
CPU times: user 75.5 ms, sys: 0 ns, total: 75.5 ms
Wall time: 73 ms
In [23]: print(repr(Zinv))
Matrix([
[(a*b + a*c + a*d)/(a + b + c + d), (a*c + a*d)/(a + b + c
+ d), a*d/(a + b + c + d)],
[ (a*c + a*d)/(a + b + c + d), (a*c + a*d + b*c + b*d)/(a + b + c
+ d), (a*d + b*d)/(a + b + c + d)],
[ a*d/(a + b + c + d), (a*d + b*d)/(a + b + c
+ d), (a*d + b*d + c*d)/(a + b + c + d)]])
Now the time goes from an hour to 70 milliseconds. We can also make
this faster still very easily. Currently on master this faster class
is not used in this case but it is in the integer case above. What is
needed is to enable this for more cases.
> There is also the problem that complicated symbolic results are often
> not very useful. For example the solutions of a quadratic equation are
> well known and very useful. However, the corresponding solutions for
> cubic or quartic equations are really only of theoretical interest. It
> is even possible to get solutions for quintic equations (so I am told)
> by using theta functions. My point is, that these are of no practical use.
No but we can still represent the roots of polynomials of higher
degree as RootOf:
In [15]: M = randMatrix(5)
In [16]: print(M.eigenvals())
{CRootOf(x**5 - 287*x**4 + 8804*x**3 + 352285*x**2 - 12562287*x +
173719704, 0): 1, CRootOf(x**5 - 287*x**4 + 8804*x**3 + 352285*x**2 -
12562287*x + 173719704, 1): 1, CRootOf(x**5 - 287*x**4 + 8804*x**3 +
352285*x**2 - 12562287*x + 173719704, 2): 1, CRootOf(x**5 - 287*x**4 +
8804*x**3 + 352285*x**2 - 12562287*x + 173719704, 3): 1, CRootOf(x**5
- 287*x**4 + 8804*x**3 + 352285*x**2 - 12562287*x + 173719704, 4): 1}
Knowing the minimal polynomial of each eigenvalue is sufficient (in
the computational algebra subsystem) to compute all of the
eigenvectors and do many more things without needing any quintic
formula.
At some point I will write something to explain why even the quadratic
formula should be avoided.
> Do you have some motivational examples of what can be achieved by moving
> from SymPy to SymEngine? (I just use SymEngine for comparison purposes)
As far as I know the advantages of using SymEngine are only for speed.
SymEngine does not have as many features as SymPy but it has many of
the "core" parts and it is *much* faster for those.
There are other reasons for moving SymPy to a new design of symbolic
subsystem that are not only motivated by speed though but would also
make things a lot faster. I will explain more later.
If you are concerned about the time spent working on this then
understand that for many of these things much of the hard work has
already been done. There is now a faster implementation of e.g. linear
algebra (DomainMatrix) in SymPy already and most of the difficult code
for it has already been written but it is not yet being used by
default. Users can use it directly if they want to (to_DM()) but
really most users want to call high-level functions liks solve etc and
so what needs to be done is to flip a few switches so that the fast
code gets used by default instead of the slow code. Then things will
be faster automatically for end users. Here are three pull requests
that show examples of this:
https://github.com/sympy/sympy/pull/25458 (charpoly)
https://github.com/sympy/sympy/pull/25443 (rref for ZZ/QQ)
https://github.com/sympy/sympy/pull/25452 (inv)
It might not be obvious when looking at those pull requests but in
each case I had to limit the scope and argue carefully about what
operations may or may not be affected by the more limited change I was
proposing. I have to do this so that other SymPy developers can
understand and hopefully merge the changes but actually it would be
better not to limit the scope in this way because it means that we are
not improving all of the things that can be improved.
The problem though is that enabling the faster code fully would change
the form of outputs in some cases and would "break" some test cases in
the test suite. Many people who might review a pull request might
object to e.g. test cases being changed but this is actually what
needs to happen. I have so far avoided more disruptive changes because
I just wanted to get some pull requests merged that showed some
significant speed ups so that at least some effect of the changes
would be visible.
To get other SymPy contributors to understand the need for making
these changes I need to ensure that they understand what the changes
are and what their impact is which is partly why I'm writing these
blog posts. Hopefully if more developers have a good understanding of
these things then it will be easier for them to review these changes
and at least people will have a better understanding of the reasons
for them. Also for some things it is actually better if users
understand how to use them rather than just expecting SymPy itself to
figure out the best algorithm for them.
--
Oscar