This is an archive of the discontinued LLVM Phabricator instance.

[mlir][python] allow for detaching operations from a block
ClosedPublic

Authored by ftynse on Oct 28 2021, 3:18 AM.

Details

Summary

Provide support for removing an operation from the block that contains it and
moving it back to detached state. This allows for the operation to be moved to
a different block, a common IR manipulation for, e.g., module merging.

Also fix a potential one-past-end iterator dereference in Operation::moveAfter
discovered in the process.

Diff Detail

Event Timeline

ftynse created this revision.Oct 28 2021, 3:18 AM
ftynse requested review of this revision.Oct 28 2021, 3:18 AM

Nice

mlir/include/mlir-c/IR.h
350

Could the same comment "style" as in line 342 be used here? (I think if you s/Creates/Removes/ and s/inserted/destroyed/ it works here and then it is formulaic how one reads these :-))

mlir/lib/Bindings/Python/IRCore.cpp
2214

Why not removeFromParent to keep it consistent with python and C++ method on Operation?

mlir/lib/Bindings/Python/IRModule.h
435

operation

This LGTM, but we may not want to encourage this for the use-case here necessarily: seems a bit low-level.
For example, if it is was native, the code sample in the test should use instead op->moveBefore(Operation *): it seems slightly less low-level, and also allows for inserting anywhere in the block (in particular when all you have is another operation, it also works with block->end())

Another one that is very suitable to "merging" is the splice API exposed by Blocks. It seems more efficient (and provides again a higher-level / more encapsulated API) to just splice a block into another block than manually iterating and moving operations.

ftynse updated this revision to Diff 383352.Oct 29 2021, 7:54 AM
ftynse marked 3 inline comments as done.

Address review.

This LGTM, but we may not want to encourage this for the use-case here necessarily: seems a bit low-level.
For example, if it is was native, the code sample in the test should use instead op->moveBefore(Operation *): it seems slightly less low-level, and also allows for inserting anywhere in the block (in particular when all you have is another operation, it also works with block->end())

IMO, moveBefore/moveAfter is at exactly same level of abstraction as appending to the block. I added those for convenience, but append is still necessary to be able to add operations to an empty block. There is no equivalent of block->end() in C, let alone Python, because we are not modeling iterators as objects. In Python, it would be quite confusing to have C++-style iterators because Python has its own, different notion of iterators.

Another one that is very suitable to "merging" is the splice API exposed by Blocks. It seems more efficient (and provides again a higher-level / more encapsulated API) to just splice a block into another block than manually iterating and moving operations.

My personal, possibility controversial, opinion is that splice is a horrible API that it is also _lower_ level than append/moveAfter/moveBefore. It requires the caller to know that Blocks are linked lists, to access the underlying list object of the block (Block.getOperations(), no equivalent in C or Python, one is probably undesirable in Python), to get the list iterator out of operation (no equivalent in C or Python, again probably undesirable in Python), and has several undocumented overloads. Its semantics is to move linked list nodes pointed to by iterators from one list to another, which is at a lower level of abstraction than moving one operation before/after another that only reasons about operations, not their storage. I've never been able to call it correctly from the first attempt, and I would really like to avoid propagating it to Python. It should be possible to design a more pythonic approach of operating on slices (in Python sense) of consecutive operations, but it requires careful consideration of what we want to expose at the C level to support that, which goes beyond what I want here.

Merging modules specifically requires to iterate over individual operations anyway for symbol name conflict resolution purposes.

mlir/lib/Bindings/Python/IRCore.cpp
2214

Because bindings and their comments use the term "detached operation" to indicate operations that do not belong to a block (and are therefore owned by the caller).

ftynse edited the summary of this revision. (Show Details)Oct 29 2021, 8:12 AM
ftynse edited the summary of this revision. (Show Details)
jpienaar added inline comments.Oct 29 2021, 8:13 AM
mlir/lib/Bindings/Python/IRCore.cpp
2214

Isnt Python a binding here too?

IMO, moveBefore/moveAfter is at exactly same level of abstraction as appending to the block.

