Context
The following is an issue I have come across many times and still do not have a satisfactory solution for.
The context of it is this. Let's say we have an equation, a linear ODE for the function f
of the variable x
:
equation = c2[x] f''[x] + c1[x] f'[x] + c0[x] f[x] + d0[x] == 0
each of the coefficients depends on x
, but they can also depend on another function(s), let's say g[x]
, or on other parameter(s), let's say p
.
We want to solve this equation numerically, therefore we discretize the variable x
onto some grid, let's say
grid = {1.,2.,3.,4.};
The derivatives will of course become matrices, but this question is only about the coefficients, which become vectors.
At this point an example is probably useful. The equation above is not important, we can focus on one of the coefficients, which could for example take the form:
c = (Sinh[1/2 (g x^4 + x^2)] + p^3 x/ (g - x^3)) g^2 x^2;
(Note: I hope this is a sufficiently complicated example, but any simplifying structure here is unintended, the dependence on the variable x
, the function(s) g
and the parameters p
can be arbitrary)
Of course x
is known ahead of time (grid
above), but p
and g
are not.
Imagine a sequence of computations running many times in a loop, that contains the pieces:
(* ... *)
p = 3.; (* actually some computation *)
g = {0.714, 0.429,0.734, 0.921}; (* some other computation *)
cComputed = Block[{x = grid}, c]
(* use this vector c to construct and solve the discretized equation *)
{-47.6352, 78.8935, 1.77501*10^15, 3.19207*10^55}
Done this way, at every pass through the loop everything in c
is computed from scratch.
However since we know x
ahead of time, we can already do a big part of the computations:
cPartlyComputed = g^2({1.`, 4.`, 9.`, 16.`} Sinh[{0.5`, 8.`, 40.5`, 128.`} g + {0.5`, 2.`, 4.5`, 8.`}]
+ p^3 {1.`, 8.`, 27.`, 64.`}/(g - {1.`, 8.`, 27.`, 64.`}));
Note that I have not merely inserted {1.,2.,3.,4.}
for x
, but also computed x^2
, 1/2 x^2
and 1/2 x^4
.
speed comparison
A quick test to show that this gives a significant speedup:
without = Function[{g, p, x}, g^2 x^2 ((p^3 x)/(g - x^3) + Sinh[1/2 (x^2 + g x^4)])];
with = Function[{g, p}, g^2 ({1.`, 4.`, 9.`, 16.`} Sinh[g {0.5`, 8.`, 40.5`, 128.`} + {0.5`, 2.`, 4.5`, 8.`}] + (p^3 {1.`, 8.`, 27.`, 64.`})/(g + {-1.`, -8.`, -27.`, -64.`}))];
gval = RandomReal[{}, {4}];
pval = 3.;
Do[without[gval, pval, grid], {10^5}] // AbsoluteTiming//Print;
Do[with[gval, pval], {10^5}] // AbsoluteTiming//Print;
1.35754
0.719002
About a factor two speedup.
The question
This is the question: how can we create the expression cPartlyComputed
above automatically?
naive solution
The naive solution illustrates the problem:
cTry = c /. x -> grid;
{1. g^2 ((1. p^3)/(-1. + g) + Sinh[1/2 (1. + 1. g)]), 4. g^2 ((2. p^3)/(-8. + g) + Sinh[1/2 (4. + 16. g)]), 9. g^2 ((3. p^3)/(-27. + g) + Sinh[1/2 (9. + 81. g)]), 16. g^2 ((4. p^3)/(-64. + g) + Sinh[1/2 (16. + 256. g)])}
The unknown vector g
has entered into the elements of a list. When we have computed g
and insert it into this, we will get a matrix, not the vector that we want.
Best attempt so far
Since the essence of the problem seems to be the Listable
property of Plus
and Times
when applied to symbolic arguments, the idea was to temporarily remove this property, only for symbolic arguments, or create our own Plus
and Times
identical to the built-in ones except for this property.
I tried the latter:
ClearAll[CircleTimes, CirclePlus]
Attributes[CirclePlus] = Attributes[CircleTimes] = Complement[Attributes[Plus], {Listable, Protected}];
Verbatim[CircleTimes][a_] := a;
Verbatim[CirclePlus][a_] := a;
numericOrList[a_] := NumericQ[a] || Head[a] === List;
a_\[CircleTimes]b_ := a*b /; numericOrList[a] && numericOrList[b];
a_\[CirclePlus]b_ := a + b /; numericOrList[a] && numericOrList[b];
a_\[CircleTimes](b_\[CirclePlus]c_) :=
a\[CircleTimes]b\[CirclePlus]a\[CircleTimes]c /; numericOrList[a]
a_\[CircleTimes]x1_\[CirclePlus]a_\[CircleTimes]x2_ :=
a\[CircleTimes](x1 + x2) /; numericOrList[x1] && numericOrList[x2];
evaluate[expression_, var_, grid_] := Hold[Evaluate[
expression /. {Plus -> CirclePlus, Times -> CircleTimes} /. var -> grid
]] /. {CirclePlus -> Plus, CircleTimes -> Times}
some explanation:
CircleTimes
and CirclePlus
are undefined built-in functions that display nicely. As mentioned we give them all the properties of Times
and Plus
except Listable
(and Protected
of course).
We check their arguments with numericOrList
which is True
for pure numbers or for lists, which in our context will always be lists of pure numbers. On such arguments we do want the Listable
property, so all the definitions are to ensure that they reduce to Times
and Plus
in these cases.
The Verbatim
is necessary to prevent an infinite recursion, see Carl Woll's answer to this question.
Finally the function evaluate
uses these CircleTimes
and CirclePlus
together with the naive answer above, holding the result ready to be used in a later computation.
performance
This seems to work:
evaluate[c,x,grid]
Hold[g^2 ({1., 4., 9., 16.} Sinh[ g {0.5, 8., 40.5, 128.} + {0.5, 2., 4.5, 8.}] + ( p^3 {1., 8., 27., 64.})/(g + {-1., -8., -27., -64.}))]
identical to the cPartlyComputed
above that I did by hand.
However it is prohibitively slow, presumably because of all the pattern matching. On this other still very simple example:
c2=-5832. - 432. x^2 - 1728. f x^3 - 12. (32. + 72. d1f + 144. f^2 - 27. p^4) x^4
- 24. f (32. + 72. d1f - 27. p^4) x^5
it creates something that seems optimal:
evaluate[c2,x,grid]
Hold[d1f {-864., -13824., -69984., -221184.} + f (d1f {-1728., -55296., -419904., -1.76947*10^6} + p^4 {648., 20736., 157464., 663552.} + {-768., -24576., -186624., -786432.}) + f {-1728., -13824., -46656., -110592.} + f^2 {-1728., -27648., -139968., -442368.} + p^4 {324., 5184., 26244., 82944.} + {-6648., -13704., -40824., -111048.}]
but takes 34 seconds to do so on my laptop (I don't understand why, the pattern matching is probably slow but that slow?) This is so slow that I cannot even test if it would at least work in a more general case.
Requirements of the solution
So what I am looking for is this:
- A function
evaluate[expression_,var_,grid_]
that replaces the symbolic variablevar
with the numerical listgrid
inexpression
, without messing it up by taking any other symbols inexpression
into the lists. expression
can be either a single expression or a (nested) list of expressions, each one being an arbitrary nonlinear function of one or several symbols. A more realistic example on which it should work is given below.- The result should contain no, or as few as possible, operations that can be carried out with just the knowledge of
var
. - While for a given problem this has to be done only once and the result will be used many times, it is still important that it be reasonably fast.
- If the equation, and the functions and parameters, are at machine precision, then inserting packed arrays for the functions should ideally result in a packed array as output as well.
- Ideally the code should be short and easy to understand.
It doesn't have to be related to my attempt above, perhaps there is a way to "hack" the built in Times
and Plus
to behave like this in a much faster way, or something else entirely.
An actual example to test on
I've uploaded a more realistic case on which it should work. It depends on the functions f,g,h
and their derivatives, which have been rewritten as f'[x] -> d1f
etc( if it's easier this can of course be undone) and on the scalar parameter p
.
Update
There have been two answers so far, by Carl Woll and xzczd, which are both close to what I'm looking for but not quite. A quick comparison on the example linked above: with a grid size of 4, Carl Woll's solution takes about 0.013
seconds to produce something with a LeafCount
of 4573
, while xzczd's answer takes about 0.25
seconds to produce something with a LeafCount
of 3409
*. So the former is a lot faster but also computes less. Computations with the result of the latter are then about 25% faster than with the former.
However both leave computations undone.
With Carl Woll's answer I think it's because the rules aren't repeated, resulting in expressions like f {-1728., -13824., -46656., -110592.} + f {0., -51104., -61502., -3.270*^6}
that are not evaluated. (it is also becoming harder to read the code)
With xzczd's answer the problem is that additions of numbers or a number and a list aren't computed, because for some reason including that it becomes incredibly slow.
Therefore I will set a bounty for a solution that does do really all the computations in a reasonable time (I don't think it should take more than 0.5 seconds for the example linked above, possibly much shorter). And if it's still easily readable a year from now that would be great!
[* Actually to obtain this I've added to his last two patterns also the ones with patt2
instead of patt
]
cPartlyComputed
can't be defined that way. The conclusion doesn't change much though:Clear[g, p]; test1 = Compile[{{g, _Real, 1}, p, {x, _Real, 1}}, #] &@c; test2 = Hold@Compile[{{g, _Real, 1}, p}, c] /. OwnValues@c /. x -> grid // ReleaseHold; pvalue = 3.; gvalue = {0.714, 0.429, 0.734, 0.921}; Do[test1[gvalue, pvalue, grid], {10^6}]; // AbsoluteTiming // Print; (*{3.192,Null}*) Do[test2[gvalue, pvalue], {10^6}]; // AbsoluteTiming // Print;(*{3.193,Null}*)
As we can see the influence of inlining ofgrid
is quite limited. $\endgroup$test2
you have only replacedx
with the grid, but not pre-computed for instancex^2
, which is what I'm looking to do. It should definitely be faster to precompute as much as possible. It might depend on the expression by how much of course, but in my experience it's significant. $\endgroup$test3 = Hold@Compile[{{g, _Real, 1}, p}, c] /. OwnValues@c /. x :> grid /. HoldPattern[grid^a_.] :> RuleCondition[grid^a] // ReleaseHold; Do[test3[gvalue, pvalue], {10^6}]; // AbsoluteTiming // Print;(* {2.76234,Null} *)
But still, I doubt if this will have a significant influence in real situation. $\endgroup$