modelica / ModelicaSpecification

Specification of the Modelica Language
https://specification.modelica.org
Creative Commons Attribution Share Alike 4.0 International
102 stars 41 forks source link

Compile-time evaluation of impure functions #3125

Open MarkusOlssonModelon opened 2 years ago

MarkusOlssonModelon commented 2 years ago

Should Modelica tools allow compile-time evaluation of impure functions? Consider the following example:

model Example1
    parameter String fileName, matrixName;
    parameter Integer n[2] = Modelica.Utilities.Streams.readMatrixSize(fileName, matrixName);
    parameter Real matrix[n[1], n[2]] = Modelica.Utilities.Streams.readRealMatrix(fileName, matrixName, n[1], n[2]);
end Example1;

The function readMatrixSize is impure in MSL 4.0.0, but it is clearly meant to be used to determine array sizes. I don't think there is anything in the Modelica specification requiring that array sizes are evaluated at compile-time, but some (most?) Modelica tools do it that way.

One reason not to allow evaluation of impure functions at compile-time can be seen in the following example:

class EO
    extends ExternalObject;
    ...
end EO;

impure function uniqueIncreasingNumber
    input EO eo;
    output Integer y;
    external; // first call returns 1, following calls return the previous value plus one.
end uniqueIncreasingNumber;

model Example2
    parameter EO eo = EO();
    parameter Integer a = uniqueIncreasingNumber(eo) annotation(Evaluate=true);
    parameter Integer not_a = uniqueIncreasingNumber(eo);
end Example2;

Without the Evaluate=true annotation, both integer parameters in Example2 would be evaluated during initialization and would get different values. With the annotation however, if we allow evaluating impure functions at compile time the value of parameter not_a will be the same as a, both will be 1.

If readMatrixSize is always evaluated during compilation I don't see a need for it to be impure. We can assume that the contents of the file do not change mid-compilation, so it can be viewed as a mathematical function - a mapping from strings to numbers that always give the same results for a given input. This leads me to two alternatives:

  1. Don't allow compile-time evaluation of impure functions - modelers will have to change such functions to be pure.
  2. Allow compile-time evaluation of impure functions - this will lead to models like Example2 breaking.

There is one related thing I should bring up, in the specification (section 12.3) it says the following:

With the prefix keyword impure it is stated that a Modelica function is impure and it is only allowed to call such a function from within:

  • ...
  • Binding equations for components declared as parameter - which is seen as syntactic sugar for having a parameter with fixed=false and the binding as an initial equation

I don't think there is anything in the specification disallowing compile-time solving of initial equations, but the purpose of changing these binding equations to initial equations seem to me to be to specify exactly when they hold true (during initialization), which is not the case if they are allowed to be evaluated during compilation.

HansOlsson commented 2 years ago

One way of considering this is that the problem with impure functions is that they have a hidden dependency on the unspecified "environment". For many impure functions (including readMatrix and readMatrixSize) that environment is the external file-system.

If the impure function has an external object as input (as uniqueIncreasingNumber) I would say that it should not be possible to compile time evaluate it - because such an evaluation would impact the external object that is part of the simulation - and the evaluation would ignore that update.

Note that if we define a function with an internal external object that is different:

impure function uniqueIncreasingNumber2
   input Integer n;
   output Integer y;
protected
   EO eo=EO();
algorithm
   for i in 1:n loop
      y:=uniqueIncreasingNumber(eo);
   end for;
end uniqueIncreasingNumber2;

That could be compile time evaluated (creating and deleting the external object) - although in this case it would likely be bad. This bad case could also be implemented as an external function with a state in the compiled code.

So my proposal is: An impure function may modify an external object sent as argument and/or the external environment of the simulation (e.g. the file-system).

henrikt-ma commented 2 years ago

So my proposal is: An impure function may modify an external object sent as argument and/or the external environment of the simulation (e.g. the file-system).

Is this supposed to imply that impure functions can only be invoked at runtime (which is when the external environment of the simulation is available)?

casella commented 2 years ago

Is this supposed to imply that impure functions can only be invoked at runtime (which is when the external environment of the simulation is available)?

If the idea of impure functions is that they may return different values depending on when you call them, I guess that is the only logical outcome.

If you wanted a function to be constant-evaluated at compile-time, you should declare it as pure. Only in this way the compiler knows it won't ever change, so it is authorized to constant evaluate it at compile time.

hubertus65 commented 2 years ago

So if I put up a corollary that seems consistent with all the comments above: the function Modelica.Utilities.Streams.readMatrixSize with the purpose to read a matrix size from a file at compile time should be declared a pure function, not an impure one, as it is today. Note: if I get agreement or extended silence here, I will create a ticket on the MSL side to fix that. Thanks for the comments!

HansOlsson commented 2 years ago

Thinking more I believe these proposals are not good, and I missed two parts:

If the impure function only depends on the external environment it would make sense to call it at compile time, but we cannot tell if that is the case with the current semantics.

But we know that if the value changes between compilation and the call something would need to modify the external environment, and for the normal parametric calls of readMatrixSize I don't see how we could guarantee that some other call would modify the environment before the call; and thus it seems safe-ish to evaluate readMatrixSize during compilation. This also means that if someone calls readMatrixSize in a when-clause we shouldn't evaluate that call during the compilation setep.

On the other hand, if it involves an external object which is part of the model it doesn't make sense to use another external object during the compilation; and thus we need to delay that call until the model is run.

Thus I don't see that we should change anything yet, but we might in the future refine the semantics of impure functions.

hubertus65 commented 2 years ago

Let me start with what I see as "must have" and "nice to have" requirements for the case of reading array parameters from tables in external files in Modelica models. I'd also like to introduce 3 distinct phases in this:

  1. Compile-time
  2. Initialization time (e.g. fmi_initialize if exported as FMU)
  3. Run time.

Based on the above, I see good reasons to handle readMatrixSize and readMatrix differently. readMatrixSize must be evaluated at compile time, readMatrix can be evaluated at initialization, and must not be evaluated at run time. This is purely from an end-use requirements point of view, and the ability to allow execution of readMatrix at initialization time can be seen as an optimization/performance issue.

From this point of view I could see that it would be fine to declare both functions as pure, since even readmatrix does not have a side effect other than changing the parameters values. In terms of a semantic definition, one might have to split initialization into several phases (for parameters, and possibly inputs at time 0) and the equation system initializtion.

In my mental image, the idea of an impure function applies only to equation initialization, and run-time, but not the other phases. This is from my view about end-user requirements, and my understanding of how impure functions could interact with solvers. @casella, I'd like your view on this, you tend to have strong views on uers needs :-).

HansOlsson commented 2 years ago

Based on the above, I see good reasons to handle readMatrixSize and readMatrix differently. readMatrixSize must be evaluated at compile time, readMatrix can be evaluated at initialization, and must not be evaluated at run time.

The difference between them depends on how readMatrix and readMatrixSize are used - not on the functions themselves; and the difference is smaller.

It's typical that readMatrixSize must be evaluated at translation time because it is used to give the size of an array. However, it is not unusual that a parameter is bound to the result of readMatrix and it is used in such a way that it also must be evaluated during translation.

On the other hand readMatrix was made impure because of a use-case where the code was writing to a matrix, and used something like when .... then A=readMatrix(...);end when; to read the same matrix, and in that case it should clearly not be evaluated during the translation. I could see that someone use readMatrixSize in the same way to e.g. get the number of lines and then print them one by one.

From this point of view I could see that it would be fine to declare both functions as pure, since even readmatrix does not have a side effect other than changing the parameters values. In terms of a semantic definition, one might have to split initialization into several phases (for parameters, and possibly inputs at time 0) and the equation system initializtion.

A function is currently impure if it writes or reads from the external environment, and clearly readMatrix does the latter. Thus I don't see that we should change the purity of those functions at the moment, but we could clarify the rules regarding what they might read/write to (see above) - and possibly introduce sub-categories of impure functions.

