# Scalar vs 0-d array ###### tags: `design` `discussion` ## Meeting 2022-09-27 ### Summary (written retrospectively) (see [below](#scratch-pad) for the snippets made during the meeting) We decided to limit the discussion to only `field_operator`s first, see [Scope of the discussion](#Scope-of-the-discussion), as discussion should be the easiest there (we are clearly inside GT4Py, no discussion of how view changes at the entry-point to GT4Py). The whole (2h) discussion was around the question "Does it make sense to distinguish between a 0-d field and a scalar?" and evolved towards the questions: - How can we express that they are aquivalent without violating plain-Python semantics of type annoations (and their behavior in type checkers)? See [below](#How-can-we-make-float-and-Field-float-aquivalent). - Only touched on: are these techniques worth the effort. We extensively discussed the 4 possible combinations of type annotations for a scalar with the following example ```python= @field_operator def foo(bar: T0) -> T1 return bar ``` where `T0` and `T1` can be either `float` (scalar) or `Field[[], float]` (0-d field of float). In the following the discussion around each of the individual cases is summarized (if a type annotation is `T`, it indicates that for the argument made, it doesn't matter which annotation should be used) **(Field[[], float]) -> Field[[], float]** ```python= @field_operator def foo(bar: Field[[], float]) -> Field[[], float] return bar ``` No discussion, we all agree that this makes sense. **float -> T** ```python= @field_operator def foo(bar: float) -> T return bar ``` We came to the agreement that within the body of a `field_operator`, we can think of all operations as field operations ("everything is a field"). Therefore, allowing to describe arguments as scalar doesn't make sense for semantics, but is convenient for syntax. Further, we expect that the function that is being decorated will get only Fields (the decorator will wrap arguments in objects with `Field` semantics). The above annotation is therefore technically wrong as we are type-annotating the function that is being decorated (which will only get `Field`s). But we could [make it correct](#How-can-we-make-float-and-Field-float-aquivalent). **(T) -> float** ```python= @field_operator def foo(bar: T) -> float return bar ``` Because of the previous point, this type-annotation is technically wrong, because bar will always be a Field. It can be fixed with the same techniques. **() -> Field[[], float]** ```python= @field_operator def foo() -> Field[[], float] return 1.0 # this is a scalar literal ``` This is a corner-case where no field object is involved. It is technically wrong, because a float literal is not a Field. However, on the caller side, we can ensure (in the `field_operator` decorator) that returned value of the call to foo (after decoration) has `Field` semantics. This leads to the interesting discussion of [What are we type-annotating?](#What-are-we-type-annotating). #### Other discussion points ##### What are we type-annotating? ```python= @decorator def foo(arg: annotation) -> result_annotation ... ``` The type annotations are for the function `foo` before the decorator is applied. That is unfortunate for our use-case. (There was a discussion in https://github.com/python/typing/issues/412, but even if that would have succeeded the solution wouldn't have been nice for a DSL). What we ideally would like is that the user annotates the field operator, not the decorated function. The type annotation for the decorated function is basically useless for the reader. #### How can we make `float` and `Field[[], float]` aquivalent ##### Custom scalar types Instead of allowing the Python builtin types (e.g. `float`), we introduce our own types which can provide Field semantics. TODO expand here with some details TODO expand also on the following and add details ```python= scalar: gt4py.float = 4.0 assert isinstance(scalar, gt4py.float) assert isinstance(scalar, Field[[], gt4py.float]) assert scalar.dtype == gt4py.float assert scalar.dtype == Field[[], gt4py.float] assert scalar.dtype.dtype.dtype.dtype == gt4py.float assert scalar.dtype.dtype.dtype.dtype == Field[[], gt4py.float] ``` ##### ABC register Python builtins as subclass of Field TODO expand ```python= scalar: float = 4.0 # `float` is the Python builtin assert (scalar, Field[[], float]) ``` ### scratch pad #### Conclusion - it's enough that we think of everything as fields in the body of the field_operator - the function that is decorated gets only fields ```python @field_operator def foo(field: float, field2: Field[[], float]) -> float: return field @field_operator def bar(field: Field[[], float]): return foo(field) ``` ```python @field_operator def foo(field: float) -> Field[[], float]: # assert isinstance(field, Field[[], float]) # field.shape assert field.dtype == Field[[], float] assert field.dtype == float return field # currently need `return broadcast([], field)` ``` ```python @field_operator def foo(field: Field[[], float]) -> float: return field ``` ```python @field_operator def foo(field: Field[[], float]) -> Field[[], float]: return field ``` --- ```python @field_operator def foo(field: float, offset: int) -> Field[[], float]: return field(Dim[offset+1]) ``` ```python= @fundef def stencil(field: iterator, offset: iterator) return deref(shift(Dim, deref(offset)+1)(field)) ``` ## Scope of the discussion - Where does GT4Py field view begin? ```python= # outside-of-gt4py-scope out = make_a_located_field(...) scalar = 1.0 # field_operator-scope @field_operator def foo(field): return shift(Dim, 1)(2) # program-scope @program def bar(scalar: ..., field: ...) foo(scalar, out=field) # outside-of-gt4py-scope bar(scalar, out) ``` ## Hannes unstructured notes (prior to the meeting) ### Enrique and Hannes conclusions after discussing the summary At field_operator level we make the distinction between scalar and 0-d field. At iterator IR level, the lowered field_operator should take only iterators. (E.g., before we lower we promote the stuff that should be iterator to 0-d field.) #### Examples The following is forbidden because you annotate the function that is being decorated. ```python= @field_operator def foo() -> Field[[], float] return 1.0 ``` Optionally, we could add a explicit broacast option to the decorator: ```python= @field_operator def foo() -> float: return 1.0 foo: Callable[[], float] @field_operator(broadcast_to=Field[[], float]) def foo_with_broadcast() -> float: return 1.0 foo_with_broadcast: Callable[[], Field[[], float]] ``` The following is forbidden because ternaries only work with scalars ```python= @field_operator def foo(cond: Field[[], int]): return some_field_op() if cond == 1 else some_other_field_op() ``` In the following call an implicit broadcast to Field is performed in the decorator ```python= @field_operator def foo(scalar: Field[[], float]): return cond foo(1.0) ``` but the following is legal and no broadcast is performed ```python= @field_operator def foo(scalar: float) -> float: return scalar foo(1.0) ``` ### Hannes' interpretation float == Field[[], float] ```python= def foo(a: float): assert is_scalar(a) assert is_field(a) assert rank(a) == 0 ``` ### Are we discussing semantics or implementation? ### Problems at the entrypoint to GT4Py #### scalars as out arg in field view >from https://mail.python.org/pipermail/numpy-discussion/2006-February/006384.html: rank-0 array cannot be replaced by an array scalar because it is mutable. ```python= @program def foo(scalar: float): bar(out=scalar) # not possible`` ``` #### embedded execution Either - scalars need to be wrapped at the entrypoint to provide field operations, or - all operations need to be overloaded for scalar and field operations #### Comparison to NumPy discussion For numpy there is not entrypoint, we are always dealing with the user provided objects: a `float` (scalar) doesn't have `.shape` member, while a 0-d field has. Does GT4Py have an entrypoint? Most likely there is no way to avoid some logic between field_operator/programs and the user calling them. Here we can easily canonicalize user objects. Small caveat: we cannot write to a scalar, but only to a 0d-field. ### Iterator IR #### Stencils take iterators, otherwise lift doesn't work ### Corner cases ```python= @field_operator def foo(cond: ?): return some_field_op() if cond == 1 else some_other_field_op() ``` - could be lowered as where - iterator ir currently requires an iterator