lowering

MLIR toy compiler V5 tagged. Array element assignment/access is implemented.

December 23, 2025 C/C++ development and debugging., clang/llvm , , , , , ,

Screenshot

The language and compiler now supports functions, calls, parameters, returns, basic conditional blocks, scalar and array declarations, binary and unary operations, arithmetic and boolean operators, and a print statement.

See the Changelog for full details of all the changes since V4.  The IF/ELSE work was described recently, but the ARRAY element work is new.

Array element lvalues and rvalues were both implemented.  This required grammar, builder, and lowering changes.

The grammar now has optional array element indexes for many elements.  Examples:

returnStatement
  : RETURN_TOKEN (literal | scalarOrArrayElement)?
  ;

print
  : PRINT_TOKEN (scalarOrArrayElement | STRING_PATTERN)
  ;

assignment
  : scalarOrArrayElement EQUALS_TOKEN rhs
  ;

rhs
  : literal
  | unaryOperator? scalarOrArrayElement
  | binaryElement binaryOperator binaryElement
  | call
  ;

binaryElement
  : numericLiteral
  | unaryOperator? scalarOrArrayElement
  ;

booleanElement
  : booleanLiteral | scalarOrArrayElement
  ;

scalarOrArrayElement
  : IDENTIFIER (indexExpression)?
  ;

indexExpression
  : ARRAY_START_TOKEN (IDENTIFIER | INTEGER_PATTERN) ARRAY_END_TOKEN
  ;

Most of these scalarOrArrayElement used to be just LITERAL. My MLIR AssignOp and LoadOp’s are now generalized to include optional indexes:

def Toy_AssignOp : Op<Toy_Dialect, "assign"> {
  let summary = "Assign a value to a variable (scalar or array element).";

  let description = [{
    Assigns `value` to the variable referenced by `var_name`.
    If `index` is present, the assignment targets the array element at that index.
    The target variable must have been declared with a matching `toy.declare`.
  }];

  let arguments = (ins
    SymbolRefAttr:$var_name,               // @t
    Optional:$index,                // optional SSA value of index type (dynamic or none)
    AnyType:$value                         // the value being assigned
  );

  let results = (outs);

  let assemblyFormat =
    "$var_name (`[` $index^ `]`)? `=` $value `:` type($value) attr-dict";
}

def Toy_LoadOp : Op<Toy_Dialect, "load"> {
  let summary = "Load a variable (scalar or array element) by symbol reference.";
  let arguments = (ins
    SymbolRefAttr:$var_name,               // @t
    Optional:$index                 // optional SSA value of index type (dynamic or none)
  );

  let results = (outs AnyType:$result);

  let assemblyFormat =
    "$var_name (`[` $index^ `]`)? `:` type($result) attr-dict";
}

Here is a simple example program that has a couple array elements, assignments, accesses, print and exit statements:

        INT32 t[7];
        INT32 x;
        t[3] = 42;
        x = t[3];
        PRINT x;

Here is the MLIR listing for this program, illustrating a couple of the optional index inputs:

        module {
          func.func @main() -> i32 {
            "toy.scope"() ({
              "toy.declare"() <{size = 7 : i64, type = i32}> {sym_name = "t"} : () -> ()
              "toy.declare"() <{type = i32}> {sym_name = "x"} : () -> ()
              %c3_i64 = arith.constant 3 : i64
              %c42_i64 = arith.constant 42 : i64
              %0 = arith.index_cast %c3_i64 : i64 to index
              toy.assign @t[%0] = %c42_i64 : i64
              %c3_i64_0 = arith.constant 3 : i64
              %1 = arith.index_cast %c3_i64_0 : i64 to index
    >>        %2 = toy.load @t[%1] : i32
              toy.assign @x = %2 : i32
              %3 = toy.load @x : i32
              toy.print %3 : i32
              %c0_i32 = arith.constant 0 : i32
              "toy.return"(%c0_i32) : (i32) -> ()
            }) : () -> ()
            "toy.yield"() : () -> ()
          }
        }

PRINT and EXIT also now support array elements, but that isn’t in this bit of sample code.

Here is an example lowering to LLVM LL:

        define i32 @main() !dbg !4 {
          %1 = alloca i32, i64 7, align 4, !dbg !8
            #dbg_declare(ptr %1, !9, !DIExpression(), !8)
          %2 = alloca i32, i64 1, align 4, !dbg !14
            #dbg_declare(ptr %2, !15, !DIExpression(), !14)
          %3 = getelementptr i32, ptr %1, i64 3, !dbg !16
          store i32 42, ptr %3, align 4, !dbg !16
    >>    %4 = getelementptr i32, ptr %1, i64 3, !dbg !17
    >>    %5 = load i32, ptr %4, align 4, !dbg !17
          store i32 %5, ptr %2, align 4, !dbg !17
          %6 = load i32, ptr %2, align 4, !dbg !18
          %7 = sext i32 %6 to i64, !dbg !18
          call void @__toy_print_i64(i64 %7), !dbg !18
          ret i32 0, !dbg !18
        }

(with the GEP and associated load for the array access highlighted.)

Even without optimization enabled, the assembly listing is pretty good:

        0000000000000000 
: 0: sub $0x28,%rsp 4: movl $0x2a,0x18(%rsp) c: movl $0x2a,0x8(%rsp) 14: mov $0x2a,%edi 19: call 1e 1a: R_X86_64_PLT32 __toy_print_i64-0x4 1e: xor %eax,%eax 20: add $0x28,%rsp 24: ret

With optimization, everything is in registers, looking even nicer:

        0000000000000000 
: 0: push %rax 1: mov $0x2a,%edi 6: call b 7: R_X86_64_PLT32 __toy_print_i64-0x4 b: xor %eax,%eax d: pop %rcx e: ret

Added IF/ELSE support to my toy MLIR/LLVM compiler.

December 16, 2025 clang/llvm , , , , , , , , , , , ,

Screenshot

I dusted off my toy compiler this weekend, and had a bit of fun implementing IF and ELSE support. The MLIR+LLVM compiler infrastructure is impressively versatile, allowing me to implement a feature like this in a handful of hours. It was, however, an invasive feature addition, requiring grammar changes, parser/builder, and lowering adjustments, as well as additional lowering for the SCF dialect ops that were used.

I have an ELIF element in the grammar too, but haven’t done that yet (and haven’t done much testing yet.)  These are the grammar elements I now have defined:

ifelifelse
  : ifStatement
    elifStatement*
    elseStatement?
  ;

ifStatement
  : IF_TOKEN BRACE_START_TOKEN booleanValue BRACE_END_TOKEN SCOPE_START_TOKEN statement* SCOPE_END_TOKEN
  ;

elifStatement
  : ELIF_TOKEN BRACE_START_TOKEN booleanValue BRACE_END_TOKEN SCOPE_START_TOKEN statement* SCOPE_END_TOKEN
  ;

elseStatement
  : ELSE_TOKEN SCOPE_START_TOKEN statement* SCOPE_END_TOKEN
  ;

