MLIR의 진단 인프라스트럭처를 소개합니다. 소스 위치, DiagnosticEngine, Diagnostic/InFlightDiagnostic의 구성과 사용, 메타데이터와 노트 관리, 진단 출력 구성 옵션, 그리고 일반적으로 쓰이는 진단 핸들러(Scoped, SourceMgr, Verifier, Parallel)를 예제와 함께 설명합니다.
이 문서는 MLIR의 진단 인프라스트럭처를 사용하는 방법과 인터페이스를 소개합니다.
MLIR, IR의 구조, 연산 등에 대한 더 자세한 정보는 MLIR 사양을 참고하세요.
소스 위치 정보는 어떤 컴파일러에도 매우 중요합니다. 디버깅 가능성과 오류 보고의 기준을 제공하기 때문입니다. builtin 방언은 상황에 따라 다른 여러 위치 속성 타입을 제공합니다.
DiagnosticEngine은 MLIR에서 진단을 위한 주요 인터페이스 역할을 합니다. 진단 핸들러의 등록과, 진단을 방출(emission)하기 위한 핵심 API를 관리합니다. 핸들러는 일반적으로 LogicalResult(Diagnostic &) 형태를 취합니다. 결과가 success이면, 해당 진단이 완전히 처리되어 소비되었음을 의미합니다. failure이면, 이전에 등록된 핸들러로 진단을 전파해야 함을 의미합니다. MLIRContext 인스턴스를 통해 인터페이스할 수 있습니다.
DiagnosticEngine& engine = ctx->getDiagEngine();
/// 보고된 진단을 처리합니다.
// success를 반환하면 진단이 완전히 처리되었음을 의미하고,
// failure를 반환하면 진단이 이전 핸들러들로 전파되어야 함을 의미합니다.
DiagnosticEngine::HandlerID id = engine.registerHandler(
[](Diagnostic &diag) -> LogicalResult {
bool should_propagate_diagnostic = ...;
return failure(should_propagate_diagnostic);
});
// 반환 값을 완전히 생략할 수도 있으며, 이 경우 엔진은 모든 진단이
// 소비되었다고 가정합니다(즉, success() 결과와 동일).
DiagnosticEngine::HandlerID id = engine.registerHandler([](Diagnostic &diag) {
return;
});
// 사용이 끝나면 이 핸들러를 등록 해제합니다.
engine.eraseHandler(id);
앞서 언급했듯이, DiagnosticEngine은 진단 방출을 위한 핵심 API를 보유합니다. 엔진의 emit으로 새로운 진단을 방출할 수 있습니다. 이 메서드는 이후에 더 수정할 수 있는 InFlightDiagnostic을 반환합니다.
InFlightDiagnostic emit(Location loc, DiagnosticSeverity severity);
하지만 일반적으로 MLIR에서는 DiagnosticEngine을 직접 사용해 진단을 방출하는 방법은 선호되지 않습니다. operation은 진단을 방출하기 위한 유틸리티 메서드를 제공합니다:
// mlir 네임스페이스에서 제공되는 `emit` 메서드들.
InFlightDiagnostic emitError/Remark/Warning(Location);
// 이 메서드들은 연산에 첨부된 위치를 사용합니다.
InFlightDiagnostic Operation::emitError/Remark/Warning();
// 이 메서드는 "'op-name' op " 접두사가 붙은 진단을 생성합니다.
InFlightDiagnostic Operation::emitOpError();
MLIR의 Diagnostic은 사용자에게 메시지를 보고하는 데 필요한 모든 정보를 담고 있습니다. Diagnostic은 본질적으로 다음 네 가지 주요 구성 요소로 요약됩니다:
심각도(Severity) 수준
진단 인자(Diagnostic Arguments)
메타데이터(Metadata)
진단이 한 번 구성되면, 사용자는 그것을 구성(작성)하기 시작할 수 있습니다. 진단의 출력 메시지는 여기에 첨부된 일련의 진단 인자들로 구성됩니다. 새로운 인자는 여러 가지 방법으로 진단에 첨부할 수 있습니다:
// 진단을 구성할 때 사용할 몇 가지 흥미로운 값들.
Attribute fooAttr;
Type fooType;
SmallVector<int> fooInts;
// 스트리밍 연산자를 통해 진단을 구성할 수 있습니다.
op->emitError() << "흥미로운 오류를 구성합니다: " << fooAttr << ", " << fooType
<< ", (" << fooInts << ')';
// 다음과 같은 출력이 생성될 수 있습니다 (FuncAttr:@foo, IntegerType:i32, {0,1,2}):
"흥미로운 오류를 구성합니다: @foo, i32, (0, 1, 2)"
진단에 첨부된 연산은 심각도 수준이 Error인 경우 제네릭 형태로 출력되며, 그 외에는 커스텀 연산 프린터가 사용됩니다.
// `anotherOp`는 제네릭 형태로 출력됩니다,
// 예: %3 = "arith.addf"(%arg4, %2) : (f32, f32) -> f32
op->emitError() << anotherOp;
// `anotherOp`는 커스텀 프린터를 사용해 출력됩니다,
// 예: %3 = arith.addf %arg4, %2 : f32
op->emitRemark() << anotherOp;
사용자 정의 타입을 Diagnostics에서 호환 가능하게 만들려면, 다음의 friend 함수를 구현해야 합니다.
friend mlir::Diagnostic &operator<<(
mlir::Diagnostic &diagnostic, const MyType &foo);
다른 많은 컴파일러 프레임워크와 달리, MLIR에서 노트는 직접 방출할 수 없습니다. 노트가 아닌 다른 진단에 명시적으로 첨부해야 합니다. 진단을 방출할 때 attachNote를 통해 노트를 직접 첨부할 수 있습니다. 노트를 첨부할 때 명시적인 소스 위치를 제공하지 않으면, 노트는 부모 진단의 위치를 상속합니다.
// 명시적인 소스 위치를 가진 노트를 방출합니다.
op->emitError("...").attachNote(noteLoc) << "...";
// 부모 위치를 상속하는 노트를 방출합니다.
op->emitError("...").attachNote() << "...";
메타데이터는 DiagnosticArguments의 변경 가능한 벡터입니다. 벡터처럼 접근하고 수정할 수 있습니다.
이제 Diagnostics를 설명했으니, 보고될 예정인 진단을 감싸는 RAII 래퍼인 InFlightDiagnostic을 소개합니다. 이를 통해 진단이 아직 진행 중(in flight)일 때 수정할 수 있습니다. 사용자가 직접 보고하지 않으면, 파괴될 때 자동으로 보고됩니다.
{
InFlightDiagnostic diag = op->emitError() << "...";
} // 여기에서 진단이 자동으로 보고됩니다.
진단의 동작을 제어하고 향상시키는 데 도움이 되는 여러 옵션이 제공됩니다. 이러한 옵션은 MLIRContext를 통해 구성할 수 있으며, registerMLIRContextCLOptions 메서드로 커맨드라인에 등록할 수 있습니다. 옵션은 아래와 같습니다:
커맨드라인 플래그: -mlir-print-op-on-diagnostic
Operation::emitError/...를 통해 연산에서 진단이 방출되면, 해당 연산의 텍스트 형태가 출력되어 진단에 노트로 첨부됩니다. 이 옵션은 특히 검증기(verifier) 실패를 디버깅할 때, 유효하지 않을 수 있는 연산의 현재 형태를 이해하는 데 유용합니다. 예시는 아래와 같습니다:
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"() : () -> ()
^
커맨드라인 플래그: -mlir-print-stacktrace-on-diagnostic
진단이 방출될 때, 현재 스택 트레이스를 노트로 진단에 첨부합니다. 이 옵션은 컴파일러의 어느 부분에서 특정 진단이 생성되었는지 이해하는 데 유용합니다. 예시는 아래와 같습니다:
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"() : () -> ()
^
진단 인프라스트럭처와 인터페이스하려면, 사용자는 DiagnosticEngine에 진단 핸들러를 등록해야 합니다. 많은 사용자가 동일한 핸들러 기능을 원할 것임을 고려하여, MLIR은 즉시 사용할 수 있는 몇 가지 공통 진단 핸들러를 제공합니다.
이 진단 핸들러는 주어진 진단 핸들러를 등록/해제하는 간단한 RAII 클래스입니다. 이 클래스는 직접 사용할 수도 있고, 파생된 진단 핸들러와 함께 사용할 수도 있습니다.
// 핸들러를 직접 구성합니다.
MLIRContext context;
ScopedDiagnosticHandler scopedHandler(&context, [](Diagnostic &diag) {
...
});
// 다른 핸들러와 함께 사용합니다.
class MyDerivedHandler : public ScopedDiagnosticHandler {
MyDerivedHandler(MLIRContext *ctx) : ScopedDiagnosticHandler(ctx) {
// RAII로 관리할 핸들러를 설정합니다.
setHandler([&](Diagnostic diag) {
...
});
}
};
이 진단 핸들러는 llvm::SourceMgr 인스턴스를 감싸는 래퍼입니다. 해당 소스 파일의 한 줄과 함께 진단 메시지를 인라인으로 표시하는 기능을 제공합니다. 이 핸들러는 또한 진단의 소스 라인을 표시하려 할 때, 새로 확인된 소스 파일을 자동으로 SourceMgr에 로드합니다. 이 핸들러의 사용 예시는 mlir-opt 도구에서 볼 수 있습니다.
$ mlir-opt foo.mlir
/tmp/test.mlir:6:24: error: expected non-function type
func.func @foo() -> (index, ind) {
^
이 핸들러를 도구에서 사용하려면, 다음을 추가하세요:
SourceMgr sourceMgr;
MLIRContext context;
SourceMgrDiagnosticHandler sourceMgrHandler(sourceMgr, &context);
어떤 상황에서는, 매우 깊은 호출 스택의 호출 지점(callsite) 위치와 함께 진단이 방출될 수 있으며, 그 중 많은 프레임이 사용자 소스 코드와 무관할 수 있습니다. 이러한 상황은 사용자 소스 코드가 대형 프레임워크나 라이브러리의 코드와 얽혀 있을 때 자주 발생합니다. 이 경우, 관련 없는 프레임워크 소스 위치로 인해 진단의 문맥이 종종 가려집니다. 이러한 혼선을 줄이기 위해 SourceMgrDiagnosticHandler는 사용자에게 보여줄 위치를 필터링하는 기능을 제공합니다. 필터링을 활성화하려면, 생성 시 SourceMgrDiagnosticHandler에 어떤 위치를 보여줄지 알려주는 필터 함수를 제공하면 됩니다. 간단한 예시는 아래와 같습니다:
// 여기서는 사용자에게 보여줄 위치를 제어하는 함수 객체(functor)를 정의합니다.
// 이 함수는 위치를 보여줘야 하면 true를, 아니면 false를 반환해야 합니다.
// NameLoc 같은 컨테이너 위치를 필터링할 때, 이 함수는 자식 위치로 재귀하지 않아야 합니다.
// 중첩된 위치로의 재귀는 호출자가 필요에 따라 수행합니다.
auto shouldShowFn = [](Location loc) -> bool {
FileLineColLoc fileLoc = dyn_cast<FileLineColLoc>(loc);
// 파일이 아닌 위치에 대해서는 필터링을 수행하지 않습니다.
// 참고: 호출자가 필요한 자식 위치로 재귀합니다.
if (!fileLoc)
return true;
// 프레임워크 코드를 포함하는 파일 위치는 표시하지 않습니다.
return !fileLoc.getFilename().strref().contains("my/framework/source/");
};
SourceMgr sourceMgr;
MLIRContext context;
SourceMgrDiagnosticHandler sourceMgrHandler(sourceMgr, &context, shouldShowFn);
참고: 모든 위치가 필터링되어 사라지는 경우, 스택의 첫 번째 위치는 여전히 표시됩니다.
이 핸들러는 llvm::SourceMgr를 감싸며, 컨텍스트에 특정 진단이 방출되었는지 검증하는 데 사용됩니다. 이 핸들러를 사용하려면, 소스 파일에 기대하는 진단을 다음 형식으로 주석으로 표시하세요:
expected-(error|note|remark|warning)(-re)? {{ message }}제공된 message는 생성된 진단에 포함되리라 기대되는 문자열입니다. -re 접미사는 message에서 정규식 매칭을 활성화할 때 사용할 수 있습니다. 존재하는 경우, message는 {{``}} 블록 안에 정규식 매치 시퀀스를 정의할 수 있습니다. 정규식 매처는 확장 POSIX 정규식(ERE)을 지원합니다. 몇 가지 예시는 아래와 같습니다:
// 같은 줄에서 오류를 기대합니다.
func.func @bad_branch() {
cf.br ^missing // expected-error {{reference to an undefined block}}
}
// 인접한 줄에서 오류를 기대합니다.
func.func @foo(%a : f32) {
// expected-error@+1 {{unknown comparison predicate "foo"}}
%result = arith.cmpf "foo", %a, %a : f32
return
}
// 지정자가 없는 다음 줄에서 오류를 기대합니다.
// expected-remark@below {{remark on function below}}
// expected-remark@below {{another remark on function below}}
func.func @bar(%a : f32)
// 지정자가 없는 이전 줄에서 오류를 기대합니다.
func.func @baz(%a : f32)
// expected-remark@above {{remark on function above}}
// expected-remark@above {{another remark on function above}}
// 부모 함수명을 언급하는 오류를 기대하지만, 이름을 하드코딩하지 않기 위해
// 정규식을 사용합니다.
func.func @foo() -> i32 {
// expected-error-re@+1 {{'func.return' op has 0 operands, but enclosing function (@{{.*}}) returns 1}}
return
}
핸들러는 예기치 않은 진단이 관측되었거나, 기대한 진단이 생성되지 않았을 경우 오류를 보고합니다.
$ 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}}
^~~~~~~~~~~~~~~~~~~~~~~~~~
SourceMgr Diagnostic Handler와 마찬가지로, 이 핸들러는 다음과 같이 어떤 도구에도 추가할 수 있습니다:
SourceMgr sourceMgr;
MLIRContext context;
SourceMgrDiagnosticVerifierHandler sourceMgrHandler(sourceMgr, &context);
MLIR은 처음부터 멀티스레드 환경을 고려하여 설계되었습니다. 멀티스레딩에서 중요한 점 중 하나는 결정성(determinism)입니다. 이는 여러 스레드에서 동작할 때 보이는 동작이 단일 스레드에서 동작할 때와 동일함을 의미합니다. 진단의 경우, 동작하는 스레드 수와 관계없이 진단의 순서가 동일해야 함을 의미합니다. 이 문제를 해결하기 위해 ParallelDiagnosticHandler가 도입되었습니다.
이 타입의 핸들러를 생성한 뒤 남은 유일한 단계는, 핸들러에 진단을 방출할 각 스레드가 해당하는 ‘orderID’를 설정하도록 보장하는 것입니다. orderID는 동기적으로 실행할 때 진단이 방출되는 순서에 대응합니다. 예를 들어, 단일 스레드에서 연산 목록 [a, b, c]를 처리하고 있다면, 연산 ‘a’를 처리하는 동안 방출된 진단은 ‘b’나 ‘c’의 진단보다 먼저 방출됩니다. 이것이 ‘orderID’와 1:1로 대응합니다. ‘a’를 처리하는 스레드는 orderID를 ‘0’으로 설정해야 하고, ‘b’를 처리하는 스레드는 ‘1’, 그 다음은 계속 증가시키면 됩니다. 이렇게 하면 핸들러가 수신 스레드에 상관없이 수신한 진단을 결정적으로 정렬할 수 있습니다.
간단한 예시는 아래와 같습니다:
MLIRContext *context = ...;
ParallelDiagnosticHandler handler(context);
// 연산 목록을 병렬로 처리합니다.
std::vector<Operation *> opsToProcess = ...;
llvm::parallelFor(0, opsToProcess.size(), [&](size_t i) {
// i번째 연산을 처리하고 있음을 핸들러에 알립니다.
handler.setOrderIDForThread(i);
auto *op = opsToProcess[i];
...
// 이 스레드에서의 진단 처리를 마쳤음을 핸들러에 알립니다.
handler.eraseOrderIDForThread();
});