Diagnostic Infrastructure
This document presents an introduction to using and interfacing with MLIR’s diagnostics infrastructure.
See MLIR specification for more information about MLIR, the structure of the IR, operations, etc.
Source Locations ¶
Source location information is extremely important for any compiler, because it provides a baseline for debuggability and error-reporting. The builtin dialect provides several different location attributes types depending on the situational need.
Diagnostic Engine ¶
The DiagnosticEngine
acts as the main interface for diagnostics in MLIR. It
manages the registration of diagnostic handlers, as well as the core API for
diagnostic emission. Handlers generally take the form of
LogicalResult(Diagnostic &)
. If the result is success
, it signals that the
diagnostic has been fully processed and consumed. If failure
, it signals that
the diagnostic should be propagated to any previously registered handlers. It
can be interfaced with via an MLIRContext
instance.
DiagnosticEngine& engine = ctx->getDiagEngine();
/// Handle the reported diagnostic.
// Return success to signal that the diagnostic has either been fully processed,
// or failure if the diagnostic should be propagated to the previous handlers.
DiagnosticEngine::HandlerID id = engine.registerHandler(
[](Diagnostic &diag) -> LogicalResult {
bool should_propagate_diagnostic = ...;
return failure(should_propagate_diagnostic);
});
// We can also elide the return value completely, in which the engine assumes
// that all diagnostics are consumed(i.e. a success() result).
DiagnosticEngine::HandlerID id = engine.registerHandler([](Diagnostic &diag) {
return;
});
// Unregister this handler when we are done.
engine.eraseHandler(id);
Constructing a Diagnostic ¶
As stated above, the DiagnosticEngine
holds the core API for diagnostic
emission. A new diagnostic can be emitted with the engine via emit
. This
method returns an
InFlightDiagnostic that can be
modified further.
InFlightDiagnostic emit(Location loc, DiagnosticSeverity severity);
Using the DiagnosticEngine
, though, is generally not the preferred way to emit
diagnostics in MLIR.
operation
provides utility
methods for emitting diagnostics:
// `emit` methods available in the mlir namespace.
InFlightDiagnostic emitError/Remark/Warning(Location);
// These methods use the location attached to the operation.
InFlightDiagnostic Operation::emitError/Remark/Warning();
// This method creates a diagnostic prefixed with "'op-name' op ".
InFlightDiagnostic Operation::emitOpError();
Diagnostic ¶
A Diagnostic
in MLIR contains all of the necessary information for reporting a
message to the user. A Diagnostic
essentially boils down to four main
components:
- Source Location
- Severity Level
- Error, Note, Remark, Warning
- Diagnostic Arguments
- The diagnostic arguments are used when constructing the output message.
- Metadata
- Some additional information attached that can be used to identify this diagnostic other than source location and severity level (e.g. for diagnostic handlers to do some filtering). Metadata is not part of the output message.
Appending arguments ¶
One a diagnostic has been constructed, the user can start composing it. The output message of a diagnostic is composed of a set of diagnostic arguments that have been attached to it. New arguments can be attached to a diagnostic in a few different ways:
// A few interesting things to use when composing a diagnostic.
Attribute fooAttr;
Type fooType;
SmallVector<int> fooInts;
// Diagnostics can be composed via the streaming operators.
op->emitError() << "Compose an interesting error: " << fooAttr << ", " << fooType
<< ", (" << fooInts << ')';
// This could generate something like (FuncAttr:@foo, IntegerType:i32, {0,1,2}):
"Compose an interesting error: @foo, i32, (0, 1, 2)"
Operations attached to a diagnostic will be printed in generic form if the
severity level is Error
, otherwise custom operation printers will be used.
// `anotherOp` will be printed in generic form,
// e.g. %3 = "arith.addf"(%arg4, %2) : (f32, f32) -> f32
op->emitError() << anotherOp;
// `anotherOp` will be printed using the custom printer,
// e.g. %3 = arith.addf %arg4, %2 : f32
op->emitRemark() << anotherOp;
To make a custom type compatible with Diagnostics, one must implement the following friend function.
friend mlir::Diagnostic &operator<<(
mlir::Diagnostic &diagnostic, const MyType &foo);
Attaching notes ¶
Unlike many other compiler frameworks, notes in MLIR cannot be emitted directly.
They must be explicitly attached to another diagnostic non-note diagnostic. When
emitting a diagnostic, notes can be directly attached via attachNote
. When
attaching a note, if the user does not provide an explicit source location the
note will inherit the location of the parent diagnostic.
// Emit a note with an explicit source location.
op->emitError("...").attachNote(noteLoc) << "...";
// Emit a note that inherits the parent location.
op->emitError("...").attachNote() << "...";
Managing Metadata ¶
Metadata is a mutable vector of DiagnosticArguments. It can be accessed and modified as a vector.
InFlight Diagnostic ¶
Now that
Diagnostics have been explained, we introduce the
InFlightDiagnostic
, an RAII wrapper around a diagnostic that is set to be
reported. This allows for modifying a diagnostic while it is still in flight. If
it is not reported directly by the user it will automatically report when
destroyed.
{
InFlightDiagnostic diag = op->emitError() << "...";
} // The diagnostic is automatically reported here.
Diagnostic Configuration Options ¶
Several options are provided to help control and enhance the behavior of
diagnostics. These options can be configured via the MLIRContext, and registered
to the command line with the registerMLIRContextCLOptions
method. These
options are listed below:
Print Operation On Diagnostic ¶
Command Line Flag: -mlir-print-op-on-diagnostic
When a diagnostic is emitted on an operation, via Operation::emitError/...
,
the textual form of that operation is printed and attached as a note to the
diagnostic. This option is useful for understanding the current form of an
operation that may be invalid, especially when debugging verifier failures. An
example output is shown below:
test.mlir:3:3: error: 'module_terminator' op expects parent op 'builtin.module'
"module_terminator"() : () -> ()
^
test.mlir:3:3: note: see current operation: "module_terminator"() : () -> ()
"module_terminator"() : () -> ()
^
Print StackTrace On Diagnostic ¶
Command Line Flag: -mlir-print-stacktrace-on-diagnostic
When a diagnostic is emitted, attach the current stack trace as a note to the diagnostic. This option is useful for understanding which part of the compiler generated certain diagnostics. An example output is shown below:
test.mlir:3:3: error: 'module_terminator' op expects parent op 'builtin.module'
"module_terminator"() : () -> ()
^
test.mlir:3:3: note: diagnostic emitted with trace:
#0 0x000055dd40543805 llvm::sys::PrintStackTrace(llvm::raw_ostream&) llvm/lib/Support/Unix/Signals.inc:553:11
#1 0x000055dd3f8ac162 emitDiag(mlir::Location, mlir::DiagnosticSeverity, llvm::Twine const&) /lib/IR/Diagnostics.cpp:292:7
#2 0x000055dd3f8abe8e mlir::emitError(mlir::Location, llvm::Twine const&) /lib/IR/Diagnostics.cpp:304:10
#3 0x000055dd3f998e87 mlir::Operation::emitError(llvm::Twine const&) /lib/IR/Operation.cpp:324:29
#4 0x000055dd3f99d21c mlir::Operation::emitOpError(llvm::Twine const&) /lib/IR/Operation.cpp:652:10
#5 0x000055dd3f96b01c mlir::OpTrait::HasParent<mlir::ModuleOp>::Impl<mlir::ModuleTerminatorOp>::verifyTrait(mlir::Operation*) /mlir/IR/OpDefinition.h:897:18
#6 0x000055dd3f96ab38 mlir::Op<mlir::ModuleTerminatorOp, mlir::OpTrait::ZeroOperands, mlir::OpTrait::ZeroResults, mlir::OpTrait::HasParent<mlir::ModuleOp>::Impl, mlir::OpTrait::IsTerminator>::BaseVerifier<mlir::OpTrait::HasParent<mlir::ModuleOp>::Impl<mlir::ModuleTerminatorOp>, mlir::OpTrait::IsTerminator<mlir::ModuleTerminatorOp> >::verifyTrait(mlir::Operation*) /mlir/IR/OpDefinition.h:1052:29
# ...
"module_terminator"() : () -> ()
^
Common Diagnostic Handlers ¶
To interface with the diagnostics infrastructure, users will need to register a
diagnostic handler with the
DiagnosticEngine
.
Recognizing the many users will want the same handler functionality, MLIR
provides several common diagnostic handlers for immediate use.
Scoped Diagnostic Handler ¶
This diagnostic handler is a simple RAII class that registers and unregisters a given diagnostic handler. This class can be either be used directly, or in conjunction with a derived diagnostic handler.
// Construct the handler directly.
MLIRContext context;
ScopedDiagnosticHandler scopedHandler(&context, [](Diagnostic &diag) {
...
});
// Use this handler in conjunction with another.
class MyDerivedHandler : public ScopedDiagnosticHandler {
MyDerivedHandler(MLIRContext *ctx) : ScopedDiagnosticHandler(ctx) {
// Set the handler that should be RAII managed.
setHandler([&](Diagnostic diag) {
...
});
}
};
SourceMgr Diagnostic Handler ¶
This diagnostic handler is a wrapper around an llvm::SourceMgr instance. It
provides support for displaying diagnostic messages inline with a line of a
respective source file. This handler will also automatically load newly seen
source files into the SourceMgr when attempting to display the source line of a
diagnostic. Example usage of this handler can be seen in the mlir-opt
tool.
$ mlir-opt foo.mlir
/tmp/test.mlir:6:24: error: expected non-function type
func.func @foo() -> (index, ind) {
^
To use this handler in your tool, add the following:
SourceMgr sourceMgr;
MLIRContext context;
SourceMgrDiagnosticHandler sourceMgrHandler(sourceMgr, &context);
Filtering Locations ¶
In some situations, a diagnostic may be emitted with a callsite location in a
very deep call stack in which many frames are unrelated to the user source code.
These situations often arise when the user source code is intertwined with that
of a large framework or library. The context of the diagnostic in these cases is
often obfuscated by the unrelated framework source locations. To help alleviate
this obfuscation, the SourceMgrDiagnosticHandler
provides support for
filtering which locations are shown to the user. To enable filtering, a user
must simply provide a filter function to the SourceMgrDiagnosticHandler
on
construction that indicates which locations should be shown. A quick example is
shown below:
// Here we define the functor that controls which locations are shown to the
// user. This functor should return true when a location should be shown, and
// false otherwise. When filtering a container location, such as a NameLoc, this
// function should not recurse into the child location. Recursion into nested
// location is performed as necessary by the caller.
auto shouldShowFn = [](Location loc) -> bool {
FileLineColLoc fileLoc = loc.dyn_cast<FileLineColLoc>();
// We don't perform any filtering on non-file locations.
// Reminder: The caller will recurse into any necessary child locations.
if (!fileLoc)
return true;
// Don't show file locations that contain our framework code.
return !fileLoc.getFilename().strref().contains("my/framework/source/");
};
SourceMgr sourceMgr;
MLIRContext context;
SourceMgrDiagnosticHandler sourceMgrHandler(sourceMgr, &context, shouldShowFn);
Note: In the case where all locations are filtered out, the first location in the stack will still be shown.
SourceMgr Diagnostic Verifier Handler ¶
This handler is a wrapper around a llvm::SourceMgr that is used to verify that certain diagnostics have been emitted to the context. To use this handler, annotate your source file with expected diagnostics in the form of:
expected-(error|note|remark|warning)(-re)? {{ message }}
The provided message
is a string expected to be contained within the generated
diagnostic. The -re
suffix may be used to enable regex matching within the
message
. When present, the message
may define regex match sequences within
{{
}}
blocks. The regular expression matcher supports Extended POSIX regular
expressions (ERE). A few examples are shown below:
// Expect an error on the same line.
func.func @bad_branch() {
cf.br ^missing // expected-error {{reference to an undefined block}}
}
// Expect an error on an adjacent line.
func.func @foo(%a : f32) {
// expected-error@+1 {{unknown comparison predicate "foo"}}
%result = arith.cmpf "foo", %a, %a : f32
return
}
// Expect an error on the next line that does not contain a designator.
// expected-remark@below {{remark on function below}}
// expected-remark@below {{another remark on function below}}
func.func @bar(%a : f32)
// Expect an error on the previous line that does not contain a designator.
func.func @baz(%a : f32)
// expected-remark@above {{remark on function above}}
// expected-remark@above {{another remark on function above}}
// Expect an error mentioning the parent function, but use regex to avoid
// hardcoding the name.
func.func @foo() -> i32 {
// expected-error-re@+1 {{'func.return' op has 0 operands, but enclosing function (@{{.*}}) returns 1}}
return
}
The handler will report an error if any unexpected diagnostics were seen, or if any expected diagnostics weren’t.
$ mlir-opt foo.mlir
/tmp/test.mlir:6:24: error: unexpected error: expected non-function type
func.func @foo() -> (index, ind) {
^
/tmp/test.mlir:15:4: error: expected remark "expected some remark" was not produced
// expected-remark {{expected some remark}}
^~~~~~~~~~~~~~~~~~~~~~~~~~
Similarly to the SourceMgr Diagnostic Handler, this handler can be added to any tool via the following:
SourceMgr sourceMgr;
MLIRContext context;
SourceMgrDiagnosticVerifierHandler sourceMgrHandler(sourceMgr, &context);
Parallel Diagnostic Handler ¶
MLIR is designed from the ground up to be multi-threaded. One important to thing to keep in mind when multi-threading is determinism. This means that the behavior seen when operating on multiple threads is the same as when operating on a single thread. For diagnostics, this means that the ordering of the diagnostics is the same regardless of the amount of threads being operated on. The ParallelDiagnosticHandler is introduced to solve this problem.
After creating a handler of this type, the only remaining step is to ensure that each thread that will be emitting diagnostics to the handler sets a respective ‘orderID’. The orderID corresponds to the order in which diagnostics would be emitted when executing synchronously. For example, if we were processing a list of operations [a, b, c] on a single-thread. Diagnostics emitted while processing operation ‘a’ would be emitted before those for ‘b’ or ‘c’. This corresponds 1-1 with the ‘orderID’. The thread that is processing ‘a’ should set the orderID to ‘0’; the thread processing ‘b’ should set it to ‘1’; and so on and so forth. This provides a way for the handler to deterministically order the diagnostics that it receives given the thread that it is receiving on.
A simple example is shown below:
MLIRContext *context = ...;
ParallelDiagnosticHandler handler(context);
// Process a list of operations in parallel.
std::vector<Operation *> opsToProcess = ...;
llvm::parallelFor(0, opsToProcess.size(), [&](size_t i) {
// Notify the handler that we are processing the i'th operation.
handler.setOrderIDForThread(i);
auto *op = opsToProcess[i];
...
// Notify the handler that we are finished processing diagnostics on this
// thread.
handler.eraseOrderIDForThread();
});