Previously, I had a single monolithic ifelifelse token, but the generated parser data structure was horrendously complicated:

  class  IfelifelseContext : public antlr4::ParserRuleContext {
  public:
    IfelifelseContext(antlr4::ParserRuleContext *parent, size_t invokingState);
    virtual size_t getRuleIndex() const override;
    antlr4::tree::TerminalNode *IF_TOKEN();
    std::vector<antlr4::tree::TerminalNode *> BRACE_START_TOKEN();
    antlr4::tree::TerminalNode* BRACE_START_TOKEN(size_t i);
    std::vector<BooleanValueContext *> booleanValue();
    BooleanValueContext* booleanValue(size_t i);
    std::vector<antlr4::tree::TerminalNode *> BRACE_END_TOKEN();
    antlr4::tree::TerminalNode* BRACE_END_TOKEN(size_t i);
    std::vector<antlr4::tree::TerminalNode *> SCOPE_START_TOKEN();
    antlr4::tree::TerminalNode* SCOPE_START_TOKEN(size_t i);
    std::vector<antlr4::tree::TerminalNode *> SCOPE_END_TOKEN();
    antlr4::tree::TerminalNode* SCOPE_END_TOKEN(size_t i);
    std::vector<StatementContext *> statement();
    StatementContext* statement(size_t i);
    std::vector<antlr4::tree::TerminalNode *> ELIF_TOKEN();
    antlr4::tree::TerminalNode* ELIF_TOKEN(size_t i);
    antlr4::tree::TerminalNode *ELSE_TOKEN();

    virtual void enterRule(antlr4::tree::ParseTreeListener *listener) override;
    virtual void exitRule(antlr4::tree::ParseTreeListener *listener) override;

  };

In particular, I don’t see a way to determine if a statement should be part of the IF, the ELIF, or the ELSE body. Splitting the grammar into pieces was much better, and leave me with:

  class  IfelifelseContext : public antlr4::ParserRuleContext {
  public:
    IfelifelseContext(antlr4::ParserRuleContext *parent, size_t invokingState);
    virtual size_t getRuleIndex() const override;
    IfStatementContext *ifStatement();
    std::vector<ElifStatementContext *> elifStatement();
    ElifStatementContext* elifStatement(size_t i);
    ElseStatementContext *elseStatement();

    virtual void enterRule(antlr4::tree::ParseTreeListener *listener) override;
    virtual void exitRule(antlr4::tree::ParseTreeListener *listener) override;

  };

which allows me to drill down into each of the lower level elements, and drive the codegen from those. In particular, I can define ANTLR4 parse tree walker callbacks:

        void exitIfStatement(ToyParser::IfStatementContext *ctx) override;
        void exitElseStatement(ToyParser::ElseStatementContext *ctx) override;
        void enterIfelifelse( ToyParser::IfelifelseContext *ctx ) override;

and drive the codegen from there. The choice to use enter/exit callbacks for the various statement objects locks you into a certain mode of programming. In particular, this means that I don’t have functions for parsing a generic statement, so I am forced to emit the if/else body statements indirectly. Here’s an example:

        mlir::Value conditionPredicate = MLIRListener::parsePredicate( loc, booleanValue );

        insertionPointStack.push_back( builder.saveInsertionPoint() );

        // Create the scf.if — it will be inserted at the current IP
        auto ifOp = builder.create<mlir::scf::IfOp>( loc, conditionPredicate );

        mlir::Block &thenBlock = ifOp.getThenRegion().front();
        builder.setInsertionPointToStart( &thenBlock );

Then in the exitIf callback, if there is an else statement to process, I create a block for that, set the insertion point for that, and let the statement processing continue. When that else processing is done, I can restore the insertion point to just after the scf.if, and the rest of the function generation can proceed. Here’s an example of the MLIR dump before any lowering:

module {
  func.func @main() -> i32 {
    "toy.scope"() ({
      "toy.declare"() <{type = i32}> {sym_name = "x"} : () -> ()
      %c3_i64 = arith.constant 3 : i64
      "toy.assign"(%c3_i64) <{var_name = @x}> : (i64) -> ()
      %0 = "toy.load"() <{var_name = @x}> : () -> i32
      %c4_i64 = arith.constant 4 : i64
      %1 = "toy.less"(%0, %c4_i64) : (i32, i64) -> i1
      scf.if %1 {
        %5 = "toy.string_literal"() <{value = "x < 4"}> : () -> !llvm.ptr
        toy.print %5 : !llvm.ptr
        %6 = "toy.string_literal"() <{value = "a second statement"}> : () -> !llvm.ptr
        toy.print %6 : !llvm.ptr
      } else {
        %5 = "toy.string_literal"() <{value = "!(x < 4) -- should be dead code"}> : () -> !llvm.ptr
        toy.print %5 : !llvm.ptr
      }
      %2 = "toy.load"() <{var_name = @x}> : () -> i32
      %c5_i64 = arith.constant 5 : i64
      %3 = "toy.less"(%c5_i64, %2) : (i64, i32) -> i1
      scf.if %3 {
        %5 = "toy.string_literal"() <{value = "x > 5"}> : () -> !llvm.ptr
        toy.print %5 : !llvm.ptr
      } else {
        %5 = "toy.string_literal"() <{value = "!(x > 5) -- should see this"}> : () -> !llvm.ptr
        toy.print %5 : !llvm.ptr
      }
      %4 = "toy.string_literal"() <{value = "Done."}> : () -> !llvm.ptr
      toy.print %4 : !llvm.ptr
      %c0_i32 = arith.constant 0 : i32
      "toy.return"(%c0_i32) : (i32) -> ()
    }) : () -> ()
    "toy.yield"() : () -> ()
  }
}

This is the IR for the following “program”:

INT32 x;

x = 3;

IF ( x < 4 )
{
   PRINT "x < 4";
   PRINT "a second statement";
}
ELSE
{
   PRINT "!(x < 4) -- should be dead code";
};

IF ( x > 5 )
{
   PRINT "x > 5";
}
ELSE
{
   PRINT "!(x > 5) -- should see this";
};

PRINT "Done.";

There are existing mechanisms for lowering the SCF dialect, so it doesn’t take much work. There was one lowering quirk that I didn’t expect to have to deal with. I lower the toy.print Op in two steps, first to toy.call, and then let all the toy.call lowering kick in — however, I was doing that as part of a somewhat hacky toy.scope lowering step. That didn’t work anymore, since I can now have functions that aren’t in the scope, but part of an scf if or else block body. To fix that, I had to switch to a more conventional CallOp lowering class:

    class CallOpLowering : public ConversionPattern {
    public:
      explicit CallOpLowering(MLIRContext* context)
          : ConversionPattern(toy::CallOp::getOperationName(), 1, context) {}

      LogicalResult matchAndRewrite(Operation* op, ArrayRef<Value> operands,
                                    ConversionPatternRewriter& rewriter) const override {
        auto callOp = cast<toy::CallOp>(op);
        auto loc = callOp.getLoc();

        // Get the callee symbol reference (stored as "callee" attribute)
        auto calleeAttr = callOp->getAttrOfType<FlatSymbolRefAttr>("callee");
        if (!calleeAttr)
          return failure();

        // Get result types (empty for void, one type for scalar return)
        TypeRange resultTypes = callOp.getResultTypes();

        auto mlirCall = rewriter.create<mlir::func::CallOp>( loc, resultTypes, 
                                                             calleeAttr, callOp.getOperands() );

        // Replace uses correctly
        if (!resultTypes.empty()) {
          // Non-void: replace the single result
          rewriter.replaceOp(op, mlirCall.getResults());
        } else {
          // Void: erase the op (no result to replace)
          rewriter.eraseOp(op);
        }

        return success();
      }
    };

