Changes to MLIR MemRef Type

374 views
Skip to first unread message

Andy Davis

unread,
Jun 25, 2019, 7:55:00 PM6/25/19
to MLIR
All,
Just wanted to communicate that we are working on proposals that will result in changes to the MLIR MemRef type. We will communicate the concrete proposals when they are available. At a high level, there are a couple of changes that we are thinking about:
1) Removing the AffineMap from the MemRef type, so that memrefs can be passed between affine and non-affine regions.
2) Adding layout descriptor to the MemRef type. A competing proposal here is to make MemRef a simple buffer with a size, and use a view instruction to specify the layout and shape of the buffer.

Jun Qi

unread,
Jun 25, 2019, 10:17:35 PM6/25/19
to MLIR
I think that AffineMap is expressive to describe the layout. 
For example, there is a Tensor, whose layout is HWC. Then I want to do data tiling of this Tensor, it will be changed to CHW8c.

#map0 = (d0, d1, d2) -> (d2 floordiv 8, d0, d1, d2 mod 8)
%t1 = alloc() : memref<12x12x16xf32>
%t2 = alloc() : memref<12x12x16xf32, #map0>

affine
.for %i = 0 to 12 {
  affine
.for %j = 0 to 12 {
    affine
.for %k = 0 to 16 {
     
%1 = affine.load %t1[%i, %j, %k]
      affine.store %1 %t2[%i, %j, %k]
   
}
 
}
}


%t1 is the original data, and %t2 is the data after tiling. 
When I pass the same logic index, it will generate different memory address.

We can also do loop tiling at the same time,

affine.for %k = 0 to 2 {
  affine
.for %i = 0 to 12 {
    affine
.for %j = 0 to 12 {
      affine
.for %k_inner = 0 to 8 {
       
%2 = affine.load %t2[%i, %j, %k * 8 + %k_inner]
     
}
   
}
 
}
}

So there will be two AffineMaps, one is in affine.load, the other is in memref. These two AffineMaps could be composed, and then generate the right address in physical memory.

The layout has a close relation with data tiling and loop tiling. It's better to give a whole picture to the community.

Thanks! 


在 2019年6月26日星期三 UTC+8上午7:55:00,Andy Davis写道:

Jake Hall

unread,
Jun 26, 2019, 8:20:53 AM6/26/19
to MLIR
Hi Andy,

We would be interested to know how this might impact the "memory-space" attribute on MemRefs or any intended replacement.
In a system with disjoint address spaces the "memory-space" attribute could be quite useful for us to express the location of the underlying memory, and to understand where communication is needed.

Thanks,
Jake

Andy Davis

unread,
Jun 26, 2019, 12:49:51 PM6/26/19
to Jun Qi, MLIR
Thanks Jun,

I agree that AffineMap is very expressive for specifying layout.  The issue is that we would like to pass MemRefs/Buffers between affine and non-affine regions of code, and AffineMap is an affine dialect construct.  Likely the layout will being attribute and you can use AffineMap to express layout in affine regions if you like.

Agree about the composition of affine.load/affine.store affine maps and layout.  Forcing affine.load and affine.store to take affine maps by construction, will avoid the need to always compose the load/store affine maps when you want to evaluate that composition.

--
You received this message because you are subscribed to the Google Groups "MLIR" group.
To unsubscribe from this group and stop receiving emails from it, send an email to mlir+uns...@tensorflow.org.
To view this discussion on the web visit https://groups.google.com/a/tensorflow.org/d/msgid/mlir/7cba604a-b253-4009-b540-817782ff7906%40tensorflow.org.

Andy Davis

unread,
Jun 26, 2019, 12:51:41 PM6/26/19
to Jake Hall, MLIR
Thanks Jake,

The memory-space property will be preserved. At this point its not clear that it will be another attribute, or if it will be part of the layout attribute (as was done in the Tensor type layout attribute proposal).

--
You received this message because you are subscribed to the Google Groups "MLIR" group.
To unsubscribe from this group and stop receiving emails from it, send an email to mlir+uns...@tensorflow.org.

Uday Bondhugula

unread,
Jun 26, 2019, 5:30:59 PM6/26/19
to MLIR


On Wednesday, June 26, 2019 at 10:19:51 PM UTC+5:30, Andy Davis wrote:
Thanks Jun,

I agree that AffineMap is very expressive for specifying layout.  The issue is that we would like to pass MemRefs/Buffers between affine and non-affine regions of code, and AffineMap is an affine dialect construct.  Likely the layout will being attribute and you can use AffineMap to express layout in affine

But AffineMap exists in IR/ and is available for all of MLIR. I'm not sure why any part of MLIR can't make use of affine maps. Could you say a little more as to what the difficulty was in passing the current memref? Any block of op's in MLIR is a valid affine region trivially if you don't "open up" any op that may be holding regions.  If the issue is the restriction on the operands of the 'alloc' op, that could be addressed by introducing another version of alloc that didn't have those restrictions (or rename the current one affine.alloc).  

And if the issue is the difficulty in dealing with non-identity layout maps with memref's in certain passes/places, that could be addressed by enforcing (and verifying) that the memref's appearing have an identity layout. It would be pretty straightforward (and necessary at some point) to have a utility that lowers memref's with arbitrary layouts to those with identity layouts by just composing the layout map with the subscript/index maps on the load's/stores. And finally, if one wants a 1-d physical buffer + index abstraction, that could be addressed by lowering (either to the LLVM dialect or making use of the unrestricted load/store op's on flattened memref's). 

~ Uday

 
regions if you like.

Agree about the composition of affine.load/affine.store affine maps and layout.  Forcing affine.load and affine.store to take affine maps by construction, will avoid the need to always compose the load/store affine maps when you want to evaluate that composition.

To unsubscribe from this group and stop receiving emails from it, send an email to ml...@tensorflow.org.

Uday Bondhugula

unread,
Jun 26, 2019, 5:53:59 PM6/26/19
to MLIR


On Wednesday, June 26, 2019 at 7:47:35 AM UTC+5:30, Jun Qi wrote:
I think that AffineMap is expressive to describe the layout. 
For example, there is a Tensor, whose layout is HWC. Then I want to do data tiling of this Tensor, it will be changed to CHW8c.

#map0 = (d0, d1, d2) -> (d2 floordiv 8, d0, d1, d2 mod 8)
%t1 = alloc() : memref<12x12x16xf32>
%t2 = alloc() : memref<12x12x16xf32, #map0>

affine
.for %i = 0 to 12 {
  affine
.for %j = 0 to 12 {
    affine
.for %k = 0 to 16 {
     
%1 = affine.load %t1[%i, %j, %k]
      affine.store %1 %t2[%i, %j, %k]
   
}
 
}
}


%t1 is the original data, and %t2 is the data after tiling. 
When I pass the same logic index, it will generate different memory address.

We can also do loop tiling at the same time,

affine.for %k = 0 to 2 {
  affine
.for %i = 0 to 12 {
    affine
.for %j = 0 to 12 {
      affine
.for %k_inner = 0 to 8 {
       
%2 = affine.load %t2[%i, %j, %k * 8 + %k_inner]
     
}
   
}
 
}
}

So there will be two AffineMaps, one is in affine.load, the other is in memref. These two AffineMaps could be composed, and then generate the right address in physical memory.

The layout has a close relation with data tiling and loop tiling. It's better to give a whole picture to the community.

This is an excellent example and great points. This was indeed the motivation behind having an affine map encoded into the type of the memref. It allows one to abstract away the actual mapping to physical memory from the logical indexing, and move that information into the 'type' of the memref. The indexing thus stays free of the data layout choice. Note that the layout map doesn't impact data dependences, and so, in theory, one can freely change or try different layout maps (as long as they are one-to-one) while still generating correct code. Affine maps allow a range of complex layouts - but if something more is useful/needed, those could be achieved by introducing custom index computation in front of the load's/store's, with the caveat that passes/utilities looking at the load/store would no longer be able to analyze the nature of accesses. 

~ Uday

~ Uday

Jun Qi

unread,
Jun 27, 2019, 1:26:38 AM6/27/19
to MLIR
Agreed. Such a utility that composes layout map in memref to index map in load/store would be very helpful!

在 2019年6月27日星期四 UTC+8上午5:30:59,Uday Bondhugula写道:

Andy Davis

unread,
Jun 28, 2019, 9:11:47 PM6/28/19
to Uday Bondhugula, MLIR
Thanks Uday.  I think that this is motivated in part, by part of the motivation for creating affine.load/store (so that std.load/store do not always have to deal with the affine constraint), and we'd like the memref/buffer type to be passed freely between non-affine and affine regions. Do you have a use case for using an affine map in a non-affine region? 

To unsubscribe from this group and stop receiving emails from it, send an email to mlir+uns...@tensorflow.org.
To view this discussion on the web visit https://groups.google.com/a/tensorflow.org/d/msgid/mlir/9e738334-f5e0-4bda-9e80-7496e54412a4%40tensorflow.org.

Uday Bondhugula

unread,
Jun 29, 2019, 2:41:49 AM6/29/19
to MLIR


On Friday, June 28, 2019 at 6:11:47 PM UTC-7, Andy Davis wrote:
Thanks Uday.  I think that this is motivated in part, by part of the motivation for creating affine.load/store (so that std.load/store do not always have to deal with the affine constraint), and we'd like the memref/buffer type to be passed freely between non-affine and affine regions. Do you have a use case for using an affine map in a non-affine region? 

Affine layout maps (expressing layouts like permutations, tiled layouts, compositions of those, etc) don't have to be associated with affine control flow or data accesses; they could be used by any MLIR including with unrestricted std.load/std.store. Data layouts don't impact dependences or execution semantics (for shared-memory compilation) - so, it's not tied-up with affine accesses or control flow, and I don't see why layout maps should be restricted to some part of the IR. An identity layout map could be viewed as one that specifies nothing, and we already don't print it as part of the type in such cases. The current layout map isn't placing any constraint on std.load/std.store. Is your objective that of allowing more complex (non-affine) layouts encoded in the type?

~ Uday
 

Alex Zinenko

unread,
Jul 3, 2019, 1:50:41 PM7/3/19
to Uday Bondhugula, MLIR
We are slowly moving towards more modular dialects, and eventually all attribute kinds will belong to specific dialects.  AffineMap will logically go into the Affine dialect and it looks preferable to avoid the dependency from a built-in type (memref) on a no-longer-built-in dialect (Affine).  Orthogonally to that, we are exploring different ways of encoding layouts that are available in the existing tools with a vague idea of making the layout somehow extensible and easier to use.

Regarding the 1d buffers, this may be necessary at a higher level of abstraction than memrefs.  For example, if we hypothetically wanted to repllicate the XLA representation to MLIR affine+standard, we would need to reflect the fact that XLA allocates large linear buffers (e.g. in the GPU global memory) and (re)uses slices of that linear buffer to store data.  The slice could be a memref, but we need to also know about the underlying buffer and its linear structure as well.

To unsubscribe from this group and stop receiving emails from it, send an email to mlir+uns...@tensorflow.org.
To view this discussion on the web visit https://groups.google.com/a/tensorflow.org/d/msgid/mlir/8d152d13-00c8-450c-a71d-3df94c1ed550%40tensorflow.org.


--
-- Alex

Chris Lattner

unread,
Jul 7, 2019, 3:38:47 PM7/7/19
to Alex Zinenko, Uday Bondhugula, MLIR
It’s a cosmetic thing, but I’d also really love to see ‘memref' get renamed to ‘buffer’.

-Chris


Alex Zinenko

unread,
Jul 8, 2019, 4:29:47 AM7/8/19
to Chris Lattner, Uday Bondhugula, MLIR
IMO, "buffer" sounds like it implies ownership/exclusiveness semantics.  That is, having two values of type "buffer<???>" sounds like a guarantee that they are different buffers and don't alias, which is not the case with memrefs currently.  I know this isn't true in many programming models, but it is often the case in ML-ish libraries.  Memref, on the other hand, implies it's a reference, although I'm not a fan of the name.
--
-- Alex

Mamy Ratsimbazafy

unread,
Jul 8, 2019, 10:53:51 AM7/8/19
to MLIR
I've had a similar potential bikeshed on how to name a (pointer, length) pair.

Here is an overview of the names used in the wild:

- Slice, from Rust and from Go
- Openarray, from NimPascal, Modula, Oberon
- View
- Range or MemRange

-Mamy


--
-- Alex

--
You received this message because you are subscribed to the Google Groups "MLIR" group.
To unsubscribe from this group and stop receiving emails from it, send an email to ml...@tensorflow.org.

Uday Bondhugula

