# Formalize Field-View concepts ###### tags: `cycle 15` - Shaped by: Enrique - Appetite: full cycle for @fthaler and at least 3 part-time core developers with deep understanding of the gt4py model. - Developers: ## Problem The current _field-view_ user frontend in `gt4py.next` is based on the idea of dealing with fields using a NumPy-like/tensor syntax with some extra domain-specific operations on top (e.g. `neighbor_sum`, `shift/remap`, ...). User-codes written are transformed to the IteratorIR representation which is later processed in the toolchain to obtain the final high-perfomance implementation of the program. One of the key ideas behing this `field-view` frontend design was to support a fast embedded implementation using tensor libraries like NumPy, JAX or PyTorch. Fast embedded execution is a great feature to help users to easily debug their codes. However, up until now only proof-of-concept implementation of embedded execution has been developed, and it is still quite rough, but it cannot be completed until the _field-view_ concepts are fully defined. ## Appetite The appetite for this project is large since this is a key milestone to formalize the core concepts of the current _field-view_ frontend and therefore for the development of the _embedded_ execution mode. Moreover, @fthaler is leaving soon and we need to gather and transfer as much knowledge as possible from hin, since he has a very deep understanding of the gt4py concepts both in the IR and in the frontends. ## Solution In this project we will discuss and clarify all the concepts defining _field-view_ (`field`, `remap`, `neighbor_reduction`, `dimension`, ...) and how they translate to a embedded implementation with tensor libraries. Additionally, once the concepts are well defined, we will investigate if _field-view_ itself can be used as a higher-level tensor-based IR with better optimization opportunities for the code-generation backends. As part of the discussion, we will: - Review and collect field-view assumptions used in the current lowering to IteratorIR. - Discuss efficient Python implementation strategies for the concepts. - Review and discuss [FieldIR idea](https://hackmd.io/@gridtools/S1y9Q3rFc) from @fthaler - Optionally, if time allows it, investigate the relationship between Field IR and Iterator IR - Maybe try to define Field-view / FieldIR in terms of _functor_, _profunctor_ and _comonad_ concepts as Iterator IR (see [Iterator IR, Functors, and Comonads](https://hackmd.io/x7vfhtrdQh-WQgAGEbHiZQ)) ## No-gos During the development of the project, it could be needed to use the existing embedded field-view prototype or to implement new prototypes to test some concepts, however, those prototypes are not expected to have a production-quality implementation and thus they won't be merged in `main`. ## Progress Peter's list of GT4Py issues: https://hackmd.io/@gridtools/HkMMfEqSn/edit ### Agenda for 2023-06-13 - [x] Details of how `remap` works, Dimensions, slicing - [x] Concatenation of fields, boundary conditions - [x] Scan ### Agenda for 2023-06-20 - [X] compile-time domain sizes would be much more complicated (specially in the blue line), because in a multi-node setup every node might have a different domain. - Short-term, it is not possible to implement a compile-time size IR, until the infrastructure to deal with this issues is in place. - [ ] why do all iterators of a stencil have to be in the same position? Write it down! <!--![](https://hackmd.io/_uploads/SkgKsykuh.jpg)--> ### Open field view questions - What is a field? - $f: D \rightarrow V, D = D_0 \times D_1 \times \dots \times D_n$ - Definition of domain of operations of fields - arithmetic binary: - $h(f,g): D_f \cap D_g \rightarrow V$ - $h(f,g)(x) = h(f(x), g(x)) \forall x \in D_f \cap D_g$ - currently: implicit broadcast in some cases - remap: - $remap(f, c) = f \circ c$ - $c: D_c \rightarrow D_{f'}, D_{f'} \subset D_f$ - $D$ of $remap(f,c)$ - inverse image of c for points in $D_f$: $c^{-1}[D_f]$ - where: - maybe desired - does intersection on all 3 fields, like arithmetic binary - $where(c, t, f)$ - with $c: D_c \rightarrow \{True, False\}$ - current state - resulting domain is domain of $D_c \cap (D_\tilde{t}\cup D_\tilde{f})$, $D_\tilde{t} = \left \{ d \forall d \in D_t \text{ where } c(d) == True \right \}$ and $D_\tilde{t} \subset D_c$ (f analogous). - this is bad for - dependency analysis if used for boundary conditions, because $c$ is runtime, and we need to reconstruct domain slicing from arbitrary expressions (using index fields). - embedded execution: maybe we cannot compute the domain of the resulting field efficiently - broadcast: - $broadcast(f, D): D_f \times D \rightarrow V$ - $broadcast(f, D)(x_0, \dots, x_D) = f(x_0, \dots, x_{D-1})$ - union (one of the 2, not implemented): - with precedence - without overlap - neighbor_reduce - $neighborsum(f, D_s): D \rightarrow V$, where $f: D \times D_s \rightarrow V$ - currently: - only 1 sparse dimension supported - if we want more we have to think about dimensions tags that appear multiple times (and the order of allowed reductions) - What is a field_operator? - single-output-field-operator: a function $f(g_0,g_1,...) \rightarrow h$ where $g_i, h$ are fields. - multi-output-field-operator: a set of functions $f_j(g_0,g_1,...) \rightarrow h_j$ where $g_i, h_j$ are fields. - Till wants to make this more concrete - Explicit slicing vs intersecting domains vs intersecting domains + explicit slicing? - intersecting domains (with possibility to define domain in the output) - if we need, we might want to introduce explicit slicing on top - apply field operator: take field $f: D_f \rightarrow V$, Domain $D_\text{out}$ and buffer $M$ assign the elements $f(D_\text{out})$ to $M$, $D_f \subset D\text{out}$ - compile-time vs runtime-time domain? - compile-time is much simpler to analyze - if embedded is fast enough, compile time doesn't really matter - we are partly using compile-time domains already (as a short-cut in temporaries) - What does combined IR mean? - The IR has 2 extremes: fully field representation (every operation is a map over the temporary domain) and fully local representation (only a single map of a stencil over the output domain) - local view: it only uses `map` (and other higher order functions) to apply scalar functions over fields - field view: it only uses _mapped_ versions of builtin scalar functions - combined view: it is possible to use `map`s and operators between fields - Do we need tuple of tensor? - (tensor IR doesn't have it) - A Field that represent data of neighbor points can be represented as a Field with local dimension (MCH Sparsefield, for variable number of neigbors its a masked/sparse array) or as a Field of local Fields (where the local Fields can have different number of values). - we conclude for the frontend we want to use the Field with local dimension - (probably we should do the same in Iterator IR / combined IR) - Column mode of Iterator IR (scan): - Iterator IR column probably is Iterator of 1D Field #### Boundary conditions - we allow slicing with compile time values (or relative to the field domain) - then we can concatenate non-overlapping fields with the constraint that they produce a rectangular shape ```python= @field_operator(backend=build_config.backend) def with_boundary_values( lower: Field[[Vertex, K], float_type], interior: Field[[Vertex, K], float_type], upper: Field[[Vertex, K], float_type], level_indices: Field[[K], int], num_level: int ) -> Field[[Vertex, K], float_type]: return where(level_indices == 0, lower, where(level_indices == num_level - 1, upper, interior)) # TODO(tehrengruber): move to seperate file @field_operator(backend=build_config.backend, grid_type=GridType.UNSTRUCTURED) def nabla_z(psi: Field[[Vertex, K], float_type], level_indices: Field[[K], int], num_level: int): return with_boundary_values( (domain(psi)[:, 0:1], psi(Koff[1]) - psi(Koff[0])), psi(Koff[1]) - psi(Koff[-1]), (domain(psi)[:, -1:], psi(Koff[0]) - psi(Koff[-1]), ) ] field[K(0):K(1)] + field == (field + field)[K(0):K(1)] == where(K==0, 2*field, None) # Non-overlapping concatenate concatenate( field[K(0):K(MAX_K - 1)], const_field[K(MAX_K):K(MAX_K + 1)] ) field[K.relative[0:-1] ^ const_field[K(30):] # Setting with precedence (JAX syntax) c = a.at[somewhere].set(b) # This can be implemented with slicing and concatenate ``` ### Combined IR questions - itir.NeighborList vs field view local dimension #### Field concept constraints (_Dimension_ from [Gibbons 2017](https://www.cs.ox.ac.uk/people/jeremy.gibbons/publications/aplicative.pdf)) - Functor: fmap - Applicative (Monoidal): pure, apply <*> - Foldable: foldl - Traversable: traverse - Naperian/Representable <= Profunctor ?? - Profunctor: contramap #### Iterator concept constraints ([IndexedComonadStore](https://hackage.haskell.org/package/lens-4.14/docs/Control-Lens-Internal-Context.html#t:IndexedComonadStore)) - Functor (IndexedFunctor): fmap - Comonad (IndexedComonad): extend (ITIR: lift), extract (ITIR: deref) - StoreComonad (IndexedStoreComonad): pos, peek, (extra: peeks, seek, seeks (ITIR: shift), experiment (ITIR: neighbors)) #### Playground: fields and iterators ```python # -- Fields and field operators -- f_a: Field[[I,J,K], Scalar] f_b: Field[[I,J,K], Scalar] def field_operator( field_a: Field[[I,J,K], Scalar], field_b: Field[[I,J,K], Scalar] ) -> Field[[I,J,K], Scalar]: return 0.5*field_a + 0.5*field_b f_c: Field[[I,J,K], Scalar] = field_operator(field_a, field_b) # -- Iterator and local operators -- def local_stencil( it_a: Iterator[[I,J,K], [I,J,K], Scalar], it_b: Iterator[[I,J,K], [I,J,K], Scalar], ) -> Scalar: return 0.5*extract(it_a) + 0.5*extract(it_b) f_a: Field[[I,J,K], Scalar] f_b: Field[[I,J,K], Scalar] # Option 1 -> field_op: (Field, Field) -> Field (implicitly computes maximum domain) ?? field_op: Callable[[Field[I,J,K], Field[I,J,K]], Field[I,J,K]] = fmap( local_stencil, #domain_maker=annalyze_stencil(local_stencil) # what about concatenate?? ) f_c: Field[[I,J,K], Scalar] = field_op(field_a, field_b) # Option 2 -> field_op: domain -> ((Field, Field) -> Field) (explicit domain) ?? field_op: Callable[ [Domain], Callable[[Field[I,J,K], Field[I,J,K]], Field[I,J,K] ] = fmap(local_stencil) f_c: Field[[I,J,K], Scalar] = field_op[out_domain](field_a, field_b) # -- Reshaping fields -- def expand_domain(it: Iterator[[I,J,K], [I,J], Field[[K], Scalar]]) -> Scalar: return extract(seeks((lambda i,j,k: i,j), it))[pos(it).k] f_splitted: Field[[I,J], Field[K]] f: Field[[I,J,K], Scalar] = fmap(expand_domain)[domain(I,J,K)](f_splitted) def slice_domain(it: Iterator[[I,J], [I,J,K], Scalar]) -> Field[[k], scalar]: return field( domain=(K,), [extract(seeks((lambda i,j: i,j,k), it)) for k in K] ) f: Field[[I,J,K], Scalar] f_splitted: Field[[I,J], Field[K]] = fmap(slice_domain)[domain(I,J)](f) ``` #### Playground: scan ```python # -- Scan with fields f_ijk: Field[[I,J,K], Scalar] def f_scan_pass( state: Field[[I,J], Scalar], # state: Field[[I_current, J_current], Scalar], field: Field[[I,J], Scalar] ) -> Field[[I,J], Scalar]: # -> Field[[I_current, J_current], Scalar]: return state + 0.5*field field_op: Callable[[Field[I,J,K]], Field[I,J,K]] = scan( f_scan_pass, K ) # -- Scan with iterators def it_scan_pass( state: Iterator[[?], [?], Scalar], it: Iterator[[I,J], [I,J], Scalar] ) -> Scalar: return state + 0.5*extract(it) + 0.25*extract(seeks(lambda i: i-1, it)) field_op: Callable[[Field[I,J,K]], Field[I,J,K]] = scan( it_scan_pass, K )[0:K_max] def it_scan_pass_k( state: Iterator[[?], [?], Scalar], it: Iterator[[K], [K], Scalar] ) -> Field[[I,J], Scalar]: return state + 0.5*extract(it) + 0.25*extract(seeks(lambda k: k-1, it)) combined_field_op: Callable[[Field[I,J,K]], Field[I,J,K]] = fmap( lambda it_of_f_k: scan(it_scan_pass_k)[0:K_max](extract(it_of_f_k)) )[0:I_max, 0:J_max](field_ijk) ``` #### Playground: Tuples ```python! def foo_it(it0, it1): return make_tuple(deref(it0), deref(it1)) def foo_f(f0: Field[D, dtype], f1: Field[D, dtype]) -> Field[D, tuple[dtype,dtype]]: return zip(f0, f1) def bar_f(f0: Field[D0, dtype], f1: Field[D1, dtype]): return f0, f1 ``` ```python! def whole_dycore(inp): return ... def main(inp): return diff_x(whole_dycore(inp)), diff_y(whole_dycore(inp)) def main2(inp): dycore_of_inp = whole_dycore(inp) return diff_x(dycore_of_inp), diff_y(dycore_of_inp) ``` ```python! def foo(e, v): if bar: tmp0 = e + 1 tmp1 = v + 2 + 4 return tmp0, tmp1 else: return e, v def foo_explicit(e,v): if bar: tmp0 = fmap(plus, d)(e, 1) tmp1 = fmap(plus, d)(fmap(plus, d)(v, 2), 4) return tmp0, tmp1 else: return e,v def foo1(e,v): if bar: tmp0 = fmap(...) tmp1 = fmap(lambda x: x + 2 + 4)(v) else: e, v def foo2(e,v): fmap(lambda e, v, bar: e + 1 if bar else e), fmap(lambda e,v, bar: v+2+4 if bar else v) def foo_e(e, v): if bar: tmp0 = e + 1 tmp1 = v + 2 return tmp0 else: return e def foo_v(e, v): ... # Broadcast constant if def foo_bcast(e, v): out_e = where(broadcast(bar, e), e + 1, e) out_v = where(broadcast(bar, v), v + 2 + 4, v) return out_e, out_v # if bar: # tmp0 = e + 1 # tmp1 = v + 2 + 4 # return tmp0, tmp1 # else: # return e, v ``` - ~~Tendency: FieldIR doesn't need the concept of returning multiple fields (on different domain), but it's important in the frontend, therefore we need a good understanding how to translate from field_operator with multiple returns to the IR without that. (E.g. extension of IR to connect to the frontend).~~ Definitions: Bob: is a function that takes fields and returns a single field, build from builtin functions which are constructed from fmapped local operations. - Second Try: FieldIR should have the concept of a function taking fields and returning fields. Such a function is not lowerable to a single stencil. But lowers to a tuple of Bobs, each of them lowering to stencil. (Since all field view builtins come from fmap, we can transform every Bob to a single stencil and every function returning fields to a tuple of stencils). (Till approves, but wants a better name for Bob.) - Intermediate solution: FieldIR should have the concept of a function taking fields and returning potentially multiple fields defined on arbitrary domains. Such a function is not lowerable to a single ITIR stencil. But: For all cases (single return statement with fields of different domain, if statements are always compile time) that we will need in the foreseeable future there is a simple way to write such functions (without duplicating the expressions which we are unsure how well we can optimize that) in a way that they only return a single field. As such we can also lower them regularly. All other cases that we can not lower this way should be prohibited (until we potentially abandon ITIR). ```python! def foo_f(f0: Field[D, dtype], f1: Field[D, dtype]) -> Field[D, array[dtype,2]]: return fzip(f0, f1) def bar_f(...): a, b = funzip(foo_f) def foo_f(f0: Field[D, dtype], f1: Field[D, dtype]) -> array[Field[D,dtype],2]: return zip(f0, f1) func <$> arg1 <*> arg2 <*> arg3 def bar_f(...): a, b = foo_f ``` ##### Questions How to fmap fields with the same domain? (Note: above we said we cannot map fields with different domains together) Ideas: - we zip the fields first 1. `(Field[D, dtype], f1: Field[D, dtype]) -> Field[D, array[dtype,2]]` now we can fmap fields with same domain and dtype 2. `(Field[D, dtype1], f1: Field[D, dtype2]) -> Field[D, tupe[dtype1,dtype2]]` this might not be a very interesting Field for the frontend, but allows to fmap fields with different dtype together what we absolutely want 3. as execution hint/policy introduce multi-fmap `mfmap` which maps `N` `field_operator`s over the same domain and returns `N` fields 4. similar to 3, but introduce `mfmap` takes a single local operations that returns tuple and outputs to tuple of fields Hannes thoughts: (1) is not useful as it doesn't allow expressing a single kernel over operations on different dtypes Maybe all above doesn't make sense because it's late...