Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

wip: hir refactoring #332

Draft
wants to merge 49 commits into
base: next
Choose a base branch
from
Draft

wip: hir refactoring #332

wants to merge 49 commits into from

Conversation

bitwalker
Copy link
Contributor

TBD

@bitwalker bitwalker added frontend blocker This issue is one of our top priorities labels Sep 23, 2024
@bitwalker bitwalker self-assigned this Sep 23, 2024

Values have _uses_ corresponding to operands or successor arguments (special operands which are used
to satisfy successor block parameters). As a result, values also have _users_, corresponding to the
specific operation and operand forming a _use.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
specific operation and operand forming a _use.
specific operation and operand forming a _use_.

This commit moves away from `derive!` for operation definitions, to the
new `#[operation]` macro, and implements a number of
changes/improvements to the `Operation` type and its related APIs.

In particular with this commit, the builder infrastructure for
operations was finalized and started to be tested with "real" ops.
Validation was further improved by typing operands with type constraints
that are validated when an op is verified. The handling of successors,
operands, and results was improved and unified around a shared
abstraction.

The pattern and pattern rewriter infrastructure was sketched out as
well, and I anticipate landing those next, along with tests for all of
it.

Once patterns are done, the next two items of note are the data analysis
framework, and the conversion framework. With those done, we're ready to
move to the new IR.
}
}

pub fn match_and_rewrite<A, F, S>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks astonishing! Looking forward to RewritePattern impl example.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've gotten virtually of the rewrite infra built now, but the last kink I'm working out is how to approach the way the Listener classes are used in MLIR. In effect, the way it is used in MLIR relies on mutable aliases, which is obviously a no go in Rust, and because of that (and I suspect somewhat as an artifact of the design they chose), the class hierarchy and interactions between builders/rewriters/listeners is very confusing.

I've untangled the nest a bit in terms of how it actually works in MLIR, but its a non-starter in Rust, so a different design is required, but one that ideally achieves similar goals. The key thing listeners enable in MLIR, is the ability for say, the pattern rewrite driver, to be notified whenever a rewrite modifies the IR in some way, and as a result, modify its own state to take those modifications into account. There are other things as well, such as listeners which perform expensive debugging checks without those checks needing to be implemented by each builder/rewriter, but the main thing I care about is the rewrite driver.

There are a couple of approaches that I think should work, but need to test them out first. In any case, that's the main reason things have been a bit slow in the last week.

}
}

