lf-lang / lingua-franca

Intuitive concurrent programming in any language
https://www.lf-lang.org
Other
238 stars 63 forks source link

Multi reactor instances and scoping rules #190

Closed cmnrd closed 3 years ago

cmnrd commented 4 years ago

So far, the C++ target does not implement the Lingua Franca scoping rules. This means if you do this:

reactor Foo {
  output o:int;
  reaction(startup) {=
    o.set(42);
  =}
}

The C++ compiler does not report any errors because o was not declared as an effect of the reaction. This has been an open issue for a while and I have been looking for an elegant way to implement the scoping mechanism. What I came up with works well for simple examples as above and is similar to what is done in C and TS. The generator simply brings the declared triggers, sources, and effects in scope by using an alias. It is a bit more complex in reality, but I think you get the idea from this simplified example:

reaction_body(Foo* _lf_self) {
  // prologue
  auto& o = _lf_self->o;
  // reaction code
  o.set(42);
}

Now this gets a bit more complex if contained reactors are involved. For instance, with this program:

reactor Foo {
  input i:int;
  reaction(i) {=
     std::cout << *i.get() << '\n';
  =}
}
reactor Bar {
  foo = new Foo();
  reaction(startup) -> foo.o {=
    foo.o.set();
  =}
}

the reaction of Bar would be generated as follows:

reaction_body(Bar* _lf_self) {
  // prologue
  const auto& startup = _lf_self->startup;
  struct { auto& o = _lf_self->foo.o; } foo;
  // reaction code
  foo.o.set(42);
}

This is a bit more complicated due to the struct that is needed as a proxy for the Foo reactor, but that is still ok. The compiler should be able to optimize those simple alias assignments away. Also note how const can be used in C++ to permit only read access to a port or action (startup in this example).

So far so good. But it gets complicated as soon as we use an array instantiation. Consider this example:

reactor Bar {
  foo = new[42] Foo();
  reaction(startup) -> foo.o {= // writes to all instances of foo
    for(int i = 0; i < 42; i++) {
      foo[i].o.set(42);
    }
  =}
}

How should the generated code look now? It needs to instantiate an array of aliases!

reaction_body(Bar* _lf_self) {
  // prologue
  const auto& startup = _lf_self->startup;
  using foo_t = struct { Output<int>& o; };
  std::array<foo_t, 42> foo{ _lf_self->foo[0].o, _lf_self->foo[1].o, /* .../*, _lf_self->foo[41].o,};
  // reaction code
  foo.o.set(42);
}

Note that the array initialization must be unrolled and a for loop cannot be used since the reference o in foo_t must be initialized immediately on object creation. This could be avoided by using a pointer rather than a reference, but then the target code would need to use -> as in foo[x]->set(42). I am not sure what the compiler would do with this code, but I don't expect it to optimize the array away. So this could be a significant overhead in memory and computation time! The overhead in computation time could be mitigated though, by only creating the array once and keeping it for latter triggerings of the reaction.

Now it gets even more complicated if our reaction writes only to one specific instance of foo. For example:

reactor Bar {
  foo = new[42] Foo();
  reaction(startup) -> foo[4].o {= // write only to instance 4 in the array foo
    foo[4].o.set(42); // this should work
    foo[0].o.set(42); // this should produce a compile error
  =}
}

I actually have no idea how to achieve this...

Maybe you have some thoughts on how this could be solved? I assume there would be similar issues in C and Typescript for array instantiations of reactors.

One idea I have is to introduce an alias via as keyqord directly in Lingua Franca.

reactor Bar {
  foo = new[42] Foo();
  reaction(startup) -> foo[4].o as o {= // write only to instance 4 in the array foo
    o.set(42); // this should work
    foo[0].o.set(42); // this produces a compile error
  =}
}

What do you think of this?

edwardalee commented 4 years ago

I suggest we disallow this in the grammar:

reaction(startup) -> foo[4].o

The cost of this is extra dependencies.

cmnrd commented 4 years ago

That would be an option. Actually, we have the same problem also in this case:

reaction(startup) -> foo.o[4]

What should we do when the multi port or multi reactor as a trigger. I think this has a bit more subtle implications:

reaction(foo[4].o)
reaction(foo.o[4])

If we were to disallow this syntax, we couldn't trigger on a specific port anymore, but would always trigger on all the ports. Then the programmer would be responsible for checking if a given port is present. I think that would be fine, but there is some overhead associated with this solution as it causes more reaction triggerings.

edwardalee commented 4 years ago

Our experience with multiports in Ptolemy II has never, to my knowledge, presented us with a use case where we would want reactions to be triggered by individual channels of the multi port, so I don’t think this restriction is likely to be burdensome.