I lower all calls (print and any other) first to mlir::func::CallOp, and then let existing mlir func lowering kick in and do the rest.

After my “stage I” lowering, this program is mostly translated into LLVM:

module attributes {llvm.ident = "toycalculator V2"} {
  llvm.mlir.global private constant @str_5(dense<[68, 111, 110, 101, 46]> : tensor<5xi8>) {addr_space = 0 : i32} : !llvm.array<5 x i8>
  llvm.mlir.global private constant @str_4(dense<[33, 40, 120, 32, 62, 32, 53, 41, 32, 45, 45, 32, 115, 104, 111, 117, 108, 100, 32, 115, 101, 101, 32, 116, 104, 105, 115]> : tensor<27xi8>) {addr_space = 0 : i32} : !llvm.array<27 x i8>
  llvm.mlir.global private constant @str_3(dense<[120, 32, 62, 32, 53]> : tensor<5xi8>) {addr_space = 0 : i32} : !llvm.array<5 x i8>
  llvm.mlir.global private constant @str_2(dense<[33, 40, 120, 32, 60, 32, 52, 41, 32, 45, 45, 32, 115, 104, 111, 117, 108, 100, 32, 98, 101, 32, 100, 101, 97, 100, 32, 99, 111, 100, 101]> : tensor<31xi8>) {addr_space = 0 : i32} : !llvm.array<31 x i8>
  llvm.mlir.global private constant @str_1(dense<[97, 32, 115, 101, 99, 111, 110, 100, 32, 115, 116, 97, 116, 101, 109, 101, 110, 116]> : tensor<18xi8>) {addr_space = 0 : i32} : !llvm.array<18 x i8>
  func.func private @__toy_print_string(i64, !llvm.ptr)
  llvm.mlir.global private constant @str_0(dense<[120, 32, 60, 32, 52]> : tensor<5xi8>) {addr_space = 0 : i32} : !llvm.array<5 x i8>
  func.func @main() -> i32 attributes {llvm.debug.subprogram = #llvm.di_subprogram, compileUnit = , sourceLanguage = DW_LANG_C, file = <"if.toy" in ".">, producer = "toycalculator", isOptimized = false, emissionKind = Full>, scope = #llvm.di_file<"if.toy" in ".">, name = "main", linkageName = "main", file = <"if.toy" in ".">, line = 1, scopeLine = 1, subprogramFlags = Definition, type = >>} {
    "toy.scope"() ({
      %0 = llvm.mlir.constant(1 : i64) : i64
      %1 = llvm.alloca %0 x i32 {alignment = 4 : i64, bindc_name = "x"} : (i64) -> !llvm.ptr
      llvm.intr.dbg.declare #llvm.di_local_variable, compileUnit = , sourceLanguage = DW_LANG_C, file = <"if.toy" in ".">, producer = "toycalculator", isOptimized = false, emissionKind = Full>, scope = #llvm.di_file<"if.toy" in ".">, name = "main", linkageName = "main", file = <"if.toy" in ".">, line = 1, scopeLine = 1, subprogramFlags = Definition, type = >>, name = "x", file = <"if.toy" in ".">, line = 1, alignInBits = 32, type = #llvm.di_basic_type> = %1 : !llvm.ptr
      %2 = llvm.mlir.constant(3 : i64) : i64
      %3 = llvm.trunc %2 : i64 to i32
      llvm.store %3, %1 {alignment = 4 : i64} : i32, !llvm.ptr
      %4 = llvm.load %1 : !llvm.ptr -> i32
      %5 = llvm.mlir.constant(4 : i64) : i64
      %6 = llvm.sext %4 : i32 to i64
      %7 = llvm.icmp "slt" %6, %5 : i64
      scf.if %7 {
        %15 = llvm.mlir.addressof @str_0 : !llvm.ptr
        %16 = llvm.mlir.constant(5 : i64) : i64
        "toy.call"(%16, %15) <{callee = @__toy_print_string}> : (i64, !llvm.ptr) -> ()
        %17 = llvm.mlir.addressof @str_1 : !llvm.ptr
        %18 = llvm.mlir.constant(18 : i64) : i64
        "toy.call"(%18, %17) <{callee = @__toy_print_string}> : (i64, !llvm.ptr) -> ()
      } else {
        %15 = llvm.mlir.addressof @str_2 : !llvm.ptr
        %16 = llvm.mlir.constant(31 : i64) : i64
        "toy.call"(%16, %15) <{callee = @__toy_print_string}> : (i64, !llvm.ptr) -> ()
      }
      %8 = llvm.load %1 : !llvm.ptr -> i32
      %9 = llvm.mlir.constant(5 : i64) : i64
      %10 = llvm.sext %8 : i32 to i64
      %11 = llvm.icmp "slt" %9, %10 : i64
      scf.if %11 {
        %15 = llvm.mlir.addressof @str_3 : !llvm.ptr
        %16 = llvm.mlir.constant(5 : i64) : i64
        "toy.call"(%16, %15) <{callee = @__toy_print_string}> : (i64, !llvm.ptr) -> ()
      } else {
        %15 = llvm.mlir.addressof @str_4 : !llvm.ptr
        %16 = llvm.mlir.constant(27 : i64) : i64
        "toy.call"(%16, %15) <{callee = @__toy_print_string}> : (i64, !llvm.ptr) -> ()
      }
      %12 = llvm.mlir.addressof @str_5 : !llvm.ptr
      %13 = llvm.mlir.constant(5 : i64) : i64
      "toy.call"(%13, %12) <{callee = @__toy_print_string}> : (i64, !llvm.ptr) -> ()
      %14 = llvm.mlir.constant(0 : i32) : i32
      "toy.return"(%14) : (i32) -> ()
    }) : () -> ()
    "toy.yield"() : () -> ()
  }
}

The only things that are left in my MLIR toy dialect are “toy.scope”, “toy.call”, “toy.yield”, and “toy.return”. After my second lowering pass, everything is in the MLIR LLVM dialect:

llvm.mlir.global private constant @str_5(dense<[68, 111, 110, 101, 46]> : tensor<5xi8>) {addr_space = 0 : i32} : !llvm.array<5 x i8>
llvm.mlir.global private constant @str_4(dense<[33, 40, 120, 32, 62, 32, 53, 41, 32, 45, 45, 32, 115, 104, 111, 117, 108, 100, 32, 115, 101, 101, 32, 116, 104, 105, 115]> : tensor<27xi8>) {addr_space = 0 : i32} : !llvm.array<27 x i8>
llvm.mlir.global private constant @str_3(dense<[120, 32, 62, 32, 53]> : tensor<5xi8>) {addr_space = 0 : i32} : !llvm.array<5 x i8>
llvm.mlir.global private constant @str_2(dense<[33, 40, 120, 32, 60, 32, 52, 41, 32, 45, 45, 32, 115, 104, 111, 117, 108, 100, 32, 98, 101, 32, 100, 101, 97, 100, 32, 99, 111, 100, 101]> : tensor<31xi8>) {addr_space = 0 : i32} : !llvm.array<31 x i8>
llvm.mlir.global private constant @str_1(dense<[97, 32, 115, 101, 99, 111, 110, 100, 32, 115, 116, 97, 116, 101, 109, 101, 110, 116]> : tensor<18xi8>) {addr_space = 0 : i32} : !llvm.array<18 x i8>
func.func private @__toy_print_string(i64, !llvm.ptr)
llvm.mlir.global private constant @str_0(dense<[120, 32, 60, 32, 52]> : tensor<5xi8>) {addr_space = 0 : i32} : !llvm.array<5 x i8>
func.func @main() -> i32 attributes {llvm.debug.subprogram = #llvm.di_subprogram, compileUnit = , sourceLanguage = DW_LANG_C, file = <"if.toy" in ".">, producer = "toycalculator", isOptimized = false, emissionKind = Full>, scope = #llvm.di_file<"if.toy" in ".">, name = "main", linkageName = "main", file = <"if.toy" in ".">, line = 1, scopeLine = 1, subprogramFlags = Definition, type = >>} {
  %0 = llvm.mlir.constant(1 : i64) : i64
  %1 = llvm.alloca %0 x i32 {alignment = 4 : i64, bindc_name = "x"} : (i64) -> !llvm.ptr
  llvm.intr.dbg.declare #llvm.di_local_variable, compileUnit = , sourceLanguage = DW_LANG_C, file = <"if.toy" in ".">, producer = "toycalculator", isOptimized = false, emissionKind = Full>, scope = #llvm.di_file<"if.toy" in ".">, name = "main", linkageName = "main", file = <"if.toy" in ".">, line = 1, scopeLine = 1, subprogramFlags = Definition, type = >>, name = "x", file = <"if.toy" in ".">, line = 1, alignInBits = 32, type = #llvm.di_basic_type> = %1 : !llvm.ptr
  %2 = llvm.mlir.constant(3 : i64) : i64
  %3 = llvm.trunc %2 : i64 to i32
  llvm.store %3, %1 {alignment = 4 : i64} : i32, !llvm.ptr
  %4 = llvm.load %1 : !llvm.ptr -> i32
  %5 = llvm.mlir.constant(4 : i64) : i64
  %6 = llvm.sext %4 : i32 to i64
  %7 = llvm.icmp "slt" %6, %5 : i64
  llvm.cond_br %7, ^bb1, ^bb2
^bb1:  // pred: ^bb0
  %8 = llvm.mlir.addressof @str_0 : !llvm.ptr
  %9 = llvm.mlir.constant(5 : i64) : i64
  call @__toy_print_string(%9, %8) : (i64, !llvm.ptr) -> ()
  %10 = llvm.mlir.addressof @str_1 : !llvm.ptr
  %11 = llvm.mlir.constant(18 : i64) : i64
  call @__toy_print_string(%11, %10) : (i64, !llvm.ptr) -> ()
  llvm.br ^bb3
^bb2:  // pred: ^bb0
  %12 = llvm.mlir.addressof @str_2 : !llvm.ptr
  %13 = llvm.mlir.constant(31 : i64) : i64
  call @__toy_print_string(%13, %12) : (i64, !llvm.ptr) -> ()
  llvm.br ^bb3
^bb3:  // 2 preds: ^bb1, ^bb2
  %14 = llvm.load %1 : !llvm.ptr -> i32
  %15 = llvm.mlir.constant(5 : i64) : i64
  %16 = llvm.sext %14 : i32 to i64
  %17 = llvm.icmp "slt" %15, %16 : i64
  llvm.cond_br %17, ^bb4, ^bb5
^bb4:  // pred: ^bb3
  %18 = llvm.mlir.addressof @str_3 : !llvm.ptr
  %19 = llvm.mlir.constant(5 : i64) : i64
  call @__toy_print_string(%19, %18) : (i64, !llvm.ptr) -> ()
  llvm.br ^bb6
^bb5:  // pred: ^bb3
  %20 = llvm.mlir.addressof @str_4 : !llvm.ptr
  %21 = llvm.mlir.constant(27 : i64) : i64
  call @__toy_print_string(%21, %20) : (i64, !llvm.ptr) -> ()
  llvm.br ^bb6
^bb6:  // 2 preds: ^bb4, ^bb5
  %22 = llvm.mlir.addressof @str_5 : !llvm.ptr
  %23 = llvm.mlir.constant(5 : i64) : i64
  call @__toy_print_string(%23, %22) : (i64, !llvm.ptr) -> ()
  %24 = llvm.mlir.constant(0 : i32) : i32
  return %24 : i32
}

Once assembled, we are left with:

0000000000000000 
: 0: push %rax 1: movl $0x3,0x4(%rsp) 9: xor %eax,%eax b: test %al,%al d: jne 2a f: mov $0x5,%edi 14: mov $0x0,%esi 15: R_X86_64_32 .rodata+0x62 19: call 1e 1a: R_X86_64_PLT32 __toy_print_string-0x4 1e: mov $0x12,%edi 23: mov $0x0,%esi 24: R_X86_64_32 .rodata+0x50 28: jmp 34 2a: mov $0x1f,%edi 2f: mov $0x0,%esi 30: R_X86_64_32 .rodata+0x30 34: call 39 35: R_X86_64_PLT32 __toy_print_string-0x4 39: movslq 0x4(%rsp),%rax 3e: cmp $0x6,%rax 42: jl 50 44: mov $0x5,%edi 49: mov $0x0,%esi 4a: R_X86_64_32 .rodata+0x2b 4e: jmp 5a 50: mov $0x1b,%edi 55: mov $0x0,%esi 56: R_X86_64_32 .rodata+0x10 5a: call 5f 5b: R_X86_64_PLT32 __toy_print_string-0x4 5f: mov $0x5,%edi 64: mov $0x0,%esi 65: R_X86_64_32 .rodata 69: call 6e 6a: R_X86_64_PLT32 __toy_print_string-0x4 6e: xor %eax,%eax 70: pop %rcx 71: ret

Of course, we may enable optimization, and get something much nicer. With -O2, by the time we are done the LLVM lowering, all the constant propagation has kicked in, leaving just:

; ModuleID = 'if.toy'
source_filename = "if.toy"
target datalayout = "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128"
target triple = "x86_64-unknown-linux-gnu"

@str_5 = private constant [5 x i8] c"Done."
@str_4 = private constant [27 x i8] c"!(x > 5) -- should see this"
@str_1 = private constant [18 x i8] c"a second statement"
@str_0 = private constant [5 x i8] c"x < 4"

declare void @__toy_print_string(i64, ptr) local_unnamed_addr

define noundef i32 @main() local_unnamed_addr !dbg !4 {
    #dbg_value(i32 3, !8, !DIExpression(), !10)
  tail call void @__toy_print_string(i64 5, ptr nonnull @str_0), !dbg !11
  tail call void @__toy_print_string(i64 18, ptr nonnull @str_1), !dbg !12
  tail call void @__toy_print_string(i64 27, ptr nonnull @str_4), !dbg !13
  tail call void @__toy_print_string(i64 5, ptr nonnull @str_5), !dbg !14
  ret i32 0, !dbg !14
}

Our program is reduced to just a couple of print statements:

0000000000400470 
: 400470: 50 push %rax 400471: bf 05 00 00 00 mov $0x5,%edi 400476: be 12 12 40 00 mov $0x401212,%esi 40047b: e8 f0 fe ff ff call 400370 <__toy_print_string@plt> 400480: bf 12 00 00 00 mov $0x12,%edi 400485: be 00 12 40 00 mov $0x401200,%esi 40048a: e8 e1 fe ff ff call 400370 <__toy_print_string@plt> 40048f: bf 1b 00 00 00 mov $0x1b,%edi 400494: be e0 11 40 00 mov $0x4011e0,%esi 400499: e8 d2 fe ff ff call 400370 <__toy_print_string@plt> 40049e: bf 05 00 00 00 mov $0x5,%edi 4004a3: be d0 11 40 00 mov $0x4011d0,%esi 4004a8: e8 c3 fe ff ff call 400370 <__toy_print_string@plt> 4004ad: 31 c0 xor %eax,%eax 4004af: 59 pop %rcx 4004b0: c3 ret

We've seen how easy it was to implement enough control flow to almost make the toy language useful. Another side effect of this MLIR+LLVM infrastructure, is that our IF/ELSE debugging support comes for free (having paid the cost earlier of having figured out how to emit the dwarf instrumentation). Here's an example:

> gdb -q out/if
Reading symbols from out/if...
(gdb) b main
Breakpoint 1 at 0x400471: file if.toy, line 3.
(gdb) run
Starting program: /home/pjoot/toycalculator/samples/out/if 
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib64/libthread_db.so.1".

Breakpoint 1, main () at if.toy:3
3       x = 3;
(gdb) l
1       INT32 x;
2
3       x = 3;
4
5       IF ( x < 4 )
6       {
7          PRINT "x < 4";
8          PRINT "a second statement";
9       }
10      ELSE
(gdb) b 7
Breakpoint 2 at 0x40047f: file if.toy, line 7.
(gdb) c
Continuing.