I think you're missing something that I see as important: removeFromParent. That makes a move a higher abstraction than "detach-reattach".
(It's really the fact that the user must detach manually that triggers me here)

I added those for convenience, but append is still necessary to be able to add operations to an empty block.

Sure, but you can have append be more like op->moveToBlockEnd(block) which provides a superset of the same feature by also handling the detach.

There is no equivalent of block->end() in C, let alone Python, because we are not modeling iterators as objects. In Python, it would be quite confusing to have C++-style iterators because Python has its own, different notion of iterators.

Yes, I wasn't suggesting adding iterators, I rather read something like moveToBlockEnd anyway.

Another one that is very suitable to "merging" is the splice API exposed by Blocks. It seems more efficient (and provides again a higher-level / more encapsulated API) to just splice a block into another block than manually iterating and moving operations.

My personal, possibility controversial, opinion is that splice is a horrible API that it is also _lower_ level than append/moveAfter/moveBefore.

I don't quite get your point about lower-level here? Splicing two block is conceptually a loop that calls these other APIs, which makes it a strictly higher level construct to me.

It requires the caller to know that Blocks are linked lists

How so?
The caller of: block->getOperations().splice(block->end(), block->getOperations()); does not need to know anything about linked list. We could change the block storage to be a vector and the API would stand.

to access the underlying list object of the block (Block.getOperations(), no equivalent in C or Python, one is probably undesirable in Python), to get the list iterator out of operation (no equivalent in C or Python, again probably undesirable in Python), and has several undocumented overloads.

I think there are two things that gets mixed up here: the concept of splice and the way it is exposed in C++ on the Block class.

I am more interested in the concept than the C++ API which could benefit a forwarding API on block.

Its semantics is to move linked list nodes pointed to by iterators from one list to another,

No: this is the implementation, not the semantics. The semantics of a splice should be "Transfers elements container A, inserting them at position B."

The fact is that because it is a higher level API than moving individual element, it allows for a faster implementation leveraging internal details of the containers (which is what you describe).

which is at a lower level of abstraction than moving one operation before/after another that only reasons about operations, not their storage

Again: sorry but I don't understand you point about lower/higher level of abstraction.

It should be possible to design a more pythonic approach of operating on slices (in Python sense) of consecutive operations, but it requires careful consideration of what we want to expose at the C level to support that, which goes beyond what I want here.

I understand what you want, but I object that this is not what is desirable for a Python abstraction, so I'd really like a more careful consideration of the Pythonic API based on higher-level constructs (move, splice/concat, etc.) than "detach" and "attach".

Merging modules specifically requires to iterate over individual operations anyway for symbol name conflict resolution purposes.

Right, but an ideal merging module API for a user would be: module.mergeInto(otherModule) anyway.

I've been wondering if we use different definitions of higher-level / low-level, may be worth clarifying otherwise we'll talk past each others. So I went back to basic and check that I'm looking at it roughly from the general intro of the wikipedia page here: https://en.wikipedia.org/wiki/High-_and_low-level

High-level describe those operations that are more abstract in nature; wherein the overall goals and systemic features are typically more concerned with the wider, macro system as a whole.
Low-level describes more specific individual components of a systematic operation, focusing on the details of rudimentary micro functions rather than macro, complex processes. Low-level classification is typically more concerned with individual components within the system and how they operate.

There are two axis of "low-level" vs "high-level" that triggered me here:

  1. Having use remove_from_parent and then append instead of a single API call.

Here you expose to python the Operation::remove() method, while I don't have an objection to expose this method, we should note that it has exactly 2 uses in tree right now. It is extremely unusual that we have to just manually "detach" an operation like that. The fact that a user in Python would have to do this as a prerequisite to accomplish something in MLIR is an important red flag to me. The only use of this API should be to detach without knowing where to re-attach (for example extracting a nested module to make it a standalone entity).

  1. Having to loop over a block to move individually each operation instead of operating on the range on a single API call.

That's the splice discussion, and maybe it got derailed because C++ is a language that design API with leaky low-level implementation choice (not necessarily for bad reason, but still).
I suspect the discussion got derailed because you anchored to the "leaky" aspect of "splice" instead of the abstract way.

Let's forget the name "splice" or the C++ API on ilist and just look at the "concepts" we expose to our users: "merge blocks"

Also, my intuition would be to set a different bar between the level of APIs exposed to the C API users compared to Python users: adding a high-level API to the C API surface likely needs a good justification, but there is rarely a reason to not wrap a sequence of C API calls behind a higher-level API exposed by the Python bindings to its users. (made up example: we may or may not want to add mlirBlockMergeIntoOtherBlock(Block *src, Block *dst) but it does not mean we can't expose a block.mergeInto(destBlock) Python-level API on the python Block class).

mlir/lib/Bindings/Python/IRCore.cpp
2214

Because bindings and their comments use the term "detached operation" t

So why remove_from_parent instead of detach_from_parent here?

ftynse updated this revision to Diff 383585.Oct 30 2021, 6:49 AM
ftynse marked 2 inline comments as done.

Address some review.

As a matter of fact, we seem to agree on most points. Having a possibility to move+append in one call similarly to moveBefore/After makes sense, I updated the code.

By higher-level API in this particular case, I understand operating on the concept of a block without having to know how it is organized. The two-argument version of the splice API you referenced is okay-ish in that sense, the others (https://github.com/llvm/llvm-project/blob/main/llvm/include/llvm/ADT/ilist.h#L333-L346) less so. The first issue, relevant for all versions of splice, is having to operate on block->getOperations() instead of block directly. The second, relevant for the overloads with 3+ arguments, is the need to pass the operations ilist of the block that currently contains the operations. This is only necessary because we operate on ilist, whose element iterators don't have a reference back to the container presumably to reduce memory consumption. An Operation actually has such a reference. The truly high-level API would let us indeed write block->mergeInto(other) instead of other->getOperations().splice(other.end(), block->getOperations()) and something like block->mergeIntoBefore(operation, other->front()) instead of other->getOperations().splice(operation.getIterator(), other->getOperations(), other->front()). This adds an abstraction on top of what is provided by ilist, otherwise the caller is really operating on ilist of operations, not on a block.

I agree that we need some sort of bulk operation movement API in Python, but, like I mentioned above, it needs careful consideration and is much better off as a patch and discussion. One concern there is the amount of non-trivial code that we put into bindings, and the runtime+memory complexity it might entail, as opposed to writing it in C++ or in Python from primitives.

mehdi_amini accepted this revision.Oct 30 2021, 12:14 PM
mehdi_amini added inline comments.
mlir/lib/Bindings/Python/IRCore.cpp
2425

Nit: spurious braces?

This revision is now accepted and ready to land.Oct 30 2021, 12:14 PM
ftynse marked an inline comment as done.Oct 31 2021, 1:19 AM
ftynse updated this revision to Diff 383641.Oct 31 2021, 1:37 AM

Address remaining review.

This revision was landed with ongoing or failed builds.Oct 31 2021, 1:42 AM
This revision was automatically updated to reflect the committed changes.