unread,
Jul 10, 2019, 1:41:31 AM7/10/19
to MLIR


On Monday, July 8, 2019 at 1:59:47 PM UTC+5:30, Alex Zinenko wrote:
IMO, "buffer" sounds like it implies ownership/exclusiveness semantics.  That is, having two values of type "buffer<???>" sounds like a guarantee that they are different buffers and don't alias, which is not the case with memrefs currently.  I know this isn't true in many programming models, but it is often the case in ML-ish libraries.  Memref, on the other hand, implies it's a reference, although I'm not a fan of the name.

+1 to retaining memref's, although note that if you do have a buffer type, they could still alias (for eg. when the same buffer SSA value is passed via two different arguments of the same function call). 

But I would still argue against these 1-d buffer (pointer + offset) abstractions in MLIR proper because:

1) I think MLIR is a lot about preserving the multi-dimensional nature of data whenever possible, and adding such pointer + offset abstractions in the core type system is often a liability because users could generate these instead of true multi-dimensional memref's. As a result, you get lower level representations instead of higher level ones too early, and you are back to the issues one faces with IRs like LLVM while performing high-level or mid-level analyses/transforms.

2) You could always have lower-level dialects have such buffer abstractions (for eg. the LLVM dialect could have a buffer + offset abstraction that readily lowers to GEPs). This also makes sure that such things get generated when everything else (surrounding IR) is also low level, i.e., lowering happens in a coordinated way. While one could say that MLIR allows mixing arbitrary dialect ops and that everything being associated with some dialect is modular, it is also a hindrance for passes/utilities to have to deal with high and low level versions of the same thing from different dialects.

Coming back, I still don't see the motivation behind changing the memref type to a 1-d buffer with size. One could add such abstractions separately, but it looks like those are still better done in specific low level dialects instead of in MLIR's type system.

~ Uday

 



--
-- Alex

--
You received this message because you are subscribed to the Google Groups "MLIR" group.
To unsubscribe from this group and stop receiving emails from it, send an email to ml...@tensorflow.org.

Alex Zinenko

unread,
Jul 10, 2019, 4:35:02 AM7/10/19
to Uday Bondhugula, MLIR
On Wed, Jul 10, 2019 at 7:41 AM 'Uday Bondhugula' via MLIR <ml...@tensorflow.org> wrote:


On Monday, July 8, 2019 at 1:59:47 PM UTC+5:30, Alex Zinenko wrote:
IMO, "buffer" sounds like it implies ownership/exclusiveness semantics.  That is, having two values of type "buffer<???>" sounds like a guarantee that they are different buffers and don't alias, which is not the case with memrefs currently.  I know this isn't true in many programming models, but it is often the case in ML-ish libraries.  Memref, on the other hand, implies it's a reference, although I'm not a fan of the name.

+1 to retaining memref's, although note that if you do have a buffer type, they could still alias (for eg. when the same buffer SSA value is passed via two different arguments of the same function call). 

But I would still argue against these 1-d buffer (pointer + offset) abstractions in MLIR proper because:

These days, you need to define what "MLIR proper" means.  MLIR's IR is currently a set of dialects containing operations, attributes and types.  There are so called "standard types", which are expected to belong to the standard dialect after a refactoring.  The only difference between those and dialect-defined types is that they don't have to be prefixed with a bang. (Note that the same is true for ops in the standard dialect that are allowed to elide the std. prefix.)  I would strongly support that some MLIR dialect that lives in the MLIR code base (i.e. tensorflow/mlir as opposed to tensorflow/tensorflow/compiler) has a sized buffer abstraction.  Whether it is the standard dialect or not is a different discussion.  At this point, "standard" does not mean anything to me.
 

1) I think MLIR is a lot about preserving the multi-dimensional nature of data whenever possible, and adding such pointer + offset abstractions in the core type system is often a liability because users could generate these instead of true multi-dimensional memref's. As a result, you get lower level representations instead of higher level ones too early, and you are back to the issues one faces with IRs like LLVM while performing high-level or mid-level analyses/transforms.

I fully agree with the danger of having lower-level abstractions available and being used directly instead of higher-level abstractions.  I do however think that it is an organizational problem, not a technical problem.  The LLVM dialect is available and literally nothing prevents users from targeting it directly instead of standard/affine. And people actually did go directly to LLVM (although that code actually reuses the lowering functions, it never materializes the intermediate steps in the IR).  We should just explain properly why targeting a low-level dialect directly may be a bad idea.  Sometimes it is not, when the user upstream knows what they are doing and by lowreing early they can actually tell MLIR not to touch their code.

Depending on the interface points with an external representation, a 1D sized buffer may be the right abstraction, if followed by some "view" operation that reconstructs the multidimensional nature of the underlying data.  For example, if you are willing to interoperate with C, you can accept a bare pointer and view it as a multi-dimensional array with layouts internally.  So it's not a matter of having an abstraction, it's a matter of using it judiciously.
 

2) You could always have lower-level dialects have such buffer abstractions (for eg. the LLVM dialect could have a buffer + offset abstraction that readily lowers to GEPs). This also makes sure that such things get generated when everything else (surrounding IR) is also low level, i.e., lowering happens in a coordinated way. While one could say that MLIR allows mixing arbitrary dialect ops and that everything being associated with some dialect is modular, it is also a hindrance for passes/utilities to have to deal with high and low level versions of the same thing from different dialects.

The design rationale of the LLVM dialect is to reflect the actual LLVM IR as closely as possible.  LLVM IR does not have a sized buffer abstraction (it does have fixed-size arrays though), so neither should the LLVM dialect.

I am not convinced that buffers should appear very late.  If we follow XLA's steps to go from tensor-level operations to scalar operations, buffer allocation happens before loops are generated.
 

Coming back, I still don't see the motivation behind changing the memref type to a 1-d buffer with size. One could add such abstractions separately, but it looks like those are still better done in specific low level dialects instead of in MLIR's type system.

This sounds consistent with Andy's original email, except for naming
> A competing proposal here is to make MemRef a simple buffer with a size, and use a view instruction to specify the layout and shape of the buffer.

In the end, there is one abstraction for a sized buffer and another abstraction for a reference to a buffer with shape and layout.

 
To unsubscribe from this group and stop receiving emails from it, send an email to mlir+uns...@tensorflow.org.
To view this discussion on the web visit https://groups.google.com/a/tensorflow.org/d/msgid/mlir/83bb23c6-88ea-43d8-92fa-45957ef035c2%40tensorflow.org.


