Closed simon-camp closed 2 months ago
Thank you for this PR, it will put emitc on good foundations!
I'm trying to summarize the discussion so far to highlight questions I have right now (much of this was also already written down in the RFC):
lvalue
possibly renamed to mlvalue
to distinguish this from array variables)lvalue_to_rvalue
) which is directly emitted as an assignment to an anonymous variable.
&
*
AutomaticAllocationScope
to all op having regions (func, for, if branches)Somewhat related, we have a few ops (subscript, get_global) that are specifically handled in the emitter to not immediately create variables. Can we categorize these as exactly those ops returning mlvalues except for the variable op?
[^1] I don't know how to lower the memref-to-emitc if we don't distinguish modifiable and array variables.
I'm trying to summarize the discussion so far to highlight questions I have right now (much of this was also already written down in the RFC):
Thanks for the summary, that looks good!
For arrays, are you intending to for emitc.variable
/emitc.get_global
have type !emitc.array
for arrays and !emitc.lvalue<type>
for other types? I would like that.
For arrays, are you intending to for
emitc.variable
/emitc.get_global
have type!emitc.array
for arrays and!emitc.lvalue<type>
for other types? I would like that.
Yeah that's what I would try to see how it works out. We can catch !emitc.lvalue<!emitc.array<>>
in the lvalue type verifier then. And you are right I forgot that global arrays need to be handled similarly to variables.
:white_check_mark: With the latest revision this PR passed the C/C++ code formatter.
I'm trying to summarize the discussion so far to highlight questions I have right now (much of this was also already written down in the RFC):
- add new type that designates modifiable values (currently
lvalue
possibly renamed tomlvalue
to distinguish this from array variables)- EmitC operations work on non-lvalues unless explicitly defined otherwise
Conversion to non lvalue types is done explicitly through a new op (currently
lvalue_to_rvalue
) which is directly emitted as an assignment to an anonymous variable.
- (Maybe we should rename this to lvalue_load or something as the name lvalue conversion is connotated with implicit behaviour for me)
ops working with the new type
lhs operand of the assign op
operand of the apply op if it's an
&
the result of the subscript op
result of the apply op if it's an
*
result of the variable op
unless it's an array, as arrays are not assignable. Additionally there would be no way to work this such a value as the conversion op wouldn't work either [^1]
result of the get_global op
adding traits/effects to ops
lvalue_to_rvalue has a read effect
assign has a write effect on lhs
variable has alloc effect
we should add
AutomaticAllocationScope
to all op having regions (func, for, if branches)Somewhat related, we have a few ops (subscript, get_global) that are specifically handled in the emitter to not immediately create variables. Can we categorize these as exactly those ops returning mlvalues except for the variable op?
[^1] I don't know how to lower the memref-to-emitc if we don't distinguish modifiable and array variables.
Most things should be implemented now. Most notable things missing are the memory effects on the ops and missing checks on one test file.
@llvm/pr-subscribers-mlir
@llvm/pr-subscribers-mlir-emitc
Author: Simon Camphausen (simon-camp)
As this is a major breaking change, which possibly impacts many of the users of the EmitC dialect I would suggest to post a PSA on discourse and wait for a while before finally merging this PR. Suggestions welcome.
PSA: Modelling memory of EmitC variables
WIth PR 91475 explicit variables will be modeled as lvalues in the type system. This introduces some breaking changes to multiple operations.
emitc.variable
andemitc.global
ops are restricted to returnemitc.array
oremitc.lvalue
types
- the result of the
emitc.variable
op can be materialized as SSA values with theemitc.load
op- Taking the address of a value is restricted to operands with lvalue type
- Conversion from lvalues into SSA values is done with the new
emitc.load
op- The var operand of the
emitc.assign
op is restricted to lvalue type- The result of the
emitc.subscript
andemitc.get_global
ops is a lvalue type
- results can be materialized as SSA values with the
emitc.load
op
Thanks @simon-camp for getting the work done, thanks @aniragil and @mgehre-amd for reviewing and for the valuable discussions. This really pushes the dialect forward!
There is one thing we should agree on before this is merged. As the patch introduces breaking changes, @simon-camp and I discussed offline that this requires a PSA. An non-breaking intermediate solution would be to relax the restriction on
emitc.variable
and to allowEmitCType
as a result. This would mean to replacelet results = (outs Res<AnyTypeOf<[EmitC_ArrayType, EmitC_LValueType]>, "",
with
let results = (outs Res<AnyTypeOf<[EmitCType, EmitC_ArrayType, EmitC_LValueType]>, "",
while we ignore the changes regarding the memory effects. Furthermore, the modifications to
emitc.apply
would need to be rolled-back and instead anemitc.dereference
(en.cppreference.com/w/c/language/operator_member_access) /emitc.indirection
(en.cppreference.com/w/cpp/language/operator_member_access) andemitc.address_of
should be introduced (which we probably want to do in a follow up anyway). With this, theemitc.variable
as well asemitc.apply
op could still be used as before but users can adapt to the new behavior before the result type of theemitc.variable
op gets restricted and theemitc.apply
op is removed. However, adopting to the behavior shouldn't be too hard, thus we tend to keep the effort low and just go with a PSA and before merging this in x? weeks. WDYT?
Fine for me.
There is one thing we should agree on before this is merged. As the patch introduces breaking changes, @simon-camp and I discussed offline that this requires a PSA. An non-breaking intermediate solution would be to relax the restriction on
emitc.variable
and to allowEmitCType
as a result. This would mean to replacelet results = (outs Res<AnyTypeOf<[EmitC_ArrayType, EmitC_LValueType]>, "",
with
let results = (outs Res<AnyTypeOf<[EmitCType, EmitC_ArrayType, EmitC_LValueType]>, "",
while we ignore the changes regarding the memory effects. Furthermore, the modifications to
emitc.apply
would need to be rolled-back and instead anemitc.dereference
(https://en.cppreference.com/w/c/language/operator_member_access) /emitc.indirection
(https://en.cppreference.com/w/cpp/language/operator_member_access) andemitc.address_of
should be introduced (which we probably want to do in a follow up anyway). With this, theemitc.variable
as well asemitc.apply
op could still be used as before but users can adapt to the new behavior before the result type of theemitc.variable
op gets restricted and theemitc.apply
op is removed.
Sounds good (what about the changes to emitc.subscript
?)
We can take it further by keeping emitc.variable
as-is along with emitc.apply
and introducing a new emitc.define
op along with emitc.address_of
and emitc.dereference
to work on lvalues. Then, some time after the PSA, erase both emitc.variable
and emitc.apply
from the dialect, which might be cleaner/safer than altering their semantics.
(BTW, we could perhaps extend the existing emitc.subscript
op to do dereferencing. It already accepts !emitc.ptr
, but still requires an index. If we allow zero indices for pointers it could translate to *p
).
However, adopting to the behavior shouldn't be too hard, thus we tend to keep the effort low and just go with a PSA and before merging this in x? weeks. WDYT?
I'm also fine with just giving the community a heads up and pushing the patch as is in a x weeks. As this patch is quite large, x should probably be relatively small to prevent it from bit rotting and to allow further development of emitc (we can perhaps wait a longer x before erasing these ops if we make emitc.variable
obsolete, while still pushing it immediately).
I'm currently preparing a patch to integrate the changes into iree and I'm having issues updating the code in a few places. We fall back to invoking macros through OpaqueCall ops for things that are not representable in the EmitC dialect at the moment, and parameters might be used as lvalues in the macro expansion.
So one way forward may be to allow lvalues in the operands of the OpaqueCall and update the op description. What do you think?
So one way forward may be to allow lvalues in the operands of the OpaqueCall and update the op description. What do you think?
Allowing lvalues and adjusting the op description is fine for me :+1: (while hoping to provide better options with other new ops that will follow in the future).
I've rebased this PR one last time after the introduction of the emitc.switch
op. I plan to merge this as soon as the CI has run. Thanks again for all the valuable feedback.
Is there a plan to minimize the amount of explicit assignments of loaded value or should I open an issue? In general
func.func @cast_variables() -> i1 {
%0 = "emitc.variable"(){value = 42 : i8} : () -> !emitc.lvalue<i8>
%1 = emitc.load %0: !emitc.lvalue<i8>
%2 = emitc.cast %1: i8 to i1
return %2 : i1
}
being now
bool cast_variables() {
int8_t v1 = 42;
int8_t v2 = v1;
return (bool) v2;
}
with declare variables at top:
bool cast_variables() {
int8_t v1;
int8_t v2;
v1 = 42;
v2 = v1;
return (bool) v2;
}
Look really artificial and redundant compared to what it was before and I'm not sure how downstream(us) could deal with all these since a lot of that is inside the emitter and you can not really do anything about it with verbatim
, etc.
Maybe the emitc.load
could be allowed inside the emitc.expression
, since it doesn't do anything useful on its own and more of a type-system level safety? Then form-expressions
pass will likely deal with it correctly.
Maybe the
emitc.load
could be allowed inside theemitc.expression
, since it doesn't do anything useful on its own and more of a type-system level safety? Thenform-expressions
pass will likely deal with it correctly.
The emitc.load
op models the side effect of reading the value. If this would be used inside an expression we wouldn't be able to ensure the correct semantics (i.e. ordering of multiple load ops), as the order of evaluation is unspecified in C.
But I'd like to hear opinions from @aniragil or @mgehre-amd.
The emitc.load op models the side effect of reading the value.
Maybe the variable should have hasSideEffect
field indicating whether it can actually has side effect, e.g. volatile
qualified, since I don't think you can have any of the side effects listed on the page you've linked with regular emitc.lvalue<i32>
as of now.
If this would be used inside an expression we wouldn't be able to ensure the correct semantics (i.e. ordering of multiple load ops), as the order of evaluation is unspecified in C.
I don't think you'd be able to ensure it either unless you restrict it even further. From the generated code point of view, the code is exactly the same since all you add is intermediate steps. The end expressions are exactly the same, just the variable is named differently because you'd have a temporary binding. So, I'm not quite sure what the current restriction prevents in generated code.
If the point is to ensure that during the mlir passes it won't end up in expression and transformations could be performed on defined order than maybe the load could be inlined like emitc.expression
(using inline attribute) during the emitter phase?
Maybe the
emitc.load
could be allowed inside theemitc.expression
, since it doesn't do anything useful on its own and more of a type-system level safety? Thenform-expressions
pass will likely deal with it correctly.The
emitc.load
op models the side effect of reading the value. If this would be used inside an expression we wouldn't be able to ensure the correct semantics (i.e. ordering of multiple load ops), as the order of evaluation is unspecified in C.But I'd like to hear opinions from @aniragil or @mgehre-amd.
Wouldn't all possible orders of emitc.load inside an expression still give the same result, as long as there is no emitc.store inside the expression? As our emitc.load is close to a lvalue-to-rvalue conversion, and those are mostly created implicitly in C++ code, it seems fine to me to allow emitc.load in expressions.
So I think there are two issues here:
First, we currently don't fold expressions with side effects into their using expressions at all, since we might be moving side effects beyond other, potentially conflicting side effects, e.g.:
int f(int a, int b) {
int8_t v1; // actual variable
int8_t v2; // "SSA-value" variable
int8_t *v3; // "SSA-value" variable
v1 = a; // emitc.assign
v3 = &v1; // emitc.apply "&"
v2 = v1; // emic.load
func(v3); // may modify v1
v4 = v2 * 3; // emitc.expression
return v4;
}
(Note however that we do fold into expressions with side effects) We're being highly conservative, and can definitely try to relax that for cases we can prove semantics is retained, including for variable loading.
Second, folding more than a single side-effect into an expression brings up the evaluation order issue @simon-camp mentioned. As lvalues are not just variables, a load op used for an array access may lead to a invalid memory access and trigger an error/exception. I'm not sure compilers must respect evaluation order in illegal access cases, but AFAIK for variables (automatic / global) there is no such concern so I believe @mgehre-amd's observation is right for variables, at least for C and C++'s native types (not sure this holds for C++ classes whose copy constructors might do all sorts of stuff internally). In general, we could use the comma operator to enforce evaluation order within expressions. While I don't think we can define variables inside comma expressions, specifically when --declare-variables-at-top is used we could fold dangerous loads in-order using commas, e.g.
int f(int a[300], int b[700], int i, int j) {
int8_t v1;
int8_t v2;
int8_t v2;
// v1 = a[i];
// v2 = b[j];
// return v1 * v2;
return (v1 = a[i], v2 = b[j], v1 * v2);
}
Not sure it looks better than the alternative though.
In general, we could use the comma operator to enforce evaluation order within expressions. While I don't think we can define variables inside comma expressions, specifically when --declare-variables-at-top is used we could fold dangerous loads in-order using commas, e.g.
I don't think that it's worth it given that it's overly complicated and doesn't solve much, you still define the variables in the end of the day where the goal is not to, if you write them on the same line with comas it won't change much.
There're two solutions I could think of, based on what I've seen so far:
Track side effects inside the emitter, since everything is already in place there and the cache is there. The load operation could check whether it has side effect or not and could be inlined if there's no side effect or user asked to inline anyway potentially resulting in undefined evaluation in the generated code.
Or:
Define a trait HasLoadSideEffect
, which could be used to unsure that load's operand inside the CExpression
is not having this trait.
As a side note, I think mine use case is quite different to what you're going for with lvalue, since we want something more readable being generated in the end and looking like something a regular person could write, doing explicit bindings for subscript is certainly not that (I'd use at
or something that does bounds checking). Though, having more defined things generated is a good default from what I can say.
Track side effects inside the emitter, since everything is already in place there and the cache is there. The load operation could check whether it has side effect or not and could be inlined if there's no side effect or user asked to inline anyway potentially resulting in undefined evaluation in the generated code.
We're actually in the process of making the translator follow MLIR's guidelines, which are to be as simple and as 1-1 as possible, leaving all legalization/optimization work to passes.
Define a trait
HasLoadSideEffect
, which could be used to unsure that load's operand inside theCExpression
is not having this trait.
AFAIK traits shouldn't be dynamic, but that's a technicality as we could use attributes instead. However, that still leaves the translator to do this transformation, which is less desired.
As a side note, I think mine use case is quite different to what you're going for with lvalue, since we want something more readable being generated in the end and looking like something a regular person could write, doing explicit bindings for subscript is certainly not that (I'd use
at
or something that does bounds checking). Though, having more defined things generated is a good default from what I can say.
Actually, making the emitted C code more readable and closer to how humans write it is very important to me as well (and the reason I added emitc.expression
), good to know others interested in raising the bar in that aspect. Now that the lvalues change is in we could look into incorporating lvalues into expressions as well.
Also, could you elaborate on your motivation for using --declare-variables-at-top? Is it part of your readability preference or functionally required, e.g. by your target C compiler?
Also, could you elaborate on your motivation for using --declare-variables-at-top? Is it part of your readability preference or functionally required, e.g. by your target C compiler?
I haven't said that we use it though, and I prefer to not use it.
I haven't said that we use it though, and I prefer to not use it.
Oh, thanks for clarifying that.
This adds an
emitc.lvalue
type which models assignable lvlaues in the type system. Operations modifying memory are restricted to this type accordingly.See also the discussion on discourse. The most notable changes are as follows.
emitc.variable
andemitc.global
ops are restricted to returnemitc.array
oremitc.lvalue
typesemitc.load
opemitc.assign
op is restricted to lvalue typeemitc.subscript
andemitc.get_global
ops is a lvalue typeemitc.member
andemitc.member_of_ptr
ops are restricted to lvalue types