I also don't see that there's major problem at the moment; it makes sense that parameters using readMatrix and readMatrixSize are evaluated during translation if needed and normally will give the same result during initialization time (as there's no way to reliable modify them before that within the model); whereas uniqueIncreasingNumber(eo) requires the external object eo that will be part of the simulation so it makes sense to delay that until the initialization time, since the eo isn't known.

Clearly that relies on some assumptions that, although sensible, should be clarified.

casella commented 2 years ago

I wish I could give some wise suggestion, but I'm afraid I'm not really into all the details, which is where the devil is, so I'm reluctant to make strong statements at this point that are not backed by a good enough analysis. That would require a lot of time that unfortunately I don't have a the moment.

In my naive user-oriented view, pure means "always gives the same result, so the compiler can optimize based on this property", while impure means "can give different results, so optimizations assuming it always gives the same result cannot be done". Furthermore, "give the same result" could be restrictively interpreted as "during simulation", or have broader meanings, like "also between different simulations, or between compile time and simulation time". I would prefer this latter more comprehensive interpretation, because limiting to "during simulation" seems quite arbitrary to me.

I understand there's been an extremely long and articulate discussion on pure and impure, which may go beyond this naive view, and maybe contradict it outright, possibly only in some borderline case. Is that the case, or am I fundamentally on the right track?

hubertus65 commented 2 years ago

My understanding from this discussion is that there is some need for clarification in the specification regarding pure and impure. I agree that the case from the original question (evaluate at compile time: yes or no) depends on the use of the function, not the function itself. Some interpretation here, but I think everybody agrees that it is okay to evaluate an impure function at compile time if it is used to set structural parameters (Integers). In addition, it would be convenient if impure functions that are setting non-structural parameters could be evaluated at initialization time. In this case of course only if the structural parameters didn't change, which is quite messy to deduce I guess, but could be checked through an assert on the Modelica-side.

henrikt-ma commented 2 years ago

A function is currently impure if it writes or reads from the external environment, and clearly readMatrix does the latter. Thus I don't see that we should change the purity of those functions at the moment, but we could clarify the rules regarding what they might read/write to (see above) - and possibly introduce sub-categories of impure functions.

Yes, this is something about impure that I've been missing from the beginning: the variability of an impure function without side effects.

By declaring the function as impure parameter function readMatrix one would say that the result of readMatrix may read the external environment, but given the same arguments the result is guaranteed to not change after simulation initialization. In other words, function calls have at least parameter variability and should therefore normally not be evaluated at translation time. However, if the result of the function call is bound to a parameter, and that parameter needs to be evaluated during translation, that could force the function call to be evaluated during translation anyway.

Similarly, a call to an impure discrete function would have at least discrete-time variability, meaning it couldn't be used to set the value of a parameter. Further, it the result of the function call would need to be updated each time simulation is stopped at an event (possibly also requiring re-evaluation in every event iteration).

On the contrary, an impure constant function could be treated similar to a pure function, but emphasizing the fact that the result depends on the external environment (but is assumed to not change after translation).

For impure functions with side effects, I don't see the same use of variability specification – if the function can modify the environment during every call, it seems natural to assume that also the return value may differ with every call. This is the kind of impure function we have today.

HansOlsson commented 2 years ago

A function is currently impure if it writes or reads from the external environment, and clearly readMatrix does the latter. Thus I don't see that we should change the purity of those functions at the moment, but we could clarify the rules regarding what they might read/write to (see above) - and possibly introduce sub-categories of impure functions.

Yes, this is something about impure that I've been missing from the beginning: the variability of an impure function without side effects.

By declaring the function as impure parameter function readMatrix one would say that the result of readMatrix may read the external environment, but given the same arguments the result is guaranteed to not change after simulation initialization.

To me that specific case would just be a step backward - unless we add one extra assumption.

One of the examples that showed the problems with a pure readMatrix was a model that wrote to a matrix (at specific times) and then at the end tried to read back the result at the end, but the pure readMatrix had been optimized to be called at the initialization (since the result of a pure function shouldn't change). Here we would get the same issue with the impure readMatrix function.

However, if we know which functions may modify the external environment and can see that they haven't been called we could perform this optimization. But I'm unsure if this case is that significant.

I could see a clearer benefit for impure functions taking an external object as argument.

henrikt-ma commented 2 years ago

One of the examples that showed the problems with a pure readMatrix was a model that wrote to a matrix (at specific times) and then at the end tried to read back the result at the end, but the pure readMatrix had been optimized to be called at the initialization (since the result of a pure function shouldn't change). Here we would get the same issue with the impure readMatrix function.

However, if we know which functions may modify the external environment and can see that they haven't been called we could perform this optimization. But I'm unsure if this case is that significant.

Well, if we could declare the variability of impure functions without side effects, then we could also generalize what we do with pure. That is, just like pure(foo(…)) locally changes the purity attached to foo, we could write discrete(readMatrix(…)) if we wanted to locally make readMatrix behave as if it were declared impure discrete function readMatrix. That is, this call to readMatrix needs to be re-evaluated at each event.

While pure(foo(…)) creates some complications by allowing for side-effects in context that from the outside will be treated as free of side-effects, I don't see similar problems with allowing discrete(foo(…)) to locally increase the variability of a call to an impure function. Allowing variability to be overruled in the other direction would be a more dangerous operation, so if possible one should probably not open up for that possibility.

HansOlsson commented 2 years ago

One of the examples that showed the problems with a pure readMatrix was a model that wrote to a matrix (at specific times) and then at the end tried to read back the result at the end, but the pure readMatrix had been optimized to be called at the initialization (since the result of a pure function shouldn't change). Here we would get the same issue with the impure readMatrix function. However, if we know which functions may modify the external environment and can see that they haven't been called we could perform this optimization. But I'm unsure if this case is that significant.

Well, if we could declare the variability of impure functions without side effects, then we could also generalize what we do with pure. That is, just like pure(foo(…)) locally changes the purity attached to foo, we could write discrete(readMatrix(…)) if we wanted to locally make readMatrix behave as if it were declared impure discrete function readMatrix. That is, this call to readMatrix needs to be re-evaluated at each event.

That's not a good solution.

One of the general conclusions for good (software) design is that defaults should be safe. The safe default is that readMatrix should re-evaluate the result at each event. In contrast pure(foo(…)) is consistent with this design, as the impure function foo can only be called in certain places, and if you want to override that you need non-default behaviour as indicated by pure(...).

To me it also unclear what we are trying to accomplish here.

hubertus65 commented 2 years ago

To me it is also unclear what we are trying to accomplish here.

Clarity for compiler implementors, and Modelica users, and an agreed-upon and shared understanding of the desired semantics. Just the length and scope of this discussion show me that we don't seem to be quite there yet. From my point of view as basically an advanced user, I'd say that I don't feel that I have a good feeling for the combination of impure/pure with variability that Henrik brought up is not clear to me. I liked his explanation:

By declaring the function as impure parameter function readMatrix one would say that the result of readMatrix may read the external environment, but given the same arguments the result is guaranteed to not change after simulation initialization. In other words, function calls have at least parameter variability and should therefore normally not be evaluated at translation time. However, if the result of the function call is bound to a parameter, and that parameter needs to be evaluated during translation, that could force the function call to be evaluated during translation anyway.

This is understandable by a somewhat advanced user and is helpful.

Overall in the spec, from an end-user that cares about performance point of view, I am missing more clarity between what has to be evaluated at compile-time, and what at initialization time. That may be on purpose (optimizations are up to implementors), but the clarity of intent of language designers would not hurt.

A clarification of the design intent of the combination of pure/impure with result variablity in the spec would be helpful for library authors.

HansOlsson commented 2 years ago

To me it is also unclear what we are trying to accomplish here.

Clarity for compiler implementors, and Modelica users, and an agreed-upon and shared understanding of the desired semantics. Just the length and scope of this discussion show me that we don't seem to be quite there yet. From my point of view as basically an advanced user, I'd say that I don't feel that I have a good feeling for the combination of impure/pure with variability that Henrik brought up is not clear to me. I liked his explanation:

By declaring the function as impure parameter function readMatrix one would say that the result of readMatrix may read the external environment, but given the same arguments the result is guaranteed to not change after simulation initialization. In other words, function calls have at least parameter variability and should therefore normally not be evaluated at translation time. However, if the result of the function call is bound to a parameter, and that parameter needs to be evaluated during translation, that could force the function call to be evaluated during translation anyway.

This is understandable by a somewhat advanced user and is helpful.

It's understandable - but it's completely misleading; as the function depends on the environment that may be changed.

We cannot have optimization that as default gives the wrong results, and you manually have to disable to get correct results.

A clarification of the design intent of the combination of pure/impure with result variablity in the spec would be helpful for library authors.

I would say that we should go further and step back and even rethink pure/impure. The original idea was that everything should be pure by default. That was formalized in Modelica 3.3 and turned out to be a complete failure when actually implemented, and was partially reverted in Modelica 3.4 to restore compatibility. It might be that we should have reconsider it even more.

casella commented 2 years ago

We cannot have optimization that as default gives the wrong results, and you manually have to disable to get correct results.

I'm now quite lost in this discussion, but I 100% agree with this statement.

In any case, understanding the meaning of pure/impure shouldn't require a PhD in Modelica. If it does, then there's probably something wrong with the concept itself, and we should try to make it simpler and more easily understood.

My 2 cts.