--
-- Alex

Uday Bondhugula

unread,
Jul 17, 2019, 9:07:29 AM7/17/19
to MLIR


On Wednesday, July 10, 2019 at 2:05:02 PM UTC+5:30, Alex Zinenko wrote:


On Wed, Jul 10, 2019 at 7:41 AM 'Uday Bondhugula' via MLIR <ml...@tensorflow.org> wrote:


On Monday, July 8, 2019 at 1:59:47 PM UTC+5:30, Alex Zinenko wrote:
IMO, "buffer" sounds like it implies ownership/exclusiveness semantics.  That is, having two values of type "buffer<???>" sounds like a guarantee that they are different buffers and don't alias, which is not the case with memrefs currently.  I know this isn't true in many programming models, but it is often the case in ML-ish libraries.  Memref, on the other hand, implies it's a reference, although I'm not a fan of the name.

+1 to retaining memref's, although note that if you do have a buffer type, they could still alias (for eg. when the same buffer SSA value is passed via two different arguments of the same function call). 

But I would still argue against these 1-d buffer (pointer + offset) abstractions in MLIR proper because:

These days, you need to define what "MLIR proper" means.  MLIR's IR is currently a set of dialects containing operations, attributes and types.  There are so called "standard types", which are expected to belong to the standard dialect after a refactoring.  The only difference between those and dialect-defined types is that they don't have to be prefixed with a bang. (Note that the same is true for ops in the standard dialect that are allowed to elide the std. prefix.)  I would strongly support that some MLIR dialect that lives in the MLIR code base (i.e. tensorflow/mlir as opposed to tensorflow/tensorflow/compiler) has a sized buffer abstraction.  Whether it is the standard dialect or not is a different discussion.  At this point, "standard" does not mean anything to me.
 

1) I think MLIR is a lot about preserving the multi-dimensional nature of data whenever possible, and adding such pointer + offset abstractions in the core type system is often a liability because users could generate these instead of true multi-dimensional memref's. As a result, you get lower level representations instead of higher level ones too early, and you are back to the issues one faces with IRs like LLVM while performing high-level or mid-level analyses/transforms.

I fully agree with the danger of having lower-level abstractions available and being used directly instead of higher-level abstractions.  I do however think that it is an organizational problem, not a technical problem.  The LLVM dialect is available and literally nothing prevents users from targeting it directly instead of standard/affine. And people actually did go directly to LLVM (although that code actually reuses the lowering functions, it never materializes the intermediate steps in the IR).  We should just explain properly why targeting a low-level dialect directly may be a bad idea.  Sometimes it is not, when the user upstream knows what they are doing and by lowreing early they can actually tell MLIR not to touch their code.

Depending on the interface points with an external representation, a 1D sized buffer may be the right abstraction, if followed by some "view" operation that reconstructs the multidimensional nature of the underlying data.  For example, if you are willing to interoperate with C, you can accept a bare pointer and view it as a multi-dimensional array with layouts internally.  So it's not a matter of having an abstraction, it's a matter of using it judiciously.
 

2) You could always have lower-level dialects have such buffer abstractions (for eg. the LLVM dialect could have a buffer + offset abstraction that readily lowers to GEPs). This also makes sure that such things get generated when everything else (surrounding IR) is also low level, i.e., lowering happens in a coordinated way. While one could say that MLIR allows mixing arbitrary dialect ops and that everything being associated with some dialect is modular, it is also a hindrance for passes/utilities to have to deal with high and low level versions of the same thing from different dialects.

The design rationale of the LLVM dialect is to reflect the actual LLVM IR as closely as possible.  LLVM IR does not have a sized buffer abstraction (it does have fixed-size arrays though), so neither should the LLVM dialect.

I am not convinced that buffers should appear very late.  If we follow XLA's steps to go from tensor-level operations to scalar operations, buffer allocation happens before loops are generated.

But buffer allocation and reuse can already be done with the current multidimensional memref's and views. It's still not clear why one needs a memref to be a 1-d buffer + size abstraction in order to do allocation and reuse. What is really missing is the equivalent of a "pointer to the underlying buffer" abstraction that is desired when mapping to calls to say external tuned libraries or for FFI at the mid-level or at the high-level of MLIR (when you don't want to introduce lower level dialect abstractions in the IR), or in scenarios like the ones Nagy Mostafa and you mention in two other threads respectively. And these appear to be better addressed by introducing a 'buf' operation on a memref that returns a buffer type (which is elemental type-erased like you asked for).  This would be very different from the proposal in the OP of changing the memref type to make it a sized 1-d buffer because with the latter, you are also incidentally (and unintentionally?) providing a user a way to deference a 1-d thing, which really isn't needed and which is an extra alias. Wasn't your intention to get access to the underlying buffer as opposed to use it for indexing?

OTOH, having a 'buf' operation return a buffer type given the current memref operand means:
(1) one still can't deference it, but is still able to use it with an FFI, as an argument to BLAS calls etc., which is very useful during early stages of lowering, (the type will readily map to void *)
(2) the only way to construct such buffer types would be by using a 'buf' operation on an existing memref that is of the desired dimensionality.

~ Uday

 

Alex Zinenko

unread,
Jul 18, 2019, 6:10:40 AM7/18/19
to Uday Bondhugula, MLIR
On Wed, Jul 17, 2019 at 3:07 PM 'Uday Bondhugula' via MLIR <ml...@tensorflow.org> wrote:


On Wednesday, July 10, 2019 at 2:05:02 PM UTC+5:30, Alex Zinenko wrote:


On Wed, Jul 10, 2019 at 7:41 AM 'Uday Bondhugula' via MLIR <ml...@tensorflow.org> wrote:


On Monday, July 8, 2019 at 1:59:47 PM UTC+5:30, Alex Zinenko wrote:
IMO, "buffer" sounds like it implies ownership/exclusiveness semantics.  That is, having two values of type "buffer<???>" sounds like a guarantee that they are different buffers and don't alias, which is not the case with memrefs currently.  I know this isn't true in many programming models, but it is often the case in ML-ish libraries.  Memref, on the other hand, implies it's a reference, although I'm not a fan of the name.

+1 to retaining memref's, although note that if you do have a buffer type, they could still alias (for eg. when the same buffer SSA value is passed via two different arguments of the same function call). 

But I would still argue against these 1-d buffer (pointer + offset) abstractions in MLIR proper because:

These days, you need to define what "MLIR proper" means.  MLIR's IR is currently a set of dialects containing operations, attributes and types.  There are so called "standard types", which are expected to belong to the standard dialect after a refactoring.  The only difference between those and dialect-defined types is that they don't have to be prefixed with a bang. (Note that the same is true for ops in the standard dialect that are allowed to elide the std. prefix.)  I would strongly support that some MLIR dialect that lives in the MLIR code base (i.e. tensorflow/mlir as opposed to tensorflow/tensorflow/compiler) has a sized buffer abstraction.  Whether it is the standard dialect or not is a different discussion.  At this point, "standard" does not mean anything to me.
 

1) I think MLIR is a lot about preserving the multi-dimensional nature of data whenever possible, and adding such pointer + offset abstractions in the core type system is often a liability because users could generate these instead of true multi-dimensional memref's. As a result, you get lower level representations instead of higher level ones too early, and you are back to the issues one faces with IRs like LLVM while performing high-level or mid-level analyses/transforms.