derive! {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The verification subsystem is fascinating! Great job! There is a lot of macros and type-level magic going on here. I must admit that after reading all the extensive documentation, I still don't fully understand how it works. I'm fine with this level of complexity, but looking at the derive! macro usage, I'm not sure that macro's benefits outweigh the complexity. I mean this derive! call expands to the following code:

#[doc = r" Op's regions have no arguments"]
pub trait NoRegionArguments {}
impl<T: crate::Op + NoRegionArguments> crate::Verify<dyn NoRegionArguments> for T {
    #[inline]
    fn verify(&self, context: &crate::Context) -> Result<(), crate::Report> {
        <crate::Operation as crate::Verify<dyn NoRegionArguments>>::verify(
            self.as_operation(),
            context,
        )
    }
}
impl crate::Verify<dyn NoRegionArguments> for crate::Operation {
    fn should_verify(&self, _context: &crate::Context) -> bool {
        self.implements::<dyn NoRegionArguments>()
    }

    fn verify(&self, context: &crate::Context) -> Result<(), crate::Report> {
        #[inline]
        fn no_region_arguments(op: &Operation, context: &Context) -> Result<(), Report> {
            for region in op.regions().iter() {
                if region.entry().has_arguments() {
                    return Err(context
                        .session
                        .diagnostics
                        .diagnostic(Severity::Error)
                        .with_message("invalid operation")
                        .with_primary_label(
                            op.span(),
                            "this operation does not permit regions with arguments, but one was \
                             found",
                        )
                        .into_report());
                }
            }
            Ok(())
        }
        no_region_arguments(self, context)?;
        Ok(())
    }
}

Which is not that larger than the macro call itself. If part of it can be generated by an attribute macros it's not that hard to write the rest of it by hand (Verify::verify only?). I mean, the benefits of derive! macro might not outweigh the drawbacks (remembering the syntax, writing code inside a macro call, etc.).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I mostly didn't want any of the type-level magic (and boilerplate) to be hand-written as things were evolving - modifying the macro and updating all of the traits at once was far easier, and is really the only reason why the derive! macro still exists. However, as you've pointed out, it would be best to define an attribute macro for this as well, just like #[operation], but I've punted on that since the derive! macro gets the job done for now, and not many new op traits will be getting defined in the near term, but its certainly on the TODO list.

hir2/src/lib.rs Outdated
Comment on lines 1 to 19
#![feature(allocator_api)]
#![feature(alloc_layout_extra)]
#![feature(coerce_unsized)]
#![feature(unsize)]
#![feature(ptr_metadata)]
#![feature(layout_for_ptr)]
#![feature(slice_ptr_get)]
#![feature(specialization)]
#![feature(rustc_attrs)]
#![feature(debug_closure_helpers)]
#![feature(trait_alias)]
#![feature(is_none_or)]
#![feature(try_trait_v2)]
#![feature(try_trait_v2_residual)]
#![feature(tuple_trait)]
#![feature(fn_traits)]
#![feature(unboxed_closures)]
#![feature(const_type_id)]
#![feature(exact_size_is_empty)]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There goes my hope to someday ditch the nightly. :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I've really blown a hole in that plan 😂. Once you need some of the nigh perma-unstable features like ptr_metadata, fn_traits, or specialization/min_specialization, it's basically game over for using stable, unless you have alternative options that allow you to sidestep the use of those features.

That said, a fair number of these are pretty close to stabilization. The ones that are problematic are:

  • specialization and rustc_attrs are needed because min_specialization isn't quite sufficient for the type-level magic I'm doing for automatically deriving verifiers for operations. If improvements are made to min_specialization, we can at least switch over to that. Longer term, we'd have to come up with an alternative approach to verification if we wanted to switch to stable though.
  • ptr_metadata has an unclear stabilization path, and while it is basically table stakes for implementing custom smart pointer types, stabilizing it will require Rust to commit to some implementation details they are still unsure they want to commit to.
  • allocator_api and alloc_layout_extra should've been stabilized ages ago IMO, but they are still being fiddled with. However, we aren't really leaning on these very much, and may actually be able to remove them.
  • coerce_unsized and unsize are unlikely to be stabilized any time soon, as they've been a source of unsoundness in the language (not due to the features, but due to the traits themselves, which in stable are automatically derived). Unfortunately, they are also table stakes in implementing smart pointer types with ergonomics like the ones in libstd/liballoc, so its hard to avoid them.
  • try_trait_v2 and try_trait_v2_residual are purely ergonomic, so we could remove them in exchange for more verbose code in a couple places, not a big deal
  • fn_traits, unboxed_closures, and tuple_trait are all intertwined with the ability to implement implementations of Fn/FnMut/FnOnce by hand, which I'm currently using to make the ergonomics of op builders as pleasant as possible. We might be able to tackle ergonomics in another way though, and as a result, no longer require these three features.

So, long story short, most of these provide some really significant ergonomics benefits - but if we really wanted to ditch nightly for some reason, we could sacrifice some of those benefits and likely escape the need for most of these features. Verification is the main one that is not so easily abandoned, as the alternative is a lot of verbose and fragile boilerplate for verifying each individual operation - that's not to say it can't be done, just that I'm not sure the tradeoff is worth it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can live with it. The verification is totally worth it!

/// else_region: RegionRef,
/// }
#[proc_macro_attribute]
pub fn operation(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really dig the operation proc macro attribute. I expanded it in a few ops. So much pretty complex boilerplate is generated. Awesome!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if you say the derive! macro before I implemented the #[operation] attribute macro, but...it was madness 😅.

Now it is much cleaner (and more maintainable!), but it does a lot under the covers, so the main tradeoff is lack of visibility into all of that. That said, I tried to keep the macro focused on generating boilerplate for things that feel pretty intuitive, e.g. deriving traits, op construction and verification, correspondence between fields of the struct definition and the methods that get generated. So hopefully we never need to actually look at the generated code except in rare circumstances.

}

#[inline(always)]
unsafe fn downcast_ref_unchecked<T: Op>(&self, ptr: *const ()) -> &T {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a fan of op traits implementation! TIL about DynMetadata. So cool!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm definitely happy with how that has turned out so far, it enables a number of really neat tricks! You might also be interested in a bit of trivia about pointer metadata in Rust:

  • DynMetadata<dyn Trait> is the type of metadata associated with a trait object for the trait Trait, and is basically just a newtype for a pointer to the vtable of the trait object. While you could specify DynMetadata<T> for some T that isn't a trait, that doesn't actually mean anything, and is technically invalid AFAIK.
  • usize is the type of metadata associated with "unsized" types (except trait objects), and corresponds to the unit size for the pointee type. For example, &[T] is an unsized reference, and its pointer metadata is the number of T in the slice, i.e. a typical "fat" pointer; but the semantics of the metadata value depend on the type, so you can have custom types that use usize metadata to indicate the allocation size of the pointee, or something else along those lines.
  • () is the type of metadata associated with all "sized" types, i.e. pointers to those types don't actually have metadata

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! I'm definitely going to dig into it.

Copy link
Contributor

@greenhat greenhat left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks super cool! I'm looking forward to using it!

@bitwalker
Copy link
Contributor Author

bitwalker commented Oct 16, 2024

Thanks for reviewing things, and for the kind comments! As mentioned, things are quite close to finished now, the last thing that needs some fiddling is finding a Rust-friendly design for handling the interaction between patterns, builders/rewriters, and the rewrite driver (i.e. so that the rewrite driver is made aware of changes to the IR made by a rewrite pattern via the builder/rewriter).

Once that's out of the way, there is a short list of tasks to switch over to this new IR:

  • Port dominator tree and loop tree analyses on top of the new IR
  • Port data flow analysis framework on top of the new IR
  • Port liveness analysis and spills transformation using the data flow analysis framework
  • Wire up lowering from the new IR to MASM
  • Wire up lowering from Wasm to the new IR

This commit adds the following useful tools (used in later commits):

* The `Graph` trait for abstracting over flow graphs
* The `GraphVisitor` and `DfsVisitor` primitives for performing and
  hooking into depth-first traversals of a `Graph` in either pre-order
  or post-order, or both.
* Type aliases for pre- and post-order dfs visitors for blocks
* The `GraphDiff` trait and `CfgDiff` data structure for representing
  pending insertions/deletions in a flow graph. This enables incremental
  updating of flow graph dependent analyses such as the dominator tree.
This introduces the incrementally updateable dominator and
post-dominator analyses from LLVM, based on the Semi-NCA algorithm. This
enables us to keep the (post-)dominator tree analysis up to date during
rewriting of the IR, without needing to recompute it from scratch each
time.

This is an initial port of the original C++ code, modified to be more
Rust-like, while still remaining quite close to the original. This could
be easily cleaned up/rewritten in the future to be more idiomatic Rust,
but for now it should suffice.
This commit implements the generic loop analysis from LLVM, in Rust. It
is a more sophisticated analysis than the one in HIR1, and provides us
with some additional useful information that will come in handy during
certain transformations to MASM.

See the "Loop Terminlogy" document on llvm.org for details on how LLVM
reasons about loops, which corresponds to the analysis above.
Comment on lines +275 to +322
fn matches(&self, op: OperationRef) -> Result<bool, Report> {
use crate::matchers::{self, match_chain, match_op, MatchWith, Matcher};

let binder = MatchWith(|op: &UnsafeIntrusiveEntityRef<Shl>| {
log::trace!(
"found matching 'hir.shl' operation, checking if `shift` operand is foldable"
);
let op = op.borrow();
let shift = op.shift().as_operand_ref();
let matched = matchers::foldable_operand_of::<Immediate>().matches(&shift);
matched.and_then(|imm| {
log::trace!("`shift` operand is an immediate: {imm}");
let imm = imm.as_u64();
if imm.is_none() {
log::trace!("`shift` operand is not a valid u64 value");
}
if imm.is_some_and(|imm| imm == 1) {
Some(())
} else {
None
}
})
});
log::trace!("attempting to match '{}'", self.name());
let matched = match_chain(match_op::<Shl>(), binder).matches(&op.borrow()).is_some();
log::trace!("'{}' matched: {matched}", self.name());
Ok(matched)
}

fn rewrite(&self, op: OperationRef, rewriter: &mut dyn Rewriter) {
log::trace!("found match, rewriting '{}'", op.borrow().name());
let (span, lhs) = {
let shl = op.borrow();
let shl = shl.downcast_ref::<Shl>().unwrap();
let span = shl.span();
let lhs = shl.lhs().as_value_ref();
(span, lhs)
};
let constant_builder = rewriter.create::<Constant, _>(span);
let constant: UnsafeIntrusiveEntityRef<Constant> =
constant_builder(Immediate::U32(2)).unwrap();
let shift = constant.borrow().result().as_value_ref();
let mul_builder = rewriter.create::<Mul, _>(span);
let mul = mul_builder(lhs, shift, Overflow::Wrapping).unwrap();
let mul = mul.borrow().as_operation().as_operation_ref();
log::trace!("replacing shl with mul");
rewriter.replace_op(op, mul);
}
Copy link
Contributor

@greenhat greenhat Oct 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Ergonomics looks great! The code gives strong MLIR vibes! It'd take some time to get used to the types, techniques, etc. but it worth it. I'm looking forward to starting writing some rewrites.

Further refactoring is needed to symbol management, but for now, this
cleans up the existing implementation and organizes it a bit better; as
well as provides greater re-use of common symbol-related functionality
and improved symbol table management.
This framework is ported from MLIR, but redesigned somewhat in a more
Rust-friendly fashion. There are still some pending fixes/improvements
here to enable some interesting use cases this implementation doesn't
support quite yet, but that can wait for now. The high notes:

* Load multiple analyses into the DataFlowSolver, and run it on an
  operation until the set of analyses reach a fixpoint. Analyses, in a
  sense, run simultaneously, though that is not literally the case.
  Instead, a change subscription/propagation mechanism is implemented so
  that an analysis can query for some interesting state it wishes to
  know (but does not itself compute), and implicitly this subscribes it
  to future changes to that state, at the queried program point. When
  that state changes, the analysis in question is re-run with the new
  state at that program point, and is able to derive new facts and
  information, which might then trigger further re-analysis by other
  analyses. This is guaranteed to reach fixpoint unless someone writes
  an incorrect join/meet implementation (i.e. it does not actually
  follow the requirements of a join/meet semilattice).
* Analyses are run once on the entire operation the solver is applied
  to, however they will be re-run (as needed), whenever some new facts
  are discovered about a program point they have derived facts about
  from the changed state, but _only on that program point_. This ensures
  minimal re-computation of analysis results, while still maximizing the
  benefit of the combined analyses (e.g. dead code elimination +
  constant propagation == sparse conditional constant propagation)
* Analyses have a fair amount of ways in which they can hook into the
  change management system, as well as customize the analysis itself.
* There are two primary analysis types for which most of the heavy
  lifting has been done here: forward and backward analyses of both
  dense and sparse varieties. Dense analyses anchor state to program
  points, while sparse analyses anchor state to values (which do not
  change after initial definition, hence sparse). Forward analyses
  follow the CFG, while backward analyses work bottom-up.
* Dead code analysis is implemented, and some of the infra for constant
  propagation is implemented, which I will revisit once the new IR is
  hooked up to codegen.
Copy link
Contributor

@greenhat greenhat left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking great!

The component and module interfaces and instances feels generic and flexible enough to be able to represent Wasm CM.
Let's consider the following WIT:

package miden:basic-wallet@1.0.0;

use miden:base/core-types@1.0.0;

interface basic-wallet {
    use core-types.{core-asset, tag, recipient, note-type};
    receive-asset: func(core-asset: core-asset);
    send-asset: func(core-asset: core-asset, tag: tag, note-type: note-type, recipient: recipient);
}

interface aux {
    use core-types.{felt};
    process-list-felt: func(input: list<felt>) -> list<felt>;
}

interface foo {
    get-foo(a: list<felt>) -> list<felt>;
}

interface bar {
    get-bar(a: list<felt>) -> list<felt>;
}


world basic-wallet-world {
    include miden:core-import/all@1.0.0;
    
    import foo;
    import bar;

    export basic-wallet;
    export aux;
}

The component (high-level) imports (foo and bar) would be represented as module interfaces (one per component imported interface?) and the lowerings we will generate by creating a module that imports this module interface and exports the generated lowering functions with low-level types signatures which in turn be imports for the core Wasm module.
The component (high-level) exports (basic-wallet and aux) would be represented as module interfaces (one per component exported interface?) and the liftings we will generate by creating a module that imports the core Wasm module exports and exports the generated lifting functions with high-level types signatures.
We're going to need to give those generated liftings/lowerings modules access to the core Wasm module memory (Wasm memory

region: RegionRef,
patterns: Rc<FrozenRewritePatternSet>,
mut config: GreedyRewriteConfig,
) -> Result<bool, bool> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It took me some time to grasp the "error's" bool part in Result<bool, bool> pattern. I wonder if this extra mental overhead is worth it. Plus, the need of a comment explaining its meaning vs. self-explaining error type.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A dedicated type might be worth it, basically we want to signal whether or not convergence occurred, and whether it did or not, we also want to know whether the IR was changed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
blocker This issue is one of our top priorities frontend
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants