-
Notifications
You must be signed in to change notification settings - Fork 6
Interfaces and their implementation
pliron
is an extensible IR framework. This means that the framework does
not restrict the kind of Operations
(Op
s / Opcodes), Attribute
s or Type
s that can be
represented in the IR. A user is free to define new Op
s, Attribute
s or Type
s and
assign semantics to them.
For sanity to prevail, to not have to specify analysis and transformation details for each of these individually, common behaviour can be grouped into an Interface. MLIR uses Interfaces and Traits to achieve this. There is some difference between the two.
In pliron
, we wrap around Rust traits to provide Interfaces. In the following discussion, we use Op
and Op
interfaces to explain the workings of interfaces, but it equally applies to Attribute
s and Type
s.
As an example, here's an Interface for Op
s that always have exactly one region:
The OneRegionInterface:
/// [Op]s that have exactly one region.
#[op_interface]
pub trait OneRegionInterface {
/// Get the single region that this [Op] has.
fn get_region(&self, ctx: &Context) -> Ptr<Region> {
self.get_operation().deref(ctx).get_region(0)
}
/// Checks that the operation has exactly one region.
fn verify(op: &dyn Op, ctx: &Context) -> Result<()>
where
Self: Sized,
{
let self_op = op.get_operation().deref(ctx);
if self_op.regions.len() != 1 {
return verify_err!(self_op.loc(), OneRegionVerifyErr(op.get_opid().to_string()));
}
Ok(())
}
}
Here, the #[op_interface]
annotates the Rust trait as an Op
Interface. Complementary to it, we also have an #[op_interface_impl]
macro that helps an Op
implement an interface.
#[op_interface]
pub trait MyOpInterface {
fn gubbi(&self);
}
#[op_interface_impl]
impl MyOpInterface for MyOp {
fn gubbi(&self) { println!("gubbi"); }
}
These two macros enable the following additional functionality:
- Casting from a
dyn Op
to adyn Interface
object, thus enabling dynamic checking and use of interfaces. - Automatic verification of Interface properties. For example, a verifier for the
OneRegionInterface
above must ensure that everyOp
that implements the interface must have exactly one region. These macros ensure that such an Interface verifier is automatically called when the verifier for theOp
is called. Also, if there are super-interfaces specified, the verifiers for those are called prior to calling the verifier for the Interface itself.
We now go on to discuss how these two functionality work, in more detail.
Interfaces convey that an Op
implementing it provides certain functionality or holds certain properties. When working on the IR, it becomes essential to, given a dyn Op
, determine whether the Op
implements an interface, and if it does, to be able to call the methods defined by that interface. i.e., we want a function
pub fn op_cast<T: ?Sized + Op>(op: &dyn Op) -> Option<&T>
that can be called for any interface (Rust trait) object dyn T
.
To enable this, pliron
provides infrastructure for casting from dyn Any
to dyn Trait
, for traits that the type contained by the Any
object implements. This utility works by maintaining a static table TRAIT_CASTERS_MAP
that maps TypeId
s of concrete types to each interface (Rust trait) that it implements, and querying the table when performing the actual cast. The #[op_interface_impl]
macro is responsible for making an entry in this static table so that future queries can perform the actual cast. We use the downcast_rs crate to help upcast dyn Op
objects to an Any
object so that we can use the above trait_cast
utility.
An Op
implementing an interface must satisfy certain constraints defined by
the interface itself. While an Op
's verifier can manually call the verifier
for each interface that it implements, it is certainly more convenient and
safer to have that be automatic.
MLIR achieves automatic verification of an Op
's interfaces and traits via
variadic template magic.
template <typename ConcreteType, template <typename T> class... Traits>
class Op : public OpState, public Traits<ConcreteType>... {
...
/// Implementation of `VerifyInvariantsFn` OperationName hook.
static LogicalResult verifyInvariants(Operation *op) {
static_assert(hasNoDataMembers(),
"Op class shouldn't define new data members");
return failure(
failed(op_definition_impl::verifyTraits<Traits<ConcreteType>...>(op)) ||
failed(cast<ConcreteType>(op).verify()));
}
...
}
Every trait or interface that an Op
implements is listed as a type parameter
during the class definition of that Op
. A behaviour different from the interface's
default definition will be overridden in the class definition. A minor downside to
this approach is that, implementing a new interface for an Op
requires changes to
the Op
's definition. This is especially a problem when the interface is defined
in client code (i.e., not part of the library in which the Op
is defined).
To get automatic verification of interfaces for pliron
, we need to generate, at compile time,
a function that has calls to every interface implemented by an Op
. A less desirable, but
simpler solution
was to have the definition of an Op
explicitly list the interfaces that it
implements. While this has the same downside as MLIR's solution, there is also
the possibility that a user implements an interface but forgets to add it
to this list.
An additional requirement is that we want the verifiers of super-interfaces to run before the sub-interfaces. This allows every verifier implementation to conveniently assume that it's super-interface is already verified.
To get this working, we declare a static OP_INTERFACE_VERIFIERS_MAP
that
maps Op
s to an ordered (as per the super-interface dependence) list of
verifiers. So when an Op
needs to be verified, each verifier for that Op
in this list is called, in order. This is again made convenient by the
#[op_interface_impl]
macro that helps register verifiers of Op
interfaces
into this map. The map is Lazy
and hence is built on the first access, which involves sorting a list of dependence pairs,
to arrive at the final order.
In both the topics above, we maintain tables TRAIT_CASTERS_MAP
and OP_INTERFACE_VERIFIERS_MAP
that need to be updated at each application of #[op_interface_impl]
, but all at compile time.
The linkme crate allows us to declare
a static array distributed_slice
of const expressions, whose contents can be
any static value of that type, marked by a provided macro. Every marked element
is finally linked into one distributed_slice
.
Our solution has the added advantage of not limiting the user from implementing
his own interface for an externally defined Op
, or for implementing an externally
defined interface for his Op
. The only restriction is that both the Op
and the
interface cannot be externally defined, and this comes directly from Rust's
restriction on either a trait or a type that implements the trait to be in
the current crate.