I fully agree with the danger of having lower-level abstractions available and being used directly instead of higher-level abstractions.  I do however think that it is an organizational problem, not a technical problem.  The LLVM dialect is available and literally nothing prevents users from targeting it directly instead of standard/affine. And people actually did go directly to LLVM (although that code actually reuses the lowering functions, it never materializes the intermediate steps in the IR).  We should just explain properly why targeting a low-level dialect directly may be a bad idea.  Sometimes it is not, when the user upstream knows what they are doing and by lowreing early they can actually tell MLIR not to touch their code.

Depending on the interface points with an external representation, a 1D sized buffer may be the right abstraction, if followed by some "view" operation that reconstructs the multidimensional nature of the underlying data.  For example, if you are willing to interoperate with C, you can accept a bare pointer and view it as a multi-dimensional array with layouts internally.  So it's not a matter of having an abstraction, it's a matter of using it judiciously.
 

2) You could always have lower-level dialects have such buffer abstractions (for eg. the LLVM dialect could have a buffer + offset abstraction that readily lowers to GEPs). This also makes sure that such things get generated when everything else (surrounding IR) is also low level, i.e., lowering happens in a coordinated way. While one could say that MLIR allows mixing arbitrary dialect ops and that everything being associated with some dialect is modular, it is also a hindrance for passes/utilities to have to deal with high and low level versions of the same thing from different dialects.

The design rationale of the LLVM dialect is to reflect the actual LLVM IR as closely as possible.  LLVM IR does not have a sized buffer abstraction (it does have fixed-size arrays though), so neither should the LLVM dialect.

I am not convinced that buffers should appear very late.  If we follow XLA's steps to go from tensor-level operations to scalar operations, buffer allocation happens before loops are generated.

But buffer allocation and reuse can already be done with the current multidimensional memref's and views. It's still not clear why one needs a memref to be a 1-d buffer + size abstraction in order to do allocation and reuse. What is really missing is the equivalent of a "pointer to the underlying buffer" abstraction that is desired when mapping to calls to say external tuned libraries or for FFI at the mid-level or at the high-level of MLIR (when you don't want to introduce lower level dialect abstractions in the IR), or in scenarios like the ones Nagy Mostafa and you mention in two other threads respectively. And these appear to be better addressed by introducing a 'buf' operation on a memref that returns a buffer type (which is elemental type-erased like you asked for).  This would be very different from the proposal in the OP of changing the memref type to make it a sized 1-d buffer because with the latter, you are also incidentally (and unintentionally?) providing a user a way to deference a 1-d thing, which really isn't needed and which is an extra alias. Wasn't your intention to get access to the underlying buffer as opposed to use it for indexing?

I think there is a bit of terminology confusion between different people in different threads.  What you describe above is very close to what I had in mind.  One of the design points I want to make is to separate allocation abstraction (I call this a buffer) from indexing abstraction (I call this a view), but this is not fully decided yet.  Currently, memrefs are both.  The proposition (2) is the original email is to make memref what I call a buffer, and create a view type.  What you seem to be proposing is to create a buffer type, and make memref what I call a view.  It's essentially the same thing except for naming :)  I think it does make sense to have both a view-creation and an underlying-buffer-extraction operations to go between buffers and views, where the latter is the "buf" operation you are proposing.
 

OTOH, having a 'buf' operation return a buffer type given the current memref operand means:
(1) one still can't deference it, but is still able to use it with an FFI, as an argument to BLAS calls etc., which is very useful during early stages of lowering, (the type will readily map to void *)
(2) the only way to construct such buffer types would be by using a 'buf' operation on an existing memref that is of the desired dimensionality.

This means the lowering starts with functions accepting memrefs, which is not always desirable.  I have a use case where I would want to have something like:

@func compute(%inputs: !buffer<i8>, %output: !buffer<i8>, %temp: !buffer<i8>) {
  %a = "slice"(%inputs) {offset = 1024, shape=[32,32], layout="WH", type="f32"} : (!buffer<i8>) -> memref<?x?xf32>
  %b = "slice"(%inputs) {offset = 0, shape=[32,32], layout="HW", type="f32"} : (!buffer<i8>) -> memref<?x?xf32, (i,j)->(j,i)>
  %c = "slice"(%temp) {offset = 0, shape=[32,32], layout="WH", type="f32"} : (!buffer<i8>) -> memref<?x?xf32>
  %d = "slice"(%outputs) {offset = 0, shape=[32,32], layout="WH", type="f32"} : (!buffer<i8>) -> memref<?x?xf32>
  "layer.fc"(%a, %b, %c) // %c is the output
  "layer.relu(%c) // inplace
  "layer.fc"(%a, %c, %d) // %d is the output
  "layer.relu"(%c) // inplace
  return
}

to delegate buffer assignment to the framework using MLIR.  It feels conceptually cleaner if the framework passes buffers to the function, rather than memrefs from which the function should recover the buffers.  This is especially true for various temporary or scratchpad buffers when something like TF captures all the memory, where implementation function essentially gets a pointer to a temporary buffer whether it needs it or not and is allowed to do whatever it wants with it.
 
To unsubscribe from this group and stop receiving emails from it, send an email to mlir+uns...@tensorflow.org.
To view this discussion on the web visit https://groups.google.com/a/tensorflow.org/d/msgid/mlir/d7c3e5d9-562a-4b9b-aec9-8c7090a33a31%40tensorflow.org.


--
-- Alex

Uday Bondhugula

unread,
Jul 23, 2019, 10:11:23 AM7/23/19
to MLIR


On Thursday, July 18, 2019 at 3:40:40 PM UTC+5:30, Alex Zinenko wrote:


On Wed, Jul 17, 2019 at 3:07 PM 'Uday Bondhugula' via MLIR <ml...@tensorflow.org> wrote:


On Wednesday, July 10, 2019 at 2:05:02 PM UTC+5:30, Alex Zinenko wrote:


On Wed, Jul 10, 2019 at 7:41 AM 'Uday Bondhugula' via MLIR <ml...@tensorflow.org> wrote:


On Monday, July 8, 2019 at 1:59:47 PM UTC+5:30, Alex Zinenko wrote:
IMO, "buffer" sounds like it implies ownership/exclusiveness semantics.  That is, having two values of type "buffer<???>" sounds like a guarantee that they are different buffers and don't alias, which is not the case with memrefs currently.  I know this isn't true in many programming models, but it is often the case in ML-ish libraries.  Memref, on the other hand, implies it's a reference, although I'm not a fan of the name.

+1 to retaining memref's, although note that if you do have a buffer type, they could still alias (for eg. when the same buffer SSA value is passed via two different arguments of the same function call). 

But I would still argue against these 1-d buffer (pointer + offset) abstractions in MLIR proper because:

These days, you need to define what "MLIR proper" means.  MLIR's IR is currently a set of dialects containing operations, attributes and types.  There are so called "standard types", which are expected to belong to the standard dialect after a refactoring.  The only difference between those and dialect-defined types is that they don't have to be prefixed with a bang. (Note that the same is true for ops in the standard dialect that are allowed to elide the std. prefix.)  I would strongly support that some MLIR dialect that lives in the MLIR code base (i.e. tensorflow/mlir as opposed to tensorflow/tensorflow/compiler) has a sized buffer abstraction.  Whether it is the standard dialect or not is a different discussion.  At this point, "standard" does not mean anything to me.
 

1) I think MLIR is a lot about preserving the multi-dimensional nature of data whenever possible, and adding such pointer + offset abstractions in the core type system is often a liability because users could generate these instead of true multi-dimensional memref's. As a result, you get lower level representations instead of higher level ones too early, and you are back to the issues one faces with IRs like LLVM while performing high-level or mid-level analyses/transforms.