Breakpoint 2, main () at if.toy:7
7          PRINT "x < 4";
(gdb) l
2
3       x = 3;
4
5       IF ( x < 4 )
6       {
7          PRINT "x < 4";
8          PRINT "a second statement";
9       }
10      ELSE
11      {
(gdb) p x
$1 = 3
(gdb) l
12         PRINT "!(x < 4) -- should be dead code";
13      };
14
15      IF ( x > 5 )
16      {
17         PRINT "x > 5";
18      }
19      ELSE
20      {
21         PRINT "!(x > 5) -- should see this";
(gdb) b 21
Breakpoint 3 at 0x4004c0: file if.toy, line 21.
(gdb) c
Continuing.
x < 4
a second statement

Breakpoint 3, main () at if.toy:21
21         PRINT "!(x > 5) -- should see this";

Added FUNCTION/CALL support to my toy compiler

July 7, 2025 clang/llvm , , , , ,

I’ve tagged V4 for my toy language and MLIR based compiler.

See the Changelog for the gory details (or the commit history).  There are three specific new features, relative to the V3 tag:

    1. Adds support (grammar, builder, lowering) for function declarations, and function calls. Much of the work for this was done in branch use_mlir_funcop_with_scopeop, later squashed and merged as a big commit. Here’s an example

      FUNCTION bar ( INT16 w, INT32 z )
      {
          PRINT "In bar";
          PRINT w;
          PRINT z;
          RETURN;
      };
      
      FUNCTION foo ( )
      {
          INT16 v;
          v = 3;
          PRINT "In foo";
          CALL bar( v, 42 );
          PRINT "Called bar";
          RETURN;
      };
      
      PRINT "In main";
      CALL foo();
      PRINT "Back in main";
      

      Here is the MLIR for this program:

      module {
        func.func private @foo() {
          "toy.scope"() ({
            "toy.declare"() <{type = i16}> {sym_name = "v"} : () -> ()
            %c3_i64 = arith.constant 3 : i64
            "toy.assign"(%c3_i64) <{var_name = @v}> : (i64) -> ()
            %0 = "toy.string_literal"() <{value = "In foo"}> : () -> !llvm.ptr
            toy.print %0 : !llvm.ptr
            %1 = "toy.load"() <{var_name = @v}> : () -> i16
            %c42_i64 = arith.constant 42 : i64
            %2 = arith.trunci %c42_i64 : i64 to i32
            "toy.call"(%1, %2) <{callee = @bar}> : (i16, i32) -> ()
            %3 = "toy.string_literal"() <{value = "Called bar"}> : () -> !llvm.ptr
            toy.print %3 : !llvm.ptr
            "toy.return"() : () -> ()
          }) : () -> ()
          "toy.yield"() : () -> ()
        }
        func.func private @bar(%arg0: i16, %arg1: i32) {
          "toy.scope"() ({
            "toy.declare"() <{param_number = 0 : i64, parameter, type = i16}> {sym_name = "w"} : () -> ()
            "toy.declare"() <{param_number = 1 : i64, parameter, type = i32}> {sym_name = "z"} : () -> ()
            %0 = "toy.string_literal"() <{value = "In bar"}> : () -> !llvm.ptr
            toy.print %0 : !llvm.ptr
            %1 = "toy.load"() <{var_name = @w}> : () -> i16
            toy.print %1 : i16
            %2 = "toy.load"() <{var_name = @z}> : () -> i32
            toy.print %2 : i32
            "toy.return"() : () -> ()
          }) : () -> ()
          "toy.yield"() : () -> ()
        }
        func.func @main() -> i32 {
          "toy.scope"() ({
            %c0_i32 = arith.constant 0 : i32
            %0 = "toy.string_literal"() <{value = "In main"}> : () -> !llvm.ptr
            toy.print %0 : !llvm.ptr
            "toy.call"() <{callee = @foo}> : () -> ()
            %1 = "toy.string_literal"() <{value = "Back in main"}> : () -> !llvm.ptr
            toy.print %1 : !llvm.ptr
            "toy.return"(%c0_i32) : (i32) -> ()
          }) : () -> ()
          "toy.yield"() : () -> ()
        }
      }
      

      Here’s a sample program with an assigned CALL value:

      FUNCTION bar ( INT16 w )
      {
          PRINT w;
          RETURN;
      };
      
      PRINT "In main";
      CALL bar( 3 );
      PRINT "Back in main";
      

      The MLIR for this one looks like:

      module {
        func.func private @bar(%arg0: i16) {
          "toy.scope"() ({
            "toy.declare"() <{param_number = 0 : i64, parameter, type = i16}> {sym_name = "w"} : () -> ()
            %0 = "toy.load"() <{var_name = @w}> : () -> i16
            toy.print %0 : i16
            "toy.return"() : () -> ()
          }) : () -> ()
          "toy.yield"() : () -> ()
        }
        func.func @main() -> i32 {
          "toy.scope"() ({
            %c0_i32 = arith.constant 0 : i32
            %0 = "toy.string_literal"() <{value = "In main"}> : () -> !llvm.ptr
            toy.print %0 : !llvm.ptr
            %c3_i64 = arith.constant 3 : i64
            %1 = arith.trunci %c3_i64 : i64 to i16
            "toy.call"(%1) <{callee = @bar}> : (i16) -> ()
            %2 = "toy.string_literal"() <{value = "Back in main"}> : () -> !llvm.ptr
            toy.print %2 : !llvm.ptr
            "toy.return"(%c0_i32) : (i32) -> ()
          }) : () -> ()
          "toy.yield"() : () -> ()
        }
      }
      

      I’ve implemented a two stage lowering, where the toy.scope, toy.yield, toy.call, and toy.returns are stripped out leaving just the func and llvm dialects. Code from that stage of the lowering is cleaner looking

      llvm.mlir.global private constant @str_1(dense<[66, 97, 99, 107, 32, 105, 110, 32, 109, 97, 105, 110]> : tensor<12xi8>) {addr_space = 0 : i32} : !llvm.array<12 x i8>
      func.func private @__toy_print_string(i64, !llvm.ptr)
      llvm.mlir.global private constant @str_0(dense<[73, 110, 32, 109, 97, 105, 110]> : tensor<7xi8>) {addr_space = 0 : i32} : !llvm.array<7 x i8>
      func.func private @__toy_print_i64(i64)
      func.func private @bar(%arg0: i16) {
        %0 = llvm.mlir.constant(1 : i64) : i64
        %1 = llvm.alloca %0 x i16 {alignment = 2 : i64, bindc_name = "w.addr"} : (i64) -> !llvm.ptr
        llvm.store %arg0, %1 : i16, !llvm.ptr
        %2 = llvm.load %1 : !llvm.ptr -> i16
        %3 = llvm.sext %2 : i16 to i64
        call @__toy_print_i64(%3) : (i64) -> ()
        return
      }
      func.func @main() -> i32 {
        %0 = llvm.mlir.constant(0 : i32) : i32
        %1 = llvm.mlir.addressof @str_0 : !llvm.ptr
        %2 = llvm.mlir.constant(7 : i64) : i64
        call @__toy_print_string(%2, %1) : (i64, !llvm.ptr) -> ()
        %3 = llvm.mlir.constant(3 : i64) : i64
        %4 = llvm.mlir.constant(3 : i16) : i16
        call @bar(%4) : (i16) -> ()
        %5 = llvm.mlir.addressof @str_1 : !llvm.ptr
        %6 = llvm.mlir.constant(12 : i64) : i64
        call @__toy_print_string(%6, %5) : (i64, !llvm.ptr) -> ()
        return %0 : i32
      }
      

      There are some dead code constants left there (%3), seeming due to type conversion, but they get stripped out nicely by the time we get to LLVM-IR:

      @str_1 = private constant [12 x i8] c"Back in main"
      @str_0 = private constant [7 x i8] c"In main"
      
      declare void @__toy_print_string(i64, ptr)
      
      declare void @__toy_print_i64(i64)
      
      define void @bar(i16 %0) {
        %2 = alloca i16, i64 1, align 2
        store i16 %0, ptr %2, align 2
        %3 = load i16, ptr %2, align 2
        %4 = sext i16 %3 to i64
        call void @__toy_print_i64(i64 %4)
        ret void
      }
      
      define i32 @main() {
        call void @__toy_print_string(i64 7, ptr @str_0)
        call void @bar(i16 3)
        call void @__toy_print_string(i64 12, ptr @str_1)
        ret i32 0
      }
    2. Generalize NegOp lowering to support all types, not just f64.
    3. Allow PRINT of string literals, avoiding requirement for variables. Example:

          %0 = "toy.string_literal"() <{value = "A string literal!"}> : () -> !llvm.ptr loc(#loc)
          "toy.print"(%0) : (!llvm.ptr) -> () loc(#loc)

       

The next obvious thing to do for the language/compiler would be to implement conditionals (IF/ELIF/ELSE) and loops. I think that there are MLIR dialects to facilitate both (like the affine dialect for loops.)

However, having now finished this function support feature (which I’ve been working on for quite a while), I’m going to take a break from this project. Even though I’ve only been working on this toy compiler project in my spare time, it periodically invades my thoughts. With all that I have to learn for my new job, I’d rather have one less extra thing to think about, so that I don’t feel pulled in too many directions at once.

Tagged V3 of my toy compiler (playing with the MLIR -> LLVM-IR toolchain)

June 3, 2025 clang/llvm , , , , , , , , , , , , , ,

Screenshot

 

I’ve added a number of elements to the language and compiler:

  • comparison operators (<, <=, EQ, NE) yielding BOOL values.  These work for any combinations of floating and integer types (including BOOL.)

  • integer bitwise operators (OR, AND, XOR).  These only for for integer types (including BOOL.)

  • a NOT operator, yielding BOOL.

  • Array + string declaration and lowering support, including debug instrumentation, and print support for string variables.

This version also fixes a few specific issues:

  • Fixed -g/-OX propagation to lowering.  If -g not specified, now don’t generate the DI.
  • Show the optimized .ll with –emit-llvm instead of the just-lowered .ll (unless not invoking the assembly printer, where the ll optimization passes are registered.)
  • Reorganize the grammar so that all the simple lexer tokens are last.  Rename a bunch of the tokens, introducing some consistency.
  • calculator.td: introduce IntOrFloat constraint type, replacing AnyType usage; array decl support, and string support.
  • driver: writeLL helper function, pass -g to lowering if set.
  • parser: handle large integer constants properly, array decl support, and string support.
  • simplest.cpp: This MWE is updated to include a global variable and global variable access.
  • parser: implicit exit: use the last saved location, instead of the module start.  This means the line numbers don’t jump around at the very end of the program anymore (i.e.: implicit return/exit)

I started with the comparison operators, thinking that I’d add if statement support, and loops, but got sidetracked.  In particular, I generated a number of really large test programs, and without some way to print a string message, it was hard to figure out where an error was occuring.  This led to implementing PRINT string-variable support as an interesting feature first.

As a side effect of adding STRING support, I’ve also got declaration support for arbitrary fixed size arrays for any type.  I haven’t implemented array access yet, but that probably won’t be too hard.

Here’s an example of a program that uses STRING variables:

STRING t[2];
STRING u[3];
STRING s[2];
INT8 s2;
s = "hi";
PRINT s;
t = "hi";
PRINT t;
u = "hi";
PRINT u;
u = "bye";
PRINT u;

This is the MLIR for the program:

module {
  toy.program {
    "toy.declare"() <{name = "t", size = 2 : i64, type = i8}> : () -> ()
    "toy.declare"() <{name = "u", size = 3 : i64, type = i8}> : () -> ()
    "toy.declare"() <{name = "s", size = 2 : i64, type = i8}> : () -> ()
    "toy.declare"() <{name = "s2", type = i8}> : () -> ()
    toy.string_assign "s" = "hi"
    %0 = toy.load "s" : !llvm.ptr
    toy.print %0 : !llvm.ptr
    toy.string_assign "t" = "hi"
    %1 = toy.load "t" : !llvm.ptr
    toy.print %1 : !llvm.ptr
    toy.string_assign "u" = "hi"
    %2 = toy.load "u" : !llvm.ptr
    toy.print %2 : !llvm.ptr
    toy.string_assign "u" = "bye"
    %3 = toy.load "u" : !llvm.ptr
    toy.print %3 : !llvm.ptr
    toy.exit
  }
}

It’s a bit clunky, because I cheated and didn’t try to implement PRINT support of string literals directly. I thought that since I had variable support already (which emits llvm.alloca), I could change that alloca trivially to an array from a scalar value.

I think that this did turn out to be a relatively easy way to do it, but this little item did take much more effort than I expected.

The DeclareOp builder is fairly straightforward:

builder.create<toy::DeclareOp>( loc, builder.getStringAttr( varName ), mlir::TypeAttr::get( ty ), nullptr ); // for scalars
...
auto sizeAttr = builder.getI64IntegerAttr( arraySize );
builder.create<toy::DeclareOp>( loc, builder.getStringAttr( varName ), mlir::TypeAttr::get( ty ), sizeAttr ); // for arrays

This matches the Optional size now added to DeclareOp for arrays:

def Toy_DeclareOp : Op<Toy_Dialect, "declare"> {
  let summary = "Declare a variable or array, specifying its name, type (integer or float), and optional size.";
  let arguments = (ins StrAttr:$name, TypeAttr:$type, OptionalAttr:$size);
  let results = (outs);

There’s a new AssignStringOp complementing AssignOp:

def Toy_AssignOp : Op<Toy_Dialect, "assign"> {
  let summary = "Assign a (non-string) value to a variable associated with a declaration";
  let arguments = (ins StrAttr:$name, AnyType:$value);
  let results = (outs);

  // toy.assign "x", %0 : i32
  let assemblyFormat = "$name `,` $value `:` type($value) attr-dict";
}

def Toy_AssignStringOp : Op<Toy_Dialect, "string_assign"> {
  let summary = "Assign a string literal to an i8 array variable";
  let arguments = (ins StrAttr:$name, Builtin_StringAttr:$value);
  let results = (outs);
  let assemblyFormat = "$name `=` $value attr-dict";
}

I also feel this is a cludge. I probably really want a string literal type like flang’s. Here’s a fortran hello world:

program hello
  print *, "Hello, world!"
end program hello

and selected parts of the flang fir dialect MLIR for it:

    %4 = fir.declare %3 typeparams %c13 {fortran_attrs = #fir.var_attrs, uniq_name = "_QQclX48656C6C6F2C20776F726C6421"} : (!fir.ref<!fir.char<1,13>>, index) -> !fir.ref<!fir.char<1,13>>
    %5 = fir.convert %4 : (!fir.ref<!fir.char<1,13>>) -> !fir.ref
    %6 = fir.convert %c13 : (index) -> i64
    %7 = fir.call @_FortranAioOutputAscii(%2, %5, %6) fastmath : (!fir.ref, !fir.ref, i64) -> i1
  ...

  fir.global linkonce @_QQclX48656C6C6F2C20776F726C6421 constant : !fir.char<1,13> {
    %0 = fir.string_lit "Hello, world!"(13) : !fir.char<1,13>
    fir.has_value %0 : !fir.char<1,13>
  }

I had to specialize the LoadOp builder too so that it didn’t create a scalar load. That code looks like:

mlir::Type varType;
mlir::Type elemType = declareOp.getTypeAttr().getValue();
        
if ( declareOp.getSizeAttr() )    // Check if size attribute exists
{       
    // Array: load a generic pointer 
    varType = mlir::LLVM::LLVMPointerType::get( builder.getContext(), /*addressSpace=*/0 );
}       
else    
{       
    // Scalar: load the value
    varType = elemType;
}       
        
auto value = builder.create<toy::LoadOp>( loc, varType, builder.getStringAttr( varName ) );

Lowering didn’t require too much. I needed a print function object:

auto ptrType = LLVM::LLVMPointerType::get( ctx );
auto printFuncStringType = LLVM::LLVMFunctionType::get( LLVM::LLVMVoidType::get( ctx ),
                                                        { pr_builder.getI64Type(), ptrType }, false );
pr_printFuncString = pr_builder.create<LLVM::LLVMFuncOp>( pr_module.getLoc(), "__toy_print_string",
                                                          printFuncStringType, LLVM::Linkage::External );

With LoadOp now possibly having pointer valued return

if ( loadOp.getResult().getType().isa<mlir::LLVM::LLVMPointerType>() )
{           
    // Return the allocated pointer
    LLVM_DEBUG( llvm::dbgs() << "Loading array address: " << allocaOp.getResult() << '\n' );
    rewriter.replaceOp( op, allocaOp.getResult() );
}           
else        
{           
    // Scalar load
    auto load = rewriter.create<LLVM::LoadOp>( loc, elemType, allocaOp );
    LLVM_DEBUG( llvm::dbgs() << "new load op: " << load << '\n' );
    rewriter.replaceOp( op, load.getResult() );
}           

assign-string lowering basically just generates a memcpy from a global:

Type elemType = allocaOp.getElemType();
int64_t numElems = 0;
if ( auto constOp = allocaOp.getArraySize().getDefiningOp<LLVM::ConstantOp>() )
{           
    auto intAttr = mlir::dyn_cast<IntegerAttr>( constOp.getValue() );
    numElems = intAttr.getInt();
}           
LLVM_DEBUG( llvm::dbgs() << "numElems: " << numElems << '\n' );
LLVM_DEBUG( llvm::dbgs() << "elemType: " << elemType << '\n' );

if ( !mlir::isa<mlir::IntegerType>( elemType ) || elemType.getIntOrFloatBitWidth() != 8 )
{           
    return rewriter.notifyMatchFailure( assignOp, "string assignment requires i8 array" );
}           
if ( numElems == 0 )
{           
    return rewriter.notifyMatchFailure( assignOp, "invalid array size" );
}           

size_t strLen = value.size();
size_t copySize = std::min( strLen + 1, static_cast<size_t>( numElems ) );
if ( strLen > static_cast<size_t>( numElems ) )
{           
    return rewriter.notifyMatchFailure( assignOp, "string too large for array" );
}           

mlir::LLVM::GlobalOp globalOp = lState.lookupOrInsertGlobalOp( rewriter, value, loc, copySize, strLen );

auto globalPtr = rewriter.create<LLVM::AddressOfOp>( loc, globalOp ); 

auto destPtr = allocaOp.getResult();

auto sizeConst = 
    rewriter.create<LLVM::ConstantOp>( loc, rewriter.getI64Type(), rewriter.getI64IntegerAttr( copySize ) );

rewriter.create<LLVM::MemcpyOp>( loc, destPtr, globalPtr, sizeConst, rewriter.getBoolAttr( false ) );

rewriter.eraseOp( op );

I used global’s like what we’d find in clang LLVM-IR. For example, here’s a C hello world:

#include <string.h>

int main()
{
    const char* s = "hi there";
    char buf[100];
    memcpy( buf, s, strlen( s ) + 1 );

    return strlen( buf );
}

where our LLVM-IR looks like:

@.str = private unnamed_addr constant [9 x i8] c"hi there\00", align 1

; Function Attrs: noinline nounwind optnone uwtable
define dso_local i32 @main() #0 {
  %1 = alloca i32, align 4
  %2 = alloca ptr, align 8
  %3 = alloca [100 x i8], align 16
  store i32 0, ptr %1, align 4
  store ptr @.str, ptr %2, align 8
  %4 = getelementptr inbounds [100 x i8], ptr %3, i64 0, i64 0
  %5 = load ptr, ptr %2, align 8
  %6 = load ptr, ptr %2, align 8
  %7 = call i64 @strlen(ptr noundef %6) #3
  %8 = add i64 %7, 1
  call void @llvm.memcpy.p0.p0.i64(ptr align 16 %4, ptr align 1 %5, i64 %8, i1 false)
  %9 = getelementptr inbounds [100 x i8], ptr %3, i64 0, i64 0
  %10 = call i64 @strlen(ptr noundef %9) #3
  %11 = trunc i64 %10 to i32
  ret i32 %11
}

My lowered LLVM-IR for the program is similar:

@str_1 = private constant [3 x i8] c"bye"
@str_0 = private constant [2 x i8] c"hi"

declare void @__toy_print_f64(double)

declare void @__toy_print_i64(i64)

declare void @__toy_print_string(i64, ptr)

define i32 @main() {
  %1 = alloca i8, i64 2, align 1
  %2 = alloca i8, i64 3, align 1
  %3 = alloca i8, i64 2, align 1
  %4 = alloca i8, i64 1, align 1
  call void @llvm.memcpy.p0.p0.i64(ptr align 1 %3, ptr align 1 @str_0, i64 2, i1 false)
  call void @__toy_print_string(i64 2, ptr %3)
  call void @llvm.memcpy.p0.p0.i64(ptr align 1 %1, ptr align 1 @str_0, i64 2, i1 false)
  call void @__toy_print_string(i64 2, ptr %1)
  call void @llvm.memcpy.p0.p0.i64(ptr align 1 %2, ptr align 1 @str_0, i64 3, i1 false)
  call void @__toy_print_string(i64 3, ptr %2)
  call void @llvm.memcpy.p0.p0.i64(ptr align 1 %2, ptr align 1 @str_1, i64 3, i1 false)
  call void @__toy_print_string(i64 3, ptr %2)
  ret i32 0
}
...

I managed my string literals with a simple hash, avoiding replication if repeated:

mlir::LLVM::GlobalOp lookupOrInsertGlobalOp( ConversionPatternRewriter& rewriter, mlir::StringAttr& stringLit,
                                             mlir::Location loc, size_t copySize, size_t strLen )
{       
    mlir::LLVM::GlobalOp globalOp;
    auto it = pr_stringLiterals.find( stringLit.str() );
    if ( it != pr_stringLiterals.end() )
    {       
        globalOp = it->second;
        LLVM_DEBUG( llvm::dbgs() << "Reusing global: " << globalOp.getSymName() << '\n' ); 
    }       
    else    
    {       
        auto savedIP = rewriter.saveInsertionPoint();
        rewriter.setInsertionPointToStart( pr_module.getBody() );

        auto i8Type = rewriter.getI8Type();
        auto arrayType = mlir::LLVM::LLVMArrayType::get( i8Type, copySize );

        SmallVector<char> stringData( stringLit.begin(), stringLit.end() );
        if ( copySize > strLen )
        {       
            stringData.push_back( '\0' ); 
        }       
        auto denseAttr = DenseElementsAttr::get( RankedTensorType::get( { static_cast<int64_t>( copySize ) }, i8Type ),
                                                 ArrayRef<char>( stringData ) );

        std::string globalName = "str_" + std::to_string( pr_stringLiterals.size() );
        globalOp =
            rewriter.create<LLVM::GlobalOp>( loc, arrayType, true, LLVM::Linkage::Private, globalName, denseAttr );
        globalOp->setAttr( "unnamed_addr", rewriter.getUnitAttr() );

        pr_stringLiterals[stringLit.str()] = globalOp;
        LLVM_DEBUG( llvm::dbgs() << "Created global: " << globalName << '\n' );

        rewriter.restoreInsertionPoint( savedIP );
    }           

    return globalOp;
}         

Without the insertion point swaperoo, this GlobalOp creation doesn’t work, as we need to be in the ModuleOp level where the symbol table lives.

 

… anyways, it looks like I’m droning on.  There’s been lots of stuff to get this far, but there are still many many things to do before what I’ve got even qualifies as a basic programming language (if statements, loops, functions, array assignments, types, …)

Have added boolean operations to my toy MLIR compiler

May 30, 2025 C/C++ development and debugging. , , , ,

Screenshot

The git repo for the project now has a way to encode predicates, which I figured was a good first step towards adding some useful control flow (IF+LOOPS).  Specifically, the toy language/compiler now supports the following operators:

  • <
  • <=
  • >
  • >=
  • EQ
  • NE

This list works for any floating point or integer type (including BOOL, which is like “INT1”).  I also added AND,OR,XOR (for integer types, including BOOL.)  The grammar has a NOT operator, but it’s not implemented in the parser yet.

Here’s a sample program:

BOOL b;
BOOL i1;
INT16 l16;
i1 = TRUE;
l16 = -100;
b = i1 < l16;
PRINT b;
b = i1 > l16;
PRINT b;

My MLIR is:

module {
  toy.program {
    toy.declare "b" : i1
    toy.declare "i1" : i1
    toy.declare "l16" : i16
    %true = arith.constant true
    toy.assign "i1", %true : i1
    %c-100_i64 = arith.constant -100 : i64
    toy.assign "l16", %c-100_i64 : i64
    %0 = toy.load "i1" : i1
    %1 = toy.load "l16" : i16
    %2 = "toy.less"(%0, %1) : (i1, i16) -> i1
    toy.assign "b", %2 : i1
    %3 = toy.load "b" : i1
    toy.print %3 : i1
    %4 = toy.load "i1" : i1
    %5 = toy.load "l16" : i16
    %6 = "toy.less"(%5, %4) : (i16, i1) -> i1
    toy.assign "b", %6 : i1
    %7 = toy.load "b" : i1
    toy.print %7 : i1
    toy.exit
  }
}

Here’s the LLVM-IR after lowering:

declare void @__toy_print_f64(double)

declare void @__toy_print_i64(i64)

define i32 @main() !dbg !4 {
  %1 = alloca i1, i64 1, align 1, !dbg !8
    #dbg_declare(ptr %1, !9, !DIExpression(), !8)
  %2 = alloca i1, i64 1, align 1, !dbg !11
    #dbg_declare(ptr %2, !12, !DIExpression(), !11)
  %3 = alloca i16, i64 1, align 2, !dbg !13
    #dbg_declare(ptr %3, !14, !DIExpression(), !13)
  store i1 true, ptr %2, align 1, !dbg !16
  store i16 -100, ptr %3, align 2, !dbg !17
  %4 = load i1, ptr %2, align 1, !dbg !18
  %5 = load i16, ptr %3, align 2, !dbg !18
  %6 = zext i1 %4 to i16, !dbg !18
  %7 = icmp slt i16 %6, %5, !dbg !18
  store i1 %7, ptr %1, align 1, !dbg !18
  %8 = load i1, ptr %1, align 1, !dbg !19
  %9 = zext i1 %8 to i64, !dbg !19
  call void @__toy_print_i64(i64 %9), !dbg !19
  %10 = load i1, ptr %2, align 1, !dbg !20
  %11 = load i16, ptr %3, align 2, !dbg !20
  %12 = zext i1 %10 to i16, !dbg !20
  %13 = icmp slt i16 %11, %12, !dbg !20
  store i1 %13, ptr %1, align 1, !dbg !20
  %14 = load i1, ptr %1, align 1, !dbg !21
  %15 = zext i1 %14 to i64, !dbg !21
  call void @__toy_print_i64(i64 %15), !dbg !21
  ret i32 0, !dbg !8
}

I’m going to want to try to refactor the type conversion logic, as what I have now in lowering is pretty clunky.