I fully agree with the danger of having lower-level abstractions available and being used directly instead of higher-level abstractions.  I do however think that it is an organizational problem, not a technical problem.  The LLVM dialect is available and literally nothing prevents users from targeting it directly instead of standard/affine. And people actually did go directly to LLVM (although that code actually reuses the lowering functions, it never materializes the intermediate steps in the IR).  We should just explain properly why targeting a low-level dialect directly may be a bad idea.  Sometimes it is not, when the user upstream knows what they are doing and by lowreing early they can actually tell MLIR not to touch their code.

Depending on the interface points with an external representation, a 1D sized buffer may be the right abstraction, if followed by some "view" operation that reconstructs the multidimensional nature of the underlying data.  For example, if you are willing to interoperate with C, you can accept a bare pointer and view it as a multi-dimensional array with layouts internally.  So it's not a matter of having an abstraction, it's a matter of using it judiciously.
 

2) You could always have lower-level dialects have such buffer abstractions (for eg. the LLVM dialect could have a buffer + offset abstraction that readily lowers to GEPs). This also makes sure that such things get generated when everything else (surrounding IR) is also low level, i.e., lowering happens in a coordinated way. While one could say that MLIR allows mixing arbitrary dialect ops and that everything being associated with some dialect is modular, it is also a hindrance for passes/utilities to have to deal with high and low level versions of the same thing from different dialects.

The design rationale of the LLVM dialect is to reflect the actual LLVM IR as closely as possible.  LLVM IR does not have a sized buffer abstraction (it does have fixed-size arrays though), so neither should the LLVM dialect.

I am not convinced that buffers should appear very late.  If we follow XLA's steps to go from tensor-level operations to scalar operations, buffer allocation happens before loops are generated.

But buffer allocation and reuse can already be done with the current multidimensional memref's and views. It's still not clear why one needs a memref to be a 1-d buffer + size abstraction in order to do allocation and reuse. What is really missing is the equivalent of a "pointer to the underlying buffer" abstraction that is desired when mapping to calls to say external tuned libraries or for FFI at the mid-level or at the high-level of MLIR (when you don't want to introduce lower level dialect abstractions in the IR), or in scenarios like the ones Nagy Mostafa and you mention in two other threads respectively. And these appear to be better addressed by introducing a 'buf' operation on a memref that returns a buffer type (which is elemental type-erased like you asked for).  This would be very different from the proposal in the OP of changing the memref type to make it a sized 1-d buffer because with the latter, you are also incidentally (and unintentionally?) providing a user a way to deference a 1-d thing, which really isn't needed and which is an extra alias. Wasn't your intention to get access to the underlying buffer as opposed to use it for indexing?

I think there is a bit of terminology confusion between different people in different threads.  What you describe above is very close to what I had in mind.  One of the design points I want to make is to separate allocation abstraction (I call this a buffer) from indexing abstraction (I call this a view), but this is not fully decided yet.  Currently, memrefs are both.  The proposition (2) is the original email is to make memref what I call a buffer, and create a view type.  What you seem to be proposing is to create a buffer type, and make memref what I call a view.  It's essentially the same thing except for naming :)  I think it does make sense to have both a view-creation and an underlying-buffer-extraction operations to go between buffers and views, where the latter is the "buf" operation you are proposing.


I see - thanks. So, the discussion (putting naming aside) is really about whether to allocate a buffer and create memref's from it or just allocate memref's and extract the underlying buffer when needed: let me call these proposals A and B respectively. As you point out below, one also needs to be able to create a memref given a buffer, typically at entry points from external land. So, even with the second proposal, there has to be a way to create a memref from a 'buffer' type (sort of an view_from_buf operations along the lines of an alloc_static that's already in the spec). Your example below is useful for discussion and some comments further below.

 
 

OTOH, having a 'buf' operation return a buffer type given the current memref operand means:
(1) one still can't deference it, but is still able to use it with an FFI, as an argument to BLAS calls etc., which is very useful during early stages of lowering, (the type will readily map to void *)
(2) the only way to construct such buffer types would be by using a 'buf' operation on an existing memref that is of the desired dimensionality.

This means the lowering starts with functions accepting memrefs, which is not always desirable.  I have a use case where I would want to have something like:

@func compute(%inputs: !buffer<i8>, %output: !buffer<i8>, %temp: !buffer<i8>) {
  %a = "slice"(%inputs) {offset = 1024, shape=[32,32], layout="WH", type="f32"} : (!buffer<i8>) -> memref<?x?xf32>
  %b = "slice"(%inputs) {offset = 0, shape=[32,32], layout="HW", type="f32"} : (!buffer<i8>) -> memref<?x?xf32, (i,j)->(j,i)>
  %c = "slice"(%temp) {offset = 0, shape=[32,32], layout="WH", type="f32"} : (!buffer<i8>) -> memref<?x?xf32>
  %d = "slice"(%outputs) {offset = 0, shape=[32,32], layout="WH", type="f32"} : (!buffer<i8>) -> memref<?x?xf32>
  "layer.fc"(%a, %b, %c) // %c is the output
  "layer.relu(%c) // inplace
  "layer.fc"(%a, %c, %d) // %d is the output
  "layer.relu"(%c) // inplace
  return
}

to delegate buffer assignment to the framework using MLIR.  It feels conceptually cleaner if the framework passes buffers to the function, rather than memrefs from which the function should recover the buffers.  This is especially true for various temporary or scratchpad buffers when something like TF captures all the memory, where implementation function essentially gets a pointer to a temporary buffer whether it needs it or not and is allowed to do whatever it wants with it.

Do you see an issue achieving this cleanly with proposal B (with a view operation that can create the current memref given a buffer)? Is there a need to allocate a buffer each time one wants to create a memref? The "ref" in memref already means that it's some sort of a view - it's just that you don't explicitly expose the underlying buffer by default (less verbose?). So, one could just always allocate the multidimensional typed memref's. At exit points, one would typically need to get back the underlying buffer (a type erased void *), which could be done with a 'buf' operation. And at entry points interfacing with something external, you'd need to be able to create the memref from the buffer. But would you ever need an alloc to return a buffer type itself? (except in lower level dialects) 

I'm also assuming that a 'view' operation will allow changing the elemental type - otherwise, I don't see how one could in general do buffer reuse across memref's with different elemental types in the current state. Creating views from buffer type would allow one to cleanly do that, but either proposal is going to have that?

~ Uday

Andy Davis

unread,
Jul 23, 2019, 6:14:05 PM7/23/19
to Uday Bondhugula, MLIR
Yes, in proposal A, views would be able to change the elemental type, so we could reuse previously allocated buffers.

I also imagine being able to efficiently reuse a large allocation, by taking multiple small views created at distinct offsets.

%b = alloc : buffer<11048576, memory_space, alignment>

%v0 = view %b : shape<xf32>
// Load/store on view '%v0' which takes a view of the entire buffer
// ...
// At this point '%v0' has no more uses in the function (and is not live in/out).

%v1 = view %b : shape<32x32xi32>
%v2 = view %b : shape<64xf32, offset = 1024>
// '%v1' and '%v1' can now reuse disjoint regions of '%b' concurrently, using different shapes and element types.

One nice thing about this scheme, is that there are clear points where the buffer and view are defined/created (as opposed with proposal B, you sometimes you would be creating a memref with a new allocation, and sometimes creating the memref with an extracted buffer (for the reuse case)).



--
You received this message because you are subscribed to the Google Groups "MLIR" group.
To unsubscribe from this group and stop receiving emails from it, send an email to mlir+uns...@tensorflow.org.

Alex Zinenko

unread,
Jul 24, 2019, 4:58:51 AM7/24/19
to Andy Davis, Uday Bondhugula, MLIR
With the proposal B, my example would look like

func @compute(%inputs: memref<?xi8>, %outputs: memref<?xi8>, %temp: memref<?xi8>) {
  %input_buffer = "buf"(%inputs) : (memref<?xi8>) -> !buffer<i8>
  %a = "slice"(%input_buffer) {offset = 1024, shape=[32,32], layout="WH", type="f32"} : (!buffer<i8>) -> memref<?x?xf32>
  /*...*/
}

which is a more convoluted way of introducing a more powerful memref_cast that allows for type/layout/offset changes

func @compute(%inputs: memref<?xi8>, %outputs: memref<?xi8>, %temp: memref<?xi8>) {
  %a = "memref_powerful_cast"(%inputs) {take_underlying_buffer = true, offset = 1024, shape=[32,32], layout="WH", type="f32"} : (memref<?xi8>) -> memref<?x?xf32>
}

My main concern here is with the conceptual modeling: memref forces a structure on the function interface, saying the function accepts a multi-dimensional contiguous buffer of a specific size.  However the caller of the function (external to MLIR in my case) allocated just a plain buffer, so it resorts to passing a "type-erased" memref into the function.  Furthermore, the function will not be using memref<?xi8> as a single-dimensional array of bytes, but will rather use parts of it as multi-dimensional arrays with layouts and different types.  Passing in a buffer type makes this behavior more explicit from the signature.



--
-- Alex

Uday Bondhugula

unread,
Jul 25, 2019, 12:40:05 AM7/25/19
to MLIR


On Wednesday, July 24, 2019 at 2:28:51 PM UTC+5:30, Alex Zinenko wrote:
With the proposal B, my example would look like

func @compute(%inputs: memref<?xi8>, %outputs: memref<?xi8>, %temp: memref<?xi8>) {
  %input_buffer = "buf"(%inputs) : (memref<?xi8>) -> !buffer<i8>
  %a = "slice"(%input_buffer) {offset = 1024, shape=[32,32], layout="WH", type="f32"} : (!buffer<i8>) -> memref<?x?xf32>
  /*...*/
}


No, with both proposals, your example would still take 'buffer' type's as inputs if you prefer that. With proposal B, you would then create the memref from the buffer where you need it. The only difference between the proposals is what alloc returns - do you want to create a buffer type each time you want to create a memref or just have the alloc return the memref you want?

~ Uday
To unsubscribe from this group and stop receiving emails from it, send an email to ml...@tensorflow.org.

--
You received this message because you are subscribed to the Google Groups "MLIR" group.
To unsubscribe from this group and stop receiving emails from it, send an email to ml...@tensorflow.org.


--
-- Alex

Mehdi AMINI

unread,
Jul 25, 2019, 12:47:50 AM7/25/19
to Uday Bondhugula, MLIR
On Wed, Jul 24, 2019 at 9:40 PM 'Uday Bondhugula' via MLIR <ml...@tensorflow.org> wrote:


On Wednesday, July 24, 2019 at 2:28:51 PM UTC+5:30, Alex Zinenko wrote:
With the proposal B, my example would look like

func @compute(%inputs: memref<?xi8>, %outputs: memref<?xi8>, %temp: memref<?xi8>) {
  %input_buffer = "buf"(%inputs) : (memref<?xi8>) -> !buffer<i8>
  %a = "slice"(%input_buffer) {offset = 1024, shape=[32,32], layout="WH", type="f32"} : (!buffer<i8>) -> memref<?x?xf32>
  /*...*/
}


No, with both proposals, your example would still take 'buffer' type's as inputs if you prefer that. With proposal B, you would then create the memref from the buffer where you need it. The only difference between the proposals is what alloc returns - do you want to create a buffer type each time you want to create a memref or just have the alloc return the memref you want?

I you have a buffer type anyway, and you also have the ability to create a memref from a buffer, then why wouldn't alloc return a buffer?

-- 
Mehdi
 
To unsubscribe from this group and stop receiving emails from it, send an email to mlir+uns...@tensorflow.org.
To view this discussion on the web visit https://groups.google.com/a/tensorflow.org/d/msgid/mlir/7b634b6b-dddc-4b47-983b-ad928a2a7d80%40tensorflow.org.

Uday Bondhugula

unread,
Jul 25, 2019, 1:54:14 AM7/25/19
to MLIR

>If you have a buffer type anyway, and you also

>have the ability to create a memref from a >buffer, then why wouldn't alloc return a
>buffer?

Because all you would do with the buffer type in nearly all cases is to create a memref from it immediately (except when interacting with external interfaces). And for the latter, you can just extract the buffer type or vice versa. Is there another place where the buffer type is used as an operand?

- Uday

Alex Zinenko

unread,
Jul 25, 2019, 4:16:23 AM7/25/19
to MLIR
It doesn't bother me much to allocate a buffer first.  It's similar to creating a constant before using it in an operation.

On the other hand, having alloc return a memref creates two implicit kinds of memrefs: "allocated" memrefs that you may need to deallocate and "alias" memrefs that you don't.  You can actually call dealloc on any of the aliases, freeing potentially more memory than the memref type says.

What's your use case for extracting the buffer out of the memref, other than slicing the buffer differently?  I don't have one so I may not be needing that operation at all.

--
You received this message because you are subscribed to the Google Groups "MLIR" group.
To unsubscribe from this group and stop receiving emails from it, send an email to mlir+uns...@tensorflow.org.


--
-- Alex

Uday Bondhugula

unread,
Jul 25, 2019, 6:06:12 AM7/25/19
to MLIR


On Thursday, July 25, 2019 at 1:46:23 PM UTC+5:30, Alex Zinenko wrote:
 
What's your use case for extracting the buffer out of the memref, other than slicing the buffer differently?  I don't have one so I may not be needing that operation at all.

This is essential if you want to let's say call an external library routine (say from MKL) in the middle of a sequence of operations. One just extracts the buffer type, which is what gets passed to the foreign interface. This is one of the main use cases for having the 'buf' operation if you go with proposal B.

On this note, I wanted to ask what the scheme for constructing 'views' is with proposal A: are views constructed only from a buffer type or could you also construct in turn from another view? If it's the former, note that if you use a complex layout (that is let's say not easily analyzable), you will be unable to figure out the connection between two views. OTOH, if views are relative, the complex layout is part of the alloc'ed memref and gets factored out, and one can still analyze the connection/dependence between all the view memref's and between the alloc'ed memref and the views, for fusion, buffer reuse, etc.  
On the other hand, having alloc return a memref creates two implicit kinds of memrefs: "allocated" memrefs that you may need to deallocate and "alias" memrefs that you don't.  You can actually call dealloc on any of the aliases, freeing potentially more memory than the memref tyt pe says.

The problem of calling dealloc on a memref that is the result of 'view' is the same as that of calling dealloc multiple times on an alloc'ed buffer type. If these things are being generated as part of a lowering, I don't see an issue with it with either proposal: there'll be a matching dealloc for an alloc, but not for the views.

~ Uday



 

On Thu, Jul 25, 2019 at 7:54 AM 'Uday Bondhugula' via MLIR <ml...@tensorflow.org> wrote:

>If you have a buffer type anyway, and you also
>have the ability to create a memref from a >buffer, then why wouldn't alloc return a
>buffer?

Because all you would do with the buffer type in nearly all cases is to create a memref from it immediately (except when interacting with external interfaces). And for the latter, you can just extract the buffer type or vice versa. Is there another place where the buffer type is used as an operand?

- Uday

--
You received this message because you are subscribed to the Google Groups "MLIR" group.
To unsubscribe from this group and stop receiving emails from it, send an email to ml...@tensorflow.org.


--
-- Alex

Andy Davis

unread,
Jul 25, 2019, 12:54:13 PM7/25/19
to Uday Bondhugula, MLIR
With proposal A, I expect that views can be constructed from other views, and we should be able to factor out complex layouts (as you suggest). Here is an example (not proposing this as the syntax to use):

%b = alloc() : buffer<size, alignment, memory_space>

// Create a complex layout view:
%v0 = view %b : <1024x1024xf32, tiled_layout<...>>

// Create a view derived from '%v0'
%v1 = view %v0 : <64x64xf32, affine_map1>

// Create another view derived from '%v0'
%v2 = view %v0 : <10x10x10xf32, affine_map2>

// Load a value from view '%v1"
%x = affine.load %v1[%i0]

// Store a value to view '%v2"
store %y, %v2[%i0]

Now, since '%v1' and '%v2' have the same parent '%v0', and we have affine maps for both %v1 and  %v2 that map back to %v0's coordinate space (i.e. 1024x1024xf32), then we should be able to analyze the dependence between loads and stores on %v1 and %v2.







To unsubscribe from this group and stop receiving emails from it, send an email to mlir+uns...@tensorflow.org.
To view this discussion on the web visit https://groups.google.com/a/tensorflow.org/d/msgid/mlir/4ee89613-4b28-4dd1-9c32-56e73c5ed722%40tensorflow.org.
Reply all
Reply to author
Forward
0 new messages